feat: DoH server endpoint + DoT enabled by default (#79)
* chore: document multi-forwarder and cache warming in config and README Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: DNS-over-HTTPS server endpoint (RFC 8484) Serve DoH at POST /dns-query on the existing HTTPS proxy (port 443). Automatically enabled when proxy TLS is active — no config needed. Also fix zone map priority so local zones override RFC 6762 .local special-use handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove GoatCounter analytics from site GoatCounter domains (goatcounter.com, gc.zgo.at) are blocked by Hagezi Pro, which is Numa's default blocklist. A DNS privacy tool should not embed analytics that its own resolver blocks. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: enable DoT listener by default DoT now starts automatically with `sudo numa`, matching the proxy and DoH which are already on by default. The self-signed CA infrastructure is shared with the proxy, so there is no additional setup. This makes `numa setup-phone` work out of the box. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #79.
This commit is contained in:
@@ -411,7 +411,7 @@ pub struct DnssecConfig {
|
||||
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct DotConfig {
|
||||
#[serde(default)]
|
||||
#[serde(default = "default_dot_enabled")]
|
||||
pub enabled: bool,
|
||||
#[serde(default = "default_dot_port")]
|
||||
pub port: u16,
|
||||
@@ -428,7 +428,7 @@ pub struct DotConfig {
|
||||
impl Default for DotConfig {
|
||||
fn default() -> Self {
|
||||
DotConfig {
|
||||
enabled: false,
|
||||
enabled: default_dot_enabled(),
|
||||
port: default_dot_port(),
|
||||
bind_addr: default_dot_bind_addr(),
|
||||
cert_path: None,
|
||||
@@ -437,6 +437,9 @@ impl Default for DotConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_dot_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_dot_port() -> u16 {
|
||||
853
|
||||
}
|
||||
|
||||
@@ -110,6 +110,10 @@ pub async fn resolve_query(
|
||||
300,
|
||||
));
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
resp.answers = records.clone();
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else if is_special_use_domain(&qname) {
|
||||
// RFC 6761/8880: private PTR, DDR, NAT64 — answer locally
|
||||
let resp = special_use_response(&query, &qname, qtype);
|
||||
@@ -158,10 +162,6 @@ pub async fn resolve_query(
|
||||
60,
|
||||
));
|
||||
(resp, QueryPath::Blocked, DnssecStatus::Indeterminate)
|
||||
} else if let Some(records) = ctx.zone_map.get(qname.as_str()).and_then(|m| m.get(&qtype)) {
|
||||
let mut resp = DnsPacket::response_from(&query, ResultCode::NOERROR);
|
||||
resp.answers = records.clone();
|
||||
(resp, QueryPath::Local, DnssecStatus::Indeterminate)
|
||||
} else {
|
||||
let cached = ctx.cache.read().unwrap().lookup_with_status(&qname, qtype);
|
||||
if let Some((cached, cached_dnssec)) = cached {
|
||||
|
||||
188
src/doh.rs
Normal file
188
src/doh.rs
Normal file
@@ -0,0 +1,188 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use hyper::StatusCode;
|
||||
use log::warn;
|
||||
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::ctx::{resolve_query, ServerCtx};
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
|
||||
const MAX_DNS_MSG: usize = 4096;
|
||||
const DOH_CONTENT_TYPE: &str = "application/dns-message";
|
||||
|
||||
pub async fn doh_post(State(state): State<super::proxy::DohState>, req: Request) -> Response {
|
||||
let host = super::proxy::extract_host(&req);
|
||||
if !is_doh_host(host.as_deref(), &state.ctx.proxy_tld) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
let content_type = req
|
||||
.headers()
|
||||
.get(hyper::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
if !content_type.starts_with(DOH_CONTENT_TYPE) {
|
||||
return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response();
|
||||
}
|
||||
|
||||
let body = match axum::body::to_bytes(req.into_body(), MAX_DNS_MSG).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return (StatusCode::PAYLOAD_TOO_LARGE, "body exceeds 4096 bytes").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if body.is_empty() {
|
||||
return (StatusCode::BAD_REQUEST, "empty body").into_response();
|
||||
}
|
||||
|
||||
let src = state
|
||||
.remote_addr
|
||||
.unwrap_or_else(|| SocketAddr::from(([127, 0, 0, 1], 0)));
|
||||
|
||||
resolve_doh(&body, src, &state.ctx).await
|
||||
}
|
||||
|
||||
fn is_doh_host(host: Option<&str>, tld: &str) -> bool {
|
||||
match host {
|
||||
Some(h) if h == tld => true,
|
||||
Some(h) => {
|
||||
h.len() == 2 * tld.len() + 1
|
||||
&& h.starts_with(tld)
|
||||
&& h.as_bytes().get(tld.len()) == Some(&b'.')
|
||||
&& h.ends_with(tld)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_doh(dns_bytes: &[u8], src: SocketAddr, ctx: &ServerCtx) -> Response {
|
||||
let mut buffer = BytePacketBuffer::from_bytes(dns_bytes);
|
||||
let query = match DnsPacket::from_buffer(&mut buffer) {
|
||||
Ok(q) => q,
|
||||
Err(e) => {
|
||||
warn!("DoH: parse error from {}: {}", src, e);
|
||||
let query_id = u16::from_be_bytes([
|
||||
dns_bytes.first().copied().unwrap_or(0),
|
||||
dns_bytes.get(1).copied().unwrap_or(0),
|
||||
]);
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query_id;
|
||||
resp.header.response = true;
|
||||
resp.header.rescode = ResultCode::FORMERR;
|
||||
return serialize_response(&resp);
|
||||
}
|
||||
};
|
||||
|
||||
let query_id = query.header.id;
|
||||
let query_rd = query.header.recursion_desired;
|
||||
let questions = query.questions.clone();
|
||||
|
||||
match resolve_query(query, src, ctx).await {
|
||||
Ok(resp_buffer) => {
|
||||
let min_ttl = extract_min_ttl(resp_buffer.filled());
|
||||
dns_response(resp_buffer.filled(), min_ttl)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("DoH: resolve error for {}: {}", src, e);
|
||||
let mut resp = DnsPacket::new();
|
||||
resp.header.id = query_id;
|
||||
resp.header.response = true;
|
||||
resp.header.recursion_desired = query_rd;
|
||||
resp.header.recursion_available = true;
|
||||
resp.header.rescode = ResultCode::SERVFAIL;
|
||||
resp.questions = questions;
|
||||
serialize_response(&resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_min_ttl(wire: &[u8]) -> u32 {
|
||||
let mut buf = BytePacketBuffer::from_bytes(wire);
|
||||
match DnsPacket::from_buffer(&mut buf) {
|
||||
Ok(pkt) => pkt.answers.iter().map(|r| r.ttl()).min().unwrap_or(0),
|
||||
Err(_) => 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn dns_response(wire: &[u8], min_ttl: u32) -> Response {
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
(hyper::header::CONTENT_TYPE, DOH_CONTENT_TYPE),
|
||||
(
|
||||
hyper::header::CACHE_CONTROL,
|
||||
&format!("max-age={}", min_ttl),
|
||||
),
|
||||
],
|
||||
Bytes::copy_from_slice(wire),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn serialize_response(pkt: &DnsPacket) -> Response {
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
match pkt.write(&mut buf) {
|
||||
Ok(_) => dns_response(buf.filled(), 0),
|
||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::buffer::BytePacketBuffer;
|
||||
use crate::header::ResultCode;
|
||||
use crate::packet::DnsPacket;
|
||||
use crate::record::DnsRecord;
|
||||
|
||||
#[test]
|
||||
fn is_doh_host_matches_tld() {
|
||||
assert!(is_doh_host(Some("numa"), "numa"));
|
||||
assert!(is_doh_host(Some("numa.numa"), "numa"));
|
||||
assert!(!is_doh_host(Some("foo.numa"), "numa"));
|
||||
assert!(!is_doh_host(None, "numa"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_min_ttl_from_response() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.response = true;
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".to_string(),
|
||||
addr: std::net::Ipv4Addr::new(1, 2, 3, 4),
|
||||
ttl: 300,
|
||||
});
|
||||
pkt.answers.push(DnsRecord::A {
|
||||
domain: "example.com".to_string(),
|
||||
addr: std::net::Ipv4Addr::new(5, 6, 7, 8),
|
||||
ttl: 60,
|
||||
});
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
assert_eq!(extract_min_ttl(buf.filled()), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_min_ttl_no_answers() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.response = true;
|
||||
let mut buf = BytePacketBuffer::new();
|
||||
pkt.write(&mut buf).unwrap();
|
||||
assert_eq!(extract_min_ttl(buf.filled()), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialize_formerr_response() {
|
||||
let mut pkt = DnsPacket::new();
|
||||
pkt.header.id = 0xABCD;
|
||||
pkt.header.response = true;
|
||||
pkt.header.rescode = ResultCode::FORMERR;
|
||||
let resp = serialize_response(&pkt);
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
@@ -73,11 +73,15 @@ impl HealthMeta {
|
||||
recursive_enabled: bool,
|
||||
mdns_enabled: bool,
|
||||
blocking_enabled: bool,
|
||||
doh_enabled: bool,
|
||||
) -> Self {
|
||||
let ca_path = data_dir.join("ca.pem");
|
||||
let ca_fingerprint_sha256 = compute_ca_fingerprint(&ca_path);
|
||||
|
||||
let mut features = Vec::new();
|
||||
if doh_enabled {
|
||||
features.push("doh".to_string());
|
||||
}
|
||||
if dot_enabled {
|
||||
features.push("dot".to_string());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod cache;
|
||||
pub mod config;
|
||||
pub mod ctx;
|
||||
pub mod dnssec;
|
||||
pub mod doh;
|
||||
pub mod dot;
|
||||
pub mod forward;
|
||||
pub mod header;
|
||||
|
||||
@@ -243,6 +243,7 @@ async fn main() -> numa::Result<()> {
|
||||
None
|
||||
};
|
||||
|
||||
let doh_enabled = initial_tls.is_some();
|
||||
let health_meta = numa::health::HealthMeta::build(
|
||||
&resolved_data_dir,
|
||||
config.dot.enabled,
|
||||
@@ -252,6 +253,7 @@ async fn main() -> numa::Result<()> {
|
||||
resolved_mode == numa::config::UpstreamMode::Recursive,
|
||||
config.lan.enabled,
|
||||
config.blocking.enabled,
|
||||
doh_enabled,
|
||||
);
|
||||
|
||||
let ca_pem = std::fs::read_to_string(resolved_data_dir.join("ca.pem")).ok();
|
||||
@@ -431,6 +433,13 @@ async fn main() -> numa::Result<()> {
|
||||
if config.dot.enabled {
|
||||
row("DoT", g, &format!("tls://:{}", config.dot.port));
|
||||
}
|
||||
if doh_enabled {
|
||||
row(
|
||||
"DoH",
|
||||
g,
|
||||
&format!("https://:{}/dns-query", config.proxy.tls_port),
|
||||
);
|
||||
}
|
||||
if config.lan.enabled {
|
||||
row("LAN", g, "mDNS (_numa._tcp.local)");
|
||||
}
|
||||
|
||||
36
src/proxy.rs
36
src/proxy.rs
@@ -4,7 +4,7 @@ use std::sync::Arc;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::routing::any;
|
||||
use axum::routing::{any, post};
|
||||
use axum::Router;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::StatusCode;
|
||||
@@ -18,6 +18,14 @@ use crate::ctx::ServerCtx;
|
||||
|
||||
type HttpClient = Client<hyper_util::client::legacy::connect::HttpConnector, Body>;
|
||||
|
||||
/// State passed to the DoH handler. Includes the remote address so
|
||||
/// `resolve_query` can log the client IP.
|
||||
#[derive(Clone)]
|
||||
pub struct DohState {
|
||||
pub ctx: Arc<ServerCtx>,
|
||||
pub remote_addr: Option<std::net::SocketAddr>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ProxyState {
|
||||
ctx: Arc<ServerCtx>,
|
||||
@@ -74,9 +82,17 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
|
||||
|
||||
// Hold a separate Arc so we can access tls_config after ctx moves into ProxyState
|
||||
let tls_holder = Arc::clone(&ctx);
|
||||
let state = ProxyState { ctx, client };
|
||||
let proxy_state = ProxyState {
|
||||
ctx: Arc::clone(&ctx),
|
||||
client,
|
||||
};
|
||||
|
||||
let app = Router::new().fallback(any(proxy_handler)).with_state(state);
|
||||
// DoH route (RFC 8484) served only on the TLS listener.
|
||||
// DohState.remote_addr is set per-connection below.
|
||||
let doh_state = DohState {
|
||||
ctx,
|
||||
remote_addr: None,
|
||||
};
|
||||
|
||||
loop {
|
||||
let (tcp_stream, remote_addr) = match listener.accept().await {
|
||||
@@ -91,7 +107,17 @@ pub async fn start_proxy_tls(ctx: Arc<ServerCtx>, port: u16, bind_addr: Ipv4Addr
|
||||
// unwrap safe: guarded by is_none() check above
|
||||
let acceptor =
|
||||
TlsAcceptor::from(Arc::clone(&*tls_holder.tls_config.as_ref().unwrap().load()));
|
||||
let app = app.clone();
|
||||
|
||||
let mut conn_doh_state = doh_state.clone();
|
||||
conn_doh_state.remote_addr = Some(remote_addr);
|
||||
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/dns-query",
|
||||
post(crate::doh::doh_post).with_state(conn_doh_state),
|
||||
)
|
||||
.fallback(any(proxy_handler))
|
||||
.with_state(proxy_state.clone());
|
||||
|
||||
tokio::spawn(async move {
|
||||
let tls_stream = match acceptor.accept(tcp_stream).await {
|
||||
@@ -232,7 +258,7 @@ pre .str {{ color: #d48a5a }}
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_host(req: &Request) -> Option<String> {
|
||||
pub fn extract_host(req: &Request) -> Option<String> {
|
||||
req.headers()
|
||||
.get(hyper::header::HOST)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
|
||||
Reference in New Issue
Block a user