mirror of
https://github.com/telemt/telemt.git
synced 2026-10-28 03:27:14 +03:00
bc1c130f38
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
425 lines
18 KiB
Rust
425 lines
18 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use super::*;
|
|
|
|
// Debug capture validation is independent from restart-only storage limits.
|
|
mod debug;
|
|
// Memory-envelope arithmetic remains isolated from protocol validation.
|
|
mod memory;
|
|
// Carrier ordering, cumulative deadlines, and fallback identity are validated together.
|
|
mod negotiation;
|
|
// Request and lifecycle timeout relationships are validated together.
|
|
mod timeouts;
|
|
// WebSocket transport policy is validated independently from HTTP body policy.
|
|
mod websocket;
|
|
|
|
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<()> {
|
|
validate_source(config)?;
|
|
validate_decoy_listener_separation(config)
|
|
}
|
|
|
|
/// Validates source policy without requiring DNS evidence for opted-in origins.
|
|
pub(super) fn validate_source(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)?;
|
|
debug::validate(&config.web.debug, &config.web.limits)?;
|
|
let carriers = negotiation::validate(&config.web)?;
|
|
if carriers.contains(&WebCarrier::Https) && config.web.limits.max_http_handlers < 2 {
|
|
return config_error("WEB https candidates require web.limits.max_http_handlers >= 2");
|
|
}
|
|
if carriers.contains(&WebCarrier::HttpsLanes) && config.web.limits.max_http_handlers < 4 {
|
|
return config_error(
|
|
"WEB https-lanes candidates require web.limits.max_http_handlers >= 4",
|
|
);
|
|
}
|
|
timeouts::validate(&config.web.timeouts)?;
|
|
websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
|
|
validate_vhosts(config)?;
|
|
validate_decoy_listener_separation_inner(config, false)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Rejects a direct decoy recursion into an effective WEB listener.
|
|
pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result<()> {
|
|
validate_decoy_listener_separation_inner(config, true)
|
|
}
|
|
|
|
fn validate_decoy_listener_separation_inner(
|
|
config: &ProxyConfig,
|
|
require_resolved: bool,
|
|
) -> Result<()> {
|
|
let web_listeners = 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))
|
|
})
|
|
.map(|listener| SocketAddr::new(listener.ip, listener.port.unwrap_or(config.server.port)))
|
|
.collect::<Vec<_>>();
|
|
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
|
|
let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else {
|
|
continue;
|
|
};
|
|
let parsed = decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?;
|
|
for upstream_addr in decoy_dns::addresses(config, vhost_idx, &parsed, require_resolved)? {
|
|
if web_listeners
|
|
.iter()
|
|
.any(|listener| listener_covers(*listener, upstream_addr))
|
|
{
|
|
return config_error(&format!(
|
|
"web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn listener_covers(listener: SocketAddr, target: SocketAddr) -> bool {
|
|
listener.port() == target.port()
|
|
&& (listener.ip() == target.ip()
|
|
|| (listener.ip().is_unspecified() && listener.is_ipv4() == target.is_ipv4()))
|
|
}
|
|
|
|
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.pending_bytes_per_lane <= WEB_FRAME_HEADER_BYTES + WEB_QUEUE_ITEM_COST {
|
|
return config_error(
|
|
"web.limits.pending_bytes_per_lane must preserve one non-empty DATA frame",
|
|
);
|
|
}
|
|
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_overload_connections",
|
|
limits.max_http_overload_connections,
|
|
),
|
|
("max_http_handlers", limits.max_http_handlers),
|
|
(
|
|
"max_lane_open_waits_per_session",
|
|
limits.max_lane_open_waits_per_session,
|
|
),
|
|
("pending_bytes_per_lane", limits.pending_bytes_per_lane),
|
|
("pending_items_per_lane", limits.pending_items_per_lane),
|
|
(
|
|
"max_websocket_evictions_in_flight",
|
|
limits.max_websocket_evictions_in_flight,
|
|
),
|
|
(
|
|
"max_carrier_learning_entries",
|
|
limits.max_carrier_learning_entries,
|
|
),
|
|
("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),
|
|
("debug_records_capacity", limits.debug_records_capacity),
|
|
("debug_bytes_global", limits.debug_bytes_global),
|
|
("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_overload_connections",
|
|
limits.max_http_overload_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.max_lane_open_waits_per_session > limits.max_streams_per_session
|
|
|| limits.pending_bytes_per_session > limits.pending_bytes_global
|
|
|| limits.pending_items_per_session > limits.pending_items_global
|
|
|| limits.pending_bytes_per_lane > limits.pending_bytes_per_session
|
|
|| limits.pending_items_per_lane > limits.pending_items_per_session
|
|
|| 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",
|
|
);
|
|
}
|
|
memory::validate(limits)?;
|
|
Ok(())
|
|
}
|
|
|
|
// Virtual-host, hostname, and decoy validation.
|
|
mod vhosts;
|
|
use vhosts::*;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|