Bounded Debugging + Websocket Carriers + Carriers Negotiation

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-08-26 17:00:20 +03:00
parent 43cd84aaa5
commit 923c79796a
52 changed files with 3450 additions and 980 deletions
+8
View File
@@ -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
+11 -1
View File
@@ -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",
+15 -39
View File
@@ -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 {
+11
View File
@@ -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
+108
View File
@@ -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());
}
}
+40
View File
@@ -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(())
}
+2 -2
View File
@@ -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 {