mirror of
https://github.com/telemt/telemt.git
synced 2026-10-30 12:30:34 +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:
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
/// Typed source together with its exact include graph and rendered input.
|
||||
pub(super) type DecodedSourceGraph = (
|
||||
ProxyConfig,
|
||||
BTreeSet<PathBuf>,
|
||||
@@ -7,6 +8,7 @@ pub(super) type DecodedSourceGraph = (
|
||||
String,
|
||||
);
|
||||
|
||||
/// Decodes source while retaining the metadata needed for atomic mutations.
|
||||
pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSourceGraph> {
|
||||
let ConfigSourceGraph {
|
||||
source_contents,
|
||||
@@ -17,6 +19,7 @@ pub(super) fn decode_source_graph(graph: ConfigSourceGraph) -> Result<DecodedSou
|
||||
let parsed_toml: toml::Value =
|
||||
toml::from_str(&processed).map_err(|e| ProxyError::Config(e.to_string()))?;
|
||||
handle_unknown_config_keys(&parsed_toml)?;
|
||||
ProxyConfig::validate_decoy_source_keys(&parsed_toml)?;
|
||||
let general_table = parsed_toml
|
||||
.get("general")
|
||||
.and_then(|value| value.as_table());
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
use std::future::Future;
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Rejects mode-specific keys even when unknown-key handling is permissive.
|
||||
pub(super) fn validate_mode_keys(document: &toml::Value) -> Result<()> {
|
||||
if let Some(vhosts) = document
|
||||
.get("web")
|
||||
.and_then(|web| web.get("vhosts"))
|
||||
.and_then(toml::Value::as_array)
|
||||
{
|
||||
for (idx, vhost) in vhosts.iter().enumerate() {
|
||||
if let Some(decoy) = vhost.get("decoy")
|
||||
&& decoy.get("resolve").is_some()
|
||||
&& decoy.get("mode").and_then(toml::Value::as_str) != Some("http_upstream")
|
||||
{
|
||||
return Err(ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.resolve is only valid for mode=http_upstream"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parses one origin and preserves the existing literal-address containment policy.
|
||||
pub(super) fn parse_origin(
|
||||
idx: usize,
|
||||
upstream: &str,
|
||||
resolve: WebDecoyResolve,
|
||||
) -> Result<url::Url> {
|
||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
||||
ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
if parsed.scheme() != "http"
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
|| parsed.path() != "/"
|
||||
|| parsed.port() == Some(0)
|
||||
{
|
||||
return Err(ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
|
||||
)));
|
||||
}
|
||||
match parsed.host() {
|
||||
Some(url::Host::Ipv4(ip)) => validate_address(idx, IpAddr::V4(ip))?,
|
||||
Some(url::Host::Ipv6(ip)) => validate_address(idx, IpAddr::V6(ip))?,
|
||||
Some(url::Host::Domain(_)) if resolve == WebDecoyResolve::Startup => {}
|
||||
_ => {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream host must be a loopback or private IP literal unless resolve = \"startup\""
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn validate_address(idx: usize, ip: IpAddr) -> Result<()> {
|
||||
let private = match ip {
|
||||
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
|
||||
IpAddr::V6(ip) => ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local(),
|
||||
};
|
||||
if !private {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream must remain inside loopback or a private network"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns all captured answers so non-selected addresses cannot hide a listener loop.
|
||||
pub(super) fn addresses(
|
||||
config: &ProxyConfig,
|
||||
idx: usize,
|
||||
parsed: &url::Url,
|
||||
require_resolved: bool,
|
||||
) -> Result<Vec<SocketAddr>> {
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?;
|
||||
let answers = match parsed.host() {
|
||||
Some(url::Host::Ipv4(ip)) => vec![SocketAddr::new(IpAddr::V4(ip), port)],
|
||||
Some(url::Host::Ipv6(ip)) => vec![SocketAddr::new(IpAddr::V6(ip), port)],
|
||||
Some(url::Host::Domain(host)) => {
|
||||
let key = (host.to_string(), port);
|
||||
match config.web.decoy_dns.origins.get(&key) {
|
||||
Some(answers) if !answers.is_empty() => answers.clone(),
|
||||
_ if !require_resolved => return Ok(Vec::new()),
|
||||
_ => {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream requires async preparation for resolve = \"startup\""
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => return Err(ProxyError::Config("WEB decoy host is missing".to_string())),
|
||||
};
|
||||
for addr in &answers {
|
||||
validate_address(idx, addr.ip())?;
|
||||
if addr.port() != port {
|
||||
return Err(ProxyError::Config(
|
||||
"WEB decoy DNS snapshot port mismatch".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(answers)
|
||||
}
|
||||
|
||||
/// Captures fresh resolver evidence without publishing any partially prepared configuration.
|
||||
pub(super) async fn prepare<F, Fut>(config: &mut ProxyConfig, mut lookup: F) -> Result<()>
|
||||
where
|
||||
F: FnMut(String, u16) -> Fut,
|
||||
Fut: Future<Output = std::io::Result<Vec<SocketAddr>>>,
|
||||
{
|
||||
let mut snapshot = WebDecoyDnsSnapshot::default();
|
||||
for (idx, vhost) in config.web.vhosts.iter().enumerate() {
|
||||
let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else {
|
||||
continue;
|
||||
};
|
||||
let parsed = parse_origin(idx, upstream, *resolve)?;
|
||||
let Some(url::Host::Domain(host)) = parsed.host() else {
|
||||
continue;
|
||||
};
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| ProxyError::Config("WEB decoy port cannot be resolved".to_string()))?;
|
||||
let key = (host.to_string(), port);
|
||||
if snapshot.origins.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
// Dropping this wait does not forcibly cancel a running OS resolver call.
|
||||
let answers = tokio::time::timeout(
|
||||
Duration::from_secs(config.web.timeouts.decoy_resolve_secs),
|
||||
lookup(key.0.clone(), port),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream DNS resolution timed out after {} seconds",
|
||||
config.web.timeouts.decoy_resolve_secs
|
||||
))
|
||||
})?
|
||||
.map_err(|error| {
|
||||
ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream DNS resolution failed: {error}"
|
||||
))
|
||||
})?;
|
||||
if answers.is_empty() {
|
||||
return Err(ProxyError::Config(format!(
|
||||
"web.vhosts[{idx}].decoy.upstream DNS resolution returned no addresses"
|
||||
)));
|
||||
}
|
||||
for addr in &answers {
|
||||
validate_address(idx, addr.ip())?;
|
||||
}
|
||||
snapshot.origins.insert(key, answers);
|
||||
}
|
||||
config.web.decoy_dns = Arc::new(snapshot);
|
||||
validate_web::validate_decoy_listener_separation(config)
|
||||
}
|
||||
|
||||
/// Builds the unchanged request-time tuple from immutable configuration evidence.
|
||||
pub(super) fn build_upstream(
|
||||
config: &ProxyConfig,
|
||||
idx: usize,
|
||||
upstream: &str,
|
||||
resolve: WebDecoyResolve,
|
||||
) -> Result<WebRuntimeDecoy> {
|
||||
let parsed = parse_origin(idx, upstream, resolve)?;
|
||||
let addr = addresses(config, idx, &parsed, true)?
|
||||
.first()
|
||||
.copied()
|
||||
.ok_or_else(|| ProxyError::Config("WEB decoy DNS snapshot is empty".to_string()))?;
|
||||
// URL authority keeps the configured hostname and brackets IPv6 literals.
|
||||
let authority = parsed[url::Position::BeforeHost..url::Position::AfterPort].to_string();
|
||||
Ok(WebRuntimeDecoy::HttpUpstream { addr, authority })
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
/// Compares selected endpoints separately from source-only configuration equality.
|
||||
pub(crate) fn web_decoy_endpoints_equal(&self, other: &Self) -> bool {
|
||||
fn selected(config: &ProxyConfig) -> impl Iterator<Item = (&String, SocketAddr)> {
|
||||
config
|
||||
.web
|
||||
.runtime
|
||||
.iter()
|
||||
.flat_map(|runtime| runtime.vhosts.iter())
|
||||
.filter_map(|(host, vhost)| match vhost.decoy {
|
||||
WebRuntimeDecoy::HttpUpstream { addr, .. } => Some((host, addr)),
|
||||
WebRuntimeDecoy::StaticDirectory(_) => None,
|
||||
})
|
||||
}
|
||||
selected(self).eq(selected(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,330 @@
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use super::*;
|
||||
|
||||
const SOURCE: &str = r#"
|
||||
[access.users]
|
||||
alice = "000102030405060708090a0b0c0d0e0f"
|
||||
[[server.listeners]]
|
||||
ip = "127.0.0.1"
|
||||
port = 18080
|
||||
transport = "web"
|
||||
web_trusted_proxy_cidrs = ["127.0.0.1/32"]
|
||||
[web]
|
||||
enabled = true
|
||||
[[web.vhosts]]
|
||||
host = "proxy.example.com"
|
||||
public_addr = "203.0.113.10:443"
|
||||
[web.vhosts.decoy]
|
||||
mode = "http_upstream"
|
||||
upstream = "http://Example.COM:18081"
|
||||
resolve = "startup"
|
||||
[[web.vhosts.profiles]]
|
||||
user = "alice"
|
||||
secret_mode = "dd"
|
||||
"#;
|
||||
|
||||
fn source(text: &str) -> ParsedConfigSource {
|
||||
pipeline::parse_source_graph(ConfigSourceGraph {
|
||||
rendered: text.to_string(),
|
||||
source_contents: BTreeMap::new(),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn prepared(answers: &[&str]) -> LoadedConfig {
|
||||
let mut parsed = source(SOURCE);
|
||||
let answers: Vec<SocketAddr> = answers.iter().map(|addr| addr.parse().unwrap()).collect();
|
||||
prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Ok(answers.clone()))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
pipeline::finish(parsed).unwrap()
|
||||
}
|
||||
|
||||
fn endpoint(config: &ProxyConfig) -> (SocketAddr, &str) {
|
||||
let runtime = config.web.runtime.as_ref().unwrap();
|
||||
match &runtime.vhosts["proxy.example.com"].decoy {
|
||||
WebRuntimeDecoy::HttpUpstream { addr, authority } => (*addr, authority),
|
||||
_ => panic!("expected HTTP decoy"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_snapshot_deduplicates_normalized_origins_and_preserves_order() {
|
||||
let mut parsed = source(SOURCE);
|
||||
let mut second = parsed.config.web.vhosts[0].clone();
|
||||
second.host = "bsi.bund.de".to_string();
|
||||
second.decoy = WebDecoyConfig::HttpUpstream {
|
||||
upstream: "http://example.com:18081/".to_string(),
|
||||
resolve: WebDecoyResolve::Startup,
|
||||
};
|
||||
parsed.config.web.vhosts.push(second);
|
||||
let calls = AtomicUsize::new(0);
|
||||
prepare(&mut parsed.config, |host, port| {
|
||||
assert_eq!((host.as_str(), port), ("example.com", 18081));
|
||||
calls.fetch_add(1, Ordering::SeqCst);
|
||||
std::future::ready(Ok(vec![
|
||||
"[fd00::2]:18081".parse().unwrap(),
|
||||
"10.0.0.2:18081".parse().unwrap(),
|
||||
]))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let mut loaded = pipeline::finish(parsed).unwrap();
|
||||
for _ in 0..3 {
|
||||
loaded.config.validate_effective_web().unwrap();
|
||||
loaded.config.rebuild_runtime_web().unwrap();
|
||||
}
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
endpoint(&loaded.config),
|
||||
("[fd00::2]:18081".parse().unwrap(), "example.com:18081")
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.config
|
||||
.web
|
||||
.decoy_dns
|
||||
.origins
|
||||
.values()
|
||||
.next()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
!serde_json::to_string(&loaded.config)
|
||||
.unwrap()
|
||||
.contains("fd00::2")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_rejects_every_public_answer_including_non_selected() {
|
||||
for disallowed in [
|
||||
"8.8.8.8:18081",
|
||||
"[2001:4860:4860::8888]:18081",
|
||||
"[::ffff:127.0.0.1]:18081",
|
||||
] {
|
||||
let mut parsed = source(SOURCE);
|
||||
let error = prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Ok(vec![
|
||||
"127.0.0.2:18081".parse().unwrap(),
|
||||
disallowed.parse().unwrap(),
|
||||
]))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("must remain inside"));
|
||||
assert!(parsed.config.web.runtime.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_rejects_non_selected_direct_and_wildcard_listener_loops() {
|
||||
for listener in ["127.0.0.1", "0.0.0.0"] {
|
||||
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com:18080"));
|
||||
parsed.config.server.listeners[0].ip = listener.parse().unwrap();
|
||||
let error = prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Ok(vec![
|
||||
"[fd00::2]:18080".parse().unwrap(),
|
||||
"127.0.0.1:18080".parse().unwrap(),
|
||||
]))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("overlaps WEB listener"));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_empty_answers_and_resolver_errors_fail_closed() {
|
||||
let mut parsed = source(SOURCE);
|
||||
let error = prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Ok(Vec::new()))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("returned no addresses"));
|
||||
let error = prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"test resolver failure",
|
||||
)))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("DNS resolution failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_timeout_is_bounded() {
|
||||
let mut parsed = source(SOURCE);
|
||||
parsed.config.web.timeouts.decoy_resolve_secs = 1;
|
||||
let error = tokio::time::timeout(
|
||||
Duration::from_secs(3),
|
||||
prepare(&mut parsed.config, |_, _| {
|
||||
std::future::pending::<std::io::Result<Vec<SocketAddr>>>()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("timed out after 1 seconds"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_literal_startup_never_looks_up_and_keeps_ipv6_authority() {
|
||||
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "[::1]:18081"));
|
||||
prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Err(std::io::Error::other("IP literals must not reach DNS")))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = pipeline::finish(parsed).unwrap();
|
||||
assert_eq!(
|
||||
endpoint(&loaded.config),
|
||||
("[::1]:18081".parse().unwrap(), "[::1]:18081")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoy_dns_source_parse_is_not_a_prepared_runtime() {
|
||||
let parsed = source(SOURCE);
|
||||
assert!(parsed.config.web.runtime.is_none());
|
||||
assert!(parsed.config.web.decoy_dns.origins.is_empty());
|
||||
assert!(
|
||||
pipeline::finish(parsed)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("requires async preparation")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_evidence_is_bound_to_origin_and_port() {
|
||||
let loaded = prepared(&["10.0.0.2:18081"]).await;
|
||||
for changed in ["http://telekom.com:18081", "http://example.com:18082"] {
|
||||
let mut config = loaded.config.clone();
|
||||
config.web.vhosts[0].decoy = WebDecoyConfig::HttpUpstream {
|
||||
upstream: changed.to_string(),
|
||||
resolve: WebDecoyResolve::Startup,
|
||||
};
|
||||
assert!(
|
||||
config
|
||||
.validate_effective_web()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("requires async preparation")
|
||||
);
|
||||
assert!(config.rebuild_runtime_web().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_reload_pins_old_generation_and_detects_dns_only_changes() {
|
||||
let old = prepared(&["10.0.0.2:18081"]).await;
|
||||
let new = prepared(&["10.0.0.3:18081"]).await;
|
||||
assert_eq!(old.rendered_hash, new.rendered_hash);
|
||||
assert_eq!(
|
||||
serde_json::to_value(&old.config).unwrap(),
|
||||
serde_json::to_value(&new.config).unwrap()
|
||||
);
|
||||
let resolved =
|
||||
crate::maestro::runtime_build::resolve_reload_config(&old.config, &new.config).unwrap();
|
||||
assert!(resolved.runtime_changed);
|
||||
assert!(!old.config.web_decoy_endpoints_equal(&new.config));
|
||||
assert_eq!(endpoint(&old.config).0, "10.0.0.2:18081".parse().unwrap());
|
||||
let same = prepared(&["10.0.0.3:18081", "10.0.0.4:18081"]).await;
|
||||
assert!(same.config.web_decoy_endpoints_equal(&new.config));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_effective_listener_overlay_rechecks_every_answer() {
|
||||
let old = prepared(&["10.0.0.2:18081"]).await;
|
||||
let mut parsed = source(SOURCE);
|
||||
parsed.config.server.listeners[0].port = Some(18082);
|
||||
parsed.config.web.vhosts[0].decoy = WebDecoyConfig::HttpUpstream {
|
||||
upstream: "http://example.com:18080".to_string(),
|
||||
resolve: WebDecoyResolve::Startup,
|
||||
};
|
||||
prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Ok(vec![
|
||||
"10.0.0.2:18080".parse().unwrap(),
|
||||
"127.0.0.1:18080".parse().unwrap(),
|
||||
]))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let desired = pipeline::finish(parsed).unwrap();
|
||||
assert!(
|
||||
crate::maestro::runtime_build::resolve_reload_config(&old.config, &desired.config)
|
||||
.err()
|
||||
.unwrap()
|
||||
.contains("overlaps WEB listener")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_rejects_ipv6_direct_and_wildcard_listener_loops() {
|
||||
for listener in ["::1", "::"] {
|
||||
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com:18080"));
|
||||
parsed.config.network.ipv6 = Some(true);
|
||||
parsed.config.server.listeners[0].ip = listener.parse().unwrap();
|
||||
let error = prepare(&mut parsed.config, |_, _| {
|
||||
std::future::ready(Ok(vec![
|
||||
"10.0.0.2:18080".parse().unwrap(),
|
||||
"[::1]:18080".parse().unwrap(),
|
||||
]))
|
||||
})
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("overlaps WEB listener"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoy_dns_startup_does_not_relax_origin_url_rules() {
|
||||
for url in [
|
||||
"https://example.com",
|
||||
"http://user@example.com",
|
||||
"http://user:pass@example.com",
|
||||
"http://example.com/path",
|
||||
"http://example.com/?q=1",
|
||||
"http://example.com/#fragment",
|
||||
"http://example.com:0",
|
||||
] {
|
||||
assert!(
|
||||
parse_origin(0, url, WebDecoyResolve::Startup).is_err(),
|
||||
"accepted {url}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn decoy_dns_default_port_authority_and_evidence_port_fence() {
|
||||
let mut parsed = source(&SOURCE.replace("Example.COM:18081", "example.com"));
|
||||
prepare(&mut parsed.config, |_, port| {
|
||||
assert_eq!(port, 80);
|
||||
std::future::ready(Ok(vec!["127.0.0.2:80".parse().unwrap()]))
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let loaded = pipeline::finish(parsed).unwrap();
|
||||
assert_eq!(endpoint(&loaded.config).1, "example.com");
|
||||
let mut config = loaded.config.clone();
|
||||
Arc::make_mut(&mut config.web.decoy_dns).origins.insert(
|
||||
("example.com".to_string(), 80),
|
||||
vec!["127.0.0.2:81".parse().unwrap()],
|
||||
);
|
||||
assert!(
|
||||
config
|
||||
.validate_effective_web()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("port mismatch")
|
||||
);
|
||||
assert!(config.rebuild_runtime_web().is_err());
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
/// Normalizes source policy and authentication data before external decoy preparation.
|
||||
pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
||||
// Normalize optional TLS fetch scope: whitespace-only values disable scoped routing.
|
||||
config.censorship.tls_fetch_scope = config.censorship.tls_fetch_scope.trim().to_string();
|
||||
@@ -217,6 +218,5 @@ pub(super) fn apply(config: &mut ProxyConfig) -> Result<()> {
|
||||
validate_logging_config(&config.logging)?;
|
||||
validate_upstreams(config)?;
|
||||
config.rebuild_runtime_user_auth()?;
|
||||
config.rebuild_runtime_web()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
use super::*;
|
||||
|
||||
/// Completes legacy synchronous loads without performing hostname resolution.
|
||||
pub(super) fn load_source_graph(graph: ConfigSourceGraph) -> Result<LoadedConfig> {
|
||||
finish(parse_source_graph(graph)?)
|
||||
}
|
||||
|
||||
/// Normalizes source while leaving DNS and immutable site acquisition to preparation.
|
||||
pub(super) fn parse_source_graph(graph: ConfigSourceGraph) -> Result<ParsedConfigSource> {
|
||||
let (mut config, source_files, source_contents, processed) =
|
||||
decode::decode_source_graph(graph)?;
|
||||
validate_core::validate(&mut config)?;
|
||||
validate_runtime::validate(&mut config)?;
|
||||
validate_me::validate(&mut config)?;
|
||||
validate_server::validate(&mut config)?;
|
||||
validate_web::validate(&mut config)?;
|
||||
validate_web::validate_source(&mut config)?;
|
||||
effective::apply(&mut config)?;
|
||||
Ok(LoadedConfig {
|
||||
Ok(ParsedConfigSource {
|
||||
config,
|
||||
source_files: source_files.into_iter().collect(),
|
||||
source_contents,
|
||||
rendered_hash: hash_rendered_snapshot(&processed),
|
||||
})
|
||||
}
|
||||
|
||||
/// Completes synchronous validation and static acquisition after DNS is captured.
|
||||
pub(super) fn finish(mut parsed: ParsedConfigSource) -> Result<LoadedConfig> {
|
||||
parsed.config.validate_effective_web()?;
|
||||
parsed.config.rebuild_runtime_web()?;
|
||||
Ok(LoadedConfig {
|
||||
config: parsed.config,
|
||||
source_files: parsed.source_files,
|
||||
source_contents: parsed.source_contents,
|
||||
rendered_hash: parsed.rendered_hash,
|
||||
})
|
||||
}
|
||||
|
||||
impl ParsedConfigSource {
|
||||
/// Resolves each unique decoy origin once, then builds a fully validated candidate.
|
||||
pub(crate) async fn prepare(mut self) -> Result<LoadedConfig> {
|
||||
decoy_dns::prepare(&mut self.config, |host, port| async move {
|
||||
tokio::net::lookup_host((host.as_str(), port))
|
||||
.await
|
||||
.map(|answers| answers.collect())
|
||||
})
|
||||
.await?;
|
||||
tokio::task::spawn_blocking(move || finish(self))
|
||||
.await
|
||||
.map_err(|error| ProxyError::Config(format!("config preparation failed: {error}")))?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,8 +47,10 @@ pub(super) fn rebuild(config: &mut ProxyConfig) -> Result<()> {
|
||||
let mut static_bytes = 0usize;
|
||||
|
||||
let carrier_candidates: Arc<[WebCarrier]> = config.web.carrier_candidates().into();
|
||||
for vhost in &config.web.vhosts {
|
||||
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
|
||||
let decoy = build_decoy(
|
||||
config,
|
||||
vhost_idx,
|
||||
vhost,
|
||||
&config.web.limits,
|
||||
&mut static_files,
|
||||
@@ -182,42 +184,16 @@ fn client_secret(secret: [u8; 16], mode: WebSecretMode) -> ([u8; 17], usize) {
|
||||
}
|
||||
|
||||
fn build_decoy(
|
||||
config: &ProxyConfig,
|
||||
vhost_idx: usize,
|
||||
vhost: &WebVhostConfig,
|
||||
limits: &WebLimitsConfig,
|
||||
static_files: &mut usize,
|
||||
static_bytes: &mut usize,
|
||||
) -> Result<WebRuntimeDecoy> {
|
||||
match &vhost.decoy {
|
||||
WebDecoyConfig::HttpUpstream { upstream } => {
|
||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
||||
ProxyError::Config(format!(
|
||||
"WEB decoy upstream for `{}` is invalid: {error}",
|
||||
vhost.host
|
||||
))
|
||||
})?;
|
||||
let ip = match parsed.host() {
|
||||
Some(url::Host::Ipv4(ip)) => std::net::IpAddr::V4(ip),
|
||||
Some(url::Host::Ipv6(ip)) => std::net::IpAddr::V6(ip),
|
||||
_ => {
|
||||
return Err(ProxyError::Config(
|
||||
"WEB decoy host must be an IP literal".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let host = ip.to_string();
|
||||
let port = parsed.port_or_known_default().ok_or_else(|| {
|
||||
ProxyError::Config("WEB decoy port cannot be resolved".to_string())
|
||||
})?;
|
||||
let authority = match (ip, parsed.port()) {
|
||||
(std::net::IpAddr::V6(_), Some(_)) => format!("[{host}]:{port}"),
|
||||
(std::net::IpAddr::V6(_), None) => format!("[{host}]"),
|
||||
(std::net::IpAddr::V4(_), Some(_)) => format!("{host}:{port}"),
|
||||
(std::net::IpAddr::V4(_), None) => host.clone(),
|
||||
};
|
||||
Ok(WebRuntimeDecoy::HttpUpstream {
|
||||
addr: SocketAddr::new(ip, port),
|
||||
authority,
|
||||
})
|
||||
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
|
||||
decoy_dns::build_upstream(config, vhost_idx, upstream, *resolve)
|
||||
}
|
||||
WebDecoyConfig::StaticDirectory { directory, index } => {
|
||||
let site = load_static_site(directory, index, limits, static_files, static_bytes)?;
|
||||
|
||||
@@ -365,10 +365,11 @@ const WEB_TIMEOUTS_CONFIG_KEYS: &[&str] = &[
|
||||
"http_overload_timeout_ms",
|
||||
"shutdown_secs",
|
||||
"decoy_header_secs",
|
||||
"decoy_resolve_secs",
|
||||
];
|
||||
|
||||
const WEB_VHOST_CONFIG_KEYS: &[&str] = &["host", "base_path", "public_addr", "decoy", "profiles"];
|
||||
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index"];
|
||||
const WEB_DECOY_CONFIG_KEYS: &[&str] = &["mode", "upstream", "directory", "index", "resolve"];
|
||||
const WEB_PROFILE_CONFIG_KEYS: &[&str] = &[
|
||||
"user",
|
||||
"secret_mode",
|
||||
|
||||
@@ -27,6 +27,12 @@ const MAX_WEB_MEMORY_ENVELOPE_BYTES: usize = 4 * 1024 * 1024 * 1024;
|
||||
|
||||
/// Validates WEB policy and resource bounds before building runtime state.
|
||||
pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||
validate_source(config)?;
|
||||
validate_decoy_listener_separation(config)
|
||||
}
|
||||
|
||||
/// Validates source policy without requiring DNS evidence for opted-in origins.
|
||||
pub(super) fn validate_source(config: &mut ProxyConfig) -> Result<()> {
|
||||
let web_listener_count = config
|
||||
.server
|
||||
.listeners
|
||||
@@ -83,12 +89,19 @@ pub(super) fn validate(config: &mut ProxyConfig) -> Result<()> {
|
||||
timeouts::validate(&config.web.timeouts)?;
|
||||
websocket::validate(&carriers, &config.web.limits, &config.web.timeouts)?;
|
||||
validate_vhosts(config)?;
|
||||
validate_decoy_listener_separation(config)?;
|
||||
validate_decoy_listener_separation_inner(config, false)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rejects a direct decoy recursion into an effective WEB listener.
|
||||
pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result<()> {
|
||||
validate_decoy_listener_separation_inner(config, true)
|
||||
}
|
||||
|
||||
fn validate_decoy_listener_separation_inner(
|
||||
config: &ProxyConfig,
|
||||
require_resolved: bool,
|
||||
) -> Result<()> {
|
||||
let web_listeners = config
|
||||
.server
|
||||
.listeners
|
||||
@@ -101,30 +114,19 @@ pub(super) fn validate_decoy_listener_separation(config: &ProxyConfig) -> Result
|
||||
.map(|listener| SocketAddr::new(listener.ip, listener.port.unwrap_or(config.server.port)))
|
||||
.collect::<Vec<_>>();
|
||||
for (vhost_idx, vhost) in config.web.vhosts.iter().enumerate() {
|
||||
let WebDecoyConfig::HttpUpstream { upstream } = &vhost.decoy else {
|
||||
let WebDecoyConfig::HttpUpstream { upstream, resolve } = &vhost.decoy else {
|
||||
continue;
|
||||
};
|
||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
||||
ProxyError::Config(format!(
|
||||
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
let Some(port) = parsed.port_or_known_default() else {
|
||||
continue;
|
||||
};
|
||||
let upstream_ip = match parsed.host() {
|
||||
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
|
||||
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
|
||||
_ => continue,
|
||||
};
|
||||
let upstream_addr = SocketAddr::new(upstream_ip, port);
|
||||
if web_listeners
|
||||
.iter()
|
||||
.any(|listener| listener_covers(*listener, upstream_addr))
|
||||
{
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}"
|
||||
));
|
||||
let parsed = decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?;
|
||||
for upstream_addr in decoy_dns::addresses(config, vhost_idx, &parsed, require_resolved)? {
|
||||
if web_listeners
|
||||
.iter()
|
||||
.any(|listener| listener_covers(*listener, upstream_addr))
|
||||
{
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}].decoy upstream overlaps WEB listener {upstream_addr}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -26,6 +26,7 @@ pub(super) fn validate(timeouts: &WebTimeoutsConfig) -> Result<()> {
|
||||
("http_idle_secs", timeouts.http_idle_secs),
|
||||
("shutdown_secs", timeouts.shutdown_secs),
|
||||
("decoy_header_secs", timeouts.decoy_header_secs),
|
||||
("decoy_resolve_secs", timeouts.decoy_resolve_secs),
|
||||
];
|
||||
if let Some((field, _)) = values
|
||||
.into_iter()
|
||||
|
||||
@@ -150,45 +150,8 @@ pub(super) fn web_host_last_label_is_numeric(host: &str) -> bool {
|
||||
|
||||
pub(super) fn validate_decoy(vhost_idx: usize, decoy: &WebDecoyConfig) -> Result<()> {
|
||||
match decoy {
|
||||
WebDecoyConfig::HttpUpstream { upstream } => {
|
||||
let parsed = url::Url::parse(upstream).map_err(|error| {
|
||||
ProxyError::Config(format!(
|
||||
"web.vhosts[{vhost_idx}].decoy.upstream is invalid: {error}"
|
||||
))
|
||||
})?;
|
||||
if parsed.scheme() != "http"
|
||||
|| parsed.host_str().is_none()
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.query().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
|| parsed.path() != "/"
|
||||
|| parsed.port() == Some(0)
|
||||
{
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}].decoy.upstream must be an http origin without credentials, path, query, or fragment"
|
||||
));
|
||||
}
|
||||
let ip = match parsed.host() {
|
||||
Some(url::Host::Ipv4(ip)) => IpAddr::V4(ip),
|
||||
Some(url::Host::Ipv6(ip)) => IpAddr::V6(ip),
|
||||
_ => {
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}].decoy.upstream host must be a loopback or private IP literal"
|
||||
));
|
||||
}
|
||||
};
|
||||
let private = match ip {
|
||||
IpAddr::V4(ip) => ip.is_loopback() || ip.is_private() || ip.is_link_local(),
|
||||
IpAddr::V6(ip) => {
|
||||
ip.is_loopback() || ip.is_unique_local() || ip.is_unicast_link_local()
|
||||
}
|
||||
};
|
||||
if !private {
|
||||
return config_error(&format!(
|
||||
"web.vhosts[{vhost_idx}].decoy.upstream must remain inside loopback or a private network"
|
||||
));
|
||||
}
|
||||
WebDecoyConfig::HttpUpstream { upstream, resolve } => {
|
||||
decoy_dns::parse_origin(vhost_idx, upstream, *resolve)?;
|
||||
}
|
||||
WebDecoyConfig::StaticDirectory { directory, index } => {
|
||||
if !directory.is_absolute() {
|
||||
|
||||
Reference in New Issue
Block a user