mirror of
https://github.com/telemt/telemt.git
synced 2026-09-21 18:18:24 +03:00
WEB
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user