From 923c79796a2ff2c98ff3c3c9f8a53726c0f08c6b Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:00:20 +0300 Subject: [PATCH 01/11] Bounded Debugging + Websocket Carriers + Carriers Negotiation Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com> --- src/api/web_status.rs | 27 +- src/api/web_status/details.rs | 34 ++ src/api/web_status/tests.rs | 16 +- src/config/hot_reload.rs | 1 + src/config/hot_reload/tests.rs | 40 ++ src/config/hot_reload/watcher.rs | 2 +- src/config/load/runtime_web.rs | 8 + src/config/load/strict_keys.rs | 12 +- src/config/load/validate_web.rs | 54 +- src/config/load/validate_web/memory.rs | 11 + src/config/load/validate_web/negotiation.rs | 108 ++++ src/config/load/validate_web/timeouts.rs | 40 ++ src/config/load/validate_web/websocket.rs | 4 +- .../tests/load_basic_tests/web_tests.rs | 83 ++- src/config/types.rs | 8 +- src/config/types/web.rs | 205 +++---- src/config/types/web/defaults.rs | 97 ++++ src/config/types/web_carrier.rs | 115 ++++ src/web/bridge.rs | 279 +++++----- src/web/bridge/tests.rs | 76 +++ src/web/http.rs | 42 +- src/web/http/legacy_tests.rs | 99 ++++ src/web/http/negotiation_tests.rs | 176 ++++++ src/web/http/request.rs | 197 ++++++- src/web/http/tests.rs | 144 ++--- src/web/http/websocket.rs | 50 +- src/web/http/websocket/driver.rs | 239 ++------- src/web/http/websocket/driver/io.rs | 214 ++++++++ src/web/http/websocket/tests.rs | 154 +++++- src/web/manager.rs | 29 + src/web/manager/credentials.rs | 140 +---- src/web/manager/learning.rs | 346 ++++++++++++ src/web/manager/lifecycle.rs | 32 +- src/web/manager/negotiation.rs | 187 +++++++ src/web/manager/session_creation.rs | 505 ++++++++++++++++++ src/web/manager/state.rs | 53 +- src/web/manager/websocket.rs | 1 + src/web/session.rs | 145 +++-- src/web/session/backend_tests.rs | 17 +- src/web/session/downlink.rs | 8 + src/web/session/lanes.rs | 11 + src/web/session/lanes/tests.rs | 8 + src/web/session/lifecycle.rs | 161 ++++++ src/web/session/uplink.rs | 17 + src/web/session/websocket.rs | 11 + src/web/session/websocket/tests.rs | 8 + src/web/trace/exchange.rs | 27 +- src/web/trace/mod.rs | 3 +- src/web/trace/sanitize.rs | 40 +- src/web/trace/store.rs | 77 ++- src/web/trace/store/websocket.rs | 39 +- src/web/trace/types.rs | 30 ++ 52 files changed, 3450 insertions(+), 980 deletions(-) create mode 100644 src/config/load/validate_web/negotiation.rs create mode 100644 src/config/load/validate_web/timeouts.rs create mode 100644 src/config/types/web/defaults.rs create mode 100644 src/config/types/web_carrier.rs create mode 100644 src/web/bridge/tests.rs create mode 100644 src/web/http/legacy_tests.rs create mode 100644 src/web/http/negotiation_tests.rs create mode 100644 src/web/http/websocket/driver/io.rs create mode 100644 src/web/manager/learning.rs create mode 100644 src/web/manager/negotiation.rs create mode 100644 src/web/manager/session_creation.rs create mode 100644 src/web/session/lifecycle.rs diff --git a/src/api/web_status.rs b/src/api/web_status.rs index 41ef777..0914a97 100644 --- a/src/api/web_status.rs +++ b/src/api/web_status.rs @@ -18,7 +18,7 @@ mod details; // Query parsing and matching remain independent from bounded HTML rendering. mod query; -use details::{push_body, push_frames, push_headers}; +use details::{push_body, push_frames, push_headers, push_lifecycle}; use query::{GroupBy, StatusQuery, client_ip, parse_query, record_matches}; struct GroupSummary { @@ -56,11 +56,11 @@ pub(super) async fn render( ); }; let now_millis = crate::web::trace::store_epoch_millis(); - let since_millis = query - .record - .is_none() - .then(|| now_millis.saturating_sub(query.window_secs.saturating_mul(1000))) - .unwrap_or(0); + let since_millis = if query.record.is_none() { + now_millis.saturating_sub(query.window_secs.saturating_mul(1000)) + } else { + 0 + }; let records = store.snapshot_matching(|record| record_matches(record, &query, since_millis)); let status = store.status(); let mut html = String::with_capacity(MAX_PAGE_BYTES); @@ -367,20 +367,7 @@ fn push_record(html: &mut String, record: &TraceRecord) { push_body(html, "message body", message.body.as_ref()); push_frames(html, &message.frames); } - TraceRecordKind::Lifecycle(event) => { - html.push_str("
event: ");
-            html.push_str(event.event.as_str());
-            html.push_str("\nstream: ");
-            html.push_str(
-                &event
-                    .stream_id
-                    .map(|v| v.to_string())
-                    .unwrap_or_else(|| "-".to_string()),
-            );
-            html.push_str("\nreason: ");
-            html.push_str(event.reason.unwrap_or("-"));
-            html.push_str("
"); - } + TraceRecordKind::Lifecycle(event) => push_lifecycle(html, event), } html.push_str(""); } diff --git a/src/api/web_status/details.rs b/src/api/web_status/details.rs index fd21217..e48775b 100644 --- a/src/api/web_status/details.rs +++ b/src/api/web_status/details.rs @@ -84,3 +84,37 @@ pub(super) fn push_body( } html.push_str(""); } + +pub(super) fn push_lifecycle( + html: &mut String, + event: &crate::web::trace::TraceLifecycleRecord, +) { + html.push_str("
event: ");
+    html.push_str(event.event.as_str());
+    html.push_str("\nstream: ");
+    html.push_str(
+        &event
+            .stream_id
+            .map(|value| value.to_string())
+            .unwrap_or_else(|| "-".to_string()),
+    );
+    html.push_str("\nreason: ");
+    html.push_str(event.reason.unwrap_or("-"));
+    if let Some(carrier) = &event.carrier {
+        html.push_str("\nclient class: ");
+        html.push_str(carrier.client_class);
+        html.push_str("\ncarrier: ");
+        html.push_str(carrier.carrier.as_str());
+        html.push_str("\nattempt: ");
+        html.push_str(&carrier.attempt.to_string());
+        html.push_str("\nscores: https=");
+        html.push_str(&carrier.scores[0].to_string());
+        html.push_str(" https-lanes=");
+        html.push_str(&carrier.scores[1].to_string());
+        html.push_str(" websocket=");
+        html.push_str(&carrier.scores[2].to_string());
+        html.push_str(" websocket-lanes=");
+        html.push_str(&carrier.scores[3].to_string());
+    }
+    html.push_str("
"); +} diff --git a/src/api/web_status/tests.rs b/src/api/web_status/tests.rs index f9dc268..b2480a3 100644 --- a/src/api/web_status/tests.rs +++ b/src/api/web_status/tests.rs @@ -20,11 +20,15 @@ fn html_escaping_covers_active_markup_characters() { #[tokio::test] async fn renderer_filters_groups_and_sets_control_plane_security_headers() { - let mut policy = WebDebugConfig::default(); - policy.enabled = true; - let mut limits = crate::config::WebLimitsConfig::default(); - limits.debug_records_capacity = 8; - limits.debug_bytes_global = 16 * 1024; + let policy = WebDebugConfig { + enabled: true, + ..Default::default() + }; + let limits = crate::config::WebLimitsConfig { + debug_records_capacity: 8, + debug_bytes_global: 16 * 1024, + ..Default::default() + }; let store = WebTraceStore::new(policy.clone(), &limits); store.record_lifecycle( None, @@ -79,7 +83,7 @@ async fn render_permits_remain_owned_by_inflight_response_bodies() { #[test] fn page_truncation_preserves_utf8_boundary_and_cap() { - let mut html = "я".repeat(MAX_PAGE_BYTES); + let mut html = "\u{044f}".repeat(MAX_PAGE_BYTES); truncate_page(&mut html); assert!(html.len() <= MAX_PAGE_BYTES); assert!(html.ends_with("[page output truncated]")); diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index a8f1401..10e3d9d 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -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 diff --git a/src/config/hot_reload/tests.rs b/src/config/hot_reload/tests.rs index 5cc8f33..8a5a99b 100644 --- a/src/config/hot_reload/tests.rs +++ b/src/config/hot_reload/tests.rs @@ -30,6 +30,23 @@ fn write_reload_config(path: &Path, ad_tag: Option<&str>, server_port: Option 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. diff --git a/src/config/hot_reload/watcher.rs b/src/config/hot_reload/watcher.rs index e2b3e60..838988d 100644 --- a/src/config/hot_reload/watcher.rs +++ b/src/config/hot_reload/watcher.rs @@ -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); diff --git a/src/config/load/runtime_web.rs b/src/config/load/runtime_web.rs index 5d56073..9f7eb65 100644 --- a/src/config/load/runtime_web.rs +++ b/src/config/load/runtime_web.rs @@ -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 diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs index 5c153a2..b1a31b9 100644 --- a/src/config/load/strict_keys.rs +++ b/src/config/load/strict_keys.rs @@ -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", diff --git a/src/config/load/validate_web.rs b/src/config/load/validate_web.rs index 20427aa..95ed829 100644 --- a/src/config/load/validate_web.rs +++ b/src/config/load/validate_web.rs @@ -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 { diff --git a/src/config/load/validate_web/memory.rs b/src/config/load/validate_web/memory.rs index 3fb9666..d8cf814 100644 --- a/src/config/load/validate_web/memory.rs +++ b/src/config/load/validate_web/memory.rs @@ -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 diff --git a/src/config/load/validate_web/negotiation.rs b/src/config/load/validate_web/negotiation.rs new file mode 100644 index 0000000..ec2c894 --- /dev/null +++ b/src/config/load/validate_web/negotiation.rs @@ -0,0 +1,108 @@ +use std::collections::HashSet; + +use super::*; + +/// Validates bounded carrier selection and learning policy. +pub(super) fn validate(config: &WebConfig) -> Result> { + 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::("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()); + } +} diff --git a/src/config/load/validate_web/timeouts.rs b/src/config/load/validate_web/timeouts.rs new file mode 100644 index 0000000..2482b89 --- /dev/null +++ b/src/config/load/validate_web/timeouts.rs @@ -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(()) +} diff --git a/src/config/load/validate_web/websocket.rs b/src/config/load/validate_web/websocket.rs index 84180d1..4380f97 100644 --- a/src/config/load/validate_web/websocket.rs +++ b/src/config/load/validate_web/websocket.rs @@ -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 { diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs index 4390f92..d39049e 100644 --- a/src/config/tests/load_basic_tests/web_tests.rs +++ b/src/config/tests/load_basic_tests/web_tests.rs @@ -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] diff --git a/src/config/types.rs b/src/config/types.rs index 8c7f3f5..3575b04 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -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, diff --git a/src/config/types/web.rs b/src/config/types/web.rs index db32ec0..7d54f85 100644 --- a/src/config/types/web.rs +++ b/src/config/types/web.rs @@ -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>, } +impl WebConfig { + /// Returns the configured negotiation order with the fallback appended once. + pub(crate) fn carrier_candidates(&self) -> Vec { + 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); diff --git a/src/config/types/web/defaults.rs b/src/config/types/web/defaults.rs new file mode 100644 index 0000000..70c83ec --- /dev/null +++ b/src/config/types/web/defaults.rs @@ -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); diff --git a/src/config/types/web_carrier.rs b/src/config/types/web_carrier.rs new file mode 100644 index 0000000..f3749e2 --- /dev/null +++ b/src/config/types/web_carrier.rs @@ -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), +} + +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(&self, serializer: S) -> Result + 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(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Flag(bool), + List(Vec), + } + + 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)), + } + } +} diff --git a/src/web/bridge.rs b/src/web/bridge.rs index 67a1b57..76aff87 100644 --- a/src/web/bridge.rs +++ b/src/web/bridge.rs @@ -1,6 +1,5 @@ use base64::Engine as _; -use crate::config::WebCarrier; use crate::crypto::SecureRandom; /// Browser security policy for the transient Telegram Desktop bridge page. @@ -14,14 +13,17 @@ pub(crate) struct BridgePage { pub(crate) content_security_policy: String, } -/// Renders the selected HTTPS WEB carrier bridge with a fresh CSP nonce. +/// Renders the bounded WEB carrier-negotiation bridge with a fresh CSP nonce. +#[allow(clippy::too_many_arguments)] pub(crate) fn render( host: &str, bootstrap: &str, batch_limit: usize, queue_limit: usize, queue_items: usize, - carrier: WebCarrier, + negotiation_enabled: bool, + candidate_count: usize, + carrier_deadlines: [u64; 4], rng: &SecureRandom, ) -> BridgePage { let mut nonce = [0u8; 18]; @@ -34,7 +36,19 @@ pub(crate) fn render( .replace("__BATCH_LIMIT__", &batch_limit.to_string()) .replace("__QUEUE_LIMIT__", &queue_limit.to_string()) .replace("__QUEUE_ITEMS__", &queue_items.to_string()) - .replace("__CARRIER__", carrier.as_str()); + .replace( + "__NEGOTIATION_ENABLED__", + if negotiation_enabled { "true" } else { "false" }, + ) + .replace("__CANDIDATE_COUNT__", &candidate_count.to_string()) + .replace( + "__CARRIER_DEADLINES__", + &carrier_deadlines + .iter() + .map(u64::to_string) + .collect::>() + .join(","), + ); BridgePage { body, content_security_policy: format!( @@ -54,13 +68,18 @@ const DOCUMENT: &str = r##"