mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 05:12:02 +03:00
WEB: bounded opt-in wildcard host routing added
This commit is contained in:
@@ -137,3 +137,7 @@ async fn grouped_hosts_cannot_bypass_frozen_process_limits() {
|
||||
}
|
||||
assert_eq!(active.web.runtime.as_ref().unwrap().vhosts.len(), 1);
|
||||
}
|
||||
|
||||
// Effective process policy must reject patterns until the explicit restart has happened.
|
||||
#[path = "wildcard_tests.rs"]
|
||||
mod wildcard_tests;
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@
|
||||
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
|
||||
//! applied; a warning is emitted. SYN limiter rules are process-owned and are
|
||||
//! reconciled only during privileged startup.
|
||||
//! `web.decoy_fasttrack_mode` is also restart-only so one process never mixes
|
||||
//! `web.allow_wildcard_hosts, web.decoy_fasttrack_mode` is also restart-only so one process never mixes
|
||||
//! capability timing policies or process-lifetime counter semantics.
|
||||
//! Non-hot changes are never mixed into the runtime config snapshot.
|
||||
|
||||
|
||||
@@ -85,6 +85,10 @@ pub(super) fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot
|
||||
warned = true;
|
||||
warn!("config reload: server listener settings changed; restart required");
|
||||
}
|
||||
if old.web.allow_wildcard_hosts != new.web.allow_wildcard_hosts {
|
||||
warned = true;
|
||||
warn!("config reload: web.allow_wildcard_hosts changed; restart required");
|
||||
}
|
||||
if old.web.decoy_fasttrack_mode != new.web.decoy_fasttrack_mode {
|
||||
warned = true;
|
||||
warn!("config reload: web.decoy_fasttrack_mode changed; restart required");
|
||||
|
||||
@@ -342,10 +342,12 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC
|
||||
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
|
||||
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
|
||||
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
|
||||
let allow_wildcard_hosts = cfg.web.allow_wildcard_hosts;
|
||||
let process_limits = cfg.web.limits.clone();
|
||||
let decoy_fasttrack_mode = cfg.web.decoy_fasttrack_mode;
|
||||
cfg.web = new.web.clone();
|
||||
cfg.web.limits = process_limits;
|
||||
cfg.web.allow_wildcard_hosts = allow_wildcard_hosts;
|
||||
cfg.web.decoy_fasttrack_mode = decoy_fasttrack_mode;
|
||||
if cfg.web.carrier_negotiation_enabled()
|
||||
&& cfg.web.carrier_learning
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -185,12 +185,24 @@ pub(super) fn build_upstream(
|
||||
impl ProxyConfig {
|
||||
/// Compares selected endpoints separately from source-only configuration equality.
|
||||
pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool {
|
||||
fn selected(config: &ProxyConfig) -> impl Iterator<Item = (&String, SocketAddr)> {
|
||||
fn selected(config: &ProxyConfig) -> impl Iterator<Item = ((u8, &str), SocketAddr)> {
|
||||
config
|
||||
.web
|
||||
.runtime
|
||||
.iter()
|
||||
.flat_map(|runtime| runtime.vhosts.iter())
|
||||
.flat_map(|runtime| {
|
||||
runtime
|
||||
.vhosts
|
||||
.iter()
|
||||
.map(|(host, vhost)| ((0, host.as_str()), vhost))
|
||||
.chain(
|
||||
runtime
|
||||
.wildcards
|
||||
.iter()
|
||||
.map(|(suffix, vhost)| ((1, suffix.as_str()), vhost)),
|
||||
)
|
||||
.chain(runtime.catch_all.iter().map(|vhost| ((2, "*"), vhost)))
|
||||
})
|
||||
.filter_map(|(host, vhost)| match vhost.decoy.as_ref() {
|
||||
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, *addr)),
|
||||
WebRuntimeDecoy::StaticDirectory(_) => None,
|
||||
|
||||
@@ -357,3 +357,32 @@ async fn grouped_hosts_share_one_resolved_origin_and_endpoint_inventory() {
|
||||
reordered.rebuild_runtime_web().unwrap();
|
||||
assert!(!loaded.config.web_decoy_endpoints_equal(&reordered));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wildcard_dns_only_endpoint_changes_are_visible_without_source_changes() {
|
||||
for selector in ["*.example.com", "*"] {
|
||||
let text = SOURCE
|
||||
.replace("[web]", "[web]\nallow_wildcard_hosts = true")
|
||||
.replace(
|
||||
"host = \"proxy.example.com\"",
|
||||
&format!("host = \"{selector}\""),
|
||||
)
|
||||
+ "\n[web.limits]\nmemory_envelope_bytes = 1610612736\n";
|
||||
let mut snapshots = Vec::new();
|
||||
for addr in ["127.0.0.2:18081", "127.0.0.3:18081"] {
|
||||
let mut parsed = source(&text);
|
||||
prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Ok(vec![addr.parse().unwrap()]))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
snapshots.push(pipeline::finish(parsed).unwrap().config);
|
||||
}
|
||||
assert_eq!(
|
||||
serde_json::to_value(&snapshots[0]).unwrap(),
|
||||
serde_json::to_value(&snapshots[1]).unwrap()
|
||||
);
|
||||
assert!(!snapshots[0].web_decoy_endpoints_equal(&snapshots[1]));
|
||||
assert!(snapshots[0].web_decoy_endpoints_equal(&snapshots[0]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,127 +35,11 @@ const WEB_CAPABILITY_CONTEXT_V2: &[u8] = b"tdesktop-web-proxy-bridge-v2\n";
|
||||
const WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0";
|
||||
const MAX_WEB_STATIC_DEPTH: usize = 64;
|
||||
|
||||
/// Builds the immutable WEB routing and decoy snapshot for one generation.
|
||||
pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
let auth = config.runtime_user_auth().ok_or_else(|| {
|
||||
ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string())
|
||||
})?;
|
||||
let mut runtime_vhosts = BTreeMap::new();
|
||||
let mut runtime_profiles = Vec::new();
|
||||
let mut runtime_capabilities = Vec::new();
|
||||
let mut static_files = 0usize;
|
||||
let mut static_bytes = 0usize;
|
||||
|
||||
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
|
||||
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
|
||||
let decoy = Arc::new(build_decoy(
|
||||
config,
|
||||
vhost_idx,
|
||||
vhost,
|
||||
&config.web.limits,
|
||||
&mut static_files,
|
||||
&mut static_bytes,
|
||||
)?);
|
||||
let mut host_profiles: Vec<_> = vhost
|
||||
.host
|
||||
.iter()
|
||||
.chain(vhost.hosts.iter().flatten())
|
||||
.map(|host| {
|
||||
(
|
||||
host,
|
||||
Vec::with_capacity(vhost.profiles.len()),
|
||||
Vec::with_capacity(vhost.profiles.len()),
|
||||
HashSet::with_capacity(vhost.profiles.len()),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for profile in &vhost.profiles {
|
||||
let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| {
|
||||
ProxyError::Config(format!(
|
||||
"WEB profile references unknown access user `{}`",
|
||||
profile.user
|
||||
))
|
||||
})?;
|
||||
let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| {
|
||||
ProxyError::Config("WEB profile user snapshot is inconsistent".to_string())
|
||||
})?;
|
||||
let (client_secret, client_secret_len) =
|
||||
client_secret(auth_entry.secret, profile.secret_mode);
|
||||
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
|
||||
let shared = Arc::new(WebRuntimeProfileShared {
|
||||
public_addr: vhost.public_addr,
|
||||
user: profile.user.clone(),
|
||||
credential_id: auth_entry.credential_id,
|
||||
secret_mode: profile.secret_mode,
|
||||
carrier: config.web.carrier,
|
||||
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
|
||||
carrier_learning: config.web.carrier_negotiation_enabled()
|
||||
&& config.web.carrier_learning,
|
||||
carriers: Arc::clone(&carrier_candidates),
|
||||
carrier_negotiation_deadlines_secs: config
|
||||
.web
|
||||
.timeouts
|
||||
.carrier_negotiation_deadlines_secs,
|
||||
key_fingerprint,
|
||||
max_sessions: profile
|
||||
.max_sessions
|
||||
.unwrap_or(config.web.limits.max_sessions_global),
|
||||
max_streams: profile
|
||||
.max_streams
|
||||
.unwrap_or(config.web.limits.max_streams_global),
|
||||
max_streams_per_session: profile
|
||||
.max_streams_per_session
|
||||
.unwrap_or(config.web.limits.max_streams_per_session),
|
||||
});
|
||||
// Only host-bound bindings and capability indexes expand with the alias count.
|
||||
for (host, profiles, capability_table, capabilities) in &mut host_profiles {
|
||||
let capability = derive_web_capability(
|
||||
&client_secret[..client_secret_len],
|
||||
host.as_bytes(),
|
||||
vhost.base_path.as_bytes(),
|
||||
)?;
|
||||
if !capabilities.insert(capability) {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"WEB vhost `{host}` contains profiles with the same client capability"
|
||||
)));
|
||||
}
|
||||
profiles.push(Arc::new(WebRuntimeProfile {
|
||||
host: (*host).clone(),
|
||||
capability,
|
||||
shared: Arc::clone(&shared),
|
||||
}));
|
||||
capability_table.push(capability);
|
||||
}
|
||||
}
|
||||
for (host, profiles, capability_table, _) in host_profiles {
|
||||
runtime_capabilities.extend(capability_table.iter().copied());
|
||||
runtime_profiles.extend(profiles.iter().cloned());
|
||||
runtime_vhosts.insert(
|
||||
host.clone(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
host: host.clone(),
|
||||
base: if vhost.base_path.is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
format!("/{}/", vhost.base_path)
|
||||
},
|
||||
decoy_fasttrack_mode: config.web.decoy_fasttrack_mode,
|
||||
decoy: Arc::clone(&decoy),
|
||||
decoy_header_secs: config.web.timeouts.decoy_header_secs,
|
||||
profiles,
|
||||
capabilities: capability_table.into_boxed_slice(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
vhosts: runtime_vhosts,
|
||||
profiles: runtime_profiles,
|
||||
capabilities: runtime_capabilities.into_boxed_slice(),
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
// Exact bindings and wildcard templates share one immutable snapshot builder.
|
||||
mod build;
|
||||
pub(super) use build::rebuild;
|
||||
// Cold wildcard derivation uses the existing immutable authentication snapshot.
|
||||
mod wildcard;
|
||||
|
||||
fn debug_key_fingerprint(secret: &[u8]) -> String {
|
||||
let mut digest = Sha256::new();
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
|
||||
];
|
||||
|
||||
const WEB_CONFIG_KEYS: &[&str] = &[
|
||||
"allow_wildcard_hosts",
|
||||
"enabled",
|
||||
"carrier",
|
||||
"carrier_method",
|
||||
@@ -277,6 +278,7 @@ const WEB_CONFIG_KEYS: &[&str] = &[
|
||||
];
|
||||
|
||||
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
|
||||
"wildcard",
|
||||
"max_header_bytes",
|
||||
"max_body_bytes",
|
||||
"max_frame_payload_bytes",
|
||||
@@ -535,3 +537,12 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const WEB_WILDCARD_LIMITS_CONFIG_KEYS: &[&str] = &[
|
||||
"max_rules",
|
||||
"max_profiles_per_request",
|
||||
"cache_hosts",
|
||||
"cache_bytes",
|
||||
"cold_hmac_per_minute",
|
||||
"cold_hmac_burst",
|
||||
];
|
||||
|
||||
@@ -37,6 +37,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> {
|
||||
LISTENER_CONFIG_KEYS,
|
||||
WEB_CONFIG_KEYS,
|
||||
WEB_LIMITS_CONFIG_KEYS,
|
||||
WEB_WILDCARD_LIMITS_CONFIG_KEYS,
|
||||
WEB_DEBUG_CONFIG_KEYS,
|
||||
WEB_TIMEOUTS_CONFIG_KEYS,
|
||||
WEB_VHOST_CONFIG_KEYS,
|
||||
@@ -242,6 +243,13 @@ pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<Unkn
|
||||
&["web", "limits"],
|
||||
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(
|
||||
parsed_toml,
|
||||
&mut unknown,
|
||||
|
||||
@@ -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)?;
|
||||
let carriers = negotiation::validate(&config.web)?;
|
||||
if carriers.contains(&WebCarrier::Https) && config.web.limits.max_http_handlers < 2 {
|
||||
@@ -182,7 +182,7 @@ fn validate_web_listener(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
||||
fn validate_limits(limits: &WebLimitsConfig, wildcard: bool) -> Result<()> {
|
||||
if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) {
|
||||
return config_error("web.limits.max_header_bytes must be within [8192, 65536]");
|
||||
}
|
||||
@@ -412,7 +412,7 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
|
||||
"web.limits pending ceilings must preserve one uplink batch and downlink progress",
|
||||
);
|
||||
}
|
||||
memory::validate(limits)?;
|
||||
memory::validate(limits, wildcard)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024;
|
||||
const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 64;
|
||||
|
||||
/// Validates process-wide body, header, queue, static, and debug reservations.
|
||||
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
pub(super) fn validate(limits: &WebLimitsConfig, wildcard: bool) -> Result<()> {
|
||||
if limits.max_carrier_learning_entries == 0 {
|
||||
return config_error("web.limits.max_carrier_learning_entries must be > 0");
|
||||
}
|
||||
@@ -83,9 +83,15 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
.ok_or_else(|| {
|
||||
ProxyError::Config("web.limits lane state reservation overflowed usize".to_string())
|
||||
})?;
|
||||
let wildcard_reservation = if wildcard {
|
||||
wildcard_reservation(limits)?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let reserved = limits
|
||||
.pending_bytes_global
|
||||
.checked_add(limits.max_body_bytes_global)
|
||||
.checked_add(wildcard_reservation)
|
||||
.and_then(|value| value.checked_add(limits.max_body_bytes_global))
|
||||
.and_then(|value| value.checked_add(limits.max_static_bytes))
|
||||
.and_then(|value| value.checked_add(debug_ring_index))
|
||||
.and_then(|value| value.checked_add(status_pages))
|
||||
@@ -106,6 +112,52 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Cache entries include their digest index and allocator overhead. Detached entry
|
||||
// leases remain charged until the last borrower releases them. Handler scratch and
|
||||
// authenticated host bindings have independent existing concurrency ceilings.
|
||||
fn wildcard_reservation(limits: &WebLimitsConfig) -> Result<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -113,13 +165,13 @@ mod tests {
|
||||
#[test]
|
||||
fn default_envelope_includes_bounded_lane_and_learning_metadata() {
|
||||
let limits = WebLimitsConfig::default();
|
||||
assert!(validate(&limits).is_ok());
|
||||
assert!(validate(&limits, false).is_ok());
|
||||
|
||||
let previous_envelope = WebLimitsConfig {
|
||||
memory_envelope_bytes: 768 * 1024 * 1024,
|
||||
..limits
|
||||
};
|
||||
assert!(validate(&previous_envelope).is_err());
|
||||
assert!(validate(&previous_envelope, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -128,7 +180,7 @@ mod tests {
|
||||
max_profiles: usize::MAX,
|
||||
..WebLimitsConfig::default()
|
||||
};
|
||||
let error = validate(&limits).unwrap_err().to_string();
|
||||
let error = validate(&limits, false).unwrap_err().to_string();
|
||||
assert!(error.contains("web capability index reservation overflowed usize"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
use super::*;
|
||||
|
||||
/// Bounds expanded routing tables before normalizing exact hostname selectors.
|
||||
/// Bounds exact bindings and wildcard rules before normalizing hostname selectors.
|
||||
pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
|
||||
let limits = &config.web.limits;
|
||||
let mut host_count = 0usize;
|
||||
let mut rule_count = 0usize;
|
||||
let mut profile_count = 0usize;
|
||||
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
|
||||
let count = match (&vhost.host, &vhost.hosts) {
|
||||
(Some(_), None) => 1,
|
||||
(None, Some(hosts)) if !hosts.is_empty() => hosts.len(),
|
||||
match (&vhost.host, &vhost.hosts) {
|
||||
(Some(_), None) => {}
|
||||
(None, Some(hosts)) if !hosts.is_empty() => {}
|
||||
_ => {
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}] requires exactly one of host or non-empty hosts"
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
let selectors = vhost.host.iter().chain(vhost.hosts.iter().flatten());
|
||||
let patterns = selectors
|
||||
.clone()
|
||||
.filter(|host| host.trim().starts_with('*'))
|
||||
.count();
|
||||
let exact = selectors.count() - patterns;
|
||||
rule_count = rule_count.checked_add(patterns).ok_or_else(|| {
|
||||
ProxyError::Config("WEB wildcard rule count overflowed usize".to_string())
|
||||
})?;
|
||||
if patterns > 0
|
||||
&& (!config.web.allow_wildcard_hosts
|
||||
|| rule_count > limits.wildcard.max_rules
|
||||
|| vhost.profiles.len() > limits.wildcard.max_profiles_per_request)
|
||||
{
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}] wildcard selectors require allow_wildcard_hosts and must fit web.limits.wildcard bounds"
|
||||
));
|
||||
}
|
||||
host_count = host_count
|
||||
.checked_add(count)
|
||||
.checked_add(exact)
|
||||
.ok_or_else(|| ProxyError::Config("WEB hostname count overflowed usize".to_string()))?;
|
||||
profile_count = count
|
||||
.checked_mul(vhost.profiles.len())
|
||||
profile_count = exact
|
||||
.checked_add(usize::from(patterns > 0))
|
||||
.and_then(|count| count.checked_mul(vhost.profiles.len()))
|
||||
.and_then(|expanded| profile_count.checked_add(expanded))
|
||||
.ok_or_else(|| ProxyError::Config("WEB profile count overflowed usize".to_string()))?;
|
||||
if host_count > limits.max_vhosts {
|
||||
@@ -43,7 +63,16 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
|
||||
} else {
|
||||
format!("web.vhosts[{vhost_idx}].hosts[{host_idx}]")
|
||||
};
|
||||
*host = normalize_web_host(host, &field)?;
|
||||
*host = if config.web.allow_wildcard_hosts && host.trim() == "*" {
|
||||
"*".to_string()
|
||||
} else if config.web.allow_wildcard_hosts
|
||||
&& let Some(suffix) = host.trim().strip_prefix("*.")
|
||||
{
|
||||
let normalized = normalize_web_host(&format!("x.{suffix}"), &field)?;
|
||||
format!("*.{}", &normalized[2..])
|
||||
} else {
|
||||
normalize_web_host(host, &field)?
|
||||
};
|
||||
if !hosts.insert(host.clone()) {
|
||||
return config_error(&format!("duplicate WEB vhost host `{host}` at {field}"));
|
||||
}
|
||||
|
||||
@@ -239,3 +239,7 @@ fn grouped_static_snapshot_is_loaded_and_charged_once() {
|
||||
std::fs::write(directory.path().join("index.html"), b"longer").unwrap();
|
||||
assert!(load_config_error_from_temp_toml(&format!("{source}{limits}")).contains("max_static"));
|
||||
}
|
||||
|
||||
// Wildcard admission remains opt-in and independently bounded.
|
||||
#[path = "wildcard_tests.rs"]
|
||||
mod wildcard_tests;
|
||||
|
||||
@@ -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
@@ -55,11 +55,11 @@ pub use server::{
|
||||
pub use web::{
|
||||
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode,
|
||||
WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig,
|
||||
WebSecretMode, WebTimeoutsConfig, WebVhostConfig,
|
||||
WebSecretMode, WebTimeoutsConfig, WebVhostConfig, WebWildcardLimitsConfig,
|
||||
};
|
||||
pub(crate) use web::{
|
||||
WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile,
|
||||
WebRuntimeProfileShared, WebRuntimeVhost, WebStaticAsset, WebStaticSite,
|
||||
WebRuntimeProfileShared, WebRuntimeVhost, WebRuntimeWildcard, WebStaticAsset, WebStaticSite,
|
||||
};
|
||||
pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES;
|
||||
#[allow(unused_imports)]
|
||||
|
||||
+18
-6
@@ -9,6 +9,10 @@ use serde::{Deserialize, Serialize};
|
||||
use super::web_carrier::{WebCarrier, WebCarrierMethod, WebCarriers};
|
||||
use super::web_debug::WebDebugConfig;
|
||||
|
||||
// Wildcard policy is restart-frozen independently of reloadable routing rules.
|
||||
mod wildcard;
|
||||
pub use wildcard::WebWildcardLimitsConfig;
|
||||
|
||||
// Serialized WEB defaults remain separate from the runtime data model.
|
||||
mod defaults;
|
||||
use defaults::*;
|
||||
@@ -73,13 +77,13 @@ pub enum WebDecoyConfig {
|
||||
},
|
||||
}
|
||||
|
||||
/// Exact WEB hostnames sharing one immutable decoy and explicit profile policy.
|
||||
/// WEB hostname selectors sharing one immutable decoy and explicit profile policy.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WebVhostConfig {
|
||||
/// Legacy single hostname selector, mutually exclusive with `hosts`.
|
||||
/// Single exact or opted-in wildcard selector, mutually exclusive with `hosts`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub host: Option<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")]
|
||||
pub hosts: Option<Vec<String>>,
|
||||
/// Optional canonical WEB endpoint prefix without surrounding slashes.
|
||||
@@ -97,6 +101,9 @@ pub struct WebVhostConfig {
|
||||
/// Hard process and protocol limits for WEB ingress.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WebLimitsConfig {
|
||||
/// Bounds automatic wildcard derivation and positive capability retention.
|
||||
#[serde(default)]
|
||||
pub wildcard: WebWildcardLimitsConfig,
|
||||
/// Maximum bytes accepted while parsing one HTTP request head.
|
||||
#[serde(default = "default_web_max_header_bytes")]
|
||||
pub max_header_bytes: usize,
|
||||
@@ -199,7 +206,7 @@ pub struct WebLimitsConfig {
|
||||
/// Maximum concrete hostname count across all WEB groups.
|
||||
#[serde(default = "default_web_max_vhosts")]
|
||||
pub max_vhosts: usize,
|
||||
/// Maximum expanded hostname/profile binding count across all WEB groups.
|
||||
/// Maximum exact bindings plus one shared profile set per wildcard group.
|
||||
#[serde(default = "default_web_max_profiles")]
|
||||
pub max_profiles: usize,
|
||||
/// Maximum static snapshot entry count, charged once per source group.
|
||||
@@ -243,6 +250,7 @@ pub struct WebLimitsConfig {
|
||||
impl Default for WebLimitsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wildcard: WebWildcardLimitsConfig::default(),
|
||||
max_header_bytes: default_web_max_header_bytes(),
|
||||
max_body_bytes: default_web_max_body_bytes(),
|
||||
max_frame_payload_bytes: default_web_max_frame_payload_bytes(),
|
||||
@@ -422,6 +430,9 @@ pub enum WebCarrierNegotiationAggressiveness {
|
||||
/// WEB ingress, carrier, fallback, and lifecycle configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebConfig {
|
||||
/// Restart-only opt-in to automatic suffix and catch-all hostname selectors.
|
||||
#[serde(default)]
|
||||
pub allow_wildcard_hosts: bool,
|
||||
/// Enables issuance of new WEB bridge and session credentials.
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
@@ -458,7 +469,7 @@ pub struct WebConfig {
|
||||
/// WEB lifecycle deadlines.
|
||||
#[serde(default)]
|
||||
pub timeouts: WebTimeoutsConfig,
|
||||
/// Exact hostname groups served by WEB listeners.
|
||||
/// Exact hostname and opted-in wildcard groups served by WEB listeners.
|
||||
#[serde(default)]
|
||||
pub vhosts: Vec<WebVhostConfig>,
|
||||
/// Validated immutable runtime snapshot built during configuration loading.
|
||||
@@ -494,6 +505,7 @@ impl Default for WebConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
allow_wildcard_hosts: false,
|
||||
carrier: WebCarrier::default(),
|
||||
carrier_method: WebCarrierMethod::default(),
|
||||
conveyor: default_web_conveyor(),
|
||||
@@ -516,5 +528,5 @@ impl Default for WebConfig {
|
||||
mod runtime;
|
||||
pub(crate) use runtime::{
|
||||
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeProfileShared, WebRuntimeVhost,
|
||||
WebStaticAsset, WebStaticSite,
|
||||
WebRuntimeWildcard, WebStaticAsset, WebStaticSite,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
use super::*;
|
||||
|
||||
/// Precomputed WEB configuration consumed by listener hot paths.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct WebRuntimeConfig {
|
||||
/// Process-unique immutable WEB snapshot identity, including same-generation reloads.
|
||||
pub(crate) snapshot_id: u64,
|
||||
/// Suffix index without the leading wildcard and dot.
|
||||
pub(crate) wildcards: BTreeMap<String, Arc<WebRuntimeVhost>>,
|
||||
/// Lowest-priority whole-group fallback.
|
||||
pub(crate) catch_all: Option<Arc<WebRuntimeVhost>>,
|
||||
/// Canonical host lookup used by HTTP request routing.
|
||||
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
|
||||
/// Flat profile inventory used by startup link emission.
|
||||
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]]>,
|
||||
}
|
||||
|
||||
/// Precomputed immutable virtual-host data.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebRuntimeVhost {
|
||||
/// Canonical lowercase ACE hostname.
|
||||
/// Shared automatic-host policy; exact hosts keep their precomputed bindings.
|
||||
pub(crate) wildcard: Option<WebRuntimeWildcard>,
|
||||
/// Canonical lowercase ACE hostname; empty for a wildcard template.
|
||||
pub(crate) host: String,
|
||||
/// Exact slash-delimited endpoint base, including the trailing slash.
|
||||
pub(crate) base: String,
|
||||
@@ -24,13 +32,13 @@ pub(crate) struct WebRuntimeVhost {
|
||||
pub(crate) decoy: Arc<WebRuntimeDecoy>,
|
||||
/// Upstream connect and response-head deadline.
|
||||
pub(crate) decoy_header_secs: u64,
|
||||
/// Exact capability profiles accepted by this host.
|
||||
/// Precomputed exact-host profiles; wildcard policies live in `wildcard`.
|
||||
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
|
||||
/// Contiguous capability table aligned one-to-one with `profiles`.
|
||||
pub(crate) capabilities: Box<[[u8; 32]]>,
|
||||
}
|
||||
|
||||
/// Precomputed exact-user capability entry.
|
||||
/// Authenticated exact-host profile binding.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebRuntimeProfile {
|
||||
/// Canonical host that owns this profile.
|
||||
@@ -45,6 +53,8 @@ pub(crate) struct WebRuntimeProfile {
|
||||
/// Limits still apply to each binding's exact host/user/mode quota owner.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebRuntimeProfileShared {
|
||||
/// Full domain-separated secret, protocol-version, and endpoint identity.
|
||||
pub(crate) derivation_context: [u8; 32],
|
||||
/// Stable public destination tuple supplied to relay routing.
|
||||
pub(crate) public_addr: SocketAddr,
|
||||
/// Exact access user authenticated by logical streams.
|
||||
@@ -106,3 +116,27 @@ pub(crate) struct WebStaticAsset {
|
||||
/// Strong SHA-256 entity tag.
|
||||
pub(crate) etag: String,
|
||||
}
|
||||
|
||||
/// Host-independent wildcard group, never a session or quota owner.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct WebRuntimeWildcard {
|
||||
/// Source-group identity local to the immutable WEB snapshot.
|
||||
pub(crate) group_id: usize,
|
||||
/// Ordered shared policies aligned with cold capability derivation.
|
||||
pub(crate) profiles: Vec<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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -143,7 +143,11 @@ pub(super) async fn run_telemt_core(
|
||||
config.server.max_connections as usize
|
||||
};
|
||||
let max_connections = Arc::new(Semaphore::new(max_connections_limit));
|
||||
let web_trace = WebTraceStore::new(config.web.debug.clone(), &config.web.limits);
|
||||
let web_trace = WebTraceStore::new_with_wildcards(
|
||||
config.web.debug.clone(),
|
||||
&config.web.limits,
|
||||
config.web.allow_wildcard_hosts,
|
||||
);
|
||||
let web_runtime_control = WebRuntimeControl::new();
|
||||
|
||||
let (detected_ips_tx, detected_ips_rx) = watch::channel((None::<IpAddr>, None::<IpAddr>));
|
||||
|
||||
@@ -438,6 +438,10 @@ pub(crate) fn resolve_reload_config(
|
||||
fields.push("web.limits".to_string());
|
||||
effective.web.limits = old.web.limits.clone();
|
||||
}
|
||||
if old.web.allow_wildcard_hosts != desired.web.allow_wildcard_hosts {
|
||||
fields.push("web.allow_wildcard_hosts".to_string());
|
||||
effective.web.allow_wildcard_hosts = old.web.allow_wildcard_hosts;
|
||||
}
|
||||
if old.web.decoy_fasttrack_mode != desired.web.decoy_fasttrack_mode {
|
||||
fields.push("web.decoy_fasttrack_mode".to_string());
|
||||
effective.web.decoy_fasttrack_mode = old.web.decoy_fasttrack_mode;
|
||||
|
||||
+52
-12
@@ -28,6 +28,8 @@ mod activity;
|
||||
mod body;
|
||||
// Canonical capability parsing and complete scans remain isolated from HTTP routing.
|
||||
mod capability;
|
||||
// Lazy wildcard derivation shares one table between containment and bridge authentication.
|
||||
mod wildcard;
|
||||
// Authentic credential containment stays independent from carrier routing.
|
||||
mod secrets;
|
||||
// Decoy routing and upstream proxying are isolated from carrier authentication.
|
||||
@@ -184,13 +186,30 @@ async fn handle_request(
|
||||
let Some(web_runtime) = config.web.runtime.as_ref() else {
|
||||
return generic_not_found();
|
||||
};
|
||||
let Some(host) = canonical_request_host(&request) else {
|
||||
let mut host_storage = [0u8; 253];
|
||||
// Keep the header owner independent from the moved request on the legacy path.
|
||||
let host_header = request.headers().get(header::HOST).cloned();
|
||||
let host = if config.web.allow_wildcard_hosts {
|
||||
let Some(host) = request::wildcard_request_host(&request, &mut host_storage) else {
|
||||
return generic_not_found();
|
||||
};
|
||||
host
|
||||
} else {
|
||||
if canonical_request_host(&request).is_none() {
|
||||
return generic_not_found();
|
||||
}
|
||||
let Some(value) = host_header.as_ref().and_then(|value| value.to_str().ok()) else {
|
||||
return generic_not_found();
|
||||
};
|
||||
value.strip_suffix(":443").unwrap_or(value)
|
||||
};
|
||||
let Some(vhost) = web_runtime.route(host).cloned() else {
|
||||
return generic_not_found();
|
||||
};
|
||||
let Some(vhost) = web_runtime.vhosts.get(host).cloned() else {
|
||||
return generic_not_found();
|
||||
let table = match wildcard::prepare(&mut request, &config, &vhost, host, &runtime) {
|
||||
Ok(table) => table,
|
||||
Err(()) => return service_unavailable(),
|
||||
};
|
||||
secrets::mark_internal_credential(&mut request, web_runtime, &runtime);
|
||||
let suffix = request.uri().path().strip_prefix(&vhost.base);
|
||||
if suffix == Some(WEBSOCKET_SUFFIX) {
|
||||
return websocket::handle(
|
||||
@@ -200,6 +219,7 @@ async fn handle_request(
|
||||
trusted_proxy_cidrs,
|
||||
runtime,
|
||||
vhost,
|
||||
host,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -211,6 +231,7 @@ async fn handle_request(
|
||||
trusted_proxy_cidrs,
|
||||
runtime,
|
||||
vhost,
|
||||
host,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -223,6 +244,9 @@ async fn handle_request(
|
||||
runtime,
|
||||
generation,
|
||||
vhost,
|
||||
host,
|
||||
table,
|
||||
web_runtime.snapshot_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -241,6 +265,9 @@ async fn handle_root(
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
generation: Arc<RuntimeGeneration>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
host: &str,
|
||||
table: Option<crate::web::manager::wildcard::CapabilityTable>,
|
||||
snapshot_id: u64,
|
||||
) -> HttpResponse {
|
||||
let representation = recovery::classify(&request);
|
||||
if matches!(representation, recovery::RootRepresentation::Invalid) {
|
||||
@@ -279,7 +306,8 @@ async fn handle_root(
|
||||
.record_decoy_fasttrack(WebDecoyFastTrackDisposition::EnforceCandidateFullScan);
|
||||
}
|
||||
}
|
||||
let matched_profile = match_profile(&vhost, candidate.scan_bytes());
|
||||
let matched_profile =
|
||||
wildcard::match_profile(&vhost, host, table.as_ref(), candidate.scan_bytes());
|
||||
let recovery_requested = matches!(representation, recovery::RootRepresentation::Recovery(_));
|
||||
let profile = matched_profile.filter(|_| canonical && request.method() == Method::GET);
|
||||
let Some(profile) = profile else {
|
||||
@@ -302,7 +330,7 @@ async fn handle_root(
|
||||
.and_then(|value| value.to_str().ok());
|
||||
let recovery_session = match representation {
|
||||
recovery::RootRepresentation::Recovery(Some(hash)) => {
|
||||
runtime.bridge_recovery_session(hash, &vhost.host, &profile)
|
||||
runtime.bridge_recovery_session(hash, host, &profile)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -341,6 +369,17 @@ async fn handle_root(
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
};
|
||||
if let (Some(cache), Some(table)) = (&runtime.wildcard, table) {
|
||||
cache.publish(snapshot_id, &vhost, host, table, || {
|
||||
runtime
|
||||
.active_generation()
|
||||
.config()
|
||||
.web
|
||||
.runtime
|
||||
.as_ref()
|
||||
.is_some_and(|current| current.snapshot_id == snapshot_id)
|
||||
});
|
||||
}
|
||||
if let Some(trace) = request_trace(&request) {
|
||||
trace.bind_profile(&profile, bootstrap.trace_session_id);
|
||||
trace.register_redaction(bootstrap.token.as_bytes());
|
||||
@@ -363,7 +402,7 @@ async fn handle_root(
|
||||
}
|
||||
let Some(response) = recovery::response(
|
||||
&bootstrap,
|
||||
&vhost,
|
||||
host,
|
||||
&profile,
|
||||
&config.web.limits,
|
||||
&config.web.timeouts,
|
||||
@@ -374,7 +413,7 @@ async fn handle_root(
|
||||
return response;
|
||||
}
|
||||
let page = bridge::render(
|
||||
&vhost.host,
|
||||
host,
|
||||
&vhost.base,
|
||||
&bootstrap.token,
|
||||
config.web.limits.carrier_batch_bytes,
|
||||
@@ -435,6 +474,7 @@ async fn handle_api(
|
||||
trusted_proxy_cidrs: &[IpNetwork],
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
host: &str,
|
||||
) -> HttpResponse {
|
||||
if request.uri().query().is_some() || !compatible_cookie_header(&request) {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
@@ -450,12 +490,12 @@ async fn handle_api(
|
||||
};
|
||||
match request.uri().path().strip_prefix(&vhost.base) {
|
||||
Some("api/v1/session") => {
|
||||
handle_session(request, runtime, vhost, token_hash, client_ip).await
|
||||
handle_session(request, runtime, vhost, host, token_hash, client_ip).await
|
||||
}
|
||||
Some("api/v1/up") => handle_up(request, runtime, vhost, token_hash).await,
|
||||
Some("api/v1/down") => handle_down(request, runtime, vhost, token_hash).await,
|
||||
Some("api/v1/up") => handle_up(request, runtime, vhost, host, token_hash).await,
|
||||
Some("api/v1/down") => handle_down(request, runtime, vhost, host, token_hash).await,
|
||||
Some("api/v1/diagnostic") => {
|
||||
diagnostic::handle(request, runtime, vhost, token_hash, client_ip).await
|
||||
diagnostic::handle(request, runtime, vhost, host, token_hash, client_ip).await
|
||||
}
|
||||
_ => serve_decoy(request, vhost, true, &runtime).await,
|
||||
}
|
||||
|
||||
@@ -398,6 +398,7 @@ async fn prefixed_decoy_request_keeps_its_original_path_and_query() {
|
||||
let mut config = runtime_config_with_base(capability, WebCarrier::Https, "/relay/");
|
||||
let profile = Arc::clone(&config.web.runtime.as_ref().unwrap().profiles[0]);
|
||||
let vhost = Arc::new(WebRuntimeVhost {
|
||||
wildcard: None,
|
||||
host: "proxy.example.com".to_string(),
|
||||
base: "/relay/".to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
@@ -412,6 +413,9 @@ async fn prefixed_decoy_request_keeps_its_original_path_and_query() {
|
||||
let mut vhosts = BTreeMap::new();
|
||||
vhosts.insert("proxy.example.com".to_string(), vhost);
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
snapshot_id: 0,
|
||||
wildcards: Default::default(),
|
||||
catch_all: None,
|
||||
vhosts,
|
||||
profiles: vec![profile],
|
||||
capabilities: vec![capability].into_boxed_slice(),
|
||||
|
||||
@@ -63,6 +63,7 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() {
|
||||
vhosts.insert(
|
||||
"proxy.example.com".to_string(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
wildcard: None,
|
||||
host: "proxy.example.com".to_string(),
|
||||
base: "/relay/".to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
@@ -78,6 +79,7 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() {
|
||||
vhosts.insert(
|
||||
"other.example.com".to_string(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
wildcard: None,
|
||||
host: "other.example.com".to_string(),
|
||||
base: "/other/".to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
@@ -91,6 +93,9 @@ async fn every_authentic_credential_placement_stays_out_of_the_upstream() {
|
||||
}),
|
||||
);
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
snapshot_id: 0,
|
||||
wildcards: Default::default(),
|
||||
catch_all: None,
|
||||
vhosts,
|
||||
profiles: vec![profile],
|
||||
capabilities: vec![capability].into_boxed_slice(),
|
||||
|
||||
@@ -195,6 +195,7 @@ async fn decoy_forwarding_preserves_every_reference_request_target() {
|
||||
vhosts.insert(
|
||||
"proxy.example.com".to_string(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
wildcard: None,
|
||||
host: previous.host.clone(),
|
||||
base: previous.base.clone(),
|
||||
decoy_fasttrack_mode: previous.decoy_fasttrack_mode,
|
||||
@@ -208,6 +209,9 @@ async fn decoy_forwarding_preserves_every_reference_request_target() {
|
||||
}),
|
||||
);
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
snapshot_id: 0,
|
||||
wildcards: Default::default(),
|
||||
catch_all: None,
|
||||
vhosts,
|
||||
profiles: runtime_config.profiles.clone(),
|
||||
capabilities: runtime_config.capabilities.clone(),
|
||||
|
||||
@@ -19,6 +19,7 @@ pub(super) async fn handle(
|
||||
request: Request<RequestBody>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
host: &str,
|
||||
token_hash: TokenHash,
|
||||
client_ip: IpAddr,
|
||||
) -> HttpResponse {
|
||||
@@ -26,7 +27,7 @@ pub(super) async fn handle(
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
let Some((trace_session_id, profile, body_timeout)) =
|
||||
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
|
||||
runtime.bootstrap_trace_identity(token_hash, host)
|
||||
else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
@@ -56,7 +57,7 @@ pub(super) async fn handle(
|
||||
let Some(event) = parse_event(&body) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
match runtime.claim_bridge_diagnostic(token_hash, &vhost.host, event) {
|
||||
match runtime.claim_bridge_diagnostic(token_hash, host, event) {
|
||||
Ok(first) => {
|
||||
if first {
|
||||
runtime.trace().record_profile_lifecycle(
|
||||
|
||||
@@ -20,6 +20,7 @@ pub(super) async fn handle_down(
|
||||
request: Request<RequestBody>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
host: &str,
|
||||
token_hash: TokenHash,
|
||||
) -> HttpResponse {
|
||||
if !matches!(*request.method(), hyper::Method::POST | hyper::Method::PUT)
|
||||
@@ -30,7 +31,7 @@ pub(super) async fn handle_down(
|
||||
let Some(cursor) = canonical_u64_header(&request, "x-down-cursor") else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
|
||||
let Ok(session) = runtime.get_session(token_hash, host) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
if session.carrier().uses_websocket() {
|
||||
|
||||
@@ -348,3 +348,7 @@ async fn alias_recovery_cannot_retire_a_foreign_session() {
|
||||
assert!(runtime.get_session(token_hash(&b), HOSTS[1]).is_ok());
|
||||
stop(runtime, &[generation]).await;
|
||||
}
|
||||
|
||||
// Automatic hosts share policies but retain exact-host authentication and quotas.
|
||||
#[path = "host_list_tests/wildcard.rs"]
|
||||
mod wildcard;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use serde::Serialize;
|
||||
use super::request::{bearer_token_hash, compatible_cookie_header};
|
||||
use super::response::{full_response, insert_header};
|
||||
use super::{HttpResponse, RequestBody};
|
||||
use crate::config::{WebRuntimeProfile, WebRuntimeVhost};
|
||||
use crate::config::WebRuntimeProfile;
|
||||
use crate::web::manager::{BootstrapResult, TokenHash};
|
||||
|
||||
pub(super) const MEDIA_TYPE: &str = "application/vnd.telemt.web-recovery+json";
|
||||
@@ -71,7 +71,7 @@ pub(super) fn has_media_type(request: &Request<RequestBody>) -> bool {
|
||||
/// Builds the bounded no-store recovery representation.
|
||||
pub(super) fn response(
|
||||
bootstrap: &BootstrapResult,
|
||||
vhost: &WebRuntimeVhost,
|
||||
host: &str,
|
||||
profile: &WebRuntimeProfile,
|
||||
limits: &crate::config::WebLimitsConfig,
|
||||
timeouts: &crate::config::WebTimeoutsConfig,
|
||||
@@ -101,7 +101,7 @@ pub(super) fn response(
|
||||
},
|
||||
};
|
||||
let body = serde_json::to_vec(&document).ok()?;
|
||||
if body.len() > MAX_RESPONSE_BYTES || profile.host != vhost.host {
|
||||
if body.len() > MAX_RESPONSE_BYTES || profile.host != host {
|
||||
return None;
|
||||
}
|
||||
let mut response = full_response(StatusCode::OK, Bytes::from(body));
|
||||
|
||||
@@ -17,7 +17,9 @@ use super::capability::scan_capabilities;
|
||||
|
||||
// Canonical host and forwarded-address provenance remain isolated from credentials.
|
||||
mod identity;
|
||||
pub(super) use identity::{canonical_request_host, carrier_ip_learning_eligible, client_ip};
|
||||
pub(super) use identity::{
|
||||
canonical_request_host, carrier_ip_learning_eligible, client_ip, wildcard_request_host,
|
||||
};
|
||||
|
||||
/// Matches one capability after a complete branchless scan of the virtual host table.
|
||||
pub(super) fn match_profile(
|
||||
|
||||
@@ -25,6 +25,49 @@ pub(in crate::web::http) fn canonical_request_host<B>(request: &Request<B>) -> O
|
||||
Some(host)
|
||||
}
|
||||
|
||||
/// Canonicalizes one ASCII DNS authority into bounded request-local storage.
|
||||
/// Only the literal public HTTPS port is permitted; forwarding headers are ignored.
|
||||
pub(in crate::web::http) fn wildcard_request_host<'a, B>(
|
||||
request: &Request<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.
|
||||
pub(in crate::web::http) fn client_ip<B>(
|
||||
request: &Request<B>,
|
||||
|
||||
+40
-16
@@ -14,20 +14,39 @@ pub(super) fn mark_internal_credential<B>(
|
||||
config: &WebRuntimeConfig,
|
||||
runtime: &WebProcessRuntime,
|
||||
) {
|
||||
let found = visit_metadata(request, |candidate| {
|
||||
bool::from(scan_capabilities(&config.capabilities, candidate).matched)
|
||||
|| runtime.authentic_token(candidate)
|
||||
|| runtime
|
||||
.wildcard
|
||||
.as_ref()
|
||||
.is_some_and(|cache| cache.contains(candidate))
|
||||
});
|
||||
if found {
|
||||
mark(request);
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a request after a full selected-host capability scan authenticated metadata.
|
||||
pub(super) fn mark<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_contains = uri
|
||||
.authority()
|
||||
.is_some_and(|authority| contains_secret(authority.as_str().as_bytes(), config, runtime))
|
||||
uri.authority()
|
||||
.is_some_and(|value| visit_text(value.as_str().as_bytes(), &mut visit))
|
||||
|| uri
|
||||
.path_and_query()
|
||||
.is_some_and(|path| contains_secret(path.as_str().as_bytes(), config, runtime));
|
||||
let headers_contain = request.headers().iter().any(|(name, value)| {
|
||||
contains_secret(name.as_str().as_bytes(), config, runtime)
|
||||
|| contains_secret(value.as_bytes(), config, runtime)
|
||||
});
|
||||
if uri_contains || headers_contain {
|
||||
request.extensions_mut().insert(InternalCredential);
|
||||
}
|
||||
.is_some_and(|value| visit_text(value.as_str().as_bytes(), &mut visit))
|
||||
|| request.headers().iter().any(|(name, value)| {
|
||||
visit_text(name.as_str().as_bytes(), &mut visit)
|
||||
|| visit_text(value.as_bytes(), &mut visit)
|
||||
})
|
||||
}
|
||||
|
||||
/// Returns whether request metadata was authenticated before routing.
|
||||
@@ -35,7 +54,7 @@ pub(super) fn has_internal_credential<B>(request: &Request<B>) -> bool {
|
||||
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 run_len = 0usize;
|
||||
let mut offset = 0usize;
|
||||
@@ -58,10 +77,7 @@ fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessR
|
||||
window[42] = byte;
|
||||
}
|
||||
if run_len >= window.len()
|
||||
&& canonical_credential(&window).is_some_and(|candidate| {
|
||||
bool::from(scan_capabilities(&config.capabilities, &candidate).matched)
|
||||
|| runtime.authentic_token(&candidate)
|
||||
})
|
||||
&& canonical_credential(&window).is_some_and(|candidate| visit(&candidate))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -88,3 +104,11 @@ fn hex_value(value: u8) -> Option<u8> {
|
||||
#[cfg(test)]
|
||||
#[path = "secrets/tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
fn contains_secret(text: &[u8], config: &WebRuntimeConfig, runtime: &WebProcessRuntime) -> bool {
|
||||
visit_text(text, &mut |candidate| {
|
||||
bool::from(scan_capabilities(&config.capabilities, candidate).matched)
|
||||
|| runtime.authentic_token(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(super) async fn handle_session(
|
||||
request: Request<RequestBody>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
host: &str,
|
||||
token_hash: TokenHash,
|
||||
client_ip: IpAddr,
|
||||
) -> HttpResponse {
|
||||
@@ -39,7 +40,7 @@ pub(super) async fn handle_session(
|
||||
let Some(carrier_failure) = optional_failure_header(&request) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let session = runtime.get_session(token_hash, &vhost.host).ok();
|
||||
let session = runtime.get_session(token_hash, host).ok();
|
||||
if let Some(trace) = request_trace(&request)
|
||||
&& let Some(session) = &session
|
||||
{
|
||||
@@ -63,7 +64,7 @@ pub(super) async fn handle_session(
|
||||
};
|
||||
if !body.is_empty()
|
||||
|| runtime
|
||||
.close_token(token_hash, &vhost.host, carrier_failure)
|
||||
.close_token(token_hash, host, carrier_failure)
|
||||
.is_err()
|
||||
{
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
@@ -73,7 +74,7 @@ pub(super) async fn handle_session(
|
||||
if request.method() != Method::POST || !binary_content_type(&request) {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
}
|
||||
let Some(carrier_request) = carrier_request(&request, &vhost.host) else {
|
||||
let Some(carrier_request) = carrier_request(&request, host) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let up_window = if request.headers().contains_key("x-telemt-up-window") {
|
||||
@@ -89,7 +90,7 @@ pub(super) async fn handle_session(
|
||||
let carrier_request = carrier_request.with_up_window(up_window);
|
||||
let ip_learning_eligible = carrier_ip_learning_eligible(&request, client_ip);
|
||||
let Some((trace_session_id, profile, body_timeout)) =
|
||||
runtime.bootstrap_trace_identity(token_hash, &vhost.host)
|
||||
runtime.bootstrap_trace_identity(token_hash, host)
|
||||
else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
@@ -117,7 +118,7 @@ pub(super) async fn handle_session(
|
||||
}
|
||||
match runtime.create_session(
|
||||
token_hash,
|
||||
&vhost.host,
|
||||
host,
|
||||
client_ip,
|
||||
&body,
|
||||
carrier_request,
|
||||
@@ -185,9 +186,7 @@ pub(super) async fn handle_session(
|
||||
}
|
||||
Err(ManagerError::Committed) => {
|
||||
let mut response = carrier_empty(StatusCode::CONFLICT);
|
||||
if let Some(echo) =
|
||||
runtime.carrier_echo(token_hash, &vhost.host, client_ip, carrier_request)
|
||||
{
|
||||
if let Some(echo) = runtime.carrier_echo(token_hash, host, client_ip, carrier_request) {
|
||||
response.headers_mut().insert(
|
||||
HeaderName::from_static("x-carrier-mode"),
|
||||
HeaderValue::from_static(echo.carrier.as_str()),
|
||||
|
||||
@@ -129,6 +129,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability,
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: "203.0.113.10:443".parse().unwrap(),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
@@ -158,6 +159,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
index: "index.html".to_string(),
|
||||
});
|
||||
let vhost = Arc::new(WebRuntimeVhost {
|
||||
wildcard: None,
|
||||
host: "proxy.example.com".to_string(),
|
||||
base: base.to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
@@ -171,6 +173,7 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
vhosts.insert(
|
||||
"other.example.com".to_string(),
|
||||
Arc::new(WebRuntimeVhost {
|
||||
wildcard: None,
|
||||
host: "other.example.com".to_string(),
|
||||
base: "/".to_string(),
|
||||
decoy_fasttrack_mode: WebDecoyFastTrackMode::Off,
|
||||
@@ -193,6 +196,9 @@ fn runtime_config_with_carriers_and_deadlines(
|
||||
config.web.limits.max_bootstraps_per_ip = 1;
|
||||
config.web.timeouts.shutdown_secs = 1;
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
snapshot_id: 0,
|
||||
wildcards: Default::default(),
|
||||
catch_all: None,
|
||||
vhosts,
|
||||
profiles: vec![profile],
|
||||
capabilities: vec![capability].into_boxed_slice(),
|
||||
|
||||
+2
-1
@@ -6,6 +6,7 @@ pub(super) async fn handle_up(
|
||||
request: Request<RequestBody>,
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
host: &str,
|
||||
token_hash: crate::web::manager::TokenHash,
|
||||
) -> HttpResponse {
|
||||
if !matches!(*request.method(), Method::POST | Method::PUT) || !binary_content_type(&request) {
|
||||
@@ -15,7 +16,7 @@ pub(super) async fn handle_up(
|
||||
else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let Ok(session) = runtime.get_session(token_hash, &vhost.host) else {
|
||||
let Ok(session) = runtime.get_session(token_hash, host) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
if session.carrier().uses_websocket() {
|
||||
|
||||
@@ -253,6 +253,7 @@ pub(super) async fn handle(
|
||||
trusted_proxy_cidrs: &[IpNetwork],
|
||||
runtime: Arc<WebProcessRuntime>,
|
||||
vhost: Arc<WebRuntimeVhost>,
|
||||
host: &str,
|
||||
) -> HttpResponse {
|
||||
let request_deadline = super::request_deadline(&request);
|
||||
let Some(parsed) = parse_upgrade(&request) else {
|
||||
@@ -265,7 +266,7 @@ pub(super) async fn handle(
|
||||
else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let Ok(session) = runtime.get_session(parsed.token_hash, &vhost.host) else {
|
||||
let Ok(session) = runtime.get_session(parsed.token_hash, host) else {
|
||||
return serve_decoy(request, vhost, true, &runtime).await;
|
||||
};
|
||||
let kind = match (parsed.carrier, session.carrier()) {
|
||||
|
||||
@@ -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
@@ -21,6 +21,8 @@ use crate::web::telemetry::{WebRejectionReason, WebTelemetry};
|
||||
use crate::web::trace::WebTraceStore;
|
||||
// Credential maps, quotas, and token-bucket helpers remain private to the manager.
|
||||
mod state;
|
||||
/// Wildcard derivation and positive containment are bounded independently of session quotas.
|
||||
pub(crate) mod wildcard;
|
||||
// Carrier attempt metadata remains explicit and independent from HTTP parsing.
|
||||
mod negotiation;
|
||||
// Bounded process-local carrier evidence is isolated from session registries.
|
||||
@@ -221,6 +223,8 @@ pub(crate) struct WebProcessRuntime {
|
||||
active_runtime: Arc<ArcSwap<RuntimeGeneration>>,
|
||||
trace: Arc<WebTraceStore>,
|
||||
token_authenticator: TokenAuthenticator,
|
||||
/// Process-frozen optional wildcard derivation and retention state.
|
||||
pub(crate) wildcard: Option<wildcard::WildcardState>,
|
||||
limits: WebLimitsConfig,
|
||||
state: Mutex<ManagerState>,
|
||||
stream_admission: Mutex<StreamAdmissionState>,
|
||||
@@ -253,7 +257,11 @@ impl WebProcessRuntime {
|
||||
#[cfg(test)]
|
||||
pub(crate) fn start(active_runtime: Arc<ArcSwap<RuntimeGeneration>>) -> Arc<Self> {
|
||||
let config = active_runtime.load().config();
|
||||
let trace = WebTraceStore::new(config.web.debug.clone(), &config.web.limits);
|
||||
let trace = WebTraceStore::new_with_wildcards(
|
||||
config.web.debug.clone(),
|
||||
&config.web.limits,
|
||||
config.web.allow_wildcard_hosts,
|
||||
);
|
||||
Self::start_with_trace(active_runtime, trace, WebTelemetry::new())
|
||||
}
|
||||
|
||||
@@ -295,6 +303,10 @@ impl WebProcessRuntime {
|
||||
active_runtime,
|
||||
trace,
|
||||
token_authenticator,
|
||||
wildcard: config
|
||||
.web
|
||||
.allow_wildcard_hosts
|
||||
.then(|| wildcard::WildcardState::new(limits.wildcard.clone())),
|
||||
http_connections: Arc::new(Semaphore::new(limits.max_http_connections)),
|
||||
http_overload_connections: Arc::new(Semaphore::new(
|
||||
limits.max_http_overload_connections,
|
||||
|
||||
@@ -164,7 +164,7 @@ impl WebProcessRuntime {
|
||||
}
|
||||
let trace_session_id = self.trace.next_session_id();
|
||||
let bridge_diagnostics_enabled = config.web.debug.bridge_diagnostics_enabled();
|
||||
let (user_agent, user_agent_id) = bounded_user_agent(user_agent);
|
||||
let (user_agent, user_agent_id) = bounded_user_agent(user_agent, self.wildcard.is_some());
|
||||
let issued_profile = Arc::clone(&profile);
|
||||
let expires_at = now + Duration::from_secs(config.web.timeouts.bootstrap_lifetime_secs);
|
||||
state.bootstrap_expiry = Some(state.bootstrap_expiry.unwrap_or(expires_at).min(expires_at));
|
||||
@@ -381,7 +381,7 @@ impl WebProcessRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_user_agent(value: Option<&str>) -> (Option<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 HASH_CONTEXT: &[u8] = b"telemt-web-user-agent-v1\0";
|
||||
let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
@@ -394,6 +394,7 @@ fn bounded_user_agent(value: Option<&str>) -> (Option<Arc<str>>, Option<[u8; 16]
|
||||
let mut id = [0; 16];
|
||||
id.copy_from_slice(&digest[..16]);
|
||||
let mut display = String::with_capacity(value.len().min(DISPLAY_BYTES));
|
||||
let mut truncated = false;
|
||||
for character in value.chars() {
|
||||
let character = if character.is_control() {
|
||||
'\u{fffd}'
|
||||
@@ -401,9 +402,28 @@ fn bounded_user_agent(value: Option<&str>) -> (Option<Arc<str>>, Option<[u8; 16]
|
||||
character
|
||||
};
|
||||
if display.len().saturating_add(character.len_utf8()) > DISPLAY_BYTES {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
display.push(character);
|
||||
}
|
||||
if wildcard {
|
||||
crate::web::trace::scrub_text(&mut display, truncated);
|
||||
}
|
||||
(Some(Arc::from(display)), Some(id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wildcard_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wildcard_user_agent_redaction_preserves_original_hash_and_masks_expanded_cutoff() {
|
||||
let value = format!("{}/{}tail", "x\t".repeat(60), "A".repeat(43));
|
||||
let (plain, plain_hash) = bounded_user_agent(Some(&value), false);
|
||||
let (redacted, redacted_hash) = bounded_user_agent(Some(&value), true);
|
||||
assert_eq!(plain_hash, redacted_hash);
|
||||
assert!(plain.unwrap().ends_with(&"A".repeat(15)));
|
||||
assert!(redacted.unwrap().ends_with(&"*".repeat(15)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,8 +287,35 @@ pub(super) fn profile_key(profile: &WebRuntimeProfile) -> ProfileKey {
|
||||
/// Re-resolves an issued profile against the active generation without weakening identity.
|
||||
pub(super) fn matching_profile(
|
||||
runtime: &WebRuntimeConfig,
|
||||
expected: &WebRuntimeProfile,
|
||||
expected: &Arc<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
|
||||
.profiles
|
||||
.iter()
|
||||
|
||||
@@ -21,6 +21,7 @@ impl Fixture {
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [7; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: "203.0.113.10:443".parse().unwrap(),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
|
||||
@@ -18,6 +18,9 @@ fn generation(id: u64, lifetime: u64) -> Arc<RuntimeGeneration> {
|
||||
config.web.limits.new_bootstraps_burst = 4096;
|
||||
config.web.limits.new_sessions_burst = 4096;
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
snapshot_id: 0,
|
||||
wildcards: Default::default(),
|
||||
catch_all: None,
|
||||
vhosts: BTreeMap::new(),
|
||||
profiles: vec![Arc::clone(&fixture.profile)],
|
||||
capabilities: vec![fixture.profile.capability].into_boxed_slice(),
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,197 @@
|
||||
use super::*;
|
||||
|
||||
fn config() -> ProxyConfig {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
r#"
|
||||
[access.users]
|
||||
alice = "000102030405060708090a0b0c0d0e0f"
|
||||
bob = "100102030405060708090a0b0c0d0e0f"
|
||||
[web]
|
||||
allow_wildcard_hosts = true
|
||||
[web.limits]
|
||||
memory_envelope_bytes = 1610612736
|
||||
[[web.vhosts]]
|
||||
host = "*.example.com"
|
||||
public_addr = "203.0.113.10:443"
|
||||
[web.vhosts.decoy]
|
||||
mode = "http_upstream"
|
||||
upstream = "http://127.0.0.1:18081"
|
||||
[[web.vhosts.profiles]]
|
||||
user = "alice"
|
||||
secret_mode = "plain"
|
||||
[[web.vhosts.profiles]]
|
||||
user = "bob"
|
||||
secret_mode = "dd"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
ProxyConfig::load(path).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_budget_reserves_full_table_and_warm_hits_remain_available() {
|
||||
let config = config();
|
||||
let web = config.web.runtime.as_ref().unwrap();
|
||||
let vhost = &web.wildcards["example.com"];
|
||||
let cache = WildcardState::new(WebWildcardLimitsConfig {
|
||||
cold_hmac_burst: 3,
|
||||
cold_hmac_per_minute: 1,
|
||||
..Default::default()
|
||||
});
|
||||
let table = cache.prepare(&config, vhost, "a.example.com").unwrap();
|
||||
assert_eq!(cache.test_stats(), (0, 0, 2));
|
||||
cache.publish(web.snapshot_id, vhost, "a.example.com", table, || true);
|
||||
assert!(cache.prepare(&config, vhost, "b.example.com").is_none());
|
||||
assert!(matches!(
|
||||
cache.prepare(&config, vhost, "a.example.com"),
|
||||
Some(CapabilityTable::Warm(_))
|
||||
));
|
||||
assert_eq!(cache.test_stats().2, 2);
|
||||
let mut state = State::default();
|
||||
let now = Instant::now();
|
||||
assert!(reserve(&mut state, now, 2, &cache.limits));
|
||||
assert!(!reserve(&mut state, now, 2, &cache.limits));
|
||||
assert!(reserve(
|
||||
&mut state,
|
||||
now + std::time::Duration::from_secs(60),
|
||||
2,
|
||||
&cache.limits
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evicted_borrowers_keep_their_byte_lease_and_no_configuration_snapshot() {
|
||||
let config = config();
|
||||
let web = config.web.runtime.as_ref().unwrap();
|
||||
let vhost = &web.wildcards["example.com"];
|
||||
let weak = Arc::downgrade(vhost);
|
||||
let cost = 2 * 256 + 2048 + 2 * "a.example.com".len();
|
||||
let cache = WildcardState::new(WebWildcardLimitsConfig {
|
||||
cache_hosts: 1,
|
||||
cache_bytes: cost,
|
||||
..Default::default()
|
||||
});
|
||||
let table = cache.prepare(&config, vhost, "a.example.com").unwrap();
|
||||
let capability = table.capabilities()[0];
|
||||
cache.publish(web.snapshot_id, vhost, "a.example.com", table, || true);
|
||||
let borrowed = cache.prepare(&config, vhost, "a.example.com").unwrap();
|
||||
assert!(cache.contains(&capability));
|
||||
let next = cache.prepare(&config, vhost, "b.example.com").unwrap();
|
||||
cache.publish(web.snapshot_id, vhost, "b.example.com", next, || true);
|
||||
assert_eq!(cache.test_stats().0, 0);
|
||||
assert_eq!(cache.test_stats().1, cost);
|
||||
assert!(!cache.contains(&capability));
|
||||
drop(config);
|
||||
assert!(weak.upgrade().is_none());
|
||||
drop(borrowed);
|
||||
assert_eq!(cache.test_stats().1, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_misses_do_not_pollute_cache_and_snapshot_identity_fences_hits() {
|
||||
let config = config();
|
||||
let web = config.web.runtime.as_ref().unwrap();
|
||||
let vhost = &web.wildcards["example.com"];
|
||||
let cache = WildcardState::new(WebWildcardLimitsConfig::default());
|
||||
for index in 0..1000 {
|
||||
drop(
|
||||
cache
|
||||
.prepare(&config, vhost, &format!("h{index}.example.com"))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
assert_eq!(cache.test_stats(), (0, 0, 2000));
|
||||
let table = cache.prepare(&config, vhost, "a.example.com").unwrap();
|
||||
cache.publish(web.snapshot_id, vhost, "a.example.com", table, || true);
|
||||
let next = self::config();
|
||||
let next_vhost = &next.web.runtime.as_ref().unwrap().wildcards["example.com"];
|
||||
assert!(matches!(
|
||||
cache.prepare(&next, next_vhost, "a.example.com"),
|
||||
Some(CapabilityTable::Cold(_))
|
||||
));
|
||||
let disabled = WildcardState::new(WebWildcardLimitsConfig {
|
||||
cache_hosts: 0,
|
||||
..Default::default()
|
||||
});
|
||||
let table = disabled.prepare(&config, vhost, "a.example.com").unwrap();
|
||||
disabled.publish(web.snapshot_id, vhost, "a.example.com", table, || true);
|
||||
assert_eq!(disabled.test_stats(), (0, 0, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_publication_and_eviction_keep_entry_and_byte_bounds() {
|
||||
let config = Arc::new(config());
|
||||
let cache = Arc::new(WildcardState::new(WebWildcardLimitsConfig {
|
||||
cache_hosts: 4,
|
||||
cache_bytes: 8192,
|
||||
..Default::default()
|
||||
}));
|
||||
std::thread::scope(|scope| {
|
||||
for worker in 0..8 {
|
||||
let cache = Arc::clone(&cache);
|
||||
let config = Arc::clone(&config);
|
||||
scope.spawn(move || {
|
||||
let web = config.web.runtime.as_ref().unwrap();
|
||||
let vhost = &web.wildcards["example.com"];
|
||||
for index in 0..100 {
|
||||
let host = format!("h{worker}-{index}.example.com");
|
||||
let table = cache.prepare(&config, vhost, &host).unwrap();
|
||||
cache.publish(web.snapshot_id, vhost, &host, table, || true);
|
||||
let (hosts, bytes, _) = cache.test_stats();
|
||||
assert!(hosts <= 4 && bytes <= 8192);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_publication_cannot_evict_current_warm_table() {
|
||||
let old = config();
|
||||
let current = config();
|
||||
let cache = WildcardState::new(WebWildcardLimitsConfig::default());
|
||||
let old_web = old.web.runtime.as_ref().unwrap();
|
||||
let current_web = current.web.runtime.as_ref().unwrap();
|
||||
let old_vhost = &old_web.wildcards["example.com"];
|
||||
let current_vhost = ¤t_web.wildcards["example.com"];
|
||||
let late = cache.prepare(&old, old_vhost, "a.example.com").unwrap();
|
||||
let latest = cache
|
||||
.prepare(¤t, current_vhost, "a.example.com")
|
||||
.unwrap();
|
||||
cache.publish(
|
||||
current_web.snapshot_id,
|
||||
current_vhost,
|
||||
"a.example.com",
|
||||
latest,
|
||||
|| true,
|
||||
);
|
||||
cache.publish(
|
||||
old_web.snapshot_id,
|
||||
old_vhost,
|
||||
"a.example.com",
|
||||
late,
|
||||
|| false,
|
||||
);
|
||||
assert!(matches!(
|
||||
cache.prepare(¤t, current_vhost, "a.example.com"),
|
||||
Some(CapabilityTable::Warm(_))
|
||||
));
|
||||
assert_eq!(cache.test_stats().2, 4);
|
||||
// A prepared generation can activate after a later-built watcher snapshot.
|
||||
// Numeric ID ordering must not prevent that active snapshot from warming.
|
||||
let reactivated = cache.prepare(&old, old_vhost, "a.example.com").unwrap();
|
||||
cache.publish(
|
||||
old_web.snapshot_id,
|
||||
old_vhost,
|
||||
"a.example.com",
|
||||
reactivated,
|
||||
|| true,
|
||||
);
|
||||
assert!(matches!(
|
||||
cache.prepare(&old, old_vhost, "a.example.com"),
|
||||
Some(CapabilityTable::Warm(_))
|
||||
));
|
||||
}
|
||||
@@ -68,6 +68,7 @@ fn test_runtime_with_dc(
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [7; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: "203.0.113.10:443".parse().unwrap(),
|
||||
user: "default".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
@@ -111,6 +112,9 @@ fn test_runtime_with_dc(
|
||||
});
|
||||
}
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
snapshot_id: 0,
|
||||
wildcards: Default::default(),
|
||||
catch_all: None,
|
||||
vhosts: BTreeMap::new(),
|
||||
profiles: vec![Arc::clone(&profile)],
|
||||
capabilities: vec![[7; 32]].into_boxed_slice(),
|
||||
|
||||
@@ -23,6 +23,7 @@ fn fixture(
|
||||
host: "proxy.example.com".into(),
|
||||
capability: [0; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: "203.0.113.10:443".parse().unwrap(),
|
||||
user: "default".into(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
|
||||
@@ -34,6 +34,7 @@ fn session() -> (Arc<WebSession>, Arc<WebProcessRuntime>) {
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [0; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
|
||||
@@ -49,6 +49,7 @@ fn new_session_with_automatic(
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [0; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
|
||||
@@ -13,6 +13,7 @@ fn session(carrier: WebCarrier, deadline: Instant) -> Arc<WebSession> {
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [0; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
|
||||
@@ -136,6 +136,7 @@ fn session() -> Arc<WebSession> {
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [0; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
|
||||
@@ -18,6 +18,7 @@ fn session_with_automatic(automatic: bool) -> Arc<WebSession> {
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [0; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: SocketAddr::from(([203, 0, 113, 10], 443)),
|
||||
user: "alice".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
|
||||
@@ -50,6 +50,7 @@ fn runtime(admission: bool) -> TestRuntime {
|
||||
host: "proxy.example.com".to_string(),
|
||||
capability: [7; 32],
|
||||
shared: Arc::new(crate::config::WebRuntimeProfileShared {
|
||||
derivation_context: [0; 32],
|
||||
public_addr: "203.0.113.10:443".parse().unwrap(),
|
||||
user: "default".to_string(),
|
||||
secret_mode: WebSecretMode::Plain,
|
||||
@@ -70,6 +71,9 @@ fn runtime(admission: bool) -> TestRuntime {
|
||||
config.web.carrier = WebCarrier::WebsocketLanes;
|
||||
config.web.timeouts.shutdown_secs = 1;
|
||||
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
|
||||
snapshot_id: 0,
|
||||
wildcards: Default::default(),
|
||||
catch_all: None,
|
||||
vhosts: BTreeMap::new(),
|
||||
profiles: vec![Arc::clone(&profile)],
|
||||
capabilities: vec![[7; 32]].into_boxed_slice(),
|
||||
|
||||
+54
-23
@@ -78,30 +78,48 @@ impl HttpTraceExchange {
|
||||
let started_epoch_millis = epoch_millis();
|
||||
let dynamic = request_dynamic_bytes(request, &policy);
|
||||
let dynamic_reserved = store.try_reserve(dynamic);
|
||||
let (method, path, user_agent, request_headers, redactions) = if dynamic_reserved {
|
||||
(
|
||||
request.method().as_str().to_string(),
|
||||
request.uri().path().to_string(),
|
||||
request
|
||||
let (mut method, mut path, mut user_agent, request_headers, redactions) =
|
||||
if dynamic_reserved {
|
||||
(
|
||||
request.method().as_str().to_string(),
|
||||
request.uri().path().to_string(),
|
||||
request
|
||||
.headers()
|
||||
.get(hyper::header::USER_AGENT)
|
||||
.map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)),
|
||||
if policy.capture_headers {
|
||||
sanitized_headers(request.headers(), store.wildcard_redaction)
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
sensitive_values(request.headers(), request.uri().query()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
bounded_text(request.method().as_str().as_bytes(), 32),
|
||||
bounded_text(request.uri().path().as_bytes(), 512),
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
if store.wildcard_redaction {
|
||||
super::wildcard::scrub_text(
|
||||
&mut method,
|
||||
!dynamic_reserved && request.method().as_str().len() > 32,
|
||||
);
|
||||
super::wildcard::scrub_text(
|
||||
&mut path,
|
||||
!dynamic_reserved && request.uri().path().len() > 512,
|
||||
);
|
||||
if let Some(user_agent) = &mut user_agent {
|
||||
let truncated = request
|
||||
.headers()
|
||||
.get(hyper::header::USER_AGENT)
|
||||
.map(|value| bounded_text(value.as_bytes(), USER_AGENT_MAX_BYTES)),
|
||||
if policy.capture_headers {
|
||||
sanitized_headers(request.headers())
|
||||
} else {
|
||||
Vec::new()
|
||||
},
|
||||
sensitive_values(request.headers(), request.uri().query()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
bounded_text(request.method().as_str().as_bytes(), 32),
|
||||
bounded_text(request.uri().path().as_bytes(), 512),
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
)
|
||||
};
|
||||
.is_some_and(|value| value.len() > USER_AGENT_MAX_BYTES);
|
||||
super::wildcard::scrub_text(user_agent, truncated);
|
||||
}
|
||||
}
|
||||
Arc::new(Self {
|
||||
store,
|
||||
epoch,
|
||||
@@ -209,7 +227,8 @@ impl HttpTraceExchange {
|
||||
let reserved = self.reserve_locked(&mut state, dynamic);
|
||||
state.status = Some(response.status().as_u16());
|
||||
if self.policy.capture_headers && reserved {
|
||||
state.response_headers = sanitized_headers(response.headers());
|
||||
state.response_headers =
|
||||
sanitized_headers(response.headers(), self.store.wildcard_redaction);
|
||||
}
|
||||
if reserved {
|
||||
state
|
||||
@@ -394,6 +413,18 @@ impl HttpTraceExchange {
|
||||
let redactions = std::mem::take(&mut state.redactions);
|
||||
scrub_body(&mut state.request_body.captured, &redactions);
|
||||
scrub_body(&mut state.response_body.captured, &redactions);
|
||||
if self.store.wildcard_redaction {
|
||||
super::wildcard::scrub_bytes(
|
||||
&mut state.request_body.captured,
|
||||
state.request_body.truncated
|
||||
|| state.request_body.state != Some(TraceBodyState::Complete),
|
||||
);
|
||||
super::wildcard::scrub_bytes(
|
||||
&mut state.response_body.captured,
|
||||
state.response_body.truncated
|
||||
|| state.response_body.state != Some(TraceBodyState::Complete),
|
||||
);
|
||||
}
|
||||
let request_body = body_snapshot(&self.policy, &mut state.request_body);
|
||||
let response_body = body_snapshot(&self.policy, &mut state.response_body);
|
||||
TraceRecord {
|
||||
|
||||
@@ -18,3 +18,7 @@ pub(crate) use types::{
|
||||
TraceLifecycleContext, TraceLifecycleEvent, TraceLifecycleRecord, TraceRecord, TraceRecordKind,
|
||||
TraceRoute, TraceWebSocketContext,
|
||||
};
|
||||
|
||||
// Shape-based sanitization is exclusively for diagnostic copies, never public routing.
|
||||
mod wildcard;
|
||||
pub(crate) use wildcard::scrub_text;
|
||||
|
||||
@@ -54,12 +54,21 @@ pub(super) fn response_dynamic_bytes<B>(
|
||||
}
|
||||
|
||||
/// 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
|
||||
.iter()
|
||||
.map(|(name, value)| TraceHeader {
|
||||
name: name.as_str().to_string(),
|
||||
value: header_value_allowed(name).then(|| bounded_text(value.as_bytes(), 4096)),
|
||||
.map(|(name, value)| {
|
||||
let mut copied = TraceHeader {
|
||||
name: name.as_str().to_string(),
|
||||
value: header_value_allowed(name).then(|| bounded_text(value.as_bytes(), 4096)),
|
||||
};
|
||||
if wildcard {
|
||||
super::wildcard::scrub_text(&mut copied.name, false);
|
||||
if let Some(text) = &mut copied.value {
|
||||
super::wildcard::scrub_text(text, value.len() > 4096);
|
||||
}
|
||||
}
|
||||
copied
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
+14
-1
@@ -77,6 +77,8 @@ pub(crate) struct TraceClearOutcome {
|
||||
|
||||
/// Process-owned bounded WEB debug trace store.
|
||||
pub(crate) struct WebTraceStore {
|
||||
/// Process-frozen redaction for credentials outside the finite hostname inventory.
|
||||
pub(super) wildcard_redaction: bool,
|
||||
policy: ArcSwap<WebDebugConfig>,
|
||||
policy_update: Mutex<()>,
|
||||
enabled: AtomicBool,
|
||||
@@ -97,9 +99,20 @@ pub(crate) struct WebTraceStore {
|
||||
}
|
||||
|
||||
impl WebTraceStore {
|
||||
/// Creates a process-owned store from restart-only capacity and initial policy.
|
||||
/// Creates an exact-host trace store for existing fixtures.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new(policy: WebDebugConfig, limits: &WebLimitsConfig) -> Arc<Self> {
|
||||
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 {
|
||||
wildcard_redaction,
|
||||
enabled: AtomicBool::new(policy.enabled),
|
||||
policy: ArcSwap::from_pointee(policy),
|
||||
policy_update: Mutex::new(()),
|
||||
|
||||
@@ -27,10 +27,19 @@ impl WebTraceStore {
|
||||
if !self.enabled.load(Ordering::Acquire) || !self.policy.load().enabled {
|
||||
return None;
|
||||
}
|
||||
let user_agent = request
|
||||
let mut user_agent = request
|
||||
.headers()
|
||||
.get(hyper::header::USER_AGENT)
|
||||
.map(|value| super::super::sanitize::bounded_text(value.as_bytes(), 512));
|
||||
if self.wildcard_redaction
|
||||
&& let Some(text) = &mut user_agent
|
||||
{
|
||||
let truncated = request
|
||||
.headers()
|
||||
.get(hyper::header::USER_AGENT)
|
||||
.is_some_and(|value| value.len() > 512);
|
||||
super::super::wildcard::scrub_text(text, truncated);
|
||||
}
|
||||
Some(TraceWebSocketContext {
|
||||
connection_id,
|
||||
peer_ip,
|
||||
@@ -94,12 +103,18 @@ impl WebTraceStore {
|
||||
if !self.try_reserve_record(reservation) {
|
||||
return;
|
||||
}
|
||||
let body = (policy.body_capture != WebDebugBodyCapture::Off).then(|| TraceBodySnapshot {
|
||||
observed_bytes: payload.len() as u64,
|
||||
captured: payload[..capture_bytes].to_vec(),
|
||||
truncated: capture_limit != 0 && capture_bytes < payload.len(),
|
||||
state: TraceBodyState::Complete,
|
||||
});
|
||||
let mut body =
|
||||
(policy.body_capture != WebDebugBodyCapture::Off).then(|| TraceBodySnapshot {
|
||||
observed_bytes: payload.len() as u64,
|
||||
captured: payload[..capture_bytes].to_vec(),
|
||||
truncated: capture_limit != 0 && capture_bytes < payload.len(),
|
||||
state: TraceBodyState::Complete,
|
||||
});
|
||||
if self.wildcard_redaction
|
||||
&& let Some(body) = &mut body
|
||||
{
|
||||
super::super::wildcard::scrub_bytes(&mut body.captured, body.truncated);
|
||||
}
|
||||
let frames = if policy.capture_frames && message_type == "binary" {
|
||||
websocket_frames(direction, payload, &self.frame_limits)
|
||||
} else {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user