WEB: share immutable profiles + decoys across exact host lists

This commit is contained in:
Alexey
2026-10-15 07:05:36 +03:00
parent 05a367259a
commit 831cd0f1a7
51 changed files with 1661 additions and 343 deletions
+4
View File
@@ -76,3 +76,7 @@ mod carrier_method_tests;
#[cfg(test)]
#[path = "hot_reload/conveyor_tests.rs"]
mod conveyor_tests;
// Host groups are validated and published as one complete immutable snapshot.
#[cfg(test)]
mod host_list_tests;
+68
View File
@@ -0,0 +1,68 @@
use super::*;
#[test]
fn host_list_reload_publishes_complete_tables_and_preserves_invalid_candidates() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
let source = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[web]
enabled = true
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[[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 = "dd"
"#;
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(Arc::clone(&initial));
let (log_tx, _log_rx) = watch::channel(initial.general.log_level.clone());
let mut state = ReloadState::new(Some(loaded.rendered_hash));
let capability = initial.web.runtime.as_ref().unwrap().capabilities[0];
for (selector, valid, hosts) in [
(
"hosts = [\"a.example.com\", \"A.Example.COM\"]",
false,
vec![],
),
(
"hosts = [\"b.example.com\", \"a.example.com\"]",
true,
vec!["b.example.com", "a.example.com"],
),
("hosts = [\"a.example.com\"]", true, vec!["a.example.com"]),
] {
let before = config_tx.borrow().clone();
std::fs::write(&path, source.replace("host = \"a.example.com\"", selector)).unwrap();
reload_config(&path, &config_tx, &log_tx, None, None, &mut state);
let applied = config_tx.borrow().clone();
if !valid {
assert!(Arc::ptr_eq(&before, &applied));
continue;
}
let runtime = applied.web.runtime.as_ref().unwrap();
assert_eq!(runtime.vhosts.len(), hosts.len());
assert_eq!(runtime.profiles.len(), hosts.len());
assert_eq!(runtime.capabilities.len(), hosts.len());
assert_eq!(runtime.vhosts["a.example.com"].capabilities[0], capability);
for (idx, host) in hosts.iter().enumerate() {
assert_eq!(runtime.profiles[idx].host, *host);
assert_eq!(
runtime.capabilities[idx],
runtime.vhosts[*host].capabilities[0]
);
}
}
}
+2 -2
View File
@@ -191,8 +191,8 @@ impl ProxyConfig {
.runtime
.iter()
.flat_map(|runtime| runtime.vhosts.iter())
.filter_map(|(host, vhost)| match vhost.decoy {
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, addr)),
.filter_map(|(host, vhost)| match vhost.decoy.as_ref() {
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, *addr)),
WebRuntimeDecoy::StaticDirectory(_) => None,
})
}
+31 -2
View File
@@ -45,7 +45,7 @@ async fn prepared(answers: &[&str]) -> LoadedConfig {
fn endpoint(config: &ProxyConfig) -> (SocketAddr, &str) {
let runtime = config.web.runtime.as_ref().unwrap();
match &runtime.vhosts["proxy.example.com"].decoy {
match runtime.vhosts["proxy.example.com"].decoy.as_ref() {
WebRuntimeDecoy::HttpUpstream { addr, authority } => (*addr, authority),
_ => panic!("expected HTTP decoy"),
}
@@ -55,7 +55,7 @@ fn endpoint(config: &ProxyConfig) -> (SocketAddr, &str) {
async fn decoy_dns_snapshot_deduplicates_normalized_origins_and_preserves_order() {
let mut parsed = source(SOURCE);
let mut second = parsed.config.web.vhosts[0].clone();
second.host = "bsi.bund.de".to_string();
second.host = Some("bsi.bund.de".to_string());
second.decoy = WebDecoyConfig::HttpUpstream {
upstream: "http://example.com:18081/".to_string(),
resolve: WebDecoyResolve::Startup,
@@ -328,3 +328,32 @@ async fn decoy_dns_default_port_authority_and_evidence_port_fence() {
);
assert!(config.rebuild_runtime_web().is_err());
}
#[tokio::test]
async fn grouped_hosts_share_one_resolved_origin_and_endpoint_inventory() {
let mut parsed = source(&SOURCE.replace(
"host = \"proxy.example.com\"",
"hosts = [\"proxy.example.com\", \"other.example.com\"]",
));
let calls = AtomicUsize::new(0);
prepare(&mut parsed.config, |_, _| {
calls.fetch_add(1, Ordering::SeqCst);
std::future::ready(Ok(vec!["127.0.0.2:18081".parse().unwrap()]))
})
.await
.unwrap();
let loaded = pipeline::finish(parsed).unwrap();
let runtime = loaded.config.web.runtime.as_ref().unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(Arc::ptr_eq(
&runtime.vhosts["proxy.example.com"].decoy,
&runtime.vhosts["other.example.com"].decoy
));
let mut reordered = loaded.config.clone();
reordered.web.vhosts[0].hosts.as_mut().unwrap().reverse();
reordered.rebuild_runtime_web().unwrap();
assert!(loaded.config.web_decoy_endpoints_equal(&reordered));
reordered.web.vhosts[0].hosts.as_mut().unwrap().pop();
reordered.rebuild_runtime_web().unwrap();
assert!(!loaded.config.web_decoy_endpoints_equal(&reordered));
}
+55 -39
View File
@@ -48,17 +48,27 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
let decoy = build_decoy(
let decoy = Arc::new(build_decoy(
config,
vhost_idx,
vhost,
&config.web.limits,
&mut static_files,
&mut static_bytes,
)?;
let mut profiles = Vec::with_capacity(vhost.profiles.len());
let mut capability_table = Vec::with_capacity(vhost.profiles.len());
let mut capabilities = HashSet::with_capacity(vhost.profiles.len());
)?);
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!(
@@ -71,20 +81,8 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
})?;
let (client_secret, client_secret_len) =
client_secret(auth_entry.secret, profile.secret_mode);
let capability = derive_web_capability(
&client_secret[..client_secret_len],
vhost.host.as_bytes(),
vhost.base_path.as_bytes(),
)?;
let key_fingerprint = debug_key_fingerprint(&client_secret[..client_secret_len]);
if !capabilities.insert(capability) {
return Err(ProxyError::Config(format!(
"WEB vhost `{}` contains profiles with the same client capability",
vhost.host
)));
}
let runtime_profile = Arc::new(WebRuntimeProfile {
host: vhost.host.clone(),
let shared = Arc::new(WebRuntimeProfileShared {
public_addr: vhost.public_addr,
user: profile.user.clone(),
credential_id: auth_entry.credential_id,
@@ -98,7 +96,6 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
.web
.timeouts
.carrier_negotiation_deadlines_secs,
capability,
key_fingerprint,
max_sessions: profile
.max_sessions
@@ -110,27 +107,46 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
.max_streams_per_session
.unwrap_or(config.web.limits.max_streams_per_session),
});
capability_table.push(capability);
runtime_capabilities.push(capability);
profiles.push(Arc::clone(&runtime_profile));
runtime_profiles.push(runtime_profile);
// 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(),
}),
);
}
runtime_vhosts.insert(
vhost.host.clone(),
Arc::new(WebRuntimeVhost {
host: vhost.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,
decoy_header_secs: config.web.timeouts.decoy_header_secs,
profiles,
capabilities: capability_table.into_boxed_slice(),
}),
);
}
config.web.runtime = Some(Arc::new(WebRuntimeConfig {
+8 -1
View File
@@ -368,7 +368,14 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
"decoy_resolve_secs",
];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "base_path", "public_addr", "decoy", "profiles"];
const WEB_VHOST_CONFIG_KEYS: &[&str] = &[
"host",
"hosts",
"base_path",
"public_addr",
"decoy",
"profiles",
];
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index", "resolve"];
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
"user",
+49 -16
View File
@@ -1,21 +1,57 @@
use super::*;
/// Bounds expanded routing tables before normalizing exact hostname selectors.
pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
let limits = &config.web.limits;
if config.web.vhosts.len() > limits.max_vhosts {
return config_error("web.vhosts exceeds web.limits.max_vhosts");
}
let mut hosts = HashSet::with_capacity(config.web.vhosts.len());
let mut host_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(),
_ => {
return config_error(&format!(
"web.vhosts[{vhost_idx}] requires exactly one of host or non-empty hosts"
));
}
};
host_count = host_count
.checked_add(count)
.ok_or_else(|| ProxyError::Config("WEB hostname count overflowed usize".to_string()))?;
profile_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 {
return config_error("web.vhosts exceeds web.limits.max_vhosts");
}
if profile_count > limits.max_profiles {
return config_error("WEB profiles exceed web.limits.max_profiles");
}
}
let mut hosts = HashSet::with_capacity(host_count);
for (vhost_idx, vhost) in config.web.vhosts.iter_mut().enumerate() {
vhost.host = normalize_web_host(&vhost.host, &format!("web.vhosts[{vhost_idx}].host"))?;
let single = vhost.host.is_some();
for (host_idx, host) in vhost
.host
.iter_mut()
.chain(vhost.hosts.iter_mut().flatten())
.enumerate()
{
let field = if single {
format!("web.vhosts[{vhost_idx}].host")
} else {
format!("web.vhosts[{vhost_idx}].hosts[{host_idx}]")
};
*host = normalize_web_host(host, &field)?;
if !hosts.insert(host.clone()) {
return config_error(&format!("duplicate WEB vhost host `{host}` at {field}"));
}
}
validate_web_base_path(
&vhost.base_path,
&format!("web.vhosts[{vhost_idx}].base_path"),
)?;
if !hosts.insert(vhost.host.clone()) {
return config_error(&format!("duplicate WEB vhost host `{}`", vhost.host));
}
if vhost.public_addr.port() != 443 || vhost.public_addr.ip().is_unspecified() {
return config_error(&format!(
"web.vhosts[{vhost_idx}].public_addr must be a concrete socket address on port 443"
@@ -42,8 +78,8 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
}
if !profiles.insert((profile.user.as_str(), profile.secret_mode)) {
return config_error(&format!(
"duplicate WEB profile for user `{}` in vhost `{}`",
profile.user, vhost.host
"duplicate WEB profile for user `{}` in web.vhosts[{vhost_idx}]",
profile.user
));
}
let max_streams = profile.max_streams.unwrap_or(limits.max_streams_global);
@@ -68,14 +104,8 @@ pub(super) fn validate_vhosts(config: &mut ProxyConfig) -> Result<()> {
"web.vhosts[{vhost_idx}].profiles[{profile_idx}] limits must be non-zero and within global WEB limits"
));
}
profile_count = profile_count.checked_add(1).ok_or_else(|| {
ProxyError::Config("WEB profile count overflowed usize".to_string())
})?;
}
}
if profile_count > limits.max_profiles {
return config_error("WEB profiles exceed web.limits.max_profiles");
}
Ok(())
}
@@ -98,6 +128,7 @@ fn validate_web_base_path(value: &str, field: &str) -> Result<()> {
))
}
/// Applies the client-compatible hostname rules only during configuration loading.
pub(super) fn normalize_web_host(value: &str, field: &str) -> Result<String> {
let input = value.trim();
if input.is_empty()
@@ -137,6 +168,7 @@ pub(super) fn normalize_web_host(value: &str, field: &str) -> Result<String> {
Ok(host)
}
/// Rejects numeric suffixes that clients may interpret as address literals.
pub(super) fn web_host_last_label_is_numeric(host: &str) -> bool {
let label = host.rsplit('.').next().unwrap_or_default();
let digits = label
@@ -148,6 +180,7 @@ pub(super) fn web_host_last_label_is_numeric(host: &str) -> bool {
label.bytes().all(|byte| byte.is_ascii_digit())
}
/// Validates one group's ordinary-site origin or bounded static snapshot source.
pub(super) fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> {
match decoy {
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
+31 -17
View File
@@ -11,6 +11,9 @@ mod carrier_method_tests;
mod conveyor_tests;
#[path = "web_tests/decoy_dns_tests.rs"]
mod decoy_dns_tests;
// Exact host groups must preserve the legacy per-host routing contract.
#[path = "web_tests/host_list_tests.rs"]
mod host_list_tests;
const WEB_CONFIG: &str = r#"
[access.users]
@@ -58,17 +61,17 @@ fn web_config_builds_canonical_runtime_snapshot() {
assert_eq!(vhost.capabilities[0], vhost.profiles[0].capability);
assert_eq!(runtime.capabilities.as_ref(), vhost.capabilities.as_ref());
assert_eq!(vhost.decoy_fasttrack_mode, WebDecoyFastTrackMode::Off);
assert_eq!(vhost.profiles[0].user, "alice");
assert_eq!(vhost.profiles[0].secret_mode, WebSecretMode::Dd);
assert_eq!(vhost.profiles[0].carrier, WebCarrier::HttpsLanes);
assert_eq!(vhost.profiles[0].max_sessions, 4);
assert_eq!(vhost.profiles[0].max_streams, 64);
assert_eq!(vhost.profiles[0].max_streams_per_session, 16);
assert_eq!(vhost.profiles[0].key_fingerprint.len(), 16);
assert_ne!(vhost.profiles[0].key_fingerprint, "0001020304050607");
assert!(!vhost.profiles[0].carrier_negotiation_enabled);
assert_eq!(vhost.profiles[0].shared.user, "alice");
assert_eq!(vhost.profiles[0].shared.secret_mode, WebSecretMode::Dd);
assert_eq!(vhost.profiles[0].shared.carrier, WebCarrier::HttpsLanes);
assert_eq!(vhost.profiles[0].shared.max_sessions, 4);
assert_eq!(vhost.profiles[0].shared.max_streams, 64);
assert_eq!(vhost.profiles[0].shared.max_streams_per_session, 16);
assert_eq!(vhost.profiles[0].shared.key_fingerprint.len(), 16);
assert_ne!(vhost.profiles[0].shared.key_fingerprint, "0001020304050607");
assert!(!vhost.profiles[0].shared.carrier_negotiation_enabled);
assert_eq!(
vhost.profiles[0].carriers.as_ref(),
vhost.profiles[0].shared.carriers.as_ref(),
[WebCarrier::HttpsLanes]
);
}
@@ -251,7 +254,11 @@ fn web_profile_user_labels_are_bounded_for_runtime_status() {
fn web_carriers_missing_or_false_disable_negotiation() {
let missing = load_config_from_temp_toml(WEB_CONFIG);
assert!(!missing.web.carrier_negotiation_enabled());
assert!(!missing.web.runtime.unwrap().profiles[0].carrier_learning);
assert!(
!missing.web.runtime.unwrap().profiles[0]
.shared
.carrier_learning
);
let disabled = WEB_CONFIG.replace(
"carrier = \"https-lanes\"",
@@ -259,9 +266,16 @@ fn web_carriers_missing_or_false_disable_negotiation() {
);
let disabled = load_config_from_temp_toml(&disabled);
assert!(!disabled.web.carrier_negotiation_enabled());
assert!(!disabled.web.runtime.as_ref().unwrap().profiles[0].carrier_learning);
assert!(
!disabled.web.runtime.as_ref().unwrap().profiles[0]
.shared
.carrier_learning
);
assert_eq!(
disabled.web.runtime.unwrap().profiles[0].carriers.as_ref(),
disabled.web.runtime.unwrap().profiles[0]
.shared
.carriers
.as_ref(),
[WebCarrier::HttpsLanes]
);
}
@@ -277,14 +291,14 @@ fn web_carrier_array_enables_ordered_negotiation_and_appends_fallback() {
assert!(!config.web.carrier_learning);
let profile = &config.web.runtime.unwrap().profiles[0];
assert_eq!(
profile.carriers.as_ref(),
profile.shared.carriers.as_ref(),
[
WebCarrier::Websocket,
WebCarrier::Https,
WebCarrier::HttpsLanes
]
);
assert!(!profile.carrier_learning);
assert!(!profile.shared.carrier_learning);
}
#[test]
@@ -494,7 +508,7 @@ fn web_ipv6_decoy_uses_a_valid_http_authority() {
let config = load_config_from_temp_toml(&ipv6);
let runtime = config.web.runtime.expect("WEB runtime snapshot");
let vhost = runtime.vhosts.get("proxy.example.com").unwrap();
let WebRuntimeDecoy::HttpUpstream { authority, .. } = &vhost.decoy else {
let WebRuntimeDecoy::HttpUpstream { authority, .. } = vhost.decoy.as_ref() else {
panic!("expected HTTP decoy");
};
assert_eq!(authority, "[::1]:18081");
@@ -509,7 +523,7 @@ fn websocket_carriers_build_runtime_profiles_with_bounded_defaults() {
let configured = WEB_CONFIG.replace("https-lanes", name);
let config = load_config_from_temp_toml(&configured);
let profile = &config.web.runtime.unwrap().profiles[0];
assert_eq!(profile.carrier, carrier);
assert_eq!(profile.shared.carrier, carrier);
assert_eq!(config.web.limits.websocket_bytes_global, 256 * 1024 * 1024);
assert_eq!(config.web.limits.websocket_admission_watermark_pct, 75);
assert_eq!(config.web.limits.websocket_eviction_watermark_pct, 90);
@@ -0,0 +1,241 @@
use super::*;
#[test]
fn exact_host_list_builds_distinct_host_bound_capabilities() {
let source = WEB_CONFIG.replace(
"host = \"Proxy.Example.COM\"",
"hosts = [\"Proxy.Example.COM\", \"second.example.com\"]",
);
let config = load_config_from_temp_toml(&source);
let runtime = config.web.runtime.as_ref().unwrap();
assert_eq!(runtime.vhosts.len(), 2);
assert_eq!(runtime.profiles.len(), 2);
assert_eq!(runtime.capabilities.len(), 2);
assert_ne!(runtime.capabilities[0], runtime.capabilities[1]);
for host in ["proxy.example.com", "second.example.com"] {
let legacy = load_config_from_temp_toml(&WEB_CONFIG.replace("Proxy.Example.COM", host));
assert_eq!(
runtime.vhosts[host].capabilities,
legacy.web.runtime.as_ref().unwrap().vhosts[host].capabilities,
);
}
}
#[test]
fn grouped_profiles_share_policy_but_match_legacy_bindings() {
for mode in ["plain", "dd"] {
for base in ["", "MixedCase/path"] {
let source = WEB_CONFIG
.replace("secret_mode = \"dd\"", &format!("secret_mode = \"{mode}\""))
.replace(
"public_addr =",
&format!("base_path = \"{base}\"\npublic_addr ="),
);
let grouped = load_config_from_temp_toml(&source.replace(
"host = \"Proxy.Example.COM\"",
"hosts = [\"Proxy.Example.COM\", \"second.example.com\"]",
));
let runtime = grouped.web.runtime.as_ref().unwrap();
let first = &runtime.vhosts["proxy.example.com"];
let second = &runtime.vhosts["second.example.com"];
assert!(Arc::ptr_eq(&first.decoy, &second.decoy));
assert!(Arc::ptr_eq(
&first.profiles[0].shared,
&second.profiles[0].shared
));
for host in ["proxy.example.com", "second.example.com"] {
let legacy = load_config_from_temp_toml(&source.replace("Proxy.Example.COM", host));
let expected = &legacy.web.runtime.as_ref().unwrap().vhosts[host];
let actual = &runtime.vhosts[host];
assert_eq!(actual.base, expected.base);
assert_eq!(actual.capabilities, expected.capabilities);
assert_eq!(actual.profiles[0].host, host);
let actual = &actual.profiles[0].shared;
let expected = &expected.profiles[0].shared;
assert_eq!(actual.user, expected.user);
assert_eq!(actual.credential_id, expected.credential_id);
assert_eq!(actual.key_fingerprint, expected.key_fingerprint);
assert_eq!(actual.public_addr, expected.public_addr);
assert_eq!(actual.carriers, expected.carriers);
assert_eq!(actual.max_sessions, expected.max_sessions);
assert_eq!(actual.max_streams, expected.max_streams);
assert_eq!(
actual.max_streams_per_session,
expected.max_streams_per_session
);
}
}
}
}
#[test]
fn host_selectors_reject_ambiguity_and_canonical_collisions() {
for selector in [
"",
"host = \"\"",
"hosts = []",
"hosts = [\"\"]",
"host = \"a.example.com\"\nhosts = []",
"host = \"a.example.com\"\nhosts = [\"b.example.com\"]",
"hosts = [\"a.example.com\", \"A.EXAMPLE.COM\"]",
"hosts = [\"BÜCHER.example\", \"xn--bcher-kva.example\"]",
"hosts = [\"*.example.com\"]",
"hosts = [\"*\"]",
"hosts = [\"127.0.0.1\"]",
"hosts = [\"a.example.com:443\"]",
"hosts = [\"a.example.com.\"]",
"hosts = [\"a.example.com/path\"]",
] {
let error = load_config_error_from_temp_toml(
&WEB_CONFIG.replace("host = \"Proxy.Example.COM\"", selector),
);
assert!(error.contains("web.vhosts[0]"), "{selector}: {error}");
}
let group = WEB_CONFIG.replace(
"host = \"Proxy.Example.COM\"",
"hosts = [\"a.example.com\", \"proxy.example.com\"]",
);
let (_, legacy_vhost) = WEB_CONFIG.split_once("[[web.vhosts]]").unwrap();
let error = load_config_error_from_temp_toml(&format!("{group}\n[[web.vhosts]]{legacy_vhost}"));
assert!(error.contains("duplicate WEB vhost host `proxy.example.com` at web.vhosts[1].host"));
}
#[test]
fn host_selectors_round_trip_without_expanding_the_source() {
for selector in [
"host = \"Proxy.Example.COM\"",
"hosts = [\"Proxy.Example.COM\"]",
"hosts = [\"Proxy.Example.COM\", \"BÜCHER.example\"]",
] {
let config = load_config_from_temp_toml(&format!(
"[general]\nconfig_strict = true\n{}",
WEB_CONFIG.replace("host = \"Proxy.Example.COM\"", selector),
));
let value = serde_json::to_value(&config.web).unwrap();
let vhosts = value["vhosts"].as_array().unwrap();
assert_eq!(vhosts.len(), 1);
if selector.starts_with("host =") {
assert_eq!(vhosts[0]["host"], "proxy.example.com");
assert!(vhosts[0].get("hosts").is_none());
} else {
assert!(vhosts[0].get("host").is_none());
assert_eq!(vhosts[0]["hosts"][0], "proxy.example.com");
if vhosts[0]["hosts"].as_array().unwrap().len() == 2 {
assert_eq!(vhosts[0]["hosts"][1], "xn--bcher-kva.example");
}
}
assert!(value.get("runtime").is_none());
let mut document = toml::map::Map::new();
document.insert(
"web".to_string(),
toml::Value::try_from(&config.web).unwrap(),
);
let prefix = WEB_CONFIG.split_once("[web]").unwrap().0;
let round_trip = load_config_from_temp_toml(&format!(
"[general]\nconfig_strict = true\n{prefix}{}",
toml::to_string(&document).unwrap(),
));
assert_eq!(round_trip.web.vhosts, config.web.vhosts);
}
}
#[test]
fn expanded_host_profile_limits_apply_before_runtime_preparation() {
let source = WEB_CONFIG.replace(
"host = \"Proxy.Example.COM\"",
"hosts = [\"a.example.com\", \"b.example.com\"]",
);
let source = format!(
"{source}\n[[web.vhosts.profiles]]\nuser = \"alice\"\nsecret_mode = \"plain\"\n[web.limits]\nmax_vhosts = 2\nmax_profiles = 4\n"
);
let config = load_config_from_temp_toml(&source);
let runtime = config.web.runtime.as_ref().unwrap();
assert_eq!(runtime.vhosts.len(), 2);
assert_eq!(runtime.profiles.len(), 4);
assert_eq!(runtime.capabilities.len(), 4);
assert_eq!(
runtime
.profiles
.iter()
.map(|profile| profile.host.as_str())
.collect::<Vec<_>>(),
[
"a.example.com",
"a.example.com",
"b.example.com",
"b.example.com"
]
);
assert!(!Arc::ptr_eq(
&runtime.profiles[0].shared,
&runtime.profiles[1].shared
));
assert!(
load_config_error_from_temp_toml(&source.replace("max_vhosts = 2", "max_vhosts = 1"))
.contains("max_vhosts")
);
assert!(
load_config_error_from_temp_toml(&source.replace("max_profiles = 4", "max_profiles = 3"))
.contains("max_profiles")
);
let invalid_origin = source.replace("http://127.0.0.1:18081", "http://example.com:18081");
assert!(
load_config_error_from_temp_toml(
&invalid_origin.replace("max_profiles = 4", "max_profiles = 3")
)
.contains("max_profiles")
);
}
#[test]
fn grouped_static_snapshot_is_loaded_and_charged_once() {
let directory = tempfile::tempdir().unwrap();
std::fs::write(directory.path().join("index.html"), b"hello").unwrap();
let source = WEB_CONFIG
.replace(
"host = \"Proxy.Example.COM\"",
"hosts = [\"a.example.com\", \"b.example.com\"]",
)
.replace(
"mode = \"http_upstream\"\nupstream = \"http://127.0.0.1:18081\"",
&format!(
"mode = \"static_directory\"\ndirectory = {:?}",
directory.path()
),
);
let limits =
"\n[web.limits]\nmax_static_files = 1\nmax_static_file_bytes = 5\nmax_static_bytes = 5\n";
let config = load_config_from_temp_toml(&format!("{source}{limits}"));
let runtime = config.web.runtime.as_ref().unwrap();
let a = &runtime.vhosts["a.example.com"];
let b = &runtime.vhosts["b.example.com"];
assert!(Arc::ptr_eq(&a.decoy, &b.decoy));
let WebRuntimeDecoy::StaticDirectory(site) = a.decoy.as_ref() else {
panic!("static decoy");
};
assert_eq!(site.assets["/index.html"].body.as_ref(), b"hello");
let (prefix, group) = source.split_once("[[web.vhosts]]").unwrap();
let selector = "hosts = [\"a.example.com\", \"b.example.com\"]";
let separate = format!(
"{prefix}[[web.vhosts]]{}\n[[web.vhosts]]{}{limits}",
group.replace(selector, "host = \"a.example.com\""),
group.replace(selector, "host = \"b.example.com\"")
);
assert!(load_config_error_from_temp_toml(&separate).contains("max_static"));
let expanded = load_config_from_temp_toml(
&separate
.replace("max_static_files = 1", "max_static_files = 2")
.replace("max_static_bytes = 5", "max_static_bytes = 10"),
);
let expanded = expanded.web.runtime.as_ref().unwrap();
assert!(!Arc::ptr_eq(
&expanded.vhosts["a.example.com"].decoy,
&expanded.vhosts["b.example.com"].decoy
));
assert!(!Arc::ptr_eq(
&expanded.profiles[0].shared,
&expanded.profiles[1].shared
));
std::fs::write(directory.path().join("index.html"), b"longer").unwrap();
assert!(load_config_error_from_temp_toml(&format!("{source}{limits}")).contains("max_static"));
}
+2 -2
View File
@@ -58,8 +58,8 @@ pub use web::{
WebSecretMode, WebTimeoutsConfig, WebVhostConfig,
};
pub(crate) use web::{
WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost,
WebStaticAsset, WebStaticSite,
WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile,
WebRuntimeProfileShared, WebRuntimeVhost, WebStaticAsset, WebStaticSite,
};
pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES;
#[allow(unused_imports)]
+16 -12
View File
@@ -73,19 +73,23 @@ pub enum WebDecoyConfig {
},
}
/// One externally visible WEB hostname and its explicit access profiles.
/// Exact WEB hostnames sharing one immutable decoy and explicit profile policy.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebVhostConfig {
/// Canonical lowercase ACE hostname used by Telegram Desktop.
pub host: String,
/// Legacy single hostname 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.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hosts: Option<Vec<String>>,
/// Optional canonical WEB endpoint prefix without surrounding slashes.
#[serde(default)]
pub base_path: String,
/// Stable public destination tuple used by inner relay routing and KDF metadata.
pub public_addr: SocketAddr,
/// Ordinary-site fallback for this hostname.
/// Ordinary-site fallback shared by every hostname in this group.
pub decoy: WebDecoyConfig,
/// Access users and exact secret modes enabled for this hostname.
/// Access profiles whose limits apply independently to each hostname.
#[serde(default)]
pub profiles: Vec<WebProfileConfig>,
}
@@ -192,19 +196,19 @@ pub struct WebLimitsConfig {
/// Live bootstrap credential ceiling for one forwarded client address.
#[serde(default = "default_web_max_bootstraps_per_ip")]
pub max_bootstraps_per_ip: usize,
/// Maximum configured WEB virtual-host count.
/// Maximum concrete hostname count across all WEB groups.
#[serde(default = "default_web_max_vhosts")]
pub max_vhosts: usize,
/// Maximum configured WEB access-profile count across all virtual hosts.
/// Maximum expanded hostname/profile binding count across all WEB groups.
#[serde(default = "default_web_max_profiles")]
pub max_profiles: usize,
/// Maximum static snapshot entry count across all virtual hosts.
/// Maximum static snapshot entry count, charged once per source group.
#[serde(default = "default_web_max_static_files")]
pub max_static_files: usize,
/// Maximum bytes read from one static snapshot file.
#[serde(default = "default_web_max_static_file_bytes")]
pub max_static_file_bytes: usize,
/// Maximum static snapshot bytes across all virtual hosts.
/// Maximum static snapshot bytes, charged once per source group.
#[serde(default = "default_web_max_static_bytes")]
pub max_static_bytes: usize,
/// Maximum retained WEB debug record count.
@@ -454,7 +458,7 @@ pub struct WebConfig {
/// WEB lifecycle deadlines.
#[serde(default)]
pub timeouts: WebTimeoutsConfig,
/// Public hostnames served by WEB listeners.
/// Exact hostname groups served by WEB listeners.
#[serde(default)]
pub vhosts: Vec<WebVhostConfig>,
/// Validated immutable runtime snapshot built during configuration loading.
@@ -511,6 +515,6 @@ impl Default for WebConfig {
// Immutable runtime WEB configuration consumed by hot paths.
mod runtime;
pub(crate) use runtime::{
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
WebStaticSite,
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeProfileShared, WebRuntimeVhost,
WebStaticAsset, WebStaticSite,
};
+19 -4
View File
@@ -21,7 +21,7 @@ pub(crate) struct WebRuntimeVhost {
/// Restart-frozen decoy capability-scan policy.
pub(crate) decoy_fasttrack_mode: WebDecoyFastTrackMode,
/// Immutable ordinary-site fallback snapshot.
pub(crate) decoy: WebRuntimeDecoy,
pub(crate) decoy: Arc<WebRuntimeDecoy>,
/// Upstream connect and response-head deadline.
pub(crate) decoy_header_secs: u64,
/// Exact capability profiles accepted by this host.
@@ -35,6 +35,16 @@ pub(crate) struct WebRuntimeVhost {
pub(crate) struct WebRuntimeProfile {
/// Canonical host that owns this profile.
pub(crate) host: String,
/// HMAC-derived capability bound to this exact host and endpoint base.
pub(crate) capability: [u8; 32],
/// Immutable policy shared within a source group, never the quota owner.
pub(crate) shared: Arc<WebRuntimeProfileShared>,
}
/// Host-independent profile policy retained by all bindings in one source group.
/// Limits still apply to each binding's exact host/user/mode quota owner.
#[derive(Debug)]
pub(crate) struct WebRuntimeProfileShared {
/// Stable public destination tuple supplied to relay routing.
pub(crate) public_addr: SocketAddr,
/// Exact access user authenticated by logical streams.
@@ -53,8 +63,6 @@ pub(crate) struct WebRuntimeProfile {
pub(crate) carriers: Arc<[WebCarrier]>,
/// Cumulative carrier-attempt deadlines frozen when the bridge is issued.
pub(crate) carrier_negotiation_deadlines_secs: [u64; 4],
/// HMAC-derived bridge capability.
pub(crate) capability: [u8; 32],
/// Non-secret domain-separated client-secret fingerprint for debugging.
pub(crate) key_fingerprint: String,
/// Per-profile live session ceiling.
@@ -68,7 +76,14 @@ pub(crate) struct WebRuntimeProfile {
/// Runtime-ready ordinary-site fallback.
#[derive(Debug)]
pub(crate) enum WebRuntimeDecoy {
HttpUpstream { addr: SocketAddr, authority: String },
/// Fixed private origin prepared before the generation is published.
HttpUpstream {
/// Resolved destination, independent of the request Host.
addr: SocketAddr,
/// Configured origin authority forwarded to the upstream server.
authority: String,
},
/// Immutable files acquired once for the entire source group.
StaticDirectory(Arc<WebStaticSite>),
}