WEB: bounded opt-in wildcard host routing added

This commit is contained in:
Alexey
2026-10-17 23:42:26 +03:00
parent b2b088495d
commit 9738244285
67 changed files with 2572 additions and 239 deletions
+1 -1
View File
@@ -22,7 +22,7 @@
//! `server.port`, `censorship.*`, `network.*`, `use_middle_proxy`) are **not**
//! applied; a warning is emitted. SYN limiter rules are process-owned and are
//! reconciled only during privileged startup.
//! `web.decoy_fasttrack_mode` is also restart-only so one process never mixes
//! `web.allow_wildcard_hosts, web.decoy_fasttrack_mode` is also restart-only so one process never mixes
//! capability timing policies or process-lifetime counter semantics.
//! Non-hot changes are never mixed into the runtime config snapshot.
+4
View File
@@ -85,6 +85,10 @@ pub(super) fn warn_non_hot_changes(old: &ProxyConfig, new: &ProxyConfig, non_hot
warned = true;
warn!("config reload: server listener settings changed; restart required");
}
if old.web.allow_wildcard_hosts != new.web.allow_wildcard_hosts {
warned = true;
warn!("config reload: web.allow_wildcard_hosts changed; restart required");
}
if old.web.decoy_fasttrack_mode != new.web.decoy_fasttrack_mode {
warned = true;
warn!("config reload: web.decoy_fasttrack_mode changed; restart required");
+2
View File
@@ -342,10 +342,12 @@ pub(super) fn overlay_hot_fields(old: &ProxyConfig, new: &ProxyConfig) -> ProxyC
cfg.access.user_max_unique_ips_global_each = new.access.user_max_unique_ips_global_each;
cfg.access.user_max_unique_ips_mode = new.access.user_max_unique_ips_mode;
cfg.access.user_max_unique_ips_window_secs = new.access.user_max_unique_ips_window_secs;
let allow_wildcard_hosts = cfg.web.allow_wildcard_hosts;
let process_limits = cfg.web.limits.clone();
let decoy_fasttrack_mode = cfg.web.decoy_fasttrack_mode;
cfg.web = new.web.clone();
cfg.web.limits = process_limits;
cfg.web.allow_wildcard_hosts = allow_wildcard_hosts;
cfg.web.decoy_fasttrack_mode = decoy_fasttrack_mode;
if cfg.web.carrier_negotiation_enabled()
&& cfg.web.carrier_learning
+4
View File
@@ -66,3 +66,7 @@ secret_mode = "dd"
}
}
}
// The watcher must enforce the same effective opt-in as API reconciliation.
#[path = "wildcard_tests.rs"]
mod wildcard_tests;
+61
View File
@@ -0,0 +1,61 @@
use super::*;
#[test]
fn wildcard_watcher_freezes_enable_flag_and_publishes_patterns_only_after_restart() {
for enabled in [false, true] {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
let source = format!(
r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[web]
allow_wildcard_hosts = {enabled}
[web.limits]
memory_envelope_bytes = 1610612736
[[web.vhosts]]
host = "a.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://127.0.0.1:18081"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "plain"
"#
);
std::fs::write(&path, &source).unwrap();
let loaded = ProxyConfig::load_with_metadata(&path).unwrap();
let initial = Arc::new(loaded.config);
let (config_tx, _config_rx) = watch::channel(initial.clone());
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
let mut state = ReloadState::new(Some(loaded.rendered_hash));
let desired = source
.replace(
"allow_wildcard_hosts = false",
"allow_wildcard_hosts = true",
)
.replace("host = \"a.example.com\"", "host = \"*.example.com\"");
std::fs::write(&path, desired).unwrap();
reload_config(&path, &config_tx, &log_tx, None, None, &mut state);
let active = config_tx.borrow().clone();
assert_eq!(active.web.allow_wildcard_hosts, enabled);
if enabled {
assert!(
active
.web
.runtime
.as_ref()
.unwrap()
.wildcards
.contains_key("example.com")
);
assert_ne!(
active.web.runtime.as_ref().unwrap().snapshot_id,
initial.web.runtime.as_ref().unwrap().snapshot_id
);
} else {
assert!(Arc::ptr_eq(&initial, &active));
}
}
}
+14 -2
View File
@@ -185,12 +185,24 @@ pub(super) fn build_upstream(
impl ProxyConfig {
/// Compares selected endpoints separately from source-only configuration equality.
pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool {
fn selected(config: &ProxyConfig) -> impl Iterator<Item = (&String, SocketAddr)> {
fn selected(config: &ProxyConfig) -> impl Iterator<Item = ((u8, &str), SocketAddr)> {
config
.web
.runtime
.iter()
.flat_map(|runtime| runtime.vhosts.iter())
.flat_map(|runtime| {
runtime
.vhosts
.iter()
.map(|(host, vhost)| ((0, host.as_str()), vhost))
.chain(
runtime
.wildcards
.iter()
.map(|(suffix, vhost)| ((1, suffix.as_str()), vhost)),
)
.chain(runtime.catch_all.iter().map(|vhost| ((2, "*"), vhost)))
})
.filter_map(|(host, vhost)| match vhost.decoy.as_ref() {
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, *addr)),
WebRuntimeDecoy::StaticDirectory(_) => None,
+29
View File
@@ -357,3 +357,32 @@ async fn grouped_hosts_share_one_resolved_origin_and_endpoint_inventory() {
reordered.rebuild_runtime_web().unwrap();
assert!(!loaded.config.web_decoy_endpoints_equal(&reordered));
}
#[tokio::test]
async fn wildcard_dns_only_endpoint_changes_are_visible_without_source_changes() {
for selector in ["*.example.com", "*"] {
let text = SOURCE
.replace("[web]", "[web]\nallow_wildcard_hosts = true")
.replace(
"host = \"proxy.example.com\"",
&format!("host = \"{selector}\""),
)
+ "\n[web.limits]\nmemory_envelope_bytes = 1610612736\n";
let mut snapshots = Vec::new();
for addr in ["127.0.0.2:18081", "127.0.0.3:18081"] {
let mut parsed = source(&text);
prepare(&mut parsed.config, |_, _| {
std::future::ready(Ok(vec![addr.parse().unwrap()]))
})
.await
.unwrap();
snapshots.push(pipeline::finish(parsed).unwrap().config);
}
assert_eq!(
serde_json::to_value(&snapshots[0]).unwrap(),
serde_json::to_value(&snapshots[1]).unwrap()
);
assert!(!snapshots[0].web_decoy_endpoints_equal(&snapshots[1]));
assert!(snapshots[0].web_decoy_endpoints_equal(&snapshots[0]));
}
}
+5 -121
View File
@@ -35,127 +35,11 @@ const WEB_CAPABILITY_CONTEXT_V2: &[u8] = b"tdesktop-web-proxy-bridge-v2\n";
const WEB_DEBUG_FINGERPRINT_CONTEXT: &[u8] = b"telemt-web-debug-key-fingerprint-v1\0";
const MAX_WEB_STATIC_DEPTH: usize = 64;
/// Builds the immutable WEB routing and decoy snapshot for one generation.
pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
let auth = config.runtime_user_auth().ok_or_else(|| {
ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string())
})?;
let mut runtime_vhosts = BTreeMap::new();
let mut runtime_profiles = Vec::new();
let mut runtime_capabilities = Vec::new();
let mut static_files = 0usize;
let mut static_bytes = 0usize;
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let decoy = Arc::new(build_decoy(
config,
vhost_idx,
vhost,
&config.web.limits,
&mut static_files,
&mut static_bytes,
)?);
let mut host_profiles: Vec<_> = vhost
.host
.iter()
.chain(vhost.hosts.iter().flatten())
.map(|host| {
(
host,
Vec::with_capacity(vhost.profiles.len()),
Vec::with_capacity(vhost.profiles.len()),
HashSet::with_capacity(vhost.profiles.len()),
)
})
.collect();
for profile in &vhost.profiles {
let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| {
ProxyError::Config(format!(
"WEB profile references unknown access user `{}`",
profile.user
))
})?;
let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| {
ProxyError::Config("WEB profile user snapshot is inconsistent".to_string())
})?;
let (client_secret, client_secret_len) =
client_secret(auth_entry.secret, profile.secret_mode);
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
let shared = Arc::new(WebRuntimeProfileShared {
public_addr: vhost.public_addr,
user: profile.user.clone(),
credential_id: auth_entry.credential_id,
secret_mode: profile.secret_mode,
carrier: config.web.carrier,
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
carrier_learning: config.web.carrier_negotiation_enabled()
&& config.web.carrier_learning,
carriers: Arc::clone(&carrier_candidates),
carrier_negotiation_deadlines_secs: config
.web
.timeouts
.carrier_negotiation_deadlines_secs,
key_fingerprint,
max_sessions: profile
.max_sessions
.unwrap_or(config.web.limits.max_sessions_global),
max_streams: profile
.max_streams
.unwrap_or(config.web.limits.max_streams_global),
max_streams_per_session: profile
.max_streams_per_session
.unwrap_or(config.web.limits.max_streams_per_session),
});
// Only host-bound bindings and capability indexes expand with the alias count.
for (host, profiles, capability_table, capabilities) in &mut host_profiles {
let capability = derive_web_capability(
&client_secret[..client_secret_len],
host.as_bytes(),
vhost.base_path.as_bytes(),
)?;
if !capabilities.insert(capability) {
return Err(ProxyError::Config(format!(
"WEB vhost `{host}` contains profiles with the same client capability"
)));
}
profiles.push(Arc::new(WebRuntimeProfile {
host: (*host).clone(),
capability,
shared: Arc::clone(&shared),
}));
capability_table.push(capability);
}
}
for (host, profiles, capability_table, _) in host_profiles {
runtime_capabilities.extend(capability_table.iter().copied());
runtime_profiles.extend(profiles.iter().cloned());
runtime_vhosts.insert(
host.clone(),
Arc::new(WebRuntimeVhost {
host: host.clone(),
base: if vhost.base_path.is_empty() {
"/".to_string()
} else {
format!("/{}/", vhost.base_path)
},
decoy_fasttrack_mode: config.web.decoy_fasttrack_mode,
decoy: Arc::clone(&decoy),
decoy_header_secs: config.web.timeouts.decoy_header_secs,
profiles,
capabilities: capability_table.into_boxed_slice(),
}),
);
}
}
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
vhosts: runtime_vhosts,
profiles: runtime_profiles,
capabilities: runtime_capabilities.into_boxed_slice(),
}));
Ok(())
}
// Exact bindings and wildcard templates share one immutable snapshot builder.
mod build;
pub(super) use build::rebuild;
// Cold wildcard derivation uses the existing immutable authentication snapshot.
mod wildcard;
fn debug_key_fingerprint(secret: &[u8]) -> String {
let mut digest = Sha256::new();
+183
View File
@@ -0,0 +1,183 @@
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_SNAPSHOT: AtomicU64 = AtomicU64::new(1);
/// Builds the immutable WEB routing and decoy snapshot for one generation.
pub(in crate::config::load) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
let auth = config.runtime_user_auth().ok_or_else(|| {
ProxyError::Config("WEB runtime requires the user authentication snapshot".to_string())
})?;
let snapshot_id = NEXT_SNAPSHOT
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
value.checked_add(1)
})
.map_err(|_| ProxyError::Config("WEB snapshot identity exhausted".to_string()))?;
let mut wildcards = BTreeMap::new();
let mut catch_all = None;
let mut runtime_vhosts = BTreeMap::new();
let mut runtime_profiles = Vec::new();
let mut runtime_capabilities = Vec::new();
let mut static_files = 0usize;
let mut static_bytes = 0usize;
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let decoy = Arc::new(build_decoy(
config,
vhost_idx,
vhost,
&config.web.limits,
&mut static_files,
&mut static_bytes,
)?);
let mut host_profiles: Vec<_> = vhost
.host
.iter()
.chain(vhost.hosts.iter().flatten())
.filter(|host| !host.starts_with('*'))
.map(|host| {
(
host,
Vec::with_capacity(vhost.profiles.len()),
Vec::with_capacity(vhost.profiles.len()),
HashSet::with_capacity(vhost.profiles.len()),
)
})
.collect();
let has_wildcards = vhost
.host
.iter()
.chain(vhost.hosts.iter().flatten())
.any(|host| host.starts_with('*'));
let mut shared_profiles = Vec::new();
let mut contexts = HashSet::new();
for profile in &vhost.profiles {
let user_id = auth.user_id_by_name(&profile.user).ok_or_else(|| {
ProxyError::Config(format!(
"WEB profile references unknown access user `{}`",
profile.user
))
})?;
let auth_entry = auth.entry_by_id(user_id).ok_or_else(|| {
ProxyError::Config("WEB profile user snapshot is inconsistent".to_string())
})?;
let (client_secret, client_secret_len) =
client_secret(auth_entry.secret, profile.secret_mode);
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
let shared = Arc::new(WebRuntimeProfileShared {
derivation_context: wildcard::derivation_context(
&client_secret[..client_secret_len],
vhost.base_path.as_bytes(),
),
public_addr: vhost.public_addr,
user: profile.user.clone(),
credential_id: auth_entry.credential_id,
secret_mode: profile.secret_mode,
carrier: config.web.carrier,
carrier_negotiation_enabled: config.web.carrier_negotiation_enabled(),
carrier_learning: config.web.carrier_negotiation_enabled()
&& config.web.carrier_learning,
carriers: Arc::clone(&carrier_candidates),
carrier_negotiation_deadlines_secs: config
.web
.timeouts
.carrier_negotiation_deadlines_secs,
key_fingerprint,
max_sessions: profile
.max_sessions
.unwrap_or(config.web.limits.max_sessions_global),
max_streams: profile
.max_streams
.unwrap_or(config.web.limits.max_streams_global),
max_streams_per_session: profile
.max_streams_per_session
.unwrap_or(config.web.limits.max_streams_per_session),
});
if has_wildcards {
if !contexts.insert(shared.derivation_context) {
return Err(ProxyError::Config(
"WEB wildcard group contains duplicate client capabilities".to_string(),
));
}
shared_profiles.push(Arc::clone(&shared));
}
// Only host-bound bindings and capability indexes expand with the alias count.
for (host, profiles, capability_table, capabilities) in &mut host_profiles {
let capability = derive_web_capability(
&client_secret[..client_secret_len],
host.as_bytes(),
vhost.base_path.as_bytes(),
)?;
if !capabilities.insert(capability) {
return Err(ProxyError::Config(format!(
"WEB vhost `{host}` contains profiles with the same client capability"
)));
}
profiles.push(Arc::new(WebRuntimeProfile {
host: (*host).clone(),
capability,
shared: Arc::clone(&shared),
}));
capability_table.push(capability);
}
}
if has_wildcards {
let template = Arc::new(WebRuntimeVhost {
wildcard: Some(WebRuntimeWildcard {
group_id: vhost_idx,
profiles: shared_profiles,
}),
host: String::new(),
base: if vhost.base_path.is_empty() {
"/".to_string()
} else {
format!("/{}/", vhost.base_path)
},
decoy_fasttrack_mode: config.web.decoy_fasttrack_mode,
decoy: Arc::clone(&decoy),
decoy_header_secs: config.web.timeouts.decoy_header_secs,
profiles: Vec::new(),
capabilities: Box::new([]),
});
for selector in vhost.host.iter().chain(vhost.hosts.iter().flatten()) {
if selector == "*" {
catch_all = Some(Arc::clone(&template));
} else if let Some(suffix) = selector.strip_prefix("*.") {
wildcards.insert(suffix.to_string(), Arc::clone(&template));
}
}
}
for (host, profiles, capability_table, _) in host_profiles {
runtime_capabilities.extend(capability_table.iter().copied());
runtime_profiles.extend(profiles.iter().cloned());
runtime_vhosts.insert(
host.clone(),
Arc::new(WebRuntimeVhost {
wildcard: None,
host: host.clone(),
base: if vhost.base_path.is_empty() {
"/".to_string()
} else {
format!("/{}/", vhost.base_path)
},
decoy_fasttrack_mode: config.web.decoy_fasttrack_mode,
decoy: Arc::clone(&decoy),
decoy_header_secs: config.web.timeouts.decoy_header_secs,
profiles,
capabilities: capability_table.into_boxed_slice(),
}),
);
}
}
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
snapshot_id,
wildcards,
catch_all,
vhosts: runtime_vhosts,
profiles: runtime_profiles,
capabilities: runtime_capabilities.into_boxed_slice(),
}));
Ok(())
}
+49
View File
@@ -0,0 +1,49 @@
use super::*;
// A length-delimited full digest fences secret, mode and endpoint changes without
// performing HMAC while a process session registry is locked.
pub(super) fn derivation_context(secret: &[u8], base: &[u8]) -> [u8; 32] {
let mut digest = Sha256::new();
digest.update(b"telemt-web-derivation-context-v1\0");
digest.update([if base.is_empty() { 1 } else { 2 }]);
digest.update((secret.len() as u64).to_be_bytes());
digest.update(secret);
digest.update((base.len() as u64).to_be_bytes());
digest.update(base);
digest.finalize().into()
}
impl ProxyConfig {
/// Derives one complete bounded table after the process HMAC budget is reserved.
pub(crate) fn wildcard_capabilities(
&self,
vhost: &WebRuntimeVhost,
host: &str,
) -> Result<Box<[[u8; 32]]>> {
let group = vhost
.wildcard
.as_ref()
.ok_or_else(|| ProxyError::Config("WEB wildcard template is missing".to_string()))?;
let auth = self.runtime_user_auth().ok_or_else(|| {
ProxyError::Config("WEB authentication snapshot is missing".to_string())
})?;
let mut capabilities = Vec::with_capacity(group.profiles.len());
for profile in &group.profiles {
let entry = auth
.user_id_by_name(&profile.user)
.and_then(|id| auth.entry_by_id(id))
.ok_or_else(|| {
ProxyError::Config(
"WEB wildcard authentication snapshot is inconsistent".to_string(),
)
})?;
let (secret, len) = client_secret(entry.secret, profile.secret_mode);
capabilities.push(derive_web_capability(
&secret[..len],
host.as_bytes(),
vhost.base.trim_matches('/').as_bytes(),
)?);
}
Ok(capabilities.into_boxed_slice())
}
}
+11
View File
@@ -261,6 +261,7 @@ const LISTENER_CONFIG_KEYS: &[&str] = &[
];
const WEB_CONFIG_KEYS: &[&str] = &[
"allow_wildcard_hosts",
"enabled",
"carrier",
"carrier_method",
@@ -277,6 +278,7 @@ const WEB_CONFIG_KEYS: &[&str] = &[
];
const WEB_LIMITS_CONFIG_KEYS: &[&str] = &[
"wildcard",
"max_header_bytes",
"max_body_bytes",
"max_frame_payload_bytes",
@@ -535,3 +537,12 @@ pub(super) fn handle_unknown_config_keys(parsed_toml: &toml::Value) -> Result<()
Ok(())
}
const WEB_WILDCARD_LIMITS_CONFIG_KEYS: &[&str] = &[
"max_rules",
"max_profiles_per_request",
"cache_hosts",
"cache_bytes",
"cold_hmac_per_minute",
"cold_hmac_burst",
];
+8
View File
@@ -37,6 +37,7 @@ fn known_config_keys_for_suggestion() -> Vec<&'static str> {
LISTENER_CONFIG_KEYS,
WEB_CONFIG_KEYS,
WEB_LIMITS_CONFIG_KEYS,
WEB_WILDCARD_LIMITS_CONFIG_KEYS,
WEB_DEBUG_CONFIG_KEYS,
WEB_TIMEOUTS_CONFIG_KEYS,
WEB_VHOST_CONFIG_KEYS,
@@ -242,6 +243,13 @@ pub(super) fn collect_unknown_config_keys(parsed_toml: &toml::Value) -> Vec<Unkn
&["web", "limits"],
WEB_LIMITS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
&known_for_suggestion,
&["web", "limits", "wildcard"],
WEB_WILDCARD_LIMITS_CONFIG_KEYS,
);
check_known_table(
parsed_toml,
&mut unknown,
+3 -3
View File
@@ -75,7 +75,7 @@ pub(super) fn validate_source(config: &mut ProxyConfig) -> Result<()> {
));
}
validate_limits(&config.web.limits)?;
validate_limits(&config.web.limits, config.web.allow_wildcard_hosts)?;
debug::validate(&config.web.debug, &config.web.limits)?;
let carriers = negotiation::validate(&config.web)?;
if carriers.contains(&WebCarrier::Https) && config.web.limits.max_http_handlers < 2 {
@@ -182,7 +182,7 @@ fn validate_web_listener(
Ok(())
}
fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
fn validate_limits(limits: &WebLimitsConfig, wildcard: bool) -> Result<()> {
if !(8192..=MAX_WEB_HEADER_BYTES).contains(&limits.max_header_bytes) {
return config_error("web.limits.max_header_bytes must be within [8192, 65536]");
}
@@ -412,7 +412,7 @@ fn validate_limits(limits: &WebLimitsConfig) -> Result<()> {
"web.limits pending ceilings must preserve one uplink batch and downlink progress",
);
}
memory::validate(limits)?;
memory::validate(limits, wildcard)?;
Ok(())
}
+57 -5
View File
@@ -11,7 +11,7 @@ const WEB_OVERLOAD_CONNECTION_BYTES: usize = 4 * 1024;
const WEB_CAPABILITY_INDEX_ENTRY_BYTES: usize = 64;
/// Validates process-wide body, header, queue, static, and debug reservations.
pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
pub(super) fn validate(limits: &WebLimitsConfig, wildcard: bool) -> Result<()> {
if limits.max_carrier_learning_entries == 0 {
return config_error("web.limits.max_carrier_learning_entries must be > 0");
}
@@ -83,9 +83,15 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
.ok_or_else(|| {
ProxyError::Config("web.limits lane state reservation overflowed usize".to_string())
})?;
let wildcard_reservation = if wildcard {
wildcard_reservation(limits)?
} else {
0
};
let reserved = limits
.pending_bytes_global
.checked_add(limits.max_body_bytes_global)
.checked_add(wildcard_reservation)
.and_then(|value| value.checked_add(limits.max_body_bytes_global))
.and_then(|value| value.checked_add(limits.max_static_bytes))
.and_then(|value| value.checked_add(debug_ring_index))
.and_then(|value| value.checked_add(status_pages))
@@ -106,6 +112,52 @@ pub(super) fn validate(limits: &WebLimitsConfig) -> Result<()> {
Ok(())
}
// Cache entries include their digest index and allocator overhead. Detached entry
// leases remain charged until the last borrower releases them. Handler scratch and
// authenticated host bindings have independent existing concurrency ceilings.
fn wildcard_reservation(limits: &WebLimitsConfig) -> Result<usize> {
let wildcard = &limits.wildcard;
if wildcard.max_rules == 0
|| wildcard.max_profiles_per_request == 0
|| wildcard.cache_bytes == 0
|| wildcard.cold_hmac_per_minute == 0
|| (wildcard.cold_hmac_burst as usize) < wildcard.max_profiles_per_request
{
return config_error(
"web.limits.wildcard requires positive bounds and a burst covering a complete profile table",
);
}
let rules = wildcard
.max_rules
.checked_mul(1024)
.and_then(|value| {
limits
.max_profiles
.checked_mul(1024)
.and_then(|profiles| value.checked_add(profiles))
})
.ok_or_else(|| {
ProxyError::Config("WEB wildcard template reservations overflowed usize".to_string())
})?;
wildcard
.max_profiles_per_request
.checked_mul(32)
.and_then(|value| value.checked_add(1024))
.and_then(|value| value.checked_mul(limits.max_http_handlers))
.and_then(|value| value.checked_add(wildcard.cache_bytes))
.and_then(|value| value.checked_add(rules))
.and_then(|value| {
limits
.max_bootstraps_global
.checked_add(limits.max_sessions_global)
.and_then(|bindings| bindings.checked_mul(1024))
.and_then(|bindings| value.checked_add(bindings))
})
.ok_or_else(|| {
ProxyError::Config("WEB wildcard memory reservations overflowed usize".to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -113,13 +165,13 @@ mod tests {
#[test]
fn default_envelope_includes_bounded_lane_and_learning_metadata() {
let limits = WebLimitsConfig::default();
assert!(validate(&limits).is_ok());
assert!(validate(&limits, false).is_ok());
let previous_envelope = WebLimitsConfig {
memory_envelope_bytes: 768 * 1024 * 1024,
..limits
};
assert!(validate(&previous_envelope).is_err());
assert!(validate(&previous_envelope, false).is_err());
}
#[test]
@@ -128,7 +180,7 @@ mod tests {
max_profiles: usize::MAX,
..WebLimitsConfig::default()
};
let error = validate(&limits).unwrap_err().to_string();
let error = validate(&limits, false).unwrap_err().to_string();
assert!(error.contains("web capability index reservation overflowed usize"));
}
}
+38 -9
View File
@@ -1,25 +1,45 @@
use super::*;
/// Bounds expanded routing tables before normalizing exact hostname selectors.
/// Bounds exact bindings and wildcard rules before normalizing hostname selectors.
pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
let limits = &config.web.limits;
let mut host_count = 0usize;
let mut rule_count = 0usize;
let mut profile_count = 0usize;
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let count = match (&vhost.host, &vhost.hosts) {
(Some(_), None) => 1,
(None, Some(hosts)) if !hosts.is_empty() => hosts.len(),
match (&vhost.host, &vhost.hosts) {
(Some(_), None) => {}
(None, Some(hosts)) if !hosts.is_empty() => {}
_ => {
return config_error(&format!(
"web.vhosts[{vhost_idx}] requires exactly one of host or non-empty hosts"
));
}
};
}
let selectors = vhost.host.iter().chain(vhost.hosts.iter().flatten());
let patterns = selectors
.clone()
.filter(|host| host.trim().starts_with('*'))
.count();
let exact = selectors.count() - patterns;
rule_count = rule_count.checked_add(patterns).ok_or_else(|| {
ProxyError::Config("WEB wildcard rule count overflowed usize".to_string())
})?;
if patterns > 0
&& (!config.web.allow_wildcard_hosts
|| rule_count > limits.wildcard.max_rules
|| vhost.profiles.len() > limits.wildcard.max_profiles_per_request)
{
return config_error(&format!(
"web.vhosts[{vhost_idx}] wildcard selectors require allow_wildcard_hosts and must fit web.limits.wildcard bounds"
));
}
host_count = host_count
.checked_add(count)
.checked_add(exact)
.ok_or_else(|| ProxyError::Config("WEB hostname count overflowed usize".to_string()))?;
profile_count = count
.checked_mul(vhost.profiles.len())
profile_count = exact
.checked_add(usize::from(patterns > 0))
.and_then(|count| count.checked_mul(vhost.profiles.len()))
.and_then(|expanded| profile_count.checked_add(expanded))
.ok_or_else(|| ProxyError::Config("WEB profile count overflowed usize".to_string()))?;
if host_count > limits.max_vhosts {
@@ -43,7 +63,16 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
} else {
format!("web.vhosts[{vhost_idx}].hosts[{host_idx}]")
};
*host = normalize_web_host(host, &field)?;
*host = if config.web.allow_wildcard_hosts && host.trim() == "*" {
"*".to_string()
} else if config.web.allow_wildcard_hosts
&& let Some(suffix) = host.trim().strip_prefix("*.")
{
let normalized = normalize_web_host(&format!("x.{suffix}"), &field)?;
format!("*.{}", &normalized[2..])
} else {
normalize_web_host(host, &field)?
};
if !hosts.insert(host.clone()) {
return config_error(&format!("duplicate WEB vhost host `{host}` at {field}"));
}
@@ -239,3 +239,7 @@ fn grouped_static_snapshot_is_loaded_and_charged_once() {
std::fs::write(directory.path().join("index.html"), b"longer").unwrap();
assert!(load_config_error_from_temp_toml(&format!("{source}{limits}")).contains("max_static"));
}
// Wildcard admission remains opt-in and independently bounded.
#[path = "wildcard_tests.rs"]
mod wildcard_tests;
@@ -0,0 +1,171 @@
use super::*;
#[test]
fn wildcard_opt_in_accepts_shared_pattern_without_exact_expansion() {
let source = WEB_CONFIG
.replace("[web]", "[web]\nallow_wildcard_hosts = true")
.replace("Proxy.Example.COM", "*.Example.COM")
.replace(
"[[web.vhosts]]",
"[web.limits]\nmemory_envelope_bytes = 1610612736\n[[web.vhosts]]",
);
let config = load_config_from_temp_toml(&source);
assert_eq!(config.web.vhosts[0].host.as_deref(), Some("*.example.com"));
let runtime = config.web.runtime.as_ref().unwrap();
assert!(runtime.vhosts.is_empty());
assert!(runtime.profiles.is_empty());
assert!(runtime.capabilities.is_empty());
}
fn wildcard_source(selector: &str) -> String {
WEB_CONFIG
.replace("[web]", "[web]\nallow_wildcard_hosts = true")
.replace("host = \"Proxy.Example.COM\"", selector)
.replace(
"[[web.vhosts]]",
"[web.limits]\nmemory_envelope_bytes = 1610612736\n[[web.vhosts]]",
)
}
#[test]
fn wildcard_selector_bounds_precedence_and_shared_inventory() {
let source = wildcard_source(
"hosts = [\"*.Example.COM\", \"*.deep.example.com\", \"exact.example.com\", \"*\"]",
);
let config = load_config_from_temp_toml(&source);
let runtime = config.web.runtime.as_ref().unwrap();
assert_eq!(runtime.vhosts.len(), 1);
assert_eq!(runtime.profiles.len(), 1);
assert_eq!(runtime.wildcards.len(), 2);
assert!(std::sync::Arc::ptr_eq(
&runtime.wildcards["example.com"],
&runtime.wildcards["deep.example.com"]
));
assert!(
runtime
.route("exact.example.com")
.unwrap()
.wildcard
.is_none()
);
assert!(std::sync::Arc::ptr_eq(
runtime.route("a.deep.example.com").unwrap(),
&runtime.wildcards["deep.example.com"]
));
assert!(std::sync::Arc::ptr_eq(
runtime.route("example.com").unwrap(),
runtime.catch_all.as_ref().unwrap()
));
let repeated = load_config_from_temp_toml(&source);
assert_ne!(
runtime.snapshot_id,
repeated.web.runtime.as_ref().unwrap().snapshot_id
);
// Exact bindings plus one common wildcard profile set consume two slots.
let bounded = source.replace(
"memory_envelope_bytes = 1610612736",
"memory_envelope_bytes = 1610612736\nmax_vhosts = 1\nmax_profiles = 2",
);
load_config_from_temp_toml(&bounded);
assert!(
load_config_error_from_temp_toml(&bounded.replace("max_profiles = 2", "max_profiles = 1"))
.contains("max_profiles")
);
}
#[test]
fn wildcard_configuration_rejects_ambiguous_selectors_and_resource_exhaustion() {
for selector in [
"foo*.example.com",
"**.example.com",
"*.127.0.0.1",
"*.example.com.",
"*.example.com:443",
"*.*.example.com",
] {
assert!(
!load_config_error_from_temp_toml(&wildcard_source(&format!("host = \"{selector}\"")))
.is_empty()
);
}
assert!(
load_config_error_from_temp_toml(&wildcard_source(
"hosts = [\"*.EXAMPLE.com\", \"*.example.com\"]"
))
.contains("duplicate")
);
assert!(
load_config_error_from_temp_toml(&wildcard_source("host = \"*.example.com\"").replace(
"allow_wildcard_hosts = true",
"allow_wildcard_hosts = false"
))
.contains("allow_wildcard_hosts")
);
for limits in [
"max_rules = 0",
"max_rules = 9223372036854775807",
"max_profiles_per_request = 0",
"cache_bytes = 0",
"cold_hmac_per_minute = 0",
"cold_hmac_burst = 255",
"max_profiles_per_request = 9223372036854775807",
] {
let source = format!(
"{}\n[web.limits.wildcard]\n{limits}\n",
wildcard_source("host = \"*.example.com\"")
);
assert!(
load_config_error_from_temp_toml(&source).contains("wildcard"),
"{limits}"
);
}
let source = format!(
"[general]\nconfig_strict = true\n{}\n[web.limits.wildcard]\ncache_hosts = 0\n",
wildcard_source("host = \"*.example.com\"")
);
load_config_from_temp_toml(&source);
assert!(
load_config_error_from_temp_toml(&source.replace("cache_hosts", "cache_hostz"))
.contains("cache_hostz")
);
}
#[test]
fn wildcard_context_matches_exact_protocol_and_fences_reload_changes() {
let source = wildcard_source("hosts = [\"proxy.example.com\", \"*.example.com\"]");
let config = load_config_from_temp_toml(&source);
let runtime = config.web.runtime.as_ref().unwrap();
let template = &runtime.wildcards["example.com"];
assert_eq!(
config
.wildcard_capabilities(template, "proxy.example.com")
.unwrap()[0],
runtime.profiles[0].capability
);
let context = template.wildcard.as_ref().unwrap().profiles[0].derivation_context;
for changed in [
source.replace("secret_mode = \"dd\"", "secret_mode = \"plain\""),
source.replace("public_addr =", "base_path = \"relay\"\npublic_addr ="),
source.replace(
"000102030405060708090a0b0c0d0e0f",
"100102030405060708090a0b0c0d0e0f",
),
] {
let changed = load_config_from_temp_toml(&changed);
let changed_context = changed.web.runtime.as_ref().unwrap().wildcards["example.com"]
.wildcard
.as_ref()
.unwrap()
.profiles[0]
.derivation_context;
assert_ne!(context, changed_context);
}
let changed =
load_config_from_temp_toml(&source.replace("max_sessions = 4", "max_sessions = 5"));
assert_eq!(
context,
changed.web.runtime.as_ref().unwrap().profiles[0]
.shared
.derivation_context
);
}
+2 -2
View File
@@ -55,11 +55,11 @@ pub use server::{
pub use web::{
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode,
WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig,
WebSecretMode, WebTimeoutsConfig, WebVhostConfig,
WebSecretMode, WebTimeoutsConfig, WebVhostConfig, WebWildcardLimitsConfig,
};
pub(crate) use web::{
WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile,
WebRuntimeProfileShared, WebRuntimeVhost, WebStaticAsset, WebStaticSite,
WebRuntimeProfileShared, WebRuntimeVhost, WebRuntimeWildcard, WebStaticAsset, WebStaticSite,
};
pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES;
#[allow(unused_imports)]
+18 -6
View File
@@ -9,6 +9,10 @@ use serde::{Deserialize, Serialize};
use super::web_carrier::{WebCarrier, WebCarrierMethod, WebCarriers};
use super::web_debug::WebDebugConfig;
// Wildcard policy is restart-frozen independently of reloadable routing rules.
mod wildcard;
pub use wildcard::WebWildcardLimitsConfig;
// Serialized WEB defaults remain separate from the runtime data model.
mod defaults;
use defaults::*;
@@ -73,13 +77,13 @@ pub enum WebDecoyConfig {
},
}
/// Exact WEB hostnames sharing one immutable decoy and explicit profile policy.
/// WEB hostname selectors sharing one immutable decoy and explicit profile policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebVhostConfig {
/// Legacy single hostname selector, mutually exclusive with `hosts`.
/// Single exact or opted-in wildcard selector, mutually exclusive with `hosts`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
/// Non-empty exact hostname list, normalized to lowercase ACE during validation.
/// Non-empty selector list, normalized to lowercase ACE during validation.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hosts: Option<Vec<String>>,
/// Optional canonical WEB endpoint prefix without surrounding slashes.
@@ -97,6 +101,9 @@ pub struct WebVhostConfig {
/// Hard process and protocol limits for WEB ingress.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebLimitsConfig {
/// Bounds automatic wildcard derivation and positive capability retention.
#[serde(default)]
pub wildcard: WebWildcardLimitsConfig,
/// Maximum bytes accepted while parsing one HTTP request head.
#[serde(default = "default_web_max_header_bytes")]
pub max_header_bytes: usize,
@@ -199,7 +206,7 @@ pub struct WebLimitsConfig {
/// Maximum concrete hostname count across all WEB groups.
#[serde(default = "default_web_max_vhosts")]
pub max_vhosts: usize,
/// Maximum expanded hostname/profile binding count across all WEB groups.
/// Maximum exact bindings plus one shared profile set per wildcard group.
#[serde(default = "default_web_max_profiles")]
pub max_profiles: usize,
/// Maximum static snapshot entry count, charged once per source group.
@@ -243,6 +250,7 @@ pub struct WebLimitsConfig {
impl Default for WebLimitsConfig {
fn default() -> Self {
Self {
wildcard: WebWildcardLimitsConfig::default(),
max_header_bytes: default_web_max_header_bytes(),
max_body_bytes: default_web_max_body_bytes(),
max_frame_payload_bytes: default_web_max_frame_payload_bytes(),
@@ -422,6 +430,9 @@ pub enum WebCarrierNegotiationAggressiveness {
/// WEB ingress, carrier, fallback, and lifecycle configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebConfig {
/// Restart-only opt-in to automatic suffix and catch-all hostname selectors.
#[serde(default)]
pub allow_wildcard_hosts: bool,
/// Enables issuance of new WEB bridge and session credentials.
#[serde(default)]
pub enabled: bool,
@@ -458,7 +469,7 @@ pub struct WebConfig {
/// WEB lifecycle deadlines.
#[serde(default)]
pub timeouts: WebTimeoutsConfig,
/// Exact hostname groups served by WEB listeners.
/// Exact hostname and opted-in wildcard groups served by WEB listeners.
#[serde(default)]
pub vhosts: Vec<WebVhostConfig>,
/// Validated immutable runtime snapshot built during configuration loading.
@@ -494,6 +505,7 @@ impl Default for WebConfig {
fn default() -> Self {
Self {
enabled: false,
allow_wildcard_hosts: false,
carrier: WebCarrier::default(),
carrier_method: WebCarrierMethod::default(),
conveyor: default_web_conveyor(),
@@ -516,5 +528,5 @@ impl Default for WebConfig {
mod runtime;
pub(crate) use runtime::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeProfileShared, WebRuntimeVhost,
WebStaticAsset, WebStaticSite,
WebRuntimeWildcard, WebStaticAsset, WebStaticSite,
};
+39 -5
View File
@@ -1,20 +1,28 @@
use super::*;
/// Precomputed WEB configuration consumed by listener hot paths.
#[derive(Debug)]
#[derive(Debug, Default)]
pub(crate) struct WebRuntimeConfig {
/// Process-unique immutable WEB snapshot identity, including same-generation reloads.
pub(crate) snapshot_id: u64,
/// Suffix index without the leading wildcard and dot.
pub(crate) wildcards: BTreeMap<String, Arc<WebRuntimeVhost>>,
/// Lowest-priority whole-group fallback.
pub(crate) catch_all: Option<Arc<WebRuntimeVhost>>,
/// Canonical host lookup used by HTTP request routing.
pub(crate) vhosts: BTreeMap<String, Arc<WebRuntimeVhost>>,
/// Flat profile inventory used by startup link emission.
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
/// Complete active capability table used to contain misplaced credentials.
/// Finite exact-host capability table used to contain misplaced credentials.
pub(crate) capabilities: Box<[[u8; 32]]>,
}
/// Precomputed immutable virtual-host data.
#[derive(Debug)]
pub(crate) struct WebRuntimeVhost {
/// Canonical lowercase ACE hostname.
/// Shared automatic-host policy; exact hosts keep their precomputed bindings.
pub(crate) wildcard: Option<WebRuntimeWildcard>,
/// Canonical lowercase ACE hostname; empty for a wildcard template.
pub(crate) host: String,
/// Exact slash-delimited endpoint base, including the trailing slash.
pub(crate) base: String,
@@ -24,13 +32,13 @@ pub(crate) struct WebRuntimeVhost {
pub(crate) decoy: Arc<WebRuntimeDecoy>,
/// Upstream connect and response-head deadline.
pub(crate) decoy_header_secs: u64,
/// Exact capability profiles accepted by this host.
/// Precomputed exact-host profiles; wildcard policies live in `wildcard`.
pub(crate) profiles: Vec<Arc<WebRuntimeProfile>>,
/// Contiguous capability table aligned one-to-one with `profiles`.
pub(crate) capabilities: Box<[[u8; 32]]>,
}
/// Precomputed exact-user capability entry.
/// Authenticated exact-host profile binding.
#[derive(Debug)]
pub(crate) struct WebRuntimeProfile {
/// Canonical host that owns this profile.
@@ -45,6 +53,8 @@ pub(crate) struct WebRuntimeProfile {
/// Limits still apply to each binding's exact host/user/mode quota owner.
#[derive(Debug)]
pub(crate) struct WebRuntimeProfileShared {
/// Full domain-separated secret, protocol-version, and endpoint identity.
pub(crate) derivation_context: [u8; 32],
/// Stable public destination tuple supplied to relay routing.
pub(crate) public_addr: SocketAddr,
/// Exact access user authenticated by logical streams.
@@ -106,3 +116,27 @@ pub(crate) struct WebStaticAsset {
/// Strong SHA-256 entity tag.
pub(crate) etag: String,
}
/// Host-independent wildcard group, never a session or quota owner.
#[derive(Debug)]
pub(crate) struct WebRuntimeWildcard {
/// Source-group identity local to the immutable WEB snapshot.
pub(crate) group_id: usize,
/// Ordered shared policies aligned with cold capability derivation.
pub(crate) profiles: Vec<Arc<WebRuntimeProfileShared>>,
}
impl WebRuntimeConfig {
/// Selects exactly one group: exact, longest suffix, then catch-all.
pub(crate) fn route(&self, host: &str) -> Option<&Arc<WebRuntimeVhost>> {
if let Some(vhost) = self.vhosts.get(host) {
return Some(vhost);
}
for (offset, _) in host.match_indices('.') {
if let Some(vhost) = self.wildcards.get(&host[offset + 1..]) {
return Some(vhost);
}
}
self.catch_all.as_ref()
}
}
+32
View File
@@ -0,0 +1,32 @@
use serde::{Deserialize, Serialize};
/// Process-frozen bounds for automatic wildcard hostname handling.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct WebWildcardLimitsConfig {
/// Maximum suffix and catch-all selectors across all source groups.
pub max_rules: usize,
/// Maximum complete profile scan and cold derivation cost for one request.
pub max_profiles_per_request: usize,
/// Maximum positively authenticated hosts retained; zero disables retention.
pub cache_hosts: usize,
/// Maximum charged bytes, including entries still borrowed after eviction.
pub cache_bytes: usize,
/// Sustained process-wide HMAC evaluation budget per minute.
pub cold_hmac_per_minute: u32,
/// Atomic cold-derivation burst measured in HMAC evaluations.
pub cold_hmac_burst: u32,
}
impl Default for WebWildcardLimitsConfig {
fn default() -> Self {
Self {
max_rules: 32,
max_profiles_per_request: 256,
cache_hosts: 256,
cache_bytes: 8_388_608,
cold_hmac_per_minute: 1_000_000,
cold_hmac_burst: 65_536,
}
}
}