Merge pull request #948 from telemt/web-resolve

WEB: Resolve opt-in decoy hostnames during config preparation
This commit is contained in:
Alexey
2026-10-04 14:43:23 +03:00
committed by GitHub
32 changed files with 1253 additions and 170 deletions
Generated
+1 -1
View File
@@ -2780,7 +2780,7 @@ dependencies = [
[[package]]
name = "telemt"
version = "3.5.13"
version = "3.5.14"
dependencies = [
"aes",
"anyhow",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "telemt"
version = "3.5.13"
version = "3.5.14"
edition = "2024"
rust-version = "1.88"
+9 -3
View File
@@ -158,7 +158,7 @@ async fn prepare_patch_to_path(
) -> Result<PreparedConfigPatch, ApiFailure> {
// 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;
+105
View File
@@ -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);
}
+27 -15
View File
@@ -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<PathBuf, String>) -> 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<LoadedConfig, ApiFailure> {
) -> Result<ParsedConfigSource, ApiFailure> {
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<PathBuf, ApiFailure> {
@@ -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<PathBuf, String>,
@@ -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<ProxyConfig, ApiFailure> {
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))
}
+2 -2
View File
@@ -113,7 +113,7 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
expected_revision: Option<&str>,
) -> Result<String, ApiFailure> {
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,
+43 -42
View File
@@ -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<PathBuf>,
@@ -132,9 +135,21 @@ fn apply_watch_manifest<W1: Watcher, W2: Watcher>(
}
}
/// 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<ProxyConfig> {
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<Arc<ProxyConfig>>,
log_tx: &watch::Sender<LogLevel>,
detected_ip_v4: Option<IpAddr>,
@@ -142,13 +157,7 @@ fn reload_config_with_resolver(
reload_state: &mut ReloadState,
dns_resolver: Option<&crate::network::dns_overrides::GenerationDnsResolver>,
) -> Option<WatchManifest> {
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<IpAddr>,
reload_state: &mut ReloadState,
) -> Option<WatchManifest> {
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;
@@ -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(&current, &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));
}
@@ -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<crate::config::ParsedConfigSource> {
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<Arc<ProxyConfig>>,
log_tx: &watch::Sender<LogLevel>,
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
reload_state: &mut ReloadState,
dns_resolver: Option<Arc<crate::network::dns_overrides::GenerationDnsResolver>>,
force: bool,
) -> Option<WatchManifest> {
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)
}
}
}
+43
View File
@@ -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<PathBuf>,
/// Exact source documents used for revisions and atomic mutation checks.
pub(crate) source_contents: BTreeMap<PathBuf, String>,
/// 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<P: AsRef<Path>>(path: P) -> Result<ParsedConfigSource> {
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<P: AsRef<Path>>(
path: P,
overrides: &BTreeMap<PathBuf, String>,
) -> Result<ParsedConfigSource> {
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<LoadedConfig> {
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()
}
+3
View File
@@ -1,5 +1,6 @@
use super::*;
/// Typed source together with its exact include graph and rendered input.
pub(super) type DecodedSourceGraph = (
ProxyConfig,
BTreeSet<PathBuf>,
@@ -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<DecodedSourceGraph> {
let ConfigSourceGraph {
source_contents,
@@ -17,6 +19,7 @@ pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSou
let parsed_toml: toml::Value =
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
handle_unknown_config_keys(&parsed_toml)?;
ProxyConfig::validate_decoy_source_keys(&parsed_toml)?;
let general_table = parsed_toml
.get("general")
.and_then(|value| value.as_table());
+204
View File
@@ -0,0 +1,204 @@
use std::future::Future;
use std::time::Duration;
use super::*;
/// Rejects mode-specific keys even when unknown-key handling is permissive.
pub(super) fn validate_mode_keys(document: &toml::Value) -> 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<url::Url> {
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<Vec<SocketAddr>> {
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<F, Fut>(config: &mut ProxyConfig, mut lookup: F) -> Result<()>
where
F: FnMut(String, u16) -> Fut,
Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
{
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<WebRuntimeDecoy> {
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<Item = (&String, SocketAddr)> {
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;
+330
View File
@@ -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<SocketAddr> = 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::<std::io::Result<Vec<SocketAddr>>>()
}),
)
.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());
}
+1 -1
View File
@@ -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(())
}
+35 -2
View File
@@ -1,18 +1,51 @@
use super::*;
/// Completes legacy synchronous loads without performing hostname resolution.
pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
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<ParsedConfigSource> {
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<LoadedConfig> {
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<LoadedConfig> {
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}")))?
}
}
+7 -31
View File
@@ -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<WebRuntimeDecoy> {
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)?;
+2 -1
View File
@@ -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",
+25 -23
View File
@@ -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::<Vec<_>>();
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(())
+1
View File
@@ -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()
+2 -39
View File
@@ -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() {
+1 -1
View File
@@ -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::*;
@@ -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]
@@ -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
);
}
+4 -4
View File
@@ -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)]
+15
View File
@@ -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<Arc<WebRuntimeConfig>>,
/// Immutable DNS evidence omitted from serialized configuration and revisions.
#[serde(skip)]
pub(crate) decoy_dns: Arc<WebDecoyDnsSnapshot>,
}
impl WebConfig {
@@ -489,6 +503,7 @@ impl Default for WebConfig {
timeouts: WebTimeoutsConfig::default(),
vhosts: Vec::new(),
runtime: None,
decoy_dns: Arc::default(),
}
}
}
+22
View File
@@ -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<SocketAddr>>,
}
+1
View File
@@ -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);
+30 -2
View File
@@ -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<Self> {
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();
}
}
+2 -2
View File
@@ -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);
+1
View File
@@ -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)]
+4
View File
@@ -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::*;
+55
View File
@@ -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;
}