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
+1
View File
@@ -16,6 +16,7 @@
//! | `general` | `telemetry` / `me_*_policy` | Applied immediately |
//! | `network` | `dns_overrides` | Applied immediately |
//! | `access` | All user/quota fields | Effective immediately |
//! | `web` | Carrier, timing, and debug policy | Applied to newly issued sessions |
//! Fields that require re-binding sockets (`server.listeners`, legacy
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted. SYN limiter rules are process-owned and are
+40
View File
@@ -30,6 +30,23 @@ fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option<u1
std::fs::write(path, config).unwrap();
}
fn write_web_reload_config(path: &Path, carriers: &str, carrier_learning: bool) {
let config = format!(
r#"
[censorship]
tls_domain = "example.com"
[access.users]
user = "00000000000000000000000000000000"
[web]
carriers = {carriers}
carrier_learning = {carrier_learning}
"#,
);
std::fs::write(path, config).unwrap();
}
fn temp_config_path(prefix: &str) -> PathBuf {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -264,6 +281,29 @@ fn reload_keeps_hot_apply_when_non_hot_fields_change() {
let _ = std::fs::remove_file(path);
}
#[test]
fn reload_publishes_web_negotiation_policy_outside_hot_field_reporting() {
let path = temp_config_path("telemt_web_negotiation_reload");
write_web_reload_config(&path, "false", true);
let initial_cfg = Arc::new(ProxyConfig::load(&path).unwrap());
let initial_hash = ProxyConfig::load_with_metadata(&path)
.unwrap()
.rendered_hash;
let (config_tx, _config_rx) = watch::channel(initial_cfg.clone());
let (log_tx, _log_rx) = watch::channel(initial_cfg.general.log_level.clone());
let mut reload_state = ReloadState::new(Some(initial_hash));
write_web_reload_config(&path, "[\"websocket\", \"https\"]", false);
reload_config(&path, &config_tx, &log_tx, None, None, &mut reload_state).unwrap();
let applied = config_tx.borrow().clone();
assert!(applied.web.carrier_negotiation_enabled());
assert!(!applied.web.carrier_learning);
let _ = std::fs::remove_file(path);
}
#[test]
fn classify_sni_change_requires_restart() {
// censorship.* is not in overlay_hot_fields -> restart.
+1 -1
View File
@@ -164,7 +164,7 @@ pub(super) fn reload_config(
let old_hot = HotFields::from_config(&old_cfg);
let applied_hot = HotFields::from_config(&applied_cfg);
let non_hot_changed = !config_equal(&applied_cfg, &new_cfg);
let hot_changed = old_hot != applied_hot;
let hot_changed = !config_equal(&old_cfg, &applied_cfg);
if non_hot_changed {
warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed);
+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 {
+82 -1
View File
@@ -49,6 +49,87 @@ fn web_config_builds_canonical_runtime_snapshot() {
assert_eq!(vhost.profiles[0].max_streams_per_session, 16);
assert_eq!(vhost.profiles[0].key_fingerprint.len(), 16);
assert_ne!(vhost.profiles[0].key_fingerprint, "0001020304050607");
assert!(!vhost.profiles[0].carrier_negotiation_enabled);
assert_eq!(vhost.profiles[0].carriers.as_ref(), [WebCarrier::HttpsLanes]);
}
#[test]
fn web_carriers_missing_or_false_disable_negotiation() {
let missing = load_config_from_temp_toml(WEB_CONFIG);
assert!(!missing.web.carrier_negotiation_enabled());
let disabled = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\ncarriers = false",
);
let disabled = load_config_from_temp_toml(&disabled);
assert!(!disabled.web.carrier_negotiation_enabled());
assert_eq!(
disabled.web.runtime.unwrap().profiles[0].carriers.as_ref(),
[WebCarrier::HttpsLanes]
);
}
#[test]
fn web_carrier_array_enables_ordered_negotiation_and_appends_fallback() {
let configured = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
"carrier = \"https-lanes\"\ncarriers = [\"websocket\", \"https\"]\ncarrier_learning = false",
);
let config = load_config_from_temp_toml(&configured);
assert!(config.web.carrier_negotiation_enabled());
assert!(!config.web.carrier_learning);
let profile = &config.web.runtime.unwrap().profiles[0];
assert_eq!(
profile.carriers.as_ref(),
[
WebCarrier::Websocket,
WebCarrier::Https,
WebCarrier::HttpsLanes
]
);
assert!(!profile.carrier_learning);
}
#[test]
fn web_carriers_reject_true_empty_and_duplicates() {
for value in [
"true",
"[]",
"[\"https\", \"https\"]",
] {
let invalid = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
&format!("carrier = \"https-lanes\"\ncarriers = {value}"),
);
assert!(load_config_error_from_temp_toml(&invalid).contains("web.carriers"));
}
}
#[test]
fn web_carrier_deadlines_and_learning_window_are_configurable() {
let configured = WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.timeouts]\ncarrier_negotiation_deadlines_secs = [1, 2, 4, 9]\ncarrier_learning_secs = 30\n\n[[web.vhosts]]",
);
let config = load_config_from_temp_toml(&configured);
assert_eq!(
config.web.timeouts.carrier_negotiation_deadlines_secs,
[1, 2, 4, 9]
);
assert_eq!(config.web.timeouts.carrier_learning_secs, 30);
}
#[test]
fn web_carrier_learning_capacity_must_remain_nonzero() {
let invalid = WEB_CONFIG.replace(
"[[web.vhosts]]",
"[web.limits]\nmax_carrier_learning_entries = 0\n\n[[web.vhosts]]",
);
assert!(
load_config_error_from_temp_toml(&invalid)
.contains("web.limits.max_carrier_learning_entries")
);
}
#[test]
@@ -106,7 +187,7 @@ fn https_lanes_requires_separate_poll_and_control_handler_capacity() {
"carrier = \"https-lanes\"\n\n[web.limits]\nmax_http_handlers = 1\nmax_body_readers = 1",
);
let error = load_config_error_from_temp_toml(&invalid);
assert!(error.contains("web.carrier=https-lanes requires"));
assert!(error.contains("WEB https-lanes candidates require"));
}
#[test]
+6 -2
View File
@@ -24,6 +24,8 @@ mod network;
mod policies;
mod server;
mod web;
// WEB carrier tokens and fixed-slot policy helpers remain independent from bulky config types.
mod web_carrier;
// WEB debug capture policy is reusable by config reload and process storage.
mod web_debug;
@@ -51,9 +53,11 @@ pub use server::{
};
#[allow(unused_imports)]
pub use web::{
WebCarrier, WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode,
WebTimeoutsConfig, WebVhostConfig,
WebConfig, WebDecoyConfig, WebLimitsConfig, WebProfileConfig, WebSecretMode, WebTimeoutsConfig,
WebVhostConfig,
};
#[allow(unused_imports)]
pub use web_carrier::{WebCarrier, WebCarriers};
pub(crate) use web::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
WebStaticSite,
+70 -135
View File
@@ -6,8 +6,13 @@ use std::sync::Arc;
use bytes::Bytes;
use serde::{Deserialize, Serialize};
use super::web_carrier::{WebCarrier, WebCarriers};
use super::web_debug::WebDebugConfig;
// Serialized WEB defaults remain separate from the runtime data model.
mod defaults;
use defaults::*;
/// Client-facing secret representation used to derive a WEB capability.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
@@ -18,48 +23,6 @@ pub enum WebSecretMode {
Dd,
}
/// 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 {
/// Serialize all logical streams through one uplink and one downlink sequence.
#[default]
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 {
/// Returns the exact carrier token advertised to the browser bridge.
pub(crate) const fn as_str(self) -> &'static str {
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.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebProfileConfig {
@@ -147,6 +110,9 @@ pub struct WebLimitsConfig {
/// 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 bounded carrier-learning evidence entry ceiling.
#[serde(default = "default_web_max_carrier_learning_entries")]
pub max_carrier_learning_entries: usize,
/// Process-wide concurrently collected request body ceiling.
#[serde(default = "default_web_max_body_readers")]
pub max_body_readers: usize,
@@ -253,6 +219,7 @@ impl Default for WebLimitsConfig {
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_carrier_learning_entries: default_web_max_carrier_learning_entries(),
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(),
@@ -311,6 +278,12 @@ pub struct WebTimeoutsConfig {
/// Maximum graceful close wait for an evicted WebSocket.
#[serde(default = "default_web_websocket_eviction_secs")]
pub websocket_eviction_secs: u64,
/// Cumulative carrier-attempt deadlines for up to four unique candidates.
#[serde(default = "default_web_carrier_negotiation_deadlines_secs")]
pub carrier_negotiation_deadlines_secs: [u64; 4],
/// Fixed process-local carrier-learning evidence lifetime.
#[serde(default = "default_web_carrier_learning_secs")]
pub carrier_learning_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,
@@ -338,6 +311,9 @@ impl Default for WebTimeoutsConfig {
websocket_write_secs: default_web_websocket_write_secs(),
websocket_backpressure_secs: default_web_websocket_backpressure_secs(),
websocket_eviction_secs: default_web_websocket_eviction_secs(),
carrier_negotiation_deadlines_secs:
default_web_carrier_negotiation_deadlines_secs(),
carrier_learning_secs: default_web_carrier_learning_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(),
@@ -348,14 +324,20 @@ impl Default for WebTimeoutsConfig {
}
/// WEB ingress, carrier, fallback, and lifecycle configuration.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebConfig {
/// Enables issuance of new WEB bridge and session credentials.
#[serde(default)]
pub enabled: bool,
/// Carrier selected for newly issued WEB bridge sessions.
/// Sole carrier when negotiation is disabled and final fallback when enabled.
#[serde(default)]
pub carrier: WebCarrier,
/// Ordered carriers considered by server-side negotiation before the fallback carrier.
#[serde(default)]
pub carriers: WebCarriers,
/// Enables bounded process-local carrier learning for automatic sessions.
#[serde(default = "default_web_carrier_learning")]
pub carrier_learning: bool,
/// Hard process and protocol limits.
#[serde(default)]
pub limits: WebLimitsConfig,
@@ -373,6 +355,41 @@ pub struct WebConfig {
pub(crate) runtime: Option<Arc<WebRuntimeConfig>>,
}
impl WebConfig {
/// Returns the configured negotiation order with the fallback appended once.
pub(crate) fn carrier_candidates(&self) -> Vec<WebCarrier> {
let Some(configured) = self.carriers.enabled() else {
return vec![self.carrier];
};
let mut candidates = configured.to_vec();
if !candidates.contains(&self.carrier) {
candidates.push(self.carrier);
}
candidates
}
/// Returns whether the explicit candidate list enables auto-negotiation.
pub(crate) fn carrier_negotiation_enabled(&self) -> bool {
self.carriers.enabled().is_some()
}
}
impl Default for WebConfig {
fn default() -> Self {
Self {
enabled: false,
carrier: WebCarrier::default(),
carriers: WebCarriers::default(),
carrier_learning: default_web_carrier_learning(),
limits: WebLimitsConfig::default(),
debug: WebDebugConfig::default(),
timeouts: WebTimeoutsConfig::default(),
vhosts: Vec::new(),
runtime: None,
}
}
}
/// Precomputed WEB configuration consumed by listener hot paths.
#[derive(Debug)]
pub(crate) struct WebRuntimeConfig {
@@ -406,8 +423,16 @@ pub(crate) struct WebRuntimeProfile {
pub(crate) user: String,
/// Client secret representation and inner protocol policy.
pub(crate) secret_mode: WebSecretMode,
/// Carrier frozen into bridge and session state at issuance time.
/// Sole carrier or final fallback frozen into the issued bridge policy.
pub(crate) carrier: WebCarrier,
/// Whether an explicit carrier list enabled automatic negotiation.
pub(crate) carrier_negotiation_enabled: bool,
/// Whether automatic outcomes consult and update process-local evidence.
pub(crate) carrier_learning: bool,
/// Ordered negotiation candidates including the fallback carrier exactly once.
pub(crate) carriers: Arc<[WebCarrier]>,
/// Cumulative carrier-attempt deadlines frozen when the bridge is issued.
pub(crate) carrier_negotiation_deadlines_secs: [u64; 4],
/// HMAC-derived bridge capability.
pub(crate) capability: [u8; 32],
/// Non-secret domain-separated client-secret fingerprint for debugging.
@@ -446,93 +471,3 @@ pub(crate) struct WebStaticAsset {
/// Strong SHA-256 entity tag.
pub(crate) etag: String,
}
fn default_web_static_index() -> String {
"index.html".to_string()
}
macro_rules! usize_default {
($name:ident, $value:expr) => {
fn $name() -> usize {
$value
}
};
}
macro_rules! u32_default {
($name:ident, $value:expr) => {
fn $name() -> u32 {
$value
}
};
}
macro_rules! u8_default {
($name:ident, $value:expr) => {
fn $name() -> u8 {
$value
}
};
}
macro_rules! u64_default {
($name:ident, $value:expr) => {
fn $name() -> u64 {
$value
}
};
}
usize_default!(default_web_max_header_bytes, 16 * 1024);
usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024);
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);
usize_default!(default_web_max_sessions_per_ip, 16);
usize_default!(default_web_max_streams_per_session, 128);
usize_default!(default_web_max_streams_global, 4096);
usize_default!(default_web_max_stream_handshakes, 256);
usize_default!(default_web_max_tombstones, 4096);
usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024);
usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024);
usize_default!(default_web_pending_items_per_session, 16 * 1024);
usize_default!(default_web_pending_items_global, 256 * 1024);
usize_default!(default_web_control_bytes_per_session, 256 * 1024);
usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024);
usize_default!(default_web_max_bootstraps_global, 512);
usize_default!(default_web_max_bootstraps_per_ip, 64);
usize_default!(default_web_max_vhosts, 8);
usize_default!(default_web_max_profiles, 32);
usize_default!(default_web_max_static_files, 4096);
usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024);
usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024);
usize_default!(default_web_debug_records_capacity, 65_536);
usize_default!(default_web_debug_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024);
u32_default!(default_web_new_bootstraps_per_minute, 1200);
u32_default!(default_web_new_bootstraps_burst, 256);
u32_default!(default_web_new_sessions_per_minute, 600);
u32_default!(default_web_new_sessions_burst, 128);
u32_default!(default_web_new_streams_per_minute, 6000);
u32_default!(default_web_new_streams_burst, 512);
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);
u64_default!(default_web_shutdown_secs, 15);
u64_default!(default_web_decoy_header_timeout_secs, 30);
+97
View File
@@ -0,0 +1,97 @@
pub(super) fn default_web_static_index() -> String {
"index.html".to_string()
}
macro_rules! usize_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> usize {
$value
}
};
}
macro_rules! u32_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> u32 {
$value
}
};
}
macro_rules! u8_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> u8 {
$value
}
};
}
macro_rules! u64_default {
($name:ident, $value:expr) => {
pub(super) fn $name() -> u64 {
$value
}
};
}
usize_default!(default_web_max_header_bytes, 16 * 1024);
usize_default!(default_web_max_body_bytes, 2 * 1024 * 1024);
usize_default!(default_web_max_frame_payload_bytes, 1024 * 1024);
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_carrier_learning_entries, 4096);
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);
usize_default!(default_web_max_sessions_per_ip, 16);
usize_default!(default_web_max_streams_per_session, 128);
usize_default!(default_web_max_streams_global, 4096);
usize_default!(default_web_max_stream_handshakes, 256);
usize_default!(default_web_max_tombstones, 4096);
usize_default!(default_web_pending_bytes_per_session, 32 * 1024 * 1024);
usize_default!(default_web_pending_bytes_global, 512 * 1024 * 1024);
usize_default!(default_web_pending_items_per_session, 16 * 1024);
usize_default!(default_web_pending_items_global, 256 * 1024);
usize_default!(default_web_control_bytes_per_session, 256 * 1024);
usize_default!(default_web_control_bytes_global, 16 * 1024 * 1024);
usize_default!(default_web_max_bootstraps_global, 512);
usize_default!(default_web_max_bootstraps_per_ip, 64);
usize_default!(default_web_max_vhosts, 8);
usize_default!(default_web_max_profiles, 32);
usize_default!(default_web_max_static_files, 4096);
usize_default!(default_web_max_static_file_bytes, 8 * 1024 * 1024);
usize_default!(default_web_max_static_bytes, 64 * 1024 * 1024);
usize_default!(default_web_debug_records_capacity, 65_536);
usize_default!(default_web_debug_bytes_global, 64 * 1024 * 1024);
usize_default!(default_web_memory_envelope_bytes, 768 * 1024 * 1024);
u32_default!(default_web_new_bootstraps_per_minute, 1200);
u32_default!(default_web_new_bootstraps_burst, 256);
u32_default!(default_web_new_sessions_per_minute, 600);
u32_default!(default_web_new_sessions_burst, 128);
u32_default!(default_web_new_streams_per_minute, 6000);
u32_default!(default_web_new_streams_burst, 512);
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);
pub(super) fn default_web_carrier_negotiation_deadlines_secs() -> [u64; 4] {
[3, 5, 8, 12]
}
u64_default!(default_web_carrier_learning_secs, 600);
pub(super) fn default_web_carrier_learning() -> bool {
true
}
u64_default!(default_web_bootstrap_lifetime_secs, 120);
u64_default!(default_web_reconnect_grace_secs, 120);
u64_default!(default_web_http_idle_secs, 75);
u64_default!(default_web_shutdown_secs, 15);
u64_default!(default_web_decoy_header_timeout_secs, 30);
+115
View File
@@ -0,0 +1,115 @@
use serde::{Deserialize, Serialize};
/// Carrier selected for one newly issued WEB relay session.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum WebCarrier {
/// Serialize all logical streams through one uplink and one downlink sequence.
#[default]
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 {
/// Every carrier supported by the WEB v1 bridge.
pub(crate) const ALL: [Self; 4] = [
Self::Https,
Self::HttpsLanes,
Self::Websocket,
Self::WebsocketLanes,
];
/// Returns the exact carrier token advertised to the browser bridge.
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Https => "https",
Self::HttpsLanes => "https-lanes",
Self::Websocket => "websocket",
Self::WebsocketLanes => "websocket-lanes",
}
}
/// Returns the stable fixed-slot index used by bounded learning state.
pub(crate) const fn index(self) -> usize {
match self {
Self::Https => 0,
Self::HttpsLanes => 1,
Self::Websocket => 2,
Self::WebsocketLanes => 3,
}
}
/// 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)
}
}
/// Optional ordered carrier list that enables server-side auto-negotiation.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum WebCarriers {
/// Auto-negotiation is disabled and only `web.carrier` is used.
#[default]
Disabled,
/// Auto-negotiation uses this ordered candidate list before the fallback.
Enabled(Vec<WebCarrier>),
}
impl WebCarriers {
/// Returns the explicit candidate list when negotiation is enabled.
pub fn enabled(&self) -> Option<&[WebCarrier]> {
match self {
Self::Disabled => None,
Self::Enabled(carriers) => Some(carriers),
}
}
}
impl Serialize for WebCarriers {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match self {
Self::Disabled => false.serialize(serializer),
Self::Enabled(carriers) => carriers.serialize(serializer),
}
}
}
impl<'de> Deserialize<'de> for WebCarriers {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Repr {
Flag(bool),
List(Vec<WebCarrier>),
}
match Repr::deserialize(deserializer)? {
Repr::Flag(false) => Ok(Self::Disabled),
Repr::Flag(true) => Err(serde::de::Error::custom(
"web.carriers accepts false or a non-empty carrier array",
)),
Repr::List(carriers) => Ok(Self::Enabled(carriers)),
}
}
}