mirror of
https://github.com/telemt/telemt.git
synced 2026-10-27 19:17:44 +03:00
Merge pull request #948 from telemt/web-resolve
WEB: Resolve opt-in decoy hostnames during config preparation
This commit is contained in:
Generated
+1
-1
@@ -2780,7 +2780,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "telemt"
|
||||
version = "3.5.13"
|
||||
version = "3.5.14"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "telemt"
|
||||
version = "3.5.13"
|
||||
version = "3.5.14"
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(¤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));
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
@@ -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,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(())
|
||||
}
|
||||
|
||||
@@ -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}")))?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>,
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user