WEB: Resolve opt-in decoy hostnames during config preparation #929

Co-Authored-By: brekotis <93345790+brekotis@users.noreply.github.com>
This commit is contained in:
Alexey
2026-10-04 14:32:06 +03:00
parent d3de9865cf
commit bc1c130f38
30 changed files with 1251 additions and 168 deletions
+9 -3
View File
@@ -158,7 +158,7 @@ async fn prepare_patch_to_path(
) -> Result<PreparedConfigPatch, ApiFailure> {
// 1. optimistic concurrency
let loaded = load_config_snapshot(config_path, false).await?;
let current = compute_snapshot_revision(&loaded);
let current = compute_snapshot_revision(&loaded.source_contents);
if expected_revision.is_some_and(|expected| expected != current) {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
@@ -208,6 +208,8 @@ async fn prepare_patch_to_path(
let mut merged = Toml::try_from(&old_cfg)
.map_err(|e| ApiFailure::internal(format!("failed to serialize config: {}", e)))?;
deep_merge(&mut merged, &patch_toml);
ProxyConfig::validate_decoy_source_keys(&merged)
.map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))?;
let requested_cfg: ProxyConfig = merged
.clone()
@@ -263,7 +265,7 @@ async fn prepare_patch_to_path(
}
// 4. classify the validated, normalized candidate.
let revision = compute_snapshot_revision(&candidate);
let revision = compute_snapshot_revision(&candidate.source_contents);
let new_cfg = candidate.config;
let class = classify_config_changes(&old_cfg, &new_cfg);
let deferred_process_fields =
@@ -306,7 +308,7 @@ fn reload_submit_failure(error: ReloadSubmitError) -> ApiFailure {
/// Returns only the editable config sections and current revision.
pub(super) async fn read_managed_config(config_path: &Path) -> Result<(Toml, String), ApiFailure> {
let loaded = load_config_snapshot(config_path, false).await?;
let revision = compute_snapshot_revision(&loaded);
let revision = compute_snapshot_revision(&loaded.source_contents);
let parsed = Toml::try_from(&loaded.config)
.map_err(|error| ApiFailure::internal(format!("failed to serialize config: {error}")))?;
@@ -457,3 +459,7 @@ mod tests;
#[cfg(test)]
#[path = "config_edit/conveyor_tests.rs"]
mod conveyor_tests;
#[cfg(test)]
#[path = "config_edit/decoy_dns_tests.rs"]
mod decoy_dns_tests;
+105
View File
@@ -0,0 +1,105 @@
use super::*;
const SOURCE: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
[[web.vhosts]]
host = "proxy.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://localhost:18081"
resolve = "startup"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "dd"
"#;
#[tokio::test]
async fn decoy_dns_api_get_and_mutation_base_need_no_prepared_runtime() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
std::fs::write(
&path,
SOURCE.replace("localhost", "unresolvable.example.invalid"),
)
.unwrap();
let (managed, revision) = read_managed_config(&path).await.unwrap();
assert_eq!(
managed["web"]["vhosts"][0]["decoy"]["resolve"].as_str(),
Some("startup")
);
assert!(managed["web"].get("decoy_dns").is_none());
let (base, base_revision) = crate::api::config_store::load_config_for_mutation(&path, None)
.await
.unwrap();
assert_eq!(revision, base_revision);
assert!(base.web.runtime.is_none());
assert!(base.web.decoy_dns.origins.is_empty());
}
#[tokio::test]
async fn decoy_dns_api_patch_without_reload_prepares_before_disk_write() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
std::fs::write(&path, SOURCE).unwrap();
// This current-thread runtime exercises the real Tokio resolver without nested block_on.
let active = ProxyConfig::load_prepared(path.clone())
.await
.unwrap()
.config;
let before = std::fs::read(&path).unwrap();
let mut vhosts = serde_json::to_value(&active.web.vhosts).unwrap();
vhosts[0]["decoy"]["upstream"] = Json::String("http://localhost:18080".to_string());
let error = apply_patch_to_path(&path, &serde_json::json!({"web": {"vhosts": vhosts}}), None)
.await
.unwrap_err();
assert!(error.message.contains("overlaps WEB listener"));
assert_eq!(std::fs::read(&path).unwrap(), before);
assert!(active.web.runtime.is_some());
let prepared = prepare_patch_to_path(
&path,
&serde_json::json!({"web": {"conveyor": false}}),
None,
)
.await
.unwrap();
assert_eq!(prepared.desired_config.web.decoy_dns.origins.len(), 1);
assert!(prepared.desired_config.web.runtime.is_some());
assert_eq!(std::fs::read(&path).unwrap(), before);
apply_patch_to_path(
&path,
&serde_json::json!({"web": {"conveyor": false}}),
None,
)
.await
.unwrap();
let reloaded = ProxyConfig::load_prepared(path).await.unwrap();
assert!(!reloaded.config.web.conveyor);
}
#[tokio::test]
async fn decoy_dns_api_rejects_resolve_on_static_mode_before_serde_drops_it() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("config.toml");
std::fs::write(&path, SOURCE).unwrap();
let base = ProxyConfig::parse_source(&path).unwrap();
let mut vhosts = serde_json::to_value(&base.config.web.vhosts).unwrap();
vhosts[0]["decoy"] = serde_json::json!({
"mode": "static_directory", "directory": directory.path(), "resolve": "startup"
});
let before = std::fs::read(&path).unwrap();
let error = apply_patch_to_path(&path, &serde_json::json!({"web": {"vhosts": vhosts}}), None)
.await
.unwrap_err();
assert!(error.message.contains("only valid for mode=http_upstream"));
assert_eq!(std::fs::read(&path).unwrap(), before);
}
+27 -15
View File
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
use hyper::header::IF_MATCH;
use sha2::{Digest, Sha256};
use crate::config::{ConfigSourceGraph, LoadedConfig, ProxyConfig};
use crate::config::{ConfigSourceGraph, LoadedConfig, ParsedConfigSource, ProxyConfig};
use super::model::ApiFailure;
@@ -63,7 +63,7 @@ pub(super) async fn load_config_for_mutation(
expected_revision: Option<&str>,
) -> Result<(ProxyConfig, String), ApiFailure> {
let loaded = load_config_snapshot(config_path, false).await?;
let revision = compute_snapshot_revision(&loaded);
let revision = compute_snapshot_revision(&loaded.source_contents);
if expected_revision.is_some_and(|expected| expected != revision) {
return Err(ApiFailure::new(
hyper::StatusCode::CONFLICT,
@@ -98,9 +98,10 @@ pub(super) fn compute_revision(content: &str) -> String {
hex::encode(hasher.finalize())
}
pub(super) fn compute_snapshot_revision(loaded: &LoadedConfig) -> String {
/// Hashes source documents only; prepared DNS answers never change a revision.
pub(super) fn compute_snapshot_revision(source_contents: &BTreeMap<PathBuf, String>) -> String {
compute_source_revision(&ConfigSourceGraph {
source_contents: loaded.source_contents.clone(),
source_contents: source_contents.clone(),
rendered: String::new(),
})
}
@@ -118,12 +119,13 @@ pub(super) fn compute_source_revision(graph: &ConfigSourceGraph) -> String {
hex::encode(hasher.finalize())
}
/// Reads a normalized source snapshot without resolving decoys or loading static assets.
pub(super) async fn load_config_snapshot(
config_path: &Path,
invalid_is_bad_request: bool,
) -> Result<LoadedConfig, ApiFailure> {
) -> Result<ParsedConfigSource, ApiFailure> {
let config_path = config_path.to_path_buf();
tokio::task::spawn_blocking(move || ProxyConfig::load_with_metadata(config_path))
tokio::task::spawn_blocking(move || ProxyConfig::parse_source(config_path))
.await
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
.map_err(|error| {
@@ -135,8 +137,9 @@ pub(super) async fn load_config_snapshot(
})
}
/// Selects the single writable source document owning all requested sections.
pub(super) fn resolve_single_source_owner(
loaded: &LoadedConfig,
loaded: &ParsedConfigSource,
config_path: &Path,
targets: &[&str],
) -> Result<PathBuf, ApiFailure> {
@@ -227,6 +230,7 @@ fn has_include_inside_table(content: &str) -> bool {
false
}
/// Prepares the entire mutation candidate before any source file may be replaced.
pub(super) async fn load_candidate_snapshot(
config_path: &Path,
base_sources: &BTreeMap<PathBuf, String>,
@@ -236,12 +240,16 @@ pub(super) async fn load_candidate_snapshot(
let config_path = config_path.to_path_buf();
let mut overrides = base_sources.clone();
overrides.insert(owner_path, owner_contents);
tokio::task::spawn_blocking(move || {
ProxyConfig::load_with_source_overrides(config_path, &overrides)
let parsed = tokio::task::spawn_blocking(move || {
ProxyConfig::parse_source_with_overrides(config_path, &overrides)
})
.await
.map_err(|error| ApiFailure::internal(format!("failed to join config loader: {error}")))?
.map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))
.map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))?;
parsed
.prepare()
.await
.map_err(|error| ApiFailure::bad_request(format!("invalid patched config: {error}")))
}
fn normalize_source_path(path: &Path) -> PathBuf {
@@ -265,19 +273,23 @@ fn normalize_source_path(path: &Path) -> PathBuf {
normalized
}
/// Reads desired user and quota policy without acquiring runtime decoy state.
pub(super) async fn load_config_from_disk(config_path: &Path) -> Result<ProxyConfig, ApiFailure> {
let config_path = config_path.to_path_buf();
tokio::task::spawn_blocking(move || ProxyConfig::load(config_path))
load_config_snapshot(config_path, false)
.await
.map_err(|e| ApiFailure::internal(format!("failed to join config loader: {}", e)))?
.map_err(|e| ApiFailure::internal(format!("failed to load config: {}", e)))
.map(|source| source.config)
}
/// Acquires fresh decoy evidence for an explicit API reload, even at the same revision.
pub(super) async fn load_config_for_reload(
config_path: &Path,
) -> Result<(ProxyConfig, String), ApiFailure> {
let loaded = load_config_snapshot(config_path, true).await?;
let revision = compute_snapshot_revision(&loaded);
let revision = compute_snapshot_revision(&loaded.source_contents);
let loaded = loaded
.prepare()
.await
.map_err(|error| ApiFailure::bad_request(format!("invalid runtime config: {error}")))?;
Ok((loaded.config, revision))
}
+2 -2
View File
@@ -113,7 +113,7 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
expected_revision: Option<&str>,
) -> Result<String, ApiFailure> {
let loaded = load_config_snapshot(config_path, false).await?;
let loaded_revision = compute_snapshot_revision(&loaded);
let loaded_revision = compute_snapshot_revision(&loaded.source_contents);
if expected_revision.is_some_and(|expected| expected != loaded_revision) {
return Err(revision_conflict());
}
@@ -159,7 +159,7 @@ pub(in crate::api) async fn save_access_sections_to_disk_if_revision(
owner_contents.clone(),
)
.await?;
let _candidate_revision = compute_snapshot_revision(&candidate);
let _candidate_revision = compute_snapshot_revision(&candidate.source_contents);
let revision = write_atomic_if_unchanged(
config_path.to_path_buf(),
loaded_revision,