From 9738244285fc8d0481ef91ed97b9787c4fc608b2 Mon Sep 17 00:00:00 2001 From: Alexey <247128645+axkurcom@users.noreply.github.com> Date: Sat, 17 Oct 2026 23:42:26 +0300 Subject: [PATCH] WEB: bounded opt-in wildcard host routing added --- src/api/config_edit/host_list_tests.rs | 4 + src/api/config_edit/wildcard_tests.rs | 84 ++++++ src/config/hot_reload.rs | 2 +- src/config/hot_reload/diff.rs | 4 + src/config/hot_reload/fields.rs | 2 + src/config/hot_reload/host_list_tests.rs | 4 + src/config/hot_reload/wildcard_tests.rs | 61 ++++ src/config/load/decoy_dns.rs | 16 +- src/config/load/decoy_dns/tests.rs | 29 ++ src/config/load/runtime_web.rs | 126 +------- src/config/load/runtime_web/build.rs | 183 ++++++++++++ src/config/load/runtime_web/wildcard.rs | 49 ++++ src/config/load/strict_keys.rs | 11 + src/config/load/strict_keys/check.rs | 8 + src/config/load/validate_web.rs | 6 +- src/config/load/validate_web/memory.rs | 62 +++- src/config/load/validate_web/vhosts.rs | 47 ++- .../web_tests/host_list_tests.rs | 4 + .../web_tests/wildcard_tests.rs | 171 +++++++++++ src/config/types.rs | 4 +- src/config/types/web.rs | 24 +- src/config/types/web/runtime.rs | 44 ++- src/config/types/web/wildcard.rs | 32 +++ src/maestro/orchestrator.rs | 6 +- src/maestro/runtime_build.rs | 4 + src/web/http.rs | 64 ++++- src/web/http/base_path_tests.rs | 4 + src/web/http/base_path_tests/credentials.rs | 5 + src/web/http/base_path_tests/routing.rs | 4 + src/web/http/diagnostic.rs | 5 +- src/web/http/down.rs | 3 +- src/web/http/host_list_tests.rs | 4 + src/web/http/host_list_tests/wildcard.rs | 269 ++++++++++++++++++ .../host_list_tests/wildcard/benchmarks.rs | 116 ++++++++ .../http/host_list_tests/wildcard/decoy.rs | 91 ++++++ .../http/host_list_tests/wildcard/reload.rs | 175 ++++++++++++ .../host_list_tests/wildcard/websocket.rs | 57 ++++ src/web/http/recovery.rs | 6 +- src/web/http/request.rs | 4 +- src/web/http/request/identity.rs | 43 +++ src/web/http/secrets.rs | 56 ++-- src/web/http/session.rs | 15 +- src/web/http/tests.rs | 6 + src/web/http/up.rs | 3 +- src/web/http/websocket.rs | 3 +- src/web/http/wildcard.rs | 72 +++++ src/web/manager.rs | 14 +- src/web/manager/credentials.rs | 24 +- src/web/manager/state.rs | 29 +- src/web/manager/state/expiry/fixtures.rs | 1 + .../manager/state/expiry/production_tests.rs | 3 + src/web/manager/wildcard.rs | 257 +++++++++++++++++ src/web/manager/wildcard/tests.rs | 197 +++++++++++++ src/web/session/backend_tests.rs | 4 + src/web/session/conveyor/tests.rs | 1 + src/web/session/downlink_tests.rs | 1 + src/web/session/lanes/tests.rs | 1 + src/web/session/negotiation/tests.rs | 1 + src/web/session/stream_io/tests.rs | 1 + src/web/session/uplink_tests.rs | 1 + src/web/session/websocket/tests.rs | 4 + src/web/trace/exchange.rs | 77 +++-- src/web/trace/mod.rs | 4 + src/web/trace/sanitize.rs | 17 +- src/web/trace/store.rs | 15 +- src/web/trace/store/websocket.rs | 29 +- src/web/trace/wildcard.rs | 138 +++++++++ 67 files changed, 2572 insertions(+), 239 deletions(-) create mode 100644 src/api/config_edit/wildcard_tests.rs create mode 100644 src/config/hot_reload/wildcard_tests.rs create mode 100644 src/config/load/runtime_web/build.rs create mode 100644 src/config/load/runtime_web/wildcard.rs create mode 100644 src/config/tests/load_basic_tests/web_tests/wildcard_tests.rs create mode 100644 src/config/types/web/wildcard.rs create mode 100644 src/web/http/host_list_tests/wildcard.rs create mode 100644 src/web/http/host_list_tests/wildcard/benchmarks.rs create mode 100644 src/web/http/host_list_tests/wildcard/decoy.rs create mode 100644 src/web/http/host_list_tests/wildcard/reload.rs create mode 100644 src/web/http/host_list_tests/wildcard/websocket.rs create mode 100644 src/web/http/wildcard.rs create mode 100644 src/web/manager/wildcard.rs create mode 100644 src/web/manager/wildcard/tests.rs create mode 100644 src/web/trace/wildcard.rs diff --git a/src/api/config_edit/host_list_tests.rs b/src/api/config_edit/host_list_tests.rs index 90c1ab3..1ac2d08 100644 --- a/src/api/config_edit/host_list_tests.rs +++ b/src/api/config_edit/host_list_tests.rs @@ -137,3 +137,7 @@ async fn grouped_hosts_cannot_bypass_frozen_process_limits() { } assert_eq!(active.web.runtime.as_ref().unwrap().vhosts.len(), 1); } + +// Effective process policy must reject patterns until the explicit restart has happened. +#[path = "wildcard_tests.rs"] +mod wildcard_tests; diff --git a/src/api/config_edit/wildcard_tests.rs b/src/api/config_edit/wildcard_tests.rs new file mode 100644 index 0000000..8855aa1 --- /dev/null +++ b/src/api/config_edit/wildcard_tests.rs @@ -0,0 +1,84 @@ +use super::*; + +#[tokio::test] +async fn wildcard_api_stages_restart_and_rejects_patterns_atomically_until_enabled() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, source()).unwrap(); + let active = ProxyConfig::load(&path).unwrap(); + let enable = serde_json::json!({"web": {"allow_wildcard_hosts": true, + "limits": {"memory_envelope_bytes": 1610612736u64}}}); + let mut prepared = prepare_patch_to_path(&path, &enable, None).await.unwrap(); + let resolved = + reconcile_runtime_effect(&mut prepared.response, &active, &prepared.desired_config) + .unwrap(); + assert!(prepared.response.process_restart_required); + assert!( + prepared + .response + .deferred_process_fields + .iter() + .any(|field| field == "web.allow_wildcard_hosts") + ); + assert!(!resolved.effective.web.allow_wildcard_hosts); + write_atomic_if_unchanged( + prepared.config_path, + prepared.expected_revision, + prepared.owner_path, + prepared.expected_owner_contents, + prepared.owner_contents, + ) + .await + .unwrap(); + let staged = ProxyConfig::load(&path).unwrap(); + assert!(staged.web.allow_wildcard_hosts); + let before = std::fs::read(&path).unwrap(); + let revision = read_managed_config(&path).await.unwrap().1; + let mut prepared = + prepare_patch_to_path(&path, &patch(&["*.example.com"]), Some(revision.clone())) + .await + .unwrap(); + let rejected = + reconcile_runtime_effect(&mut prepared.response, &active, &prepared.desired_config) + .err() + .unwrap(); + assert_eq!(rejected.status, hyper::StatusCode::BAD_REQUEST); + assert!(rejected.message.contains("allow_wildcard_hosts")); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(read_managed_config(&path).await.unwrap().1, revision); + // The same reviewed candidate becomes reloadable after the staged process restart. + let resolved = + reconcile_runtime_effect(&mut prepared.response, &staged, &prepared.desired_config) + .unwrap(); + assert!(resolved.runtime_changed); + assert!(!prepared.response.process_restart_required); + assert!( + resolved + .effective + .web + .runtime + .as_ref() + .unwrap() + .wildcards + .contains_key("example.com") + ); + write_atomic_if_unchanged( + prepared.config_path, + prepared.expected_revision, + prepared.owner_path, + prepared.expected_owner_contents, + prepared.owner_contents, + ) + .await + .unwrap(); + assert!( + ProxyConfig::load(&path) + .unwrap() + .web + .runtime + .as_ref() + .unwrap() + .vhosts + .is_empty() + ); +} diff --git a/src/config/hot_reload.rs b/src/config/hot_reload.rs index a43f4df..9b1268a 100644 --- a/src/config/hot_reload.rs +++ b/src/config/hot_reload.rs @@ -22,7 +22,7 @@ //! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** //! applied; a warning is emitted. SYN limiter rules are process-owned and are //! reconciled only during privileged startup. -//! `web.decoy_fasttrack_mode` is also restart-only so one process never mixes +//! `web.allow_wildcard_hosts, web.decoy_fasttrack_mode` is also restart-only so one process never mixes //! capability timing policies or process-lifetime counter semantics. //! Non-hot changes are never mixed into the runtime config snapshot. diff --git a/src/config/hot_reload/diff.rs b/src/config/hot_reload/diff.rs index 3e62595..cc107fe 100644 --- a/src/config/hot_reload/diff.rs +++ b/src/config/hot_reload/diff.rs @@ -85,6 +85,10 @@ pub(super) fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot warned = true; warn!("config reload: server listener settings changed; restart required"); } + if old.web.allow_wildcard_hosts != new.web.allow_wildcard_hosts { + warned = true; + warn!("config reload: web.allow_wildcard_hosts changed; restart required"); + } if old.web.decoy_fasttrack_mode != new.web.decoy_fasttrack_mode { warned = true; warn!("config reload: web.decoy_fasttrack_mode changed; restart required"); diff --git a/src/config/hot_reload/fields.rs b/src/config/hot_reload/fields.rs index a8b2ac9..450583d 100644 --- a/src/config/hot_reload/fields.rs +++ b/src/config/hot_reload/fields.rs @@ -342,10 +342,12 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each; cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode; cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; + let allow_wildcard_hosts = cfg.web.allow_wildcard_hosts; let process_limits = cfg.web.limits.clone(); let decoy_fasttrack_mode = cfg.web.decoy_fasttrack_mode; cfg.web = new.web.clone(); cfg.web.limits = process_limits; + cfg.web.allow_wildcard_hosts = allow_wildcard_hosts; cfg.web.decoy_fasttrack_mode = decoy_fasttrack_mode; if cfg.web.carrier_negotiation_enabled() && cfg.web.carrier_learning diff --git a/src/config/hot_reload/host_list_tests.rs b/src/config/hot_reload/host_list_tests.rs index ec7dd3d..1d9d510 100644 --- a/src/config/hot_reload/host_list_tests.rs +++ b/src/config/hot_reload/host_list_tests.rs @@ -66,3 +66,7 @@ secret_mode = "dd" } } } + +// The watcher must enforce the same effective opt-in as API reconciliation. +#[path = "wildcard_tests.rs"] +mod wildcard_tests; diff --git a/src/config/hot_reload/wildcard_tests.rs b/src/config/hot_reload/wildcard_tests.rs new file mode 100644 index 0000000..ea60922 --- /dev/null +++ b/src/config/hot_reload/wildcard_tests.rs @@ -0,0 +1,61 @@ +use super::*; + +#[test] +fn wildcard_watcher_freezes_enable_flag_and_publishes_patterns_only_after_restart() { + for enabled in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + let source = format!( + r#" +[access.users] +alice = "000102030405060708090a0b0c0d0e0f" +[web] +allow_wildcard_hosts = {enabled} +[web.limits] +memory_envelope_bytes = 1610612736 +[[web.vhosts]] +host = "a.example.com" +public_addr = "203.0.113.10:443" +[web.vhosts.decoy] +mode = "http_upstream" +upstream = "http://127.0.0.1:18081" +[[web.vhosts.profiles]] +user = "alice" +secret_mode = "plain" +"# + ); + std::fs::write(&path, &source).unwrap(); + let loaded = ProxyConfig::load_with_metadata(&path).unwrap(); + let initial = Arc::new(loaded.config); + let (config_tx, _config_rx) = watch::channel(initial.clone()); + let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone()); + let mut state = ReloadState::new(Some(loaded.rendered_hash)); + let desired = source + .replace( + "allow_wildcard_hosts = false", + "allow_wildcard_hosts = true", + ) + .replace("host = \"a.example.com\"", "host = \"*.example.com\""); + std::fs::write(&path, desired).unwrap(); + reload_config(&path, &config_tx, &log_tx, None, None, &mut state); + let active = config_tx.borrow().clone(); + assert_eq!(active.web.allow_wildcard_hosts, enabled); + if enabled { + assert!( + active + .web + .runtime + .as_ref() + .unwrap() + .wildcards + .contains_key("example.com") + ); + assert_ne!( + active.web.runtime.as_ref().unwrap().snapshot_id, + initial.web.runtime.as_ref().unwrap().snapshot_id + ); + } else { + assert!(Arc::ptr_eq(&initial, &active)); + } + } +} diff --git a/src/config/load/decoy_dns.rs b/src/config/load/decoy_dns.rs index 65869f3..1173e8f 100644 --- a/src/config/load/decoy_dns.rs +++ b/src/config/load/decoy_dns.rs @@ -185,12 +185,24 @@ pub(super) fn build_upstream( impl ProxyConfig { /// Compares selected endpoints separately from source-only configuration equality. pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool { - fn selected(config: &ProxyConfig) -> impl Iterator { + fn selected(config: &ProxyConfig) -> impl Iterator { config .web .runtime .iter() - .flat_map(|runtime| runtime.vhosts.iter()) + .flat_map(|runtime| { + runtime + .vhosts + .iter() + .map(|(host, vhost)| ((0, host.as_str()), vhost)) + .chain( + runtime + .wildcards + .iter() + .map(|(suffix, vhost)| ((1, suffix.as_str()), vhost)), + ) + .chain(runtime.catch_all.iter().map(|vhost| ((2, "*"), vhost))) + }) .filter_map(|(host, vhost)| match vhost.decoy.as_ref() { WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, *addr)), WebRuntimeDecoy::StaticDirectory(_) => None, diff --git a/src/config/load/decoy_dns/tests.rs b/src/config/load/decoy_dns/tests.rs index f9054fc..23e7445 100644 --- a/src/config/load/decoy_dns/tests.rs +++ b/src/config/load/decoy_dns/tests.rs @@ -357,3 +357,32 @@ async fn grouped_hosts_share_one_resolved_origin_and_endpoint_inventory() { reordered.rebuild_runtime_web().unwrap(); assert!(!loaded.config.web_decoy_endpoints_equal(&reordered)); } + +#[tokio::test] +async fn wildcard_dns_only_endpoint_changes_are_visible_without_source_changes() { + for selector in ["*.example.com", "*"] { + let text = SOURCE + .replace("[web]", "[web]\nallow_wildcard_hosts = true") + .replace( + "host = \"proxy.example.com\"", + &format!("host = \"{selector}\""), + ) + + "\n[web.limits]\nmemory_envelope_bytes = 1610612736\n"; + let mut snapshots = Vec::new(); + for addr in ["127.0.0.2:18081", "127.0.0.3:18081"] { + let mut parsed = source(&text); + prepare(&mut parsed.config, |_, _| { + std::future::ready(Ok(vec![addr.parse().unwrap()])) + }) + .await + .unwrap(); + snapshots.push(pipeline::finish(parsed).unwrap().config); + } + assert_eq!( + serde_json::to_value(&snapshots[0]).unwrap(), + serde_json::to_value(&snapshots[1]).unwrap() + ); + assert!(!snapshots[0].web_decoy_endpoints_equal(&snapshots[1])); + assert!(snapshots[0].web_decoy_endpoints_equal(&snapshots[0])); + } +} diff --git a/src/config/load/runtime_web.rs b/src/config/load/runtime_web.rs index a23c263..3d3d2a7 100644 --- a/src/config/load/runtime_web.rs +++ b/src/config/load/runtime_web.rs @@ -35,127 +35,11 @@ const WEB_CAPABILITY_CONTEXT_V2: &[u8] = b"tdesktop-web-proxy-bridge-v2\n"; const WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0"; const MAX_WEB_STATIC_DEPTH: usize = 64; -/// Builds the immutable WEB routing and decoy snapshot for one generation. -pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { - let auth = config.runtime_user_auth().ok_or_else(|| { - ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string()) - })?; - let mut runtime_vhosts = BTreeMap::new(); - let mut runtime_profiles = Vec::new(); - let mut runtime_capabilities = Vec::new(); - let mut static_files = 0usize; - let mut static_bytes = 0usize; - - let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into(); - for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() { - let decoy = Arc::new(build_decoy( - config, - vhost_idx, - vhost, - &config.web.limits, - &mut static_files, - &mut static_bytes, - )?); - let mut host_profiles: Vec<_> = vhost - .host - .iter() - .chain(vhost.hosts.iter().flatten()) - .map(|host| { - ( - host, - Vec::with_capacity(vhost.profiles.len()), - Vec::with_capacity(vhost.profiles.len()), - HashSet::with_capacity(vhost.profiles.len()), - ) - }) - .collect(); - for profile in &vhost.profiles { - let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| { - ProxyError::Config(format!( - "WEB profile references unknown access user `{}`", - profile.user - )) - })?; - let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| { - ProxyError::Config("WEB profile user snapshot is inconsistent".to_string()) - })?; - let (client_secret, client_secret_len) = - client_secret(auth_entry.secret, profile.secret_mode); - let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]); - let shared = Arc::new(WebRuntimeProfileShared { - public_addr: vhost.public_addr, - user: profile.user.clone(), - credential_id: auth_entry.credential_id, - secret_mode: profile.secret_mode, - carrier: config.web.carrier, - carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(), - carrier_learning: config.web.carrier_negotiation_enabled() - && config.web.carrier_learning, - carriers: Arc::clone(&carrier_candidates), - carrier_negotiation_deadlines_secs: config - .web - .timeouts - .carrier_negotiation_deadlines_secs, - key_fingerprint, - max_sessions: profile - .max_sessions - .unwrap_or(config.web.limits.max_sessions_global), - max_streams: profile - .max_streams - .unwrap_or(config.web.limits.max_streams_global), - max_streams_per_session: profile - .max_streams_per_session - .unwrap_or(config.web.limits.max_streams_per_session), - }); - // Only host-bound bindings and capability indexes expand with the alias count. - for (host, profiles, capability_table, capabilities) in &mut host_profiles { - let capability = derive_web_capability( - &client_secret[..client_secret_len], - host.as_bytes(), - vhost.base_path.as_bytes(), - )?; - if !capabilities.insert(capability) { - return Err(ProxyError::Config(format!( - "WEB vhost `{host}` contains profiles with the same client capability" - ))); - } - profiles.push(Arc::new(WebRuntimeProfile { - host: (*host).clone(), - capability, - shared: Arc::clone(&shared), - })); - capability_table.push(capability); - } - } - for (host, profiles, capability_table, _) in host_profiles { - runtime_capabilities.extend(capability_table.iter().copied()); - runtime_profiles.extend(profiles.iter().cloned()); - runtime_vhosts.insert( - host.clone(), - Arc::new(WebRuntimeVhost { - host: host.clone(), - base: if vhost.base_path.is_empty() { - "/".to_string() - } else { - format!("/{}/", vhost.base_path) - }, - decoy_fasttrack_mode: config.web.decoy_fasttrack_mode, - decoy: Arc::clone(&decoy), - decoy_header_secs: config.web.timeouts.decoy_header_secs, - profiles, - capabilities: capability_table.into_boxed_slice(), - }), - ); - } - } - - config.web.runtime = Some(Arc::new(WebRuntimeConfig { - vhosts: runtime_vhosts, - profiles: runtime_profiles, - capabilities: runtime_capabilities.into_boxed_slice(), - })); - Ok(()) -} +// Exact bindings and wildcard templates share one immutable snapshot builder. +mod build; +pub(super) use build::rebuild; +// Cold wildcard derivation uses the existing immutable authentication snapshot. +mod wildcard; fn debug_key_fingerprint(secret: &[u8]) -> String { let mut digest = Sha256::new(); diff --git a/src/config/load/runtime_web/build.rs b/src/config/load/runtime_web/build.rs new file mode 100644 index 0000000..94f82fa --- /dev/null +++ b/src/config/load/runtime_web/build.rs @@ -0,0 +1,183 @@ +use super::*; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_SNAPSHOT: AtomicU64 = AtomicU64::new(1); + +/// Builds the immutable WEB routing and decoy snapshot for one generation. +pub(in crate::config::load) fn rebuild(config: &mut ProxyConfig) -> Result<()> { + let auth = config.runtime_user_auth().ok_or_else(|| { + ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string()) + })?; + let snapshot_id = NEXT_SNAPSHOT + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .map_err(|_| ProxyError::Config("WEB snapshot identity exhausted".to_string()))?; + let mut wildcards = BTreeMap::new(); + let mut catch_all = None; + let mut runtime_vhosts = BTreeMap::new(); + let mut runtime_profiles = Vec::new(); + let mut runtime_capabilities = Vec::new(); + let mut static_files = 0usize; + let mut static_bytes = 0usize; + + let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into(); + for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() { + let decoy = Arc::new(build_decoy( + config, + vhost_idx, + vhost, + &config.web.limits, + &mut static_files, + &mut static_bytes, + )?); + let mut host_profiles: Vec<_> = vhost + .host + .iter() + .chain(vhost.hosts.iter().flatten()) + .filter(|host| !host.starts_with('*')) + .map(|host| { + ( + host, + Vec::with_capacity(vhost.profiles.len()), + Vec::with_capacity(vhost.profiles.len()), + HashSet::with_capacity(vhost.profiles.len()), + ) + }) + .collect(); + let has_wildcards = vhost + .host + .iter() + .chain(vhost.hosts.iter().flatten()) + .any(|host| host.starts_with('*')); + let mut shared_profiles = Vec::new(); + let mut contexts = HashSet::new(); + for profile in &vhost.profiles { + let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| { + ProxyError::Config(format!( + "WEB profile references unknown access user `{}`", + profile.user + )) + })?; + let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| { + ProxyError::Config("WEB profile user snapshot is inconsistent".to_string()) + })?; + let (client_secret, client_secret_len) = + client_secret(auth_entry.secret, profile.secret_mode); + let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]); + let shared = Arc::new(WebRuntimeProfileShared { + derivation_context: wildcard::derivation_context( + &client_secret[..client_secret_len], + vhost.base_path.as_bytes(), + ), + public_addr: vhost.public_addr, + user: profile.user.clone(), + credential_id: auth_entry.credential_id, + secret_mode: profile.secret_mode, + carrier: config.web.carrier, + carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(), + carrier_learning: config.web.carrier_negotiation_enabled() + && config.web.carrier_learning, + carriers: Arc::clone(&carrier_candidates), + carrier_negotiation_deadlines_secs: config + .web + .timeouts + .carrier_negotiation_deadlines_secs, + key_fingerprint, + max_sessions: profile + .max_sessions + .unwrap_or(config.web.limits.max_sessions_global), + max_streams: profile + .max_streams + .unwrap_or(config.web.limits.max_streams_global), + max_streams_per_session: profile + .max_streams_per_session + .unwrap_or(config.web.limits.max_streams_per_session), + }); + if has_wildcards { + if !contexts.insert(shared.derivation_context) { + return Err(ProxyError::Config( + "WEB wildcard group contains duplicate client capabilities".to_string(), + )); + } + shared_profiles.push(Arc::clone(&shared)); + } + // Only host-bound bindings and capability indexes expand with the alias count. + for (host, profiles, capability_table, capabilities) in &mut host_profiles { + let capability = derive_web_capability( + &client_secret[..client_secret_len], + host.as_bytes(), + vhost.base_path.as_bytes(), + )?; + if !capabilities.insert(capability) { + return Err(ProxyError::Config(format!( + "WEB vhost `{host}` contains profiles with the same client capability" + ))); + } + profiles.push(Arc::new(WebRuntimeProfile { + host: (*host).clone(), + capability, + shared: Arc::clone(&shared), + })); + capability_table.push(capability); + } + } + if has_wildcards { + let template = Arc::new(WebRuntimeVhost { + wildcard: Some(WebRuntimeWildcard { + group_id: vhost_idx, + profiles: shared_profiles, + }), + host: String::new(), + base: if vhost.base_path.is_empty() { + "/".to_string() + } else { + format!("/{}/", vhost.base_path) + }, + decoy_fasttrack_mode: config.web.decoy_fasttrack_mode, + decoy: Arc::clone(&decoy), + decoy_header_secs: config.web.timeouts.decoy_header_secs, + profiles: Vec::new(), + capabilities: Box::new([]), + }); + for selector in vhost.host.iter().chain(vhost.hosts.iter().flatten()) { + if selector == "*" { + catch_all = Some(Arc::clone(&template)); + } else if let Some(suffix) = selector.strip_prefix("*.") { + wildcards.insert(suffix.to_string(), Arc::clone(&template)); + } + } + } + for (host, profiles, capability_table, _) in host_profiles { + runtime_capabilities.extend(capability_table.iter().copied()); + runtime_profiles.extend(profiles.iter().cloned()); + runtime_vhosts.insert( + host.clone(), + Arc::new(WebRuntimeVhost { + wildcard: None, + host: host.clone(), + base: if vhost.base_path.is_empty() { + "/".to_string() + } else { + format!("/{}/", vhost.base_path) + }, + decoy_fasttrack_mode: config.web.decoy_fasttrack_mode, + decoy: Arc::clone(&decoy), + decoy_header_secs: config.web.timeouts.decoy_header_secs, + profiles, + capabilities: capability_table.into_boxed_slice(), + }), + ); + } + } + + config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id, + wildcards, + catch_all, + vhosts: runtime_vhosts, + profiles: runtime_profiles, + capabilities: runtime_capabilities.into_boxed_slice(), + })); + Ok(()) +} diff --git a/src/config/load/runtime_web/wildcard.rs b/src/config/load/runtime_web/wildcard.rs new file mode 100644 index 0000000..004b20b --- /dev/null +++ b/src/config/load/runtime_web/wildcard.rs @@ -0,0 +1,49 @@ +use super::*; + +// A length-delimited full digest fences secret, mode and endpoint changes without +// performing HMAC while a process session registry is locked. +pub(super) fn derivation_context(secret: &[u8], base: &[u8]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"telemt-web-derivation-context-v1\0"); + digest.update([if base.is_empty() { 1 } else { 2 }]); + digest.update((secret.len() as u64).to_be_bytes()); + digest.update(secret); + digest.update((base.len() as u64).to_be_bytes()); + digest.update(base); + digest.finalize().into() +} + +impl ProxyConfig { + /// Derives one complete bounded table after the process HMAC budget is reserved. + pub(crate) fn wildcard_capabilities( + &self, + vhost: &WebRuntimeVhost, + host: &str, + ) -> Result> { + let group = vhost + .wildcard + .as_ref() + .ok_or_else(|| ProxyError::Config("WEB wildcard template is missing".to_string()))?; + let auth = self.runtime_user_auth().ok_or_else(|| { + ProxyError::Config("WEB authentication snapshot is missing".to_string()) + })?; + let mut capabilities = Vec::with_capacity(group.profiles.len()); + for profile in &group.profiles { + let entry = auth + .user_id_by_name(&profile.user) + .and_then(|id| auth.entry_by_id(id)) + .ok_or_else(|| { + ProxyError::Config( + "WEB wildcard authentication snapshot is inconsistent".to_string(), + ) + })?; + let (secret, len) = client_secret(entry.secret, profile.secret_mode); + capabilities.push(derive_web_capability( + &secret[..len], + host.as_bytes(), + vhost.base.trim_matches('/').as_bytes(), + )?); + } + Ok(capabilities.into_boxed_slice()) + } +} diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs index 8d54baa..598620d 100644 --- a/src/config/load/strict_keys.rs +++ b/src/config/load/strict_keys.rs @@ -261,6 +261,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[ ]; const WEB_CONFIG_KEYS: &[&str] = &[ + "allow_wildcard_hosts", "enabled", "carrier", "carrier_method", @@ -277,6 +278,7 @@ const WEB_CONFIG_KEYS: &[&str] = &[ ]; const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[ + "wildcard", "max_header_bytes", "max_body_bytes", "max_frame_payload_bytes", @@ -535,3 +537,12 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<() Ok(()) } + +const WEB_WILDCARD_LIMITS_CONFIG_KEYS: &[&str] = &[ + "max_rules", + "max_profiles_per_request", + "cache_hosts", + "cache_bytes", + "cold_hmac_per_minute", + "cold_hmac_burst", +]; diff --git a/src/config/load/strict_keys/check.rs b/src/config/load/strict_keys/check.rs index 70f605e..1fc6357 100644 --- a/src/config/load/strict_keys/check.rs +++ b/src/config/load/strict_keys/check.rs @@ -37,6 +37,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> { LISTENER_CONFIG_KEYS, WEB_CONFIG_KEYS, WEB_LIMITS_CONFIG_KEYS, + WEB_WILDCARD_LIMITS_CONFIG_KEYS, WEB_DEBUG_CONFIG_KEYS, WEB_TIMEOUTS_CONFIG_KEYS, WEB_VHOST_CONFIG_KEYS, @@ -242,6 +243,13 @@ pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec Result<()> { )); } - validate_limits(&config.web.limits)?; + validate_limits(&config.web.limits, config.web.allow_wildcard_hosts)?; debug::validate(&config.web.debug, &config.web.limits)?; let carriers = negotiation::validate(&config.web)?; if carriers.contains(&WebCarrier::Https) && config.web.limits.max_http_handlers < 2 { @@ -182,7 +182,7 @@ fn validate_web_listener( Ok(()) } -fn validate_limits(limits: &WebLimitsConfig) -> Result<()> { +fn validate_limits(limits: &WebLimitsConfig, wildcard: bool) -> Result<()> { if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) { return config_error("web.limits.max_header_bytes must be within [8192, 65536]"); } @@ -412,7 +412,7 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> { "web.limits pending ceilings must preserve one uplink batch and downlink progress", ); } - memory::validate(limits)?; + memory::validate(limits, wildcard)?; Ok(()) } diff --git a/src/config/load/validate_web/memory.rs b/src/config/load/validate_web/memory.rs index 491e053..218803b 100644 --- a/src/config/load/validate_web/memory.rs +++ b/src/config/load/validate_web/memory.rs @@ -11,7 +11,7 @@ const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024; const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 64; /// Validates process-wide body, header, queue, static, and debug reservations. -pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { +pub(super) fn validate(limits: &WebLimitsConfig, wildcard: bool) -> Result<()> { if limits.max_carrier_learning_entries == 0 { return config_error("web.limits.max_carrier_learning_entries must be > 0"); } @@ -83,9 +83,15 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { .ok_or_else(|| { ProxyError::Config("web.limits lane state reservation overflowed usize".to_string()) })?; + let wildcard_reservation = if wildcard { + wildcard_reservation(limits)? + } else { + 0 + }; let reserved = limits .pending_bytes_global - .checked_add(limits.max_body_bytes_global) + .checked_add(wildcard_reservation) + .and_then(|value| value.checked_add(limits.max_body_bytes_global)) .and_then(|value| value.checked_add(limits.max_static_bytes)) .and_then(|value| value.checked_add(debug_ring_index)) .and_then(|value| value.checked_add(status_pages)) @@ -106,6 +112,52 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> { Ok(()) } +// Cache entries include their digest index and allocator overhead. Detached entry +// leases remain charged until the last borrower releases them. Handler scratch and +// authenticated host bindings have independent existing concurrency ceilings. +fn wildcard_reservation(limits: &WebLimitsConfig) -> Result { + let wildcard = &limits.wildcard; + if wildcard.max_rules == 0 + || wildcard.max_profiles_per_request == 0 + || wildcard.cache_bytes == 0 + || wildcard.cold_hmac_per_minute == 0 + || (wildcard.cold_hmac_burst as usize) < wildcard.max_profiles_per_request + { + return config_error( + "web.limits.wildcard requires positive bounds and a burst covering a complete profile table", + ); + } + let rules = wildcard + .max_rules + .checked_mul(1024) + .and_then(|value| { + limits + .max_profiles + .checked_mul(1024) + .and_then(|profiles| value.checked_add(profiles)) + }) + .ok_or_else(|| { + ProxyError::Config("WEB wildcard template reservations overflowed usize".to_string()) + })?; + wildcard + .max_profiles_per_request + .checked_mul(32) + .and_then(|value| value.checked_add(1024)) + .and_then(|value| value.checked_mul(limits.max_http_handlers)) + .and_then(|value| value.checked_add(wildcard.cache_bytes)) + .and_then(|value| value.checked_add(rules)) + .and_then(|value| { + limits + .max_bootstraps_global + .checked_add(limits.max_sessions_global) + .and_then(|bindings| bindings.checked_mul(1024)) + .and_then(|bindings| value.checked_add(bindings)) + }) + .ok_or_else(|| { + ProxyError::Config("WEB wildcard memory reservations overflowed usize".to_string()) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -113,13 +165,13 @@ mod tests { #[test] fn default_envelope_includes_bounded_lane_and_learning_metadata() { let limits = WebLimitsConfig::default(); - assert!(validate(&limits).is_ok()); + assert!(validate(&limits, false).is_ok()); let previous_envelope = WebLimitsConfig { memory_envelope_bytes: 768 * 1024 * 1024, ..limits }; - assert!(validate(&previous_envelope).is_err()); + assert!(validate(&previous_envelope, false).is_err()); } #[test] @@ -128,7 +180,7 @@ mod tests { max_profiles: usize::MAX, ..WebLimitsConfig::default() }; - let error = validate(&limits).unwrap_err().to_string(); + let error = validate(&limits, false).unwrap_err().to_string(); assert!(error.contains("web capability index reservation overflowed usize")); } } diff --git a/src/config/load/validate_web/vhosts.rs b/src/config/load/validate_web/vhosts.rs index 66f3078..319bd2c 100644 --- a/src/config/load/validate_web/vhosts.rs +++ b/src/config/load/validate_web/vhosts.rs @@ -1,25 +1,45 @@ use super::*; -/// Bounds expanded routing tables before normalizing exact hostname selectors. +/// Bounds exact bindings and wildcard rules before normalizing hostname selectors. pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> { let limits = &config.web.limits; let mut host_count = 0usize; + let mut rule_count = 0usize; let mut profile_count = 0usize; for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() { - let count = match (&vhost.host, &vhost.hosts) { - (Some(_), None) => 1, - (None, Some(hosts)) if !hosts.is_empty() => hosts.len(), + match (&vhost.host, &vhost.hosts) { + (Some(_), None) => {} + (None, Some(hosts)) if !hosts.is_empty() => {} _ => { return config_error(&format!( "web.vhosts[{vhost_idx}] requires exactly one of host or non-empty hosts" )); } - }; + } + let selectors = vhost.host.iter().chain(vhost.hosts.iter().flatten()); + let patterns = selectors + .clone() + .filter(|host| host.trim().starts_with('*')) + .count(); + let exact = selectors.count() - patterns; + rule_count = rule_count.checked_add(patterns).ok_or_else(|| { + ProxyError::Config("WEB wildcard rule count overflowed usize".to_string()) + })?; + if patterns > 0 + && (!config.web.allow_wildcard_hosts + || rule_count > limits.wildcard.max_rules + || vhost.profiles.len() > limits.wildcard.max_profiles_per_request) + { + return config_error(&format!( + "web.vhosts[{vhost_idx}] wildcard selectors require allow_wildcard_hosts and must fit web.limits.wildcard bounds" + )); + } host_count = host_count - .checked_add(count) + .checked_add(exact) .ok_or_else(|| ProxyError::Config("WEB hostname count overflowed usize".to_string()))?; - profile_count = count - .checked_mul(vhost.profiles.len()) + profile_count = exact + .checked_add(usize::from(patterns > 0)) + .and_then(|count| count.checked_mul(vhost.profiles.len())) .and_then(|expanded| profile_count.checked_add(expanded)) .ok_or_else(|| ProxyError::Config("WEB profile count overflowed usize".to_string()))?; if host_count > limits.max_vhosts { @@ -43,7 +63,16 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> { } else { format!("web.vhosts[{vhost_idx}].hosts[{host_idx}]") }; - *host = normalize_web_host(host, &field)?; + *host = if config.web.allow_wildcard_hosts && host.trim() == "*" { + "*".to_string() + } else if config.web.allow_wildcard_hosts + && let Some(suffix) = host.trim().strip_prefix("*.") + { + let normalized = normalize_web_host(&format!("x.{suffix}"), &field)?; + format!("*.{}", &normalized[2..]) + } else { + normalize_web_host(host, &field)? + }; if !hosts.insert(host.clone()) { return config_error(&format!("duplicate WEB vhost host `{host}` at {field}")); } diff --git a/src/config/tests/load_basic_tests/web_tests/host_list_tests.rs b/src/config/tests/load_basic_tests/web_tests/host_list_tests.rs index df3f83c..864e86a 100644 --- a/src/config/tests/load_basic_tests/web_tests/host_list_tests.rs +++ b/src/config/tests/load_basic_tests/web_tests/host_list_tests.rs @@ -239,3 +239,7 @@ fn grouped_static_snapshot_is_loaded_and_charged_once() { std::fs::write(directory.path().join("index.html"), b"longer").unwrap(); assert!(load_config_error_from_temp_toml(&format!("{source}{limits}")).contains("max_static")); } + +// Wildcard admission remains opt-in and independently bounded. +#[path = "wildcard_tests.rs"] +mod wildcard_tests; diff --git a/src/config/tests/load_basic_tests/web_tests/wildcard_tests.rs b/src/config/tests/load_basic_tests/web_tests/wildcard_tests.rs new file mode 100644 index 0000000..edcf3af --- /dev/null +++ b/src/config/tests/load_basic_tests/web_tests/wildcard_tests.rs @@ -0,0 +1,171 @@ +use super::*; + +#[test] +fn wildcard_opt_in_accepts_shared_pattern_without_exact_expansion() { + let source = WEB_CONFIG + .replace("[web]", "[web]\nallow_wildcard_hosts = true") + .replace("Proxy.Example.COM", "*.Example.COM") + .replace( + "[[web.vhosts]]", + "[web.limits]\nmemory_envelope_bytes = 1610612736\n[[web.vhosts]]", + ); + let config = load_config_from_temp_toml(&source); + assert_eq!(config.web.vhosts[0].host.as_deref(), Some("*.example.com")); + let runtime = config.web.runtime.as_ref().unwrap(); + assert!(runtime.vhosts.is_empty()); + assert!(runtime.profiles.is_empty()); + assert!(runtime.capabilities.is_empty()); +} + +fn wildcard_source(selector: &str) -> String { + WEB_CONFIG + .replace("[web]", "[web]\nallow_wildcard_hosts = true") + .replace("host = \"Proxy.Example.COM\"", selector) + .replace( + "[[web.vhosts]]", + "[web.limits]\nmemory_envelope_bytes = 1610612736\n[[web.vhosts]]", + ) +} + +#[test] +fn wildcard_selector_bounds_precedence_and_shared_inventory() { + let source = wildcard_source( + "hosts = [\"*.Example.COM\", \"*.deep.example.com\", \"exact.example.com\", \"*\"]", + ); + let config = load_config_from_temp_toml(&source); + let runtime = config.web.runtime.as_ref().unwrap(); + assert_eq!(runtime.vhosts.len(), 1); + assert_eq!(runtime.profiles.len(), 1); + assert_eq!(runtime.wildcards.len(), 2); + assert!(std::sync::Arc::ptr_eq( + &runtime.wildcards["example.com"], + &runtime.wildcards["deep.example.com"] + )); + assert!( + runtime + .route("exact.example.com") + .unwrap() + .wildcard + .is_none() + ); + assert!(std::sync::Arc::ptr_eq( + runtime.route("a.deep.example.com").unwrap(), + &runtime.wildcards["deep.example.com"] + )); + assert!(std::sync::Arc::ptr_eq( + runtime.route("example.com").unwrap(), + runtime.catch_all.as_ref().unwrap() + )); + let repeated = load_config_from_temp_toml(&source); + assert_ne!( + runtime.snapshot_id, + repeated.web.runtime.as_ref().unwrap().snapshot_id + ); + // Exact bindings plus one common wildcard profile set consume two slots. + let bounded = source.replace( + "memory_envelope_bytes = 1610612736", + "memory_envelope_bytes = 1610612736\nmax_vhosts = 1\nmax_profiles = 2", + ); + load_config_from_temp_toml(&bounded); + assert!( + load_config_error_from_temp_toml(&bounded.replace("max_profiles = 2", "max_profiles = 1")) + .contains("max_profiles") + ); +} + +#[test] +fn wildcard_configuration_rejects_ambiguous_selectors_and_resource_exhaustion() { + for selector in [ + "foo*.example.com", + "**.example.com", + "*.127.0.0.1", + "*.example.com.", + "*.example.com:443", + "*.*.example.com", + ] { + assert!( + !load_config_error_from_temp_toml(&wildcard_source(&format!("host = \"{selector}\""))) + .is_empty() + ); + } + assert!( + load_config_error_from_temp_toml(&wildcard_source( + "hosts = [\"*.EXAMPLE.com\", \"*.example.com\"]" + )) + .contains("duplicate") + ); + assert!( + load_config_error_from_temp_toml(&wildcard_source("host = \"*.example.com\"").replace( + "allow_wildcard_hosts = true", + "allow_wildcard_hosts = false" + )) + .contains("allow_wildcard_hosts") + ); + for limits in [ + "max_rules = 0", + "max_rules = 9223372036854775807", + "max_profiles_per_request = 0", + "cache_bytes = 0", + "cold_hmac_per_minute = 0", + "cold_hmac_burst = 255", + "max_profiles_per_request = 9223372036854775807", + ] { + let source = format!( + "{}\n[web.limits.wildcard]\n{limits}\n", + wildcard_source("host = \"*.example.com\"") + ); + assert!( + load_config_error_from_temp_toml(&source).contains("wildcard"), + "{limits}" + ); + } + let source = format!( + "[general]\nconfig_strict = true\n{}\n[web.limits.wildcard]\ncache_hosts = 0\n", + wildcard_source("host = \"*.example.com\"") + ); + load_config_from_temp_toml(&source); + assert!( + load_config_error_from_temp_toml(&source.replace("cache_hosts", "cache_hostz")) + .contains("cache_hostz") + ); +} + +#[test] +fn wildcard_context_matches_exact_protocol_and_fences_reload_changes() { + let source = wildcard_source("hosts = [\"proxy.example.com\", \"*.example.com\"]"); + let config = load_config_from_temp_toml(&source); + let runtime = config.web.runtime.as_ref().unwrap(); + let template = &runtime.wildcards["example.com"]; + assert_eq!( + config + .wildcard_capabilities(template, "proxy.example.com") + .unwrap()[0], + runtime.profiles[0].capability + ); + let context = template.wildcard.as_ref().unwrap().profiles[0].derivation_context; + for changed in [ + source.replace("secret_mode = \"dd\"", "secret_mode = \"plain\""), + source.replace("public_addr =", "base_path = \"relay\"\npublic_addr ="), + source.replace( + "000102030405060708090a0b0c0d0e0f", + "100102030405060708090a0b0c0d0e0f", + ), + ] { + let changed = load_config_from_temp_toml(&changed); + let changed_context = changed.web.runtime.as_ref().unwrap().wildcards["example.com"] + .wildcard + .as_ref() + .unwrap() + .profiles[0] + .derivation_context; + assert_ne!(context, changed_context); + } + let changed = + load_config_from_temp_toml(&source.replace("max_sessions = 4", "max_sessions = 5")); + assert_eq!( + context, + changed.web.runtime.as_ref().unwrap().profiles[0] + .shared + .derivation_context + ); +} diff --git a/src/config/types.rs b/src/config/types.rs index 29ebccc..88849fc 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -55,11 +55,11 @@ pub use server::{ pub use web::{ WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode, WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig, - WebSecretMode, WebTimeoutsConfig, WebVhostConfig, + WebSecretMode, WebTimeoutsConfig, WebVhostConfig, WebWildcardLimitsConfig, }; pub(crate) use web::{ WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, - WebRuntimeProfileShared, WebRuntimeVhost, WebStaticAsset, WebStaticSite, + WebRuntimeProfileShared, WebRuntimeVhost, WebRuntimeWildcard, WebStaticAsset, WebStaticSite, }; pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES; #[allow(unused_imports)] diff --git a/src/config/types/web.rs b/src/config/types/web.rs index 5815a46..cbc34c9 100644 --- a/src/config/types/web.rs +++ b/src/config/types/web.rs @@ -9,6 +9,10 @@ use serde::{Deserialize, Serialize}; use super::web_carrier::{WebCarrier, WebCarrierMethod, WebCarriers}; use super::web_debug::WebDebugConfig; +// Wildcard policy is restart-frozen independently of reloadable routing rules. +mod wildcard; +pub use wildcard::WebWildcardLimitsConfig; + // Serialized WEB defaults remain separate from the runtime data model. mod defaults; use defaults::*; @@ -73,13 +77,13 @@ pub enum WebDecoyConfig { }, } -/// Exact WEB hostnames sharing one immutable decoy and explicit profile policy. +/// WEB hostname selectors sharing one immutable decoy and explicit profile policy. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WebVhostConfig { - /// Legacy single hostname selector, mutually exclusive with `hosts`. + /// Single exact or opted-in wildcard selector, mutually exclusive with `hosts`. #[serde(default, skip_serializing_if = "Option::is_none")] pub host: Option, - /// Non-empty exact hostname list, normalized to lowercase ACE during validation. + /// Non-empty selector list, normalized to lowercase ACE during validation. #[serde(default, skip_serializing_if = "Option::is_none")] pub hosts: Option>, /// Optional canonical WEB endpoint prefix without surrounding slashes. @@ -97,6 +101,9 @@ pub struct WebVhostConfig { /// Hard process and protocol limits for WEB ingress. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct WebLimitsConfig { + /// Bounds automatic wildcard derivation and positive capability retention. + #[serde(default)] + pub wildcard: WebWildcardLimitsConfig, /// Maximum bytes accepted while parsing one HTTP request head. #[serde(default = "default_web_max_header_bytes")] pub max_header_bytes: usize, @@ -199,7 +206,7 @@ pub struct WebLimitsConfig { /// Maximum concrete hostname count across all WEB groups. #[serde(default = "default_web_max_vhosts")] pub max_vhosts: usize, - /// Maximum expanded hostname/profile binding count across all WEB groups. + /// Maximum exact bindings plus one shared profile set per wildcard group. #[serde(default = "default_web_max_profiles")] pub max_profiles: usize, /// Maximum static snapshot entry count, charged once per source group. @@ -243,6 +250,7 @@ pub struct WebLimitsConfig { impl Default for WebLimitsConfig { fn default() -> Self { Self { + wildcard: WebWildcardLimitsConfig::default(), max_header_bytes: default_web_max_header_bytes(), max_body_bytes: default_web_max_body_bytes(), max_frame_payload_bytes: default_web_max_frame_payload_bytes(), @@ -422,6 +430,9 @@ pub enum WebCarrierNegotiationAggressiveness { /// WEB ingress, carrier, fallback, and lifecycle configuration. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WebConfig { + /// Restart-only opt-in to automatic suffix and catch-all hostname selectors. + #[serde(default)] + pub allow_wildcard_hosts: bool, /// Enables issuance of new WEB bridge and session credentials. #[serde(default)] pub enabled: bool, @@ -458,7 +469,7 @@ pub struct WebConfig { /// WEB lifecycle deadlines. #[serde(default)] pub timeouts: WebTimeoutsConfig, - /// Exact hostname groups served by WEB listeners. + /// Exact hostname and opted-in wildcard groups served by WEB listeners. #[serde(default)] pub vhosts: Vec, /// Validated immutable runtime snapshot built during configuration loading. @@ -494,6 +505,7 @@ impl Default for WebConfig { fn default() -> Self { Self { enabled: false, + allow_wildcard_hosts: false, carrier: WebCarrier::default(), carrier_method: WebCarrierMethod::default(), conveyor: default_web_conveyor(), @@ -516,5 +528,5 @@ impl Default for WebConfig { mod runtime; pub(crate) use runtime::{ WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeProfileShared, WebRuntimeVhost, - WebStaticAsset, WebStaticSite, + WebRuntimeWildcard, WebStaticAsset, WebStaticSite, }; diff --git a/src/config/types/web/runtime.rs b/src/config/types/web/runtime.rs index 1eb9014..b6e7071 100644 --- a/src/config/types/web/runtime.rs +++ b/src/config/types/web/runtime.rs @@ -1,20 +1,28 @@ use super::*; /// Precomputed WEB configuration consumed by listener hot paths. -#[derive(Debug)] +#[derive(Debug, Default)] pub(crate) struct WebRuntimeConfig { + /// Process-unique immutable WEB snapshot identity, including same-generation reloads. + pub(crate) snapshot_id: u64, + /// Suffix index without the leading wildcard and dot. + pub(crate) wildcards: BTreeMap>, + /// Lowest-priority whole-group fallback. + pub(crate) catch_all: Option>, /// Canonical host lookup used by HTTP request routing. pub(crate) vhosts: BTreeMap>, /// Flat profile inventory used by startup link emission. pub(crate) profiles: Vec>, - /// Complete active capability table used to contain misplaced credentials. + /// Finite exact-host capability table used to contain misplaced credentials. pub(crate) capabilities: Box<[[u8; 32]]>, } /// Precomputed immutable virtual-host data. #[derive(Debug)] pub(crate) struct WebRuntimeVhost { - /// Canonical lowercase ACE hostname. + /// Shared automatic-host policy; exact hosts keep their precomputed bindings. + pub(crate) wildcard: Option, + /// Canonical lowercase ACE hostname; empty for a wildcard template. pub(crate) host: String, /// Exact slash-delimited endpoint base, including the trailing slash. pub(crate) base: String, @@ -24,13 +32,13 @@ pub(crate) struct WebRuntimeVhost { pub(crate) decoy: Arc, /// Upstream connect and response-head deadline. pub(crate) decoy_header_secs: u64, - /// Exact capability profiles accepted by this host. + /// Precomputed exact-host profiles; wildcard policies live in `wildcard`. pub(crate) profiles: Vec>, /// Contiguous capability table aligned one-to-one with `profiles`. pub(crate) capabilities: Box<[[u8; 32]]>, } -/// Precomputed exact-user capability entry. +/// Authenticated exact-host profile binding. #[derive(Debug)] pub(crate) struct WebRuntimeProfile { /// Canonical host that owns this profile. @@ -45,6 +53,8 @@ pub(crate) struct WebRuntimeProfile { /// Limits still apply to each binding's exact host/user/mode quota owner. #[derive(Debug)] pub(crate) struct WebRuntimeProfileShared { + /// Full domain-separated secret, protocol-version, and endpoint identity. + pub(crate) derivation_context: [u8; 32], /// Stable public destination tuple supplied to relay routing. pub(crate) public_addr: SocketAddr, /// Exact access user authenticated by logical streams. @@ -106,3 +116,27 @@ pub(crate) struct WebStaticAsset { /// Strong SHA-256 entity tag. pub(crate) etag: String, } + +/// Host-independent wildcard group, never a session or quota owner. +#[derive(Debug)] +pub(crate) struct WebRuntimeWildcard { + /// Source-group identity local to the immutable WEB snapshot. + pub(crate) group_id: usize, + /// Ordered shared policies aligned with cold capability derivation. + pub(crate) profiles: Vec>, +} + +impl WebRuntimeConfig { + /// Selects exactly one group: exact, longest suffix, then catch-all. + pub(crate) fn route(&self, host: &str) -> Option<&Arc> { + if let Some(vhost) = self.vhosts.get(host) { + return Some(vhost); + } + for (offset, _) in host.match_indices('.') { + if let Some(vhost) = self.wildcards.get(&host[offset + 1..]) { + return Some(vhost); + } + } + self.catch_all.as_ref() + } +} diff --git a/src/config/types/web/wildcard.rs b/src/config/types/web/wildcard.rs new file mode 100644 index 0000000..6055614 --- /dev/null +++ b/src/config/types/web/wildcard.rs @@ -0,0 +1,32 @@ +use serde::{Deserialize, Serialize}; + +/// Process-frozen bounds for automatic wildcard hostname handling. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct WebWildcardLimitsConfig { + /// Maximum suffix and catch-all selectors across all source groups. + pub max_rules: usize, + /// Maximum complete profile scan and cold derivation cost for one request. + pub max_profiles_per_request: usize, + /// Maximum positively authenticated hosts retained; zero disables retention. + pub cache_hosts: usize, + /// Maximum charged bytes, including entries still borrowed after eviction. + pub cache_bytes: usize, + /// Sustained process-wide HMAC evaluation budget per minute. + pub cold_hmac_per_minute: u32, + /// Atomic cold-derivation burst measured in HMAC evaluations. + pub cold_hmac_burst: u32, +} + +impl Default for WebWildcardLimitsConfig { + fn default() -> Self { + Self { + max_rules: 32, + max_profiles_per_request: 256, + cache_hosts: 256, + cache_bytes: 8_388_608, + cold_hmac_per_minute: 1_000_000, + cold_hmac_burst: 65_536, + } + } +} diff --git a/src/maestro/orchestrator.rs b/src/maestro/orchestrator.rs index 9aa61ba..aa80973 100644 --- a/src/maestro/orchestrator.rs +++ b/src/maestro/orchestrator.rs @@ -143,7 +143,11 @@ pub(super) async fn run_telemt_core( config.server.max_connections as usize }; let max_connections = Arc::new(Semaphore::new(max_connections_limit)); - let web_trace = WebTraceStore::new(config.web.debug.clone(), &config.web.limits); + let web_trace = WebTraceStore::new_with_wildcards( + config.web.debug.clone(), + &config.web.limits, + config.web.allow_wildcard_hosts, + ); let web_runtime_control = WebRuntimeControl::new(); let (detected_ips_tx, detected_ips_rx) = watch::channel((None::, None::)); diff --git a/src/maestro/runtime_build.rs b/src/maestro/runtime_build.rs index 19bb0b6..0ab9240 100644 --- a/src/maestro/runtime_build.rs +++ b/src/maestro/runtime_build.rs @@ -438,6 +438,10 @@ pub(crate) fn resolve_reload_config( fields.push("web.limits".to_string()); effective.web.limits = old.web.limits.clone(); } + if old.web.allow_wildcard_hosts != desired.web.allow_wildcard_hosts { + fields.push("web.allow_wildcard_hosts".to_string()); + effective.web.allow_wildcard_hosts = old.web.allow_wildcard_hosts; + } if old.web.decoy_fasttrack_mode != desired.web.decoy_fasttrack_mode { fields.push("web.decoy_fasttrack_mode".to_string()); effective.web.decoy_fasttrack_mode = old.web.decoy_fasttrack_mode; diff --git a/src/web/http.rs b/src/web/http.rs index 1b81fb6..c8e726c 100644 --- a/src/web/http.rs +++ b/src/web/http.rs @@ -28,6 +28,8 @@ mod activity; mod body; // Canonical capability parsing and complete scans remain isolated from HTTP routing. mod capability; +// Lazy wildcard derivation shares one table between containment and bridge authentication. +mod wildcard; // Authentic credential containment stays independent from carrier routing. mod secrets; // Decoy routing and upstream proxying are isolated from carrier authentication. @@ -184,13 +186,30 @@ async fn handle_request( let Some(web_runtime) = config.web.runtime.as_ref() else { return generic_not_found(); }; - let Some(host) = canonical_request_host(&request) else { + let mut host_storage = [0u8; 253]; + // Keep the header owner independent from the moved request on the legacy path. + let host_header = request.headers().get(header::HOST).cloned(); + let host = if config.web.allow_wildcard_hosts { + let Some(host) = request::wildcard_request_host(&request, &mut host_storage) else { + return generic_not_found(); + }; + host + } else { + if canonical_request_host(&request).is_none() { + return generic_not_found(); + } + let Some(value) = host_header.as_ref().and_then(|value| value.to_str().ok()) else { + return generic_not_found(); + }; + value.strip_suffix(":443").unwrap_or(value) + }; + let Some(vhost) = web_runtime.route(host).cloned() else { return generic_not_found(); }; - let Some(vhost) = web_runtime.vhosts.get(host).cloned() else { - return generic_not_found(); + let table = match wildcard::prepare(&mut request, &config, &vhost, host, &runtime) { + Ok(table) => table, + Err(()) => return service_unavailable(), }; - secrets::mark_internal_credential(&mut request, web_runtime, &runtime); let suffix = request.uri().path().strip_prefix(&vhost.base); if suffix == Some(WEBSOCKET_SUFFIX) { return websocket::handle( @@ -200,6 +219,7 @@ async fn handle_request( trusted_proxy_cidrs, runtime, vhost, + host, ) .await; } @@ -211,6 +231,7 @@ async fn handle_request( trusted_proxy_cidrs, runtime, vhost, + host, ) .await; } @@ -223,6 +244,9 @@ async fn handle_request( runtime, generation, vhost, + host, + table, + web_runtime.snapshot_id, ) .await; } @@ -241,6 +265,9 @@ async fn handle_root( runtime: Arc, generation: Arc, vhost: Arc, + host: &str, + table: Option, + snapshot_id: u64, ) -> HttpResponse { let representation = recovery::classify(&request); if matches!(representation, recovery::RootRepresentation::Invalid) { @@ -279,7 +306,8 @@ async fn handle_root( .record_decoy_fasttrack(WebDecoyFastTrackDisposition::EnforceCandidateFullScan); } } - let matched_profile = match_profile(&vhost, candidate.scan_bytes()); + let matched_profile = + wildcard::match_profile(&vhost, host, table.as_ref(), candidate.scan_bytes()); let recovery_requested = matches!(representation, recovery::RootRepresentation::Recovery(_)); let profile = matched_profile.filter(|_| canonical && request.method() == Method::GET); let Some(profile) = profile else { @@ -302,7 +330,7 @@ async fn handle_root( .and_then(|value| value.to_str().ok()); let recovery_session = match representation { recovery::RootRepresentation::Recovery(Some(hash)) => { - runtime.bridge_recovery_session(hash, &vhost.host, &profile) + runtime.bridge_recovery_session(hash, host, &profile) } _ => None, }; @@ -341,6 +369,17 @@ async fn handle_root( return serve_decoy(request, vhost, true, &runtime).await; } }; + if let (Some(cache), Some(table)) = (&runtime.wildcard, table) { + cache.publish(snapshot_id, &vhost, host, table, || { + runtime + .active_generation() + .config() + .web + .runtime + .as_ref() + .is_some_and(|current| current.snapshot_id == snapshot_id) + }); + } if let Some(trace) = request_trace(&request) { trace.bind_profile(&profile, bootstrap.trace_session_id); trace.register_redaction(bootstrap.token.as_bytes()); @@ -363,7 +402,7 @@ async fn handle_root( } let Some(response) = recovery::response( &bootstrap, - &vhost, + host, &profile, &config.web.limits, &config.web.timeouts, @@ -374,7 +413,7 @@ async fn handle_root( return response; } let page = bridge::render( - &vhost.host, + host, &vhost.base, &bootstrap.token, config.web.limits.carrier_batch_bytes, @@ -435,6 +474,7 @@ async fn handle_api( trusted_proxy_cidrs: &[IpNetwork], runtime: Arc, vhost: Arc, + host: &str, ) -> HttpResponse { if request.uri().query().is_some() || !compatible_cookie_header(&request) { return serve_decoy(request, vhost, true, &runtime).await; @@ -450,12 +490,12 @@ async fn handle_api( }; match request.uri().path().strip_prefix(&vhost.base) { Some("api/v1/session") => { - handle_session(request, runtime, vhost, token_hash, client_ip).await + handle_session(request, runtime, vhost, host, token_hash, client_ip).await } - Some("api/v1/up") => handle_up(request, runtime, vhost, token_hash).await, - Some("api/v1/down") => handle_down(request, runtime, vhost, token_hash).await, + Some("api/v1/up") => handle_up(request, runtime, vhost, host, token_hash).await, + Some("api/v1/down") => handle_down(request, runtime, vhost, host, token_hash).await, Some("api/v1/diagnostic") => { - diagnostic::handle(request, runtime, vhost, token_hash, client_ip).await + diagnostic::handle(request, runtime, vhost, host, token_hash, client_ip).await } _ => serve_decoy(request, vhost, true, &runtime).await, } diff --git a/src/web/http/base_path_tests.rs b/src/web/http/base_path_tests.rs index 5ddd609..c3cd31a 100644 --- a/src/web/http/base_path_tests.rs +++ b/src/web/http/base_path_tests.rs @@ -398,6 +398,7 @@ async fn prefixed_decoy_request_keeps_its_original_path_and_query() { let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/"); let profile = Arc::clone(&config.web.runtime.as_ref().unwrap().profiles[0]); let vhost = Arc::new(WebRuntimeVhost { + wildcard: None, host: "proxy.example.com".to_string(), base: "/relay/".to_string(), decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, @@ -412,6 +413,9 @@ async fn prefixed_decoy_request_keeps_its_original_path_and_query() { let mut vhosts = BTreeMap::new(); vhosts.insert("proxy.example.com".to_string(), vhost); config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id: 0, + wildcards: Default::default(), + catch_all: None, vhosts, profiles: vec![profile], capabilities: vec![capability].into_boxed_slice(), diff --git a/src/web/http/base_path_tests/credentials.rs b/src/web/http/base_path_tests/credentials.rs index f480bc6..d54e2c2 100644 --- a/src/web/http/base_path_tests/credentials.rs +++ b/src/web/http/base_path_tests/credentials.rs @@ -63,6 +63,7 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() { vhosts.insert( "proxy.example.com".to_string(), Arc::new(WebRuntimeVhost { + wildcard: None, host: "proxy.example.com".to_string(), base: "/relay/".to_string(), decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, @@ -78,6 +79,7 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() { vhosts.insert( "other.example.com".to_string(), Arc::new(WebRuntimeVhost { + wildcard: None, host: "other.example.com".to_string(), base: "/other/".to_string(), decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, @@ -91,6 +93,9 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() { }), ); config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id: 0, + wildcards: Default::default(), + catch_all: None, vhosts, profiles: vec![profile], capabilities: vec![capability].into_boxed_slice(), diff --git a/src/web/http/base_path_tests/routing.rs b/src/web/http/base_path_tests/routing.rs index d923b6f..958a57c 100644 --- a/src/web/http/base_path_tests/routing.rs +++ b/src/web/http/base_path_tests/routing.rs @@ -195,6 +195,7 @@ async fn decoy_forwarding_preserves_every_reference_request_target() { vhosts.insert( "proxy.example.com".to_string(), Arc::new(WebRuntimeVhost { + wildcard: None, host: previous.host.clone(), base: previous.base.clone(), decoy_fasttrack_mode: previous.decoy_fasttrack_mode, @@ -208,6 +209,9 @@ async fn decoy_forwarding_preserves_every_reference_request_target() { }), ); config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id: 0, + wildcards: Default::default(), + catch_all: None, vhosts, profiles: runtime_config.profiles.clone(), capabilities: runtime_config.capabilities.clone(), diff --git a/src/web/http/diagnostic.rs b/src/web/http/diagnostic.rs index 838c920..0963cad 100644 --- a/src/web/http/diagnostic.rs +++ b/src/web/http/diagnostic.rs @@ -19,6 +19,7 @@ pub(super) async fn handle( request: Request, runtime: Arc, vhost: Arc, + host: &str, token_hash: TokenHash, client_ip: IpAddr, ) -> HttpResponse { @@ -26,7 +27,7 @@ pub(super) async fn handle( return serve_decoy(request, vhost, true, &runtime).await; } let Some((trace_session_id, profile, body_timeout)) = - runtime.bootstrap_trace_identity(token_hash, &vhost.host) + runtime.bootstrap_trace_identity(token_hash, host) else { return serve_decoy(request, vhost, true, &runtime).await; }; @@ -56,7 +57,7 @@ pub(super) async fn handle( let Some(event) = parse_event(&body) else { return serve_decoy(request, vhost, true, &runtime).await; }; - match runtime.claim_bridge_diagnostic(token_hash, &vhost.host, event) { + match runtime.claim_bridge_diagnostic(token_hash, host, event) { Ok(first) => { if first { runtime.trace().record_profile_lifecycle( diff --git a/src/web/http/down.rs b/src/web/http/down.rs index d612a5a..1c0a41f 100644 --- a/src/web/http/down.rs +++ b/src/web/http/down.rs @@ -20,6 +20,7 @@ pub(super) async fn handle_down( request: Request, runtime: Arc, vhost: Arc, + host: &str, token_hash: TokenHash, ) -> HttpResponse { if !matches!(*request.method(), hyper::Method::POST | hyper::Method::PUT) @@ -30,7 +31,7 @@ pub(super) async fn handle_down( let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else { return serve_decoy(request, vhost, true, &runtime).await; }; - let Ok(session) = runtime.get_session(token_hash, &vhost.host) else { + let Ok(session) = runtime.get_session(token_hash, host) else { return serve_decoy(request, vhost, true, &runtime).await; }; if session.carrier().uses_websocket() { diff --git a/src/web/http/host_list_tests.rs b/src/web/http/host_list_tests.rs index 6cbb14c..4168133 100644 --- a/src/web/http/host_list_tests.rs +++ b/src/web/http/host_list_tests.rs @@ -348,3 +348,7 @@ async fn alias_recovery_cannot_retire_a_foreign_session() { assert!(runtime.get_session(token_hash(&b), HOSTS[1]).is_ok()); stop(runtime, &[generation]).await; } + +// Automatic hosts share policies but retain exact-host authentication and quotas. +#[path = "host_list_tests/wildcard.rs"] +mod wildcard; diff --git a/src/web/http/host_list_tests/wildcard.rs b/src/web/http/host_list_tests/wildcard.rs new file mode 100644 index 0000000..4620ebc --- /dev/null +++ b/src/web/http/host_list_tests/wildcard.rs @@ -0,0 +1,269 @@ +use super::*; +use std::time::Duration; + +fn wildcard_source(selector: &str, origin: std::net::SocketAddr) -> String { + source(selector, origin).replace("[web]", "[web]\nallow_wildcard_hosts = true") + + "\n[web.limits]\nmemory_envelope_bytes = 1610612736\n" +} + +fn capability(config: &ProxyConfig, host: &str) -> String { + // Independent reference derivation uses the client wire contract, not the cache. + use hmac::{Hmac, Mac}; + let mut mac = + Hmac::::new_from_slice(&hex::decode("000102030405060708090a0b0c0d0e0f").unwrap()) + .unwrap(); + mac.update(b"tdesktop-web-proxy-bridge-v2\n"); + mac.update(host.as_bytes()); + mac.update(b"\nrelay"); + let encoded = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + assert!(config.web.runtime.as_ref().unwrap().route(host).is_some()); + encoded +} + +async fn bridge(listener: &TcpListener, runtime: &Arc, host: &str) -> String { + let cap = capability(&runtime.active_generation().config(), host); + let response = request( + listener, + runtime, + raw_request("GET", &format!("/relay/?bridge={cap}"), host, "", &[]), + ) + .await; + assert!( + response.starts_with(b"HTTP/1.1 200"), + "{}", + String::from_utf8_lossy(&response) + ); + std::str::from_utf8(split_response(&response).1) + .unwrap() + .split_once("bootstrap=\"") + .unwrap() + .1 + .split_once('"') + .unwrap() + .0 + .to_string() +} + +#[tokio::test] +async fn wildcard_cold_containment_canonical_host_and_positive_cache_in_all_modes() { + for mode in ["off", "shadow", "enforce"] { + let site = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let text = wildcard_source( + "hosts = [\"*.example.com\", \"exact.example.com\"]", + site.local_addr().unwrap(), + ) + .replace( + "enabled = true", + &format!("enabled = true\ndecoy_fasttrack_mode = \"{mode}\""), + ); + let generation = generation(1, &text); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let host = "a.deep.example.com"; + let cap = capability(&generation.config(), host); + let encoded = cap + .bytes() + .map(|byte| format!("%{byte:02X}")) + .collect::(); + for (path, headers) in [ + (format!("/wrong/{cap}"), String::new()), + (format!("/wrong/{encoded}"), String::new()), + ("/wrong".to_string(), format!("Cookie: opaque={cap}\r\n")), + ("/relay/".to_string(), format!("User-Agent: {encoded}\r\n")), + ] { + assert_private( + &request( + &listener, + &runtime, + raw_request("GET", &path, host, &headers, &[]), + ) + .await, + ); + } + assert_eq!(runtime.wildcard.as_ref().unwrap().test_stats().0, 0); + let before = runtime.wildcard.as_ref().unwrap().test_stats().2; + let response = request( + &listener, + &runtime, + raw_request( + "GET", + &format!("/relay/?bridge={cap}"), + "A.DEEP.EXAMPLE.COM:443", + "", + &[], + ), + ) + .await; + assert!(response.starts_with(b"HTTP/1.1 200")); + let cache = runtime.wildcard.as_ref().unwrap(); + assert_eq!(cache.test_stats().0, 1); + assert_eq!(cache.test_stats().2, before + 1); + bridge(&listener, &runtime, host).await; + assert_eq!(cache.test_stats().2, before + 1); + for foreign in ["b.example.com", "exact.example.com"] { + assert_private( + &request( + &listener, + &runtime, + raw_request("GET", &format!("/wrong/{cap}"), foreign, "", &[]), + ) + .await, + ); + } + for invalid in [ + "example.com", + "127.0.0.1", + "a.example.com:0443", + "a.example.com:80", + "a..example.com", + "a.example.com.", + "a.example.com\r\nHost: b.example.com", + ] { + assert!( + request( + &listener, + &runtime, + raw_request("GET", "/relay/", invalid, "", &[]) + ) + .await + .starts_with(b"HTTP/1.1 404") + ); + } + assert!( + tokio::time::timeout(Duration::from_millis(20), site.accept()) + .await + .is_err() + ); + stop(runtime, &[generation]).await; + } +} + +#[tokio::test] +async fn wildcard_budget_exhaustion_does_not_break_warm_bootstraps_or_authenticated_carriers() { + let text = wildcard_source( + "host = \"*.example.com\"", + "127.0.0.1:18081".parse().unwrap(), + ) + "\n[web.limits.wildcard]\nmax_profiles_per_request = 1\ncold_hmac_burst = 1\ncold_hmac_per_minute = 1\n"; + let generation = generation(1, &text); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bootstrap = bridge(&listener, &runtime, HOSTS[0]).await; + let session = create(&runtime, HOSTS[0], &bootstrap).unwrap(); + let cap = capability(&generation.config(), HOSTS[1]); + assert!( + request( + &listener, + &runtime, + raw_request("GET", &format!("/relay/?bridge={cap}"), HOSTS[1], "", &[]) + ) + .await + .starts_with(b"HTTP/1.1 503") + ); + bridge(&listener, &runtime, HOSTS[0]).await; + let auth = format!( + "Authorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: 1\r\n" + ); + let response = request( + &listener, + &runtime, + raw_request( + "POST", + "/relay/api/v1/up", + HOSTS[0], + &auth, + &frame::encode(FrameType::Pong, 0, &[]), + ), + ) + .await; + assert!( + response.starts_with(b"HTTP/1.1 204"), + "{}", + String::from_utf8_lossy(&response) + ); + assert_private( + &request( + &listener, + &runtime, + raw_request( + "POST", + "/relay/api/v1/up", + HOSTS[1], + &auth, + &frame::encode(FrameType::Pong, 0, &[]), + ), + ) + .await, + ); + assert_eq!(runtime.wildcard.as_ref().unwrap().test_stats().2, 1); + stop(runtime, &[generation]).await; +} + +#[tokio::test] +async fn wildcard_cache_eviction_never_resets_host_quotas_and_recovery_uses_actual_host() { + let text = wildcard_source( + "host = \"*.example.com\"", + "127.0.0.1:18081".parse().unwrap(), + ) + "\n[web.limits.wildcard]\ncache_hosts = 1\n"; + let generation = generation(1, &text); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let a = create( + &runtime, + HOSTS[0], + &bridge(&listener, &runtime, HOSTS[0]).await, + ) + .unwrap(); + create( + &runtime, + HOSTS[1], + &bridge(&listener, &runtime, HOSTS[1]).await, + ) + .unwrap(); + assert_eq!(runtime.wildcard.as_ref().unwrap().test_stats().0, 1); + assert_eq!( + create( + &runtime, + HOSTS[0], + &bridge(&listener, &runtime, HOSTS[0]).await + ), + Err(ManagerError::Limit) + ); + let cap = capability(&generation.config(), HOSTS[0]); + let response = request( + &listener, + &runtime, + raw_request( + "GET", + &format!("/relay/?bridge={cap}"), + HOSTS[0], + &format!( + "Accept: application/vnd.telemt.web-recovery+json\r\nAuthorization: Bearer {a}\r\n" + ), + &[], + ), + ) + .await; + assert!( + response.starts_with(b"HTTP/1.1 200"), + "{}", + String::from_utf8_lossy(&response) + ); + let doc: serde_json::Value = serde_json::from_slice(split_response(&response).1).unwrap(); + assert!(doc["bootstrap"].as_str().is_some()); + stop(runtime, &[generation]).await; +} + +// Same-generation replacement and public decoy compatibility are independent regressions. +#[path = "wildcard/decoy.rs"] +mod decoy; +#[path = "wildcard/reload.rs"] +mod reload; + +// Matched loopback measurements are opt-in and never run in the normal suite. +#[path = "wildcard/benchmarks.rs"] +mod benchmarks; + +// Upgrade authentication must remain independent of cold-host admission pressure. +#[path = "wildcard/websocket.rs"] +mod websocket; diff --git a/src/web/http/host_list_tests/wildcard/benchmarks.rs b/src/web/http/host_list_tests/wildcard/benchmarks.rs new file mode 100644 index 0000000..a596a08 --- /dev/null +++ b/src/web/http/host_list_tests/wildcard/benchmarks.rs @@ -0,0 +1,116 @@ +use super::*; +use std::time::Instant; + +fn memory() -> (u64, u64) { + let text = std::fs::read_to_string("/proc/self/smaps_rollup").unwrap(); + let value = |name: &str| { + text.lines() + .find_map(|line| line.strip_prefix(name)) + .unwrap() + .split_whitespace() + .next() + .unwrap() + .parse::() + .unwrap() + }; + (value("Rss:"), value("Anonymous:")) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "manual bounded wildcard loopback and retained-memory measurement"] +async fn wildcard_loopback_probe() { + let profiles = std::env::var("TELEMT_WILDCARD_PROFILES") + .unwrap_or_else(|_| "128".to_string()) + .parse::() + .unwrap(); + let directory = tempfile::tempdir().unwrap(); + std::fs::write(directory.path().join("index.html"), "ordinary site").unwrap(); + let users = (1..profiles) + .map(|index| format!("u{index} = \"{index:032x}\"\n")) + .collect::(); + let policies = (1..profiles) + .map(|index| { + format!("[[web.vhosts.profiles]]\nuser = \"u{index}\"\nsecret_mode = \"plain\"\n") + }) + .collect::(); + let source = wildcard_source("host = \"*.example.com\"", "127.0.0.1:18081".parse().unwrap()) + .replace("[access.users]", &format!("[access.users]\n{users}")) + .replace("mode = \"http_upstream\"\nupstream = \"http://127.0.0.1:18081\"", + &format!("mode = \"static_directory\"\ndirectory = {:?}", directory.path())) + .replace("[web.limits]", &format!("{policies}[web.limits]\nmax_profiles = {profiles}\nmax_bootstraps_global = 1024\nmax_bootstraps_per_ip = 1024\nnew_bootstraps_burst = 1024\nnew_bootstraps_per_minute = 60000")); + let path = directory.path().join("probe.toml"); + std::fs::write(&path, source).unwrap(); + let before = memory(); + let started = Instant::now(); + let config = ProxyConfig::load(path).unwrap(); + let load_us = started.elapsed().as_micros(); + let loaded = memory(); + let generation = test_runtime_generation(1, config); + generation + .proxy_shared + .apply_user_config(&generation.config().access.users, &HashMap::new()); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bootstrap = bridge(&listener, &runtime, "warm.example.com").await; + let session = create(&runtime, "warm.example.com", &bootstrap).unwrap(); + let mut results = serde_json::Map::new(); + let hostile = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([0xbbu8; 32]); + for phase in ["warm", "cold", "carrier", "hostile"] { + let mut samples = Vec::new(); + let cache_before = runtime.wildcard.as_ref().unwrap().test_stats(); + for index in 0..110 { + let host = match phase { + "cold" => format!("cold{index}.example.com"), + "hostile" => format!("random{index}.example.com"), + _ => "warm.example.com".to_string(), + }; + let cap = if phase == "hostile" { + hostile.clone() + } else { + capability(&generation.config(), &host) + }; + let raw = if phase == "carrier" { + raw_request( + "POST", + "/relay/api/v1/up", + &host, + &format!( + "Authorization: Bearer {session}\r\nContent-Type: application/octet-stream\r\nX-Up-Seq: {}\r\n", + index + 1 + ), + &frame::encode(FrameType::Pong, 0, &[]), + ) + } else { + raw_request("GET", &format!("/relay/?bridge={cap}"), &host, "", &[]) + }; + let started = Instant::now(); + let response = request(&listener, &runtime, raw).await; + let elapsed = started.elapsed().as_micros(); + let status = match phase { + "carrier" => b"HTTP/1.1 204", + "hostile" => b"HTTP/1.1 404", + _ => b"HTTP/1.1 200", + }; + assert!( + response.starts_with(status), + "{}", + String::from_utf8_lossy(&response) + ); + if index >= 10 { + samples.push(elapsed); + } + } + samples.sort_unstable(); + let after = runtime.wildcard.as_ref().unwrap().test_stats(); + results.insert(phase.to_string(), serde_json::json!({"p50_us": samples[50], "p95_us": samples[95], + "hmac_evaluations": after.2 - cache_before.2, "cache_hosts": after.0, "charged_cache_bytes": after.1})); + } + let retained = memory(); + println!( + "WILDCARD_PROBE {}", + serde_json::json!({"profiles": profiles, "load_us": load_us, + "load_anonymous_delta_kib": loaded.1 as i64 - before.1 as i64, + "retained_anonymous_kib": retained.1, "retained_rss_kib": retained.0, "phases": results}) + ); + stop(runtime, &[generation]).await; +} diff --git a/src/web/http/host_list_tests/wildcard/decoy.rs b/src/web/http/host_list_tests/wildcard/decoy.rs new file mode 100644 index 0000000..e956a2b --- /dev/null +++ b/src/web/http/host_list_tests/wildcard/decoy.rs @@ -0,0 +1,91 @@ +use super::*; +use crate::web::trace::{TraceBodyState, TraceRecordKind}; + +#[tokio::test] +async fn wildcard_unknown_foreign_credentials_preserve_decoy_bytes_but_not_diagnostic_copies() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let site = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let text = wildcard_source( + "hosts = [\"*.example.com\", \"exact.example.com\"]", + site.local_addr().unwrap(), + ) + "\n[web.debug]\nenabled = true\nbody_capture = \"full\"\ncapture_headers = true\n"; + let generation = generation(1, &text); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let foreign = capability(&generation.config(), "unseen.example.com"); + let encoded = foreign + .bytes() + .map(|byte| format!("%{byte:02X}")) + .collect::(); + let echoed = format!("response/{encoded}/end"); + let expected = echoed.clone(); + let server = tokio::spawn(async move { + let (mut stream, _) = site.accept().await.unwrap(); + let mut received = Vec::new(); + let mut byte = [0; 1]; + while !received.ends_with(b"\r\n\r\n") { + stream.read_exact(&mut byte).await.unwrap(); + received.push(byte[0]); + } + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nETag: {}\r\nConnection: close\r\n\r\n{}", + echoed.len(), + echoed, + echoed + ); + stream.write_all(response.as_bytes()).await.unwrap(); + received + }); + let response = request( + &listener, + &runtime, + raw_request( + "GET", + &format!("/public/{encoded}"), + "exact.example.com", + &format!( + "Cookie: ordinary={foreign}\r\nUser-Agent: {foreign}\r\nX-{foreign}: ordinary\r\n" + ), + &[], + ), + ) + .await; + assert!(response.starts_with(b"HTTP/1.1 200")); + assert_eq!(split_response(&response).1, expected.as_bytes()); + assert_eq!( + response_header(split_response(&response).0, "etag"), + expected + ); + let forwarded = String::from_utf8(server.await.unwrap()).unwrap(); + assert!(forwarded.contains(&encoded)); + assert!(forwarded.contains(&foreign)); + let records = runtime + .trace() + .snapshot_matching(|record| matches!(record.kind, TraceRecordKind::Http(_))); + assert_eq!(records.len(), 1); + let record = &records[0].record; + assert!(!record.user_agent.as_ref().unwrap().contains(&foreign)); + let TraceRecordKind::Http(http) = &record.kind else { + panic!("HTTP trace") + }; + assert!(!http.path.contains(&encoded)); + assert!(http.request_headers.iter().all(|header| { + !header.name.contains(&foreign.to_ascii_lowercase()) + && !header + .value + .as_ref() + .is_some_and(|value| value.contains(&foreign)) + })); + assert!(http.response_headers.iter().all(|header| { + !header + .value + .as_ref() + .is_some_and(|value| value.contains(&encoded)) + })); + let body = http.response_body.as_ref().unwrap(); + assert_eq!(body.state, TraceBodyState::Complete); + assert!(!String::from_utf8_lossy(&body.captured).contains(&encoded)); + assert_eq!(runtime.wildcard.as_ref().unwrap().test_stats(), (0, 0, 0)); + drop(records); + stop(runtime, &[generation]).await; +} diff --git a/src/web/http/host_list_tests/wildcard/reload.rs b/src/web/http/host_list_tests/wildcard/reload.rs new file mode 100644 index 0000000..4755bb5 --- /dev/null +++ b/src/web/http/host_list_tests/wildcard/reload.rs @@ -0,0 +1,175 @@ +use super::*; + +#[tokio::test] +async fn wildcard_same_generation_reload_fences_cache_and_preserves_only_equivalent_profiles() { + let text = wildcard_source( + "host = \"*.example.com\"", + "127.0.0.1:18081".parse().unwrap(), + ); + let mut initial = generation(11, &text); + let (config_tx, config_rx) = tokio::sync::watch::channel(initial.config()); + Arc::get_mut(&mut initial).unwrap().config_rx = config_rx; + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(initial.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let old = bridge(&listener, &runtime, HOSTS[0]).await; + let old_snapshot = initial.config().web.runtime.as_ref().unwrap().snapshot_id; + let equivalent = generation(99, &text.replace("max_sessions = 1", "max_sessions = 2")); + config_tx.send_replace(equivalent.config()); + assert_eq!(runtime.active_generation().id, 11); + assert_ne!( + initial.config().web.runtime.as_ref().unwrap().snapshot_id, + old_snapshot + ); + assert!(create(&runtime, HOSTS[0], &old).is_ok()); + let before = runtime.wildcard.as_ref().unwrap().test_stats().2; + let unused = bridge(&listener, &runtime, HOSTS[0]).await; + assert_eq!( + runtime.wildcard.as_ref().unwrap().test_stats().2, + before + 1 + ); + let changed = generation( + 99, + &text.replace("base_path = \"relay\"", "base_path = \"new\""), + ); + config_tx.send_replace(changed.config()); + assert_eq!( + create(&runtime, HOSTS[0], &unused), + Err(ManagerError::Authentication) + ); + config_tx.send_replace(equivalent.config()); + let unused = bridge(&listener, &runtime, HOSTS[0]).await; + let changed_secret = generation( + 99, + &text.replace( + "000102030405060708090a0b0c0d0e0f", + "100102030405060708090a0b0c0d0e0f", + ), + ); + config_tx.send_replace(changed_secret.config()); + assert_eq!( + create(&runtime, HOSTS[0], &unused), + Err(ManagerError::Authentication) + ); + // A new exact winner fences the wildcard even when its old template remains loaded. + config_tx.send_replace(equivalent.config()); + let unused = bridge(&listener, &runtime, HOSTS[1]).await; + let (prefix, group) = text.split_once("[[web.vhosts]]").unwrap(); + let (group, limits) = group.split_once("[web.limits]").unwrap(); + let exact = generation( + 99, + &format!( + "{prefix}[[web.vhosts]]{group}\n[[web.vhosts]]{}\n[web.limits]{limits}", + group + .replace("host = \"*.example.com\"", "host = \"b.example.com\"") + .replace("base_path = \"relay\"", "base_path = \"other\"") + ), + ); + config_tx.send_replace(exact.config()); + assert_eq!( + create(&runtime, HOSTS[1], &unused), + Err(ManagerError::Authentication) + ); + stop( + runtime, + &[initial, equivalent, changed, changed_secret, exact], + ) + .await; +} + +#[tokio::test] +async fn wildcard_rule_precedence_never_falls_through_after_failed_authentication() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let origin = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let text = wildcard_source("host = \"*\"", origin.local_addr().unwrap()); + let (prefix, group) = text.split_once("[[web.vhosts]]").unwrap(); + let (group, limits) = group.split_once("[web.limits]").unwrap(); + let text = format!( + "{prefix}[[web.vhosts]]{group}\n[[web.vhosts]]{}\n[[web.vhosts]]{}\n[[web.vhosts]]{}\n[web.limits]{limits}", + group + .replace("host = \"*\"", "host = \"*.example.com\"") + .replace("base_path = \"relay\"", "base_path = \"suffix\""), + group + .replace("host = \"*\"", "host = \"*.deep.example.com\"") + .replace("base_path = \"relay\"", "base_path = \"deep\""), + group + .replace("host = \"*\"", "host = \"a.deep.example.com\"") + .replace("base_path = \"relay\"", "base_path = \"exact\"") + ); + let generation = generation(1, &text); + let config = generation.config(); + let web = config.web.runtime.as_ref().unwrap(); + assert_eq!(web.route("a.deep.example.com").unwrap().base, "/exact/"); + assert_eq!(web.route("b.deep.example.com").unwrap().base, "/deep/"); + assert_eq!(web.route("b.example.com").unwrap().base, "/suffix/"); + assert_eq!(web.route("example.com").unwrap().base, "/relay/"); + assert_eq!(web.route("elsewhere.org").unwrap().base, "/relay/"); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let cap = capability(&config, "b.deep.example.com"); + let server = tokio::spawn(async move { + let (mut stream, _) = origin.accept().await.unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(stream.read_u8().await.unwrap()); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nordinary") + .await + .unwrap(); + head + }); + let response = request( + &listener, + &runtime, + raw_request( + "GET", + &format!("/deep/?bridge={cap}"), + "b.deep.example.com", + "", + &[], + ), + ) + .await; + assert_eq!(split_response(&response).1, b"ordinary"); + assert!( + String::from_utf8(server.await.unwrap()) + .unwrap() + .contains("/deep/") + ); + assert_eq!(runtime.wildcard.as_ref().unwrap().test_stats().0, 0); + stop(runtime, &[generation]).await; +} + +#[tokio::test] +async fn wildcard_reordered_profiles_do_not_reuse_stale_capability_indexes() { + let text = wildcard_source( + "host = \"*.example.com\"", + "127.0.0.1:18081".parse().unwrap(), + ) + .replace( + "[access.users]", + "[access.users]\nbob = \"100102030405060708090a0b0c0d0e0f\"", + ) + .replace( + "[web.limits]", + "[[web.vhosts.profiles]]\nuser = \"bob\"\nsecret_mode = \"plain\"\n[web.limits]", + ); + let mut initial = generation(1, &text); + let (tx, rx) = tokio::sync::watch::channel(initial.config()); + Arc::get_mut(&mut initial).unwrap().config_rx = rx; + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(initial.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let unused = bridge(&listener, &runtime, HOSTS[0]).await; + let mut config = (*initial.config()).clone(); + config.web.vhosts[0].profiles.reverse(); + config.rebuild_runtime_web().unwrap(); + tx.send_replace(Arc::new(config)); + assert!(create(&runtime, HOSTS[0], &unused).is_ok()); + let fresh = bridge(&listener, &runtime, HOSTS[0]).await; + let (_, profile, _) = runtime + .bootstrap_trace_identity(token_hash(&fresh), HOSTS[0]) + .unwrap(); + assert_eq!(profile.shared.user, "alice"); + assert_eq!(runtime.wildcard.as_ref().unwrap().test_stats().2, 4); + stop(runtime, &[initial]).await; +} diff --git a/src/web/http/host_list_tests/wildcard/websocket.rs b/src/web/http/host_list_tests/wildcard/websocket.rs new file mode 100644 index 0000000..e4c547b --- /dev/null +++ b/src/web/http/host_list_tests/wildcard/websocket.rs @@ -0,0 +1,57 @@ +use super::*; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_util::sync::CancellationToken; + +#[tokio::test] +async fn wildcard_websocket_upgrade_uses_actual_host_without_cold_derivation() { + let text = wildcard_source( + "host = \"*.example.com\"", + "127.0.0.1:18081".parse().unwrap(), + ) + .replace("carrier = \"https\"", "carrier = \"websocket\"") + + "\n[web.limits.wildcard]\nmax_profiles_per_request = 1\ncold_hmac_burst = 1\ncold_hmac_per_minute = 1\ncache_hosts = 0\n"; + let generation = generation(1, &text); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let bootstrap = bridge(&listener, &runtime, HOSTS[0]).await; + let session = create(&runtime, HOSTS[0], &bootstrap).unwrap(); + for (host, status) in [(HOSTS[1], b"HTTP/1.1 404"), (HOSTS[0], b"HTTP/1.1 101")] { + let (accepted, client) = tokio::join!( + listener.accept(), + TcpStream::connect(listener.local_addr().unwrap()) + ); + let (server, peer) = accepted.unwrap(); + let mut client = client.unwrap(); + let cancel = CancellationToken::new(); + let task = tokio::spawn(crate::web::http::serve_connection( + server, + peer, + crate::config::WebClientIpSource::XForwardedFor, + Arc::from(["127.0.0.1/32".parse().unwrap()]), + runtime.clone(), + cancel.clone(), + runtime.try_http_connection().unwrap(), + )); + client.write_all(format!("GET /relay/api/v1/ws HTTP/1.1\r\nHost: {host}\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Protocol: tproxy-v1.{session}\r\nCookie: ordinary=allowed\r\n\r\n").as_bytes()).await.unwrap(); + let mut response = Vec::new(); + while !response.ends_with(b"\r\n\r\n") { + response.push( + tokio::time::timeout(Duration::from_secs(2), client.read_u8()) + .await + .unwrap() + .unwrap(), + ); + assert!(response.len() < 16 * 1024); + } + assert!( + response.starts_with(status), + "{}", + String::from_utf8_lossy(&response) + ); + drop(client); + cancel.cancel(); + task.await.unwrap(); + } + assert_eq!(runtime.wildcard.as_ref().unwrap().test_stats(), (0, 0, 1)); + stop(runtime, &[generation]).await; +} diff --git a/src/web/http/recovery.rs b/src/web/http/recovery.rs index f140ca4..fccd8ec 100644 --- a/src/web/http/recovery.rs +++ b/src/web/http/recovery.rs @@ -6,7 +6,7 @@ use serde::Serialize; use super::request::{bearer_token_hash, compatible_cookie_header}; use super::response::{full_response, insert_header}; use super::{HttpResponse, RequestBody}; -use crate::config::{WebRuntimeProfile, WebRuntimeVhost}; +use crate::config::WebRuntimeProfile; use crate::web::manager::{BootstrapResult, TokenHash}; pub(super) const MEDIA_TYPE: &str = "application/vnd.telemt.web-recovery+json"; @@ -71,7 +71,7 @@ pub(super) fn has_media_type(request: &Request) -> bool { /// Builds the bounded no-store recovery representation. pub(super) fn response( bootstrap: &BootstrapResult, - vhost: &WebRuntimeVhost, + host: &str, profile: &WebRuntimeProfile, limits: &crate::config::WebLimitsConfig, timeouts: &crate::config::WebTimeoutsConfig, @@ -101,7 +101,7 @@ pub(super) fn response( }, }; let body = serde_json::to_vec(&document).ok()?; - if body.len() > MAX_RESPONSE_BYTES || profile.host != vhost.host { + if body.len() > MAX_RESPONSE_BYTES || profile.host != host { return None; } let mut response = full_response(StatusCode::OK, Bytes::from(body)); diff --git a/src/web/http/request.rs b/src/web/http/request.rs index aa09587..deaea13 100644 --- a/src/web/http/request.rs +++ b/src/web/http/request.rs @@ -17,7 +17,9 @@ use super::capability::scan_capabilities; // Canonical host and forwarded-address provenance remain isolated from credentials. mod identity; -pub(super) use identity::{canonical_request_host, carrier_ip_learning_eligible, client_ip}; +pub(super) use identity::{ + canonical_request_host, carrier_ip_learning_eligible, client_ip, wildcard_request_host, +}; /// Matches one capability after a complete branchless scan of the virtual host table. pub(super) fn match_profile( diff --git a/src/web/http/request/identity.rs b/src/web/http/request/identity.rs index 822946b..9ec4fd6 100644 --- a/src/web/http/request/identity.rs +++ b/src/web/http/request/identity.rs @@ -25,6 +25,49 @@ pub(in crate::web::http) fn canonical_request_host(request: &Request) -> O Some(host) } +/// Canonicalizes one ASCII DNS authority into bounded request-local storage. +/// Only the literal public HTTPS port is permitted; forwarding headers are ignored. +pub(in crate::web::http) fn wildcard_request_host<'a, B>( + request: &Request, + storage: &'a mut [u8; 253], +) -> Option<&'a str> { + let mut values = request.headers().get_all(header::HOST).iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + return None; + } + let host = value.strip_suffix(":443").unwrap_or(value); + if host.len() > storage.len() || !host.contains('.') || !host.is_ascii() { + return None; + } + for (out, byte) in storage.iter_mut().zip(host.bytes()) { + *out = byte.to_ascii_lowercase(); + } + let host = std::str::from_utf8(&storage[..host.len()]).ok()?; + if host.parse::().is_ok() + || host.split('.').any(|label| { + label.is_empty() + || label.len() > 63 + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return None; + } + let last = host.rsplit('.').next()?; + if last.bytes().all(|byte| byte.is_ascii_digit()) + || last + .strip_prefix("0x") + .is_some_and(|digits| digits.bytes().all(|byte| byte.is_ascii_hexdigit())) + { + return None; + } + Some(host) +} + /// Accepts one forwarded client address or the direct address of a trusted peer. pub(in crate::web::http) fn client_ip( request: &Request, diff --git a/src/web/http/secrets.rs b/src/web/http/secrets.rs index 62e8671..f2ed02b 100644 --- a/src/web/http/secrets.rs +++ b/src/web/http/secrets.rs @@ -14,20 +14,39 @@ pub(super) fn mark_internal_credential( config: &WebRuntimeConfig, runtime: &WebProcessRuntime, ) { + let found = visit_metadata(request, |candidate| { + bool::from(scan_capabilities(&config.capabilities, candidate).matched) + || runtime.authentic_token(candidate) + || runtime + .wildcard + .as_ref() + .is_some_and(|cache| cache.contains(candidate)) + }); + if found { + mark(request); + } +} + +/// Marks a request after a full selected-host capability scan authenticated metadata. +pub(super) fn mark(request: &mut Request) { + request.extensions_mut().insert(InternalCredential); +} + +/// Visits canonical raw or percent-decoded credential windows without allocating. +pub(super) fn visit_metadata( + request: &Request, + mut visit: impl FnMut(&[u8; 32]) -> bool, +) -> bool { let uri = request.uri(); - let uri_contains = uri - .authority() - .is_some_and(|authority| contains_secret(authority.as_str().as_bytes(), config, runtime)) + uri.authority() + .is_some_and(|value| visit_text(value.as_str().as_bytes(), &mut visit)) || uri .path_and_query() - .is_some_and(|path| contains_secret(path.as_str().as_bytes(), config, runtime)); - let headers_contain = request.headers().iter().any(|(name, value)| { - contains_secret(name.as_str().as_bytes(), config, runtime) - || contains_secret(value.as_bytes(), config, runtime) - }); - if uri_contains || headers_contain { - request.extensions_mut().insert(InternalCredential); - } + .is_some_and(|value| visit_text(value.as_str().as_bytes(), &mut visit)) + || request.headers().iter().any(|(name, value)| { + visit_text(name.as_str().as_bytes(), &mut visit) + || visit_text(value.as_bytes(), &mut visit) + }) } /// Returns whether request metadata was authenticated before routing. @@ -35,7 +54,7 @@ pub(super) fn has_internal_credential(request: &Request) -> bool { request.extensions().get::().is_some() } -fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessRuntime) -> bool { +fn visit_text(text: &[u8], visit: &mut impl FnMut(&[u8; 32]) -> bool) -> bool { let mut window = [0; 43]; let mut run_len = 0usize; let mut offset = 0usize; @@ -58,10 +77,7 @@ fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessR window[42] = byte; } if run_len >= window.len() - && canonical_credential(&window).is_some_and(|candidate| { - bool::from(scan_capabilities(&config.capabilities, &candidate).matched) - || runtime.authentic_token(&candidate) - }) + && canonical_credential(&window).is_some_and(|candidate| visit(&candidate)) { return true; } @@ -88,3 +104,11 @@ fn hex_value(value: u8) -> Option { #[cfg(test)] #[path = "secrets/tests.rs"] mod tests; + +#[cfg(test)] +fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessRuntime) -> bool { + visit_text(text, &mut |candidate| { + bool::from(scan_capabilities(&config.capabilities, candidate).matched) + || runtime.authentic_token(candidate) + }) +} diff --git a/src/web/http/session.rs b/src/web/http/session.rs index 665a891..344f859 100644 --- a/src/web/http/session.rs +++ b/src/web/http/session.rs @@ -26,6 +26,7 @@ pub(super) async fn handle_session( request: Request, runtime: Arc, vhost: Arc, + host: &str, token_hash: TokenHash, client_ip: IpAddr, ) -> HttpResponse { @@ -39,7 +40,7 @@ pub(super) async fn handle_session( let Some(carrier_failure) = optional_failure_header(&request) else { return serve_decoy(request, vhost, true, &runtime).await; }; - let session = runtime.get_session(token_hash, &vhost.host).ok(); + let session = runtime.get_session(token_hash, host).ok(); if let Some(trace) = request_trace(&request) && let Some(session) = &session { @@ -63,7 +64,7 @@ pub(super) async fn handle_session( }; if !body.is_empty() || runtime - .close_token(token_hash, &vhost.host, carrier_failure) + .close_token(token_hash, host, carrier_failure) .is_err() { return serve_decoy(request, vhost, true, &runtime).await; @@ -73,7 +74,7 @@ pub(super) async fn handle_session( if request.method() != Method::POST || !binary_content_type(&request) { return serve_decoy(request, vhost, true, &runtime).await; } - let Some(carrier_request) = carrier_request(&request, &vhost.host) else { + let Some(carrier_request) = carrier_request(&request, host) else { return serve_decoy(request, vhost, true, &runtime).await; }; let up_window = if request.headers().contains_key("x-telemt-up-window") { @@ -89,7 +90,7 @@ pub(super) async fn handle_session( let carrier_request = carrier_request.with_up_window(up_window); let ip_learning_eligible = carrier_ip_learning_eligible(&request, client_ip); let Some((trace_session_id, profile, body_timeout)) = - runtime.bootstrap_trace_identity(token_hash, &vhost.host) + runtime.bootstrap_trace_identity(token_hash, host) else { return serve_decoy(request, vhost, true, &runtime).await; }; @@ -117,7 +118,7 @@ pub(super) async fn handle_session( } match runtime.create_session( token_hash, - &vhost.host, + host, client_ip, &body, carrier_request, @@ -185,9 +186,7 @@ pub(super) async fn handle_session( } Err(ManagerError::Committed) => { let mut response = carrier_empty(StatusCode::CONFLICT); - if let Some(echo) = - runtime.carrier_echo(token_hash, &vhost.host, client_ip, carrier_request) - { + if let Some(echo) = runtime.carrier_echo(token_hash, host, client_ip, carrier_request) { response.headers_mut().insert( HeaderName::from_static("x-carrier-mode"), HeaderValue::from_static(echo.carrier.as_str()), diff --git a/src/web/http/tests.rs b/src/web/http/tests.rs index 1a0d6a6..900028f 100644 --- a/src/web/http/tests.rs +++ b/src/web/http/tests.rs @@ -129,6 +129,7 @@ fn runtime_config_with_carriers_and_deadlines( host: "proxy.example.com".to_string(), capability, shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: "203.0.113.10:443".parse().unwrap(), user: "alice".to_string(), secret_mode: WebSecretMode::Plain, @@ -158,6 +159,7 @@ fn runtime_config_with_carriers_and_deadlines( index: "index.html".to_string(), }); let vhost = Arc::new(WebRuntimeVhost { + wildcard: None, host: "proxy.example.com".to_string(), base: base.to_string(), decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, @@ -171,6 +173,7 @@ fn runtime_config_with_carriers_and_deadlines( vhosts.insert( "other.example.com".to_string(), Arc::new(WebRuntimeVhost { + wildcard: None, host: "other.example.com".to_string(), base: "/".to_string(), decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, @@ -193,6 +196,9 @@ fn runtime_config_with_carriers_and_deadlines( config.web.limits.max_bootstraps_per_ip = 1; config.web.timeouts.shutdown_secs = 1; config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id: 0, + wildcards: Default::default(), + catch_all: None, vhosts, profiles: vec![profile], capabilities: vec![capability].into_boxed_slice(), diff --git a/src/web/http/up.rs b/src/web/http/up.rs index 3bc0f39..61016ee 100644 --- a/src/web/http/up.rs +++ b/src/web/http/up.rs @@ -6,6 +6,7 @@ pub(super) async fn handle_up( request: Request, runtime: Arc, vhost: Arc, + host: &str, token_hash: crate::web::manager::TokenHash, ) -> HttpResponse { if !matches!(*request.method(), Method::POST | Method::PUT) || !binary_content_type(&request) { @@ -15,7 +16,7 @@ pub(super) async fn handle_up( else { return serve_decoy(request, vhost, true, &runtime).await; }; - let Ok(session) = runtime.get_session(token_hash, &vhost.host) else { + let Ok(session) = runtime.get_session(token_hash, host) else { return serve_decoy(request, vhost, true, &runtime).await; }; if session.carrier().uses_websocket() { diff --git a/src/web/http/websocket.rs b/src/web/http/websocket.rs index 6709fcb..e1d7e3c 100644 --- a/src/web/http/websocket.rs +++ b/src/web/http/websocket.rs @@ -253,6 +253,7 @@ pub(super) async fn handle( trusted_proxy_cidrs: &[IpNetwork], runtime: Arc, vhost: Arc, + host: &str, ) -> HttpResponse { let request_deadline = super::request_deadline(&request); let Some(parsed) = parse_upgrade(&request) else { @@ -265,7 +266,7 @@ pub(super) async fn handle( else { return serve_decoy(request, vhost, true, &runtime).await; }; - let Ok(session) = runtime.get_session(parsed.token_hash, &vhost.host) else { + let Ok(session) = runtime.get_session(parsed.token_hash, host) else { return serve_decoy(request, vhost, true, &runtime).await; }; let kind = match (parsed.carrier, session.carrier()) { diff --git a/src/web/http/wildcard.rs b/src/web/http/wildcard.rs new file mode 100644 index 0000000..b6859ab --- /dev/null +++ b/src/web/http/wildcard.rs @@ -0,0 +1,72 @@ +use super::*; +use crate::config::{ProxyConfig, WebRuntimeProfile}; +use crate::web::manager::wildcard::CapabilityTable; + +/// Contains actual credentials and derives at most one selected-host table per request. +pub(super) fn prepare( + request: &mut Request, + config: &ProxyConfig, + vhost: &WebRuntimeVhost, + host: &str, + runtime: &WebProcessRuntime, +) -> Result, ()> { + // A process-authentic credential can never reach the decoy. Established carriers + // need no host-table lookup, HMAC derivation or host binding allocation. + let suffix = request.uri().path().strip_prefix(&vhost.base); + if suffix + .is_some_and(|suffix| TRANSPORT_SUFFIXES.contains(&suffix) || suffix == WEBSOCKET_SUFFIX) + && runtime.wildcard.is_some() + && secrets::visit_metadata(request, |candidate| runtime.authentic_token(candidate)) + { + secrets::mark(request); + return Ok(None); + } + let web = config.web.runtime.as_ref().ok_or(())?; + secrets::mark_internal_credential(request, web, runtime); + if vhost.wildcard.is_none() { + return Ok(None); + } + let root_full_scan = request.uri().path() == vhost.base + && matches!(*request.method(), Method::GET | Method::HEAD) + && vhost.decoy_fasttrack_mode != WebDecoyFastTrackMode::Enforce; + let candidate_present = secrets::visit_metadata(request, |_| true); + if !root_full_scan && !candidate_present { + return Ok(None); + } + let cache = runtime.wildcard.as_ref().ok_or(())?; + let table = cache.prepare(config, vhost, host).ok_or(())?; + if secrets::visit_metadata(request, |candidate| { + bool::from(capability::scan_capabilities(table.capabilities(), candidate).matched) + }) { + secrets::mark(request); + } + Ok(Some(table)) +} + +/// Creates one host binding only after a complete constant-time capability scan. +pub(super) fn match_profile( + vhost: &WebRuntimeVhost, + host: &str, + table: Option<&CapabilityTable>, + candidate: &[u8; 32], +) -> Option> { + let Some(group) = &vhost.wildcard else { + return super::match_profile(vhost, candidate); + }; + let capabilities = table?.capabilities(); + if capabilities.len() != group.profiles.len() { + return None; + } + let scan = capability::scan_capabilities(capabilities, candidate); + if !bool::from(scan.matched) { + return None; + } + let shared = group + .profiles + .get(usize::try_from(scan.matched_index).ok()?)?; + Some(Arc::new(WebRuntimeProfile { + host: host.to_string(), + capability: *candidate, + shared: Arc::clone(shared), + })) +} diff --git a/src/web/manager.rs b/src/web/manager.rs index 7f84490..e609409 100644 --- a/src/web/manager.rs +++ b/src/web/manager.rs @@ -21,6 +21,8 @@ use crate::web::telemetry::{WebRejectionReason, WebTelemetry}; use crate::web::trace::WebTraceStore; // Credential maps, quotas, and token-bucket helpers remain private to the manager. mod state; +/// Wildcard derivation and positive containment are bounded independently of session quotas. +pub(crate) mod wildcard; // Carrier attempt metadata remains explicit and independent from HTTP parsing. mod negotiation; // Bounded process-local carrier evidence is isolated from session registries. @@ -221,6 +223,8 @@ pub(crate) struct WebProcessRuntime { active_runtime: Arc>, trace: Arc, token_authenticator: TokenAuthenticator, + /// Process-frozen optional wildcard derivation and retention state. + pub(crate) wildcard: Option, limits: WebLimitsConfig, state: Mutex, stream_admission: Mutex, @@ -253,7 +257,11 @@ impl WebProcessRuntime { #[cfg(test)] pub(crate) fn start(active_runtime: Arc>) -> Arc { let config = active_runtime.load().config(); - let trace = WebTraceStore::new(config.web.debug.clone(), &config.web.limits); + let trace = WebTraceStore::new_with_wildcards( + config.web.debug.clone(), + &config.web.limits, + config.web.allow_wildcard_hosts, + ); Self::start_with_trace(active_runtime, trace, WebTelemetry::new()) } @@ -295,6 +303,10 @@ impl WebProcessRuntime { active_runtime, trace, token_authenticator, + wildcard: config + .web + .allow_wildcard_hosts + .then(|| wildcard::WildcardState::new(limits.wildcard.clone())), http_connections: Arc::new(Semaphore::new(limits.max_http_connections)), http_overload_connections: Arc::new(Semaphore::new( limits.max_http_overload_connections, diff --git a/src/web/manager/credentials.rs b/src/web/manager/credentials.rs index ba0668a..d8dc12d 100644 --- a/src/web/manager/credentials.rs +++ b/src/web/manager/credentials.rs @@ -164,7 +164,7 @@ impl WebProcessRuntime { } let trace_session_id = self.trace.next_session_id(); let bridge_diagnostics_enabled = config.web.debug.bridge_diagnostics_enabled(); - let (user_agent, user_agent_id) = bounded_user_agent(user_agent); + let (user_agent, user_agent_id) = bounded_user_agent(user_agent, self.wildcard.is_some()); let issued_profile = Arc::clone(&profile); let expires_at = now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs); state.bootstrap_expiry = Some(state.bootstrap_expiry.unwrap_or(expires_at).min(expires_at)); @@ -381,7 +381,7 @@ impl WebProcessRuntime { } } -fn bounded_user_agent(value: Option<&str>) -> (Option>, Option<[u8; 16]>) { +fn bounded_user_agent(value: Option<&str>, wildcard: bool) -> (Option>, Option<[u8; 16]>) { const DISPLAY_BYTES: usize = 256; const HASH_CONTEXT: &[u8] = b"telemt-web-user-agent-v1\0"; let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { @@ -394,6 +394,7 @@ fn bounded_user_agent(value: Option<&str>) -> (Option>, Option<[u8; 16] let mut id = [0; 16]; id.copy_from_slice(&digest[..16]); let mut display = String::with_capacity(value.len().min(DISPLAY_BYTES)); + let mut truncated = false; for character in value.chars() { let character = if character.is_control() { '\u{fffd}' @@ -401,9 +402,28 @@ fn bounded_user_agent(value: Option<&str>) -> (Option>, Option<[u8; 16] character }; if display.len().saturating_add(character.len_utf8()) > DISPLAY_BYTES { + truncated = true; break; } display.push(character); } + if wildcard { + crate::web::trace::scrub_text(&mut display, truncated); + } (Some(Arc::from(display)), Some(id)) } + +#[cfg(test)] +mod wildcard_tests { + use super::*; + + #[test] + fn wildcard_user_agent_redaction_preserves_original_hash_and_masks_expanded_cutoff() { + let value = format!("{}/{}tail", "x\t".repeat(60), "A".repeat(43)); + let (plain, plain_hash) = bounded_user_agent(Some(&value), false); + let (redacted, redacted_hash) = bounded_user_agent(Some(&value), true); + assert_eq!(plain_hash, redacted_hash); + assert!(plain.unwrap().ends_with(&"A".repeat(15))); + assert!(redacted.unwrap().ends_with(&"*".repeat(15))); + } +} diff --git a/src/web/manager/state.rs b/src/web/manager/state.rs index baaf757..c545e5e 100644 --- a/src/web/manager/state.rs +++ b/src/web/manager/state.rs @@ -287,8 +287,35 @@ pub(super) fn profile_key(profile: &WebRuntimeProfile) -> ProfileKey { /// Re-resolves an issued profile against the active generation without weakening identity. pub(super) fn matching_profile( runtime: &WebRuntimeConfig, - expected: &WebRuntimeProfile, + expected: &Arc, ) -> Option> { + if let Some(group) = runtime + .route(&expected.host) + .and_then(|vhost| vhost.wildcard.as_ref()) + { + let shared = group.profiles.iter().find(|shared| { + shared.derivation_context == expected.shared.derivation_context + && shared.public_addr == expected.shared.public_addr + && shared.user == expected.shared.user + && shared.credential_id == expected.shared.credential_id + && shared.secret_mode == expected.shared.secret_mode + && shared.carrier == expected.shared.carrier + && shared.carrier_negotiation_enabled == expected.shared.carrier_negotiation_enabled + && shared.carrier_learning == expected.shared.carrier_learning + && shared.carriers == expected.shared.carriers + && shared.carrier_negotiation_deadlines_secs + == expected.shared.carrier_negotiation_deadlines_secs + && shared.key_fingerprint == expected.shared.key_fingerprint + })?; + if Arc::ptr_eq(shared, &expected.shared) { + return Some(Arc::clone(expected)); + } + return Some(Arc::new(WebRuntimeProfile { + host: expected.host.clone(), + capability: expected.capability, + shared: Arc::clone(shared), + })); + } runtime .profiles .iter() diff --git a/src/web/manager/state/expiry/fixtures.rs b/src/web/manager/state/expiry/fixtures.rs index 257e4f9..fe11fa2 100644 --- a/src/web/manager/state/expiry/fixtures.rs +++ b/src/web/manager/state/expiry/fixtures.rs @@ -21,6 +21,7 @@ impl Fixture { host: "proxy.example.com".to_string(), capability: [7; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: "203.0.113.10:443".parse().unwrap(), user: "alice".to_string(), secret_mode: WebSecretMode::Plain, diff --git a/src/web/manager/state/expiry/production_tests.rs b/src/web/manager/state/expiry/production_tests.rs index 51bdab1..c1fdee9 100644 --- a/src/web/manager/state/expiry/production_tests.rs +++ b/src/web/manager/state/expiry/production_tests.rs @@ -18,6 +18,9 @@ fn generation(id: u64, lifetime: u64) -> Arc { config.web.limits.new_bootstraps_burst = 4096; config.web.limits.new_sessions_burst = 4096; config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id: 0, + wildcards: Default::default(), + catch_all: None, vhosts: BTreeMap::new(), profiles: vec![Arc::clone(&fixture.profile)], capabilities: vec![fixture.profile.capability].into_boxed_slice(), diff --git a/src/web/manager/wildcard.rs b/src/web/manager/wildcard.rs new file mode 100644 index 0000000..53d4c19 --- /dev/null +++ b/src/web/manager/wildcard.rs @@ -0,0 +1,257 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use parking_lot::Mutex; +use sha2::{Digest, Sha256}; + +use crate::config::{ProxyConfig, WebRuntimeVhost, WebWildcardLimitsConfig}; + +/// Request-owned table; a cold allocation is bounded by the HTTP handler permit. +pub(crate) enum CapabilityTable { + /// Positive cache hit, including its retained-memory lease. + Warm(Arc), + /// Complete temporary derivation, not yet eligible for retention. + Cold(Box<[[u8; 32]]>), +} + +impl CapabilityTable { + /// Returns the dense table used for both containment and full authentication scans. + pub(crate) fn capabilities(&self) -> &[[u8; 32]] { + match self { + Self::Warm(entry) => &entry.capabilities, + Self::Cold(capabilities) => capabilities, + } + } +} + +/// A cache entry retains no runtime generation, template, policy, or decoy snapshot. +pub(crate) struct CacheEntry { + snapshot: u64, + group: usize, + sequence: u64, + capabilities: Box<[[u8; 32]]>, + charged: usize, + live_bytes: Arc, +} + +impl Drop for CacheEntry { + fn drop(&mut self) { + self.live_bytes.fetch_sub(self.charged, Ordering::AcqRel); + } +} + +#[derive(Default)] +struct State { + hosts: BTreeMap>, + fifo: BTreeMap, + known: BTreeMap<[u8; 32], usize>, + next_sequence: u64, + last: Option, + tokens: f64, + #[cfg(test)] + evaluations: usize, +} + +/// Process-owned positive cache and atomic HMAC token bucket, independent of quotas. +pub(crate) struct WildcardState { + limits: WebWildcardLimitsConfig, + state: Mutex, + live_bytes: Arc, +} + +impl WildcardState { + /// Initializes an empty process-scoped cache without allocating hostname inventories. + pub(crate) fn new(limits: WebWildcardLimitsConfig) -> Self { + Self { + limits, + state: Mutex::new(State::default()), + live_bytes: Arc::default(), + } + } + + /// Returns a hit or reserves the entire cold cost before any HMAC evaluation. + pub(crate) fn prepare( + &self, + config: &ProxyConfig, + vhost: &WebRuntimeVhost, + host: &str, + ) -> Option { + let group = vhost.wildcard.as_ref()?; + let snapshot = config.web.runtime.as_ref()?.snapshot_id; + { + let mut state = self.state.lock(); + if let Some(entry) = state + .hosts + .get(host) + .filter(|entry| entry.snapshot == snapshot && entry.group == group.group_id) + { + return Some(CapabilityTable::Warm(Arc::clone(entry))); + } + let count = group.profiles.len(); + if count > self.limits.max_profiles_per_request + || !reserve(&mut state, Instant::now(), count, &self.limits) + { + return None; + } + } + // No registry lock spans derivation or immutable authentication lookup. + config + .wildcard_capabilities(vhost, host) + .ok() + .map(CapabilityTable::Cold) + } + + /// Tests only positive-cache membership; this never authorizes a profile. + pub(crate) fn contains(&self, candidate: &[u8; 32]) -> bool { + let digest: [u8; 32] = Sha256::digest(candidate).into(); + self.state.lock().known.contains_key(&digest) + } + + /// Retains a complete table only after successful bootstrap issuance. + pub(crate) fn publish( + &self, + snapshot: u64, + vhost: &WebRuntimeVhost, + host: &str, + table: CapabilityTable, + is_current: impl FnOnce() -> bool, + ) { + let CapabilityTable::Cold(capabilities) = table else { + return; + }; + let Some(group) = vhost.wildcard.as_ref() else { + return; + }; + if self.limits.cache_hosts == 0 { + return; + } + // Includes dense bytes, digest-index nodes, two owned host keys, tree nodes, + // Arc headers and allocator overhead; detached borrowers keep the full charge. + let Some(charged) = capabilities + .len() + .checked_mul(256) + .and_then(|value| value.checked_add(2048 + 2 * host.len())) + .filter(|value| *value <= self.limits.cache_bytes) + else { + return; + }; + let mut state = self.state.lock(); + // Snapshot IDs are unique, not activation-ordered. Recheck under the cache + // lock so a late old request cannot replace an already published current entry. + if !is_current() { + return; + } + if state + .hosts + .get(host) + .is_some_and(|entry| entry.snapshot == snapshot && entry.group == group.group_id) + { + return; + } + if let Some(entry) = state.hosts.remove(host) { + detach(&mut state, &entry); + } + loop { + if state.hosts.len() < self.limits.cache_hosts + && self.live_bytes.load(Ordering::Acquire) <= self.limits.cache_bytes - charged + { + break; + } + let Some((_, oldest)) = state.fifo.first_key_value() else { + return; + }; + let oldest = oldest.clone(); + if let Some(entry) = state.hosts.remove(&oldest) { + detach(&mut state, &entry); + } + } + let Some(next) = state.next_sequence.checked_add(1) else { + return; + }; + let sequence = state.next_sequence; + state.next_sequence = next; + self.live_bytes.fetch_add(charged, Ordering::AcqRel); + for capability in &capabilities { + *state + .known + .entry(Sha256::digest(capability).into()) + .or_default() += 1; + } + state.fifo.insert(sequence, host.to_string()); + state.hosts.insert( + host.to_string(), + Arc::new(CacheEntry { + snapshot, + group: group.group_id, + sequence, + capabilities, + charged, + live_bytes: Arc::clone(&self.live_bytes), + }), + ); + } +} + +fn detach(state: &mut State, entry: &CacheEntry) { + state.fifo.remove(&entry.sequence); + for capability in &entry.capabilities { + let digest: [u8; 32] = Sha256::digest(capability).into(); + if let Some(count) = state.known.get_mut(&digest) { + *count -= 1; + if *count == 0 { + state.known.remove(&digest); + } + } + } + // Empty BTreeMaps may retain root allocations. Release them explicitly so + // a fully detached cache has no index memory outside live entry leases. + if state.hosts.is_empty() { + state.hosts = BTreeMap::new(); + } + if state.fifo.is_empty() { + state.fifo = BTreeMap::new(); + } + if state.known.is_empty() { + state.known = BTreeMap::new(); + } +} + +fn reserve(state: &mut State, now: Instant, cost: usize, limits: &WebWildcardLimitsConfig) -> bool { + let burst = f64::from(limits.cold_hmac_burst); + state.tokens = state.last.map_or(burst, |last| { + (state.tokens + + now.saturating_duration_since(last).as_secs_f64() + * f64::from(limits.cold_hmac_per_minute) + / 60.0) + .min(burst) + }); + state.last = Some(now); + if state.tokens < cost as f64 { + return false; + } + state.tokens -= cost as f64; + #[cfg(test)] + { + state.evaluations += cost; + } + true +} + +#[cfg(test)] +impl WildcardState { + /// Exposes deterministic resource evidence without production metrics or hot-path counters. + pub(crate) fn test_stats(&self) -> (usize, usize, usize) { + let state = self.state.lock(); + ( + state.hosts.len(), + self.live_bytes.load(Ordering::Acquire), + state.evaluations, + ) + } +} + +// Cache accounting and token-bucket invariants are tested without network timing. +#[cfg(test)] +mod tests; diff --git a/src/web/manager/wildcard/tests.rs b/src/web/manager/wildcard/tests.rs new file mode 100644 index 0000000..aafb584 --- /dev/null +++ b/src/web/manager/wildcard/tests.rs @@ -0,0 +1,197 @@ +use super::*; + +fn config() -> ProxyConfig { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write( + &path, + r#" +[access.users] +alice = "000102030405060708090a0b0c0d0e0f" +bob = "100102030405060708090a0b0c0d0e0f" +[web] +allow_wildcard_hosts = true +[web.limits] +memory_envelope_bytes = 1610612736 +[[web.vhosts]] +host = "*.example.com" +public_addr = "203.0.113.10:443" +[web.vhosts.decoy] +mode = "http_upstream" +upstream = "http://127.0.0.1:18081" +[[web.vhosts.profiles]] +user = "alice" +secret_mode = "plain" +[[web.vhosts.profiles]] +user = "bob" +secret_mode = "dd" +"#, + ) + .unwrap(); + ProxyConfig::load(path).unwrap() +} + +#[test] +fn cold_budget_reserves_full_table_and_warm_hits_remain_available() { + let config = config(); + let web = config.web.runtime.as_ref().unwrap(); + let vhost = &web.wildcards["example.com"]; + let cache = WildcardState::new(WebWildcardLimitsConfig { + cold_hmac_burst: 3, + cold_hmac_per_minute: 1, + ..Default::default() + }); + let table = cache.prepare(&config, vhost, "a.example.com").unwrap(); + assert_eq!(cache.test_stats(), (0, 0, 2)); + cache.publish(web.snapshot_id, vhost, "a.example.com", table, || true); + assert!(cache.prepare(&config, vhost, "b.example.com").is_none()); + assert!(matches!( + cache.prepare(&config, vhost, "a.example.com"), + Some(CapabilityTable::Warm(_)) + )); + assert_eq!(cache.test_stats().2, 2); + let mut state = State::default(); + let now = Instant::now(); + assert!(reserve(&mut state, now, 2, &cache.limits)); + assert!(!reserve(&mut state, now, 2, &cache.limits)); + assert!(reserve( + &mut state, + now + std::time::Duration::from_secs(60), + 2, + &cache.limits + )); +} + +#[test] +fn evicted_borrowers_keep_their_byte_lease_and_no_configuration_snapshot() { + let config = config(); + let web = config.web.runtime.as_ref().unwrap(); + let vhost = &web.wildcards["example.com"]; + let weak = Arc::downgrade(vhost); + let cost = 2 * 256 + 2048 + 2 * "a.example.com".len(); + let cache = WildcardState::new(WebWildcardLimitsConfig { + cache_hosts: 1, + cache_bytes: cost, + ..Default::default() + }); + let table = cache.prepare(&config, vhost, "a.example.com").unwrap(); + let capability = table.capabilities()[0]; + cache.publish(web.snapshot_id, vhost, "a.example.com", table, || true); + let borrowed = cache.prepare(&config, vhost, "a.example.com").unwrap(); + assert!(cache.contains(&capability)); + let next = cache.prepare(&config, vhost, "b.example.com").unwrap(); + cache.publish(web.snapshot_id, vhost, "b.example.com", next, || true); + assert_eq!(cache.test_stats().0, 0); + assert_eq!(cache.test_stats().1, cost); + assert!(!cache.contains(&capability)); + drop(config); + assert!(weak.upgrade().is_none()); + drop(borrowed); + assert_eq!(cache.test_stats().1, 0); +} + +#[test] +fn hostile_misses_do_not_pollute_cache_and_snapshot_identity_fences_hits() { + let config = config(); + let web = config.web.runtime.as_ref().unwrap(); + let vhost = &web.wildcards["example.com"]; + let cache = WildcardState::new(WebWildcardLimitsConfig::default()); + for index in 0..1000 { + drop( + cache + .prepare(&config, vhost, &format!("h{index}.example.com")) + .unwrap(), + ); + } + assert_eq!(cache.test_stats(), (0, 0, 2000)); + let table = cache.prepare(&config, vhost, "a.example.com").unwrap(); + cache.publish(web.snapshot_id, vhost, "a.example.com", table, || true); + let next = self::config(); + let next_vhost = &next.web.runtime.as_ref().unwrap().wildcards["example.com"]; + assert!(matches!( + cache.prepare(&next, next_vhost, "a.example.com"), + Some(CapabilityTable::Cold(_)) + )); + let disabled = WildcardState::new(WebWildcardLimitsConfig { + cache_hosts: 0, + ..Default::default() + }); + let table = disabled.prepare(&config, vhost, "a.example.com").unwrap(); + disabled.publish(web.snapshot_id, vhost, "a.example.com", table, || true); + assert_eq!(disabled.test_stats(), (0, 0, 2)); +} + +#[test] +fn concurrent_publication_and_eviction_keep_entry_and_byte_bounds() { + let config = Arc::new(config()); + let cache = Arc::new(WildcardState::new(WebWildcardLimitsConfig { + cache_hosts: 4, + cache_bytes: 8192, + ..Default::default() + })); + std::thread::scope(|scope| { + for worker in 0..8 { + let cache = Arc::clone(&cache); + let config = Arc::clone(&config); + scope.spawn(move || { + let web = config.web.runtime.as_ref().unwrap(); + let vhost = &web.wildcards["example.com"]; + for index in 0..100 { + let host = format!("h{worker}-{index}.example.com"); + let table = cache.prepare(&config, vhost, &host).unwrap(); + cache.publish(web.snapshot_id, vhost, &host, table, || true); + let (hosts, bytes, _) = cache.test_stats(); + assert!(hosts <= 4 && bytes <= 8192); + } + }); + } + }); +} + +#[test] +fn stale_publication_cannot_evict_current_warm_table() { + let old = config(); + let current = config(); + let cache = WildcardState::new(WebWildcardLimitsConfig::default()); + let old_web = old.web.runtime.as_ref().unwrap(); + let current_web = current.web.runtime.as_ref().unwrap(); + let old_vhost = &old_web.wildcards["example.com"]; + let current_vhost = ¤t_web.wildcards["example.com"]; + let late = cache.prepare(&old, old_vhost, "a.example.com").unwrap(); + let latest = cache + .prepare(¤t, current_vhost, "a.example.com") + .unwrap(); + cache.publish( + current_web.snapshot_id, + current_vhost, + "a.example.com", + latest, + || true, + ); + cache.publish( + old_web.snapshot_id, + old_vhost, + "a.example.com", + late, + || false, + ); + assert!(matches!( + cache.prepare(¤t, current_vhost, "a.example.com"), + Some(CapabilityTable::Warm(_)) + )); + assert_eq!(cache.test_stats().2, 4); + // A prepared generation can activate after a later-built watcher snapshot. + // Numeric ID ordering must not prevent that active snapshot from warming. + let reactivated = cache.prepare(&old, old_vhost, "a.example.com").unwrap(); + cache.publish( + old_web.snapshot_id, + old_vhost, + "a.example.com", + reactivated, + || true, + ); + assert!(matches!( + cache.prepare(&old, old_vhost, "a.example.com"), + Some(CapabilityTable::Warm(_)) + )); +} diff --git a/src/web/session/backend_tests.rs b/src/web/session/backend_tests.rs index 3dcc855..f1364e9 100644 --- a/src/web/session/backend_tests.rs +++ b/src/web/session/backend_tests.rs @@ -68,6 +68,7 @@ fn test_runtime_with_dc( host: "proxy.example.com".to_string(), capability: [7; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: "203.0.113.10:443".parse().unwrap(), user: "default".to_string(), secret_mode: WebSecretMode::Plain, @@ -111,6 +112,9 @@ fn test_runtime_with_dc( }); } config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id: 0, + wildcards: Default::default(), + catch_all: None, vhosts: BTreeMap::new(), profiles: vec![Arc::clone(&profile)], capabilities: vec![[7; 32]].into_boxed_slice(), diff --git a/src/web/session/conveyor/tests.rs b/src/web/session/conveyor/tests.rs index 4aaba31..1a39098 100644 --- a/src/web/session/conveyor/tests.rs +++ b/src/web/session/conveyor/tests.rs @@ -23,6 +23,7 @@ fn fixture( host: "proxy.example.com".into(), capability: [0; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: "203.0.113.10:443".parse().unwrap(), user: "default".into(), secret_mode: WebSecretMode::Plain, diff --git a/src/web/session/downlink_tests.rs b/src/web/session/downlink_tests.rs index d1ab801..25ee3d9 100644 --- a/src/web/session/downlink_tests.rs +++ b/src/web/session/downlink_tests.rs @@ -34,6 +34,7 @@ fn session() -> (Arc, Arc) { host: "proxy.example.com".to_string(), capability: [0; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), user: "alice".to_string(), secret_mode: WebSecretMode::Plain, diff --git a/src/web/session/lanes/tests.rs b/src/web/session/lanes/tests.rs index 645724d..3dc2bfa 100644 --- a/src/web/session/lanes/tests.rs +++ b/src/web/session/lanes/tests.rs @@ -49,6 +49,7 @@ fn new_session_with_automatic( host: "proxy.example.com".to_string(), capability: [0; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), user: "alice".to_string(), secret_mode: WebSecretMode::Plain, diff --git a/src/web/session/negotiation/tests.rs b/src/web/session/negotiation/tests.rs index 7033b26..eadf809 100644 --- a/src/web/session/negotiation/tests.rs +++ b/src/web/session/negotiation/tests.rs @@ -13,6 +13,7 @@ fn session(carrier: WebCarrier, deadline: Instant) -> Arc { host: "proxy.example.com".to_string(), capability: [0; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), user: "alice".to_string(), secret_mode: WebSecretMode::Plain, diff --git a/src/web/session/stream_io/tests.rs b/src/web/session/stream_io/tests.rs index a745ab8..7d3f1f2 100644 --- a/src/web/session/stream_io/tests.rs +++ b/src/web/session/stream_io/tests.rs @@ -136,6 +136,7 @@ fn session() -> Arc { host: "proxy.example.com".to_string(), capability: [0; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), user: "alice".to_string(), secret_mode: WebSecretMode::Plain, diff --git a/src/web/session/uplink_tests.rs b/src/web/session/uplink_tests.rs index cc813bf..dbe4868 100644 --- a/src/web/session/uplink_tests.rs +++ b/src/web/session/uplink_tests.rs @@ -18,6 +18,7 @@ fn session_with_automatic(automatic: bool) -> Arc { host: "proxy.example.com".to_string(), capability: [0; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), user: "alice".to_string(), secret_mode: WebSecretMode::Plain, diff --git a/src/web/session/websocket/tests.rs b/src/web/session/websocket/tests.rs index 74386a2..328baef 100644 --- a/src/web/session/websocket/tests.rs +++ b/src/web/session/websocket/tests.rs @@ -50,6 +50,7 @@ fn runtime(admission: bool) -> TestRuntime { host: "proxy.example.com".to_string(), capability: [7; 32], shared: Arc::new(crate::config::WebRuntimeProfileShared { + derivation_context: [0; 32], public_addr: "203.0.113.10:443".parse().unwrap(), user: "default".to_string(), secret_mode: WebSecretMode::Plain, @@ -70,6 +71,9 @@ fn runtime(admission: bool) -> TestRuntime { config.web.carrier = WebCarrier::WebsocketLanes; config.web.timeouts.shutdown_secs = 1; config.web.runtime = Some(Arc::new(WebRuntimeConfig { + snapshot_id: 0, + wildcards: Default::default(), + catch_all: None, vhosts: BTreeMap::new(), profiles: vec![Arc::clone(&profile)], capabilities: vec![[7; 32]].into_boxed_slice(), diff --git a/src/web/trace/exchange.rs b/src/web/trace/exchange.rs index 0da8a5b..681d60a 100644 --- a/src/web/trace/exchange.rs +++ b/src/web/trace/exchange.rs @@ -78,30 +78,48 @@ impl HttpTraceExchange { let started_epoch_millis = epoch_millis(); let dynamic = request_dynamic_bytes(request, &policy); let dynamic_reserved = store.try_reserve(dynamic); - let (method, path, user_agent, request_headers, redactions) = if dynamic_reserved { - ( - request.method().as_str().to_string(), - request.uri().path().to_string(), - request + let (mut method, mut path, mut user_agent, request_headers, redactions) = + if dynamic_reserved { + ( + request.method().as_str().to_string(), + request.uri().path().to_string(), + request + .headers() + .get(hyper::header::USER_AGENT) + .map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)), + if policy.capture_headers { + sanitized_headers(request.headers(), store.wildcard_redaction) + } else { + Vec::new() + }, + sensitive_values(request.headers(), request.uri().query()), + ) + } else { + ( + bounded_text(request.method().as_str().as_bytes(), 32), + bounded_text(request.uri().path().as_bytes(), 512), + None, + Vec::new(), + Vec::new(), + ) + }; + if store.wildcard_redaction { + super::wildcard::scrub_text( + &mut method, + !dynamic_reserved && request.method().as_str().len() > 32, + ); + super::wildcard::scrub_text( + &mut path, + !dynamic_reserved && request.uri().path().len() > 512, + ); + if let Some(user_agent) = &mut user_agent { + let truncated = request .headers() .get(hyper::header::USER_AGENT) - .map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)), - if policy.capture_headers { - sanitized_headers(request.headers()) - } else { - Vec::new() - }, - sensitive_values(request.headers(), request.uri().query()), - ) - } else { - ( - bounded_text(request.method().as_str().as_bytes(), 32), - bounded_text(request.uri().path().as_bytes(), 512), - None, - Vec::new(), - Vec::new(), - ) - }; + .is_some_and(|value| value.len() > USER_AGENT_MAX_BYTES); + super::wildcard::scrub_text(user_agent, truncated); + } + } Arc::new(Self { store, epoch, @@ -209,7 +227,8 @@ impl HttpTraceExchange { let reserved = self.reserve_locked(&mut state, dynamic); state.status = Some(response.status().as_u16()); if self.policy.capture_headers && reserved { - state.response_headers = sanitized_headers(response.headers()); + state.response_headers = + sanitized_headers(response.headers(), self.store.wildcard_redaction); } if reserved { state @@ -394,6 +413,18 @@ impl HttpTraceExchange { let redactions = std::mem::take(&mut state.redactions); scrub_body(&mut state.request_body.captured, &redactions); scrub_body(&mut state.response_body.captured, &redactions); + if self.store.wildcard_redaction { + super::wildcard::scrub_bytes( + &mut state.request_body.captured, + state.request_body.truncated + || state.request_body.state != Some(TraceBodyState::Complete), + ); + super::wildcard::scrub_bytes( + &mut state.response_body.captured, + state.response_body.truncated + || state.response_body.state != Some(TraceBodyState::Complete), + ); + } let request_body = body_snapshot(&self.policy, &mut state.request_body); let response_body = body_snapshot(&self.policy, &mut state.response_body); TraceRecord { diff --git a/src/web/trace/mod.rs b/src/web/trace/mod.rs index 02d631e..ca8d6f4 100644 --- a/src/web/trace/mod.rs +++ b/src/web/trace/mod.rs @@ -18,3 +18,7 @@ pub(crate) use types::{ TraceLifecycleContext, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind, TraceRoute, TraceWebSocketContext, }; + +// Shape-based sanitization is exclusively for diagnostic copies, never public routing. +mod wildcard; +pub(crate) use wildcard::scrub_text; diff --git a/src/web/trace/sanitize.rs b/src/web/trace/sanitize.rs index f560967..9c7443a 100644 --- a/src/web/trace/sanitize.rs +++ b/src/web/trace/sanitize.rs @@ -54,12 +54,21 @@ pub(super) fn response_dynamic_bytes( } /// Copies header names and only allowlisted bounded values. -pub(super) fn sanitized_headers(headers: &hyper::HeaderMap) -> Vec { +pub(super) fn sanitized_headers(headers: &hyper::HeaderMap, wildcard: bool) -> Vec { headers .iter() - .map(|(name, value)| TraceHeader { - name: name.as_str().to_string(), - value: header_value_allowed(name).then(|| bounded_text(value.as_bytes(), 4096)), + .map(|(name, value)| { + let mut copied = TraceHeader { + name: name.as_str().to_string(), + value: header_value_allowed(name).then(|| bounded_text(value.as_bytes(), 4096)), + }; + if wildcard { + super::wildcard::scrub_text(&mut copied.name, false); + if let Some(text) = &mut copied.value { + super::wildcard::scrub_text(text, value.len() > 4096); + } + } + copied }) .collect() } diff --git a/src/web/trace/store.rs b/src/web/trace/store.rs index f56d5ed..95bc447 100644 --- a/src/web/trace/store.rs +++ b/src/web/trace/store.rs @@ -77,6 +77,8 @@ pub(crate) struct TraceClearOutcome { /// Process-owned bounded WEB debug trace store. pub(crate) struct WebTraceStore { + /// Process-frozen redaction for credentials outside the finite hostname inventory. + pub(super) wildcard_redaction: bool, policy: ArcSwap, policy_update: Mutex<()>, enabled: AtomicBool, @@ -97,9 +99,20 @@ pub(crate) struct WebTraceStore { } impl WebTraceStore { - /// Creates a process-owned store from restart-only capacity and initial policy. + /// Creates an exact-host trace store for existing fixtures. + #[cfg(test)] pub(crate) fn new(policy: WebDebugConfig, limits: &WebLimitsConfig) -> Arc { + Self::new_with_wildcards(policy, limits, false) + } + + /// Freezes automatic-host diagnostic redaction independently of mutable debug policy. + pub(crate) fn new_with_wildcards( + policy: WebDebugConfig, + limits: &WebLimitsConfig, + wildcard_redaction: bool, + ) -> Arc { Arc::new(Self { + wildcard_redaction, enabled: AtomicBool::new(policy.enabled), policy: ArcSwap::from_pointee(policy), policy_update: Mutex::new(()), diff --git a/src/web/trace/store/websocket.rs b/src/web/trace/store/websocket.rs index 575a4c3..0632963 100644 --- a/src/web/trace/store/websocket.rs +++ b/src/web/trace/store/websocket.rs @@ -27,10 +27,19 @@ impl WebTraceStore { if !self.enabled.load(Ordering::Acquire) || !self.policy.load().enabled { return None; } - let user_agent = request + let mut user_agent = request .headers() .get(hyper::header::USER_AGENT) .map(|value| super::super::sanitize::bounded_text(value.as_bytes(), 512)); + if self.wildcard_redaction + && let Some(text) = &mut user_agent + { + let truncated = request + .headers() + .get(hyper::header::USER_AGENT) + .is_some_and(|value| value.len() > 512); + super::super::wildcard::scrub_text(text, truncated); + } Some(TraceWebSocketContext { connection_id, peer_ip, @@ -94,12 +103,18 @@ impl WebTraceStore { if !self.try_reserve_record(reservation) { return; } - let body = (policy.body_capture != WebDebugBodyCapture::Off).then(|| TraceBodySnapshot { - observed_bytes: payload.len() as u64, - captured: payload[..capture_bytes].to_vec(), - truncated: capture_limit != 0 && capture_bytes < payload.len(), - state: TraceBodyState::Complete, - }); + let mut body = + (policy.body_capture != WebDebugBodyCapture::Off).then(|| TraceBodySnapshot { + observed_bytes: payload.len() as u64, + captured: payload[..capture_bytes].to_vec(), + truncated: capture_limit != 0 && capture_bytes < payload.len(), + state: TraceBodyState::Complete, + }); + if self.wildcard_redaction + && let Some(body) = &mut body + { + super::super::wildcard::scrub_bytes(&mut body.captured, body.truncated); + } let frames = if policy.capture_frames && message_type == "binary" { websocket_frames(direction, payload, &self.frame_limits) } else { diff --git a/src/web/trace/wildcard.rs b/src/web/trace/wildcard.rs new file mode 100644 index 0000000..9feb6b0 --- /dev/null +++ b/src/web/trace/wildcard.rs @@ -0,0 +1,138 @@ +/// Redacts credential-shaped runs in diagnostic text without changing its byte budget. +pub(crate) fn scrub_text(text: &mut String, truncated: bool) { + let mut bytes = std::mem::take(text).into_bytes(); + scrub_bytes(&mut bytes, truncated); + // Only ASCII bytes are replaced with ASCII; valid UTF-8 remains valid. + *text = String::from_utf8(bytes).unwrap_or_default(); +} + +/// Redacts raw and percent-encoded runs, including prefixes cut by capture limits. +/// This deliberately conservative rule is never used to accept or reject traffic. +pub(super) fn scrub_bytes(bytes: &mut [u8], truncated: bool) { + let mut start = 0usize; + let mut count = 0usize; + let mut offset = 0usize; + while offset < bytes.len() { + let (value, consumed) = if bytes[offset] == b'%' && offset + 2 < bytes.len() { + match (hex(bytes[offset + 1]), hex(bytes[offset + 2])) { + (Some(a), Some(b)) => ((a << 4) | b, 3), + _ => (bytes[offset], 1), + } + } else { + (bytes[offset], 1) + }; + let partial_escape = truncated + && bytes[offset] == b'%' + && offset + 2 >= bytes.len() + && bytes[offset + 1..].iter().all(|byte| hex(*byte).is_some()); + if value.is_ascii_alphanumeric() || matches!(value, b'_' | b'-') || partial_escape { + if count == 0 { + start = offset; + } + count += 1; + } else { + if count >= 43 { + bytes[start..offset].fill(b'*'); + } + count = 0; + } + offset += consumed; + } + if count >= 43 || (truncated && count > 0) { + bytes[start..].fill(b'*'); + } +} + +fn hex(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostic_redaction_handles_raw_encoded_and_every_truncation_boundary() { + for credential in ["a".repeat(43), "%61".repeat(43)] { + let complete = format!("prefix/{credential}?ordinary=value"); + let mut text = complete.clone(); + scrub_text(&mut text, false); + assert_eq!(text.len(), complete.len()); + assert!(text.starts_with("prefix/")); + assert!(text.ends_with("?ordinary=value")); + assert!( + text[7..7 + credential.len()] + .bytes() + .all(|byte| byte == b'*') + ); + for end in 1..=credential.len() { + let mut prefix = format!("prefix/{}", &credential[..end]); + scrub_text(&mut prefix, true); + assert!(prefix[7..].bytes().all(|byte| byte == b'*'), "cut {end}"); + } + } + } + + #[test] + fn wildcard_diagnostic_capture_redacts_chunks_and_incomplete_bodies_only_when_enabled() { + use crate::config::{WebDebugBodyCapture, WebDebugConfig, WebLimitsConfig}; + use crate::web::trace::{TraceBodyState, TraceDirection, TraceRecordKind, WebTraceStore}; + for enabled in [false, true] { + for end in [ + None, + Some(TraceBodyState::Aborted), + Some(TraceBodyState::Error), + Some(TraceBodyState::Complete), + ] { + let policy = WebDebugConfig { + enabled: true, + body_capture: WebDebugBodyCapture::Full, + ..Default::default() + }; + let store = WebTraceStore::new_with_wildcards( + policy.clone(), + &WebLimitsConfig { + debug_records_capacity: 8, + debug_bytes_global: 1 << 20, + ..Default::default() + }, + enabled, + ); + store.apply_policy(2, &policy); + let request = hyper::Request::builder().uri("/public").body(()).unwrap(); + let trace = store + .begin_http(&request, "127.0.0.1".parse().unwrap()) + .unwrap(); + let bytes = if end == Some(TraceBodyState::Complete) { + vec![b'A'; 43] + } else { + vec![b'A'; 21] + }; + trace.body_data(TraceDirection::Request, &bytes[..10]); + trace.body_data(TraceDirection::Request, &bytes[10..]); + if let Some(end) = end { + trace.body_finished(TraceDirection::Request, end); + } + trace.response_ready(&hyper::Response::new(())); + trace.body_finished(TraceDirection::Response, TraceBodyState::Complete); + let records = store.snapshot_matching(|_| true); + let TraceRecordKind::Http(http) = &records[0].record.kind else { + panic!("HTTP record") + }; + assert_eq!( + http.request_body.as_ref().unwrap().captured, + if enabled { + vec![b'*'; bytes.len()] + } else { + bytes + } + ); + } + } + } +}