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