feat: DNS-over-HTTPS upstream forwarding

Encrypt upstream queries via DoH — ISPs see HTTPS traffic on port 443,
not plaintext DNS on port 53. URL scheme determines transport:
https:// = DoH, bare IP = plain UDP. Falls back to Quad9 DoH when
system resolver cannot be detected.

- Upstream enum (Udp/Doh) with Display and PartialEq
- BytePacketBuffer::from_bytes constructor
- reqwest http2 feature for DoH server compatibility
- network_watch_loop guards against DoH→UDP silent downgrade
- 5 new tests (mock DoH server, HTTP errors, timeout)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Razvan Dimescu
2026-03-24 00:35:33 +02:00
parent 9c313ef06a
commit 007b8593c5
8 changed files with 294 additions and 25 deletions

View File

@@ -7,6 +7,7 @@ use log::{error, info};
use tokio::net::UdpSocket;
use numa::blocklist::{download_blocklists, parse_blocklist, BlocklistStore};
use numa::forward::Upstream;
use numa::buffer::BytePacketBuffer;
use numa::cache::DnsCache;
use numa::config::{build_zone_map, load_config, ConfigLoad};
@@ -111,13 +112,27 @@ async fn main() -> numa::Result<()> {
.default_upstream
.or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| {
info!("could not detect system DNS, falling back to 9.9.9.9 (Quad9)");
"9.9.9.9".to_string()
info!("could not detect system DNS, falling back to Quad9 DoH");
"https://dns.quad9.net/dns-query".to_string()
})
} else {
config.upstream.address.clone()
};
let upstream: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
let upstream: Upstream = if upstream_addr.starts_with("https://") {
let client = reqwest::Client::builder()
.use_rustls_tls()
.build()
.unwrap_or_default();
Upstream::Doh {
url: upstream_addr,
client,
}
} else {
let addr: SocketAddr = format!("{}:{}", upstream_addr, config.upstream.port).parse()?;
Upstream::Udp(addr)
};
let upstream_label = upstream.to_string();
let api_port = config.server.api_port;
let mut blocklist = BlocklistStore::new();
@@ -217,7 +232,7 @@ async fn main() -> numa::Result<()> {
let val_w = [
config.server.bind_addr.len(),
api_url.len(),
upstream.to_string().len(),
upstream_label.len(),
config_label.len(),
data_label.len(),
services_label.len(),
@@ -261,7 +276,7 @@ async fn main() -> numa::Result<()> {
row("DNS", g, &config.server.bind_addr);
row("API", g, &api_url);
row("Dashboard", g, &api_url);
row("Upstream", g, &upstream.to_string());
row("Upstream", g, &upstream_label);
row("Zones", g, &format!("{} records", zone_count));
row(
"Cache",
@@ -298,7 +313,7 @@ async fn main() -> numa::Result<()> {
info!(
"numa listening on {}, upstream {}, {} zone records, cache max {}, API on port {}",
config.server.bind_addr, upstream, zone_count, config.cache.max_entries, api_port,
config.server.bind_addr, upstream_label, zone_count, config.cache.max_entries, api_port,
);
// Download blocklists on startup
@@ -413,20 +428,24 @@ async fn network_watch_loop(ctx: Arc<numa::ctx::ServerCtx>) {
}
}
// Check upstream change every 30s or immediately on LAN IP change
// (heavier — spawns scutil/ipconfig, only when auto-detected)
if ctx.upstream_auto && (changed || tick.is_multiple_of(6)) {
// Re-detect upstream every 30s or on LAN IP change (UDP only —
// DoH upstreams are explicitly configured via URL, not auto-detected)
if ctx.upstream_auto
&& matches!(*ctx.upstream.lock().unwrap(), Upstream::Udp(_))
&& (changed || tick.is_multiple_of(6))
{
let dns_info = numa::system_dns::discover_system_dns();
let new_addr = dns_info
.default_upstream
.or_else(numa::system_dns::detect_dhcp_dns)
.unwrap_or_else(|| "9.9.9.9".to_string());
if let Ok(new_upstream) =
if let Ok(new_sock) =
format!("{}:{}", new_addr, ctx.upstream_port).parse::<SocketAddr>()
{
let new_upstream = Upstream::Udp(new_sock);
let mut upstream = ctx.upstream.lock().unwrap();
if new_upstream != *upstream {
info!("upstream changed: {} → {}", *upstream, new_upstream);
if *upstream != new_upstream {
info!("upstream changed: {} → {}", upstream, new_upstream);
*upstream = new_upstream;
changed = true;
}