mirror of
https://github.com/telemt/telemt.git
synced 2026-11-01 05:12:02 +03:00
WEB: share immutable profiles + decoys across exact host lists
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user