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:
119
src/forward.rs
119
src/forward.rs
@@ -1,14 +1,16 @@
|
||||
use std::fmt;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::RwLock;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::net::UdpSocket;
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::odoh::{query_through_relay, OdohConfigCache};
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::srtt::SrttCache;
|
||||
use crate::stats::UpstreamTransport;
|
||||
use crate::Result;
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -23,16 +25,34 @@ pub enum Upstream {
|
||||
tls_name: Option<String>,
|
||||
connector: tokio_rustls::TlsConnector,
|
||||
},
|
||||
/// Oblivious DNS-over-HTTPS (RFC 9230). Queries are HPKE-sealed to the
|
||||
/// target and forwarded through an independent relay. Target host lives
|
||||
/// on `target_config` (single source of truth — the cache keys on it).
|
||||
Odoh {
|
||||
relay_url: String,
|
||||
target_path: String,
|
||||
client: reqwest::Client,
|
||||
target_config: Arc<OdohConfigCache>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
/// IP address to key SRTT tracking on, if the upstream has a stable one.
|
||||
/// `Doh` routes through a URL + connection pool, so there's no single IP
|
||||
/// to track; SRTT is skipped for it.
|
||||
/// `Doh` and `Odoh` route through a URL + connection pool, so there's no
|
||||
/// single IP to track; SRTT is skipped for them.
|
||||
pub fn tracked_ip(&self) -> Option<IpAddr> {
|
||||
match self {
|
||||
Upstream::Udp(addr) | Upstream::Dot { addr, .. } => Some(addr.ip()),
|
||||
Upstream::Doh { .. } => None,
|
||||
Upstream::Doh { .. } | Upstream::Odoh { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transport(&self) -> UpstreamTransport {
|
||||
match self {
|
||||
Upstream::Udp(_) => UpstreamTransport::Udp,
|
||||
Upstream::Doh { .. } => UpstreamTransport::Doh,
|
||||
Upstream::Dot { .. } => UpstreamTransport::Dot,
|
||||
Upstream::Odoh { .. } => UpstreamTransport::Odoh,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,6 +63,20 @@ impl PartialEq for Upstream {
|
||||
(Self::Udp(a), Self::Udp(b)) => a == b,
|
||||
(Self::Doh { url: a, .. }, Self::Doh { url: b, .. }) => a == b,
|
||||
(Self::Dot { addr: a, .. }, Self::Dot { addr: b, .. }) => a == b,
|
||||
(
|
||||
Self::Odoh {
|
||||
relay_url: ra,
|
||||
target_path: pa,
|
||||
target_config: ca,
|
||||
..
|
||||
},
|
||||
Self::Odoh {
|
||||
relay_url: rb,
|
||||
target_path: pb,
|
||||
target_config: cb,
|
||||
..
|
||||
},
|
||||
) => ra == rb && pa == pb && ca.target_host() == cb.target_host(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
@@ -63,6 +97,18 @@ impl fmt::Display for Upstream {
|
||||
Some(name) => write!(f, "tls://{}#{}", addr, name),
|
||||
None => write!(f, "tls://{}", addr),
|
||||
},
|
||||
Upstream::Odoh {
|
||||
relay_url,
|
||||
target_path,
|
||||
target_config,
|
||||
..
|
||||
} => write!(
|
||||
f,
|
||||
"odoh://{}{} via {}",
|
||||
target_config.target_host(),
|
||||
target_path,
|
||||
relay_url
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,22 +128,20 @@ pub(crate) fn parse_upstream_addr(
|
||||
Err(format!("invalid upstream address: {}", s))
|
||||
}
|
||||
|
||||
/// Parse a slice of upstream address strings into `Upstream` values, failing
|
||||
/// on the first invalid entry.
|
||||
pub fn parse_upstream_list(addrs: &[String], default_port: u16) -> Result<Vec<Upstream>> {
|
||||
addrs
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, default_port))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
|
||||
if s.starts_with("https://") {
|
||||
let client = reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.http2_initial_stream_window_size(65_535)
|
||||
.http2_initial_connection_window_size(65_535)
|
||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||
.http2_keep_alive_while_idle(true)
|
||||
.http2_keep_alive_timeout(Duration::from_secs(10))
|
||||
.pool_idle_timeout(Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(1)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
return Ok(Upstream::Doh {
|
||||
url: s.to_string(),
|
||||
client,
|
||||
client: build_https_client(),
|
||||
});
|
||||
}
|
||||
// tls://IP:PORT#hostname or tls://IP#hostname (default port 853)
|
||||
@@ -118,6 +162,33 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
|
||||
Ok(Upstream::Udp(addr))
|
||||
}
|
||||
|
||||
/// HTTP/2 client tuned for DoH/ODoH: small windows for low latency, long-lived
|
||||
/// keep-alive. Shared by the DoH upstream and the ODoH config-fetcher +
|
||||
/// seal/open path. Pool defaults to one idle conn per host — good for
|
||||
/// resolvers that talk to a single upstream; relays that fan out to many
|
||||
/// targets should use [`build_https_client_with_pool`].
|
||||
pub fn build_https_client() -> reqwest::Client {
|
||||
build_https_client_with_pool(1)
|
||||
}
|
||||
|
||||
/// Same shape as [`build_https_client`], but caller picks
|
||||
/// `pool_max_idle_per_host`. Relay workloads hit many distinct target hosts
|
||||
/// and benefit from a larger pool so warm connections survive concurrent
|
||||
/// fan-out.
|
||||
pub fn build_https_client_with_pool(pool_max_idle_per_host: usize) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.use_rustls_tls()
|
||||
.http2_initial_stream_window_size(65_535)
|
||||
.http2_initial_connection_window_size(65_535)
|
||||
.http2_keep_alive_interval(Duration::from_secs(15))
|
||||
.http2_keep_alive_while_idle(true)
|
||||
.http2_keep_alive_timeout(Duration::from_secs(10))
|
||||
.pool_idle_timeout(Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(pool_max_idle_per_host)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_dot_connector() -> Result<tokio_rustls::TlsConnector> {
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let mut root_store = rustls::RootCertStore::empty();
|
||||
@@ -282,6 +353,22 @@ pub async fn forward_query_raw(
|
||||
tls_name,
|
||||
connector,
|
||||
} => forward_dot_raw(wire, *addr, tls_name, connector, timeout_duration).await,
|
||||
Upstream::Odoh {
|
||||
relay_url,
|
||||
target_path,
|
||||
client,
|
||||
target_config,
|
||||
} => {
|
||||
query_through_relay(
|
||||
wire,
|
||||
relay_url,
|
||||
target_path,
|
||||
client,
|
||||
target_config,
|
||||
timeout_duration,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user