Files
telemt/src/config/load/validate_web.rs
T
2026-08-23 09:04:53 +03:00

551 lines
23 KiB
Rust

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)?;
if config.web.carrier == WebCarrier::HttpsLanes
&& config.web.limits.max_http_handlers < 2
{
return config_error("web.carrier=https-lanes requires web.limits.max_http_handlers >= 2");
}
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;