milestone 10: doh and dot listeners, cert store with hot reload and cert reload api

This commit is contained in:
2026-08-02 14:39:18 +02:00
parent 617cc966a2
commit a589df7515
20 changed files with 4398 additions and 21 deletions
+128 -1
View File
@@ -33,6 +33,7 @@ const tls = std.crypto.tls;
const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const bootstrap = @import("config/bootstrap.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const clients = @import("server/clients.zig");
const config_export = @import("config/export.zig");
@@ -40,7 +41,9 @@ const db = @import("storage/db.zig");
const disk_monitor = @import("storage/disk_monitor.zig");
const dns_cache = @import("cache/dns_cache.zig");
const doh_client = @import("upstream/doh_client.zig");
const doh_server = @import("server/doh_server.zig");
const dot_client = @import("upstream/dot_client.zig");
const dot_server = @import("server/dot_server.zig");
const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
@@ -91,6 +94,7 @@ const ConfigError = error{
NoUsableUpstreams,
BadBindAddress,
BadRateLimit,
BadCertificate,
};
pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
@@ -112,7 +116,11 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
fn isConfigFault(err: anyerror) bool {
return switch (err) {
error.NoUsableUpstreams, error.BadBindAddress, error.BadRateLimit => true,
error.NoUsableUpstreams,
error.BadBindAddress,
error.BadRateLimit,
error.BadCertificate,
=> true,
else => false,
};
}
@@ -371,6 +379,41 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.tracker = &tracker,
};
// -----------------------------------------------------------------------
// DoH/DoT listeners (milestone-10 ruling 11)
// -----------------------------------------------------------------------
// The stores are declared before the listeners on purpose: their deinit
// defers run last, and `CertStore.deinit` asserts every connection has
// released its generation, which only holds once the listeners are gone.
// A certificate that does not load at boot is exit 2 — `nxdns check`
// promises that an enabled endpoint has readable certs — while anything
// that breaks later is the watcher's to absorb.
var doh_certs: ?cert_store.CertStore = null;
defer if (doh_certs) |*store| store.deinit(io);
if (cfg.doh_server.enabled) {
doh_certs = try openCertStore(r, gpa, io, cfg.doh_server, "doh_server", doh_server.alpn_protocols);
}
var dot_certs: ?cert_store.CertStore = null;
defer if (dot_certs) |*store| store.deinit(io);
if (cfg.dot_server.enabled) {
dot_certs = try openCertStore(r, gpa, io, cfg.dot_server, "dot_server", dot_alpn);
}
// Bound here, in this frame, rather than through doh_server's module-level
// entry: `/metrics` reads the listeners' counters through `WebState`, and
// only a listener that lives in this frame has an address to wire there.
// A failed bind warns and stays off (ruling 1, the web precedent): TLS DNS
// failing to come up must not stop the plain-DNS side this box exists for.
var doh: ?doh_server.DohServer = null;
defer if (doh) |*server| server.deinit(gpa, io);
if (doh_certs) |*store| doh = bindDoh(gpa, io, cfg.doh_server, &h, store);
var dot: ?dot_server.DotServer = null;
defer if (dot) |*server| server.deinit(io);
if (dot_certs) |*store| dot = bindDot(gpa, io, cfg.dot_server, &h, store);
// -----------------------------------------------------------------------
// web interface (ruling 26)
// -----------------------------------------------------------------------
@@ -401,6 +444,10 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.limiter = if (web_limiter) |*l| l else null,
.hub = hub,
.sink = &sink,
.doh_certs = if (doh_certs) |*store| store else null,
.dot_certs = if (dot_certs) |*store| store else null,
.doh_listener = if (doh) |*server| server else null,
.dot_listener = if (dot) |*server| server else null,
.config_db = if (web_config_db) |*database| database else null,
.querylog_db = if (web_querylog_db) |*database| database else null,
.version = version.string,
@@ -463,6 +510,10 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (udp4) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
if (tcp6) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
if (tcp4) |*s| try group.concurrent(io, tcp_server.TcpServer.serve, .{ s, io });
if (doh) |*s| try group.concurrent(io, doh_server.DohServer.serve, .{ s, io });
if (dot) |*s| try group.concurrent(io, dot_server.DotServer.serve, .{ s, io });
if (doh_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
if (dot_certs) |*store| try group.concurrent(io, cert_store.CertStore.watch, .{ store, io });
const gate: ?*disk_monitor.Monitor = &monitor;
try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate });
@@ -527,6 +578,82 @@ fn serveWebDev(
return static.serveFromDisk(web_dev_dir, io, request);
}
// ---------------------------------------------------------------------------
// DoH/DoT listeners
// ---------------------------------------------------------------------------
/// RFC 7858 has no IANA-registered ALPN id in wide use beyond "dot"
/// (milestone-10 ruling 5). Mbed TLS records the pointer, so the list must
/// outlive every `ServerContext` built with it; module scope gives it static
/// lifetime, the same shape as `doh_server.alpn_protocols`.
const dot_alpn: [*:null]const ?[*:0]const u8 = &.{"dot"};
/// The boot-time certificate load for one enabled endpoint. A failure is a
/// configuration fault the operator can fix — the same files `nxdns check`
/// verifies — reported in `check`'s style and mapped to exit 2 (ruling 11).
/// Out of memory is the one exception: nothing about the configuration is
/// wrong, so it keeps its own name and exits 1.
fn openCertStore(
r: cli.Runner,
gpa: Allocator,
io: std.Io,
endpoint: model.TlsEndpoint,
section: []const u8,
alpn: ?[*:null]const ?[*:0]const u8,
) !cert_store.CertStore {
return cert_store.CertStore.init(gpa, io, endpoint.cert_path, endpoint.key_path, alpn) catch |err| {
if (err == error.OutOfMemory) return error.OutOfMemory;
r.err.print("{s}: '{s}' + '{s}': {s}\n", .{
section,
endpoint.cert_path,
endpoint.key_path,
cert_store.humanMessage(err),
}) catch {};
return error.BadCertificate;
};
}
/// Ruling 1: a listener that cannot bind warns and stays off. The bind text
/// itself gets the same treatment — `validate` refuses it, but a hand-edited
/// database can still carry one, and it is not worth taking DNS down over.
fn bindDoh(
gpa: Allocator,
io: std.Io,
endpoint: model.TlsEndpoint,
h: *handler.Handler,
store: *cert_store.CertStore,
) ?doh_server.DohServer {
const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
log.warn("doh_server.bind '{s}' is not an IP address; DoH is disabled", .{endpoint.bind});
return null;
};
const server = doh_server.DohServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
log.warn("doh listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
return null;
};
log.info("doh listener on {f}", .{server.boundAddress()});
return server;
}
fn bindDot(
gpa: Allocator,
io: std.Io,
endpoint: model.TlsEndpoint,
h: *handler.Handler,
store: *cert_store.CertStore,
) ?dot_server.DotServer {
const bind_address = net.IpAddress.parse(endpoint.bind, endpoint.port) catch {
log.warn("dot_server.bind '{s}' is not an IP address; DoT is disabled", .{endpoint.bind});
return null;
};
const server = dot_server.DotServer.listen(gpa, io, bind_address, h, store, .{}) catch |err| {
log.warn("dot listener cannot listen on {s}:{d}: {t}", .{ endpoint.bind, endpoint.port, err });
return null;
};
log.info("dot listener on {f}", .{server.boundAddress()});
return server;
}
// ---------------------------------------------------------------------------
// background maintenance
// ---------------------------------------------------------------------------