mirror of
https://github.com/telemt/telemt.git
synced 2026-10-31 21:02:01 +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]]
|
[[package]]
|
||||||
name = "telemt"
|
name = "telemt"
|
||||||
version = "3.5.13"
|
version = "3.5.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes",
|
"aes",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "telemt"
|
name = "telemt"
|
||||||
version = "3.5.13"
|
version = "3.5.14"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.88"
|
rust-version = "1.88"
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ async fn prepare_patch_to_path(
|
|||||||
) -> Result<PreparedConfigPatch, ApiFailure> {
|
) -> Result<PreparedConfigPatch, ApiFailure> {
|
||||||
// 1. optimistic concurrency
|
// 1. optimistic concurrency
|
||||||
let loaded = load_config_snapshot(config_path, false).await?;
|
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) {
|
if expected_revision.is_some_and(|expected| expected != current) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
hyper::StatusCode::CONFLICT,
|
hyper::StatusCode::CONFLICT,
|
||||||
@@ -208,6 +208,8 @@ async fn prepare_patch_to_path(
|
|||||||
let mut merged = Toml::try_from(&old_cfg)
|
let mut merged = Toml::try_from(&old_cfg)
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
|
||||||
deep_merge(&mut merged, &patch_toml);
|
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
|
let requested_cfg: ProxyConfig = merged
|
||||||
.clone()
|
.clone()
|
||||||
@@ -263,7 +265,7 @@ async fn prepare_patch_to_path(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. classify the validated, normalized candidate.
|
// 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 new_cfg = candidate.config;
|
||||||
let class = classify_config_changes(&old_cfg, &new_cfg);
|
let class = classify_config_changes(&old_cfg, &new_cfg);
|
||||||
let deferred_process_fields =
|
let deferred_process_fields =
|
||||||
@@ -306,7 +308,7 @@ fn reload_submit_failure(error: ReloadSubmitError) -> ApiFailure {
|
|||||||
/// Returns only the editable config sections and current revision.
|
/// Returns only the editable config sections and current revision.
|
||||||
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
|
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
|
||||||
let loaded = load_config_snapshot(config_path, false).await?;
|
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)
|
let parsed = Toml::try_from(&loaded.config)
|
||||||
.map_err(|error| ApiFailure::internal(format!("failed to serialize config: {error}")))?;
|
.map_err(|error| ApiFailure::internal(format!("failed to serialize config: {error}")))?;
|
||||||
|
|
||||||
@@ -457,3 +459,7 @@ mod tests;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "config_edit/conveyor_tests.rs"]
|
#[path = "config_edit/conveyor_tests.rs"]
|
||||||
mod conveyor_tests;
|
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 hyper::header::IF_MATCH;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::config::{ConfigSourceGraph, LoadedConfig, ProxyConfig};
|
use crate::config::{ConfigSourceGraph, LoadedConfig, ParsedConfigSource, ProxyConfig};
|
||||||
|
|
||||||
use super::model::ApiFailure;
|
use super::model::ApiFailure;
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ pub(super) async fn load_config_for_mutation(
|
|||||||
expected_revision: Option<&str>,
|
expected_revision: Option<&str>,
|
||||||
) -> Result<(ProxyConfig, String), ApiFailure> {
|
) -> Result<(ProxyConfig, String), ApiFailure> {
|
||||||
let loaded = load_config_snapshot(config_path, false).await?;
|
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) {
|
if expected_revision.is_some_and(|expected| expected != revision) {
|
||||||
return Err(ApiFailure::new(
|
return Err(ApiFailure::new(
|
||||||
hyper::StatusCode::CONFLICT,
|
hyper::StatusCode::CONFLICT,
|
||||||
@@ -98,9 +98,10 @@ pub(super) fn compute_revision(content: &str) -> String {
|
|||||||
hex::encode(hasher.finalize())
|
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 {
|
compute_source_revision(&ConfigSourceGraph {
|
||||||
source_contents: loaded.source_contents.clone(),
|
source_contents: source_contents.clone(),
|
||||||
rendered: String::new(),
|
rendered: String::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -118,12 +119,13 @@ pub(super) fn compute_source_revision(graph: &ConfigSourceGraph) -> String {
|
|||||||
hex::encode(hasher.finalize())
|
hex::encode(hasher.finalize())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads a normalized source snapshot without resolving decoys or loading static assets.
|
||||||
pub(super) async fn load_config_snapshot(
|
pub(super) async fn load_config_snapshot(
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
invalid_is_bad_request: bool,
|
invalid_is_bad_request: bool,
|
||||||
) -> Result<LoadedConfig, ApiFailure> {
|
) -> Result<ParsedConfigSource, ApiFailure> {
|
||||||
let config_path = config_path.to_path_buf();
|
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
|
.await
|
||||||
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
|
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
|
||||||
.map_err(|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(
|
pub(super) fn resolve_single_source_owner(
|
||||||
loaded: &LoadedConfig,
|
loaded: &ParsedConfigSource,
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
targets: &[&str],
|
targets: &[&str],
|
||||||
) -> Result<PathBuf, ApiFailure> {
|
) -> Result<PathBuf, ApiFailure> {
|
||||||
@@ -227,6 +230,7 @@ fn has_include_inside_table(content: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prepares the entire mutation candidate before any source file may be replaced.
|
||||||
pub(super) async fn load_candidate_snapshot(
|
pub(super) async fn load_candidate_snapshot(
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
base_sources: &BTreeMap<PathBuf, String>,
|
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 config_path = config_path.to_path_buf();
|
||||||
let mut overrides = base_sources.clone();
|
let mut overrides = base_sources.clone();
|
||||||
overrides.insert(owner_path, owner_contents);
|
overrides.insert(owner_path, owner_contents);
|
||||||
tokio::task::spawn_blocking(move || {
|
let parsed = tokio::task::spawn_blocking(move || {
|
||||||
ProxyConfig::load_with_source_overrides(config_path, &overrides)
|
ProxyConfig::parse_source_with_overrides(config_path, &overrides)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
|
.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 {
|
fn normalize_source_path(path: &Path) -> PathBuf {
|
||||||
@@ -265,19 +273,23 @@ fn normalize_source_path(path: &Path) -> PathBuf {
|
|||||||
normalized
|
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> {
|
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
|
||||||
let config_path = config_path.to_path_buf();
|
load_config_snapshot(config_path, false)
|
||||||
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to join config loader: {}", e)))?
|
.map(|source| source.config)
|
||||||
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquires fresh decoy evidence for an explicit API reload, even at the same revision.
|
||||||
pub(super) async fn load_config_for_reload(
|
pub(super) async fn load_config_for_reload(
|
||||||
config_path: &Path,
|
config_path: &Path,
|
||||||
) -> Result<(ProxyConfig, String), ApiFailure> {
|
) -> Result<(ProxyConfig, String), ApiFailure> {
|
||||||
let loaded = load_config_snapshot(config_path, true).await?;
|
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))
|
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>,
|
expected_revision: Option<&str>,
|
||||||
) -> Result<String, ApiFailure> {
|
) -> Result<String, ApiFailure> {
|
||||||
let loaded = load_config_snapshot(config_path, false).await?;
|
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) {
|
if expected_revision.is_some_and(|expected| expected != loaded_revision) {
|
||||||
return Err(revision_conflict());
|
return Err(revision_conflict());
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
|
|||||||
owner_contents.clone(),
|
owner_contents.clone(),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let _candidate_revision = compute_snapshot_revision(&candidate);
|
let _candidate_revision = compute_snapshot_revision(&candidate.source_contents);
|
||||||
let revision = write_atomic_if_unchanged(
|
let revision = write_atomic_if_unchanged(
|
||||||
config_path.to_path_buf(),
|
config_path.to_path_buf(),
|
||||||
loaded_revision,
|
loaded_revision,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
// Source polling and explicit reloads have distinct DNS preparation lifecycles.
|
||||||
|
mod preparation;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub(super) struct WatchManifest {
|
pub(super) struct WatchManifest {
|
||||||
files: BTreeSet<PathBuf>,
|
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(
|
fn reload_config_with_resolver(
|
||||||
config_path: &PathBuf,
|
prepared: (LoadedConfig, ProxyConfig),
|
||||||
config_tx: &watch::Sender<Arc<ProxyConfig>>,
|
config_tx: &watch::Sender<Arc<ProxyConfig>>,
|
||||||
log_tx: &watch::Sender<LogLevel>,
|
log_tx: &watch::Sender<LogLevel>,
|
||||||
detected_ip_v4: Option<IpAddr>,
|
detected_ip_v4: Option<IpAddr>,
|
||||||
@@ -142,13 +157,7 @@ fn reload_config_with_resolver(
|
|||||||
reload_state: &mut ReloadState,
|
reload_state: &mut ReloadState,
|
||||||
dns_resolver: Option<&crate::network::dns_overrides::GenerationDnsResolver>,
|
dns_resolver: Option<&crate::network::dns_overrides::GenerationDnsResolver>,
|
||||||
) -> Option<WatchManifest> {
|
) -> Option<WatchManifest> {
|
||||||
let loaded = match ProxyConfig::load_with_metadata(config_path) {
|
let (loaded, applied_cfg) = prepared;
|
||||||
Ok(loaded) => loaded,
|
|
||||||
Err(e) => {
|
|
||||||
error!("config reload: failed to parse {:?}: {}", config_path, e);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let LoadedConfig {
|
let LoadedConfig {
|
||||||
config: new_cfg,
|
config: new_cfg,
|
||||||
source_files,
|
source_files,
|
||||||
@@ -157,34 +166,12 @@ fn reload_config_with_resolver(
|
|||||||
} = loaded;
|
} = loaded;
|
||||||
let next_manifest = WatchManifest::from_source_files(&source_files);
|
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 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 old_hot = HotFields::from_config(&old_cfg);
|
||||||
let applied_hot = HotFields::from_config(&applied_cfg);
|
let applied_hot = HotFields::from_config(&applied_cfg);
|
||||||
let non_hot_changed = !config_equal(&applied_cfg, &new_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 {
|
if non_hot_changed {
|
||||||
warn_non_hot_changes(&old_cfg, &new_cfg, 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>,
|
detected_ip_v6: Option<IpAddr>,
|
||||||
reload_state: &mut ReloadState,
|
reload_state: &mut ReloadState,
|
||||||
) -> Option<WatchManifest> {
|
) -> 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(
|
reload_config_with_resolver(
|
||||||
config_path,
|
(loaded, applied),
|
||||||
config_tx,
|
config_tx,
|
||||||
log_tx,
|
log_tx,
|
||||||
detected_ip_v4,
|
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
|
let initial_manifest = initial_loaded
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|loaded| WatchManifest::from_source_files(&loaded.source_files))
|
.map(|loaded| WatchManifest::from_source_files(&loaded.source_files))
|
||||||
@@ -291,7 +283,7 @@ pub fn spawn_config_watcher(
|
|||||||
});
|
});
|
||||||
let initial_matches_disk = initial_loaded
|
let initial_matches_disk = initial_loaded
|
||||||
.as_ref()
|
.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
|
let initial_snapshot_hash = initial_loaded
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.filter(|_| initial_matches_disk)
|
.filter(|_| initial_matches_disk)
|
||||||
@@ -384,12 +376,14 @@ pub fn spawn_config_watcher(
|
|||||||
};
|
};
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
let mut force = false;
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
msg = notify_rx.recv() => {
|
msg = notify_rx.recv() => {
|
||||||
if msg.is_none() { break; }
|
if msg.is_none() { break; }
|
||||||
}
|
}
|
||||||
_ = sighup.recv() => {
|
_ = sighup.recv() => {
|
||||||
|
force = true;
|
||||||
info!("SIGHUP received — reloading {:?}", config_path);
|
info!("SIGHUP received — reloading {:?}", config_path);
|
||||||
}
|
}
|
||||||
_ = cancellation.cancelled() => break,
|
_ = cancellation.cancelled() => break,
|
||||||
@@ -406,27 +400,31 @@ pub fn spawn_config_watcher(
|
|||||||
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
|
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
|
||||||
while notify_rx.try_recv().is_ok() {}
|
while notify_rx.try_recv().is_ok() {}
|
||||||
|
|
||||||
let mut next_manifest = reload_config_with_resolver(
|
let mut next_manifest = preparation::reload(
|
||||||
&config_path,
|
&config_path,
|
||||||
&config_tx,
|
&config_tx,
|
||||||
&log_tx,
|
&log_tx,
|
||||||
detected_ip_v4,
|
detected_ip_v4,
|
||||||
detected_ip_v6,
|
detected_ip_v6,
|
||||||
&mut reload_state,
|
&mut reload_state,
|
||||||
dns_resolver.as_deref(),
|
dns_resolver.clone(),
|
||||||
);
|
force,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
if next_manifest.is_none() {
|
if next_manifest.is_none() {
|
||||||
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
|
tokio::time::sleep(HOT_RELOAD_DEBOUNCE).await;
|
||||||
while notify_rx.try_recv().is_ok() {}
|
while notify_rx.try_recv().is_ok() {}
|
||||||
next_manifest = reload_config_with_resolver(
|
next_manifest = preparation::reload(
|
||||||
&config_path,
|
&config_path,
|
||||||
&config_tx,
|
&config_tx,
|
||||||
&log_tx,
|
&log_tx,
|
||||||
detected_ip_v4,
|
detected_ip_v4,
|
||||||
detected_ip_v6,
|
detected_ip_v6,
|
||||||
&mut reload_state,
|
&mut reload_state,
|
||||||
dns_resolver.as_deref(),
|
dns_resolver.clone(),
|
||||||
);
|
force,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(next_manifest) = next_manifest {
|
if let Some(next_manifest) = next_manifest {
|
||||||
@@ -460,3 +458,6 @@ mod path_tests {
|
|||||||
assert_eq!(normalize_watch_path(&linked), linked);
|
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 decode;
|
||||||
mod effective;
|
mod effective;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
// Candidate-owned DNS preparation is separate from pure runtime reconstruction.
|
||||||
|
mod decoy_dns;
|
||||||
mod validate_core;
|
mod validate_core;
|
||||||
mod validate_me;
|
mod validate_me;
|
||||||
mod validate_runtime;
|
mod validate_runtime;
|
||||||
@@ -74,6 +76,19 @@ pub(crate) struct LoadedConfig {
|
|||||||
pub(crate) rendered_hash: u64,
|
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.
|
/// Raw recursive source graph captured before typed deserialization.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct ConfigSourceGraph {
|
pub(crate) struct ConfigSourceGraph {
|
||||||
@@ -229,6 +244,33 @@ impl ProxyConfig {
|
|||||||
pipeline::load_source_graph(graph)
|
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<()> {
|
pub(crate) fn rebuild_runtime_user_auth(&mut self) -> Result<()> {
|
||||||
let snapshot = UserAuthSnapshot::from_users(&self.access.users)?;
|
let snapshot = UserAuthSnapshot::from_users(&self.access.users)?;
|
||||||
self.runtime_user_auth = Some(Arc::new(snapshot));
|
self.runtime_user_auth = Some(Arc::new(snapshot));
|
||||||
@@ -250,6 +292,7 @@ impl ProxyConfig {
|
|||||||
validate_web::validate_decoy_listener_separation(self)
|
validate_web::validate_decoy_listener_separation(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns authentication data owned by this configuration generation.
|
||||||
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
|
pub(crate) fn runtime_user_auth(&self) -> Option<&UserAuthSnapshot> {
|
||||||
self.runtime_user_auth.as_deref()
|
self.runtime_user_auth.as_deref()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Typed source together with its exact include graph and rendered input.
|
||||||
pub(super) type DecodedSourceGraph = (
|
pub(super) type DecodedSourceGraph = (
|
||||||
ProxyConfig,
|
ProxyConfig,
|
||||||
BTreeSet<PathBuf>,
|
BTreeSet<PathBuf>,
|
||||||
@@ -7,6 +8,7 @@ pub(super) type DecodedSourceGraph = (
|
|||||||
String,
|
String,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Decodes source while retaining the metadata needed for atomic mutations.
|
||||||
pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSourceGraph> {
|
pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSourceGraph> {
|
||||||
let ConfigSourceGraph {
|
let ConfigSourceGraph {
|
||||||
source_contents,
|
source_contents,
|
||||||
@@ -17,6 +19,7 @@ pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSou
|
|||||||
let parsed_toml: toml::Value =
|
let parsed_toml: toml::Value =
|
||||||
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
|
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||||
handle_unknown_config_keys(&parsed_toml)?;
|
handle_unknown_config_keys(&parsed_toml)?;
|
||||||
|
ProxyConfig::validate_decoy_source_keys(&parsed_toml)?;
|
||||||
let general_table = parsed_toml
|
let general_table = parsed_toml
|
||||||
.get("general")
|
.get("general")
|
||||||
.and_then(|value| value.as_table());
|
.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::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Normalizes source policy and authentication data before external decoy preparation.
|
||||||
pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
||||||
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
|
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
|
||||||
config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string();
|
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_logging_config(&config.logging)?;
|
||||||
validate_upstreams(config)?;
|
validate_upstreams(config)?;
|
||||||
config.rebuild_runtime_user_auth()?;
|
config.rebuild_runtime_user_auth()?;
|
||||||
config.rebuild_runtime_web()?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,51 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// Completes legacy synchronous loads without performing hostname resolution.
|
||||||
pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
|
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) =
|
let (mut config, source_files, source_contents, processed) =
|
||||||
decode::decode_source_graph(graph)?;
|
decode::decode_source_graph(graph)?;
|
||||||
validate_core::validate(&mut config)?;
|
validate_core::validate(&mut config)?;
|
||||||
validate_runtime::validate(&mut config)?;
|
validate_runtime::validate(&mut config)?;
|
||||||
validate_me::validate(&mut config)?;
|
validate_me::validate(&mut config)?;
|
||||||
validate_server::validate(&mut config)?;
|
validate_server::validate(&mut config)?;
|
||||||
validate_web::validate(&mut config)?;
|
validate_web::validate_source(&mut config)?;
|
||||||
effective::apply(&mut config)?;
|
effective::apply(&mut config)?;
|
||||||
Ok(LoadedConfig {
|
Ok(ParsedConfigSource {
|
||||||
config,
|
config,
|
||||||
source_files: source_files.into_iter().collect(),
|
source_files: source_files.into_iter().collect(),
|
||||||
source_contents,
|
source_contents,
|
||||||
rendered_hash: hash_rendered_snapshot(&processed),
|
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 mut static_bytes = 0usize;
|
||||||
|
|
||||||
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
|
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(
|
let decoy = build_decoy(
|
||||||
|
config,
|
||||||
|
vhost_idx,
|
||||||
vhost,
|
vhost,
|
||||||
&config.web.limits,
|
&config.web.limits,
|
||||||
&mut static_files,
|
&mut static_files,
|
||||||
@@ -182,42 +184,16 @@ fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_decoy(
|
fn build_decoy(
|
||||||
|
config: &ProxyConfig,
|
||||||
|
vhost_idx: usize,
|
||||||
vhost: &WebVhostConfig,
|
vhost: &WebVhostConfig,
|
||||||
limits: &WebLimitsConfig,
|
limits: &WebLimitsConfig,
|
||||||
static_files: &mut usize,
|
static_files: &mut usize,
|
||||||
static_bytes: &mut usize,
|
static_bytes: &mut usize,
|
||||||
) -> Result<WebRuntimeDecoy> {
|
) -> Result<WebRuntimeDecoy> {
|
||||||
match &vhost.decoy {
|
match &vhost.decoy {
|
||||||
WebDecoyConfig::HttpUpstream { upstream } => {
|
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
|
||||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
decoy_dns::build_upstream(config, vhost_idx, upstream, *resolve)
|
||||||
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::StaticDirectory { directory, index } => {
|
WebDecoyConfig::StaticDirectory { directory, index } => {
|
||||||
let site = load_static_site(directory, index, limits, static_files, static_bytes)?;
|
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",
|
"http_overload_timeout_ms",
|
||||||
"shutdown_secs",
|
"shutdown_secs",
|
||||||
"decoy_header_secs",
|
"decoy_header_secs",
|
||||||
|
"decoy_resolve_secs",
|
||||||
];
|
];
|
||||||
|
|
||||||
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "base_path", "public_addr", "decoy", "profiles"];
|
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] = &[
|
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
|
||||||
"user",
|
"user",
|
||||||
"secret_mode",
|
"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.
|
/// Validates WEB policy and resource bounds before building runtime state.
|
||||||
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
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
|
let web_listener_count = config
|
||||||
.server
|
.server
|
||||||
.listeners
|
.listeners
|
||||||
@@ -83,12 +89,19 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
|||||||
timeouts::validate(&config.web.timeouts)?;
|
timeouts::validate(&config.web.timeouts)?;
|
||||||
websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
|
websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
|
||||||
validate_vhosts(config)?;
|
validate_vhosts(config)?;
|
||||||
validate_decoy_listener_separation(config)?;
|
validate_decoy_listener_separation_inner(config, false)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rejects a direct decoy recursion into an effective WEB listener.
|
/// Rejects a direct decoy recursion into an effective WEB listener.
|
||||||
pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result<()> {
|
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
|
let web_listeners = config
|
||||||
.server
|
.server
|
||||||
.listeners
|
.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)))
|
.map(|listener| SocketAddr::new(listener.ip, listener.port.unwrap_or(config.server.port)))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
let parsed = decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?;
|
||||||
ProxyError::Config(format!(
|
for upstream_addr in decoy_dns::addresses(config, vhost_idx, &parsed, require_resolved)? {
|
||||||
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
|
if web_listeners
|
||||||
))
|
.iter()
|
||||||
})?;
|
.any(|listener| listener_covers(*listener, upstream_addr))
|
||||||
let Some(port) = parsed.port_or_known_default() else {
|
{
|
||||||
continue;
|
return config_error(&format!(
|
||||||
};
|
"web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}"
|
||||||
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}"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
|
|||||||
("http_idle_secs", timeouts.http_idle_secs),
|
("http_idle_secs", timeouts.http_idle_secs),
|
||||||
("shutdown_secs", timeouts.shutdown_secs),
|
("shutdown_secs", timeouts.shutdown_secs),
|
||||||
("decoy_header_secs", timeouts.decoy_header_secs),
|
("decoy_header_secs", timeouts.decoy_header_secs),
|
||||||
|
("decoy_resolve_secs", timeouts.decoy_resolve_secs),
|
||||||
];
|
];
|
||||||
if let Some((field, _)) = values
|
if let Some((field, _)) = values
|
||||||
.into_iter()
|
.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<()> {
|
pub(super) fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> {
|
||||||
match decoy {
|
match decoy {
|
||||||
WebDecoyConfig::HttpUpstream { upstream } => {
|
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
|
||||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?;
|
||||||
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::StaticDirectory { directory, index } => {
|
WebDecoyConfig::StaticDirectory { directory, index } => {
|
||||||
if !directory.is_absolute() {
|
if !directory.is_absolute() {
|
||||||
|
|||||||
+1
-1
@@ -6,5 +6,5 @@ mod load;
|
|||||||
mod types;
|
mod types;
|
||||||
|
|
||||||
pub use load::ProxyConfig;
|
pub use load::ProxyConfig;
|
||||||
pub(crate) use load::{ConfigSourceGraph, LoadedConfig};
|
pub(crate) use load::{ConfigSourceGraph, LoadedConfig, ParsedConfigSource};
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ mod carrier_method_tests;
|
|||||||
|
|
||||||
#[path = "web_tests/conveyor_tests.rs"]
|
#[path = "web_tests/conveyor_tests.rs"]
|
||||||
mod conveyor_tests;
|
mod conveyor_tests;
|
||||||
|
#[path = "web_tests/decoy_dns_tests.rs"]
|
||||||
|
mod decoy_dns_tests;
|
||||||
|
|
||||||
const WEB_CONFIG: &str = r#"
|
const WEB_CONFIG: &str = r#"
|
||||||
[access.users]
|
[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)]
|
#[allow(unused_imports)]
|
||||||
pub use web::{
|
pub use web::{
|
||||||
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode,
|
WebCarrierNegotiationAggressiveness, WebConfig, WebDecoyConfig, WebDecoyFastTrackMode,
|
||||||
WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig, WebSecretMode,
|
WebDecoyResolve, WebHttpConnectionCapacityAction, WebLimitsConfig, WebProfileConfig,
|
||||||
WebTimeoutsConfig, WebVhostConfig,
|
WebSecretMode, WebTimeoutsConfig, WebVhostConfig,
|
||||||
};
|
};
|
||||||
pub(crate) use web::{
|
pub(crate) use web::{
|
||||||
WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost, WebStaticAsset,
|
WebDecoyDnsSnapshot, WebRuntimeConfig, WebRuntimeDecoy, WebRuntimeProfile, WebRuntimeVhost,
|
||||||
WebStaticSite,
|
WebStaticAsset, WebStaticSite,
|
||||||
};
|
};
|
||||||
pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES;
|
pub(crate) use web_carrier::WEB_CARRIER_LEARNING_MIN_ENTRIES;
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ pub use fasttrack::WebDecoyFastTrackMode;
|
|||||||
// Accepted-socket overload policy remains separate from the bulky WEB data model.
|
// Accepted-socket overload policy remains separate from the bulky WEB data model.
|
||||||
mod overload;
|
mod overload;
|
||||||
pub use overload::WebHttpConnectionCapacityAction;
|
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.
|
/// Client-facing secret representation used to derive a WEB capability.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -55,6 +59,9 @@ pub enum WebDecoyConfig {
|
|||||||
HttpUpstream {
|
HttpUpstream {
|
||||||
/// Origin URL without a query or fragment.
|
/// Origin URL without a query or fragment.
|
||||||
upstream: String,
|
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.
|
/// Serve an immutable, bounded snapshot of a local directory.
|
||||||
StaticDirectory {
|
StaticDirectory {
|
||||||
@@ -358,6 +365,9 @@ pub struct WebTimeoutsConfig {
|
|||||||
/// Deadline for connecting to and receiving headers from an HTTP decoy.
|
/// Deadline for connecting to and receiving headers from an HTTP decoy.
|
||||||
#[serde(default = "default_web_decoy_header_timeout_secs")]
|
#[serde(default = "default_web_decoy_header_timeout_secs")]
|
||||||
pub decoy_header_secs: u64,
|
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 {
|
impl Default for WebTimeoutsConfig {
|
||||||
@@ -387,6 +397,7 @@ impl Default for WebTimeoutsConfig {
|
|||||||
http_overload_timeout_ms: default_web_http_overload_timeout_ms(),
|
http_overload_timeout_ms: default_web_http_overload_timeout_ms(),
|
||||||
shutdown_secs: default_web_shutdown_secs(),
|
shutdown_secs: default_web_shutdown_secs(),
|
||||||
decoy_header_secs: default_web_decoy_header_timeout_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.
|
/// Validated immutable runtime snapshot built during configuration loading.
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub(crate) runtime: Option<Arc<WebRuntimeConfig>>,
|
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 {
|
impl WebConfig {
|
||||||
@@ -489,6 +503,7 @@ impl Default for WebConfig {
|
|||||||
timeouts: WebTimeoutsConfig::default(),
|
timeouts: WebTimeoutsConfig::default(),
|
||||||
vhosts: Vec::new(),
|
vhosts: Vec::new(),
|
||||||
runtime: None,
|
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_http_overload_timeout_ms, 250);
|
||||||
u64_default!(default_web_shutdown_secs, 15);
|
u64_default!(default_web_shutdown_secs, 15);
|
||||||
u64_default!(default_web_decoy_header_timeout_secs, 30);
|
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;
|
const HEALTHCHECK_RESPONSE_MAX_BYTES: u64 = 64 * 1024;
|
||||||
|
|
||||||
|
/// API health contract selected by the standalone healthcheck command.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub(crate) enum HealthcheckMode {
|
pub(crate) enum HealthcheckMode {
|
||||||
|
/// Require a responsive API process.
|
||||||
Liveness,
|
Liveness,
|
||||||
|
/// Require the active runtime to report readiness.
|
||||||
Ready,
|
Ready,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HealthcheckMode {
|
impl HealthcheckMode {
|
||||||
|
/// Parses the supported command-line healthcheck modes.
|
||||||
pub(crate) fn from_cli_arg(value: &str) -> Option<Self> {
|
pub(crate) fn from_cli_arg(value: &str) -> Option<Self> {
|
||||||
match value {
|
match value {
|
||||||
"liveness" => Some(Self::Liveness),
|
"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 {
|
pub(crate) fn run(config_path: &str, mode: HealthcheckMode) -> i32 {
|
||||||
match run_inner(config_path, mode) {
|
match run_inner(config_path, mode) {
|
||||||
Ok(()) => 0,
|
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> {
|
fn run_inner(config_path: &str, mode: HealthcheckMode) -> Result<(), String> {
|
||||||
let config =
|
let config = ProxyConfig::parse_source(config_path)
|
||||||
ProxyConfig::load(config_path).map_err(|error| format!("config load failed: {error}"))?;
|
.map_err(|error| format!("config load failed: {error}"))?
|
||||||
|
.config;
|
||||||
let api_cfg = &config.server.api;
|
let api_cfg = &config.server.api;
|
||||||
if !api_cfg.enabled {
|
if !api_cfg.enabled {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -229,4 +235,26 @@ mod tests {
|
|||||||
|
|
||||||
assert!(read_response_bounded(&mut payload.as_slice()).is_err());
|
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);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut config = match ProxyConfig::load(&config_path) {
|
let mut config = match ProxyConfig::load_prepared(config_path.clone()).await {
|
||||||
Ok(c) => c,
|
Ok(loaded) => loaded.config,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
if config_path.exists() {
|
if config_path.exists() {
|
||||||
eprintln!("[telemt] Error: {}", e);
|
eprintln!("[telemt] Error: {}", e);
|
||||||
|
|||||||
@@ -512,6 +512,7 @@ pub(crate) fn deferred_process_fields(
|
|||||||
|
|
||||||
fn configs_equal(old: &ProxyConfig, new: &ProxyConfig) -> bool {
|
fn configs_equal(old: &ProxyConfig, new: &ProxyConfig) -> bool {
|
||||||
serde_json::to_value(old).ok() == serde_json::to_value(new).ok()
|
serde_json::to_value(old).ok() == serde_json::to_value(new).ok()
|
||||||
|
&& old.web_decoy_endpoints_equal(new)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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