mirror of
https://github.com/telemt/telemt.git
synced 2026-10-29 12:07:15 +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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user