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
@@ -0,0 +1,133 @@
use super::*;
const SOURCE: &str = r#"
[access.users]
alice = "000102030405060708090a0b0c0d0e0f"
[[server.listeners]]
ip = "127.0.0.1"
port = 18080
transport = "web"
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
[web]
enabled = true
[[web.vhosts]]
host = "proxy.example.com"
public_addr = "203.0.113.10:443"
[web.vhosts.decoy]
mode = "http_upstream"
upstream = "http://localhost:18081"
resolve = "startup"
[[web.vhosts.profiles]]
user = "alice"
secret_mode = "dd"
"#;
#[tokio::test]
async fn decoy_dns_watcher_publishes_dns_only_changes_once() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, SOURCE).unwrap();
let mut loaded = ProxyConfig::load_prepared(path.clone()).await.unwrap();
let (config_tx, mut config_rx) = watch::channel(Arc::new(loaded.config.clone()));
let (log_tx, _) = watch::channel(loaded.config.general.log_level.clone());
let old = config_rx.borrow().clone();
let mut state = ReloadState::new(Some(loaded.rendered_hash));
Arc::make_mut(&mut loaded.config.web.decoy_dns)
.origins
.insert(
("localhost".to_string(), 18081),
vec!["127.0.0.2:18081".parse().unwrap()],
);
loaded.config.rebuild_runtime_web().unwrap();
let applied = prepare_effective_config(&old, &loaded.config).unwrap();
reload_config_with_resolver(
(loaded.clone(), applied),
&config_tx,
&log_tx,
None,
None,
&mut state,
None,
)
.unwrap();
assert!(config_rx.has_changed().unwrap());
let published = config_rx.borrow_and_update().clone();
assert!(!old.web_decoy_endpoints_equal(&published));
let applied = prepare_effective_config(&published, &loaded.config).unwrap();
reload_config_with_resolver(
(loaded, applied),
&config_tx,
&log_tx,
None,
None,
&mut state,
None,
)
.unwrap();
assert!(!config_rx.has_changed().unwrap());
assert!(Arc::ptr_eq(&published, &config_rx.borrow()));
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
)
.await
.unwrap();
assert!(!config_rx.has_changed().unwrap());
assert!(Arc::ptr_eq(&published, &config_rx.borrow()));
// Explicit reload bypasses the unchanged source hash and replaces the injected endpoint.
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, true,
)
.await
.unwrap();
assert!(config_rx.has_changed().unwrap());
assert!(!published.web_decoy_endpoints_equal(&config_rx.borrow()));
let current = config_rx.borrow_and_update().clone();
std::fs::write(&path, SOURCE.replace("localhost:18081", "localhost:18080")).unwrap();
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
)
.await
.unwrap();
assert!(!config_rx.has_changed().unwrap());
assert!(Arc::ptr_eq(&current, &config_rx.borrow()));
}
#[tokio::test]
async fn decoy_dns_unchanged_watcher_source_skips_resolution() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(
&path,
SOURCE.replace("localhost", "unresolvable.example.invalid"),
)
.unwrap();
let source = preparation::read_source(&path).await.unwrap();
let initial = Arc::new(source.config.clone());
let (config_tx, config_rx) = watch::channel(initial.clone());
let (log_tx, _) = watch::channel(initial.general.log_level.clone());
let mut state = ReloadState::new(Some(source.rendered_hash));
assert!(preparation::source_matches_active(&initial, &source));
preparation::reload(
&path, &config_tx, &log_tx, None, None, &mut state, None, false,
)
.await
.unwrap();
assert!(!config_rx.has_changed().unwrap());
assert!(Arc::ptr_eq(&initial, &config_rx.borrow()));
}
#[tokio::test]
async fn decoy_dns_watcher_init_ignores_retained_restart_fields() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
std::fs::write(&path, SOURCE).unwrap();
let loaded = ProxyConfig::load_prepared(path.clone()).await.unwrap();
let mut source = ProxyConfig::parse_source(&path).unwrap();
source.config.web.limits.max_static_file_bytes += 1;
assert!(preparation::source_matches_active(&loaded.config, &source));
source.config.web.conveyor = !source.config.web.conveyor;
assert!(!preparation::source_matches_active(&loaded.config, &source));
}
@@ -0,0 +1,85 @@
use super::*;
/// Restart-only differences must not enqueue another DNS preparation after activation.
pub(super) fn source_matches_active(
active: &ProxyConfig,
source: &crate::config::ParsedConfigSource,
) -> bool {
config_equal(active, &overlay_hot_fields(active, &source.config))
}
/// Reads only source state; watcher initialization and unchanged events never need DNS.
pub(super) async fn read_source(path: &PathBuf) -> Option<crate::config::ParsedConfigSource> {
let path = path.clone();
match tokio::task::spawn_blocking(move || ProxyConfig::parse_source(path)).await {
Ok(Ok(source)) => Some(source),
Ok(Err(error)) => {
error!("config reload: failed to parse: {}", error);
None
}
Err(error) => {
error!("config reload: source reader failed: {}", error);
None
}
}
}
/// Prepares fresh evidence only for changed sources or an explicit reload request.
pub(super) async fn reload(
path: &PathBuf,
config_tx: &watch::Sender<Arc<ProxyConfig>>,
log_tx: &watch::Sender<LogLevel>,
detected_ip_v4: Option<IpAddr>,
detected_ip_v6: Option<IpAddr>,
reload_state: &mut ReloadState,
dns_resolver: Option<Arc<crate::network::dns_overrides::GenerationDnsResolver>>,
force: bool,
) -> Option<WatchManifest> {
let source = read_source(path).await?;
let manifest = WatchManifest::from_source_files(&source.source_files);
if !force && reload_state.is_applied(source.rendered_hash) {
return Some(manifest);
}
let loaded = match source.prepare().await {
Ok(loaded) => loaded,
Err(error) => {
error!(
"config reload: preparation failed: {}; keeping old config",
error
);
return Some(manifest);
}
};
let old = config_tx.borrow().clone();
// Static-site reconstruction after the listener overlay must not block Tokio workers.
// The worker only builds data: cancellation cannot leave a detached publisher behind.
let result = tokio::task::spawn_blocking(move || {
prepare_effective_config(&old, &loaded.config).map(|applied| (loaded, applied))
})
.await;
match result {
Ok(Ok(prepared)) => reload_config_with_resolver(
prepared,
config_tx,
log_tx,
detected_ip_v4,
detected_ip_v6,
reload_state,
dns_resolver.as_deref(),
),
Ok(Err(error)) => {
error!(
"config reload: effective WEB validation failed: {}; keeping old config",
error
);
Some(manifest)
}
Err(error) => {
error!(
"config reload: runtime builder failed: {}; keeping old config",
error
);
Some(manifest)
}
}
}