Compare commits
15 Commits
v0.14.0
...
fix/self-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25ebdb311f | ||
|
|
51cce0347b | ||
|
|
459395203d | ||
|
|
10469e96bd | ||
|
|
31adc31c9b | ||
|
|
60600b045f | ||
|
|
3e6bf3feb0 | ||
|
|
8bed7c4649 | ||
|
|
5b1642c6dc | ||
|
|
01fda7891e | ||
|
|
5e84adbd94 | ||
|
|
15978a7859 | ||
|
|
193b38b85f | ||
|
|
4c685d1602 | ||
|
|
cd6e686a1a |
18
Cargo.lock
generated
18
Cargo.lock
generated
@@ -1547,7 +1547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "numa"
|
||||
version = "0.14.0"
|
||||
version = "0.14.1"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"axum",
|
||||
@@ -1562,6 +1562,7 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"log",
|
||||
"odoh-rs",
|
||||
"psl",
|
||||
"qrcode",
|
||||
"rand_core 0.9.5",
|
||||
"rcgen",
|
||||
@@ -1802,6 +1803,21 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl"
|
||||
version = "2.1.203"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76c0777260d32b76a8c3c197646707085d37e79d63b5872a29192c8d4f60f50b"
|
||||
dependencies = [
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "numa"
|
||||
version = "0.14.0"
|
||||
version = "0.14.1"
|
||||
authors = ["razvandimescu <razvan@dimescu.com>"]
|
||||
edition = "2021"
|
||||
description = "Portable DNS resolver in Rust — .numa local domains, ad blocking, developer overrides, DNS-over-HTTPS"
|
||||
@@ -30,6 +30,7 @@ tokio-rustls = "0.26"
|
||||
arc-swap = "1"
|
||||
ring = "0.17"
|
||||
odoh-rs = "1"
|
||||
psl = "2"
|
||||
# rand_core 0.9 matches the version odoh-rs (via hpke 0.13) depends on, so we
|
||||
# share one RngCore trait and OsRng impl across the dep tree.
|
||||
rand_core = { version = "0.9", features = ["os_rng"] }
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
**DNS you own. Everywhere you go.** — [numa.rs](https://numa.rs)
|
||||
|
||||
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), and override any hostname with auto-revert — all from your laptop, no cloud account or Raspberry Pi required.
|
||||
A portable DNS resolver in a single binary. Block ads on any network, name your local services (`frontend.numa`), override any hostname with auto-revert, and seal every outbound query with **ODoH (RFC 9230)** so no single party sees both who you are and what you asked — all from your laptop, no cloud account or Raspberry Pi required.
|
||||
|
||||
Built from scratch in Rust. Zero DNS libraries. RFC 1035 wire protocol parsed by hand. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). One ~8MB binary, everything embedded.
|
||||
Built from scratch in Rust. Zero DNS libraries. Caching, ad blocking, and local service domains out of the box. Optional recursive resolution from root nameservers with full DNSSEC chain-of-trust validation, plus a DNS-over-TLS listener for encrypted client connections (iOS Private DNS, systemd-resolved, etc.). Run `numa relay` and the same binary becomes a public ODoH endpoint too — the curated DNSCrypt list currently has one surviving relay, so every Numa deploy materially expands the ecosystem. One ~8MB binary, everything embedded.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -383,7 +383,7 @@ fn run_default(rt: &tokio::runtime::Runtime) {
|
||||
|
||||
/// Library-to-library: Numa forward_query_raw vs Hickory resolver.lookup.
|
||||
fn run_direct(rt: &tokio::runtime::Runtime) {
|
||||
let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse");
|
||||
let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse");
|
||||
let resolver = rt.block_on(build_hickory_resolver());
|
||||
let timeout = Duration::from_secs(10);
|
||||
|
||||
@@ -609,9 +609,11 @@ fn run_hedge_multi(rt: &tokio::runtime::Runtime, iterations: usize) {
|
||||
DOMAINS.len()
|
||||
);
|
||||
|
||||
let primary = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse");
|
||||
let primary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse");
|
||||
let secondary_dual = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse");
|
||||
let primary = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse");
|
||||
let primary_dual =
|
||||
numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse");
|
||||
let secondary_dual =
|
||||
numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse");
|
||||
let resolver = rt.block_on(build_hickory_resolver());
|
||||
|
||||
println!("Warming up...");
|
||||
@@ -810,7 +812,7 @@ fn run_diag(rt: &tokio::runtime::Runtime) {
|
||||
fn run_diag_clients(rt: &tokio::runtime::Runtime) {
|
||||
println!("Client diagnostic: reqwest vs Hickory (20 queries to {DOH_UPSTREAM})\n");
|
||||
|
||||
let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443).expect("failed to parse");
|
||||
let upstream = numa::forward::parse_upstream(DOH_UPSTREAM, 443, None).expect("failed to parse");
|
||||
let resolver = rt.block_on(build_hickory_resolver());
|
||||
let timeout = Duration::from_secs(10);
|
||||
|
||||
|
||||
@@ -1244,7 +1244,7 @@ async function refresh() {
|
||||
|
||||
// QPS calculation
|
||||
const now = Date.now();
|
||||
const encPct = encryptionPct(stats.transport);
|
||||
const encPct = encryptionPct(stats.transport, ['dot', 'doh'], ['udp', 'tcp', 'dot', 'doh']);
|
||||
if (prevTotal !== null && prevTime !== null) {
|
||||
const dt = (now - prevTime) / 1000;
|
||||
const dq = q.total - prevTotal;
|
||||
@@ -1273,6 +1273,7 @@ async function refresh() {
|
||||
renderMemory(stats.memory, stats);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[numa dashboard] render failed:', err);
|
||||
document.getElementById('statusDot').className = 'status-dot error';
|
||||
document.getElementById('statusText').textContent = 'disconnected';
|
||||
}
|
||||
|
||||
157
src/blocklist.rs
157
src/blocklist.rs
@@ -1,5 +1,5 @@
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use log::{info, warn};
|
||||
|
||||
@@ -355,27 +355,144 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download_blocklists(lists: &[String]) -> Vec<(String, String)> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.gzip(true)
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
const RETRY_DELAYS_SECS: &[u64] = &[2, 10, 30];
|
||||
|
||||
let mut results = Vec::new();
|
||||
pub async fn download_blocklists(
|
||||
lists: &[String],
|
||||
resolver: Option<std::sync::Arc<crate::bootstrap_resolver::NumaResolver>>,
|
||||
) -> Vec<(String, String)> {
|
||||
let mut builder = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(30))
|
||||
.gzip(true);
|
||||
if let Some(r) = resolver {
|
||||
builder = builder.dns_resolver(r);
|
||||
}
|
||||
let client = builder.build().unwrap_or_default();
|
||||
|
||||
for url in lists {
|
||||
match client.get(url).send().await {
|
||||
Ok(resp) => match resp.text().await {
|
||||
Ok(text) => {
|
||||
info!("downloaded blocklist: {} ({} bytes)", url, text.len());
|
||||
results.push((url.clone(), text));
|
||||
}
|
||||
Err(e) => warn!("failed to read blocklist body {}: {}", url, e),
|
||||
},
|
||||
Err(e) => warn!("failed to download blocklist {}: {}", url, e),
|
||||
let fetches = lists.iter().map(|url| {
|
||||
let client = &client;
|
||||
async move {
|
||||
let text = fetch_with_retry(client, url).await?;
|
||||
info!("downloaded blocklist: {} ({} bytes)", url, text.len());
|
||||
Some((url.clone(), text))
|
||||
}
|
||||
});
|
||||
futures::future::join_all(fetches)
|
||||
.await
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn fetch_with_retry(client: &reqwest::Client, url: &str) -> Option<String> {
|
||||
fetch_with_retry_delays(client, url, RETRY_DELAYS_SECS).await
|
||||
}
|
||||
|
||||
async fn fetch_with_retry_delays(
|
||||
client: &reqwest::Client,
|
||||
url: &str,
|
||||
delays: &[u64],
|
||||
) -> Option<String> {
|
||||
let total = delays.len() + 1;
|
||||
for attempt in 1..=total {
|
||||
match fetch_once(client, url).await {
|
||||
Ok(text) => return Some(text),
|
||||
Err(msg) if attempt < total => {
|
||||
let delay = delays[attempt - 1];
|
||||
warn!(
|
||||
"blocklist {} attempt {}/{} failed: {} — retrying in {}s",
|
||||
url, attempt, total, msg, delay
|
||||
);
|
||||
tokio::time::sleep(Duration::from_secs(delay)).await;
|
||||
}
|
||||
Err(msg) => {
|
||||
warn!(
|
||||
"blocklist {} attempt {}/{} failed: {} — giving up",
|
||||
url, attempt, total, msg
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
None
|
||||
}
|
||||
|
||||
async fn fetch_once(client: &reqwest::Client, url: &str) -> Result<String, String> {
|
||||
let resp = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format_error_chain(&e))?;
|
||||
resp.text().await.map_err(|e| format_error_chain(&e))
|
||||
}
|
||||
|
||||
fn format_error_chain(e: &(dyn std::error::Error + 'static)) -> String {
|
||||
let mut parts = vec![e.to_string()];
|
||||
let mut src = e.source();
|
||||
while let Some(s) = src {
|
||||
parts.push(s.to_string());
|
||||
src = s.source();
|
||||
}
|
||||
parts.join(": ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod retry_tests {
|
||||
use super::*;
|
||||
use std::net::SocketAddr;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
async fn flaky_http_server(drop_first_n: usize, body: &'static str) -> SocketAddr {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
for _ in 0..drop_first_n {
|
||||
if let Ok((sock, _)) = listener.accept().await {
|
||||
drop(sock);
|
||||
}
|
||||
}
|
||||
loop {
|
||||
let Ok((mut sock, _)) = listener.accept().await else {
|
||||
return;
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0u8; 2048];
|
||||
let _ = sock.read(&mut buf).await;
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{}",
|
||||
body.len(),
|
||||
body,
|
||||
);
|
||||
let _ = sock.write_all(response.as_bytes()).await;
|
||||
let _ = sock.shutdown().await;
|
||||
});
|
||||
}
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
fn zero_delays() -> Vec<u64> {
|
||||
vec![0; RETRY_DELAYS_SECS.len()]
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_succeeds_on_final_attempt() {
|
||||
let body = "ads.example.com\ntracker.example.net\n";
|
||||
let delays = zero_delays();
|
||||
let addr = flaky_http_server(delays.len(), body).await;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("http://{addr}/");
|
||||
let result = fetch_with_retry_delays(&client, &url, &delays).await;
|
||||
assert_eq!(result.as_deref(), Some(body));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retry_gives_up_when_all_attempts_fail() {
|
||||
let delays = zero_delays();
|
||||
let addr = flaky_http_server(delays.len() + 2, "unreachable").await;
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("http://{addr}/");
|
||||
let result = fetch_with_retry_delays(&client, &url, &delays).await;
|
||||
assert_eq!(result, None);
|
||||
}
|
||||
}
|
||||
|
||||
235
src/bootstrap_resolver.rs
Normal file
235
src/bootstrap_resolver.rs
Normal file
@@ -0,0 +1,235 @@
|
||||
//! `reqwest` DNS resolver used by numa-originated HTTPS (DoH upstream, ODoH
|
||||
//! relay/target, blocklist CDN). When numa is its own system resolver
|
||||
//! (`/etc/resolv.conf → 127.0.0.1`, HAOS add-on, Pi-hole-style container),
|
||||
//! the default `getaddrinfo` path loops back through numa before numa can
|
||||
//! answer — a chicken-and-egg that deadlocks cold boot. See issue #122 and
|
||||
//! `docs/implementation/bootstrap-resolver.md`.
|
||||
//!
|
||||
//! Resolution order per hostname:
|
||||
//! 1. Per-hostname overrides (e.g. ODoH `relay_ip` / `target_ip`) → return
|
||||
//! immediately, no DNS query. Preserves ODoH's "zero plain-DNS leak"
|
||||
//! property for configured endpoints.
|
||||
//! 2. Otherwise, query A + AAAA in parallel via UDP to IP-literal bootstrap
|
||||
//! servers, with TCP fallback on UDP timeout (for networks that block
|
||||
//! outbound UDP:53 — see memory: `project_network_udp_hostile.md`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use log::{debug, info, warn};
|
||||
use reqwest::dns::{Addrs, Name, Resolve, Resolving};
|
||||
|
||||
use crate::forward::{forward_tcp, forward_udp};
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::question::QueryType;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
const UDP_TIMEOUT: Duration = Duration::from_millis(800);
|
||||
const TCP_TIMEOUT: Duration = Duration::from_millis(1500);
|
||||
const DEFAULT_BOOTSTRAP: &[SocketAddr] = &[
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(9, 9, 9, 9)), 53),
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)), 53),
|
||||
];
|
||||
|
||||
pub struct NumaResolver {
|
||||
bootstrap: Vec<SocketAddr>,
|
||||
overrides: BTreeMap<String, Vec<IpAddr>>,
|
||||
}
|
||||
|
||||
impl NumaResolver {
|
||||
/// Build a resolver from the configured `upstream.fallback` list and any
|
||||
/// per-hostname overrides (e.g. ODoH's `relay_ip`/`target_ip`).
|
||||
///
|
||||
/// `fallback` entries are filtered to IP literals only — hostnames would
|
||||
/// re-introduce the self-loop inside the resolver itself. Empty or
|
||||
/// unusable fallback yields the hardcoded default (Quad9 + Cloudflare).
|
||||
pub fn new(fallback: &[String], overrides: BTreeMap<String, Vec<IpAddr>>) -> Self {
|
||||
let mut bootstrap: Vec<SocketAddr> = Vec::with_capacity(fallback.len());
|
||||
for entry in fallback {
|
||||
match crate::forward::parse_upstream_addr(entry, 53) {
|
||||
Ok(addr) => bootstrap.push(addr),
|
||||
Err(_) => {
|
||||
warn!(
|
||||
"bootstrap_resolver: skipping non-IP fallback '{}' \
|
||||
(hostnames would re-enter the self-loop)",
|
||||
entry
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let source = if bootstrap.is_empty() {
|
||||
bootstrap = DEFAULT_BOOTSTRAP.to_vec();
|
||||
"default (no IP-literal in upstream.fallback)"
|
||||
} else {
|
||||
"upstream.fallback"
|
||||
};
|
||||
let ips: Vec<String> = bootstrap.iter().map(|s| s.ip().to_string()).collect();
|
||||
info!(
|
||||
"bootstrap resolver: {} via {} — used for numa-originated HTTPS hostname resolution",
|
||||
ips.join(", "),
|
||||
source
|
||||
);
|
||||
if !overrides.is_empty() {
|
||||
let pairs: Vec<String> = overrides
|
||||
.iter()
|
||||
.flat_map(|(host, addrs)| addrs.iter().map(move |ip| format!("{}={}", host, ip)))
|
||||
.collect();
|
||||
info!(
|
||||
"bootstrap resolver: host overrides (skip DNS, connect direct): {}",
|
||||
pairs.join(", ")
|
||||
);
|
||||
}
|
||||
Self {
|
||||
bootstrap,
|
||||
overrides,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn bootstrap(&self) -> &[SocketAddr] {
|
||||
&self.bootstrap
|
||||
}
|
||||
}
|
||||
|
||||
impl Resolve for NumaResolver {
|
||||
fn resolve(&self, name: Name) -> Resolving {
|
||||
let hostname = name.as_str().to_string();
|
||||
|
||||
if let Some(ips) = self.overrides.get(&hostname) {
|
||||
let addrs: Vec<SocketAddr> = ips.iter().map(|ip| SocketAddr::new(*ip, 0)).collect();
|
||||
debug!(
|
||||
"bootstrap_resolver: override hit for {} → {:?}",
|
||||
hostname, ips
|
||||
);
|
||||
return Box::pin(async move { Ok(Box::new(addrs.into_iter()) as Addrs) });
|
||||
}
|
||||
|
||||
let bootstrap = self.bootstrap.clone();
|
||||
Box::pin(async move {
|
||||
let addrs = resolve_via_bootstrap(&hostname, &bootstrap).await?;
|
||||
debug!(
|
||||
"bootstrap_resolver: resolved {} → {} addr(s)",
|
||||
hostname,
|
||||
addrs.len()
|
||||
);
|
||||
Ok(Box::new(addrs.into_iter()) as Addrs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_via_bootstrap(
|
||||
hostname: &str,
|
||||
bootstrap: &[SocketAddr],
|
||||
) -> Result<Vec<SocketAddr>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut last_err: Option<String> = None;
|
||||
for &server in bootstrap {
|
||||
let q_a = DnsPacket::query(0xBEEF, hostname, QueryType::A);
|
||||
let q_aaaa = DnsPacket::query(0xBEF0, hostname, QueryType::AAAA);
|
||||
let (a_res, aaaa_res) = tokio::join!(
|
||||
query_with_tcp_fallback(&q_a, server),
|
||||
query_with_tcp_fallback(&q_aaaa, server),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
match a_res {
|
||||
Ok(pkt) => extract_addrs(&pkt, &mut out),
|
||||
Err(e) => last_err = Some(format!("{} A failed: {}", server, e)),
|
||||
}
|
||||
match aaaa_res {
|
||||
Ok(pkt) => extract_addrs(&pkt, &mut out),
|
||||
// AAAA is optional — many hosts return NXDOMAIN/empty. Don't
|
||||
// treat as the primary error if A succeeded.
|
||||
Err(e) => debug!("bootstrap {} AAAA for {} failed: {}", server, hostname, e),
|
||||
}
|
||||
if !out.is_empty() {
|
||||
return Ok(out);
|
||||
}
|
||||
}
|
||||
Err(last_err
|
||||
.unwrap_or_else(|| "no bootstrap servers reachable".into())
|
||||
.into())
|
||||
}
|
||||
|
||||
async fn query_with_tcp_fallback(
|
||||
query: &DnsPacket,
|
||||
server: SocketAddr,
|
||||
) -> crate::Result<DnsPacket> {
|
||||
match forward_udp(query, server, UDP_TIMEOUT).await {
|
||||
Ok(pkt) => Ok(pkt),
|
||||
Err(e) => {
|
||||
debug!(
|
||||
"bootstrap UDP {} failed ({}), falling back to TCP",
|
||||
server, e
|
||||
);
|
||||
forward_tcp(query, server, TCP_TIMEOUT).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_addrs(pkt: &DnsPacket, out: &mut Vec<SocketAddr>) {
|
||||
for r in &pkt.answers {
|
||||
match r {
|
||||
DnsRecord::A { addr, .. } => out.push(SocketAddr::new(IpAddr::V4(*addr), 0)),
|
||||
DnsRecord::AAAA { addr, .. } => out.push(SocketAddr::new(IpAddr::V6(*addr), 0)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn empty_fallback_uses_defaults() {
|
||||
let r = NumaResolver::new(&[], BTreeMap::new());
|
||||
let got: Vec<String> = r.bootstrap().iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(got, vec!["9.9.9.9:53", "1.1.1.1:53"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_accepts_ip_literals_only() {
|
||||
let fallback = vec![
|
||||
"9.9.9.9".to_string(),
|
||||
"dns.quad9.net".to_string(),
|
||||
"1.1.1.1:5353".to_string(),
|
||||
];
|
||||
let r = NumaResolver::new(&fallback, BTreeMap::new());
|
||||
let got: Vec<String> = r.bootstrap().iter().map(|s| s.to_string()).collect();
|
||||
assert_eq!(got, vec!["9.9.9.9:53", "1.1.1.1:5353"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_returns_configured_ips_without_dns() {
|
||||
let mut overrides = BTreeMap::new();
|
||||
overrides.insert(
|
||||
"odoh-relay.example".to_string(),
|
||||
vec![IpAddr::V4(Ipv4Addr::new(178, 104, 229, 30))],
|
||||
);
|
||||
let r = NumaResolver::new(&[], overrides);
|
||||
let name: Name = "odoh-relay.example".parse().unwrap();
|
||||
let fut = r.resolve(name);
|
||||
let res = futures::executor::block_on(fut).unwrap();
|
||||
let addrs: Vec<_> = res.collect();
|
||||
assert_eq!(addrs.len(), 1);
|
||||
assert_eq!(addrs[0].ip(), IpAddr::V4(Ipv4Addr::new(178, 104, 229, 30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn override_supports_multiple_ips_including_ipv6() {
|
||||
let mut overrides = BTreeMap::new();
|
||||
overrides.insert(
|
||||
"dual.example".to_string(),
|
||||
vec![
|
||||
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
|
||||
IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
],
|
||||
);
|
||||
let r = NumaResolver::new(&[], overrides);
|
||||
let res = futures::executor::block_on(r.resolve("dual.example".parse().unwrap())).unwrap();
|
||||
let addrs: Vec<_> = res.collect();
|
||||
assert_eq!(addrs.len(), 2);
|
||||
}
|
||||
}
|
||||
119
src/config.rs
119
src/config.rs
@@ -56,7 +56,7 @@ impl ForwardingRuleConfig {
|
||||
}
|
||||
let mut primary = Vec::with_capacity(self.upstream.len());
|
||||
for s in &self.upstream {
|
||||
let u = crate::forward::parse_upstream(s, 53)
|
||||
let u = crate::forward::parse_upstream(s, 53, None)
|
||||
.map_err(|e| format!("forwarding rule for upstream '{}': {}", s, e))?;
|
||||
primary.push(u);
|
||||
}
|
||||
@@ -241,6 +241,26 @@ pub struct OdohUpstream {
|
||||
pub target_bootstrap: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
impl OdohUpstream {
|
||||
/// Per-host IP overrides for the bootstrap resolver, lifted from
|
||||
/// `relay_ip`/`target_ip`. Keeps the "zero plain-DNS leak for ODoH
|
||||
/// endpoints" property when numa is its own system resolver.
|
||||
pub fn host_ip_overrides(&self) -> std::collections::BTreeMap<String, Vec<std::net::IpAddr>> {
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
if let Some(addr) = self.relay_bootstrap {
|
||||
out.entry(self.relay_host.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(addr.ip());
|
||||
}
|
||||
if let Some(addr) = self.target_bootstrap {
|
||||
out.entry(self.target_host.clone())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(addr.ip());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl UpstreamConfig {
|
||||
/// Validate and extract ODoH-specific fields. Called during `load_config`
|
||||
/// so misconfigured ODoH fails fast at startup, the same care we take
|
||||
@@ -263,25 +283,29 @@ impl UpstreamConfig {
|
||||
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 relay_host = relay_url
|
||||
.host_str()
|
||||
.ok_or("upstream.relay has no host")?
|
||||
.ok_or("upstream.relay must include a host")?
|
||||
.to_string();
|
||||
let target_host = target_url
|
||||
.host_str()
|
||||
.ok_or("upstream.target has no host")?
|
||||
.ok_or("upstream.target must include a host")?
|
||||
.to_string();
|
||||
|
||||
if relay_host == target_host {
|
||||
return Err(format!(
|
||||
"upstream.relay and upstream.target resolve to the same host ({}); the privacy property requires distinct operators",
|
||||
relay_host
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if let Some(shared) = shared_registrable_domain(&relay_host, &target_host) {
|
||||
return Err(format!(
|
||||
"upstream.relay ({}) and upstream.target ({}) share the registrable domain ({}); the privacy property requires distinct operators",
|
||||
relay_host, target_host, shared
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let target_path = if target_url.path().is_empty() {
|
||||
"/".to_string()
|
||||
} else {
|
||||
@@ -303,6 +327,20 @@ impl UpstreamConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the registrable domain (eTLD+1) shared by both hosts, if any.
|
||||
/// Fails open on hosts the PSL can't parse (IP literals, bare TLDs).
|
||||
fn shared_registrable_domain(relay_host: &str, target_host: &str) -> Option<String> {
|
||||
let relay = psl::domain(relay_host.as_bytes())?;
|
||||
let target = psl::domain(target_host.as_bytes())?;
|
||||
if relay.as_bytes() == target.as_bytes() {
|
||||
std::str::from_utf8(relay.as_bytes())
|
||||
.ok()
|
||||
.map(str::to_owned)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
@@ -830,6 +868,59 @@ target = "https://odoh.example.com/dns-query"
|
||||
assert!(err.contains("same host"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_rejects_shared_registrable_domain() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://r.cloudflare.com/relay"
|
||||
target = "https://odoh.cloudflare.com/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||
assert!(err.contains("registrable domain"), "got: {err}");
|
||||
assert!(err.contains("cloudflare.com"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_rejects_shared_registrable_under_multi_label_suffix() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://a.foo.co.uk/relay"
|
||||
target = "https://b.foo.co.uk/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
let err = config.upstream.odoh_upstream().unwrap_err().to_string();
|
||||
assert!(err.contains("foo.co.uk"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_accepts_distinct_registrable_under_multi_label_suffix() {
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://relay.foo.co.uk/relay"
|
||||
target = "https://target.bar.co.uk/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(config.upstream.odoh_upstream().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_accepts_distinct_private_psl_suffix_subdomains() {
|
||||
// *.github.io is a public suffix, so foo.github.io and bar.github.io
|
||||
// are independent registrable domains — accept.
|
||||
let toml = r#"
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://foo.github.io/relay"
|
||||
target = "https://bar.github.io/dns-query"
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(config.upstream.odoh_upstream().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn odoh_rejects_non_https() {
|
||||
let toml = r#"
|
||||
|
||||
177
src/ctx.rs
177
src/ctx.rs
@@ -209,106 +209,68 @@ pub async fn resolve_query(
|
||||
{
|
||||
// Conditional forwarding takes priority over recursive mode
|
||||
// (e.g. Tailscale .ts.net, VPC private zones)
|
||||
upstream_transport = pool.preferred().map(|u| u.transport());
|
||||
match forward_with_failover_raw(
|
||||
raw_wire,
|
||||
pool,
|
||||
&ctx.srtt,
|
||||
ctx.timeout,
|
||||
ctx.hedge_delay,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) {
|
||||
Ok(resp) => (resp, QueryPath::Forwarded, DnssecStatus::Indeterminate),
|
||||
Err(e) => {
|
||||
error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | FORWARD ERROR | {}",
|
||||
src_addr, qtype, qname, e
|
||||
);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
let key = (qname.clone(), qtype);
|
||||
let (resp, path, err) =
|
||||
resolve_coalesced(&ctx.inflight, key, &query, QueryPath::Forwarded, || async {
|
||||
let wire = forward_with_failover_raw(
|
||||
raw_wire,
|
||||
pool,
|
||||
&ctx.srtt,
|
||||
ctx.timeout,
|
||||
ctx.hedge_delay,
|
||||
)
|
||||
}
|
||||
.await?;
|
||||
cache_and_parse(ctx, &qname, qtype, &wire)
|
||||
})
|
||||
.await;
|
||||
log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "FORWARD");
|
||||
if path == QueryPath::Forwarded {
|
||||
upstream_transport = pool.preferred().map(|u| u.transport());
|
||||
}
|
||||
(resp, path, DnssecStatus::Indeterminate)
|
||||
} else if ctx.upstream_mode == UpstreamMode::Recursive {
|
||||
// Recursive resolution makes UDP hops to roots/TLDs/auths;
|
||||
// tag as Udp so the dashboard can aggregate plaintext-wire
|
||||
// egress honestly. Only mark on success — errors stay None.
|
||||
let key = (qname.clone(), qtype);
|
||||
let (resp, path, err) = resolve_coalesced(&ctx.inflight, key, &query, || {
|
||||
crate::recursive::resolve_recursive(
|
||||
&qname,
|
||||
qtype,
|
||||
&ctx.cache,
|
||||
&query,
|
||||
&ctx.root_hints,
|
||||
&ctx.srtt,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
if path == QueryPath::Coalesced {
|
||||
debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname);
|
||||
} else if path == QueryPath::UpstreamError {
|
||||
error!(
|
||||
"{} | {:?} {} | RECURSIVE ERROR | {}",
|
||||
src_addr,
|
||||
qtype,
|
||||
qname,
|
||||
err.as_deref().unwrap_or("leader failed")
|
||||
);
|
||||
} else {
|
||||
let (resp, path, err) =
|
||||
resolve_coalesced(&ctx.inflight, key, &query, QueryPath::Recursive, || {
|
||||
crate::recursive::resolve_recursive(
|
||||
&qname,
|
||||
qtype,
|
||||
&ctx.cache,
|
||||
&query,
|
||||
&ctx.root_hints,
|
||||
&ctx.srtt,
|
||||
)
|
||||
})
|
||||
.await;
|
||||
log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "RECURSIVE");
|
||||
if path == QueryPath::Recursive {
|
||||
upstream_transport = Some(crate::stats::UpstreamTransport::Udp);
|
||||
}
|
||||
(resp, path, DnssecStatus::Indeterminate)
|
||||
} else {
|
||||
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||
match forward_with_failover_raw(
|
||||
raw_wire,
|
||||
&pool,
|
||||
&ctx.srtt,
|
||||
ctx.timeout,
|
||||
ctx.hedge_delay,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(resp_wire) => match cache_and_parse(ctx, &qname, qtype, &resp_wire) {
|
||||
Ok(resp) => {
|
||||
upstream_transport = pool.preferred().map(|u| u.transport());
|
||||
(resp, QueryPath::Upstream, DnssecStatus::Indeterminate)
|
||||
}
|
||||
Err(e) => {
|
||||
error!("{} | {:?} {} | PARSE ERROR | {}", src_addr, qtype, qname, e);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
error!(
|
||||
"{} | {:?} {} | UPSTREAM ERROR | {}",
|
||||
src_addr, qtype, qname, e
|
||||
);
|
||||
(
|
||||
DnsPacket::response_from(&query, ResultCode::SERVFAIL),
|
||||
QueryPath::UpstreamError,
|
||||
DnssecStatus::Indeterminate,
|
||||
let key = (qname.clone(), qtype);
|
||||
let (resp, path, err) =
|
||||
resolve_coalesced(&ctx.inflight, key, &query, QueryPath::Upstream, || async {
|
||||
let wire = forward_with_failover_raw(
|
||||
raw_wire,
|
||||
&pool,
|
||||
&ctx.srtt,
|
||||
ctx.timeout,
|
||||
ctx.hedge_delay,
|
||||
)
|
||||
}
|
||||
.await?;
|
||||
cache_and_parse(ctx, &qname, qtype, &wire)
|
||||
})
|
||||
.await;
|
||||
log_coalesced_outcome(src_addr, qtype, &qname, path, err.as_deref(), "UPSTREAM");
|
||||
if path == QueryPath::Upstream {
|
||||
upstream_transport = pool.preferred().map(|u| u.transport());
|
||||
}
|
||||
(resp, path, DnssecStatus::Indeterminate)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -611,11 +573,15 @@ fn acquire_inflight(inflight: &Mutex<InflightMap>, key: (String, QueryType)) ->
|
||||
|
||||
/// Run a resolve function with in-flight coalescing. Multiple concurrent calls
|
||||
/// for the same key share a single resolution — the first caller (leader)
|
||||
/// executes `resolve_fn`, and followers wait for the broadcast result.
|
||||
/// executes `resolve_fn`, and followers wait for the broadcast result. The
|
||||
/// leader's successful path is tagged with `leader_path` so callers that
|
||||
/// share this helper (recursive, forwarded-rule, forward-upstream) keep their
|
||||
/// own observability without duplicating the inflight map.
|
||||
async fn resolve_coalesced<F, Fut>(
|
||||
inflight: &Mutex<InflightMap>,
|
||||
key: (String, QueryType),
|
||||
query: &DnsPacket,
|
||||
leader_path: QueryPath,
|
||||
resolve_fn: F,
|
||||
) -> (DnsPacket, QueryPath, Option<String>)
|
||||
where
|
||||
@@ -644,7 +610,7 @@ where
|
||||
match result {
|
||||
Ok(resp) => {
|
||||
let _ = tx.send(Some(resp.clone()));
|
||||
(resp, QueryPath::Recursive, None)
|
||||
(resp, leader_path, None)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(None);
|
||||
@@ -671,6 +637,33 @@ impl Drop for InflightGuard<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit the log lines shared by the three upstream branches (Forwarded,
|
||||
/// Recursive, Upstream) after `resolve_coalesced` returns. Leader-success
|
||||
/// and transport-tagging stay at the call site since they diverge per
|
||||
/// branch, but the Coalesced debug and UpstreamError error are identical
|
||||
/// except for the label.
|
||||
fn log_coalesced_outcome(
|
||||
src_addr: SocketAddr,
|
||||
qtype: QueryType,
|
||||
qname: &str,
|
||||
path: QueryPath,
|
||||
err: Option<&str>,
|
||||
label: &str,
|
||||
) {
|
||||
match path {
|
||||
QueryPath::Coalesced => debug!("{} | {:?} {} | COALESCED", src_addr, qtype, qname),
|
||||
QueryPath::UpstreamError => error!(
|
||||
"{} | {:?} {} | {} ERROR | {}",
|
||||
src_addr,
|
||||
qtype,
|
||||
qname,
|
||||
label,
|
||||
err.unwrap_or("leader failed")
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn special_use_response(query: &DnsPacket, qname: &str, qtype: QueryType) -> DnsPacket {
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
if qname == "ipv4only.arpa" {
|
||||
@@ -909,7 +902,7 @@ mod tests {
|
||||
let key = ("coalesce.test".to_string(), QueryType::A);
|
||||
let query = DnsPacket::query(100 + i, "coalesce.test", QueryType::A);
|
||||
handles.push(tokio::spawn(async move {
|
||||
resolve_coalesced(&inf, key, &query, || async {
|
||||
resolve_coalesced(&inf, key, &query, QueryPath::Recursive, || async {
|
||||
count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
Ok(mock_response("coalesce.test"))
|
||||
@@ -953,6 +946,7 @@ mod tests {
|
||||
&inf1,
|
||||
("same.domain".to_string(), QueryType::A),
|
||||
&query_a,
|
||||
QueryPath::Recursive,
|
||||
|| async {
|
||||
count1.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
@@ -966,6 +960,7 @@ mod tests {
|
||||
&inf2,
|
||||
("same.domain".to_string(), QueryType::AAAA),
|
||||
&query_aaaa,
|
||||
QueryPath::Recursive,
|
||||
|| async {
|
||||
count2.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
@@ -995,6 +990,7 @@ mod tests {
|
||||
&inflight,
|
||||
("will-fail.test".to_string(), QueryType::A),
|
||||
&query,
|
||||
QueryPath::Recursive,
|
||||
|| async { Err::<DnsPacket, _>("upstream timeout".into()) },
|
||||
)
|
||||
.await;
|
||||
@@ -1016,6 +1012,7 @@ mod tests {
|
||||
&inf,
|
||||
("fail.test".to_string(), QueryType::A),
|
||||
&query,
|
||||
QueryPath::Recursive,
|
||||
|| async {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
Err::<DnsPacket, _>("upstream error".into())
|
||||
@@ -1056,6 +1053,7 @@ mod tests {
|
||||
&inflight,
|
||||
("question.test".to_string(), QueryType::A),
|
||||
&query,
|
||||
QueryPath::Recursive,
|
||||
|| async { Err::<DnsPacket, _>("fail".into()) },
|
||||
)
|
||||
.await;
|
||||
@@ -1080,6 +1078,7 @@ mod tests {
|
||||
&inflight,
|
||||
("err-msg.test".to_string(), QueryType::A),
|
||||
&query,
|
||||
QueryPath::Recursive,
|
||||
|| async { Err::<DnsPacket, _>("connection refused by upstream".into()) },
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -113,10 +113,7 @@ impl fmt::Display for Upstream {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parse_upstream_addr(
|
||||
s: &str,
|
||||
default_port: u16,
|
||||
) -> std::result::Result<SocketAddr, String> {
|
||||
pub fn parse_upstream_addr(s: &str, default_port: u16) -> std::result::Result<SocketAddr, String> {
|
||||
// Try full socket addr first: "1.2.3.4:5353" or "[::1]:5353"
|
||||
if let Ok(addr) = s.parse::<SocketAddr>() {
|
||||
return Ok(addr);
|
||||
@@ -129,19 +126,28 @@ pub(crate) fn parse_upstream_addr(
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
/// on the first invalid entry. DoH entries use `resolver` (when provided) as
|
||||
/// their hostname resolver.
|
||||
pub fn parse_upstream_list(
|
||||
addrs: &[String],
|
||||
default_port: u16,
|
||||
resolver: Option<Arc<crate::bootstrap_resolver::NumaResolver>>,
|
||||
) -> Result<Vec<Upstream>> {
|
||||
addrs
|
||||
.iter()
|
||||
.map(|s| parse_upstream(s, default_port))
|
||||
.map(|s| parse_upstream(s, default_port, resolver.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
|
||||
pub fn parse_upstream(
|
||||
s: &str,
|
||||
default_port: u16,
|
||||
resolver: Option<Arc<crate::bootstrap_resolver::NumaResolver>>,
|
||||
) -> Result<Upstream> {
|
||||
if s.starts_with("https://") {
|
||||
return Ok(Upstream::Doh {
|
||||
url: s.to_string(),
|
||||
client: build_https_client(),
|
||||
client: build_https_client_with_resolver(1, resolver),
|
||||
});
|
||||
}
|
||||
// tls://IP:PORT#hostname or tls://IP#hostname (default port 853)
|
||||
@@ -163,12 +169,16 @@ pub fn parse_upstream(s: &str, default_port: u16) -> Result<Upstream> {
|
||||
}
|
||||
|
||||
/// 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`].
|
||||
/// keep-alive. 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`].
|
||||
///
|
||||
/// Uses the system resolver. Callers running inside `serve::run` pass the
|
||||
/// shared [`crate::bootstrap_resolver::NumaResolver`] via
|
||||
/// [`build_https_client_with_resolver`] to avoid the self-loop documented
|
||||
/// in `docs/implementation/bootstrap-resolver.md`.
|
||||
pub fn build_https_client() -> reqwest::Client {
|
||||
build_https_client_with_pool(1)
|
||||
build_https_client_with_resolver(1, None)
|
||||
}
|
||||
|
||||
/// Same shape as [`build_https_client`], but caller picks
|
||||
@@ -176,20 +186,18 @@ pub fn build_https_client() -> reqwest::Client {
|
||||
/// 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 {
|
||||
https_client_builder(pool_max_idle_per_host)
|
||||
.build()
|
||||
.unwrap_or_default()
|
||||
build_https_client_with_resolver(pool_max_idle_per_host, None)
|
||||
}
|
||||
|
||||
/// HTTPS client for the ODoH upstream, with bootstrap-IP overrides applied
|
||||
/// so relay/target hostname resolution can bypass system DNS.
|
||||
pub fn build_odoh_client(odoh: &crate::config::OdohUpstream) -> reqwest::Client {
|
||||
let mut builder = https_client_builder(1);
|
||||
if let Some(addr) = odoh.relay_bootstrap {
|
||||
builder = builder.resolve(&odoh.relay_host, addr);
|
||||
}
|
||||
if let Some(addr) = odoh.target_bootstrap {
|
||||
builder = builder.resolve(&odoh.target_host, addr);
|
||||
/// [`build_https_client`] with an optional custom DNS resolver. Numa wires
|
||||
/// [`crate::bootstrap_resolver::NumaResolver`] here.
|
||||
pub fn build_https_client_with_resolver(
|
||||
pool_max_idle_per_host: usize,
|
||||
resolver: Option<Arc<crate::bootstrap_resolver::NumaResolver>>,
|
||||
) -> reqwest::Client {
|
||||
let mut builder = https_client_builder(pool_max_idle_per_host);
|
||||
if let Some(r) = resolver {
|
||||
builder = builder.dns_resolver(r);
|
||||
}
|
||||
builder.build().unwrap_or_default()
|
||||
}
|
||||
@@ -553,6 +561,9 @@ async fn forward_doh_raw(
|
||||
|
||||
/// Send a lightweight keepalive query to a DoH upstream to prevent
|
||||
/// the HTTP/2 + TLS connection from going idle and being torn down.
|
||||
/// The first call doubles as a startup warm-up: bootstrap-resolver failures
|
||||
/// (unreachable Quad9/Cloudflare defaults, misconfigured hostname upstream)
|
||||
/// surface here rather than on the first client query.
|
||||
pub async fn keepalive_doh(upstream: &Upstream) {
|
||||
if let Upstream::Doh { url, client } = upstream {
|
||||
// Query for . NS — minimal, always succeeds, response is small
|
||||
@@ -565,7 +576,9 @@ pub async fn keepalive_doh(upstream: &Upstream) {
|
||||
0x00, 0x02, // type NS
|
||||
0x00, 0x01, // class IN
|
||||
];
|
||||
let _ = forward_doh_raw(wire, url, client, Duration::from_secs(5)).await;
|
||||
if let Err(e) = forward_doh_raw(wire, url, client, Duration::from_secs(5)).await {
|
||||
log::warn!("DoH keepalive to {} failed: {}", url, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod api;
|
||||
pub mod blocklist;
|
||||
pub mod bootstrap_resolver;
|
||||
pub mod buffer;
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
|
||||
55
src/serve.rs
55
src/serve.rs
@@ -13,12 +13,13 @@ use log::{error, info};
|
||||
use tokio::net::UdpSocket;
|
||||
|
||||
use crate::blocklist::{download_blocklists, parse_blocklist, BlocklistStore};
|
||||
use crate::bootstrap_resolver::NumaResolver;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::cache::DnsCache;
|
||||
use crate::config::{build_zone_map, load_config, ConfigLoad};
|
||||
use crate::ctx::{handle_query, ServerCtx};
|
||||
use crate::forward::{
|
||||
build_https_client, build_odoh_client, parse_upstream_list, Upstream, UpstreamPool,
|
||||
build_https_client_with_resolver, parse_upstream_list, Upstream, UpstreamPool,
|
||||
};
|
||||
use crate::odoh::OdohConfigCache;
|
||||
use crate::override_store::OverrideStore;
|
||||
@@ -48,6 +49,23 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
(dummy, "recursive (root hints)".to_string())
|
||||
};
|
||||
|
||||
// Routes numa-originated HTTPS (DoH upstream, ODoH relay/target, blocklist
|
||||
// CDN) away from the system resolver so lookups don't loop back through
|
||||
// numa when it's its own system DNS.
|
||||
// See `docs/implementation/bootstrap-resolver.md`.
|
||||
let resolver_overrides = match config.upstream.mode {
|
||||
crate::config::UpstreamMode::Odoh => config
|
||||
.upstream
|
||||
.odoh_upstream()
|
||||
.map(|o| o.host_ip_overrides())
|
||||
.unwrap_or_default(),
|
||||
_ => std::collections::BTreeMap::new(),
|
||||
};
|
||||
let bootstrap_resolver: Arc<NumaResolver> = Arc::new(NumaResolver::new(
|
||||
&config.upstream.fallback,
|
||||
resolver_overrides,
|
||||
));
|
||||
|
||||
let (resolved_mode, upstream_auto, pool, upstream_label) = match config.upstream.mode {
|
||||
crate::config::UpstreamMode::Auto => {
|
||||
info!("auto mode: probing recursive resolution...");
|
||||
@@ -57,7 +75,7 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
(crate::config::UpstreamMode::Recursive, false, pool, label)
|
||||
} else {
|
||||
log::warn!("recursive probe failed — falling back to Quad9 DoH");
|
||||
let client = build_https_client();
|
||||
let client = build_https_client_with_resolver(1, Some(bootstrap_resolver.clone()));
|
||||
let url = DOH_FALLBACK.to_string();
|
||||
let label = url.clone();
|
||||
let pool = UpstreamPool::new(vec![Upstream::Doh { url, client }], vec![]);
|
||||
@@ -82,8 +100,16 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
config.upstream.address.clone()
|
||||
};
|
||||
|
||||
let primary = parse_upstream_list(&addrs, config.upstream.port)?;
|
||||
let fallback = parse_upstream_list(&config.upstream.fallback, config.upstream.port)?;
|
||||
let primary = parse_upstream_list(
|
||||
&addrs,
|
||||
config.upstream.port,
|
||||
Some(bootstrap_resolver.clone()),
|
||||
)?;
|
||||
let fallback = parse_upstream_list(
|
||||
&config.upstream.fallback,
|
||||
config.upstream.port,
|
||||
Some(bootstrap_resolver.clone()),
|
||||
)?;
|
||||
|
||||
let pool = UpstreamPool::new(primary, fallback);
|
||||
let label = pool.label();
|
||||
@@ -96,7 +122,7 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
}
|
||||
crate::config::UpstreamMode::Odoh => {
|
||||
let odoh = config.upstream.odoh_upstream()?;
|
||||
let client = build_odoh_client(&odoh);
|
||||
let client = build_https_client_with_resolver(1, Some(bootstrap_resolver.clone()));
|
||||
let target_config = Arc::new(OdohConfigCache::new(
|
||||
odoh.target_host.clone(),
|
||||
client.clone(),
|
||||
@@ -110,7 +136,11 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
let fallback = if odoh.strict {
|
||||
Vec::new()
|
||||
} else {
|
||||
parse_upstream_list(&config.upstream.fallback, config.upstream.port)?
|
||||
parse_upstream_list(
|
||||
&config.upstream.fallback,
|
||||
config.upstream.port,
|
||||
Some(bootstrap_resolver.clone()),
|
||||
)?
|
||||
};
|
||||
let pool = UpstreamPool::new(primary, fallback);
|
||||
let label = pool.label();
|
||||
@@ -405,8 +435,9 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
if config.blocking.enabled && !blocklist_lists.is_empty() {
|
||||
let bl_ctx = Arc::clone(&ctx);
|
||||
let bl_lists = blocklist_lists.clone();
|
||||
let bl_resolver = bootstrap_resolver.clone();
|
||||
tokio::spawn(async move {
|
||||
load_blocklists(&bl_ctx, &bl_lists).await;
|
||||
load_blocklists(&bl_ctx, &bl_lists, Some(bl_resolver.clone())).await;
|
||||
|
||||
// Periodic refresh
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(refresh_hours * 3600));
|
||||
@@ -414,7 +445,7 @@ pub async fn run(config_path: String) -> crate::Result<()> {
|
||||
loop {
|
||||
interval.tick().await;
|
||||
info!("refreshing blocklists...");
|
||||
load_blocklists(&bl_ctx, &bl_lists).await;
|
||||
load_blocklists(&bl_ctx, &bl_lists, Some(bl_resolver.clone())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -596,8 +627,8 @@ async fn network_watch_loop(ctx: Arc<ServerCtx>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_blocklists(ctx: &ServerCtx, lists: &[String]) {
|
||||
let downloaded = download_blocklists(lists).await;
|
||||
async fn load_blocklists(ctx: &ServerCtx, lists: &[String], resolver: Option<Arc<NumaResolver>>) {
|
||||
let downloaded = download_blocklists(lists, resolver).await;
|
||||
|
||||
// Parse outside the lock to avoid blocking DNS queries during parse (~100ms)
|
||||
let mut all_domains = std::collections::HashSet::new();
|
||||
@@ -632,8 +663,10 @@ async fn warm_domain(ctx: &ServerCtx, domain: &str) {
|
||||
}
|
||||
|
||||
async fn doh_keepalive_loop(ctx: Arc<ServerCtx>) {
|
||||
// First tick fires immediately so we surface bootstrap-resolver failures
|
||||
// (unreachable Quad9/Cloudflare, blocked :53, bad upstream hostname) in
|
||||
// the startup logs instead of on the first client query.
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(25));
|
||||
interval.tick().await; // skip first immediate tick
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let pool = ctx.upstream_pool.lock().unwrap().clone();
|
||||
|
||||
155
tests/docker/self-resolver-loop.sh
Executable file
155
tests/docker/self-resolver-loop.sh
Executable file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Reproducer for issue #122 — chicken-and-egg when numa is its own system
|
||||
# resolver (HAOS add-on, Pi-hole-style container, laptop with
|
||||
# resolv.conf → 127.0.0.1).
|
||||
#
|
||||
# Topology:
|
||||
# container /etc/resolv.conf → nameserver 127.0.0.1
|
||||
# numa bound on :53 → upstream DoH by hostname (quad9)
|
||||
# numa boots → spawns blocklist download
|
||||
# reqwest::get → getaddrinfo("cdn.jsdelivr.net")
|
||||
# → loopback UDP :53 → numa → cache miss → DoH upstream
|
||||
# → getaddrinfo("dns.quad9.net") → same loop → glibc EAI_AGAIN
|
||||
#
|
||||
# Expected on master: both assertions FAIL (bug reproduced).
|
||||
# Expected after bootstrap-IP fix: both assertions PASS.
|
||||
#
|
||||
# Requirements: docker (with internet access for external lists/DoH)
|
||||
# Usage: ./tests/docker/self-resolver-loop.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
|
||||
GREEN="\033[32m"; RED="\033[31m"; RESET="\033[0m"
|
||||
|
||||
pass() { printf " ${GREEN}✓${RESET} %s\n" "$1"; }
|
||||
fail() { printf " ${RED}✗${RESET} %s\n" "$1"; printf " %s\n" "$2"; FAILED=$((FAILED+1)); }
|
||||
FAILED=0
|
||||
|
||||
OUT=/tmp/numa-self-resolver.out
|
||||
|
||||
echo "── self-resolver-loop: building + reproducing on debian:bookworm ──"
|
||||
echo " (first run is slow: image pull + cold cargo build, ~5-8 min)"
|
||||
echo
|
||||
|
||||
docker run --rm \
|
||||
-v "$PWD:/src:ro" \
|
||||
-v numa-self-resolver-cargo:/root/.cargo \
|
||||
-v numa-self-resolver-target:/work/target \
|
||||
debian:bookworm bash -c '
|
||||
set -e
|
||||
|
||||
# Phase 1: install deps + build with the container DNS as given by Docker
|
||||
# (resolves deb.debian.org, static.rust-lang.org, crates.io).
|
||||
apt-get update -qq && apt-get install -y -qq curl build-essential dnsutils 2>&1 | tail -3
|
||||
|
||||
if ! command -v cargo &>/dev/null; then
|
||||
curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --quiet
|
||||
fi
|
||||
. "$HOME/.cargo/env"
|
||||
|
||||
mkdir -p /work
|
||||
tar -C /src --exclude=./target --exclude=./.git -cf - . | tar -C /work -xf -
|
||||
cd /work
|
||||
|
||||
echo "── cargo build --release --locked ──"
|
||||
cargo build --release --locked 2>&1 | tail -5
|
||||
echo
|
||||
|
||||
# Phase 2: flip system DNS to numa itself — this is the pathological
|
||||
# topology from issue #122 (HAOS add-on, resolv.conf → 127.0.0.1).
|
||||
# Everything after this point, any getaddrinfo call inside numa loops
|
||||
# back through :53.
|
||||
echo "nameserver 127.0.0.1" > /etc/resolv.conf
|
||||
echo "── /etc/resolv.conf inside container (post-flip) ──"
|
||||
cat /etc/resolv.conf
|
||||
echo
|
||||
|
||||
cat > /tmp/numa.toml <<CONF
|
||||
[server]
|
||||
bind_addr = "0.0.0.0:53"
|
||||
api_port = 5380
|
||||
api_bind_addr = "127.0.0.1"
|
||||
data_dir = "/tmp/numa-data"
|
||||
|
||||
[upstream]
|
||||
mode = "forward"
|
||||
address = ["https://dns.quad9.net/dns-query"]
|
||||
timeout_ms = 3000
|
||||
|
||||
[blocking]
|
||||
enabled = true
|
||||
lists = ["https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/hosts/pro.txt"]
|
||||
CONF
|
||||
|
||||
mkdir -p /tmp/numa-data
|
||||
|
||||
echo "── starting numa ──"
|
||||
RUST_LOG=info ./target/release/numa /tmp/numa.toml > /tmp/numa.log 2>&1 &
|
||||
NUMA_PID=$!
|
||||
|
||||
# Wait up to 120s for blocklist to populate.
|
||||
# Retry delays 2+10+30s = 42s, plus ~4 × ~10s getaddrinfo timeouts under
|
||||
# self-loop = ~82s worst case. 120s leaves headroom.
|
||||
LOADED=0
|
||||
for i in $(seq 1 120); do
|
||||
LOADED=$(curl -sf http://127.0.0.1:5380/blocking/stats 2>/dev/null \
|
||||
| grep -o "\"domains_loaded\":[0-9]*" | cut -d: -f2 || echo 0)
|
||||
[ "${LOADED:-0}" -gt 100 ] && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# First cold DoH query — time it.
|
||||
START=$(date +%s%N)
|
||||
dig @127.0.0.1 example.com A +time=15 +tries=1 > /tmp/dig.out 2>&1 || true
|
||||
END=$(date +%s%N)
|
||||
LATENCY_MS=$(( (END - START) / 1000000 ))
|
||||
STATUS=$(grep -oE "status: [A-Z]+" /tmp/dig.out | head -1 || echo "status: TIMEOUT")
|
||||
|
||||
kill $NUMA_PID 2>/dev/null || true
|
||||
wait $NUMA_PID 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "=== RESULT ==="
|
||||
echo "domains_loaded=$LOADED"
|
||||
echo "first_query_latency_ms=$LATENCY_MS"
|
||||
echo "first_query_${STATUS// /_}"
|
||||
echo
|
||||
echo "=== numa.log (tail 40) ==="
|
||||
tail -40 /tmp/numa.log
|
||||
echo
|
||||
echo "=== dig.out ==="
|
||||
cat /tmp/dig.out
|
||||
' 2>&1 | tee "$OUT"
|
||||
|
||||
echo
|
||||
echo "── assertions ──"
|
||||
|
||||
LOADED=$(grep '^domains_loaded=' "$OUT" | tail -1 | cut -d= -f2 || echo 0)
|
||||
LATENCY=$(grep '^first_query_latency_ms=' "$OUT" | tail -1 | cut -d= -f2 || echo 999999)
|
||||
STATUS_LINE=$(grep '^first_query_status_' "$OUT" | tail -1 || echo "first_query_status_TIMEOUT")
|
||||
|
||||
if [ "${LOADED:-0}" -gt 100 ]; then
|
||||
pass "blocklist downloaded (domains_loaded=$LOADED)"
|
||||
else
|
||||
fail "blocklist downloaded (got domains_loaded=${LOADED:-0}, expected >100)" \
|
||||
"chicken-and-egg: blocklist HTTPS client has no DNS bootstrap; getaddrinfo loops through numa"
|
||||
fi
|
||||
|
||||
if [ "${LATENCY:-999999}" -lt 2000 ]; then
|
||||
pass "first DoH query under 2s (latency=${LATENCY}ms, $STATUS_LINE)"
|
||||
else
|
||||
fail "first DoH query under 2s (got ${LATENCY}ms, $STATUS_LINE)" \
|
||||
"self-loop on getaddrinfo(upstream_host); plain DoH needs bootstrap-IP symmetry with ODoH"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
printf "${GREEN}── self-resolver-loop passed (fix is in place) ──${RESET}\n"
|
||||
exit 0
|
||||
else
|
||||
printf "${RED}── self-resolver-loop failed ($FAILED assertion(s)) — bug #122 reproduced ──${RESET}\n"
|
||||
exit 1
|
||||
fi
|
||||
@@ -975,6 +975,50 @@ check "Same-host relay+target rejected at startup" \
|
||||
"same host" \
|
||||
"$STARTUP_OUT"
|
||||
|
||||
# Guards ODoH's zero-plain-DNS-leak property: relay_ip / target_ip must
|
||||
# land in the bootstrap resolver's override map so reqwest connects direct
|
||||
# to the configured IPs instead of resolving the hostnames via plain DNS.
|
||||
# RFC 5737 TEST-NET-1 IPs (unroutable).
|
||||
cat > "$CONFIG" << 'CONF'
|
||||
[server]
|
||||
bind_addr = "127.0.0.1:5354"
|
||||
api_port = 5381
|
||||
|
||||
[upstream]
|
||||
mode = "odoh"
|
||||
relay = "https://odoh-relay.example.com/proxy"
|
||||
target = "https://odoh-target.example.org/dns-query"
|
||||
relay_ip = "192.0.2.1"
|
||||
target_ip = "192.0.2.2"
|
||||
|
||||
[cache]
|
||||
max_entries = 10000
|
||||
|
||||
[blocking]
|
||||
enabled = false
|
||||
|
||||
[proxy]
|
||||
enabled = false
|
||||
CONF
|
||||
|
||||
RUST_LOG=info "$BINARY" "$CONFIG" > "$LOG" 2>&1 &
|
||||
NUMA_PID=$!
|
||||
for _ in $(seq 1 30); do
|
||||
curl -sf "http://127.0.0.1:$API_PORT/health" >/dev/null 2>&1 && break
|
||||
sleep 0.1
|
||||
done
|
||||
|
||||
OVERRIDE_LOG=$(grep 'bootstrap resolver: host overrides' "$LOG" || true)
|
||||
check "relay_ip wired into bootstrap override map" \
|
||||
"odoh-relay.example.com=192.0.2.1" \
|
||||
"$OVERRIDE_LOG"
|
||||
check "target_ip wired into bootstrap override map" \
|
||||
"odoh-target.example.org=192.0.2.2" \
|
||||
"$OVERRIDE_LOG"
|
||||
|
||||
kill "$NUMA_PID" 2>/dev/null || true
|
||||
wait "$NUMA_PID" 2>/dev/null || true
|
||||
|
||||
fi # end Suite 8
|
||||
|
||||
# ---- Suite 9: Numa's own ODoH relay (--relay-mode) ----
|
||||
|
||||
Reference in New Issue
Block a user