WEB: websocket + websocket-lanes as Carrier

This commit is contained in:
Alexey
2026-08-26 09:16:06 +03:00
parent 8e577ec5ca
commit d2edd90479
50 changed files with 3642 additions and 350 deletions
+4 -1
View File
@@ -114,7 +114,10 @@ fn web_debug_prefix_requiring_deferred_capacity_is_not_hot_applied() {
new.web.debug.body_prefix_bytes = 3 * 1024 * 1024;
let applied = overlay_hot_fields(&old, &new);
assert_eq!(applied.web.limits.max_body_bytes, old.web.limits.max_body_bytes);
assert_eq!(
applied.web.limits.max_body_bytes,
old.web.limits.max_body_bytes
);
assert_eq!(
applied.web.debug.body_prefix_bytes,
old.web.debug.body_prefix_bytes
+1 -2
View File
@@ -50,8 +50,7 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
client_secret(auth_entry.secret, profile.secret_mode);
let capability =
derive_web_capability(&client_secret[..client_secret_len], vhost.host.as_bytes())?;
let key_fingerprint =
debug_key_fingerprint(&client_secret[..client_secret_len]);
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
if !capabilities.insert(capability) {
return Err(ProxyError::Config(format!(
"WEB vhost `{}` contains profiles with the same client capability",
+10 -1
View File
@@ -259,7 +259,9 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
"web_trusted_proxy_cidrs",
];
const WEB_CONFIG_KEYS: &[&str] = &["enabled", "carrier", "debug", "limits", "timeouts", "vhosts"];
const WEB_CONFIG_KEYS: &[&str] = &[
"enabled", "carrier", "debug", "limits", "timeouts", "vhosts",
];
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"max_header_bytes",
@@ -269,6 +271,10 @@ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"max_frames_per_body",
"max_http_connections",
"max_http_handlers",
"websocket_bytes_global",
"websocket_admission_watermark_pct",
"websocket_eviction_watermark_pct",
"websocket_http_connection_reserve",
"max_body_readers",
"max_body_bytes_global",
"max_sessions_global",
@@ -319,6 +325,9 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"body_secs",
"stream_handshake_secs",
"long_poll_secs",
"websocket_write_secs",
"websocket_backpressure_secs",
"websocket_eviction_secs",
"bootstrap_lifetime_secs",
"reconnect_grace_secs",
"http_idle_secs",
+9
View File
@@ -6,6 +6,8 @@ use super::*;
mod debug;
// Memory-envelope arithmetic remains isolated from protocol validation.
mod memory;
// 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;
@@ -69,6 +71,7 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
return config_error("web.carrier=https-lanes requires web.limits.max_http_handlers >= 2");
}
validate_timeouts(&config.web.timeouts)?;
websocket::validate(config.web.carrier, &config.web.limits, &config.web.timeouts)?;
validate_vhosts(config)?;
Ok(())
}
@@ -327,6 +330,12 @@ fn validate_timeouts(timeouts: &WebTimeoutsConfig) -> Result<()> {
("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),
+3 -1
View File
@@ -18,7 +18,9 @@ pub(super) fn validate(policy: &WebDebugConfig, limits: &WebLimitsConfig) -> Res
);
}
if policy.body_prefix_bytes > limits.max_body_bytes {
return config_error("web.debug.body_prefix_bytes must not exceed web.limits.max_body_bytes");
return config_error(
"web.debug.body_prefix_bytes must not exceed web.limits.max_body_bytes",
);
}
if policy.body_prefix_bytes > limits.debug_bytes_global
|| policy.decoy_body_prefix_bytes > limits.debug_bytes_global
+1 -3
View File
@@ -46,9 +46,7 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
.checked_mul(WEB_DEBUG_GROUP_SCRATCH_BYTES)
.and_then(|scratch| value.checked_add(scratch))
})
.ok_or_else(|| {
ProxyError::Config("web.debug reservations overflowed usize".to_string())
})?;
.ok_or_else(|| ProxyError::Config("web.debug reservations overflowed usize".to_string()))?;
let reserved = limits
.pending_bytes_global
.checked_add(limits.max_body_bytes_global)
+83
View File
@@ -0,0 +1,83 @@
use super::*;
const MAX_WEBSOCKET_BATCH_BYTES: usize = 2 * 1024 * 1024;
const WEBSOCKET_IO_BUFFER_BYTES: usize = 64 * 1024;
const WEBSOCKET_DRIVER_OVERHEAD_BYTES: usize = 4 * 1024;
const WEBSOCKET_FRAME_OVERHEAD_BYTES: usize = 14;
/// Validates WebSocket admission, memory, and deadline invariants.
pub(super) fn validate(
carrier: WebCarrier,
limits: &WebLimitsConfig,
timeouts: &WebTimeoutsConfig,
) -> Result<()> {
if !(1..100).contains(&limits.websocket_admission_watermark_pct)
|| !(1..100).contains(&limits.websocket_eviction_watermark_pct)
|| limits.websocket_admission_watermark_pct >= limits.websocket_eviction_watermark_pct
{
return config_error(
"web.limits WebSocket watermarks must satisfy 1 <= admission < eviction < 100",
);
}
if limits.websocket_bytes_global == 0 {
return config_error("web.limits.websocket_bytes_global must be > 0");
}
if timeouts.websocket_eviction_secs > timeouts.websocket_write_secs {
return config_error(
"web.timeouts.websocket_eviction_secs must not exceed websocket_write_secs",
);
}
if !carrier.uses_websocket() {
return Ok(());
}
if limits.carrier_batch_bytes > MAX_WEBSOCKET_BATCH_BYTES {
return config_error(
"WebSocket carriers require web.limits.carrier_batch_bytes <= 2097152",
);
}
if limits.websocket_http_connection_reserve == 0
|| limits.websocket_http_connection_reserve >= limits.max_http_connections
{
return config_error(
"WebSocket carriers require websocket_http_connection_reserve within [1, max_http_connections)",
);
}
let socket_base = WEBSOCKET_IO_BUFFER_BYTES
.checked_mul(2)
.and_then(|value| value.checked_add(WEBSOCKET_DRIVER_OVERHEAD_BYTES))
.ok_or_else(|| ProxyError::Config("WebSocket base reservation overflowed usize".into()))?;
let minimum_websocket_progress = limits
.carrier_batch_bytes
.checked_add(WEBSOCKET_FRAME_OVERHEAD_BYTES)
.and_then(|value| value.checked_mul(2))
.and_then(|value| value.checked_add(socket_base))
.ok_or_else(|| {
ProxyError::Config("WebSocket progress reservation overflowed usize".into())
})?;
if limits.websocket_bytes_global < minimum_websocket_progress {
return config_error(
"web.limits.websocket_bytes_global must preserve one socket read and write",
);
}
let data_bytes = limits
.pending_bytes_global
.saturating_sub(limits.control_bytes_global);
let queue_progress = limits
.max_body_bytes
.checked_add(
limits
.max_frames_per_body
.checked_mul(WEB_QUEUE_ITEM_COST)
.ok_or_else(|| {
ProxyError::Config("WEB queue progress reservation overflowed usize".into())
})?,
)
.and_then(|value| value.checked_add(limits.carrier_batch_bytes))
.ok_or_else(|| {
ProxyError::Config("WEB queue progress reservation overflowed usize".into())
})?;
if limits.websocket_bytes_global > data_bytes.saturating_sub(queue_progress) {
return config_error("web.limits.websocket_bytes_global must leave bounded queue progress");
}
Ok(())
}
+56 -4
View File
@@ -64,10 +64,13 @@ fn web_debug_table_uses_debug_name_and_bounded_defaults() {
assert_eq!(config.web.debug.default_window_secs, 180);
assert_eq!(config.web.debug.max_window_secs, 900);
let old_name = format!("[general]\nconfig_strict = true\n{}", WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.trace]\nenabled = true\n\n[[web.vhosts]]",
));
let old_name = format!(
"[general]\nconfig_strict = true\n{}",
WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.trace]\nenabled = true\n\n[[web.vhosts]]",
)
);
let error = load_config_error_from_temp_toml(&old_name);
assert!(error.contains("web.trace"));
}
@@ -150,3 +153,52 @@ fn web_ipv6_decoy_uses_a_valid_http_authority() {
};
assert_eq!(authority, "[::1]:18081");
}
#[test]
fn websocket_carriers_build_runtime_profiles_with_bounded_defaults() {
for (name, carrier) in [
("websocket", WebCarrier::Websocket),
("websocket-lanes", WebCarrier::WebsocketLanes),
] {
let configured = WEB_CONFIG.replace("https-lanes", name);
let config = load_config_from_temp_toml(&configured);
let profile = &config.web.runtime.unwrap().profiles[0];
assert_eq!(profile.carrier, carrier);
assert_eq!(config.web.limits.websocket_bytes_global, 256 * 1024 * 1024);
assert_eq!(config.web.limits.websocket_admission_watermark_pct, 75);
assert_eq!(config.web.limits.websocket_eviction_watermark_pct, 90);
assert_eq!(config.web.limits.websocket_http_connection_reserve, 64);
assert_eq!(config.web.timeouts.websocket_write_secs, 30);
assert_eq!(config.web.timeouts.websocket_backpressure_secs, 30);
assert_eq!(config.web.timeouts.websocket_eviction_secs, 1);
}
}
#[test]
fn websocket_limits_reject_ambiguous_or_nonprogressing_policy() {
let reversed_watermarks = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"websocket\"\n\n[web.limits]\nwebsocket_admission_watermark_pct = 90\nwebsocket_eviction_watermark_pct = 75",
);
assert!(
load_config_error_from_temp_toml(&reversed_watermarks).contains("WebSocket watermarks")
);
let no_http_reserve = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"websocket\"\n\n[web.limits]\nwebsocket_http_connection_reserve = 0",
);
assert!(
load_config_error_from_temp_toml(&no_http_reserve)
.contains("websocket_http_connection_reserve")
);
let oversized_batch = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"websocket\"\n\n[web.limits]\nmax_body_bytes = 4194304\ncarrier_batch_bytes = 4194304\nmax_body_readers = 16",
);
assert!(
load_config_error_from_temp_toml(&oversized_batch)
.contains("carrier_batch_bytes <= 2097152")
);
}
+2 -2
View File
@@ -54,12 +54,12 @@ pub use web::{
WebCarrier, WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode,
WebTimeoutsConfig, WebVhostConfig,
};
pub use web_debug::{WebDebugBodyCapture, WebDebugConfig};
pub(crate) use web_debug::web_debug_fits_limits;
pub(crate) use web::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
WebStaticSite,
};
pub(crate) use web_debug::web_debug_fits_limits;
pub use web_debug::{WebDebugBodyCapture, WebDebugConfig};
fn default_quota_state_path() -> PathBuf {
PathBuf::from("telemt.limit.json")
+65 -1
View File
@@ -18,7 +18,7 @@ pub enum WebSecretMode {
Dd,
}
/// HTTP carrier selected for newly issued WEB bridge sessions.
/// Carrier selected for newly issued WEB bridge sessions.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebCarrier {
@@ -27,6 +27,10 @@ pub enum WebCarrier {
Https,
/// Give every logical stream independent HTTPS sequencing and polling state.
HttpsLanes,
/// Multiplex all logical streams over one ordered WebSocket.
Websocket,
/// Give every logical stream an independently owned WebSocket lane.
WebsocketLanes,
}
impl WebCarrier {
@@ -35,8 +39,25 @@ impl WebCarrier {
match self {
Self::Https => "https",
Self::HttpsLanes => "https-lanes",
Self::Websocket => "websocket",
Self::WebsocketLanes => "websocket-lanes",
}
}
/// Returns whether one carrier owns independent state per logical stream.
pub(crate) const fn uses_lanes(self) -> bool {
matches!(self, Self::HttpsLanes | Self::WebsocketLanes)
}
/// Returns whether carrier messages use RFC 6455 instead of HTTP bodies.
pub(crate) const fn uses_websocket(self) -> bool {
matches!(self, Self::Websocket | Self::WebsocketLanes)
}
/// Returns whether all logical streams share one carrier state machine.
pub(crate) const fn is_multiplexed(self) -> bool {
matches!(self, Self::Https | Self::Websocket)
}
}
/// One access user explicitly exposed through a WEB virtual host.
@@ -114,6 +135,18 @@ pub struct WebLimitsConfig {
/// Process-wide concurrently executing HTTP handler ceiling.
#[serde(default = "default_web_max_http_handlers")]
pub max_http_handlers: usize,
/// Process-wide transient WebSocket byte sub-budget inside pending bytes.
#[serde(default = "default_web_websocket_bytes_global")]
pub websocket_bytes_global: usize,
/// WebSocket usage percentage above which ordinary admission uses replacement.
#[serde(default = "default_web_websocket_admission_watermark_pct")]
pub websocket_admission_watermark_pct: u8,
/// WebSocket usage percentage that triggers pressure eviction.
#[serde(default = "default_web_websocket_eviction_watermark_pct")]
pub websocket_eviction_watermark_pct: u8,
/// Accepted HTTP connections that WebSocket upgrades must leave available.
#[serde(default = "default_web_websocket_http_connection_reserve")]
pub websocket_http_connection_reserve: usize,
/// Process-wide concurrently collected request body ceiling.
#[serde(default = "default_web_max_body_readers")]
pub max_body_readers: usize,
@@ -216,6 +249,10 @@ impl Default for WebLimitsConfig {
max_frames_per_body: default_web_max_frames_per_body(),
max_http_connections: default_web_max_http_connections(),
max_http_handlers: default_web_max_http_handlers(),
websocket_bytes_global: default_web_websocket_bytes_global(),
websocket_admission_watermark_pct: default_web_websocket_admission_watermark_pct(),
websocket_eviction_watermark_pct: default_web_websocket_eviction_watermark_pct(),
websocket_http_connection_reserve: default_web_websocket_http_connection_reserve(),
max_body_readers: default_web_max_body_readers(),
max_body_bytes_global: default_web_max_body_bytes_global(),
max_sessions_global: default_web_max_sessions_global(),
@@ -265,6 +302,15 @@ pub struct WebTimeoutsConfig {
/// Maximum wait for one empty downlink long poll.
#[serde(default = "default_web_long_poll_timeout_secs")]
pub long_poll_secs: u64,
/// Maximum wait for one WebSocket write to complete.
#[serde(default = "default_web_websocket_write_secs")]
pub websocket_write_secs: u64,
/// Maximum wait for WebSocket queue or byte-budget progress.
#[serde(default = "default_web_websocket_backpressure_secs")]
pub websocket_backpressure_secs: u64,
/// Maximum graceful close wait for an evicted WebSocket.
#[serde(default = "default_web_websocket_eviction_secs")]
pub websocket_eviction_secs: u64,
/// Lifetime of an unused bootstrap credential and closed-token replay marker.
#[serde(default = "default_web_bootstrap_lifetime_secs")]
pub bootstrap_lifetime_secs: u64,
@@ -289,6 +335,9 @@ impl Default for WebTimeoutsConfig {
body_secs: default_web_body_timeout_secs(),
stream_handshake_secs: default_web_stream_handshake_timeout_secs(),
long_poll_secs: default_web_long_poll_timeout_secs(),
websocket_write_secs: default_web_websocket_write_secs(),
websocket_backpressure_secs: default_web_websocket_backpressure_secs(),
websocket_eviction_secs: default_web_websocket_eviction_secs(),
bootstrap_lifetime_secs: default_web_bootstrap_lifetime_secs(),
reconnect_grace_secs: default_web_reconnect_grace_secs(),
http_idle_secs: default_web_http_idle_secs(),
@@ -418,6 +467,14 @@ macro_rules! u32_default {
};
}
macro_rules! u8_default {
($name:ident, $value:expr) => {
fn $name() -> u8 {
$value
}
};
}
macro_rules! u64_default {
($name:ident, $value:expr) => {
fn $name() -> u64 {
@@ -433,6 +490,10 @@ usize_default!(default_web_carrier_batch_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frames_per_body, 4096);
usize_default!(default_web_max_http_connections, 1024);
usize_default!(default_web_max_http_handlers, 512);
usize_default!(default_web_websocket_bytes_global, 256 * 1024 * 1024);
u8_default!(default_web_websocket_admission_watermark_pct, 75);
u8_default!(default_web_websocket_eviction_watermark_pct, 90);
usize_default!(default_web_websocket_http_connection_reserve, 64);
usize_default!(default_web_max_body_readers, 32);
usize_default!(default_web_max_body_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_max_sessions_global, 128);
@@ -467,6 +528,9 @@ u64_default!(default_web_header_timeout_secs, 10);
u64_default!(default_web_body_timeout_secs, 30);
u64_default!(default_web_stream_handshake_timeout_secs, 10);
u64_default!(default_web_long_poll_timeout_secs, 25);
u64_default!(default_web_websocket_write_secs, 30);
u64_default!(default_web_websocket_backpressure_secs, 30);
u64_default!(default_web_websocket_eviction_secs, 1);
u64_default!(default_web_bootstrap_lifetime_secs, 120);
u64_default!(default_web_reconnect_grace_secs, 120);
u64_default!(default_web_http_idle_secs, 75);
+1 -4
View File
@@ -90,10 +90,7 @@ fn default_max_window_secs() -> u64 {
}
/// Checks whether a hot debug policy fits restart-frozen process capacities.
pub(crate) fn web_debug_fits_limits(
policy: &WebDebugConfig,
limits: &WebLimitsConfig,
) -> bool {
pub(crate) fn web_debug_fits_limits(policy: &WebDebugConfig, limits: &WebLimitsConfig) -> bool {
policy.body_prefix_bytes <= limits.max_body_bytes
&& policy.body_prefix_bytes <= limits.debug_bytes_global
&& policy.decoy_body_prefix_bytes <= limits.debug_bytes_global