mirror of
https://github.com/telemt/telemt.git
synced 2026-10-29 12:07:15 +03:00
Bounded Debugging + Websocket Carriers + Carriers Negotiation
Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
@@ -27,6 +27,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
let mut static_files = 0usize;
|
||||
let mut static_bytes = 0usize;
|
||||
|
||||
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
|
||||
for vhost in &config.web.vhosts {
|
||||
let decoy = build_decoy(
|
||||
vhost,
|
||||
@@ -63,6 +64,13 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
user: profile.user.clone(),
|
||||
secret_mode: profile.secret_mode,
|
||||
carrier: config.web.carrier,
|
||||
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
|
||||
carrier_learning: config.web.carrier_learning,
|
||||
carriers: Arc::clone(&carrier_candidates),
|
||||
carrier_negotiation_deadlines_secs: config
|
||||
.web
|
||||
.timeouts
|
||||
.carrier_negotiation_deadlines_secs,
|
||||
capability,
|
||||
key_fingerprint,
|
||||
max_sessions: profile
|
||||
|
||||
@@ -260,7 +260,14 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
|
||||
];
|
||||
|
||||
const WEB_CONFIG_KEYS: &[&str] = &[
|
||||
"enabled", "carrier", "debug", "limits", "timeouts", "vhosts",
|
||||
"enabled",
|
||||
"carrier",
|
||||
"carriers",
|
||||
"carrier_learning",
|
||||
"debug",
|
||||
"limits",
|
||||
"timeouts",
|
||||
"vhosts",
|
||||
];
|
||||
|
||||
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
|
||||
@@ -275,6 +282,7 @@ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
|
||||
"websocket_admission_watermark_pct",
|
||||
"websocket_eviction_watermark_pct",
|
||||
"websocket_http_connection_reserve",
|
||||
"max_carrier_learning_entries",
|
||||
"max_body_readers",
|
||||
"max_body_bytes_global",
|
||||
"max_sessions_global",
|
||||
@@ -328,6 +336,8 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||
"websocket_write_secs",
|
||||
"websocket_backpressure_secs",
|
||||
"websocket_eviction_secs",
|
||||
"carrier_negotiation_deadlines_secs",
|
||||
"carrier_learning_secs",
|
||||
"bootstrap_lifetime_secs",
|
||||
"reconnect_grace_secs",
|
||||
"http_idle_secs",
|
||||
|
||||
@@ -6,6 +6,10 @@ use super::*;
|
||||
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;
|
||||
|
||||
@@ -67,11 +71,14 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||
|
||||
validate_limits(&config.web.limits)?;
|
||||
debug::validate(&config.web.debug, &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");
|
||||
let carriers = negotiation::validate(&config.web)?;
|
||||
if carriers.contains(&WebCarrier::HttpsLanes) && config.web.limits.max_http_handlers < 2 {
|
||||
return config_error(
|
||||
"WEB https-lanes candidates require web.limits.max_http_handlers >= 2",
|
||||
);
|
||||
}
|
||||
validate_timeouts(&config.web.timeouts)?;
|
||||
websocket::validate(config.web.carrier, &config.web.limits, &config.web.timeouts)?;
|
||||
timeouts::validate(&config.web.timeouts)?;
|
||||
websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
|
||||
validate_vhosts(config)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -155,6 +162,10 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
||||
let positive = [
|
||||
("max_http_connections", limits.max_http_connections),
|
||||
("max_http_handlers", limits.max_http_handlers),
|
||||
(
|
||||
"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),
|
||||
@@ -324,41 +335,6 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
||||
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),
|
||||
("websocket_write_secs", timeouts.websocket_write_secs),
|
||||
(
|
||||
"websocket_backpressure_secs",
|
||||
timeouts.websocket_backpressure_secs,
|
||||
),
|
||||
("websocket_eviction_secs", timeouts.websocket_eviction_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 {
|
||||
|
||||
@@ -3,9 +3,13 @@ use super::*;
|
||||
const WEB_DEBUG_RENDERERS: usize = 2;
|
||||
const WEB_DEBUG_STATUS_PAGE_BYTES: usize = 8 * 1024 * 1024;
|
||||
const WEB_DEBUG_GROUP_SCRATCH_BYTES: usize = 4 * 1024 * 1024;
|
||||
const WEB_CARRIER_LEARNING_ENTRY_BYTES: usize = 256;
|
||||
|
||||
/// Validates process-wide body, header, queue, static, and debug reservations.
|
||||
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
if limits.max_carrier_learning_entries == 0 {
|
||||
return config_error("web.limits.max_carrier_learning_entries must be > 0");
|
||||
}
|
||||
let body_reservation = limits
|
||||
.max_body_readers
|
||||
.checked_mul(limits.max_body_bytes)
|
||||
@@ -47,6 +51,12 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
.and_then(|scratch| value.checked_add(scratch))
|
||||
})
|
||||
.ok_or_else(|| ProxyError::Config("web.debug reservations overflowed usize".to_string()))?;
|
||||
let carrier_learning_reservation = limits
|
||||
.max_carrier_learning_entries
|
||||
.checked_mul(WEB_CARRIER_LEARNING_ENTRY_BYTES)
|
||||
.ok_or_else(|| {
|
||||
ProxyError::Config("web.carrier learning reservation overflowed usize".to_string())
|
||||
})?;
|
||||
let reserved = limits
|
||||
.pending_bytes_global
|
||||
.checked_add(limits.max_body_bytes_global)
|
||||
@@ -54,6 +64,7 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
.and_then(|value| value.checked_add(debug_ring_index))
|
||||
.and_then(|value| value.checked_add(status_pages))
|
||||
.and_then(|value| value.checked_add(debug_reservation))
|
||||
.and_then(|value| value.checked_add(carrier_learning_reservation))
|
||||
.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
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Validates bounded carrier selection and learning policy.
|
||||
pub(super) fn validate(config: &WebConfig) -> Result<Vec<WebCarrier>> {
|
||||
if let Some(carriers) = config.carriers.enabled() {
|
||||
if carriers.is_empty() {
|
||||
return config_error("web.carriers must contain at least one carrier");
|
||||
}
|
||||
let mut unique = HashSet::with_capacity(carriers.len());
|
||||
if carriers.iter().any(|carrier| !unique.insert(*carrier)) {
|
||||
return config_error("web.carriers must not contain duplicate carriers");
|
||||
}
|
||||
}
|
||||
let candidates = config.carrier_candidates();
|
||||
if candidates.len() > WebCarrier::ALL.len() {
|
||||
return config_error(
|
||||
"web.carriers and the web.carrier fallback must contain at most four carriers",
|
||||
);
|
||||
}
|
||||
let deadlines = config.timeouts.carrier_negotiation_deadlines_secs;
|
||||
if deadlines[0] == 0 || deadlines.windows(2).any(|pair| pair[0] >= pair[1]) {
|
||||
return config_error(
|
||||
"web.timeouts.carrier_negotiation_deadlines_secs must be non-zero and strictly increasing",
|
||||
);
|
||||
}
|
||||
if deadlines[3] > config.timeouts.bootstrap_lifetime_secs {
|
||||
return config_error(
|
||||
"web.timeouts carrier negotiation deadline must not exceed bootstrap_lifetime_secs",
|
||||
);
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fallback_is_appended_once() {
|
||||
let config = WebConfig {
|
||||
carrier: WebCarrier::Https,
|
||||
carriers: WebCarriers::Enabled(vec![WebCarrier::Websocket, WebCarrier::Https]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
validate(&config).unwrap(),
|
||||
vec![WebCarrier::Websocket, WebCarrier::Https]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_carriers_are_rejected() {
|
||||
let config = WebConfig {
|
||||
carriers: WebCarriers::Enabled(vec![WebCarrier::Websocket, WebCarrier::Websocket]),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(validate(&config).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_false_carriers_disable_negotiation() {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Wrapper {
|
||||
value: WebCarriers,
|
||||
}
|
||||
|
||||
let config = WebConfig::default();
|
||||
assert!(!config.carrier_negotiation_enabled());
|
||||
assert_eq!(validate(&config).unwrap(), [WebCarrier::Https]);
|
||||
|
||||
let disabled: Wrapper = toml::from_str("value = false").unwrap();
|
||||
assert_eq!(disabled.value, WebCarriers::Disabled);
|
||||
assert!(toml::from_str::<Wrapper>("value = true").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_cannot_expand_the_candidate_set_beyond_four() {
|
||||
let mut config = WebConfig {
|
||||
carrier: WebCarrier::Https,
|
||||
carriers: WebCarriers::Enabled(vec![
|
||||
WebCarrier::HttpsLanes,
|
||||
WebCarrier::Websocket,
|
||||
WebCarrier::WebsocketLanes,
|
||||
WebCarrier::Https,
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(validate(&config).unwrap().len(), 4);
|
||||
|
||||
config.carriers = WebCarriers::Enabled(vec![
|
||||
WebCarrier::HttpsLanes,
|
||||
WebCarrier::Websocket,
|
||||
WebCarrier::WebsocketLanes,
|
||||
]);
|
||||
assert_eq!(validate(&config).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deadlines_are_cumulative_and_bounded_by_bootstrap_lifetime() {
|
||||
let mut config = WebConfig::default();
|
||||
config.timeouts.carrier_negotiation_deadlines_secs = [3, 3, 8, 12];
|
||||
assert!(validate(&config).is_err());
|
||||
config.timeouts.carrier_negotiation_deadlines_secs = [3, 5, 8, 121];
|
||||
assert!(validate(&config).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use super::*;
|
||||
|
||||
/// Validates WEB request, learning, and lifecycle timeouts.
|
||||
pub(super) fn validate(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),
|
||||
("websocket_write_secs", timeouts.websocket_write_secs),
|
||||
(
|
||||
"websocket_backpressure_secs",
|
||||
timeouts.websocket_backpressure_secs,
|
||||
),
|
||||
("websocket_eviction_secs", timeouts.websocket_eviction_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]"));
|
||||
}
|
||||
if timeouts.carrier_learning_secs == 0 {
|
||||
return config_error("web.timeouts.carrier_learning_secs must be > 0");
|
||||
}
|
||||
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(())
|
||||
}
|
||||
@@ -7,7 +7,7 @@ const WEBSOCKET_FRAME_OVERHEAD_BYTES: usize = 14;
|
||||
|
||||
/// Validates WebSocket admission, memory, and deadline invariants.
|
||||
pub(super) fn validate(
|
||||
carrier: WebCarrier,
|
||||
carriers: &[WebCarrier],
|
||||
limits: &WebLimitsConfig,
|
||||
timeouts: &WebTimeoutsConfig,
|
||||
) -> Result<()> {
|
||||
@@ -27,7 +27,7 @@ pub(super) fn validate(
|
||||
"web.timeouts.websocket_eviction_secs must not exceed websocket_write_secs",
|
||||
);
|
||||
}
|
||||
if !carrier.uses_websocket() {
|
||||
if !carriers.iter().any(|carrier| carrier.uses_websocket()) {
|
||||
return Ok(());
|
||||
}
|
||||
if limits.carrier_batch_bytes > MAX_WEBSOCKET_BATCH_BYTES {
|
||||
|
||||
Reference in New Issue
Block a user