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