WEB: bounded opt-in wildcard host routing added

This commit is contained in:
Alexey
2026-10-17 23:42:26 +03:00
parent b2b088495d
commit 9738244285
67 changed files with 2572 additions and 239 deletions
+4
View File
@@ -137,3 +137,7 @@ async fn grouped_hosts_cannot_bypass_frozen_process_limits() {
} }
assert_eq!(active.web.runtime.as_ref().unwrap().vhosts.len(), 1); 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;
+84
View File
@@ -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()
);
}
+1 -1
View File
@@ -22,7 +22,7 @@
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not** //! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted. SYN limiter rules are process-owned and are //! applied; a warning is emitted. SYN limiter rules are process-owned and are
//! reconciled only during privileged startup. //! 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. //! capability timing policies or process-lifetime counter semantics.
//! Non-hot changes are never mixed into the runtime config snapshot. //! Non-hot changes are never mixed into the runtime config snapshot.
+4
View File
@@ -85,6 +85,10 @@ pub(super) fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot
warned = true; warned = true;
warn!("config reload: server listener settings changed; restart required"); 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 { if old.web.decoy_fasttrack_mode != new.web.decoy_fasttrack_mode {
warned = true; warned = true;
warn!("config reload: web.decoy_fasttrack_mode changed; restart required"); warn!("config reload: web.decoy_fasttrack_mode changed; restart required");
+2
View File
@@ -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_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_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs; 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 process_limits = cfg.web.limits.clone();
let decoy_fasttrack_mode = cfg.web.decoy_fasttrack_mode; let decoy_fasttrack_mode = cfg.web.decoy_fasttrack_mode;
cfg.web = new.web.clone(); cfg.web = new.web.clone();
cfg.web.limits = process_limits; cfg.web.limits = process_limits;
cfg.web.allow_wildcard_hosts = allow_wildcard_hosts;
cfg.web.decoy_fasttrack_mode = decoy_fasttrack_mode; cfg.web.decoy_fasttrack_mode = decoy_fasttrack_mode;
if cfg.web.carrier_negotiation_enabled() if cfg.web.carrier_negotiation_enabled()
&& cfg.web.carrier_learning && cfg.web.carrier_learning
+4
View File
@@ -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;
+61
View File
@@ -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));
}
}
}
+14 -2
View File
@@ -185,12 +185,24 @@ pub(super) fn build_upstream(
impl ProxyConfig { impl ProxyConfig {
/// Compares selected endpoints separately from source-only configuration equality. /// Compares selected endpoints separately from source-only configuration equality.
pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool { pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool {
fn selected(config: &ProxyConfig) -> impl Iterator<Item = (&String, SocketAddr)> { fn selected(config: &ProxyConfig) -> impl Iterator<Item = ((u8, &str), SocketAddr)> {
config config
.web .web
.runtime .runtime
.iter() .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() { .filter_map(|(host, vhost)| match vhost.decoy.as_ref() {
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, *addr)), WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, *addr)),
WebRuntimeDecoy::StaticDirectory(_) => None, WebRuntimeDecoy::StaticDirectory(_) => None,
+29
View File
@@ -357,3 +357,32 @@ async fn grouped_hosts_share_one_resolved_origin_and_endpoint_inventory() {
reordered.rebuild_runtime_web().unwrap(); reordered.rebuild_runtime_web().unwrap();
assert!(!loaded.config.web_decoy_endpoints_equal(&reordered)); 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]));
}
}
+5 -121
View File
@@ -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 WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0";
const MAX_WEB_STATIC_DEPTH: usize = 64; const MAX_WEB_STATIC_DEPTH: usize = 64;
/// Builds the immutable WEB routing and decoy snapshot for one generation. // Exact bindings and wildcard templates share one immutable snapshot builder.
pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { mod build;
let auth = config.runtime_user_auth().ok_or_else(|| { pub(super) use build::rebuild;
ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string()) // Cold wildcard derivation uses the existing immutable authentication snapshot.
})?; mod wildcard;
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(())
}
fn debug_key_fingerprint(secret: &[u8]) -> String { fn debug_key_fingerprint(secret: &[u8]) -> String {
let mut digest = Sha256::new(); let mut digest = Sha256::new();
+183
View File
@@ -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(())
}
+49
View File
@@ -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<Box<[[u8; 32]]>> {
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())
}
}
+11
View File
@@ -261,6 +261,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
]; ];
const WEB_CONFIG_KEYS: &[&str] = &[ const WEB_CONFIG_KEYS: &[&str] = &[
"allow_wildcard_hosts",
"enabled", "enabled",
"carrier", "carrier",
"carrier_method", "carrier_method",
@@ -277,6 +278,7 @@ const WEB_CONFIG_KEYS: &[&str] = &[
]; ];
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[ const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"wildcard",
"max_header_bytes", "max_header_bytes",
"max_body_bytes", "max_body_bytes",
"max_frame_payload_bytes", "max_frame_payload_bytes",
@@ -535,3 +537,12 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()
Ok(()) 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",
];
+8
View File
@@ -37,6 +37,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> {
LISTENER_CONFIG_KEYS, LISTENER_CONFIG_KEYS,
WEB_CONFIG_KEYS, WEB_CONFIG_KEYS,
WEB_LIMITS_CONFIG_KEYS, WEB_LIMITS_CONFIG_KEYS,
WEB_WILDCARD_LIMITS_CONFIG_KEYS,
WEB_DEBUG_CONFIG_KEYS, WEB_DEBUG_CONFIG_KEYS,
WEB_TIMEOUTS_CONFIG_KEYS, WEB_TIMEOUTS_CONFIG_KEYS,
WEB_VHOST_CONFIG_KEYS, WEB_VHOST_CONFIG_KEYS,
@@ -242,6 +243,13 @@ pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<Unkn
&["web", "limits"], &["web", "limits"],
WEB_LIMITS_CONFIG_KEYS, WEB_LIMITS_CONFIG_KEYS,
); );
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web", "limits", "wildcard"],
WEB_WILDCARD_LIMITS_CONFIG_KEYS,
);
check_known_table( check_known_table(
parsed_toml, parsed_toml,
&mut unknown, &mut unknown,
+3 -3
View File
@@ -75,7 +75,7 @@ pub(super) fn validate_source(config: &mut ProxyConfig) -> Result<()> {
)); ));
} }
validate_limits(&config.web.limits)?; validate_limits(&config.web.limits, config.web.allow_wildcard_hosts)?;
debug::validate(&config.web.debug, &config.web.limits)?; debug::validate(&config.web.debug, &config.web.limits)?;
let carriers = negotiation::validate(&config.web)?; let carriers = negotiation::validate(&config.web)?;
if carriers.contains(&WebCarrier::Https) && config.web.limits.max_http_handlers < 2 { if carriers.contains(&WebCarrier::Https) && config.web.limits.max_http_handlers < 2 {
@@ -182,7 +182,7 @@ fn validate_web_listener(
Ok(()) 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) { if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) {
return config_error("web.limits.max_header_bytes must be within [8192, 65536]"); 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", "web.limits pending ceilings must preserve one uplink batch and downlink progress",
); );
} }
memory::validate(limits)?; memory::validate(limits, wildcard)?;
Ok(()) Ok(())
} }
+57 -5
View File
@@ -11,7 +11,7 @@ const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024;
const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 64; const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 64;
/// Validates process-wide body, header, queue, static, and debug reservations. /// 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 { if limits.max_carrier_learning_entries == 0 {
return config_error("web.limits.max_carrier_learning_entries must be > 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(|| { .ok_or_else(|| {
ProxyError::Config("web.limits lane state reservation overflowed usize".to_string()) ProxyError::Config("web.limits lane state reservation overflowed usize".to_string())
})?; })?;
let wildcard_reservation = if wildcard {
wildcard_reservation(limits)?
} else {
0
};
let reserved = limits let reserved = limits
.pending_bytes_global .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(limits.max_static_bytes))
.and_then(|value| value.checked_add(debug_ring_index)) .and_then(|value| value.checked_add(debug_ring_index))
.and_then(|value| value.checked_add(status_pages)) .and_then(|value| value.checked_add(status_pages))
@@ -106,6 +112,52 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
Ok(()) 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<usize> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -113,13 +165,13 @@ mod tests {
#[test] #[test]
fn default_envelope_includes_bounded_lane_and_learning_metadata() { fn default_envelope_includes_bounded_lane_and_learning_metadata() {
let limits = WebLimitsConfig::default(); let limits = WebLimitsConfig::default();
assert!(validate(&limits).is_ok()); assert!(validate(&limits, false).is_ok());
let previous_envelope = WebLimitsConfig { let previous_envelope = WebLimitsConfig {
memory_envelope_bytes: 768 * 1024 * 1024, memory_envelope_bytes: 768 * 1024 * 1024,
..limits ..limits
}; };
assert!(validate(&previous_envelope).is_err()); assert!(validate(&previous_envelope, false).is_err());
} }
#[test] #[test]
@@ -128,7 +180,7 @@ mod tests {
max_profiles: usize::MAX, max_profiles: usize::MAX,
..WebLimitsConfig::default() ..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")); assert!(error.contains("web capability index reservation overflowed usize"));
} }
} }
+38 -9
View File
@@ -1,25 +1,45 @@
use super::*; 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<()> { pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
let limits = &config.web.limits; let limits = &config.web.limits;
let mut host_count = 0usize; let mut host_count = 0usize;
let mut rule_count = 0usize;
let mut profile_count = 0usize; let mut profile_count = 0usize;
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() { for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let count = match (&vhost.host, &vhost.hosts) { match (&vhost.host, &vhost.hosts) {
(Some(_), None) => 1, (Some(_), None) => {}
(None, Some(hosts)) if !hosts.is_empty() => hosts.len(), (None, Some(hosts)) if !hosts.is_empty() => {}
_ => { _ => {
return config_error(&format!( return config_error(&format!(
"web.vhosts[{vhost_idx}] requires exactly one of host or non-empty hosts" "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 host_count = host_count
.checked_add(count) .checked_add(exact)
.ok_or_else(|| ProxyError::Config("WEB hostname count overflowed usize".to_string()))?; .ok_or_else(|| ProxyError::Config("WEB hostname count overflowed usize".to_string()))?;
profile_count = count profile_count = exact
.checked_mul(vhost.profiles.len()) .checked_add(usize::from(patterns > 0))
.and_then(|count| count.checked_mul(vhost.profiles.len()))
.and_then(|expanded| profile_count.checked_add(expanded)) .and_then(|expanded| profile_count.checked_add(expanded))
.ok_or_else(|| ProxyError::Config("WEB profile count overflowed usize".to_string()))?; .ok_or_else(|| ProxyError::Config("WEB profile count overflowed usize".to_string()))?;
if host_count > limits.max_vhosts { if host_count > limits.max_vhosts {
@@ -43,7 +63,16 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
} else { } else {
format!("web.vhosts[{vhost_idx}].hosts[{host_idx}]") 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()) { if !hosts.insert(host.clone()) {
return config_error(&format!("duplicate WEB vhost host `{host}` at {field}")); return config_error(&format!("duplicate WEB vhost host `{host}` at {field}"));
} }
@@ -239,3 +239,7 @@ fn grouped_static_snapshot_is_loaded_and_charged_once() {
std::fs::write(directory.path().join("index.html"), b"longer").unwrap(); std::fs::write(directory.path().join("index.html"), b"longer").unwrap();
assert!(load_config_error_from_temp_toml(&format!("{source}{limits}")).contains("max_static")); 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;
@@ -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
);
}
+2 -2
View File
@@ -55,11 +55,11 @@ pub use server::{
pub use web::{ pub use web::{
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode, WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode,
WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig, WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig,
WebSecretMode, WebTimeoutsConfig, WebVhostConfig, WebSecretMode, WebTimeoutsConfig, WebVhostConfig, WebWildcardLimitsConfig,
}; };
pub(crate) use web::{ pub(crate) use web::{
WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile,
WebRuntimeProfileShared, WebRuntimeVhost, WebStaticAsset, WebStaticSite, WebRuntimeProfileShared, WebRuntimeVhost, WebRuntimeWildcard, WebStaticAsset, WebStaticSite,
}; };
pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES; pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES;
#[allow(unused_imports)] #[allow(unused_imports)]
+18 -6
View File
@@ -9,6 +9,10 @@ use serde::{Deserialize, Serialize};
use super::web_carrier::{WebCarrier, WebCarrierMethod, WebCarriers}; use super::web_carrier::{WebCarrier, WebCarrierMethod, WebCarriers};
use super::web_debug::WebDebugConfig; 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. // Serialized WEB defaults remain separate from the runtime data model.
mod defaults; mod defaults;
use 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)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebVhostConfig { 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>, pub host: Option<String>,
/// 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub hosts: Option<Vec<String>>, pub hosts: Option<Vec<String>>,
/// Optional canonical WEB endpoint prefix without surrounding slashes. /// Optional canonical WEB endpoint prefix without surrounding slashes.
@@ -97,6 +101,9 @@ pub struct WebVhostConfig {
/// Hard process and protocol limits for WEB ingress. /// Hard process and protocol limits for WEB ingress.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebLimitsConfig { 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. /// Maximum bytes accepted while parsing one HTTP request head.
#[serde(default = "default_web_max_header_bytes")] #[serde(default = "default_web_max_header_bytes")]
pub max_header_bytes: usize, pub max_header_bytes: usize,
@@ -199,7 +206,7 @@ pub struct WebLimitsConfig {
/// Maximum concrete hostname count across all WEB groups. /// Maximum concrete hostname count across all WEB groups.
#[serde(default = "default_web_max_vhosts")] #[serde(default = "default_web_max_vhosts")]
pub max_vhosts: usize, 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")] #[serde(default = "default_web_max_profiles")]
pub max_profiles: usize, pub max_profiles: usize,
/// Maximum static snapshot entry count, charged once per source group. /// Maximum static snapshot entry count, charged once per source group.
@@ -243,6 +250,7 @@ pub struct WebLimitsConfig {
impl Default for WebLimitsConfig { impl Default for WebLimitsConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
wildcard: WebWildcardLimitsConfig::default(),
max_header_bytes: default_web_max_header_bytes(), max_header_bytes: default_web_max_header_bytes(),
max_body_bytes: default_web_max_body_bytes(), max_body_bytes: default_web_max_body_bytes(),
max_frame_payload_bytes: default_web_max_frame_payload_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. /// WEB ingress, carrier, fallback, and lifecycle configuration.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebConfig { 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. /// Enables issuance of new WEB bridge and session credentials.
#[serde(default)] #[serde(default)]
pub enabled: bool, pub enabled: bool,
@@ -458,7 +469,7 @@ pub struct WebConfig {
/// WEB lifecycle deadlines. /// WEB lifecycle deadlines.
#[serde(default)] #[serde(default)]
pub timeouts: WebTimeoutsConfig, pub timeouts: WebTimeoutsConfig,
/// Exact hostname groups served by WEB listeners. /// Exact hostname and opted-in wildcard groups served by WEB listeners.
#[serde(default)] #[serde(default)]
pub vhosts: Vec<WebVhostConfig>, pub vhosts: Vec<WebVhostConfig>,
/// Validated immutable runtime snapshot built during configuration loading. /// Validated immutable runtime snapshot built during configuration loading.
@@ -494,6 +505,7 @@ impl Default for WebConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
enabled: false, enabled: false,
allow_wildcard_hosts: false,
carrier: WebCarrier::default(), carrier: WebCarrier::default(),
carrier_method: WebCarrierMethod::default(), carrier_method: WebCarrierMethod::default(),
conveyor: default_web_conveyor(), conveyor: default_web_conveyor(),
@@ -516,5 +528,5 @@ impl Default for WebConfig {
mod runtime; mod runtime;
pub(crate) use runtime::{ pub(crate) use runtime::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeProfileShared, WebRuntimeVhost, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeProfileShared, WebRuntimeVhost,
WebStaticAsset, WebStaticSite, WebRuntimeWildcard, WebStaticAsset, WebStaticSite,
}; };
+39 -5
View File
@@ -1,20 +1,28 @@
use super::*; use super::*;
/// Precomputed WEB configuration consumed by listener hot paths. /// Precomputed WEB configuration consumed by listener hot paths.
#[derive(Debug)] #[derive(Debug, Default)]
pub(crate) struct WebRuntimeConfig { 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<String, Arc<WebRuntimeVhost>>,
/// Lowest-priority whole-group fallback.
pub(crate) catch_all: Option<Arc<WebRuntimeVhost>>,
/// Canonical host lookup used by HTTP request routing. /// Canonical host lookup used by HTTP request routing.
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>, pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
/// Flat profile inventory used by startup link emission. /// Flat profile inventory used by startup link emission.
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>, pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
/// 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]]>, pub(crate) capabilities: Box<[[u8; 32]]>,
} }
/// Precomputed immutable virtual-host data. /// Precomputed immutable virtual-host data.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct WebRuntimeVhost { pub(crate) struct WebRuntimeVhost {
/// Canonical lowercase ACE hostname. /// Shared automatic-host policy; exact hosts keep their precomputed bindings.
pub(crate) wildcard: Option<WebRuntimeWildcard>,
/// Canonical lowercase ACE hostname; empty for a wildcard template.
pub(crate) host: String, pub(crate) host: String,
/// Exact slash-delimited endpoint base, including the trailing slash. /// Exact slash-delimited endpoint base, including the trailing slash.
pub(crate) base: String, pub(crate) base: String,
@@ -24,13 +32,13 @@ pub(crate) struct WebRuntimeVhost {
pub(crate) decoy: Arc<WebRuntimeDecoy>, pub(crate) decoy: Arc<WebRuntimeDecoy>,
/// Upstream connect and response-head deadline. /// Upstream connect and response-head deadline.
pub(crate) decoy_header_secs: u64, 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<Arc<WebRuntimeProfile>>, pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
/// Contiguous capability table aligned one-to-one with `profiles`. /// Contiguous capability table aligned one-to-one with `profiles`.
pub(crate) capabilities: Box<[[u8; 32]]>, pub(crate) capabilities: Box<[[u8; 32]]>,
} }
/// Precomputed exact-user capability entry. /// Authenticated exact-host profile binding.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct WebRuntimeProfile { pub(crate) struct WebRuntimeProfile {
/// Canonical host that owns this profile. /// 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. /// Limits still apply to each binding's exact host/user/mode quota owner.
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct WebRuntimeProfileShared { 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. /// Stable public destination tuple supplied to relay routing.
pub(crate) public_addr: SocketAddr, pub(crate) public_addr: SocketAddr,
/// Exact access user authenticated by logical streams. /// Exact access user authenticated by logical streams.
@@ -106,3 +116,27 @@ pub(crate) struct WebStaticAsset {
/// Strong SHA-256 entity tag. /// Strong SHA-256 entity tag.
pub(crate) etag: String, 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<Arc<WebRuntimeProfileShared>>,
}
impl WebRuntimeConfig {
/// Selects exactly one group: exact, longest suffix, then catch-all.
pub(crate) fn route(&self, host: &str) -> Option<&Arc<WebRuntimeVhost>> {
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()
}
}
+32
View File
@@ -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,
}
}
}
+5 -1
View File
@@ -143,7 +143,11 @@ pub(super) async fn run_telemt_core(
config.server.max_connections as usize config.server.max_connections as usize
}; };
let max_connections = Arc::new(Semaphore::new(max_connections_limit)); 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 web_runtime_control = WebRuntimeControl::new();
let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>)); let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>));
+4
View File
@@ -438,6 +438,10 @@ pub(crate) fn resolve_reload_config(
fields.push("web.limits".to_string()); fields.push("web.limits".to_string());
effective.web.limits = old.web.limits.clone(); 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 { if old.web.decoy_fasttrack_mode != desired.web.decoy_fasttrack_mode {
fields.push("web.decoy_fasttrack_mode".to_string()); fields.push("web.decoy_fasttrack_mode".to_string());
effective.web.decoy_fasttrack_mode = old.web.decoy_fasttrack_mode; effective.web.decoy_fasttrack_mode = old.web.decoy_fasttrack_mode;
+52 -12
View File
@@ -28,6 +28,8 @@ mod activity;
mod body; mod body;
// Canonical capability parsing and complete scans remain isolated from HTTP routing. // Canonical capability parsing and complete scans remain isolated from HTTP routing.
mod capability; mod capability;
// Lazy wildcard derivation shares one table between containment and bridge authentication.
mod wildcard;
// Authentic credential containment stays independent from carrier routing. // Authentic credential containment stays independent from carrier routing.
mod secrets; mod secrets;
// Decoy routing and upstream proxying are isolated from carrier authentication. // 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 { let Some(web_runtime) = config.web.runtime.as_ref() else {
return generic_not_found(); 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(); return generic_not_found();
}; };
let Some(vhost) = web_runtime.vhosts.get(host).cloned() else { let table = match wildcard::prepare(&mut request, &config, &vhost, host, &runtime) {
return generic_not_found(); 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); let suffix = request.uri().path().strip_prefix(&vhost.base);
if suffix == Some(WEBSOCKET_SUFFIX) { if suffix == Some(WEBSOCKET_SUFFIX) {
return websocket::handle( return websocket::handle(
@@ -200,6 +219,7 @@ async fn handle_request(
trusted_proxy_cidrs, trusted_proxy_cidrs,
runtime, runtime,
vhost, vhost,
host,
) )
.await; .await;
} }
@@ -211,6 +231,7 @@ async fn handle_request(
trusted_proxy_cidrs, trusted_proxy_cidrs,
runtime, runtime,
vhost, vhost,
host,
) )
.await; .await;
} }
@@ -223,6 +244,9 @@ async fn handle_request(
runtime, runtime,
generation, generation,
vhost, vhost,
host,
table,
web_runtime.snapshot_id,
) )
.await; .await;
} }
@@ -241,6 +265,9 @@ async fn handle_root(
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
generation: Arc<RuntimeGeneration>, generation: Arc<RuntimeGeneration>,
vhost: Arc<WebRuntimeVhost>, vhost: Arc<WebRuntimeVhost>,
host: &str,
table: Option<crate::web::manager::wildcard::CapabilityTable>,
snapshot_id: u64,
) -> HttpResponse { ) -> HttpResponse {
let representation = recovery::classify(&request); let representation = recovery::classify(&request);
if matches!(representation, recovery::RootRepresentation::Invalid) { if matches!(representation, recovery::RootRepresentation::Invalid) {
@@ -279,7 +306,8 @@ async fn handle_root(
.record_decoy_fasttrack(WebDecoyFastTrackDisposition::EnforceCandidateFullScan); .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 recovery_requested = matches!(representation, recovery::RootRepresentation::Recovery(_));
let profile = matched_profile.filter(|_| canonical && request.method() == Method::GET); let profile = matched_profile.filter(|_| canonical && request.method() == Method::GET);
let Some(profile) = profile else { let Some(profile) = profile else {
@@ -302,7 +330,7 @@ async fn handle_root(
.and_then(|value| value.to_str().ok()); .and_then(|value| value.to_str().ok());
let recovery_session = match representation { let recovery_session = match representation {
recovery::RootRepresentation::Recovery(Some(hash)) => { recovery::RootRepresentation::Recovery(Some(hash)) => {
runtime.bridge_recovery_session(hash, &vhost.host, &profile) runtime.bridge_recovery_session(hash, host, &profile)
} }
_ => None, _ => None,
}; };
@@ -341,6 +369,17 @@ async fn handle_root(
return serve_decoy(request, vhost, true, &runtime).await; 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) { if let Some(trace) = request_trace(&request) {
trace.bind_profile(&profile, bootstrap.trace_session_id); trace.bind_profile(&profile, bootstrap.trace_session_id);
trace.register_redaction(bootstrap.token.as_bytes()); trace.register_redaction(bootstrap.token.as_bytes());
@@ -363,7 +402,7 @@ async fn handle_root(
} }
let Some(response) = recovery::response( let Some(response) = recovery::response(
&bootstrap, &bootstrap,
&vhost, host,
&profile, &profile,
&config.web.limits, &config.web.limits,
&config.web.timeouts, &config.web.timeouts,
@@ -374,7 +413,7 @@ async fn handle_root(
return response; return response;
} }
let page = bridge::render( let page = bridge::render(
&vhost.host, host,
&vhost.base, &vhost.base,
&bootstrap.token, &bootstrap.token,
config.web.limits.carrier_batch_bytes, config.web.limits.carrier_batch_bytes,
@@ -435,6 +474,7 @@ async fn handle_api(
trusted_proxy_cidrs: &[IpNetwork], trusted_proxy_cidrs: &[IpNetwork],
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>, vhost: Arc<WebRuntimeVhost>,
host: &str,
) -> HttpResponse { ) -> HttpResponse {
if request.uri().query().is_some() || !compatible_cookie_header(&request) { if request.uri().query().is_some() || !compatible_cookie_header(&request) {
return serve_decoy(request, vhost, true, &runtime).await; return serve_decoy(request, vhost, true, &runtime).await;
@@ -450,12 +490,12 @@ async fn handle_api(
}; };
match request.uri().path().strip_prefix(&vhost.base) { match request.uri().path().strip_prefix(&vhost.base) {
Some("api/v1/session") => { 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/up") => handle_up(request, runtime, vhost, host, token_hash).await,
Some("api/v1/down") => handle_down(request, runtime, vhost, token_hash).await, Some("api/v1/down") => handle_down(request, runtime, vhost, host, token_hash).await,
Some("api/v1/diagnostic") => { 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, _ => serve_decoy(request, vhost, true, &runtime).await,
} }
+4
View File
@@ -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 mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
let profile = Arc::clone(&config.web.runtime.as_ref().unwrap().profiles[0]); let profile = Arc::clone(&config.web.runtime.as_ref().unwrap().profiles[0]);
let vhost = Arc::new(WebRuntimeVhost { let vhost = Arc::new(WebRuntimeVhost {
wildcard: None,
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
base: "/relay/".to_string(), base: "/relay/".to_string(),
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, 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(); let mut vhosts = BTreeMap::new();
vhosts.insert("proxy.example.com".to_string(), vhost); vhosts.insert("proxy.example.com".to_string(), vhost);
config.web.runtime = Some(Arc::new(WebRuntimeConfig { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id: 0,
wildcards: Default::default(),
catch_all: None,
vhosts, vhosts,
profiles: vec![profile], profiles: vec![profile],
capabilities: vec![capability].into_boxed_slice(), capabilities: vec![capability].into_boxed_slice(),
@@ -63,6 +63,7 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() {
vhosts.insert( vhosts.insert(
"proxy.example.com".to_string(), "proxy.example.com".to_string(),
Arc::new(WebRuntimeVhost { Arc::new(WebRuntimeVhost {
wildcard: None,
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
base: "/relay/".to_string(), base: "/relay/".to_string(),
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
@@ -78,6 +79,7 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() {
vhosts.insert( vhosts.insert(
"other.example.com".to_string(), "other.example.com".to_string(),
Arc::new(WebRuntimeVhost { Arc::new(WebRuntimeVhost {
wildcard: None,
host: "other.example.com".to_string(), host: "other.example.com".to_string(),
base: "/other/".to_string(), base: "/other/".to_string(),
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, 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 { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id: 0,
wildcards: Default::default(),
catch_all: None,
vhosts, vhosts,
profiles: vec![profile], profiles: vec![profile],
capabilities: vec![capability].into_boxed_slice(), capabilities: vec![capability].into_boxed_slice(),
+4
View File
@@ -195,6 +195,7 @@ async fn decoy_forwarding_preserves_every_reference_request_target() {
vhosts.insert( vhosts.insert(
"proxy.example.com".to_string(), "proxy.example.com".to_string(),
Arc::new(WebRuntimeVhost { Arc::new(WebRuntimeVhost {
wildcard: None,
host: previous.host.clone(), host: previous.host.clone(),
base: previous.base.clone(), base: previous.base.clone(),
decoy_fasttrack_mode: previous.decoy_fasttrack_mode, 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 { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id: 0,
wildcards: Default::default(),
catch_all: None,
vhosts, vhosts,
profiles: runtime_config.profiles.clone(), profiles: runtime_config.profiles.clone(),
capabilities: runtime_config.capabilities.clone(), capabilities: runtime_config.capabilities.clone(),
+3 -2
View File
@@ -19,6 +19,7 @@ pub(super) async fn handle(
request: Request<RequestBody>, request: Request<RequestBody>,
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>, vhost: Arc<WebRuntimeVhost>,
host: &str,
token_hash: TokenHash, token_hash: TokenHash,
client_ip: IpAddr, client_ip: IpAddr,
) -> HttpResponse { ) -> HttpResponse {
@@ -26,7 +27,7 @@ pub(super) async fn handle(
return serve_decoy(request, vhost, true, &runtime).await; return serve_decoy(request, vhost, true, &runtime).await;
} }
let Some((trace_session_id, profile, body_timeout)) = let Some((trace_session_id, profile, body_timeout)) =
runtime.bootstrap_trace_identity(token_hash, &vhost.host) runtime.bootstrap_trace_identity(token_hash, host)
else { else {
return serve_decoy(request, vhost, true, &runtime).await; return serve_decoy(request, vhost, true, &runtime).await;
}; };
@@ -56,7 +57,7 @@ pub(super) async fn handle(
let Some(event) = parse_event(&body) else { let Some(event) = parse_event(&body) else {
return serve_decoy(request, vhost, true, &runtime).await; 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) => { Ok(first) => {
if first { if first {
runtime.trace().record_profile_lifecycle( runtime.trace().record_profile_lifecycle(
+2 -1
View File
@@ -20,6 +20,7 @@ pub(super) async fn handle_down(
request: Request<RequestBody>, request: Request<RequestBody>,
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>, vhost: Arc<WebRuntimeVhost>,
host: &str,
token_hash: TokenHash, token_hash: TokenHash,
) -> HttpResponse { ) -> HttpResponse {
if !matches!(*request.method(), hyper::Method::POST | hyper::Method::PUT) 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 { let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else {
return serve_decoy(request, vhost, true, &runtime).await; 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; return serve_decoy(request, vhost, true, &runtime).await;
}; };
if session.carrier().uses_websocket() { if session.carrier().uses_websocket() {
+4
View File
@@ -348,3 +348,7 @@ async fn alias_recovery_cannot_retire_a_foreign_session() {
assert!(runtime.get_session(token_hash(&b), HOSTS[1]).is_ok()); assert!(runtime.get_session(token_hash(&b), HOSTS[1]).is_ok());
stop(runtime, &[generation]).await; stop(runtime, &[generation]).await;
} }
// Automatic hosts share policies but retain exact-host authentication and quotas.
#[path = "host_list_tests/wildcard.rs"]
mod wildcard;
+269
View File
@@ -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::<Sha256>::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<WebProcessRuntime>, 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::<String>();
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;
@@ -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::<u64>()
.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::<usize>()
.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::<String>();
let policies = (1..profiles)
.map(|index| {
format!("[[web.vhosts.profiles]]\nuser = \"u{index}\"\nsecret_mode = \"plain\"\n")
})
.collect::<String>();
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;
}
@@ -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::<String>();
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;
}
@@ -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;
}
@@ -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;
}
+3 -3
View File
@@ -6,7 +6,7 @@ use serde::Serialize;
use super::request::{bearer_token_hash, compatible_cookie_header}; use super::request::{bearer_token_hash, compatible_cookie_header};
use super::response::{full_response, insert_header}; use super::response::{full_response, insert_header};
use super::{HttpResponse, RequestBody}; use super::{HttpResponse, RequestBody};
use crate::config::{WebRuntimeProfile, WebRuntimeVhost}; use crate::config::WebRuntimeProfile;
use crate::web::manager::{BootstrapResult, TokenHash}; use crate::web::manager::{BootstrapResult, TokenHash};
pub(super) const MEDIA_TYPE: &str = "application/vnd.telemt.web-recovery+json"; pub(super) const MEDIA_TYPE: &str = "application/vnd.telemt.web-recovery+json";
@@ -71,7 +71,7 @@ pub(super) fn has_media_type(request: &Request<RequestBody>) -> bool {
/// Builds the bounded no-store recovery representation. /// Builds the bounded no-store recovery representation.
pub(super) fn response( pub(super) fn response(
bootstrap: &BootstrapResult, bootstrap: &BootstrapResult,
vhost: &WebRuntimeVhost, host: &str,
profile: &WebRuntimeProfile, profile: &WebRuntimeProfile,
limits: &crate::config::WebLimitsConfig, limits: &crate::config::WebLimitsConfig,
timeouts: &crate::config::WebTimeoutsConfig, timeouts: &crate::config::WebTimeoutsConfig,
@@ -101,7 +101,7 @@ pub(super) fn response(
}, },
}; };
let body = serde_json::to_vec(&document).ok()?; 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; return None;
} }
let mut response = full_response(StatusCode::OK, Bytes::from(body)); let mut response = full_response(StatusCode::OK, Bytes::from(body));
+3 -1
View File
@@ -17,7 +17,9 @@ use super::capability::scan_capabilities;
// Canonical host and forwarded-address provenance remain isolated from credentials. // Canonical host and forwarded-address provenance remain isolated from credentials.
mod identity; 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. /// Matches one capability after a complete branchless scan of the virtual host table.
pub(super) fn match_profile( pub(super) fn match_profile(
+43
View File
@@ -25,6 +25,49 @@ pub(in crate::web::http) fn canonical_request_host<B>(request: &Request<B>) -> O
Some(host) 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<B>,
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::<IpAddr>().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. /// Accepts one forwarded client address or the direct address of a trusted peer.
pub(in crate::web::http) fn client_ip<B>( pub(in crate::web::http) fn client_ip<B>(
request: &Request<B>, request: &Request<B>,
+40 -16
View File
@@ -14,20 +14,39 @@ pub(super) fn mark_internal_credential<B>(
config: &WebRuntimeConfig, config: &WebRuntimeConfig,
runtime: &WebProcessRuntime, 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<B>(request: &mut Request<B>) {
request.extensions_mut().insert(InternalCredential);
}
/// Visits canonical raw or percent-decoded credential windows without allocating.
pub(super) fn visit_metadata<B>(
request: &Request<B>,
mut visit: impl FnMut(&[u8; 32]) -> bool,
) -> bool {
let uri = request.uri(); let uri = request.uri();
let uri_contains = uri uri.authority()
.authority() .is_some_and(|value| visit_text(value.as_str().as_bytes(), &mut visit))
.is_some_and(|authority| contains_secret(authority.as_str().as_bytes(), config, runtime))
|| uri || uri
.path_and_query() .path_and_query()
.is_some_and(|path| contains_secret(path.as_str().as_bytes(), config, runtime)); .is_some_and(|value| visit_text(value.as_str().as_bytes(), &mut visit))
let headers_contain = request.headers().iter().any(|(name, value)| { || request.headers().iter().any(|(name, value)| {
contains_secret(name.as_str().as_bytes(), config, runtime) visit_text(name.as_str().as_bytes(), &mut visit)
|| contains_secret(value.as_bytes(), config, runtime) || visit_text(value.as_bytes(), &mut visit)
}); })
if uri_contains || headers_contain {
request.extensions_mut().insert(InternalCredential);
}
} }
/// Returns whether request metadata was authenticated before routing. /// Returns whether request metadata was authenticated before routing.
@@ -35,7 +54,7 @@ pub(super) fn has_internal_credential<B>(request: &Request<B>) -> bool {
request.extensions().get::<InternalCredential>().is_some() request.extensions().get::<InternalCredential>().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 window = [0; 43];
let mut run_len = 0usize; let mut run_len = 0usize;
let mut offset = 0usize; let mut offset = 0usize;
@@ -58,10 +77,7 @@ fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessR
window[42] = byte; window[42] = byte;
} }
if run_len >= window.len() if run_len >= window.len()
&& canonical_credential(&window).is_some_and(|candidate| { && canonical_credential(&window).is_some_and(|candidate| visit(&candidate))
bool::from(scan_capabilities(&config.capabilities, &candidate).matched)
|| runtime.authentic_token(&candidate)
})
{ {
return true; return true;
} }
@@ -88,3 +104,11 @@ fn hex_value(value: u8) -> Option<u8> {
#[cfg(test)] #[cfg(test)]
#[path = "secrets/tests.rs"] #[path = "secrets/tests.rs"]
mod tests; 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)
})
}
+7 -8
View File
@@ -26,6 +26,7 @@ pub(super) async fn handle_session(
request: Request<RequestBody>, request: Request<RequestBody>,
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>, vhost: Arc<WebRuntimeVhost>,
host: &str,
token_hash: TokenHash, token_hash: TokenHash,
client_ip: IpAddr, client_ip: IpAddr,
) -> HttpResponse { ) -> HttpResponse {
@@ -39,7 +40,7 @@ pub(super) async fn handle_session(
let Some(carrier_failure) = optional_failure_header(&request) else { let Some(carrier_failure) = optional_failure_header(&request) else {
return serve_decoy(request, vhost, true, &runtime).await; 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) if let Some(trace) = request_trace(&request)
&& let Some(session) = &session && let Some(session) = &session
{ {
@@ -63,7 +64,7 @@ pub(super) async fn handle_session(
}; };
if !body.is_empty() if !body.is_empty()
|| runtime || runtime
.close_token(token_hash, &vhost.host, carrier_failure) .close_token(token_hash, host, carrier_failure)
.is_err() .is_err()
{ {
return serve_decoy(request, vhost, true, &runtime).await; 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) { if request.method() != Method::POST || !binary_content_type(&request) {
return serve_decoy(request, vhost, true, &runtime).await; 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; return serve_decoy(request, vhost, true, &runtime).await;
}; };
let up_window = if request.headers().contains_key("x-telemt-up-window") { 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 carrier_request = carrier_request.with_up_window(up_window);
let ip_learning_eligible = carrier_ip_learning_eligible(&request, client_ip); let ip_learning_eligible = carrier_ip_learning_eligible(&request, client_ip);
let Some((trace_session_id, profile, body_timeout)) = let Some((trace_session_id, profile, body_timeout)) =
runtime.bootstrap_trace_identity(token_hash, &vhost.host) runtime.bootstrap_trace_identity(token_hash, host)
else { else {
return serve_decoy(request, vhost, true, &runtime).await; return serve_decoy(request, vhost, true, &runtime).await;
}; };
@@ -117,7 +118,7 @@ pub(super) async fn handle_session(
} }
match runtime.create_session( match runtime.create_session(
token_hash, token_hash,
&vhost.host, host,
client_ip, client_ip,
&body, &body,
carrier_request, carrier_request,
@@ -185,9 +186,7 @@ pub(super) async fn handle_session(
} }
Err(ManagerError::Committed) => { Err(ManagerError::Committed) => {
let mut response = carrier_empty(StatusCode::CONFLICT); let mut response = carrier_empty(StatusCode::CONFLICT);
if let Some(echo) = if let Some(echo) = runtime.carrier_echo(token_hash, host, client_ip, carrier_request) {
runtime.carrier_echo(token_hash, &vhost.host, client_ip, carrier_request)
{
response.headers_mut().insert( response.headers_mut().insert(
HeaderName::from_static("x-carrier-mode"), HeaderName::from_static("x-carrier-mode"),
HeaderValue::from_static(echo.carrier.as_str()), HeaderValue::from_static(echo.carrier.as_str()),
+6
View File
@@ -129,6 +129,7 @@ fn runtime_config_with_carriers_and_deadlines(
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability, capability,
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: "203.0.113.10:443".parse().unwrap(), public_addr: "203.0.113.10:443".parse().unwrap(),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
@@ -158,6 +159,7 @@ fn runtime_config_with_carriers_and_deadlines(
index: "index.html".to_string(), index: "index.html".to_string(),
}); });
let vhost = Arc::new(WebRuntimeVhost { let vhost = Arc::new(WebRuntimeVhost {
wildcard: None,
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
base: base.to_string(), base: base.to_string(),
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
@@ -171,6 +173,7 @@ fn runtime_config_with_carriers_and_deadlines(
vhosts.insert( vhosts.insert(
"other.example.com".to_string(), "other.example.com".to_string(),
Arc::new(WebRuntimeVhost { Arc::new(WebRuntimeVhost {
wildcard: None,
host: "other.example.com".to_string(), host: "other.example.com".to_string(),
base: "/".to_string(), base: "/".to_string(),
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off, 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.limits.max_bootstraps_per_ip = 1;
config.web.timeouts.shutdown_secs = 1; config.web.timeouts.shutdown_secs = 1;
config.web.runtime = Some(Arc::new(WebRuntimeConfig { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id: 0,
wildcards: Default::default(),
catch_all: None,
vhosts, vhosts,
profiles: vec![profile], profiles: vec![profile],
capabilities: vec![capability].into_boxed_slice(), capabilities: vec![capability].into_boxed_slice(),
+2 -1
View File
@@ -6,6 +6,7 @@ pub(super) async fn handle_up(
request: Request<RequestBody>, request: Request<RequestBody>,
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>, vhost: Arc<WebRuntimeVhost>,
host: &str,
token_hash: crate::web::manager::TokenHash, token_hash: crate::web::manager::TokenHash,
) -> HttpResponse { ) -> HttpResponse {
if !matches!(*request.method(), Method::POST | Method::PUT) || !binary_content_type(&request) { if !matches!(*request.method(), Method::POST | Method::PUT) || !binary_content_type(&request) {
@@ -15,7 +16,7 @@ pub(super) async fn handle_up(
else { else {
return serve_decoy(request, vhost, true, &runtime).await; 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; return serve_decoy(request, vhost, true, &runtime).await;
}; };
if session.carrier().uses_websocket() { if session.carrier().uses_websocket() {
+2 -1
View File
@@ -253,6 +253,7 @@ pub(super) async fn handle(
trusted_proxy_cidrs: &[IpNetwork], trusted_proxy_cidrs: &[IpNetwork],
runtime: Arc<WebProcessRuntime>, runtime: Arc<WebProcessRuntime>,
vhost: Arc<WebRuntimeVhost>, vhost: Arc<WebRuntimeVhost>,
host: &str,
) -> HttpResponse { ) -> HttpResponse {
let request_deadline = super::request_deadline(&request); let request_deadline = super::request_deadline(&request);
let Some(parsed) = parse_upgrade(&request) else { let Some(parsed) = parse_upgrade(&request) else {
@@ -265,7 +266,7 @@ pub(super) async fn handle(
else { else {
return serve_decoy(request, vhost, true, &runtime).await; 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; return serve_decoy(request, vhost, true, &runtime).await;
}; };
let kind = match (parsed.carrier, session.carrier()) { let kind = match (parsed.carrier, session.carrier()) {
+72
View File
@@ -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<B>(
request: &mut Request<B>,
config: &ProxyConfig,
vhost: &WebRuntimeVhost,
host: &str,
runtime: &WebProcessRuntime,
) -> Result<Option<CapabilityTable>, ()> {
// 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<Arc<WebRuntimeProfile>> {
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),
}))
}
+13 -1
View File
@@ -21,6 +21,8 @@ use crate::web::telemetry::{WebRejectionReason, WebTelemetry};
use crate::web::trace::WebTraceStore; use crate::web::trace::WebTraceStore;
// Credential maps, quotas, and token-bucket helpers remain private to the manager. // Credential maps, quotas, and token-bucket helpers remain private to the manager.
mod state; 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. // Carrier attempt metadata remains explicit and independent from HTTP parsing.
mod negotiation; mod negotiation;
// Bounded process-local carrier evidence is isolated from session registries. // Bounded process-local carrier evidence is isolated from session registries.
@@ -221,6 +223,8 @@ pub(crate) struct WebProcessRuntime {
active_runtime: Arc<ArcSwap<RuntimeGeneration>>, active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
trace: Arc<WebTraceStore>, trace: Arc<WebTraceStore>,
token_authenticator: TokenAuthenticator, token_authenticator: TokenAuthenticator,
/// Process-frozen optional wildcard derivation and retention state.
pub(crate) wildcard: Option<wildcard::WildcardState>,
limits: WebLimitsConfig, limits: WebLimitsConfig,
state: Mutex<ManagerState>, state: Mutex<ManagerState>,
stream_admission: Mutex<StreamAdmissionState>, stream_admission: Mutex<StreamAdmissionState>,
@@ -253,7 +257,11 @@ impl WebProcessRuntime {
#[cfg(test)] #[cfg(test)]
pub(crate) fn start(active_runtime: Arc<ArcSwap<RuntimeGeneration>>) -> Arc<Self> { pub(crate) fn start(active_runtime: Arc<ArcSwap<RuntimeGeneration>>) -> Arc<Self> {
let config = active_runtime.load().config(); 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()) Self::start_with_trace(active_runtime, trace, WebTelemetry::new())
} }
@@ -295,6 +303,10 @@ impl WebProcessRuntime {
active_runtime, active_runtime,
trace, trace,
token_authenticator, 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_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
http_overload_connections: Arc::new(Semaphore::new( http_overload_connections: Arc::new(Semaphore::new(
limits.max_http_overload_connections, limits.max_http_overload_connections,
+22 -2
View File
@@ -164,7 +164,7 @@ impl WebProcessRuntime {
} }
let trace_session_id = self.trace.next_session_id(); let trace_session_id = self.trace.next_session_id();
let bridge_diagnostics_enabled = config.web.debug.bridge_diagnostics_enabled(); 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 issued_profile = Arc::clone(&profile);
let expires_at = now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs); 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)); 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<Arc<str>>, Option<[u8; 16]>) { fn bounded_user_agent(value: Option<&str>, wildcard: bool) -> (Option<Arc<str>>, Option<[u8; 16]>) {
const DISPLAY_BYTES: usize = 256; const DISPLAY_BYTES: usize = 256;
const HASH_CONTEXT: &[u8] = b"telemt-web-user-agent-v1\0"; const HASH_CONTEXT: &[u8] = b"telemt-web-user-agent-v1\0";
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else { 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<Arc<str>>, Option<[u8; 16]
let mut id = [0; 16]; let mut id = [0; 16];
id.copy_from_slice(&digest[..16]); id.copy_from_slice(&digest[..16]);
let mut display = String::with_capacity(value.len().min(DISPLAY_BYTES)); let mut display = String::with_capacity(value.len().min(DISPLAY_BYTES));
let mut truncated = false;
for character in value.chars() { for character in value.chars() {
let character = if character.is_control() { let character = if character.is_control() {
'\u{fffd}' '\u{fffd}'
@@ -401,9 +402,28 @@ fn bounded_user_agent(value: Option<&str>) -> (Option<Arc<str>>, Option<[u8; 16]
character character
}; };
if display.len().saturating_add(character.len_utf8()) > DISPLAY_BYTES { if display.len().saturating_add(character.len_utf8()) > DISPLAY_BYTES {
truncated = true;
break; break;
} }
display.push(character); display.push(character);
} }
if wildcard {
crate::web::trace::scrub_text(&mut display, truncated);
}
(Some(Arc::from(display)), Some(id)) (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)));
}
}
+28 -1
View File
@@ -287,8 +287,35 @@ pub(super) fn profile_key(profile: &WebRuntimeProfile) -> ProfileKey {
/// Re-resolves an issued profile against the active generation without weakening identity. /// Re-resolves an issued profile against the active generation without weakening identity.
pub(super) fn matching_profile( pub(super) fn matching_profile(
runtime: &WebRuntimeConfig, runtime: &WebRuntimeConfig,
expected: &WebRuntimeProfile, expected: &Arc<WebRuntimeProfile>,
) -> Option<Arc<WebRuntimeProfile>> { ) -> Option<Arc<WebRuntimeProfile>> {
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 runtime
.profiles .profiles
.iter() .iter()
+1
View File
@@ -21,6 +21,7 @@ impl Fixture {
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [7; 32], capability: [7; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: "203.0.113.10:443".parse().unwrap(), public_addr: "203.0.113.10:443".parse().unwrap(),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
@@ -18,6 +18,9 @@ fn generation(id: u64, lifetime: u64) -> Arc<RuntimeGeneration> {
config.web.limits.new_bootstraps_burst = 4096; config.web.limits.new_bootstraps_burst = 4096;
config.web.limits.new_sessions_burst = 4096; config.web.limits.new_sessions_burst = 4096;
config.web.runtime = Some(Arc::new(WebRuntimeConfig { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id: 0,
wildcards: Default::default(),
catch_all: None,
vhosts: BTreeMap::new(), vhosts: BTreeMap::new(),
profiles: vec![Arc::clone(&fixture.profile)], profiles: vec![Arc::clone(&fixture.profile)],
capabilities: vec![fixture.profile.capability].into_boxed_slice(), capabilities: vec![fixture.profile.capability].into_boxed_slice(),
+257
View File
@@ -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<CacheEntry>),
/// 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<AtomicUsize>,
}
impl Drop for CacheEntry {
fn drop(&mut self) {
self.live_bytes.fetch_sub(self.charged, Ordering::AcqRel);
}
}
#[derive(Default)]
struct State {
hosts: BTreeMap<String, Arc<CacheEntry>>,
fifo: BTreeMap<u64, String>,
known: BTreeMap<[u8; 32], usize>,
next_sequence: u64,
last: Option<Instant>,
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<State>,
live_bytes: Arc<AtomicUsize>,
}
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<CapabilityTable> {
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;
+197
View File
@@ -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 = &current_web.wildcards["example.com"];
let late = cache.prepare(&old, old_vhost, "a.example.com").unwrap();
let latest = cache
.prepare(&current, 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(&current, 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(_))
));
}
+4
View File
@@ -68,6 +68,7 @@ fn test_runtime_with_dc(
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [7; 32], capability: [7; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: "203.0.113.10:443".parse().unwrap(), public_addr: "203.0.113.10:443".parse().unwrap(),
user: "default".to_string(), user: "default".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
@@ -111,6 +112,9 @@ fn test_runtime_with_dc(
}); });
} }
config.web.runtime = Some(Arc::new(WebRuntimeConfig { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id: 0,
wildcards: Default::default(),
catch_all: None,
vhosts: BTreeMap::new(), vhosts: BTreeMap::new(),
profiles: vec![Arc::clone(&profile)], profiles: vec![Arc::clone(&profile)],
capabilities: vec![[7; 32]].into_boxed_slice(), capabilities: vec![[7; 32]].into_boxed_slice(),
+1
View File
@@ -23,6 +23,7 @@ fn fixture(
host: "proxy.example.com".into(), host: "proxy.example.com".into(),
capability: [0; 32], capability: [0; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: "203.0.113.10:443".parse().unwrap(), public_addr: "203.0.113.10:443".parse().unwrap(),
user: "default".into(), user: "default".into(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
+1
View File
@@ -34,6 +34,7 @@ fn session() -> (Arc<WebSession>, Arc<WebProcessRuntime>) {
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [0; 32], capability: [0; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
+1
View File
@@ -49,6 +49,7 @@ fn new_session_with_automatic(
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [0; 32], capability: [0; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
+1
View File
@@ -13,6 +13,7 @@ fn session(carrier: WebCarrier, deadline: Instant) -> Arc<WebSession> {
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [0; 32], capability: [0; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
+1
View File
@@ -136,6 +136,7 @@ fn session() -> Arc<WebSession> {
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [0; 32], capability: [0; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
+1
View File
@@ -18,6 +18,7 @@ fn session_with_automatic(automatic: bool) -> Arc<WebSession> {
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [0; 32], capability: [0; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)), public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
user: "alice".to_string(), user: "alice".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
+4
View File
@@ -50,6 +50,7 @@ fn runtime(admission: bool) -> TestRuntime {
host: "proxy.example.com".to_string(), host: "proxy.example.com".to_string(),
capability: [7; 32], capability: [7; 32],
shared: Arc::new(crate::config::WebRuntimeProfileShared { shared: Arc::new(crate::config::WebRuntimeProfileShared {
derivation_context: [0; 32],
public_addr: "203.0.113.10:443".parse().unwrap(), public_addr: "203.0.113.10:443".parse().unwrap(),
user: "default".to_string(), user: "default".to_string(),
secret_mode: WebSecretMode::Plain, secret_mode: WebSecretMode::Plain,
@@ -70,6 +71,9 @@ fn runtime(admission: bool) -> TestRuntime {
config.web.carrier = WebCarrier::WebsocketLanes; config.web.carrier = WebCarrier::WebsocketLanes;
config.web.timeouts.shutdown_secs = 1; config.web.timeouts.shutdown_secs = 1;
config.web.runtime = Some(Arc::new(WebRuntimeConfig { config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id: 0,
wildcards: Default::default(),
catch_all: None,
vhosts: BTreeMap::new(), vhosts: BTreeMap::new(),
profiles: vec![Arc::clone(&profile)], profiles: vec![Arc::clone(&profile)],
capabilities: vec![[7; 32]].into_boxed_slice(), capabilities: vec![[7; 32]].into_boxed_slice(),
+54 -23
View File
@@ -78,30 +78,48 @@ impl HttpTraceExchange {
let started_epoch_millis = epoch_millis(); let started_epoch_millis = epoch_millis();
let dynamic = request_dynamic_bytes(request, &policy); let dynamic = request_dynamic_bytes(request, &policy);
let dynamic_reserved = store.try_reserve(dynamic); let dynamic_reserved = store.try_reserve(dynamic);
let (method, path, user_agent, request_headers, redactions) = if dynamic_reserved { 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.method().as_str().to_string(),
request 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() .headers()
.get(hyper::header::USER_AGENT) .get(hyper::header::USER_AGENT)
.map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)), .is_some_and(|value| value.len() > USER_AGENT_MAX_BYTES);
if policy.capture_headers { super::wildcard::scrub_text(user_agent, truncated);
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(),
)
};
Arc::new(Self { Arc::new(Self {
store, store,
epoch, epoch,
@@ -209,7 +227,8 @@ impl HttpTraceExchange {
let reserved = self.reserve_locked(&mut state, dynamic); let reserved = self.reserve_locked(&mut state, dynamic);
state.status = Some(response.status().as_u16()); state.status = Some(response.status().as_u16());
if self.policy.capture_headers && reserved { 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 { if reserved {
state state
@@ -394,6 +413,18 @@ impl HttpTraceExchange {
let redactions = std::mem::take(&mut state.redactions); let redactions = std::mem::take(&mut state.redactions);
scrub_body(&mut state.request_body.captured, &redactions); scrub_body(&mut state.request_body.captured, &redactions);
scrub_body(&mut state.response_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 request_body = body_snapshot(&self.policy, &mut state.request_body);
let response_body = body_snapshot(&self.policy, &mut state.response_body); let response_body = body_snapshot(&self.policy, &mut state.response_body);
TraceRecord { TraceRecord {
+4
View File
@@ -18,3 +18,7 @@ pub(crate) use types::{
TraceLifecycleContext, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind, TraceLifecycleContext, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind,
TraceRoute, TraceWebSocketContext, TraceRoute, TraceWebSocketContext,
}; };
// Shape-based sanitization is exclusively for diagnostic copies, never public routing.
mod wildcard;
pub(crate) use wildcard::scrub_text;
+13 -4
View File
@@ -54,12 +54,21 @@ pub(super) fn response_dynamic_bytes<B>(
} }
/// Copies header names and only allowlisted bounded values. /// Copies header names and only allowlisted bounded values.
pub(super) fn sanitized_headers(headers: &hyper::HeaderMap) -> Vec<TraceHeader> { pub(super) fn sanitized_headers(headers: &hyper::HeaderMap, wildcard: bool) -> Vec<TraceHeader> {
headers headers
.iter() .iter()
.map(|(name, value)| TraceHeader { .map(|(name, value)| {
name: name.as_str().to_string(), let mut copied = TraceHeader {
value: header_value_allowed(name).then(|| bounded_text(value.as_bytes(), 4096)), 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() .collect()
} }
+14 -1
View File
@@ -77,6 +77,8 @@ pub(crate) struct TraceClearOutcome {
/// Process-owned bounded WEB debug trace store. /// Process-owned bounded WEB debug trace store.
pub(crate) struct WebTraceStore { pub(crate) struct WebTraceStore {
/// Process-frozen redaction for credentials outside the finite hostname inventory.
pub(super) wildcard_redaction: bool,
policy: ArcSwap<WebDebugConfig>, policy: ArcSwap<WebDebugConfig>,
policy_update: Mutex<()>, policy_update: Mutex<()>,
enabled: AtomicBool, enabled: AtomicBool,
@@ -97,9 +99,20 @@ pub(crate) struct WebTraceStore {
} }
impl 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> { pub(crate) fn new(policy: WebDebugConfig, limits: &WebLimitsConfig) -> Arc<Self> {
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<Self> {
Arc::new(Self { Arc::new(Self {
wildcard_redaction,
enabled: AtomicBool::new(policy.enabled), enabled: AtomicBool::new(policy.enabled),
policy: ArcSwap::from_pointee(policy), policy: ArcSwap::from_pointee(policy),
policy_update: Mutex::new(()), policy_update: Mutex::new(()),
+22 -7
View File
@@ -27,10 +27,19 @@ impl WebTraceStore {
if !self.enabled.load(Ordering::Acquire) || !self.policy.load().enabled { if !self.enabled.load(Ordering::Acquire) || !self.policy.load().enabled {
return None; return None;
} }
let user_agent = request let mut user_agent = request
.headers() .headers()
.get(hyper::header::USER_AGENT) .get(hyper::header::USER_AGENT)
.map(|value| super::super::sanitize::bounded_text(value.as_bytes(), 512)); .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 { Some(TraceWebSocketContext {
connection_id, connection_id,
peer_ip, peer_ip,
@@ -94,12 +103,18 @@ impl WebTraceStore {
if !self.try_reserve_record(reservation) { if !self.try_reserve_record(reservation) {
return; return;
} }
let body = (policy.body_capture != WebDebugBodyCapture::Off).then(|| TraceBodySnapshot { let mut body =
observed_bytes: payload.len() as u64, (policy.body_capture != WebDebugBodyCapture::Off).then(|| TraceBodySnapshot {
captured: payload[..capture_bytes].to_vec(), observed_bytes: payload.len() as u64,
truncated: capture_limit != 0 && capture_bytes < payload.len(), captured: payload[..capture_bytes].to_vec(),
state: TraceBodyState::Complete, 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" { let frames = if policy.capture_frames && message_type == "binary" {
websocket_frames(direction, payload, &self.frame_limits) websocket_frames(direction, payload, &self.frame_limits)
} else { } else {
+138
View File
@@ -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<u8> {
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
}
);
}
}
}
}