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