diff --git a/Cargo.lock b/Cargo.lock index 022910c..dbf8161 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2780,7 +2780,7 @@ dependencies = [ [[package]] name = "telemt" -version = "3.5.13" +version = "3.5.14" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 135b926..c5aefa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "telemt" -version = "3.5.13" +version = "3.5.14" edition = "2024" rust-version = "1.88" diff --git a/src/api/config_edit.rs b/src/api/config_edit.rs index 809dac1..4894c7c 100644 --- a/src/api/config_edit.rs +++ b/src/api/config_edit.rs @@ -158,7 +158,7 @@ async fn prepare_patch_to_path( ) -> Result { // 1. optimistic concurrency let loaded = load_config_snapshot(config_path, false).await?; - let current = compute_snapshot_revision(&loaded); + let current = compute_snapshot_revision(&loaded.source_contents); if expected_revision.is_some_and(|expected| expected != current) { return Err(ApiFailure::new( hyper::StatusCode::CONFLICT, @@ -208,6 +208,8 @@ async fn prepare_patch_to_path( let mut merged = Toml::try_from(&old_cfg) .map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?; deep_merge(&mut merged, &patch_toml); + ProxyConfig::validate_decoy_source_keys(&merged) + .map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))?; let requested_cfg: ProxyConfig = merged .clone() @@ -263,7 +265,7 @@ async fn prepare_patch_to_path( } // 4. classify the validated, normalized candidate. - let revision = compute_snapshot_revision(&candidate); + let revision = compute_snapshot_revision(&candidate.source_contents); let new_cfg = candidate.config; let class = classify_config_changes(&old_cfg, &new_cfg); let deferred_process_fields = @@ -306,7 +308,7 @@ fn reload_submit_failure(error: ReloadSubmitError) -> ApiFailure { /// Returns only the editable config sections and current revision. pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> { let loaded = load_config_snapshot(config_path, false).await?; - let revision = compute_snapshot_revision(&loaded); + let revision = compute_snapshot_revision(&loaded.source_contents); let parsed = Toml::try_from(&loaded.config) .map_err(|error| ApiFailure::internal(format!("failed to serialize config: {error}")))?; @@ -457,3 +459,7 @@ mod tests; #[cfg(test)] #[path = "config_edit/conveyor_tests.rs"] mod conveyor_tests; + +#[cfg(test)] +#[path = "config_edit/decoy_dns_tests.rs"] +mod decoy_dns_tests; diff --git a/src/api/config_edit/decoy_dns_tests.rs b/src/api/config_edit/decoy_dns_tests.rs new file mode 100644 index 0000000..11b6996 --- /dev/null +++ b/src/api/config_edit/decoy_dns_tests.rs @@ -0,0 +1,105 @@ +use super::*; + +const SOURCE: &str = r#" +[access.users] +alice = "000102030405060708090a0b0c0d0e0f" +[[server.listeners]] +ip = "127.0.0.1" +port = 18080 +transport = "web" +web_trusted_proxy_cidrs = ["127.0.0.1/32"] +[web] +enabled = true +[[web.vhosts]] +host = "proxy.example.com" +public_addr = "203.0.113.10:443" +[web.vhosts.decoy] +mode = "http_upstream" +upstream = "http://localhost:18081" +resolve = "startup" +[[web.vhosts.profiles]] +user = "alice" +secret_mode = "dd" +"#; + +#[tokio::test] +async fn decoy_dns_api_get_and_mutation_base_need_no_prepared_runtime() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write( + &path, + SOURCE.replace("localhost", "unresolvable.example.invalid"), + ) + .unwrap(); + let (managed, revision) = read_managed_config(&path).await.unwrap(); + assert_eq!( + managed["web"]["vhosts"][0]["decoy"]["resolve"].as_str(), + Some("startup") + ); + assert!(managed["web"].get("decoy_dns").is_none()); + let (base, base_revision) = crate::api::config_store::load_config_for_mutation(&path, None) + .await + .unwrap(); + assert_eq!(revision, base_revision); + assert!(base.web.runtime.is_none()); + assert!(base.web.decoy_dns.origins.is_empty()); +} + +#[tokio::test] +async fn decoy_dns_api_patch_without_reload_prepares_before_disk_write() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, SOURCE).unwrap(); + // This current-thread runtime exercises the real Tokio resolver without nested block_on. + let active = ProxyConfig::load_prepared(path.clone()) + .await + .unwrap() + .config; + let before = std::fs::read(&path).unwrap(); + let mut vhosts = serde_json::to_value(&active.web.vhosts).unwrap(); + vhosts[0]["decoy"]["upstream"] = Json::String("http://localhost:18080".to_string()); + let error = apply_patch_to_path(&path, &serde_json::json!({"web": {"vhosts": vhosts}}), None) + .await + .unwrap_err(); + assert!(error.message.contains("overlaps WEB listener")); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(active.web.runtime.is_some()); + + let prepared = prepare_patch_to_path( + &path, + &serde_json::json!({"web": {"conveyor": false}}), + None, + ) + .await + .unwrap(); + assert_eq!(prepared.desired_config.web.decoy_dns.origins.len(), 1); + assert!(prepared.desired_config.web.runtime.is_some()); + assert_eq!(std::fs::read(&path).unwrap(), before); + apply_patch_to_path( + &path, + &serde_json::json!({"web": {"conveyor": false}}), + None, + ) + .await + .unwrap(); + let reloaded = ProxyConfig::load_prepared(path).await.unwrap(); + assert!(!reloaded.config.web.conveyor); +} + +#[tokio::test] +async fn decoy_dns_api_rejects_resolve_on_static_mode_before_serde_drops_it() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write(&path, SOURCE).unwrap(); + let base = ProxyConfig::parse_source(&path).unwrap(); + let mut vhosts = serde_json::to_value(&base.config.web.vhosts).unwrap(); + vhosts[0]["decoy"] = serde_json::json!({ + "mode": "static_directory", "directory": directory.path(), "resolve": "startup" + }); + let before = std::fs::read(&path).unwrap(); + let error = apply_patch_to_path(&path, &serde_json::json!({"web": {"vhosts": vhosts}}), None) + .await + .unwrap_err(); + assert!(error.message.contains("only valid for mode=http_upstream")); + assert_eq!(std::fs::read(&path).unwrap(), before); +} diff --git a/src/api/config_store.rs b/src/api/config_store.rs index 5bb7869..01f6876 100644 --- a/src/api/config_store.rs +++ b/src/api/config_store.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use hyper::header::IF_MATCH; use sha2::{Digest, Sha256}; -use crate::config::{ConfigSourceGraph, LoadedConfig, ProxyConfig}; +use crate::config::{ConfigSourceGraph, LoadedConfig, ParsedConfigSource, ProxyConfig}; use super::model::ApiFailure; @@ -63,7 +63,7 @@ pub(super) async fn load_config_for_mutation( expected_revision: Option<&str>, ) -> Result<(ProxyConfig, String), ApiFailure> { let loaded = load_config_snapshot(config_path, false).await?; - let revision = compute_snapshot_revision(&loaded); + let revision = compute_snapshot_revision(&loaded.source_contents); if expected_revision.is_some_and(|expected| expected != revision) { return Err(ApiFailure::new( hyper::StatusCode::CONFLICT, @@ -98,9 +98,10 @@ pub(super) fn compute_revision(content: &str) -> String { hex::encode(hasher.finalize()) } -pub(super) fn compute_snapshot_revision(loaded: &LoadedConfig) -> String { +/// Hashes source documents only; prepared DNS answers never change a revision. +pub(super) fn compute_snapshot_revision(source_contents: &BTreeMap) -> String { compute_source_revision(&ConfigSourceGraph { - source_contents: loaded.source_contents.clone(), + source_contents: source_contents.clone(), rendered: String::new(), }) } @@ -118,12 +119,13 @@ pub(super) fn compute_source_revision(graph: &ConfigSourceGraph) -> String { hex::encode(hasher.finalize()) } +/// Reads a normalized source snapshot without resolving decoys or loading static assets. pub(super) async fn load_config_snapshot( config_path: &Path, invalid_is_bad_request: bool, -) -> Result { +) -> Result { let config_path = config_path.to_path_buf(); - tokio::task::spawn_blocking(move || ProxyConfig::load_with_metadata(config_path)) + tokio::task::spawn_blocking(move || ProxyConfig::parse_source(config_path)) .await .map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))? .map_err(|error| { @@ -135,8 +137,9 @@ pub(super) async fn load_config_snapshot( }) } +/// Selects the single writable source document owning all requested sections. pub(super) fn resolve_single_source_owner( - loaded: &LoadedConfig, + loaded: &ParsedConfigSource, config_path: &Path, targets: &[&str], ) -> Result { @@ -227,6 +230,7 @@ fn has_include_inside_table(content: &str) -> bool { false } +/// Prepares the entire mutation candidate before any source file may be replaced. pub(super) async fn load_candidate_snapshot( config_path: &Path, base_sources: &BTreeMap, @@ -236,12 +240,16 @@ pub(super) async fn load_candidate_snapshot( let config_path = config_path.to_path_buf(); let mut overrides = base_sources.clone(); overrides.insert(owner_path, owner_contents); - tokio::task::spawn_blocking(move || { - ProxyConfig::load_with_source_overrides(config_path, &overrides) + let parsed = tokio::task::spawn_blocking(move || { + ProxyConfig::parse_source_with_overrides(config_path, &overrides) }) .await .map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))? - .map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}"))) + .map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))?; + parsed + .prepare() + .await + .map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}"))) } fn normalize_source_path(path: &Path) -> PathBuf { @@ -265,19 +273,23 @@ fn normalize_source_path(path: &Path) -> PathBuf { normalized } +/// Reads desired user and quota policy without acquiring runtime decoy state. pub(super) async fn load_config_from_disk(config_path: &Path) -> Result { - let config_path = config_path.to_path_buf(); - tokio::task::spawn_blocking(move || ProxyConfig::load(config_path)) + load_config_snapshot(config_path, false) .await - .map_err(|e| ApiFailure::internal(format!("failed to join config loader: {}", e)))? - .map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e))) + .map(|source| source.config) } +/// Acquires fresh decoy evidence for an explicit API reload, even at the same revision. pub(super) async fn load_config_for_reload( config_path: &Path, ) -> Result<(ProxyConfig, String), ApiFailure> { let loaded = load_config_snapshot(config_path, true).await?; - let revision = compute_snapshot_revision(&loaded); + let revision = compute_snapshot_revision(&loaded.source_contents); + let loaded = loaded + .prepare() + .await + .map_err(|error| ApiFailure::bad_request(format!("invalid runtime config: {error}")))?; Ok((loaded.config, revision)) } diff --git a/src/api/config_store/persistence.rs b/src/api/config_store/persistence.rs index 83c392a..0a3219c 100644 --- a/src/api/config_store/persistence.rs +++ b/src/api/config_store/persistence.rs @@ -113,7 +113,7 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision( expected_revision: Option<&str>, ) -> Result { let loaded = load_config_snapshot(config_path, false).await?; - let loaded_revision = compute_snapshot_revision(&loaded); + let loaded_revision = compute_snapshot_revision(&loaded.source_contents); if expected_revision.is_some_and(|expected| expected != loaded_revision) { return Err(revision_conflict()); } @@ -159,7 +159,7 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision( owner_contents.clone(), ) .await?; - let _candidate_revision = compute_snapshot_revision(&candidate); + let _candidate_revision = compute_snapshot_revision(&candidate.source_contents); let revision = write_atomic_if_unchanged( config_path.to_path_buf(), loaded_revision, diff --git a/src/config/hot_reload/watcher.rs b/src/config/hot_reload/watcher.rs index 178691a..7eaed74 100644 --- a/src/config/hot_reload/watcher.rs +++ b/src/config/hot_reload/watcher.rs @@ -1,5 +1,8 @@ use super::*; +// Source polling and explicit reloads have distinct DNS preparation lifecycles. +mod preparation; + #[derive(Debug, Clone, Default, PartialEq, Eq)] pub(super) struct WatchManifest { files: BTreeSet, @@ -132,9 +135,21 @@ fn apply_watch_manifest( } } -/// Load config, validate, diff against current, and broadcast if changed. +/// Builds the effective WEB snapshot without changing live state. +fn prepare_effective_config( + old_cfg: &ProxyConfig, + new_cfg: &ProxyConfig, +) -> crate::error::Result { + new_cfg.validate()?; + let mut applied_cfg = overlay_hot_fields(old_cfg, new_cfg); + applied_cfg.validate_effective_web()?; + applied_cfg.rebuild_runtime_web()?; + Ok(applied_cfg) +} + +/// Diffs and publishes a fully prepared configuration on the watcher owner task. fn reload_config_with_resolver( - config_path: &PathBuf, + prepared: (LoadedConfig, ProxyConfig), config_tx: &watch::Sender>, log_tx: &watch::Sender, detected_ip_v4: Option, @@ -142,13 +157,7 @@ fn reload_config_with_resolver( reload_state: &mut ReloadState, dns_resolver: Option<&crate::network::dns_overrides::GenerationDnsResolver>, ) -> Option { - let loaded = match ProxyConfig::load_with_metadata(config_path) { - Ok(loaded) => loaded, - Err(e) => { - error!("config reload: failed to parse {:?}: {}", config_path, e); - return None; - } - }; + let (loaded, applied_cfg) = prepared; let LoadedConfig { config: new_cfg, source_files, @@ -157,34 +166,12 @@ fn reload_config_with_resolver( } = loaded; let next_manifest = WatchManifest::from_source_files(&source_files); - if let Err(e) = new_cfg.validate() { - error!( - "config reload: validation failed: {}; keeping old config", - e - ); - return Some(next_manifest); - } - - if reload_state.is_applied(rendered_hash) { - return Some(next_manifest); - } - let old_cfg = config_tx.borrow().clone(); - let mut applied_cfg = overlay_hot_fields(&old_cfg, &new_cfg); - if let Err(error) = applied_cfg - .validate_effective_web() - .and_then(|_| applied_cfg.rebuild_runtime_web()) - { - error!( - "config reload: effective WEB validation failed: {}; keeping old config", - error - ); - return Some(next_manifest); - } let old_hot = HotFields::from_config(&old_cfg); let applied_hot = HotFields::from_config(&applied_cfg); let non_hot_changed = !config_equal(&applied_cfg, &new_cfg); - let hot_changed = !config_equal(&old_cfg, &applied_cfg); + let hot_changed = + !config_equal(&old_cfg, &applied_cfg) || !old_cfg.web_decoy_endpoints_equal(&applied_cfg); if non_hot_changed { warn_non_hot_changes(&old_cfg, &new_cfg, non_hot_changed); @@ -228,8 +215,13 @@ pub(super) fn reload_config( detected_ip_v6: Option, reload_state: &mut ReloadState, ) -> Option { + let loaded = ProxyConfig::load_with_metadata(config_path).ok()?; + if reload_state.is_applied(loaded.rendered_hash) { + return Some(WatchManifest::from_source_files(&loaded.source_files)); + } + let applied = prepare_effective_config(&config_tx.borrow(), &loaded.config).ok()?; reload_config_with_resolver( - config_path, + (loaded, applied), config_tx, log_tx, detected_ip_v4, @@ -282,7 +274,7 @@ pub fn spawn_config_watcher( } } } - let initial_loaded = ProxyConfig::load_with_metadata(&config_path).ok(); + let initial_loaded = preparation::read_source(&config_path).await; let initial_manifest = initial_loaded .as_ref() .map(|loaded| WatchManifest::from_source_files(&loaded.source_files)) @@ -291,7 +283,7 @@ pub fn spawn_config_watcher( }); let initial_matches_disk = initial_loaded .as_ref() - .is_some_and(|loaded| config_equal(config_tx.borrow().as_ref(), &loaded.config)); + .is_some_and(|source| preparation::source_matches_active(&config_tx.borrow(), source)); let initial_snapshot_hash = initial_loaded .as_ref() .filter(|_| initial_matches_disk) @@ -384,12 +376,14 @@ pub fn spawn_config_watcher( }; loop { + let mut force = false; #[cfg(unix)] tokio::select! { msg = notify_rx.recv() => { if msg.is_none() { break; } } _ = sighup.recv() => { + force = true; info!("SIGHUP received — reloading {:?}", config_path); } _ = cancellation.cancelled() => break, @@ -406,27 +400,31 @@ pub fn spawn_config_watcher( tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await; while notify_rx.try_recv().is_ok() {} - let mut next_manifest = reload_config_with_resolver( + let mut next_manifest = preparation::reload( &config_path, &config_tx, &log_tx, detected_ip_v4, detected_ip_v6, &mut reload_state, - dns_resolver.as_deref(), - ); + dns_resolver.clone(), + force, + ) + .await; if next_manifest.is_none() { tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await; while notify_rx.try_recv().is_ok() {} - next_manifest = reload_config_with_resolver( + next_manifest = preparation::reload( &config_path, &config_tx, &log_tx, detected_ip_v4, detected_ip_v6, &mut reload_state, - dns_resolver.as_deref(), - ); + dns_resolver.clone(), + force, + ) + .await; } if let Some(next_manifest) = next_manifest { @@ -460,3 +458,6 @@ mod path_tests { assert_eq!(normalize_watch_path(&linked), linked); } } + +#[cfg(test)] +mod decoy_dns_tests; diff --git a/src/config/hot_reload/watcher/decoy_dns_tests.rs b/src/config/hot_reload/watcher/decoy_dns_tests.rs new file mode 100644 index 0000000..bb30aa4 --- /dev/null +++ b/src/config/hot_reload/watcher/decoy_dns_tests.rs @@ -0,0 +1,133 @@ +use super::*; + +const SOURCE: &str = r#" +[access.users] +alice = "000102030405060708090a0b0c0d0e0f" +[[server.listeners]] +ip = "127.0.0.1" +port = 18080 +transport = "web" +web_trusted_proxy_cidrs = ["127.0.0.1/32"] +[web] +enabled = true +[[web.vhosts]] +host = "proxy.example.com" +public_addr = "203.0.113.10:443" +[web.vhosts.decoy] +mode = "http_upstream" +upstream = "http://localhost:18081" +resolve = "startup" +[[web.vhosts.profiles]] +user = "alice" +secret_mode = "dd" +"#; + +#[tokio::test] +async fn decoy_dns_watcher_publishes_dns_only_changes_once() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, SOURCE).unwrap(); + let mut loaded = ProxyConfig::load_prepared(path.clone()).await.unwrap(); + let (config_tx, mut config_rx) = watch::channel(Arc::new(loaded.config.clone())); + let (log_tx, _) = watch::channel(loaded.config.general.log_level.clone()); + let old = config_rx.borrow().clone(); + let mut state = ReloadState::new(Some(loaded.rendered_hash)); + Arc::make_mut(&mut loaded.config.web.decoy_dns) + .origins + .insert( + ("localhost".to_string(), 18081), + vec!["127.0.0.2:18081".parse().unwrap()], + ); + loaded.config.rebuild_runtime_web().unwrap(); + let applied = prepare_effective_config(&old, &loaded.config).unwrap(); + reload_config_with_resolver( + (loaded.clone(), applied), + &config_tx, + &log_tx, + None, + None, + &mut state, + None, + ) + .unwrap(); + assert!(config_rx.has_changed().unwrap()); + let published = config_rx.borrow_and_update().clone(); + assert!(!old.web_decoy_endpoints_equal(&published)); + let applied = prepare_effective_config(&published, &loaded.config).unwrap(); + reload_config_with_resolver( + (loaded, applied), + &config_tx, + &log_tx, + None, + None, + &mut state, + None, + ) + .unwrap(); + assert!(!config_rx.has_changed().unwrap()); + assert!(Arc::ptr_eq(&published, &config_rx.borrow())); + + preparation::reload( + &path, &config_tx, &log_tx, None, None, &mut state, None, false, + ) + .await + .unwrap(); + assert!(!config_rx.has_changed().unwrap()); + assert!(Arc::ptr_eq(&published, &config_rx.borrow())); + + // Explicit reload bypasses the unchanged source hash and replaces the injected endpoint. + preparation::reload( + &path, &config_tx, &log_tx, None, None, &mut state, None, true, + ) + .await + .unwrap(); + assert!(config_rx.has_changed().unwrap()); + assert!(!published.web_decoy_endpoints_equal(&config_rx.borrow())); + + let current = config_rx.borrow_and_update().clone(); + std::fs::write(&path, SOURCE.replace("localhost:18081", "localhost:18080")).unwrap(); + preparation::reload( + &path, &config_tx, &log_tx, None, None, &mut state, None, false, + ) + .await + .unwrap(); + assert!(!config_rx.has_changed().unwrap()); + assert!(Arc::ptr_eq(¤t, &config_rx.borrow())); +} + +#[tokio::test] +async fn decoy_dns_unchanged_watcher_source_skips_resolution() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write( + &path, + SOURCE.replace("localhost", "unresolvable.example.invalid"), + ) + .unwrap(); + let source = preparation::read_source(&path).await.unwrap(); + let initial = Arc::new(source.config.clone()); + let (config_tx, config_rx) = watch::channel(initial.clone()); + let (log_tx, _) = watch::channel(initial.general.log_level.clone()); + let mut state = ReloadState::new(Some(source.rendered_hash)); + assert!(preparation::source_matches_active(&initial, &source)); + preparation::reload( + &path, &config_tx, &log_tx, None, None, &mut state, None, false, + ) + .await + .unwrap(); + assert!(!config_rx.has_changed().unwrap()); + assert!(Arc::ptr_eq(&initial, &config_rx.borrow())); +} + +#[tokio::test] +async fn decoy_dns_watcher_init_ignores_retained_restart_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + std::fs::write(&path, SOURCE).unwrap(); + let loaded = ProxyConfig::load_prepared(path.clone()).await.unwrap(); + let mut source = ProxyConfig::parse_source(&path).unwrap(); + source.config.web.limits.max_static_file_bytes += 1; + assert!(preparation::source_matches_active(&loaded.config, &source)); + source.config.web.conveyor = !source.config.web.conveyor; + assert!(!preparation::source_matches_active(&loaded.config, &source)); +} diff --git a/src/config/hot_reload/watcher/preparation.rs b/src/config/hot_reload/watcher/preparation.rs new file mode 100644 index 0000000..62062eb --- /dev/null +++ b/src/config/hot_reload/watcher/preparation.rs @@ -0,0 +1,85 @@ +use super::*; + +/// Restart-only differences must not enqueue another DNS preparation after activation. +pub(super) fn source_matches_active( + active: &ProxyConfig, + source: &crate::config::ParsedConfigSource, +) -> bool { + config_equal(active, &overlay_hot_fields(active, &source.config)) +} + +/// Reads only source state; watcher initialization and unchanged events never need DNS. +pub(super) async fn read_source(path: &PathBuf) -> Option { + let path = path.clone(); + match tokio::task::spawn_blocking(move || ProxyConfig::parse_source(path)).await { + Ok(Ok(source)) => Some(source), + Ok(Err(error)) => { + error!("config reload: failed to parse: {}", error); + None + } + Err(error) => { + error!("config reload: source reader failed: {}", error); + None + } + } +} + +/// Prepares fresh evidence only for changed sources or an explicit reload request. +pub(super) async fn reload( + path: &PathBuf, + config_tx: &watch::Sender>, + log_tx: &watch::Sender, + detected_ip_v4: Option, + detected_ip_v6: Option, + reload_state: &mut ReloadState, + dns_resolver: Option>, + force: bool, +) -> Option { + let source = read_source(path).await?; + let manifest = WatchManifest::from_source_files(&source.source_files); + if !force && reload_state.is_applied(source.rendered_hash) { + return Some(manifest); + } + let loaded = match source.prepare().await { + Ok(loaded) => loaded, + Err(error) => { + error!( + "config reload: preparation failed: {}; keeping old config", + error + ); + return Some(manifest); + } + }; + let old = config_tx.borrow().clone(); + // Static-site reconstruction after the listener overlay must not block Tokio workers. + // The worker only builds data: cancellation cannot leave a detached publisher behind. + let result = tokio::task::spawn_blocking(move || { + prepare_effective_config(&old, &loaded.config).map(|applied| (loaded, applied)) + }) + .await; + match result { + Ok(Ok(prepared)) => reload_config_with_resolver( + prepared, + config_tx, + log_tx, + detected_ip_v4, + detected_ip_v6, + reload_state, + dns_resolver.as_deref(), + ), + Ok(Err(error)) => { + error!( + "config reload: effective WEB validation failed: {}; keeping old config", + error + ); + Some(manifest) + } + Err(error) => { + error!( + "config reload: runtime builder failed: {}; keeping old config", + error + ); + Some(manifest) + } + } +} diff --git a/src/config/load.rs b/src/config/load.rs index 2ec5bd5..424bb1c 100644 --- a/src/config/load.rs +++ b/src/config/load.rs @@ -29,6 +29,8 @@ mod runtime_web; mod decode; mod effective; mod pipeline; +// Candidate-owned DNS preparation is separate from pure runtime reconstruction. +mod decoy_dns; mod validate_core; mod validate_me; mod validate_runtime; @@ -74,6 +76,19 @@ pub(crate) struct LoadedConfig { pub(crate) rendered_hash: u64, } +/// Normalized source configuration that has not acquired runtime DNS or static snapshots. +#[derive(Debug, Clone)] +pub(crate) struct ParsedConfigSource { + /// Source-only configuration; must be prepared before runtime publication. + pub(crate) config: ProxyConfig, + /// Normalized paths in the captured include graph. + pub(crate) source_files: Vec, + /// Exact source documents used for revisions and atomic mutation checks. + pub(crate) source_contents: BTreeMap, + /// Source-only hash, independent of DNS answers. + pub(crate) rendered_hash: u64, +} + /// Raw recursive source graph captured before typed deserialization. #[derive(Debug, Clone)] pub(crate) struct ConfigSourceGraph { @@ -229,6 +244,33 @@ impl ProxyConfig { pipeline::load_source_graph(graph) } + /// Parses source without resolving decoys or creating runtime WEB snapshots. + pub(crate) fn parse_source>(path: P) -> Result { + Self::parse_source_with_overrides(path, &BTreeMap::new()) + } + + /// Checks decoy mode-specific keys before a source or API patch loses unknown fields. + pub(crate) fn validate_decoy_source_keys(document: &toml::Value) -> Result<()> { + decoy_dns::validate_mode_keys(document) + } + + /// Parses an atomic mutation candidate without performing DNS. + pub(crate) fn parse_source_with_overrides>( + path: P, + overrides: &BTreeMap, + ) -> Result { + pipeline::parse_source_graph(Self::read_source_graph_with_overrides(path, overrides)?) + } + + /// Loads and prepares a complete runtime candidate without blocking async workers. + pub(crate) async fn load_prepared(path: PathBuf) -> Result { + let parsed = tokio::task::spawn_blocking(move || Self::parse_source(path)) + .await + .map_err(|error| ProxyError::Config(format!("config reader failed: {error}")))??; + parsed.prepare().await + } + + /// Rebuilds immutable credential lookup data for this configuration. pub(crate) fn rebuild_runtime_user_auth(&mut self) -> Result<()> { let snapshot = UserAuthSnapshot::from_users(&self.access.users)?; self.runtime_user_auth = Some(Arc::new(snapshot)); @@ -250,6 +292,7 @@ impl ProxyConfig { validate_web::validate_decoy_listener_separation(self) } + /// Returns authentication data owned by this configuration generation. pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> { self.runtime_user_auth.as_deref() } diff --git a/src/config/load/decode.rs b/src/config/load/decode.rs index d67ddd6..c23d6c6 100644 --- a/src/config/load/decode.rs +++ b/src/config/load/decode.rs @@ -1,5 +1,6 @@ use super::*; +/// Typed source together with its exact include graph and rendered input. pub(super) type DecodedSourceGraph = ( ProxyConfig, BTreeSet, @@ -7,6 +8,7 @@ pub(super) type DecodedSourceGraph = ( String, ); +/// Decodes source while retaining the metadata needed for atomic mutations. pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result { let ConfigSourceGraph { source_contents, @@ -17,6 +19,7 @@ pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result Result<()> { + if let Some(vhosts) = document + .get("web") + .and_then(|web| web.get("vhosts")) + .and_then(toml::Value::as_array) + { + for (idx, vhost) in vhosts.iter().enumerate() { + if let Some(decoy) = vhost.get("decoy") + && decoy.get("resolve").is_some() + && decoy.get("mode").and_then(toml::Value::as_str) != Some("http_upstream") + { + return Err(ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.resolve is only valid for mode=http_upstream" + ))); + } + } + } + Ok(()) +} + +/// Parses one origin and preserves the existing literal-address containment policy. +pub(super) fn parse_origin( + idx: usize, + upstream: &str, + resolve: WebDecoyResolve, +) -> Result { + let parsed = url::Url::parse(upstream).map_err(|error| { + ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream is invalid: {error}" + )) + })?; + if parsed.scheme() != "http" + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + || parsed.port() == Some(0) + { + return Err(ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment" + ))); + } + match parsed.host() { + Some(url::Host::Ipv4(ip)) => validate_address(idx, IpAddr::V4(ip))?, + Some(url::Host::Ipv6(ip)) => validate_address(idx, IpAddr::V6(ip))?, + Some(url::Host::Domain(_)) if resolve == WebDecoyResolve::Startup => {} + _ => { + return Err(ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream host must be a loopback or private IP literal unless resolve = \"startup\"" + ))); + } + } + Ok(parsed) +} + +fn validate_address(idx: usize, ip: IpAddr) -> Result<()> { + let private = match ip { + IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), + IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(), + }; + if !private { + return Err(ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream must remain inside loopback or a private network" + ))); + } + Ok(()) +} + +/// Returns all captured answers so non-selected addresses cannot hide a listener loop. +pub(super) fn addresses( + config: &ProxyConfig, + idx: usize, + parsed: &url::Url, + require_resolved: bool, +) -> Result> { + let port = parsed + .port_or_known_default() + .ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?; + let answers = match parsed.host() { + Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(IpAddr::V4(ip), port)], + Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(IpAddr::V6(ip), port)], + Some(url::Host::Domain(host)) => { + let key = (host.to_string(), port); + match config.web.decoy_dns.origins.get(&key) { + Some(answers) if !answers.is_empty() => answers.clone(), + _ if !require_resolved => return Ok(Vec::new()), + _ => { + return Err(ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream requires async preparation for resolve = \"startup\"" + ))); + } + } + } + _ => return Err(ProxyError::Config("WEB decoy host is missing".to_string())), + }; + for addr in &answers { + validate_address(idx, addr.ip())?; + if addr.port() != port { + return Err(ProxyError::Config( + "WEB decoy DNS snapshot port mismatch".to_string(), + )); + } + } + Ok(answers) +} + +/// Captures fresh resolver evidence without publishing any partially prepared configuration. +pub(super) async fn prepare(config: &mut ProxyConfig, mut lookup: F) -> Result<()> +where + F: FnMut(String, u16) -> Fut, + Fut: Future>>, +{ + let mut snapshot = WebDecoyDnsSnapshot::default(); + for (idx, vhost) in config.web.vhosts.iter().enumerate() { + let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else { + continue; + }; + let parsed = parse_origin(idx, upstream, *resolve)?; + let Some(url::Host::Domain(host)) = parsed.host() else { + continue; + }; + let port = parsed + .port_or_known_default() + .ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?; + let key = (host.to_string(), port); + if snapshot.origins.contains_key(&key) { + continue; + } + // Dropping this wait does not forcibly cancel a running OS resolver call. + let answers = tokio::time::timeout( + Duration::from_secs(config.web.timeouts.decoy_resolve_secs), + lookup(key.0.clone(), port), + ) + .await + .map_err(|_| { + ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream DNS resolution timed out after {} seconds", + config.web.timeouts.decoy_resolve_secs + )) + })? + .map_err(|error| { + ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream DNS resolution failed: {error}" + )) + })?; + if answers.is_empty() { + return Err(ProxyError::Config(format!( + "web.vhosts[{idx}].decoy.upstream DNS resolution returned no addresses" + ))); + } + for addr in &answers { + validate_address(idx, addr.ip())?; + } + snapshot.origins.insert(key, answers); + } + config.web.decoy_dns = Arc::new(snapshot); + validate_web::validate_decoy_listener_separation(config) +} + +/// Builds the unchanged request-time tuple from immutable configuration evidence. +pub(super) fn build_upstream( + config: &ProxyConfig, + idx: usize, + upstream: &str, + resolve: WebDecoyResolve, +) -> Result { + let parsed = parse_origin(idx, upstream, resolve)?; + let addr = addresses(config, idx, &parsed, true)? + .first() + .copied() + .ok_or_else(|| ProxyError::Config("WEB decoy DNS snapshot is empty".to_string()))?; + // URL authority keeps the configured hostname and brackets IPv6 literals. + let authority = parsed[url::Position::BeforeHost..url::Position::AfterPort].to_string(); + Ok(WebRuntimeDecoy::HttpUpstream { addr, authority }) +} + +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 { + config + .web + .runtime + .iter() + .flat_map(|runtime| runtime.vhosts.iter()) + .filter_map(|(host, vhost)| match vhost.decoy { + WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, addr)), + WebRuntimeDecoy::StaticDirectory(_) => None, + }) + } + selected(self).eq(selected(other)) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/config/load/decoy_dns/tests.rs b/src/config/load/decoy_dns/tests.rs new file mode 100644 index 0000000..cf64177 --- /dev/null +++ b/src/config/load/decoy_dns/tests.rs @@ -0,0 +1,330 @@ +use std::sync::atomic::{AtomicUsize, Ordering}; + +use super::*; + +const SOURCE: &str = r#" +[access.users] +alice = "000102030405060708090a0b0c0d0e0f" +[[server.listeners]] +ip = "127.0.0.1" +port = 18080 +transport = "web" +web_trusted_proxy_cidrs = ["127.0.0.1/32"] +[web] +enabled = true +[[web.vhosts]] +host = "proxy.example.com" +public_addr = "203.0.113.10:443" +[web.vhosts.decoy] +mode = "http_upstream" +upstream = "http://Example.COM:18081" +resolve = "startup" +[[web.vhosts.profiles]] +user = "alice" +secret_mode = "dd" +"#; + +fn source(text: &str) -> ParsedConfigSource { + pipeline::parse_source_graph(ConfigSourceGraph { + rendered: text.to_string(), + source_contents: BTreeMap::new(), + }) + .unwrap() +} + +async fn prepared(answers: &[&str]) -> LoadedConfig { + let mut parsed = source(SOURCE); + let answers: Vec = answers.iter().map(|addr| addr.parse().unwrap()).collect(); + prepare(&mut parsed.config, |_, _| { + std::future::ready(Ok(answers.clone())) + }) + .await + .unwrap(); + pipeline::finish(parsed).unwrap() +} + +fn endpoint(config: &ProxyConfig) -> (SocketAddr, &str) { + let runtime = config.web.runtime.as_ref().unwrap(); + match &runtime.vhosts["proxy.example.com"].decoy { + WebRuntimeDecoy::HttpUpstream { addr, authority } => (*addr, authority), + _ => panic!("expected HTTP decoy"), + } +} + +#[tokio::test] +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.decoy = WebDecoyConfig::HttpUpstream { + upstream: "http://example.com:18081/".to_string(), + resolve: WebDecoyResolve::Startup, + }; + parsed.config.web.vhosts.push(second); + let calls = AtomicUsize::new(0); + prepare(&mut parsed.config, |host, port| { + assert_eq!((host.as_str(), port), ("example.com", 18081)); + calls.fetch_add(1, Ordering::SeqCst); + std::future::ready(Ok(vec![ + "[fd00::2]:18081".parse().unwrap(), + "10.0.0.2:18081".parse().unwrap(), + ])) + }) + .await + .unwrap(); + let mut loaded = pipeline::finish(parsed).unwrap(); + for _ in 0..3 { + loaded.config.validate_effective_web().unwrap(); + loaded.config.rebuild_runtime_web().unwrap(); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + endpoint(&loaded.config), + ("[fd00::2]:18081".parse().unwrap(), "example.com:18081") + ); + assert_eq!( + loaded + .config + .web + .decoy_dns + .origins + .values() + .next() + .unwrap() + .len(), + 2 + ); + assert!( + !serde_json::to_string(&loaded.config) + .unwrap() + .contains("fd00::2") + ); +} + +#[tokio::test] +async fn decoy_dns_rejects_every_public_answer_including_non_selected() { + for disallowed in [ + "8.8.8.8:18081", + "[2001:4860:4860::8888]:18081", + "[::ffff:127.0.0.1]:18081", + ] { + let mut parsed = source(SOURCE); + let error = prepare(&mut parsed.config, |_, _| { + std::future::ready(Ok(vec![ + "127.0.0.2:18081".parse().unwrap(), + disallowed.parse().unwrap(), + ])) + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("must remain inside")); + assert!(parsed.config.web.runtime.is_none()); + } +} + +#[tokio::test] +async fn decoy_dns_rejects_non_selected_direct_and_wildcard_listener_loops() { + for listener in ["127.0.0.1", "0.0.0.0"] { + let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com:18080")); + parsed.config.server.listeners[0].ip = listener.parse().unwrap(); + let error = prepare(&mut parsed.config, |_, _| { + std::future::ready(Ok(vec![ + "[fd00::2]:18080".parse().unwrap(), + "127.0.0.1:18080".parse().unwrap(), + ])) + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("overlaps WEB listener")); + } +} + +#[tokio::test] +async fn decoy_dns_empty_answers_and_resolver_errors_fail_closed() { + let mut parsed = source(SOURCE); + let error = prepare(&mut parsed.config, |_, _| { + std::future::ready(Ok(Vec::new())) + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("returned no addresses")); + let error = prepare(&mut parsed.config, |_, _| { + std::future::ready(Err(std::io::Error::new( + std::io::ErrorKind::NotFound, + "test resolver failure", + ))) + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("DNS resolution failed")); +} + +#[tokio::test] +async fn decoy_dns_timeout_is_bounded() { + let mut parsed = source(SOURCE); + parsed.config.web.timeouts.decoy_resolve_secs = 1; + let error = tokio::time::timeout( + Duration::from_secs(3), + prepare(&mut parsed.config, |_, _| { + std::future::pending::>>() + }), + ) + .await + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("timed out after 1 seconds")); +} + +#[tokio::test] +async fn decoy_dns_literal_startup_never_looks_up_and_keeps_ipv6_authority() { + let mut parsed = source(&SOURCE.replace("Example.COM:18081", "[::1]:18081")); + prepare(&mut parsed.config, |_, _| { + std::future::ready(Err(std::io::Error::other("IP literals must not reach DNS"))) + }) + .await + .unwrap(); + let loaded = pipeline::finish(parsed).unwrap(); + assert_eq!( + endpoint(&loaded.config), + ("[::1]:18081".parse().unwrap(), "[::1]:18081") + ); +} + +#[test] +fn decoy_dns_source_parse_is_not_a_prepared_runtime() { + let parsed = source(SOURCE); + assert!(parsed.config.web.runtime.is_none()); + assert!(parsed.config.web.decoy_dns.origins.is_empty()); + assert!( + pipeline::finish(parsed) + .unwrap_err() + .to_string() + .contains("requires async preparation") + ); +} + +#[tokio::test] +async fn decoy_dns_evidence_is_bound_to_origin_and_port() { + let loaded = prepared(&["10.0.0.2:18081"]).await; + for changed in ["http://telekom.com:18081", "http://example.com:18082"] { + let mut config = loaded.config.clone(); + config.web.vhosts[0].decoy = WebDecoyConfig::HttpUpstream { + upstream: changed.to_string(), + resolve: WebDecoyResolve::Startup, + }; + assert!( + config + .validate_effective_web() + .unwrap_err() + .to_string() + .contains("requires async preparation") + ); + assert!(config.rebuild_runtime_web().is_err()); + } +} + +#[tokio::test] +async fn decoy_dns_reload_pins_old_generation_and_detects_dns_only_changes() { + let old = prepared(&["10.0.0.2:18081"]).await; + let new = prepared(&["10.0.0.3:18081"]).await; + assert_eq!(old.rendered_hash, new.rendered_hash); + assert_eq!( + serde_json::to_value(&old.config).unwrap(), + serde_json::to_value(&new.config).unwrap() + ); + let resolved = + crate::maestro::runtime_build::resolve_reload_config(&old.config, &new.config).unwrap(); + assert!(resolved.runtime_changed); + assert!(!old.config.web_decoy_endpoints_equal(&new.config)); + assert_eq!(endpoint(&old.config).0, "10.0.0.2:18081".parse().unwrap()); + let same = prepared(&["10.0.0.3:18081", "10.0.0.4:18081"]).await; + assert!(same.config.web_decoy_endpoints_equal(&new.config)); +} + +#[tokio::test] +async fn decoy_dns_effective_listener_overlay_rechecks_every_answer() { + let old = prepared(&["10.0.0.2:18081"]).await; + let mut parsed = source(SOURCE); + parsed.config.server.listeners[0].port = Some(18082); + parsed.config.web.vhosts[0].decoy = WebDecoyConfig::HttpUpstream { + upstream: "http://example.com:18080".to_string(), + resolve: WebDecoyResolve::Startup, + }; + prepare(&mut parsed.config, |_, _| { + std::future::ready(Ok(vec![ + "10.0.0.2:18080".parse().unwrap(), + "127.0.0.1:18080".parse().unwrap(), + ])) + }) + .await + .unwrap(); + let desired = pipeline::finish(parsed).unwrap(); + assert!( + crate::maestro::runtime_build::resolve_reload_config(&old.config, &desired.config) + .err() + .unwrap() + .contains("overlaps WEB listener") + ); +} + +#[tokio::test] +async fn decoy_dns_rejects_ipv6_direct_and_wildcard_listener_loops() { + for listener in ["::1", "::"] { + let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com:18080")); + parsed.config.network.ipv6 = Some(true); + parsed.config.server.listeners[0].ip = listener.parse().unwrap(); + let error = prepare(&mut parsed.config, |_, _| { + std::future::ready(Ok(vec![ + "10.0.0.2:18080".parse().unwrap(), + "[::1]:18080".parse().unwrap(), + ])) + }) + .await + .unwrap_err(); + assert!(error.to_string().contains("overlaps WEB listener")); + } +} + +#[test] +fn decoy_dns_startup_does_not_relax_origin_url_rules() { + for url in [ + "https://example.com", + "http://user@example.com", + "http://user:pass@example.com", + "http://example.com/path", + "http://example.com/?q=1", + "http://example.com/#fragment", + "http://example.com:0", + ] { + assert!( + parse_origin(0, url, WebDecoyResolve::Startup).is_err(), + "accepted {url}" + ); + } +} + +#[tokio::test] +async fn decoy_dns_default_port_authority_and_evidence_port_fence() { + let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com")); + prepare(&mut parsed.config, |_, port| { + assert_eq!(port, 80); + std::future::ready(Ok(vec!["127.0.0.2:80".parse().unwrap()])) + }) + .await + .unwrap(); + let loaded = pipeline::finish(parsed).unwrap(); + assert_eq!(endpoint(&loaded.config).1, "example.com"); + let mut config = loaded.config.clone(); + Arc::make_mut(&mut config.web.decoy_dns).origins.insert( + ("example.com".to_string(), 80), + vec!["127.0.0.2:81".parse().unwrap()], + ); + assert!( + config + .validate_effective_web() + .unwrap_err() + .to_string() + .contains("port mismatch") + ); + assert!(config.rebuild_runtime_web().is_err()); +} diff --git a/src/config/load/effective.rs b/src/config/load/effective.rs index 2997e15..e29ab1f 100644 --- a/src/config/load/effective.rs +++ b/src/config/load/effective.rs @@ -1,5 +1,6 @@ use super::*; +/// Normalizes source policy and authentication data before external decoy preparation. pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { // Normalize optional TLS fetch scope: whitespace-only values disable scoped routing. config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string(); @@ -217,6 +218,5 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> { validate_logging_config(&config.logging)?; validate_upstreams(config)?; config.rebuild_runtime_user_auth()?; - config.rebuild_runtime_web()?; Ok(()) } diff --git a/src/config/load/pipeline.rs b/src/config/load/pipeline.rs index b035ac8..c578cdf 100644 --- a/src/config/load/pipeline.rs +++ b/src/config/load/pipeline.rs @@ -1,18 +1,51 @@ use super::*; +/// Completes legacy synchronous loads without performing hostname resolution. pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result { + finish(parse_source_graph(graph)?) +} + +/// Normalizes source while leaving DNS and immutable site acquisition to preparation. +pub(super) fn parse_source_graph(graph: ConfigSourceGraph) -> Result { let (mut config, source_files, source_contents, processed) = decode::decode_source_graph(graph)?; validate_core::validate(&mut config)?; validate_runtime::validate(&mut config)?; validate_me::validate(&mut config)?; validate_server::validate(&mut config)?; - validate_web::validate(&mut config)?; + validate_web::validate_source(&mut config)?; effective::apply(&mut config)?; - Ok(LoadedConfig { + Ok(ParsedConfigSource { config, source_files: source_files.into_iter().collect(), source_contents, rendered_hash: hash_rendered_snapshot(&processed), }) } + +/// Completes synchronous validation and static acquisition after DNS is captured. +pub(super) fn finish(mut parsed: ParsedConfigSource) -> Result { + parsed.config.validate_effective_web()?; + parsed.config.rebuild_runtime_web()?; + Ok(LoadedConfig { + config: parsed.config, + source_files: parsed.source_files, + source_contents: parsed.source_contents, + rendered_hash: parsed.rendered_hash, + }) +} + +impl ParsedConfigSource { + /// Resolves each unique decoy origin once, then builds a fully validated candidate. + pub(crate) async fn prepare(mut self) -> Result { + decoy_dns::prepare(&mut self.config, |host, port| async move { + tokio::net::lookup_host((host.as_str(), port)) + .await + .map(|answers| answers.collect()) + }) + .await?; + tokio::task::spawn_blocking(move || finish(self)) + .await + .map_err(|error| ProxyError::Config(format!("config preparation failed: {error}")))? + } +} diff --git a/src/config/load/runtime_web.rs b/src/config/load/runtime_web.rs index 90698cb..e7abf2c 100644 --- a/src/config/load/runtime_web.rs +++ b/src/config/load/runtime_web.rs @@ -47,8 +47,10 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> { let mut static_bytes = 0usize; let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into(); - for vhost in &config.web.vhosts { + for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() { let decoy = build_decoy( + config, + vhost_idx, vhost, &config.web.limits, &mut static_files, @@ -182,42 +184,16 @@ fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) { } fn build_decoy( + config: &ProxyConfig, + vhost_idx: usize, vhost: &WebVhostConfig, limits: &WebLimitsConfig, static_files: &mut usize, static_bytes: &mut usize, ) -> Result { match &vhost.decoy { - WebDecoyConfig::HttpUpstream { upstream } => { - let parsed = url::Url::parse(upstream).map_err(|error| { - ProxyError::Config(format!( - "WEB decoy upstream for `{}` is invalid: {error}", - vhost.host - )) - })?; - let ip = match parsed.host() { - Some(url::Host::Ipv4(ip)) => std::net::IpAddr::V4(ip), - Some(url::Host::Ipv6(ip)) => std::net::IpAddr::V6(ip), - _ => { - return Err(ProxyError::Config( - "WEB decoy host must be an IP literal".to_string(), - )); - } - }; - let host = ip.to_string(); - let port = parsed.port_or_known_default().ok_or_else(|| { - ProxyError::Config("WEB decoy port cannot be resolved".to_string()) - })?; - let authority = match (ip, parsed.port()) { - (std::net::IpAddr::V6(_), Some(_)) => format!("[{host}]:{port}"), - (std::net::IpAddr::V6(_), None) => format!("[{host}]"), - (std::net::IpAddr::V4(_), Some(_)) => format!("{host}:{port}"), - (std::net::IpAddr::V4(_), None) => host.clone(), - }; - Ok(WebRuntimeDecoy::HttpUpstream { - addr: SocketAddr::new(ip, port), - authority, - }) + WebDecoyConfig::HttpUpstream { upstream, resolve } => { + decoy_dns::build_upstream(config, vhost_idx, upstream, *resolve) } WebDecoyConfig::StaticDirectory { directory, index } => { let site = load_static_site(directory, index, limits, static_files, static_bytes)?; diff --git a/src/config/load/strict_keys.rs b/src/config/load/strict_keys.rs index e02c5db..171896d 100644 --- a/src/config/load/strict_keys.rs +++ b/src/config/load/strict_keys.rs @@ -365,10 +365,11 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[ "http_overload_timeout_ms", "shutdown_secs", "decoy_header_secs", + "decoy_resolve_secs", ]; const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "base_path", "public_addr", "decoy", "profiles"]; -const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"]; +const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index", "resolve"]; const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[ "user", "secret_mode", diff --git a/src/config/load/validate_web.rs b/src/config/load/validate_web.rs index 76a51ac..dcb6609 100644 --- a/src/config/load/validate_web.rs +++ b/src/config/load/validate_web.rs @@ -27,6 +27,12 @@ const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024; /// Validates WEB policy and resource bounds before building runtime state. pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { + validate_source(config)?; + validate_decoy_listener_separation(config) +} + +/// Validates source policy without requiring DNS evidence for opted-in origins. +pub(super) fn validate_source(config: &mut ProxyConfig) -> Result<()> { let web_listener_count = config .server .listeners @@ -83,12 +89,19 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> { timeouts::validate(&config.web.timeouts)?; websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?; validate_vhosts(config)?; - validate_decoy_listener_separation(config)?; + validate_decoy_listener_separation_inner(config, false)?; Ok(()) } /// Rejects a direct decoy recursion into an effective WEB listener. pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result<()> { + validate_decoy_listener_separation_inner(config, true) +} + +fn validate_decoy_listener_separation_inner( + config: &ProxyConfig, + require_resolved: bool, +) -> Result<()> { let web_listeners = config .server .listeners @@ -101,30 +114,19 @@ pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result .map(|listener| SocketAddr::new(listener.ip, listener.port.unwrap_or(config.server.port))) .collect::>(); for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() { - let WebDecoyConfig::HttpUpstream { upstream } = &vhost.decoy else { + let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else { continue; }; - let parsed = url::Url::parse(upstream).map_err(|error| { - ProxyError::Config(format!( - "web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}" - )) - })?; - let Some(port) = parsed.port_or_known_default() else { - continue; - }; - let upstream_ip = match parsed.host() { - Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip), - Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip), - _ => continue, - }; - let upstream_addr = SocketAddr::new(upstream_ip, port); - if web_listeners - .iter() - .any(|listener| listener_covers(*listener, upstream_addr)) - { - return config_error(&format!( - "web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}" - )); + let parsed = decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?; + for upstream_addr in decoy_dns::addresses(config, vhost_idx, &parsed, require_resolved)? { + if web_listeners + .iter() + .any(|listener| listener_covers(*listener, upstream_addr)) + { + return config_error(&format!( + "web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}" + )); + } } } Ok(()) diff --git a/src/config/load/validate_web/timeouts.rs b/src/config/load/validate_web/timeouts.rs index a301a32..0a9e9aa 100644 --- a/src/config/load/validate_web/timeouts.rs +++ b/src/config/load/validate_web/timeouts.rs @@ -26,6 +26,7 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> { ("http_idle_secs", timeouts.http_idle_secs), ("shutdown_secs", timeouts.shutdown_secs), ("decoy_header_secs", timeouts.decoy_header_secs), + ("decoy_resolve_secs", timeouts.decoy_resolve_secs), ]; if let Some((field, _)) = values .into_iter() diff --git a/src/config/load/validate_web/vhosts.rs b/src/config/load/validate_web/vhosts.rs index 571d4fa..d3a8527 100644 --- a/src/config/load/validate_web/vhosts.rs +++ b/src/config/load/validate_web/vhosts.rs @@ -150,45 +150,8 @@ pub(super) fn web_host_last_label_is_numeric(host: &str) -> bool { pub(super) fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> { match decoy { - WebDecoyConfig::HttpUpstream { upstream } => { - let parsed = url::Url::parse(upstream).map_err(|error| { - ProxyError::Config(format!( - "web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}" - )) - })?; - if parsed.scheme() != "http" - || parsed.host_str().is_none() - || !parsed.username().is_empty() - || parsed.password().is_some() - || parsed.query().is_some() - || parsed.fragment().is_some() - || parsed.path() != "/" - || parsed.port() == Some(0) - { - return config_error(&format!( - "web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment" - )); - } - let ip = match parsed.host() { - Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip), - Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip), - _ => { - return config_error(&format!( - "web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal" - )); - } - }; - let private = match ip { - IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(), - IpAddr::V6(ip) => { - ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local() - } - }; - if !private { - return config_error(&format!( - "web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network" - )); - } + WebDecoyConfig::HttpUpstream { upstream, resolve } => { + decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?; } WebDecoyConfig::StaticDirectory { directory, index } => { if !directory.is_absolute() { diff --git a/src/config/mod.rs b/src/config/mod.rs index f073d51..e25594a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -6,5 +6,5 @@ mod load; mod types; pub use load::ProxyConfig; -pub(crate) use load::{ConfigSourceGraph, LoadedConfig}; +pub(crate) use load::{ConfigSourceGraph, LoadedConfig, ParsedConfigSource}; pub use types::*; diff --git a/src/config/tests/load_basic_tests/web_tests.rs b/src/config/tests/load_basic_tests/web_tests.rs index 16b085f..976b171 100644 --- a/src/config/tests/load_basic_tests/web_tests.rs +++ b/src/config/tests/load_basic_tests/web_tests.rs @@ -9,6 +9,8 @@ mod carrier_method_tests; #[path = "web_tests/conveyor_tests.rs"] mod conveyor_tests; +#[path = "web_tests/decoy_dns_tests.rs"] +mod decoy_dns_tests; const WEB_CONFIG: &str = r#" [access.users] diff --git a/src/config/tests/load_basic_tests/web_tests/decoy_dns_tests.rs b/src/config/tests/load_basic_tests/web_tests/decoy_dns_tests.rs new file mode 100644 index 0000000..dc3bbb1 --- /dev/null +++ b/src/config/tests/load_basic_tests/web_tests/decoy_dns_tests.rs @@ -0,0 +1,57 @@ +use super::*; + +#[test] +fn decoy_dns_resolution_defaults_to_never() { + let decoy: WebDecoyConfig = serde_json::from_value(serde_json::json!({ + "mode": "http_upstream", + "upstream": "http://127.0.0.1:18081" + })) + .unwrap(); + let serialized = serde_json::to_value(decoy).unwrap(); + assert_eq!(serialized["resolve"], "never"); +} + +#[test] +fn decoy_dns_timeout_defaults_to_five_seconds() { + let serialized = serde_json::to_value(WebTimeoutsConfig::default()).unwrap(); + assert_eq!(serialized["decoy_resolve_secs"], 5); +} + +#[test] +fn decoy_dns_resolution_is_opt_in_and_mode_specific() { + let hostname = WEB_CONFIG.replace("127.0.0.1:18081", "example.com:18081"); + assert!(load_config_error_from_temp_toml(&hostname).contains("IP literal")); + let never = hostname.replace( + "mode = \"http_upstream\"", + "mode = \"http_upstream\"\nresolve = \"never\"", + ); + assert!(load_config_error_from_temp_toml(&never).contains("IP literal")); + let unknown = WEB_CONFIG.replace( + "mode = \"http_upstream\"", + "mode = \"http_upstream\"\nresolve = \"always\"", + ); + assert!(load_config_error_from_temp_toml(&unknown).contains("unknown variant")); + let wrong_mode = WEB_CONFIG.replace( + "mode = \"http_upstream\"", + "mode = \"static_directory\"\nresolve = \"startup\"\ndirectory = \"/tmp\"", + ); + assert!( + load_config_error_from_temp_toml(&wrong_mode).contains("only valid for mode=http_upstream") + ); +} + +#[test] +fn decoy_dns_timeout_bounds_are_independent_from_request_deadlines() { + for seconds in [0, 3601] { + let text = format!("{WEB_CONFIG}\n[web.timeouts]\ndecoy_resolve_secs = {seconds}\n"); + assert!(load_config_error_from_temp_toml(&text).contains("decoy_resolve_secs")); + } + let text = format!("{WEB_CONFIG}\n[web.timeouts]\ndecoy_resolve_secs = 3600\n"); + assert_eq!( + load_config_from_temp_toml(&text) + .web + .timeouts + .decoy_resolve_secs, + 3600 + ); +} diff --git a/src/config/types.rs b/src/config/types.rs index c4973ea..16e9764 100644 --- a/src/config/types.rs +++ b/src/config/types.rs @@ -54,12 +54,12 @@ pub use server::{ #[allow(unused_imports)] pub use web::{ WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode, - WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig, WebSecretMode, - WebTimeoutsConfig, WebVhostConfig, + WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig, + WebSecretMode, WebTimeoutsConfig, WebVhostConfig, }; pub(crate) use web::{ - WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset, - WebStaticSite, + WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, + WebStaticAsset, WebStaticSite, }; pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES; #[allow(unused_imports)] diff --git a/src/config/types/web.rs b/src/config/types/web.rs index 4aaf874..8b4514f 100644 --- a/src/config/types/web.rs +++ b/src/config/types/web.rs @@ -18,6 +18,10 @@ pub use fasttrack::WebDecoyFastTrackMode; // Accepted-socket overload policy remains separate from the bulky WEB data model. mod overload; pub use overload::WebHttpConnectionCapacityAction; +// DNS evidence belongs to configuration generations, never request-time routing. +mod decoy_dns; +pub(crate) use decoy_dns::WebDecoyDnsSnapshot; +pub use decoy_dns::WebDecoyResolve; /// Client-facing secret representation used to derive a WEB capability. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -55,6 +59,9 @@ pub enum WebDecoyConfig { HttpUpstream { /// Origin URL without a query or fragment. upstream: String, + /// Opt-in configuration-time resolution; literal addresses never require DNS. + #[serde(default)] + resolve: WebDecoyResolve, }, /// Serve an immutable, bounded snapshot of a local directory. StaticDirectory { @@ -358,6 +365,9 @@ pub struct WebTimeoutsConfig { /// Deadline for connecting to and receiving headers from an HTTP decoy. #[serde(default = "default_web_decoy_header_timeout_secs")] pub decoy_header_secs: u64, + /// Maximum wait for each unique HTTP decoy hostname during preparation. + #[serde(default = "default_web_decoy_resolve_secs")] + pub decoy_resolve_secs: u64, } impl Default for WebTimeoutsConfig { @@ -387,6 +397,7 @@ impl Default for WebTimeoutsConfig { http_overload_timeout_ms: default_web_http_overload_timeout_ms(), shutdown_secs: default_web_shutdown_secs(), decoy_header_secs: default_web_decoy_header_timeout_secs(), + decoy_resolve_secs: default_web_decoy_resolve_secs(), } } } @@ -449,6 +460,9 @@ pub struct WebConfig { /// Validated immutable runtime snapshot built during configuration loading. #[serde(skip)] pub(crate) runtime: Option>, + /// Immutable DNS evidence omitted from serialized configuration and revisions. + #[serde(skip)] + pub(crate) decoy_dns: Arc, } impl WebConfig { @@ -489,6 +503,7 @@ impl Default for WebConfig { timeouts: WebTimeoutsConfig::default(), vhosts: Vec::new(), runtime: None, + decoy_dns: Arc::default(), } } } diff --git a/src/config/types/web/decoy_dns.rs b/src/config/types/web/decoy_dns.rs new file mode 100644 index 0000000..6a2be6c --- /dev/null +++ b/src/config/types/web/decoy_dns.rs @@ -0,0 +1,22 @@ +use std::collections::BTreeMap; +use std::net::SocketAddr; + +use serde::{Deserialize, Serialize}; + +/// Configuration-time DNS policy for an HTTP decoy origin. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum WebDecoyResolve { + /// Accept IP literals only, without consulting DNS. + #[default] + Never, + /// Pin validated DNS answers for the lifetime of the prepared configuration. + Startup, +} + +/// Complete resolver evidence, retained for validation against effective listeners. +#[derive(Debug, Clone, Default)] +pub(crate) struct WebDecoyDnsSnapshot { + /// Resolver-order answers keyed by canonical URL hostname and effective port. + pub(crate) origins: BTreeMap<(String, u16), Vec>, +} diff --git a/src/config/types/web/defaults.rs b/src/config/types/web/defaults.rs index 8680e80..0796fda 100644 --- a/src/config/types/web/defaults.rs +++ b/src/config/types/web/defaults.rs @@ -115,3 +115,4 @@ u64_default!(default_web_http_idle_secs, 75); u64_default!(default_web_http_overload_timeout_ms, 250); u64_default!(default_web_shutdown_secs, 15); u64_default!(default_web_decoy_header_timeout_secs, 30); +u64_default!(default_web_decoy_resolve_secs, 5); diff --git a/src/healthcheck.rs b/src/healthcheck.rs index 811fc73..055d939 100644 --- a/src/healthcheck.rs +++ b/src/healthcheck.rs @@ -8,13 +8,17 @@ use crate::config::ProxyConfig; const HEALTHCHECK_RESPONSE_MAX_BYTES: u64 = 64 * 1024; +/// API health contract selected by the standalone healthcheck command. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum HealthcheckMode { + /// Require a responsive API process. Liveness, + /// Require the active runtime to report readiness. Ready, } impl HealthcheckMode { + /// Parses the supported command-line healthcheck modes. pub(crate) fn from_cli_arg(value: &str) -> Option { match value { "liveness" => Some(Self::Liveness), @@ -31,6 +35,7 @@ impl HealthcheckMode { } } +/// Runs the bounded API healthcheck without preparing a new proxy generation. pub(crate) fn run(config_path: &str, mode: HealthcheckMode) -> i32 { match run_inner(config_path, mode) { Ok(()) => 0, @@ -42,8 +47,9 @@ pub(crate) fn run(config_path: &str, mode: HealthcheckMode) -> i32 { } fn run_inner(config_path: &str, mode: HealthcheckMode) -> Result<(), String> { - let config = - ProxyConfig::load(config_path).map_err(|error| format!("config load failed: {error}"))?; + let config = ProxyConfig::parse_source(config_path) + .map_err(|error| format!("config load failed: {error}"))? + .config; let api_cfg = &config.server.api; if !api_cfg.enabled { return Ok(()); @@ -229,4 +235,26 @@ mod tests { assert!(read_response_bounded(&mut payload.as_slice()).is_err()); } + + #[test] + fn decoy_dns_healthcheck_reads_source_without_resolution() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write( + &path, + r#" +[server.api] +enabled = false +[[web.vhosts]] +host = "proxy.example.com" +public_addr = "203.0.113.10:443" +[web.vhosts.decoy] +mode = "http_upstream" +upstream = "http://unresolvable.example.invalid:8080" +resolve = "startup" +"#, + ) + .unwrap(); + super::run_inner(path.to_str().unwrap(), HealthcheckMode::Liveness).unwrap(); + } } diff --git a/src/maestro/bootstrap.rs b/src/maestro/bootstrap.rs index f938cd4..f8931ba 100644 --- a/src/maestro/bootstrap.rs +++ b/src/maestro/bootstrap.rs @@ -95,8 +95,8 @@ pub(super) async fn bootstrap( std::process::exit(1); } - let mut config = match ProxyConfig::load(&config_path) { - Ok(c) => c, + let mut config = match ProxyConfig::load_prepared(config_path.clone()).await { + Ok(loaded) => loaded.config, Err(e) => { if config_path.exists() { eprintln!("[telemt] Error: {}", e); diff --git a/src/maestro/runtime_build.rs b/src/maestro/runtime_build.rs index dd6edc5..19bb0b6 100644 --- a/src/maestro/runtime_build.rs +++ b/src/maestro/runtime_build.rs @@ -512,6 +512,7 @@ pub(crate) fn deferred_process_fields( fn configs_equal(old: &ProxyConfig, new: &ProxyConfig) -> bool { serde_json::to_value(old).ok() == serde_json::to_value(new).ok() + && old.web_decoy_endpoints_equal(new) } #[cfg(test)] diff --git a/src/web/http/decoy.rs b/src/web/http/decoy.rs index d7dfe07..b87ed0b 100644 --- a/src/web/http/decoy.rs +++ b/src/web/http/decoy.rs @@ -345,6 +345,10 @@ fn remove_hop_by_hop(headers: &mut hyper::HeaderMap) { } } +#[cfg(test)] +#[path = "decoy_dns_tests.rs"] +mod decoy_dns_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/src/web/http/decoy_dns_tests.rs b/src/web/http/decoy_dns_tests.rs new file mode 100644 index 0000000..fa1178f --- /dev/null +++ b/src/web/http/decoy_dns_tests.rs @@ -0,0 +1,55 @@ +use super::*; + +use arc_swap::ArcSwap; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +use crate::config::WebCarrier; +use crate::maestro::generation::test_runtime_generation; +use crate::web::http::tests::{request, runtime_config}; + +#[tokio::test] +async fn decoy_dns_pinned_socket_keeps_configured_hostname_in_actual_http_request() { + let origin = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = runtime_config([17; 32], WebCarrier::Https); + let snapshot = Arc::get_mut(config.web.runtime.as_mut().unwrap()).unwrap(); + let vhost = Arc::get_mut(snapshot.vhosts.get_mut("proxy.example.com").unwrap()).unwrap(); + // A public hostname deliberately paired with a loopback socket proves no request-time lookup. + vhost.decoy = WebRuntimeDecoy::HttpUpstream { + addr: origin.local_addr().unwrap(), + authority: "bsi.bund.de:8080".to_string(), + }; + let generation = test_runtime_generation(1, config); + let runtime = WebProcessRuntime::start(Arc::new(ArcSwap::from(generation.clone()))); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let receive = async { + let (mut socket, _) = origin.accept().await.unwrap(); + let mut bytes = Vec::new(); + let mut buf = [0; 1024]; + while !bytes.windows(4).any(|part| part == b"\r\n\r\n") { + let count = socket.read(&mut buf).await.unwrap(); + assert_ne!(count, 0); + bytes.extend_from_slice(&buf[..count]); + } + socket + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK") + .await + .unwrap(); + String::from_utf8(bytes).unwrap() + }; + let (response, upstream) = tokio::time::timeout(std::time::Duration::from_secs(5), async { + tokio::join!(request(&listener, &runtime, b"GET /site?q=1 HTTP/1.1\r\nHost: proxy.example.com\r\nX-Forwarded-For: 192.0.2.10\r\nConnection: close\r\n\r\n".to_vec()), receive) + }).await.unwrap(); + assert!(upstream.starts_with("GET /site?q=1 HTTP/1.1\r\n")); + assert!( + upstream + .to_ascii_lowercase() + .contains("\r\nhost: bsi.bund.de:8080\r\n") + ); + assert!(response.starts_with(b"HTTP/1.1 200")); + assert!(response.ends_with(b"OK")); + runtime.shutdown().await; + generation.stop_sessions().await; + generation.stop_background_tasks().await; +}