Files
telemt/src/config/load/runtime_web.rs
T

488 lines
16 KiB
Rust

use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
#[cfg(unix)]
use std::ffi::OsString;
#[cfg(unix)]
use std::os::unix::ffi::OsStringExt;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
#[cfg(unix)]
use nix::dir::Dir;
#[cfg(unix)]
use nix::fcntl::{OFlag, openat};
#[cfg(unix)]
use nix::sys::stat::Mode;
use bytes::Bytes;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use super::*;
#[cfg(unix)]
use crate::util::secure_fs::open_dir_nofollow;
// Path-based static snapshot fallback for platforms without directory descriptors.
#[cfg(not(unix))]
mod static_site_fallback;
const WEB_CAPABILITY_CONTEXT_V1: &[u8] = b"tdesktop-web-proxy-bridge-v1\n";
const WEB_CAPABILITY_CONTEXT_V2: &[u8] = b"tdesktop-web-proxy-bridge-v2\n";
const WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0";
const MAX_WEB_STATIC_DEPTH: usize = 64;
/// Builds the immutable WEB routing and decoy snapshot for one generation.
pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
let auth = config.runtime_user_auth().ok_or_else(|| {
ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string())
})?;
let mut runtime_vhosts = BTreeMap::new();
let mut runtime_profiles = Vec::new();
let mut runtime_capabilities = Vec::new();
let mut static_files = 0usize;
let mut static_bytes = 0usize;
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let decoy = build_decoy(
config,
vhost_idx,
vhost,
&config.web.limits,
&mut static_files,
&mut static_bytes,
)?;
let mut profiles = Vec::with_capacity(vhost.profiles.len());
let mut capability_table = Vec::with_capacity(vhost.profiles.len());
let mut capabilities = HashSet::with_capacity(vhost.profiles.len());
for profile in &vhost.profiles {
let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| {
ProxyError::Config(format!(
"WEB profile references unknown access user `{}`",
profile.user
))
})?;
let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| {
ProxyError::Config("WEB profile user snapshot is inconsistent".to_string())
})?;
let (client_secret, client_secret_len) =
client_secret(auth_entry.secret, profile.secret_mode);
let capability = derive_web_capability(
&client_secret[..client_secret_len],
vhost.host.as_bytes(),
vhost.base_path.as_bytes(),
)?;
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
if !capabilities.insert(capability) {
return Err(ProxyError::Config(format!(
"WEB vhost `{}` contains profiles with the same client capability",
vhost.host
)));
}
let runtime_profile = Arc::new(WebRuntimeProfile {
host: vhost.host.clone(),
public_addr: vhost.public_addr,
user: profile.user.clone(),
credential_id: auth_entry.credential_id,
secret_mode: profile.secret_mode,
carrier: config.web.carrier,
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
carrier_learning: config.web.carrier_negotiation_enabled()
&& config.web.carrier_learning,
carriers: Arc::clone(&carrier_candidates),
carrier_negotiation_deadlines_secs: config
.web
.timeouts
.carrier_negotiation_deadlines_secs,
capability,
key_fingerprint,
max_sessions: profile
.max_sessions
.unwrap_or(config.web.limits.max_sessions_global),
max_streams: profile
.max_streams
.unwrap_or(config.web.limits.max_streams_global),
max_streams_per_session: profile
.max_streams_per_session
.unwrap_or(config.web.limits.max_streams_per_session),
});
capability_table.push(capability);
runtime_capabilities.push(capability);
profiles.push(Arc::clone(&runtime_profile));
runtime_profiles.push(runtime_profile);
}
runtime_vhosts.insert(
vhost.host.clone(),
Arc::new(WebRuntimeVhost {
host: vhost.host.clone(),
base: if vhost.base_path.is_empty() {
"/".to_string()
} else {
format!("/{}/", vhost.base_path)
},
decoy_fasttrack_mode: config.web.decoy_fasttrack_mode,
decoy,
decoy_header_secs: config.web.timeouts.decoy_header_secs,
profiles,
capabilities: capability_table.into_boxed_slice(),
}),
);
}
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
vhosts: runtime_vhosts,
profiles: runtime_profiles,
capabilities: runtime_capabilities.into_boxed_slice(),
}));
Ok(())
}
fn debug_key_fingerprint(secret: &[u8]) -> String {
let mut digest = Sha256::new();
digest.update(WEB_DEBUG_FINGERPRINT_CONTEXT);
digest.update(secret);
hex::encode(&digest.finalize()[..8])
}
/// Derives the Telegram Desktop WEB capability for one exact secret, host, and base path.
pub(crate) fn derive_web_capability(
secret: &[u8],
host: &[u8],
base_path: &[u8],
) -> Result<[u8; 32]> {
let mut mac = Hmac::<Sha256>::new_from_slice(secret)
.map_err(|_| ProxyError::Config("WEB capability secret must not be empty".to_string()))?;
if base_path.is_empty() {
mac.update(WEB_CAPABILITY_CONTEXT_V1);
mac.update(host);
} else {
mac.update(WEB_CAPABILITY_CONTEXT_V2);
mac.update(host);
mac.update(b"\n");
mac.update(base_path);
}
Ok(mac.finalize().into_bytes().into())
}
fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) {
let mut client_secret = [0u8; 17];
match mode {
WebSecretMode::Plain => {
client_secret[..16].copy_from_slice(&secret);
(client_secret, 16)
}
WebSecretMode::Dd => {
client_secret[0] = 0xdd;
client_secret[1..].copy_from_slice(&secret);
(client_secret, 17)
}
}
}
fn build_decoy(
config: &ProxyConfig,
vhost_idx: usize,
vhost: &WebVhostConfig,
limits: &WebLimitsConfig,
static_files: &mut usize,
static_bytes: &mut usize,
) -> Result<WebRuntimeDecoy> {
match &vhost.decoy {
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
decoy_dns::build_upstream(config, vhost_idx, upstream, *resolve)
}
WebDecoyConfig::StaticDirectory { directory, index } => {
let site = load_static_site(directory, index, limits, static_files, static_bytes)?;
Ok(WebRuntimeDecoy::StaticDirectory(Arc::new(site)))
}
}
}
fn load_static_site(
root: &Path,
index: &str,
limits: &WebLimitsConfig,
total_files: &mut usize,
total_bytes: &mut usize,
) -> Result<WebStaticSite> {
let mut assets = BTreeMap::new();
#[cfg(unix)]
{
let directory = open_static_root(root)?;
load_static_directory(
directory,
Path::new(""),
root,
&mut assets,
total_files,
total_bytes,
limits,
0,
)?;
}
#[cfg(not(unix))]
{
static_site_fallback::load_static_site_by_path(
root,
limits,
&mut assets,
total_files,
total_bytes,
)?;
}
if !assets.contains_key(&format!("/{index}")) {
return Err(ProxyError::Config(format!(
"WEB static directory `{}` does not contain index `{index}`",
root.display()
)));
}
Ok(WebStaticSite {
assets,
index: index.to_string(),
})
}
#[cfg(unix)]
fn open_static_root(root: &Path) -> Result<Dir> {
let descriptor = open_dir_nofollow(root).map_err(|error| {
ProxyError::Config(format!(
"WEB static directory `{}` must be a real directory, not a symlink: {error}",
root.display()
))
})?;
Dir::from_fd(descriptor).map_err(|error| {
ProxyError::Config(format!(
"failed to read WEB static directory `{}`: {error}",
root.display()
))
})
}
#[cfg(unix)]
fn load_static_directory(
mut directory: Dir,
relative: &Path,
root: &Path,
assets: &mut BTreeMap<String, WebStaticAsset>,
total_files: &mut usize,
total_bytes: &mut usize,
limits: &WebLimitsConfig,
depth: usize,
) -> Result<()> {
let mut entries = Vec::new();
for entry in directory.iter() {
let entry = entry.map_err(|error| {
ProxyError::Config(format!(
"failed to read WEB static directory `{}`: {error}",
root.join(relative).display()
))
})?;
let name = entry.file_name().to_bytes();
if name == b"." || name == b".." {
continue;
}
if *total_files >= limits.max_static_files {
return Err(ProxyError::Config(
"WEB static entries exceed process-wide web.limits.max_static_files".to_string(),
));
}
*total_files += 1;
entries.push(OsString::from_vec(name.to_vec()));
}
entries.sort_unstable();
for name in entries {
let relative_path = relative.join(&name);
let display_path = root.join(&relative_path);
let descriptor = openat(
&directory,
name.as_os_str(),
OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
Mode::empty(),
)
.map_err(|error| {
ProxyError::Config(format!(
"failed to open WEB static entry `{}` without following symlinks: {error}",
display_path.display()
))
})?;
let file = fs::File::from(descriptor);
let metadata = file.metadata().map_err(|error| {
ProxyError::Config(format!(
"failed to inspect WEB static entry `{}`: {error}",
display_path.display()
))
})?;
if metadata.is_dir() {
if depth >= MAX_WEB_STATIC_DEPTH {
return Err(ProxyError::Config(format!(
"WEB static directory `{}` exceeds the maximum nesting depth",
display_path.display()
)));
}
let descriptor = file.into();
let child = Dir::from_fd(descriptor).map_err(|error| {
ProxyError::Config(format!(
"failed to open WEB static directory `{}`: {error}",
display_path.display()
))
})?;
load_static_directory(
child,
&relative_path,
root,
assets,
total_files,
total_bytes,
limits,
depth + 1,
)?;
continue;
}
if !metadata.is_file() {
return Err(ProxyError::Config(format!(
"WEB static entry `{}` must be a regular file",
display_path.display()
)));
}
load_static_file(
file,
&metadata,
&relative_path,
&display_path,
assets,
total_bytes,
limits,
)?;
}
Ok(())
}
fn load_static_file(
mut file: fs::File,
metadata: &fs::Metadata,
relative: &Path,
display_path: &Path,
assets: &mut BTreeMap<String, WebStaticAsset>,
total_bytes: &mut usize,
limits: &WebLimitsConfig,
) -> Result<()> {
let file_len = usize::try_from(metadata.len()).map_err(|_| {
ProxyError::Config(format!(
"WEB static file `{}` is too large",
display_path.display()
))
})?;
if file_len > limits.max_static_file_bytes {
return Err(ProxyError::Config(format!(
"WEB static file `{}` exceeds web.limits.max_static_file_bytes",
display_path.display()
)));
}
*total_bytes = total_bytes.checked_add(file_len).ok_or_else(|| {
ProxyError::Config("WEB static snapshot byte count overflowed usize".to_string())
})?;
if *total_bytes > limits.max_static_bytes {
return Err(ProxyError::Config(
"WEB static snapshots exceed process-wide web.limits.max_static_bytes".to_string(),
));
}
let route = static_route(relative)?;
let mut body = Vec::with_capacity(file_len);
file.by_ref()
.take(limits.max_static_file_bytes as u64 + 1)
.read_to_end(&mut body)
.map_err(|error| {
ProxyError::Config(format!(
"failed to read WEB static file `{}`: {error}",
display_path.display()
))
})?;
let final_metadata = file.metadata().map_err(|error| {
ProxyError::Config(format!(
"failed to recheck WEB static file `{}`: {error}",
display_path.display()
))
})?;
if body.len() != file_len || !static_file_version_matches(metadata, &final_metadata) {
return Err(ProxyError::Config(format!(
"WEB static file `{}` changed while its snapshot was built",
display_path.display()
)));
}
let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body)));
assets.insert(
route,
WebStaticAsset {
body: Bytes::from(body),
content_type: static_content_type(relative),
etag,
},
);
Ok(())
}
#[cfg(unix)]
fn static_file_version_matches(before: &fs::Metadata, after: &fs::Metadata) -> bool {
before.dev() == after.dev()
&& before.ino() == after.ino()
&& before.len() == after.len()
&& before.mtime() == after.mtime()
&& before.mtime_nsec() == after.mtime_nsec()
&& before.ctime() == after.ctime()
&& before.ctime_nsec() == after.ctime_nsec()
}
#[cfg(not(unix))]
fn static_file_version_matches(before: &fs::Metadata, after: &fs::Metadata) -> bool {
before.len() == after.len()
&& before.modified().ok() == after.modified().ok()
&& before.created().ok() == after.created().ok()
}
fn static_route(relative: &Path) -> Result<String> {
let mut route = String::new();
for component in relative.components() {
let std::path::Component::Normal(component) = component else {
return Err(ProxyError::Config(
"WEB static path contains an unsafe component".to_string(),
));
};
let component = component.to_str().ok_or_else(|| {
ProxyError::Config("WEB static file names must be valid UTF-8".to_string())
})?;
route.push('/');
route.push_str(component);
}
Ok(route)
}
fn static_content_type(path: &Path) -> &'static str {
match path.extension().and_then(|extension| extension.to_str()) {
Some("html") | Some("htm") => "text/html; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("json") => "application/json",
Some("txt") => "text/plain; charset=utf-8",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("woff") => "font/woff",
Some("woff2") => "font/woff2",
Some("wasm") => "application/wasm",
_ => "application/octet-stream",
}
}
// Runtime WEB construction tests remain separate from the production loader.
#[cfg(test)]
#[path = "runtime_web/tests.rs"]
mod tests;