feat(odoh): ship ODoH client + self-hosted relay (RFC 9230)
Client (mode = "odoh"): URL-query target routing per RFC 9230 §5,
/.well-known/odohconfigs TTL cache with 60s backoff on failure, HPKE
seal/open via odoh-rs, strict-mode default that SERVFAILs on relay
failure instead of silently downgrading. Host-equality config
validation rejects same-operator relay/target pairs.
Relay (`numa relay [PORT]`): axum server with /relay + /health.
SSRF-hardened hostname validator (RFC 1035 ASCII + dot + dash),
4 KiB body cap at the axum layer, 5s full-transaction timeout, and
static 502 on target failure (reqwest internals logged, not leaked).
Aggregate counters only — no per-request logs.
Observability: new `UpstreamTransport { Udp, Doh, Dot, Odoh }`
orthogonal to `QueryPath`, so /stats can tally wire protocols
symmetrically. Recursive mode records `Some(Udp)` for honest
"bytes egressing in cleartext" accounting.
Tests: Suite 8 exercises the client end-to-end via Frank Denis's
public relay + Cloudflare target; Suite 9 exercises `numa relay`
forwarding + guards against Cloudflare as the real far end. Full
probe script at tests/probe-odoh-ecosystem.sh verifies the entire
public ODoH ecosystem (4 targets + 1 relay per DNSCrypt's curated
list — confirms deploying Numa's relay doubles global supply).
This commit is contained in:
177
src/config.rs
177
src/config.rs
@@ -134,6 +134,7 @@ pub enum UpstreamMode {
|
||||
#[default]
|
||||
Forward,
|
||||
Recursive,
|
||||
Odoh,
|
||||
}
|
||||
|
||||
impl UpstreamMode {
|
||||
@@ -142,6 +143,7 @@ impl UpstreamMode {
|
||||
UpstreamMode::Auto => "auto",
|
||||
UpstreamMode::Forward => "forward",
|
||||
UpstreamMode::Recursive => "recursive",
|
||||
UpstreamMode::Odoh => "odoh",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,7 +156,7 @@ pub struct UpstreamConfig {
|
||||
pub address: Vec<String>,
|
||||
#[serde(default = "default_upstream_port")]
|
||||
pub port: u16,
|
||||
#[serde(default)]
|
||||
#[serde(default, deserialize_with = "string_or_vec")]
|
||||
pub fallback: Vec<String>,
|
||||
#[serde(default = "default_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
@@ -166,6 +168,20 @@ pub struct UpstreamConfig {
|
||||
pub prime_tlds: Vec<String>,
|
||||
#[serde(default = "default_srtt")]
|
||||
pub srtt: bool,
|
||||
|
||||
/// Only used when `mode = "odoh"`. Full https:// URL of the relay
|
||||
/// endpoint (including path, e.g. `https://odoh-relay.numa.rs/relay`).
|
||||
#[serde(default)]
|
||||
pub relay: Option<String>,
|
||||
/// Only used when `mode = "odoh"`. Full https:// URL of the target
|
||||
/// resolver (`https://odoh.cloudflare-dns.com/dns-query`).
|
||||
#[serde(default)]
|
||||
pub target: Option<String>,
|
||||
/// Only used when `mode = "odoh"`. When true (the default), relay failure
|
||||
/// returns SERVFAIL instead of downgrading to the `fallback` upstream —
|
||||
/// a user who configured ODoH rarely wants a silent non-oblivious path.
|
||||
#[serde(default)]
|
||||
pub strict: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for UpstreamConfig {
|
||||
@@ -180,10 +196,75 @@ impl Default for UpstreamConfig {
|
||||
root_hints: default_root_hints(),
|
||||
prime_tlds: default_prime_tlds(),
|
||||
srtt: default_srtt(),
|
||||
relay: None,
|
||||
target: None,
|
||||
strict: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parsed ODoH config fields. `mode = "odoh"` requires both URLs to be
|
||||
/// present, to parse as `https://`, and to resolve to distinct hosts.
|
||||
#[derive(Debug)]
|
||||
pub struct OdohUpstream {
|
||||
pub relay_url: String,
|
||||
pub target_host: String,
|
||||
pub target_path: String,
|
||||
pub strict: bool,
|
||||
}
|
||||
|
||||
impl UpstreamConfig {
|
||||
/// Validate and extract ODoH-specific fields. Called during `load_config`
|
||||
/// so misconfigured ODoH fails fast at startup, the same care we take
|
||||
/// with the DNSSEC strict boot check.
|
||||
pub fn odoh_upstream(&self) -> Result<OdohUpstream> {
|
||||
let relay = self
|
||||
.relay
|
||||
.as_deref()
|
||||
.ok_or("mode = \"odoh\" requires upstream.relay")?;
|
||||
let target = self
|
||||
.target
|
||||
.as_deref()
|
||||
.ok_or("mode = \"odoh\" requires upstream.target")?;
|
||||
|
||||
let relay_url = reqwest::Url::parse(relay)
|
||||
.map_err(|e| format!("upstream.relay invalid URL '{}': {}", relay, e))?;
|
||||
let target_url = reqwest::Url::parse(target)
|
||||
.map_err(|e| format!("upstream.target invalid URL '{}': {}", target, e))?;
|
||||
|
||||
if relay_url.scheme() != "https" || target_url.scheme() != "https" {
|
||||
return Err("upstream.relay and upstream.target must both use https://".into());
|
||||
}
|
||||
if relay_url.host_str().is_none() || target_url.host_str().is_none() {
|
||||
return Err("upstream.relay and upstream.target must include a host".into());
|
||||
}
|
||||
if relay_url.host_str() == target_url.host_str() {
|
||||
return Err(format!(
|
||||
"upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators",
|
||||
relay_url.host_str().unwrap_or("?")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let target_host = target_url
|
||||
.host_str()
|
||||
.ok_or("upstream.target has no host")?
|
||||
.to_string();
|
||||
let target_path = if target_url.path().is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
target_url.path().to_string()
|
||||
};
|
||||
|
||||
Ok(OdohUpstream {
|
||||
relay_url: relay.to_string(),
|
||||
target_host,
|
||||
target_path,
|
||||
strict: self.strict.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
@@ -643,12 +724,22 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_parses() {
|
||||
fn fallback_array_parses() {
|
||||
let config: Config =
|
||||
toml::from_str("[upstream]\nfallback = [\"8.8.8.8\", \"1.1.1.1\"]").unwrap();
|
||||
assert_eq!(config.upstream.fallback, vec!["8.8.8.8", "1.1.1.1"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_string_parses_as_singleton_vec() {
|
||||
let config: Config =
|
||||
toml::from_str("[upstream]\nfallback = \"tls://1.1.1.1#cloudflare-dns.com\"").unwrap();
|
||||
assert_eq!(
|
||||
config.upstream.fallback,
|
||||
vec!["tls://1.1.1.1#cloudflare-dns.com"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_address_gives_empty_vec() {
|
||||
let config: Config = toml::from_str("").unwrap();
|
||||
@@ -656,6 +747,88 @@ mod tests {
|
||||
assert!(config.upstream.fallback.is_empty());
|
||||
}
|
||||
|
||||
// ── [upstream] mode = "odoh" ────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn odoh_config_parses_and_validates() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://odoh-relay.numa.rs/relay"
|
||||
target = "https://odoh.cloudflare-dns.com/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(matches!(config.upstream.mode, UpstreamMode::Odoh));
|
||||
let odoh = config.upstream.odoh_upstream().unwrap();
|
||||
assert_eq!(odoh.relay_url, "https://odoh-relay.numa.rs/relay");
|
||||
assert_eq!(odoh.target_host, "odoh.cloudflare-dns.com");
|
||||
assert_eq!(odoh.target_path, "/dns-query");
|
||||
assert!(odoh.strict, "strict defaults to true under mode=odoh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_strict_false_is_honoured() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://odoh-relay.numa.rs/relay"
|
||||
target = "https://odoh.cloudflare-dns.com/dns-query"
|
||||
strict = false
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(!config.upstream.odoh_upstream().unwrap().strict);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_rejects_same_host_relay_and_target() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://odoh.example.com/relay"
|
||||
target = "https://odoh.example.com/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||
assert!(err.contains("same host"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_rejects_non_https() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "http://odoh-relay.numa.rs/relay"
|
||||
target = "https://odoh.cloudflare-dns.com/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||
assert!(err.contains("https"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_missing_relay_rejected() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
target = "https://odoh.cloudflare-dns.com/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||
assert!(err.contains("upstream.relay"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_missing_target_rejected() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://odoh-relay.numa.rs/relay"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||
assert!(err.contains("upstream.target"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── issue #82: [[forwarding]] config section ────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user