Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
Co-Authored-By: John Preston <17900494+john-preston@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-23 03:12:11 +03:00
parent 8dbd24b11b
commit 1029703c2c
58 changed files with 7460 additions and 2646 deletions
+6
View File
@@ -337,9 +337,15 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
let process_limits = cfg.web.limits.clone();
cfg.web = new.web.clone();
cfg.web.limits = process_limits;
if cfg.rebuild_runtime_user_auth().is_err() {
cfg.runtime_user_auth = None;
}
if cfg.rebuild_runtime_web().is_err() {
cfg.web = old.web.clone();
}
cfg
}
+3
View File
@@ -123,6 +123,7 @@ fn listener_synlimit_fields_are_process_owned() {
let mut old = sample_config();
old.server.listeners.push(ListenerConfig {
ip: "0.0.0.0".parse().unwrap(),
transport: crate::config::ListenerTransport::Mtproxy,
port: Some(443),
client_mss: None,
synlimit: SynLimitMode::Iptables,
@@ -138,6 +139,8 @@ fn listener_synlimit_fields_are_process_owned() {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: crate::config::WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
let mut new = old.clone();
new.server.port = 8443;
+12
View File
@@ -22,6 +22,8 @@ mod includes;
mod strict_keys;
// Precomputed user authentication data for handshake hot paths.
mod runtime_auth;
// Validated immutable WEB configuration and static-site snapshots.
mod runtime_web;
// Post-deserialization validation helpers.
mod decode;
mod effective;
@@ -30,6 +32,7 @@ mod validate_core;
mod validate_me;
mod validate_runtime;
mod validate_server;
mod validate_web;
mod validation;
use self::includes::{hash_rendered_snapshot, normalize_config_path, preprocess_includes};
@@ -96,6 +99,10 @@ pub struct ProxyConfig {
#[serde(default)]
pub server: ServerConfig,
/// WEB carrier ingress and public-site fallback configuration.
#[serde(default)]
pub web: WebConfig,
/// Timeout values used by client, fallback, and upstream operations.
#[serde(default)]
pub timeouts: TimeoutsConfig,
@@ -204,6 +211,11 @@ impl ProxyConfig {
Ok(())
}
/// Rebuilds validated WEB capabilities and immutable decoy snapshots.
pub(crate) fn rebuild_runtime_web(&mut self) -> Result<()> {
runtime_web::rebuild(self)
}
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
self.runtime_user_auth.as_deref()
}
+7
View File
@@ -120,6 +120,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
if let Ok(ipv4) = ipv4_str.parse::<IpAddr>() {
config.server.listeners.push(ListenerConfig {
ip: ipv4,
transport: ListenerTransport::Mtproxy,
port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
@@ -135,6 +136,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
}
if let Some(ipv6_str) = &config.server.listen_addr_ipv6
@@ -142,6 +145,7 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
{
config.server.listeners.push(ListenerConfig {
ip: ipv6,
transport: ListenerTransport::Mtproxy,
port: Some(config.server.port),
client_mss: None,
synlimit: SynLimitMode::default(),
@@ -157,6 +161,8 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
announce_ip: None,
proxy_protocol: None,
reuse_allow: false,
web_client_ip_source: WebClientIpSource::XForwardedFor,
web_trusted_proxy_cidrs: Vec::new(),
});
}
}
@@ -211,5 +217,6 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
validate_logging_config(&config.logging)?;
validate_upstreams(config)?;
config.rebuild_runtime_user_auth()?;
config.rebuild_runtime_web()?;
Ok(())
}
+1
View File
@@ -7,6 +7,7 @@ pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig
validate_runtime::validate(&mut config)?;
validate_me::validate(&mut config)?;
validate_server::validate(&mut config)?;
validate_web::validate(&mut config)?;
effective::apply(&mut config)?;
Ok(LoadedConfig {
config,
+414
View File
@@ -0,0 +1,414 @@
use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::io::Read;
use std::path::Path;
use std::sync::Arc;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
use bytes::Bytes;
use hmac::{Hmac, Mac};
use sha2::{Digest, Sha256};
use super::*;
const WEB_CAPABILITY_CONTEXT: &[u8] = b"tdesktop-web-proxy-bridge-v1\n";
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 static_files = 0usize;
let mut static_bytes = 0usize;
for vhost in &config.web.vhosts {
let decoy = build_decoy(
vhost,
&config.web.limits,
&mut static_files,
&mut static_bytes,
)?;
let mut profiles = 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(),
)?;
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(),
secret_mode: profile.secret_mode,
capability,
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),
});
profiles.push(Arc::clone(&runtime_profile));
runtime_profiles.push(runtime_profile);
}
runtime_vhosts.insert(
vhost.host.clone(),
Arc::new(WebRuntimeVhost {
host: vhost.host.clone(),
decoy,
decoy_header_secs: config.web.timeouts.decoy_header_secs,
profiles,
}),
);
}
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
vhosts: runtime_vhosts,
profiles: runtime_profiles,
}));
Ok(())
}
/// Derives the Telegram Desktop WEB capability for one exact secret and host.
pub(crate) fn derive_web_capability(secret: &[u8], host: &[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())
})?;
mac.update(WEB_CAPABILITY_CONTEXT);
mac.update(host);
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(
vhost: &WebVhostConfig,
limits: &WebLimitsConfig,
static_files: &mut usize,
static_bytes: &mut usize,
) -> Result<WebRuntimeDecoy> {
match &vhost.decoy {
WebDecoyConfig::HttpUpstream { upstream } => {
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"WEB decoy upstream for `{}` is invalid: {error}",
vhost.host
))
})?;
let ip = match parsed.host() {
Some(url::Host::Ipv4(ip)) => std::net::IpAddr::V4(ip),
Some(url::Host::Ipv6(ip)) => std::net::IpAddr::V6(ip),
_ => {
return Err(ProxyError::Config(
"WEB decoy host must be an IP literal".to_string(),
));
}
};
let host = ip.to_string();
let port = parsed.port_or_known_default().ok_or_else(|| {
ProxyError::Config("WEB decoy port cannot be resolved".to_string())
})?;
let authority = match (ip, parsed.port()) {
(std::net::IpAddr::V6(_), Some(_)) => format!("[{host}]:{port}"),
(std::net::IpAddr::V6(_), None) => format!("[{host}]"),
(std::net::IpAddr::V4(_), Some(_)) => format!("{host}:{port}"),
(std::net::IpAddr::V4(_), None) => host.clone(),
};
Ok(WebRuntimeDecoy::HttpUpstream {
addr: SocketAddr::new(ip, port),
authority,
})
}
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 root_metadata = fs::symlink_metadata(root).map_err(|error| {
ProxyError::Config(format!(
"failed to inspect WEB static directory `{}`: {error}",
root.display()
))
})?;
if root_metadata.file_type().is_symlink() || !root_metadata.is_dir() {
return Err(ProxyError::Config(format!(
"WEB static directory `{}` must be a real directory, not a symlink",
root.display()
)));
}
let canonical_root = fs::canonicalize(root).map_err(|error| {
ProxyError::Config(format!(
"failed to canonicalize WEB static directory `{}`: {error}",
root.display()
))
})?;
let mut assets = BTreeMap::new();
load_static_directory(
&canonical_root,
&canonical_root,
&mut assets,
total_files,
total_bytes,
limits,
0,
)?;
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(),
})
}
fn load_static_directory(
root: &Path,
directory: &Path,
assets: &mut BTreeMap<String, WebStaticAsset>,
total_files: &mut usize,
total_bytes: &mut usize,
limits: &WebLimitsConfig,
depth: usize,
) -> Result<()> {
let entries = fs::read_dir(directory).map_err(|error| {
ProxyError::Config(format!(
"failed to read WEB static directory `{}`: {error}",
directory.display()
))
})?;
for entry in entries {
let entry = entry.map_err(|error| {
ProxyError::Config(format!("failed to read WEB static entry: {error}"))
})?;
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;
let path = entry.path();
let file_type = entry.file_type().map_err(|error| {
ProxyError::Config(format!(
"failed to inspect WEB static entry `{}`: {error}",
path.display()
))
})?;
if file_type.is_symlink() {
return Err(ProxyError::Config(format!(
"WEB static entry `{}` must not be a symlink",
path.display()
)));
}
if file_type.is_dir() {
if depth >= MAX_WEB_STATIC_DEPTH {
return Err(ProxyError::Config(format!(
"WEB static directory `{}` exceeds the maximum nesting depth",
path.display()
)));
}
load_static_directory(
root,
&path,
assets,
total_files,
total_bytes,
limits,
depth + 1,
)?;
continue;
}
if !file_type.is_file() {
return Err(ProxyError::Config(format!(
"WEB static entry `{}` must be a regular file",
path.display()
)));
}
let mut options = fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
let file = options.open(&path).map_err(|error| {
ProxyError::Config(format!(
"failed to open WEB static file `{}`: {error}",
path.display()
))
})?;
let metadata = file.metadata().map_err(|error| {
ProxyError::Config(format!(
"failed to inspect WEB static file `{}`: {error}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(ProxyError::Config(format!(
"WEB static entry `{}` changed before it was opened",
path.display()
)));
}
let file_len = usize::try_from(metadata.len()).map_err(|_| {
ProxyError::Config(format!("WEB static file `{}` is too large", 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",
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 relative = path.strip_prefix(root).map_err(|_| {
ProxyError::Config("WEB static path escaped its configured root".to_string())
})?;
let route = static_route(relative)?;
let mut body = Vec::with_capacity(file_len);
file.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}",
path.display()
))
})?;
if body.len() != file_len {
return Err(ProxyError::Config(format!(
"WEB static file `{}` changed while its snapshot was built",
path.display()
)));
}
let etag = format!("\"{}\"", hex::encode(Sha256::digest(&body)));
assets.insert(
route,
WebStaticAsset {
body: Bytes::from(body),
content_type: static_content_type(&path),
etag,
},
);
}
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",
}
}
#[cfg(test)]
mod tests {
use base64::Engine as _;
use super::*;
#[test]
fn capability_matches_reference_vectors() {
let secret = hex::decode("000102030405060708090a0b0c0d0e0f").unwrap();
let plain = derive_web_capability(&secret, b"proxy.example.com").unwrap();
assert_eq!(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(plain),
"MHLEY5PmW1GWqJkSrlmJpvJUiLhBH_QKy6yKg8a0JPk"
);
let mut dd_secret = vec![0xdd];
dd_secret.extend_from_slice(&secret);
let dd = derive_web_capability(&dd_secret, b"proxy.example.com").unwrap();
assert_eq!(
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(dd),
"IpJrt3e7sKtzPyoXy6w-Zj6GGEvsvclN66JzQEfPYLA"
);
}
}
+71 -293
View File
@@ -7,6 +7,7 @@ const TOP_LEVEL_CONFIG_KEYS: &[&str] = &[
"logging",
"network",
"server",
"web",
"timeouts",
"censorship",
"access",
@@ -238,6 +239,7 @@ const CONNTRACK_CONTROL_CONFIG_KEYS: &[&str] = &[
const LISTENER_CONFIG_KEYS: &[&str] = &[
"ip",
"transport",
"port",
"client_mss",
"synlimit",
@@ -253,6 +255,70 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
"announce_ip",
"proxy_protocol",
"reuse_allow",
"web_client_ip_source",
"web_trusted_proxy_cidrs",
];
const WEB_CONFIG_KEYS: &[&str] = &["enabled", "limits", "timeouts", "vhosts"];
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"max_header_bytes",
"max_body_bytes",
"max_frame_payload_bytes",
"carrier_batch_bytes",
"max_frames_per_body",
"max_http_connections",
"max_http_handlers",
"max_body_readers",
"max_body_bytes_global",
"max_sessions_global",
"max_sessions_per_ip",
"max_streams_per_session",
"max_streams_global",
"max_stream_handshakes",
"max_tombstones_per_session",
"pending_bytes_per_session",
"pending_bytes_global",
"pending_items_per_session",
"pending_items_global",
"control_bytes_per_session",
"control_bytes_global",
"max_bootstraps_global",
"max_bootstraps_per_ip",
"max_vhosts",
"max_profiles",
"max_static_files",
"max_static_file_bytes",
"max_static_bytes",
"memory_envelope_bytes",
"new_bootstraps_per_minute",
"new_bootstraps_burst",
"new_sessions_per_minute",
"new_sessions_burst",
"new_streams_per_minute",
"new_streams_burst",
];
const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"header_secs",
"body_secs",
"stream_handshake_secs",
"long_poll_secs",
"bootstrap_lifetime_secs",
"reconnect_grace_secs",
"http_idle_secs",
"shutdown_secs",
"decoy_header_secs",
];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "public_addr", "decoy", "profiles"];
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
"user",
"secret_mode",
"max_sessions",
"max_streams",
"max_streams_per_session",
];
const TIMEOUTS_CONFIG_KEYS: &[&str] = &[
@@ -366,300 +432,12 @@ const LOGGING_CONFIG_KEYS: &[&str] = &[
"max_age_secs",
];
#[derive(Debug)]
struct UnknownConfigKey {
path: String,
suggestion: Option<String>,
}
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current.as_table()
}
fn is_strict_config(parsed_toml: &toml::Value) -> bool {
table_at(parsed_toml, &["general"])
.and_then(|table| table.get("config_strict"))
.and_then(toml::Value::as_bool)
.unwrap_or(false)
}
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
let mut keys = Vec::new();
for group in [
TOP_LEVEL_CONFIG_KEYS,
GENERAL_CONFIG_KEYS,
NETWORK_CONFIG_KEYS,
SERVER_CONFIG_KEYS,
API_CONFIG_KEYS,
CONNTRACK_CONTROL_CONFIG_KEYS,
LISTENER_CONFIG_KEYS,
TIMEOUTS_CONFIG_KEYS,
CENSORSHIP_CONFIG_KEYS,
TLS_FETCH_CONFIG_KEYS,
ACCESS_CONFIG_KEYS,
RATE_LIMIT_BPS_CONFIG_KEYS,
UPSTREAM_CONFIG_KEYS,
PROXY_MODES_CONFIG_KEYS,
TELEMETRY_CONFIG_KEYS,
LINKS_CONFIG_KEYS,
LOGGING_CONFIG_KEYS,
] {
keys.extend_from_slice(group);
}
keys
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let b_chars: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr = vec![0usize; b_chars.len() + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b_chars.iter().enumerate() {
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
let normalized = key.to_ascii_lowercase();
let mut best: Option<(&str, usize)> = None;
for known in known_keys {
let distance = levenshtein_distance(&normalized, known);
let is_better = match best {
Some((_, best_distance)) => distance < best_distance,
None => true,
};
if distance <= 4 && is_better {
best = Some((known, distance));
}
}
best.map(|(known, _)| known.to_string())
}
fn push_unknown_keys(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &str,
table: &toml::Table,
allowed: &[&str],
) {
for key in table.keys() {
if !allowed.contains(&key.as_str()) {
let full_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
unknown.push(UnknownConfigKey {
path: full_path,
suggestion: unknown_key_suggestion(key, known_for_suggestion),
});
}
}
}
fn check_known_table(
parsed_toml: &toml::Value,
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &[&str],
allowed: &[&str],
) {
if let Some(table) = table_at(parsed_toml, path) {
push_unknown_keys(
unknown,
known_for_suggestion,
&path.join("."),
table,
allowed,
);
}
}
fn check_nested_table_value(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: String,
value: &toml::Value,
allowed: &[&str],
) {
if let Some(table) = value.as_table() {
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
}
}
fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
let known_for_suggestion = known_config_keys_for_suggestion();
let mut unknown = Vec::new();
if let Some(root) = parsed_toml.as_table() {
push_unknown_keys(
&mut unknown,
&known_for_suggestion,
"",
root,
TOP_LEVEL_CONFIG_KEYS,
);
}
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general"],
GENERAL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "modes"],
PROXY_MODES_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "telemetry"],
TELEMETRY_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "links"],
LINKS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["network"],
NETWORK_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server"],
SERVER_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "admin_api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "conntrack_control"],
CONNTRACK_CONTROL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["timeouts"],
TIMEOUTS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship"],
CENSORSHIP_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship", "tls_fetch"],
TLS_FETCH_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["access"],
ACCESS_CONFIG_KEYS,
);
if let Some(listeners) = table_at(parsed_toml, &["server"])
.and_then(|table| table.get("listeners"))
.and_then(toml::Value::as_array)
{
for (idx, listener) in listeners.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("server.listeners[{idx}]"),
listener,
LISTENER_CONFIG_KEYS,
);
}
}
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
for (idx, upstream) in upstreams.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("upstreams[{idx}]"),
upstream,
UPSTREAM_CONFIG_KEYS,
);
}
}
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
if let Some(table) = table_at(parsed_toml, &["access"])
.and_then(|access| access.get(access_map))
.and_then(toml::Value::as_table)
{
for (entry_name, value) in table {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("access.{access_map}.{entry_name}"),
value,
RATE_LIMIT_BPS_CONFIG_KEYS,
);
}
}
}
unknown
}
// Recursive table traversal and key suggestion logic.
mod check;
/// Rejects or reports unknown configuration keys according to strict mode.
pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()> {
let unknown = collect_unknown_config_keys(parsed_toml);
let unknown = check::collect_unknown_config_keys(parsed_toml);
if unknown.is_empty() {
return Ok(());
}
@@ -676,7 +454,7 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()
}
}
if is_strict_config(parsed_toml) {
if check::is_strict_config(parsed_toml) {
let mut paths = Vec::with_capacity(unknown.len());
for item in unknown {
if let Some(suggestion) = item.suggestion {
+362
View File
@@ -0,0 +1,362 @@
use super::*;
#[derive(Debug)]
/// One rejected configuration path and its optional nearest known key.
pub(super) struct UnknownConfigKey {
/// Fully qualified configuration path.
pub(super) path: String,
/// Nearest known key when edit distance is sufficiently small.
pub(super) suggestion: Option<String>,
}
fn table_at<'a>(value: &'a toml::Value, path: &[&str]) -> Option<&'a toml::Table> {
let mut current = value;
for segment in path {
current = current.get(*segment)?;
}
current.as_table()
}
/// Reads strict-key enforcement without deserializing the full configuration.
pub(super) fn is_strict_config(parsed_toml: &toml::Value) -> bool {
table_at(parsed_toml, &["general"])
.and_then(|table| table.get("config_strict"))
.and_then(toml::Value::as_bool)
.unwrap_or(false)
}
fn known_config_keys_for_suggestion() -> Vec<&'static str> {
let mut keys = Vec::new();
for group in [
TOP_LEVEL_CONFIG_KEYS,
GENERAL_CONFIG_KEYS,
NETWORK_CONFIG_KEYS,
SERVER_CONFIG_KEYS,
API_CONFIG_KEYS,
CONNTRACK_CONTROL_CONFIG_KEYS,
LISTENER_CONFIG_KEYS,
WEB_CONFIG_KEYS,
WEB_LIMITS_CONFIG_KEYS,
WEB_TIMEOUTS_CONFIG_KEYS,
WEB_VHOST_CONFIG_KEYS,
WEB_DECOY_CONFIG_KEYS,
WEB_PROFILE_CONFIG_KEYS,
TIMEOUTS_CONFIG_KEYS,
CENSORSHIP_CONFIG_KEYS,
TLS_FETCH_CONFIG_KEYS,
ACCESS_CONFIG_KEYS,
RATE_LIMIT_BPS_CONFIG_KEYS,
UPSTREAM_CONFIG_KEYS,
PROXY_MODES_CONFIG_KEYS,
TELEMETRY_CONFIG_KEYS,
LINKS_CONFIG_KEYS,
LOGGING_CONFIG_KEYS,
] {
keys.extend_from_slice(group);
}
keys
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let b_chars: Vec<char> = b.chars().collect();
let mut prev: Vec<usize> = (0..=b_chars.len()).collect();
let mut curr = vec![0usize; b_chars.len() + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b_chars.iter().enumerate() {
let replace = if ca == *cb { prev[j] } else { prev[j] + 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(replace);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b_chars.len()]
}
fn unknown_key_suggestion(key: &str, known_keys: &[&'static str]) -> Option<String> {
let normalized = key.to_ascii_lowercase();
let mut best: Option<(&str, usize)> = None;
for known in known_keys {
let distance = levenshtein_distance(&normalized, known);
let is_better = match best {
Some((_, best_distance)) => distance < best_distance,
None => true,
};
if distance <= 4 && is_better {
best = Some((known, distance));
}
}
best.map(|(known, _)| known.to_string())
}
fn push_unknown_keys(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &str,
table: &toml::Table,
allowed: &[&str],
) {
for key in table.keys() {
if !allowed.contains(&key.as_str()) {
let full_path = if path.is_empty() {
key.clone()
} else {
format!("{path}.{key}")
};
unknown.push(UnknownConfigKey {
path: full_path,
suggestion: unknown_key_suggestion(key, known_for_suggestion),
});
}
}
}
fn check_known_table(
parsed_toml: &toml::Value,
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: &[&str],
allowed: &[&str],
) {
if let Some(table) = table_at(parsed_toml, path) {
push_unknown_keys(
unknown,
known_for_suggestion,
&path.join("."),
table,
allowed,
);
}
}
fn check_nested_table_value(
unknown: &mut Vec<UnknownConfigKey>,
known_for_suggestion: &[&'static str],
path: String,
value: &toml::Value,
allowed: &[&str],
) {
if let Some(table) = value.as_table() {
push_unknown_keys(unknown, known_for_suggestion, &path, table, allowed);
}
}
/// Collects unknown keys across every supported nested configuration table.
pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<UnknownConfigKey> {
let known_for_suggestion = known_config_keys_for_suggestion();
let mut unknown = Vec::new();
if let Some(root) = parsed_toml.as_table() {
push_unknown_keys(
&mut unknown,
&known_for_suggestion,
"",
root,
TOP_LEVEL_CONFIG_KEYS,
);
}
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general"],
GENERAL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "modes"],
PROXY_MODES_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "telemetry"],
TELEMETRY_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["general", "links"],
LINKS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["logging"],
LOGGING_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["network"],
NETWORK_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server"],
SERVER_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "admin_api"],
API_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["server", "conntrack_control"],
CONNTRACK_CONTROL_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web"],
WEB_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web", "limits"],
WEB_LIMITS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web", "timeouts"],
WEB_TIMEOUTS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["timeouts"],
TIMEOUTS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship"],
CENSORSHIP_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["censorship", "tls_fetch"],
TLS_FETCH_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["access"],
ACCESS_CONFIG_KEYS,
);
if let Some(listeners) = table_at(parsed_toml, &["server"])
.and_then(|table| table.get("listeners"))
.and_then(toml::Value::as_array)
{
for (idx, listener) in listeners.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("server.listeners[{idx}]"),
listener,
LISTENER_CONFIG_KEYS,
);
}
}
if let Some(vhosts) = table_at(parsed_toml, &["web"])
.and_then(|table| table.get("vhosts"))
.and_then(toml::Value::as_array)
{
for (vhost_idx, vhost) in vhosts.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("web.vhosts[{vhost_idx}]"),
vhost,
WEB_VHOST_CONFIG_KEYS,
);
if let Some(vhost) = vhost.as_table() {
if let Some(decoy) = vhost.get("decoy") {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("web.vhosts[{vhost_idx}].decoy"),
decoy,
WEB_DECOY_CONFIG_KEYS,
);
}
if let Some(profiles) = vhost.get("profiles").and_then(toml::Value::as_array) {
for (profile_idx, profile) in profiles.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("web.vhosts[{vhost_idx}].profiles[{profile_idx}]"),
profile,
WEB_PROFILE_CONFIG_KEYS,
);
}
}
}
}
}
if let Some(upstreams) = parsed_toml.get("upstreams").and_then(toml::Value::as_array) {
for (idx, upstream) in upstreams.iter().enumerate() {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("upstreams[{idx}]"),
upstream,
UPSTREAM_CONFIG_KEYS,
);
}
}
for access_map in ["user_rate_limits", "cidr_rate_limits"] {
if let Some(table) = table_at(parsed_toml, &["access"])
.and_then(|access| access.get(access_map))
.and_then(toml::Value::as_table)
{
for (entry_name, value) in table {
check_nested_table_value(
&mut unknown,
&known_for_suggestion,
format!("access.{access_map}.{entry_name}"),
value,
RATE_LIMIT_BPS_CONFIG_KEYS,
);
}
}
}
unknown
}
+545
View File
@@ -0,0 +1,545 @@
use std::collections::HashSet;
use super::*;
const WEB_FRAME_HEADER_BYTES: usize = 8;
const WEB_QUEUE_ITEM_COST: usize = 256;
const WEB_CONTROL_EXTRA_ITEMS: usize = 16;
const WEB_CONTROL_ITEMS_PER_STREAM: usize = 3;
const WEB_INITIAL_STREAM_WINDOW: usize = 4 * 1024 * 1024;
const MAX_WEB_HEADER_BYTES: usize = 64 * 1024;
const MAX_WEB_BODY_BYTES: usize = 16 * 1024 * 1024;
const MAX_WEB_FRAME_BYTES: usize = 1024 * 1024;
const MAX_WEB_FRAMES_PER_BODY: usize = 4096;
const MAX_WEB_TOMBSTONES_PER_SESSION: usize = 4096;
const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024;
/// Validates WEB policy and resource bounds before building runtime state.
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
let web_listener_count = config
.server
.listeners
.iter()
.filter(|listener| listener.transport == ListenerTransport::Web)
.count();
let eligible_web_listener_count = config
.server
.listeners
.iter()
.filter(|listener| listener.transport == ListenerTransport::Web)
.filter(|listener| {
(listener.ip.is_ipv4() && config.network.ipv4)
|| (listener.ip.is_ipv6() && config.network.ipv6 != Some(false))
})
.count();
for (idx, listener) in config.server.listeners.iter().enumerate() {
match listener.transport {
ListenerTransport::Mtproxy => {
if !listener.web_trusted_proxy_cidrs.is_empty() {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].web_trusted_proxy_cidrs is only valid for transport=web"
)));
}
}
ListenerTransport::Web => validate_web_listener(config, idx, listener)?,
}
}
if config.web.enabled && eligible_web_listener_count == 0 {
return Err(ProxyError::Config(
"web.enabled requires at least one network-eligible server.listeners entry with transport=web"
.to_string(),
));
}
if web_listener_count > 0 && config.web.vhosts.is_empty() {
return Err(ProxyError::Config(
"WEB listeners require at least one [[web.vhosts]] entry".to_string(),
));
}
validate_limits(&config.web.limits)?;
validate_timeouts(&config.web.timeouts)?;
validate_vhosts(config)?;
Ok(())
}
fn validate_web_listener(
config: &ProxyConfig,
idx: usize,
listener: &ListenerConfig,
) -> Result<()> {
if listener.web_trusted_proxy_cidrs.is_empty() {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].web_trusted_proxy_cidrs must be non-empty for transport=web"
)));
}
if listener
.web_trusted_proxy_cidrs
.iter()
.any(|network| network.prefix() == 0)
{
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].web_trusted_proxy_cidrs must not contain a /0 network"
)));
}
let proxy_protocol = listener
.proxy_protocol
.unwrap_or(config.server.proxy_protocol);
if proxy_protocol {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].proxy_protocol must be false for transport=web; WEB identity is accepted only from the configured L7 header"
)));
}
if listener.reuse_allow {
return Err(ProxyError::Config(format!(
"server.listeners[{idx}].reuse_allow is not supported for transport=web without external session affinity"
)));
}
if listener.client_mss.is_some()
|| listener.synlimit != SynLimitMode::Off
|| listener.announce.is_some()
|| listener.announce_ip.is_some()
{
return Err(ProxyError::Config(format!(
"server.listeners[{idx}] WEB transport does not accept client_mss, synlimit, announce, or announce_ip"
)));
}
Ok(())
}
fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) {
return config_error("web.limits.max_header_bytes must be within [8192, 65536]");
}
if !(WEB_FRAME_HEADER_BYTES..=MAX_WEB_BODY_BYTES).contains(&limits.max_body_bytes) {
return config_error("web.limits.max_body_bytes must be within [8, 16777216]");
}
if !(1..=MAX_WEB_FRAME_BYTES).contains(&limits.max_frame_payload_bytes) {
return config_error("web.limits.max_frame_payload_bytes must be within [1, 1048576]");
}
if !(1..=MAX_WEB_FRAMES_PER_BODY).contains(&limits.max_frames_per_body) {
return config_error("web.limits.max_frames_per_body must be within [1, 4096]");
}
if !(1..=MAX_WEB_TOMBSTONES_PER_SESSION).contains(&limits.max_tombstones_per_session) {
return config_error("web.limits.max_tombstones_per_session must be within [1, 4096]");
}
if limits.carrier_batch_bytes > limits.max_body_bytes
|| limits.carrier_batch_bytes
< limits
.max_frame_payload_bytes
.saturating_add(WEB_FRAME_HEADER_BYTES)
{
return config_error(
"web.limits.carrier_batch_bytes must fit max_body_bytes and one maximum frame",
);
}
if limits.max_frame_payload_bytes > WEB_INITIAL_STREAM_WINDOW {
return config_error(
"web.limits.max_frame_payload_bytes must not exceed the initial stream window",
);
}
let positive = [
("max_http_connections", limits.max_http_connections),
("max_http_handlers", limits.max_http_handlers),
("max_body_readers", limits.max_body_readers),
("max_body_bytes_global", limits.max_body_bytes_global),
("max_sessions_global", limits.max_sessions_global),
("max_sessions_per_ip", limits.max_sessions_per_ip),
("max_streams_per_session", limits.max_streams_per_session),
("max_streams_global", limits.max_streams_global),
("max_stream_handshakes", limits.max_stream_handshakes),
("pending_bytes_per_session", limits.pending_bytes_per_session),
("pending_bytes_global", limits.pending_bytes_global),
("pending_items_per_session", limits.pending_items_per_session),
("pending_items_global", limits.pending_items_global),
("control_bytes_per_session", limits.control_bytes_per_session),
("control_bytes_global", limits.control_bytes_global),
("max_bootstraps_global", limits.max_bootstraps_global),
("max_bootstraps_per_ip", limits.max_bootstraps_per_ip),
("max_vhosts", limits.max_vhosts),
("max_profiles", limits.max_profiles),
("max_static_files", limits.max_static_files),
("max_static_file_bytes", limits.max_static_file_bytes),
("max_static_bytes", limits.max_static_bytes),
("memory_envelope_bytes", limits.memory_envelope_bytes),
];
if let Some((field, _)) = positive.into_iter().find(|(_, value)| *value == 0) {
return config_error(&format!("web.limits.{field} must be > 0"));
}
for (field, value) in [
("max_http_connections", limits.max_http_connections),
("max_http_handlers", limits.max_http_handlers),
("max_body_readers", limits.max_body_readers),
("max_body_bytes_global", limits.max_body_bytes_global),
("max_stream_handshakes", limits.max_stream_handshakes),
] {
if value > tokio::sync::Semaphore::MAX_PERMITS {
return config_error(&format!("web.limits.{field} exceeds Tokio semaphore capacity"));
}
}
let rates = [
("new_bootstraps_per_minute", limits.new_bootstraps_per_minute),
("new_bootstraps_burst", limits.new_bootstraps_burst),
("new_sessions_per_minute", limits.new_sessions_per_minute),
("new_sessions_burst", limits.new_sessions_burst),
("new_streams_per_minute", limits.new_streams_per_minute),
("new_streams_burst", limits.new_streams_burst),
];
if let Some((field, _)) = rates.into_iter().find(|(_, value)| *value == 0) {
return config_error(&format!("web.limits.{field} must be > 0"));
}
if limits.max_streams_per_session > u16::MAX as usize {
return config_error("web.limits.max_streams_per_session must fit synthetic source ports");
}
if limits.max_sessions_per_ip > limits.max_sessions_global
|| limits.max_streams_per_session > limits.max_streams_global
|| limits.max_stream_handshakes > limits.max_streams_global
|| limits.max_bootstraps_per_ip > limits.max_bootstraps_global
|| limits.max_http_handlers > limits.max_http_connections
|| limits.max_body_readers > limits.max_http_handlers
|| limits.pending_bytes_per_session > limits.pending_bytes_global
|| limits.pending_items_per_session > limits.pending_items_global
|| limits.control_bytes_per_session > limits.control_bytes_global
|| limits.control_bytes_per_session > limits.pending_bytes_per_session
|| limits.control_bytes_global > limits.pending_bytes_global
|| limits.max_static_file_bytes > limits.max_static_bytes
{
return config_error("web.limits per-owner ceilings must not exceed global ceilings");
}
let control_items_per_session = WEB_CONTROL_EXTRA_ITEMS
.checked_add(
limits
.max_streams_per_session
.checked_mul(WEB_CONTROL_ITEMS_PER_STREAM)
.ok_or_else(|| {
ProxyError::Config(
"web.limits control item reservation overflowed usize".to_string(),
)
})?,
)
.ok_or_else(|| {
ProxyError::Config("web.limits control item reservation overflowed usize".to_string())
})?;
let control_items_global = control_items_per_session
.checked_mul(limits.max_sessions_global)
.ok_or_else(|| {
ProxyError::Config("web.limits global control reservation overflowed usize".to_string())
})?;
let control_frame_cost = WEB_FRAME_HEADER_BYTES + 4 + WEB_QUEUE_ITEM_COST;
let required_control_bytes_per_session = control_items_per_session
.checked_mul(control_frame_cost)
.ok_or_else(|| {
ProxyError::Config("web.limits control byte reservation overflowed usize".to_string())
})?;
let required_control_bytes_global = control_items_global
.checked_mul(control_frame_cost)
.ok_or_else(|| {
ProxyError::Config("web.limits global control byte reservation overflowed usize".to_string())
})?;
if control_items_per_session >= limits.pending_items_per_session
|| control_items_global >= limits.pending_items_global
|| required_control_bytes_per_session > limits.control_bytes_per_session
|| required_control_bytes_global > limits.control_bytes_global
{
return config_error(
"web.limits control reserves must cover bounded control frames and leave data capacity",
);
}
let uplink_bytes = limits
.max_frames_per_body
.checked_mul(WEB_QUEUE_ITEM_COST)
.and_then(|value| value.checked_add(limits.max_body_bytes))
.ok_or_else(|| {
ProxyError::Config("web.limits uplink reservation overflowed usize".to_string())
})?;
let minimum_downlink_frame_bytes = WEB_FRAME_HEADER_BYTES + 1 + WEB_QUEUE_ITEM_COST;
let session_required_bytes = limits
.control_bytes_per_session
.checked_add(uplink_bytes)
.and_then(|value| value.checked_add(minimum_downlink_frame_bytes))
.ok_or_else(|| {
ProxyError::Config("web.limits session reservation overflowed usize".to_string())
})?;
let session_required_items = control_items_per_session
.checked_add(limits.max_frames_per_body)
.and_then(|value| value.checked_add(1))
.ok_or_else(|| {
ProxyError::Config("web.limits session item reservation overflowed usize".to_string())
})?;
let global_required_bytes = limits
.control_bytes_global
.checked_add(uplink_bytes)
.and_then(|value| value.checked_add(minimum_downlink_frame_bytes))
.ok_or_else(|| {
ProxyError::Config("web.limits global reservation overflowed usize".to_string())
})?;
let global_required_items = control_items_global
.checked_add(limits.max_frames_per_body)
.and_then(|value| value.checked_add(1))
.ok_or_else(|| {
ProxyError::Config("web.limits global item reservation overflowed usize".to_string())
})?;
if session_required_bytes > limits.pending_bytes_per_session
|| session_required_items > limits.pending_items_per_session
|| global_required_bytes > limits.pending_bytes_global
|| global_required_items > limits.pending_items_global
{
return config_error(
"web.limits pending ceilings must preserve one uplink batch and downlink progress",
);
}
let body_reservation = limits
.max_body_readers
.checked_mul(limits.max_body_bytes)
.ok_or_else(|| {
ProxyError::Config("web.limits body reader reservation overflowed usize".to_string())
})?;
if body_reservation > limits.max_body_bytes_global
|| limits.max_body_bytes_global > u32::MAX as usize
{
return config_error(
"web.limits max_body_readers * max_body_bytes must fit max_body_bytes_global and u32",
);
}
let http_header_reservation = limits
.max_http_connections
.checked_mul(limits.max_header_bytes)
.ok_or_else(|| {
ProxyError::Config("web.limits HTTP header reservations overflow usize".to_string())
})?;
let reserved = limits
.pending_bytes_global
.checked_add(limits.max_body_bytes_global)
.and_then(|value| value.checked_add(limits.max_static_bytes))
.and_then(|value| value.checked_add(http_header_reservation))
.ok_or_else(|| ProxyError::Config("web.limits byte ceilings overflow usize".to_string()))?;
if reserved > limits.memory_envelope_bytes
|| limits.memory_envelope_bytes > MAX_WEB_MEMORY_ENVELOPE_BYTES
{
return config_error(
"web.limits memory reservations must fit memory_envelope_bytes within 4 GiB",
);
}
Ok(())
}
fn validate_timeouts(timeouts: &WebTimeoutsConfig) -> Result<()> {
let values = [
("header_secs", timeouts.header_secs),
("body_secs", timeouts.body_secs),
("stream_handshake_secs", timeouts.stream_handshake_secs),
("long_poll_secs", timeouts.long_poll_secs),
("bootstrap_lifetime_secs", timeouts.bootstrap_lifetime_secs),
("reconnect_grace_secs", timeouts.reconnect_grace_secs),
("http_idle_secs", timeouts.http_idle_secs),
("shutdown_secs", timeouts.shutdown_secs),
("decoy_header_secs", timeouts.decoy_header_secs),
];
if let Some((field, _)) = values
.into_iter()
.find(|(_, value)| !(1..=3600).contains(value))
{
return config_error(&format!("web.timeouts.{field} must be within [1, 3600]"));
}
let request_deadline = timeouts
.header_secs
.max(timeouts.body_secs)
.max(timeouts.long_poll_secs)
.max(timeouts.decoy_header_secs);
if request_deadline >= timeouts.http_idle_secs {
return config_error("web.timeouts request deadlines must be lower than http_idle_secs");
}
Ok(())
}
fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
let limits = &config.web.limits;
if config.web.vhosts.len() > limits.max_vhosts {
return config_error("web.vhosts exceeds web.limits.max_vhosts");
}
let mut hosts = HashSet::with_capacity(config.web.vhosts.len());
let mut profile_count = 0usize;
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
vhost.host = normalize_web_host(
&vhost.host,
&format!("web.vhosts[{vhost_idx}].host"),
)?;
if !hosts.insert(vhost.host.clone()) {
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
}
if vhost.public_addr.port() != 443 || vhost.public_addr.ip().is_unspecified() {
return config_error(&format!(
"web.vhosts[{vhost_idx}].public_addr must be a concrete socket address on port 443"
));
}
if config.web.enabled && vhost.profiles.is_empty() {
return config_error(&format!(
"web.vhosts[{vhost_idx}].profiles must be non-empty when web.enabled=true"
));
}
validate_decoy(vhost_idx, &vhost.decoy)?;
let mut profiles = HashSet::with_capacity(vhost.profiles.len());
for (profile_idx, profile) in vhost.profiles.iter().enumerate() {
if !config.access.users.contains_key(&profile.user) {
return config_error(&format!(
"web.vhosts[{vhost_idx}].profiles[{profile_idx}].user references unknown access user `{}`",
profile.user
));
}
if !profiles.insert((profile.user.as_str(), profile.secret_mode)) {
return config_error(&format!(
"duplicate WEB profile for user `{}` in vhost `{}`",
profile.user, vhost.host
));
}
let max_streams = profile.max_streams.unwrap_or(limits.max_streams_global);
let max_streams_per_session = profile
.max_streams_per_session
.unwrap_or(limits.max_streams_per_session);
if profile.max_sessions == Some(0)
|| profile.max_sessions.is_some_and(|value| value > limits.max_sessions_global)
|| profile.max_streams == Some(0)
|| profile
.max_streams
.is_some_and(|value| value > limits.max_streams_global)
|| profile.max_streams_per_session == Some(0)
|| profile
.max_streams_per_session
.is_some_and(|value| value > limits.max_streams_per_session)
|| max_streams_per_session > max_streams
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].profiles[{profile_idx}] limits must be non-zero and within global WEB limits"
));
}
profile_count = profile_count.checked_add(1).ok_or_else(|| {
ProxyError::Config("WEB profile count overflowed usize".to_string())
})?;
}
}
if profile_count > limits.max_profiles {
return config_error("WEB profiles exceed web.limits.max_profiles");
}
Ok(())
}
fn normalize_web_host(value: &str, field: &str) -> Result<String> {
let input = value.trim();
if input.is_empty()
|| input.ends_with('.')
|| input
.chars()
.any(|character| matches!(character, ':' | '/' | '?' | '#' | '@'))
{
return config_error(&format!(
"{field} must be a hostname without a port, path, credentials, or trailing dot"
));
}
let host = normalize_domain_to_ascii(input, field)?;
if host.len() > 253
|| !host.contains('.')
|| host.parse::<IpAddr>().is_ok()
|| web_host_last_label_is_numeric(&host)
{
return config_error(&format!(
"{field} must be a non-IP fully-qualified hostname accepted by Telegram Desktop"
));
}
for label in host.split('.') {
if label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
{
return config_error(&format!(
"{field} contains a hostname label rejected by Telegram Desktop"
));
}
}
Ok(host)
}
fn web_host_last_label_is_numeric(host: &str) -> bool {
let label = host.rsplit('.').next().unwrap_or_default();
let digits = label
.strip_prefix("0x")
.or_else(|| label.strip_prefix("0X"));
if let Some(digits) = digits {
return digits.bytes().all(|byte| byte.is_ascii_hexdigit());
}
label.bytes().all(|byte| byte.is_ascii_digit())
}
fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> {
match decoy {
WebDecoyConfig::HttpUpstream { upstream } => {
let parsed = url::Url::parse(upstream).map_err(|error| {
ProxyError::Config(format!(
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
))
})?;
if parsed.scheme() != "http"
|| parsed.host_str().is_none()
|| !parsed.username().is_empty()
|| parsed.password().is_some()
|| parsed.query().is_some()
|| parsed.fragment().is_some()
|| parsed.path() != "/"
|| parsed.port() == Some(0)
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
));
}
let ip = match parsed.host() {
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
_ => {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal"
));
}
};
let private = match ip {
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
IpAddr::V6(ip) => {
ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
}
};
if !private {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network"
));
}
}
WebDecoyConfig::StaticDirectory { directory, index } => {
if !directory.is_absolute() {
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.directory must be absolute"
));
}
if index.is_empty()
|| index.contains('\\')
|| std::path::Path::new(index).components().count() != 1
|| matches!(index.as_str(), "." | "..")
{
return config_error(&format!(
"web.vhosts[{vhost_idx}].decoy.index must be one safe file name"
));
}
}
}
Ok(())
}
fn config_error<T>(message: &str) -> Result<T> {
Err(ProxyError::Config(message.to_string()))
}
#[cfg(test)]
mod tests;
+26
View File
@@ -0,0 +1,26 @@
use super::*;
#[test]
fn web_host_normalization_matches_client_vectors() {
assert_eq!(
normalize_web_host(" Proxy.Example.COM ", "host").unwrap(),
"proxy.example.com"
);
assert_eq!(
normalize_web_host("bücher.example", "host").unwrap(),
"xn--bcher-kva.example"
);
for invalid in [
"localhost",
"127.0.0.1",
"127.1",
"0x7f.1",
"0177.0.0.1",
"1.2.3",
"site.example:443",
"site..example",
"site.example.",
] {
assert!(normalize_web_host(invalid, "host").is_err(), "{invalid}");
}
}
+2
View File
@@ -52,3 +52,5 @@ mod synlimit_mss_tests;
mod tls_fetch_tests;
#[path = "load_basic_tests/upstream_tests.rs"]
mod upstream_tests;
#[path = "load_basic_tests/web_tests.rs"]
mod web_tests;
@@ -0,0 +1,96 @@
use super::*;
const WEB_CONFIG: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
proxy_protocol = false
web_client_ip_source = "x_forwarded_for"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
[[web.vhosts]]
host = "Proxy.Example.COM"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "dd"
max_sessions = 4
max_streams = 64
max_streams_per_session = 16
"#;
#[test]
fn web_config_builds_canonical_runtime_snapshot() {
let config = load_config_from_temp_toml(WEB_CONFIG);
let runtime = config.web.runtime.expect("WEB runtime snapshot");
let vhost = runtime
.vhosts
.get("proxy.example.com")
.expect("canonical WEB vhost");
assert_eq!(vhost.profiles.len(), 1);
assert_eq!(vhost.profiles[0].user, "alice");
assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd);
assert_eq!(vhost.profiles[0].max_sessions, 4);
assert_eq!(vhost.profiles[0].max_streams, 64);
assert_eq!(vhost.profiles[0].max_streams_per_session, 16);
}
#[test]
fn web_listener_requires_an_explicit_trusted_proxy() {
let invalid = WEB_CONFIG.replace(
"web_trusted_proxy_cidrs = [\"127.0.0.1/32\"]",
"web_trusted_proxy_cidrs = []",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("web_trusted_proxy_cidrs must be non-empty"));
}
#[test]
fn web_queue_limits_preserve_control_and_uplink_progress() {
let invalid = WEB_CONFIG.replace(
"[web]\nenabled = true",
"[web]\nenabled = true\n\n[web.limits]\ncontrol_bytes_per_session = 1",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("control reserves must cover bounded control frames"));
}
#[test]
fn web_semaphore_limits_are_rejected_before_runtime_construction() {
let invalid = WEB_CONFIG.replace(
"[web]\nenabled = true",
&format!(
"[web]\nenabled = true\n\n[web.limits]\nmax_http_connections = {}",
tokio::sync::Semaphore::MAX_PERMITS + 1,
),
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("exceeds Tokio semaphore capacity"));
}
#[test]
fn web_ipv6_decoy_uses_a_valid_http_authority() {
let ipv6 = WEB_CONFIG.replace(
"http://127.0.0.1:18081",
"http://[::1]:18081",
);
let config = load_config_from_temp_toml(&ipv6);
let runtime = config.web.runtime.expect("WEB runtime snapshot");
let vhost = runtime.vhosts.get("proxy.example.com").unwrap();
let WebRuntimeDecoy::HttpUpstream { authority, .. } = &vhost.decoy else {
panic!("expected HTTP decoy");
};
assert_eq!(authority, "[::1]:18081");
}
+12 -1
View File
@@ -23,6 +23,7 @@ mod logging;
mod network;
mod policies;
mod server;
mod web;
pub use access::{AccessConfig, CidrRateLimitKey, RateLimitBps};
#[allow(unused_imports)]
@@ -43,7 +44,17 @@ pub use policies::{
pub use server::{
CLIENT_MSS_2IN8, CLIENT_MSS_EXTREME_LOW, CLIENT_MSS_MAX, CLIENT_MSS_MIN, CLIENT_MSS_TSPU,
ConntrackBackend, ConntrackControlConfig, ConntrackMode, ConntrackPressureProfile,
ListenerConfig, ServerConfig, SynLimitMode, TimeoutsConfig,
ListenerConfig, ListenerTransport, ServerConfig, SynLimitMode, TimeoutsConfig,
WebClientIpSource,
};
#[allow(unused_imports)]
pub use web::{
WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode,
WebTimeoutsConfig, WebVhostConfig,
};
pub(crate) use web::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
WebStaticSite,
};
fn default_quota_state_path() -> PathBuf {
+29
View File
@@ -75,6 +75,26 @@ pub enum SynLimitMode {
Pf,
}
/// Application protocol accepted by one process-owned TCP listener.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum ListenerTransport {
/// Existing MTProxy TCP listener behavior.
#[default]
Mtproxy,
/// Plain HTTP WEB gateway behind a trusted TLS terminator.
Web,
}
/// Trusted L7 source used to recover a WEB client's identity address.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum WebClientIpSource {
/// Require exactly one canonical IP in `X-Forwarded-For`.
#[default]
XForwardedFor,
}
impl Serialize for SynLimitMode {
fn serialize<S>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error>
where
@@ -380,6 +400,9 @@ impl Default for TimeoutsConfig {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListenerConfig {
pub ip: IpAddr,
/// Application protocol accepted by this listener.
#[serde(default)]
pub transport: ListenerTransport,
/// Per-listener TCP port. If omitted, falls back to legacy `server.port`.
#[serde(default)]
pub port: Option<u16>,
@@ -429,6 +452,12 @@ pub struct ListenerConfig {
/// Default is false for safety.
#[serde(default)]
pub reuse_allow: bool,
/// L7 header policy used only by WEB listeners.
#[serde(default)]
pub web_client_ip_source: WebClientIpSource,
/// Immediate socket peers allowed to provide the WEB client identity header.
#[serde(default)]
pub web_trusted_proxy_cidrs: Vec<IpNetwork>,
}
/// Client-facing TCP MSS preset for extreme-low fragmentation profiles.
+434
View File
@@ -0,0 +1,434 @@
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
/// Client-facing secret representation used to derive a WEB capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum WebSecretMode {
/// Use the existing 16-byte access secret without a prefix.
Plain,
/// Prefix the existing access secret with `0xdd` for capability derivation.
Dd,
}
/// One access user explicitly exposed through a WEB virtual host.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebProfileConfig {
/// Existing `[access.users]` key authenticated by the inner MTProxy handshake.
pub user: String,
/// Exact client-facing secret representation advertised in WEB links.
pub secret_mode: WebSecretMode,
/// Optional per-profile live session ceiling.
#[serde(default)]
pub max_sessions: Option<usize>,
/// Optional per-profile live logical-stream ceiling.
#[serde(default)]
pub max_streams: Option<usize>,
/// Optional per-profile stream ceiling for one session.
#[serde(default)]
pub max_streams_per_session: Option<usize>,
}
/// Public-site fallback used for requests that are not authenticated WEB traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum WebDecoyConfig {
/// Stream requests to one fixed private HTTP origin.
HttpUpstream {
/// Origin URL without a query or fragment.
upstream: String,
},
/// Serve an immutable, bounded snapshot of a local directory.
StaticDirectory {
/// Absolute directory containing public files.
directory: PathBuf,
/// File served for `/` and directory paths.
#[serde(default = "default_web_static_index")]
index: String,
},
}
/// One externally visible WEB hostname and its explicit access profiles.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebVhostConfig {
/// Canonical lowercase ACE hostname used by Telegram Desktop.
pub host: String,
/// Stable public destination tuple used by inner relay routing and KDF metadata.
pub public_addr: SocketAddr,
/// Ordinary-site fallback for this hostname.
pub decoy: WebDecoyConfig,
/// Access users and exact secret modes enabled for this hostname.
#[serde(default)]
pub profiles: Vec<WebProfileConfig>,
}
/// Hard process and protocol limits for WEB ingress.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebLimitsConfig {
/// Maximum bytes accepted while parsing one HTTP request head.
#[serde(default = "default_web_max_header_bytes")]
pub max_header_bytes: usize,
/// Maximum collected carrier request body size.
#[serde(default = "default_web_max_body_bytes")]
pub max_body_bytes: usize,
/// Maximum payload carried by one WEB frame.
#[serde(default = "default_web_max_frame_payload_bytes")]
pub max_frame_payload_bytes: usize,
/// Maximum encoded downlink batch returned by one poll.
#[serde(default = "default_web_carrier_batch_bytes")]
pub carrier_batch_bytes: usize,
/// Maximum frame count parsed or emitted in one carrier body.
#[serde(default = "default_web_max_frames_per_body")]
pub max_frames_per_body: usize,
/// Process-wide accepted WEB HTTP connection ceiling.
#[serde(default = "default_web_max_http_connections")]
pub max_http_connections: usize,
/// Process-wide concurrently executing HTTP handler ceiling.
#[serde(default = "default_web_max_http_handlers")]
pub max_http_handlers: usize,
/// Process-wide concurrently collected request body ceiling.
#[serde(default = "default_web_max_body_readers")]
pub max_body_readers: usize,
/// Process-wide byte reservation for collected request bodies.
#[serde(default = "default_web_max_body_bytes_global")]
pub max_body_bytes_global: usize,
/// Process-wide live WEB session ceiling.
#[serde(default = "default_web_max_sessions_global")]
pub max_sessions_global: usize,
/// Live WEB session ceiling for one forwarded client address.
#[serde(default = "default_web_max_sessions_per_ip")]
pub max_sessions_per_ip: usize,
/// Default live logical-stream ceiling for one WEB session.
#[serde(default = "default_web_max_streams_per_session")]
pub max_streams_per_session: usize,
/// Process-wide live logical-stream ceiling.
#[serde(default = "default_web_max_streams_global")]
pub max_streams_global: usize,
/// Process-wide concurrent inner MTProxy handshake ceiling.
#[serde(default = "default_web_max_stream_handshakes")]
pub max_stream_handshakes: usize,
/// Closed stream identifiers retained by one session.
#[serde(default = "default_web_max_tombstones")]
pub max_tombstones_per_session: usize,
/// Total queued data and control bytes allowed for one session.
#[serde(default = "default_web_pending_bytes_per_session")]
pub pending_bytes_per_session: usize,
/// Process-wide queued data and control byte ceiling.
#[serde(default = "default_web_pending_bytes_global")]
pub pending_bytes_global: usize,
/// Total queued data and control item ceiling for one session.
#[serde(default = "default_web_pending_items_per_session")]
pub pending_items_per_session: usize,
/// Process-wide queued data and control item ceiling.
#[serde(default = "default_web_pending_items_global")]
pub pending_items_global: usize,
/// Per-session byte reserve available only to control frames.
#[serde(default = "default_web_control_bytes_per_session")]
pub control_bytes_per_session: usize,
/// Process-wide byte reserve available only to control frames.
#[serde(default = "default_web_control_bytes_global")]
pub control_bytes_global: usize,
/// Process-wide live bootstrap credential ceiling.
#[serde(default = "default_web_max_bootstraps_global")]
pub max_bootstraps_global: usize,
/// Live bootstrap credential ceiling for one forwarded client address.
#[serde(default = "default_web_max_bootstraps_per_ip")]
pub max_bootstraps_per_ip: usize,
/// Maximum configured WEB virtual-host count.
#[serde(default = "default_web_max_vhosts")]
pub max_vhosts: usize,
/// Maximum configured WEB access-profile count across all virtual hosts.
#[serde(default = "default_web_max_profiles")]
pub max_profiles: usize,
/// Maximum static snapshot entry count across all virtual hosts.
#[serde(default = "default_web_max_static_files")]
pub max_static_files: usize,
/// Maximum bytes read from one static snapshot file.
#[serde(default = "default_web_max_static_file_bytes")]
pub max_static_file_bytes: usize,
/// Maximum static snapshot bytes across all virtual hosts.
#[serde(default = "default_web_max_static_bytes")]
pub max_static_bytes: usize,
/// Declared process envelope for HTTP heads, bodies, queues, and static snapshots.
#[serde(default = "default_web_memory_envelope_bytes")]
pub memory_envelope_bytes: usize,
/// Sustained process-wide bootstrap issuance rate.
#[serde(default = "default_web_new_bootstraps_per_minute")]
pub new_bootstraps_per_minute: u32,
/// Process-wide bootstrap issuance burst.
#[serde(default = "default_web_new_bootstraps_burst")]
pub new_bootstraps_burst: u32,
/// Sustained process-wide session creation rate.
#[serde(default = "default_web_new_sessions_per_minute")]
pub new_sessions_per_minute: u32,
/// Process-wide session creation burst.
#[serde(default = "default_web_new_sessions_burst")]
pub new_sessions_burst: u32,
/// Sustained process-wide logical-stream creation rate.
#[serde(default = "default_web_new_streams_per_minute")]
pub new_streams_per_minute: u32,
/// Process-wide logical-stream creation burst.
#[serde(default = "default_web_new_streams_burst")]
pub new_streams_burst: u32,
}
impl Default for WebLimitsConfig {
fn default() -> Self {
Self {
max_header_bytes: default_web_max_header_bytes(),
max_body_bytes: default_web_max_body_bytes(),
max_frame_payload_bytes: default_web_max_frame_payload_bytes(),
carrier_batch_bytes: default_web_carrier_batch_bytes(),
max_frames_per_body: default_web_max_frames_per_body(),
max_http_connections: default_web_max_http_connections(),
max_http_handlers: default_web_max_http_handlers(),
max_body_readers: default_web_max_body_readers(),
max_body_bytes_global: default_web_max_body_bytes_global(),
max_sessions_global: default_web_max_sessions_global(),
max_sessions_per_ip: default_web_max_sessions_per_ip(),
max_streams_per_session: default_web_max_streams_per_session(),
max_streams_global: default_web_max_streams_global(),
max_stream_handshakes: default_web_max_stream_handshakes(),
max_tombstones_per_session: default_web_max_tombstones(),
pending_bytes_per_session: default_web_pending_bytes_per_session(),
pending_bytes_global: default_web_pending_bytes_global(),
pending_items_per_session: default_web_pending_items_per_session(),
pending_items_global: default_web_pending_items_global(),
control_bytes_per_session: default_web_control_bytes_per_session(),
control_bytes_global: default_web_control_bytes_global(),
max_bootstraps_global: default_web_max_bootstraps_global(),
max_bootstraps_per_ip: default_web_max_bootstraps_per_ip(),
max_vhosts: default_web_max_vhosts(),
max_profiles: default_web_max_profiles(),
max_static_files: default_web_max_static_files(),
max_static_file_bytes: default_web_max_static_file_bytes(),
max_static_bytes: default_web_max_static_bytes(),
memory_envelope_bytes: default_web_memory_envelope_bytes(),
new_bootstraps_per_minute: default_web_new_bootstraps_per_minute(),
new_bootstraps_burst: default_web_new_bootstraps_burst(),
new_sessions_per_minute: default_web_new_sessions_per_minute(),
new_sessions_burst: default_web_new_sessions_burst(),
new_streams_per_minute: default_web_new_streams_per_minute(),
new_streams_burst: default_web_new_streams_burst(),
}
}
}
/// Deadlines for WEB HTTP, bootstrap, session, and shutdown lifecycle.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebTimeoutsConfig {
/// Deadline for receiving one complete HTTP request head.
#[serde(default = "default_web_header_timeout_secs")]
pub header_secs: u64,
/// Deadline for collecting one authenticated carrier request body.
#[serde(default = "default_web_body_timeout_secs")]
pub body_secs: u64,
/// Deadline for the inner MTProxy handshake on one logical stream.
#[serde(default = "default_web_stream_handshake_timeout_secs")]
pub stream_handshake_secs: u64,
/// Maximum wait for one empty downlink long poll.
#[serde(default = "default_web_long_poll_timeout_secs")]
pub long_poll_secs: u64,
/// Lifetime of an unused bootstrap credential and closed-token replay marker.
#[serde(default = "default_web_bootstrap_lifetime_secs")]
pub bootstrap_lifetime_secs: u64,
/// Maximum carrier inactivity before a session is closed.
#[serde(default = "default_web_reconnect_grace_secs")]
pub reconnect_grace_secs: u64,
/// Maximum idle lifetime of a WEB HTTP keep-alive connection.
#[serde(default = "default_web_http_idle_secs")]
pub http_idle_secs: u64,
/// Maximum graceful wait for WEB connections and process-owned tasks.
#[serde(default = "default_web_shutdown_secs")]
pub shutdown_secs: u64,
/// Deadline for connecting to and receiving headers from an HTTP decoy.
#[serde(default = "default_web_decoy_header_timeout_secs")]
pub decoy_header_secs: u64,
}
impl Default for WebTimeoutsConfig {
fn default() -> Self {
Self {
header_secs: default_web_header_timeout_secs(),
body_secs: default_web_body_timeout_secs(),
stream_handshake_secs: default_web_stream_handshake_timeout_secs(),
long_poll_secs: default_web_long_poll_timeout_secs(),
bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(),
reconnect_grace_secs: default_web_reconnect_grace_secs(),
http_idle_secs: default_web_http_idle_secs(),
shutdown_secs: default_web_shutdown_secs(),
decoy_header_secs: default_web_decoy_header_timeout_secs(),
}
}
}
/// WEB ingress, carrier, fallback, and lifecycle configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WebConfig {
/// Enables issuance of new WEB bridge and session credentials.
#[serde(default)]
pub enabled: bool,
/// Hard process and protocol limits.
#[serde(default)]
pub limits: WebLimitsConfig,
/// WEB lifecycle deadlines.
#[serde(default)]
pub timeouts: WebTimeoutsConfig,
/// Public hostnames served by WEB listeners.
#[serde(default)]
pub vhosts: Vec<WebVhostConfig>,
/// Validated immutable runtime snapshot built during configuration loading.
#[serde(skip)]
pub(crate) runtime: Option<Arc<WebRuntimeConfig>>,
}
/// Precomputed WEB configuration consumed by listener hot paths.
#[derive(Debug)]
pub(crate) struct WebRuntimeConfig {
/// Canonical host lookup used by HTTP request routing.
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
/// Flat profile inventory used by startup link emission.
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
}
/// Precomputed immutable virtual-host data.
#[derive(Debug)]
pub(crate) struct WebRuntimeVhost {
/// Canonical lowercase ACE hostname.
pub(crate) host: String,
/// Immutable ordinary-site fallback snapshot.
pub(crate) decoy: WebRuntimeDecoy,
/// Upstream connect and response-head deadline.
pub(crate) decoy_header_secs: u64,
/// Exact capability profiles accepted by this host.
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
}
/// Precomputed exact-user capability entry.
#[derive(Debug)]
pub(crate) struct WebRuntimeProfile {
/// Canonical host that owns this profile.
pub(crate) host: String,
/// Stable public destination tuple supplied to relay routing.
pub(crate) public_addr: SocketAddr,
/// Exact access user authenticated by logical streams.
pub(crate) user: String,
/// Client secret representation and inner protocol policy.
pub(crate) secret_mode: WebSecretMode,
/// HMAC-derived bridge capability.
pub(crate) capability: [u8; 32],
/// Per-profile live session ceiling.
pub(crate) max_sessions: usize,
/// Per-profile live logical-stream ceiling.
pub(crate) max_streams: usize,
/// Per-session live relay-task ceiling.
pub(crate) max_streams_per_session: usize,
}
/// Runtime-ready ordinary-site fallback.
#[derive(Debug)]
pub(crate) enum WebRuntimeDecoy {
HttpUpstream {
addr: SocketAddr,
authority: String,
},
StaticDirectory(Arc<WebStaticSite>),
}
/// Immutable bounded static-site snapshot.
#[derive(Debug)]
pub(crate) struct WebStaticSite {
/// Canonical URL-path to immutable response asset mapping.
pub(crate) assets: BTreeMap<String, WebStaticAsset>,
/// Configured root index file name.
pub(crate) index: String,
}
/// One immutable static response body and metadata.
#[derive(Debug)]
pub(crate) struct WebStaticAsset {
/// Immutable response body retained by the runtime snapshot.
pub(crate) body: Bytes,
/// Extension-derived static content type.
pub(crate) content_type: &'static str,
/// Strong SHA-256 entity tag.
pub(crate) etag: String,
}
fn default_web_static_index() -> String {
"index.html".to_string()
}
macro_rules! usize_default {
($name:ident, $value:expr) => {
fn $name() -> usize {
$value
}
};
}
macro_rules! u32_default {
($name:ident, $value:expr) => {
fn $name() -> u32 {
$value
}
};
}
macro_rules! u64_default {
($name:ident, $value:expr) => {
fn $name() -> u64 {
$value
}
};
}
usize_default!(default_web_max_header_bytes, 16 * 1024);
usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024);
usize_default!(default_web_carrier_batch_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frames_per_body, 4096);
usize_default!(default_web_max_http_connections, 1024);
usize_default!(default_web_max_http_handlers, 512);
usize_default!(default_web_max_body_readers, 32);
usize_default!(default_web_max_body_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_max_sessions_global, 128);
usize_default!(default_web_max_sessions_per_ip, 16);
usize_default!(default_web_max_streams_per_session, 128);
usize_default!(default_web_max_streams_global, 4096);
usize_default!(default_web_max_stream_handshakes, 256);
usize_default!(default_web_max_tombstones, 4096);
usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024);
usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024);
usize_default!(default_web_pending_items_per_session, 16 * 1024);
usize_default!(default_web_pending_items_global, 256 * 1024);
usize_default!(default_web_control_bytes_per_session, 256 * 1024);
usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024);
usize_default!(default_web_max_bootstraps_global, 512);
usize_default!(default_web_max_bootstraps_per_ip, 64);
usize_default!(default_web_max_vhosts, 8);
usize_default!(default_web_max_profiles, 32);
usize_default!(default_web_max_static_files, 4096);
usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024);
usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024);
usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024);
u32_default!(default_web_new_bootstraps_per_minute, 1200);
u32_default!(default_web_new_bootstraps_burst, 256);
u32_default!(default_web_new_sessions_per_minute, 600);
u32_default!(default_web_new_sessions_burst, 128);
u32_default!(default_web_new_streams_per_minute, 6000);
u32_default!(default_web_new_streams_burst, 512);
u64_default!(default_web_header_timeout_secs, 10);
u64_default!(default_web_body_timeout_secs, 30);
u64_default!(default_web_stream_handshake_timeout_secs, 10);
u64_default!(default_web_long_poll_timeout_secs, 25);
u64_default!(default_web_bootstrap_lifetime_secs, 120);
u64_default!(default_web_reconnect_grace_secs, 120);
u64_default!(default_web_http_idle_secs, 75);
u64_default!(default_web_shutdown_secs, 15);
u64_default!(default_web_decoy_header_timeout_secs, 30);