milestone 7: serving pipeline, client tracking, pause and lifecycle

This commit is contained in:
2026-08-01 21:43:52 +02:00
parent 8c50b6617f
commit a8092bb1b9
17 changed files with 5916 additions and 131 deletions
+642
View File
@@ -0,0 +1,642 @@
//! The composition root: everything `nxdns run` owns, in the order it is built.
//!
//! Nothing else in the program constructs a collaborator. Each module takes its
//! dependencies as parameters and knows nothing about who supplies them, so
//! this file is the only place where the object graph exists — and the only
//! place a lifetime spans the whole process.
//!
//! Two rules shape it:
//!
//! 1. **Nothing that is pointed at may move.** `Pool.Entry.client` erases a
//! pointer into a DoH or DoT client, the `Logger`'s queue holds waiting
//! tasks in intrusive lists, and the `Manager` is addressed through `self`.
//! Every such value therefore lives in `serve`'s frame or in an owned heap
//! allocation, never in a temporary that is copied afterwards.
//! 2. **Every background loop is a task in one `std.Io.Group`.** Shutdown is
//! `group.cancel`, which requests cancellation and joins, so no loop can
//! still be running when the resources it borrows are released.
//!
//! Failure policy: a startup failure prints one line to the runner's error
//! writer and exits. A configuration the operator can fix exits 2, so that
//! `nxdns check` is the obvious next step; anything else exits 1. Once serving
//! starts, nothing is fatal — the pipeline answers queries with a snapshot or
//! without one (ruling 4), and every loop logs its own failures and keeps
//! going.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Certificate = std.crypto.Certificate;
const Writer = std.Io.Writer;
const net = std.Io.net;
const tls = std.crypto.tls;
const bootstrap = @import("config/bootstrap.zig");
const cli = @import("cli.zig");
const clients = @import("server/clients.zig");
const config_export = @import("config/export.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 dot_client = @import("upstream/dot_client.zig");
const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
const local_records = @import("local/records.zig");
const logger_mod = @import("storage/logger.zig");
const logging = @import("platform/logging.zig");
const manager_mod = @import("filter/manager.zig");
const migrations = @import("storage/migrations.zig");
const model = @import("config/model.zig");
const pause = @import("server/pause.zig");
const pool_mod = @import("upstream/pool.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const retention_mod = @import("storage/retention.zig");
const shutdown = @import("server/shutdown.zig");
const tcp_server = @import("server/tcp_server.zig");
const transport = @import("upstream/transport.zig");
const udp_server = @import("server/udp_server.zig");
const validate = @import("config/validate.zig");
const version = @import("version.zig");
const log = std.log.scoped(.nxdns);
/// How long the cache and rate-limiter sweeps wait between passes. Both tables
/// evict lazily on lookup as well; this is what keeps memory bounded for a
/// client that asked once and never came back.
const maintenance_interval_s = 60;
/// Bounds one blocklist download (`Manager.total_budget`). Generous, because a
/// megabyte-scale list over a household uplink is normal, and a download that
/// takes longer than this is not going to finish at all.
const download_budget_s = 300;
/// Per DoH upstream. `min_request_buf` is 512; the extra room costs nothing and
/// keeps a maximum-length name with a large OPT record comfortable.
const doh_request_buf_len = 1024;
const doh_transfer_buf_len = 4096;
/// A configuration fault the operator can fix, as opposed to a runtime one.
/// These are the only failures this file raises itself; everything else comes
/// out of a collaborator.
const ConfigError = error{
NoUsableUpstreams,
BadBindAddress,
BadRateLimit,
};
pub fn run(runner: cli.Runner, paths: cli.Paths) u8 {
const code = serve(runner, paths) catch |err| code: {
runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {};
if (isConfigFault(err)) {
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
break :code cli.exit_check;
}
break :code cli.exit_runtime;
};
// Output the operator never received is not output, so a failed flush
// outranks a successful run — the same rule `cli.finish` applies.
runner.out.flush() catch return cli.exit_runtime;
runner.err.flush() catch return cli.exit_runtime;
return code;
}
fn isConfigFault(err: anyerror) bool {
return switch (err) {
error.NoUsableUpstreams, error.BadBindAddress, error.BadRateLimit => true,
else => false,
};
}
fn serve(r: cli.Runner, paths: cli.Paths) !u8 {
const io = r.io;
const gpa = r.gpa;
// -----------------------------------------------------------------------
// storage and configuration
// -----------------------------------------------------------------------
var data = try cli.DataDir.open(io, gpa, paths.data_dir, true);
defer data.close(io, gpa);
var config_db = try data.openConfigDb(io);
defer config_db.close();
_ = try migrations.migrate(&config_db);
{
// First run only: the file seeds an empty database and is ignored
// forever after. Its diagnostics are the operator's one chance to see
// why a config file was rejected, so they are printed like `check`
// prints them.
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
_ = bootstrap.bootstrap(io, gpa, &config_db, std.Io.Dir.cwd(), paths.config, &diags) catch |err| {
diags.writeAll(r.err) catch {};
return err;
};
}
// Every string in `cfg` points into this arena, and the pool's endpoints,
// the handler's records and the monitor's paths all keep such strings. It
// therefore outlives all of them, which is why it is declared here and not
// inside a narrower scope.
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
const cfg = try config_export.readConfig(&config_db, arena);
// From here on `std.log` goes wherever the operator asked. Before this call
// the sink passes through to stderr, which is where a failure above needs
// to appear anyway.
logging.install(io, cfg.logging);
defer logging.deinstall();
if (cfg.dns.rate_limit == 0 or cfg.dns.rate_window_seconds == 0) return error.BadRateLimit;
// -----------------------------------------------------------------------
// local answers
// -----------------------------------------------------------------------
var records = try local_records.Records.build(gpa, cfg.local_records);
defer records.deinit(gpa);
var zones = try forward_zones.Zones.build(gpa, cfg.forward_zones);
defer zones.deinit(gpa);
// -----------------------------------------------------------------------
// blocklists
// -----------------------------------------------------------------------
// Two HTTP clients on purpose. A blocklist download streams tens of
// megabytes and holds its connection for the whole of it; DoH queries must
// not queue behind that, and the two have nothing to share but a type.
var fetch_http: std.http.Client = .{ .allocator = gpa, .io = io };
defer fetch_http.deinit();
var dns_http: std.http.Client = .{ .allocator = gpa, .io = io };
defer dns_http.deinit();
const fetch_transfer = try gpa.alloc(u8, fetcher.min_transfer_buf);
defer gpa.free(fetch_transfer);
const fetch_redirect = try gpa.alloc(u8, fetcher.redirect_buffer_len);
defer gpa.free(fetch_redirect);
var fetch: fetcher.Fetcher = .{
.http = &fetch_http,
.transfer_buf = fetch_transfer,
.redirect_buf = fetch_redirect,
};
var manager: manager_mod.Manager = try .init(
gpa,
&config_db,
.{ .dir = data.dir },
&fetch,
cfg.blocklist_update,
.{ .raw = .fromSeconds(download_budget_s), .clock = .awake },
);
defer manager.deinit(io);
// -----------------------------------------------------------------------
// upstream pool
// -----------------------------------------------------------------------
// Shared by every DoT client: the trust store does not vary per upstream
// and the scan is expensive. Both must outlive the clients that hold them.
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(gpa);
var bundle_lock: std.Io.RwLock = .init;
var upstreams = try Upstreams.build(gpa, cfg.upstreams, &dns_http, &bundle, &bundle_lock);
defer upstreams.deinit(gpa);
var pool: pool_mod.Pool = .init(
upstreams.active(),
.{},
.{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
);
// -----------------------------------------------------------------------
// per-query state
// -----------------------------------------------------------------------
var cache: dns_cache.DnsCache = try .init(gpa, cfg.cache);
defer cache.deinit();
var limiter: rate_limiter.RateLimiter = try .init(gpa, .{
.limit = cfg.dns.rate_limit,
.window_seconds = cfg.dns.rate_window_seconds,
});
defer limiter.deinit();
var paused: pause.Pause = .{};
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
// The queue holds waiting tasks in intrusive lists, so neither the buffer
// nor the `Logger` may move once a task has touched either.
const queue_buf = try gpa.alloc(logger_mod.Entry, cfg.logging.query_log_buffer_max);
defer gpa.free(queue_buf);
var query_logger: logger_mod.Logger = .init(cfg.logging, queue_buf);
// -----------------------------------------------------------------------
// disk, retention and the remaining connections (ruling 21)
// -----------------------------------------------------------------------
const data_path = try arena.dupeZ(u8, paths.data_dir);
const log_dir_path: ?[:0]const u8 = if (cfg.logging.output == .file)
try arena.dupeZ(u8, std.fs.path.dirname(cfg.logging.file_path) orelse ".")
else
null;
var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path);
// Ruling 17. The scheduler consults it before every scheduled pass; the
// startup `reload` below is an operator action and stays ungated.
manager.monitor = &monitor;
// One synchronous sample before anything can consult the gate. `Monitor`
// initializes to `.ok`, and `Monitor.run` takes its first sample inside the
// task — so without this call the log writer, the tracker and the blocklist
// scheduler would all see "writes allowed" during the boot window and could
// write to a critically full disk before the gate ever reflected it.
// `run`'s own first sample repeats this one, which costs one extra statvfs
// and directory scan at startup and keeps `run`'s "sample first, then
// sleep" contract intact.
//
// `sample` returns nothing: every failure warns and counts inside the
// monitor, and leaves the previous reading in place. A first sample that
// fails therefore leaves the state at `.ok` — an unreadable filesystem is
// not evidence that the disk is full, which is the monitor's documented
// policy and the right one here too.
monitor.sample(io);
var retention: retention_mod.Retention = .init(cfg.logging);
var querylog_writer_db = try data.openQuerylogDb(io);
defer querylog_writer_db.close();
var querylog_retention_db = try data.reopenQuerylogDb(io);
defer querylog_retention_db.close();
var tracker_db = try data.openConfigDb(io);
defer tracker_db.close();
// -----------------------------------------------------------------------
// first snapshot
// -----------------------------------------------------------------------
// Ruling 4: serving starts either way. A household loses more from a DNS
// server that refuses to start than from one that answers unfiltered until
// the scheduler's first pass succeeds.
manager.reload(io) catch |err| {
log.warn(
"loading the blocklist snapshot failed ({s}); serving unfiltered until the next refresh",
.{@errorName(err)},
);
};
// -----------------------------------------------------------------------
// listeners
// -----------------------------------------------------------------------
var h: handler.Handler = .{
.upstream = pool.client(),
.blocking = .{ .mode = cfg.blocking.response, .ttl = cfg.blocking.ttl },
.ecs_mode = cfg.edns.ecs_mode,
.forward_read_timeout = .{ .raw = model.readTimeout(cfg.upstream), .clock = .awake },
.manager = &manager,
.records = &records,
.zones = &zones,
.cache = &cache,
.negative_ttl_max = cfg.cache.negative_ttl_max,
.limiter = &limiter,
.logger = &query_logger,
.pause = &paused,
.tracker = &tracker,
};
const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6") catch |err| return err;
const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4") catch |err| return err;
// IPv6 first, and the order is load-bearing — see `Listeners`.
var udp6: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
if (!ipv6Unavailable(err)) return reportBind(r, "udp", v6_bind, err);
log.warn("this system has no IPv6; serving IPv4 only", .{});
break :bound null;
};
defer if (udp6) |*s| s.deinit(gpa, io);
const v6_is_wildcard = udp6 != null and isWildcard(v6_bind);
var udp4: ?udp_server.UdpServer = udp_server.UdpServer.bind(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
if (err != error.AddressInUse or !v6_is_wildcard) return reportBind(r, "udp", v4_bind, err);
log.info("the IPv6 UDP listener is dual-stack and already serves IPv4", .{});
break :bound null;
};
defer if (udp4) |*s| s.deinit(gpa, io);
var tcp6: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v6_bind, &h, .{}) catch |err| bound: {
if (!ipv6Unavailable(err)) return reportBind(r, "tcp", v6_bind, err);
break :bound null;
};
defer if (tcp6) |*s| s.deinit(gpa, io);
var tcp4: ?tcp_server.TcpServer = tcp_server.TcpServer.listen(gpa, io, v4_bind, &h, .{}) catch |err| bound: {
if (err != error.AddressInUse or !(tcp6 != null and isWildcard(v6_bind))) {
return reportBind(r, "tcp", v4_bind, err);
}
log.info("the IPv6 TCP listener is dual-stack and already serves IPv4", .{});
break :bound null;
};
defer if (tcp4) |*s| s.deinit(gpa, io);
// -----------------------------------------------------------------------
// run
// -----------------------------------------------------------------------
shutdown.install(io);
// Declared after everything it borrows, so its `cancel` — which both
// requests cancellation and joins — is the first thing that runs on the way
// out (ruling 22). Nothing below this line may be released while a task
// could still touch it.
var group: std.Io.Group = .init;
defer group.cancel(io);
if (udp6) |*s| try group.concurrent(io, udp_server.UdpServer.serve, .{ s, io });
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 });
const gate: ?*disk_monitor.Monitor = &monitor;
try group.concurrent(io, logger_mod.Logger.runWriter, .{ &query_logger, io, &querylog_writer_db, gate });
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db });
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io });
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
try group.concurrent(io, runMaintenance, .{ &h, io });
logStartup(io, &manager, upstreams.active().len, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
.tcp4 = if (tcp4) |*s| s.boundAddress() else null,
});
// A canceled wait is a shutdown request too: whoever canceled this task
// wants the process to stop, and the teardown below is how it stops.
shutdown.wait(io) catch {};
log.info("shutting down", .{});
// Before the group is canceled, so the writer sees a closed queue and
// drains what it holds rather than losing it to cancellation (ruling 22).
query_logger.shutdown(io);
return cli.exit_ok;
}
// ---------------------------------------------------------------------------
// background maintenance
// ---------------------------------------------------------------------------
/// Sweeps the cache and the rate-limiter table. Both are guarded by mutexes the
/// handler owns, because the handler is what contends for them; the sweeps live
/// here because walking a whole table is not work a query should pay for.
///
/// The locks are taken cancelably: unlike `handle`, this loop has an error
/// union to carry `error.Canceled` out of, and a shutdown that arrives while
/// the query path holds a lock should not wait for it.
fn runMaintenance(h: *handler.Handler, io: std.Io) std.Io.Cancelable!void {
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(maintenance_interval_s),
.clock = .boot,
};
while (true) {
try interval.sleep(io);
if (h.cache) |cache| {
const now_s = std.Io.Clock.real.now(io).toSeconds();
try h.cache_mutex.lock(io);
_ = cache.sweep(now_s);
h.cache_mutex.unlock(io);
}
if (h.limiter) |limiter| {
const now = std.Io.Clock.awake.now(io);
try h.limiter_mutex.lock(io);
_ = limiter.sweep(now);
h.limiter_mutex.unlock(io);
}
}
}
// ---------------------------------------------------------------------------
// upstreams
// ---------------------------------------------------------------------------
/// The pool's entries and everything they point into.
///
/// `Pool.Entry.client` is a type-erased pointer into `doh` or `dot`, and each
/// client borrows a slice of `doh_buf`/`dot_buf`, so all five allocations live
/// exactly as long as the pool does. One entry is used by one task at a time
/// (`Entry.busy`), which is why the buffers are per client and not shared the
/// way `cli.probeUpstreams` shares them.
const Upstreams = struct {
entries: []pool_mod.Entry,
used: usize,
doh: []doh_client.DohClient,
dot: []dot_client.DotClient,
doh_buf: []u8,
dot_buf: []u8,
/// A disabled upstream is left out entirely; a malformed one warns and is
/// skipped, because one bad row in a table of four must not take DNS down.
/// No usable row at all is a configuration fault.
fn build(
gpa: Allocator,
servers: []const model.UpstreamServer,
http: *std.http.Client,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
) (Allocator.Error || ConfigError)!Upstreams {
var enabled: usize = 0;
for (servers) |server| {
if (server.enabled) enabled += 1;
}
if (enabled == 0) return error.NoUsableUpstreams;
const chunk = tls.Client.min_buffer_len;
var self: Upstreams = .{
.entries = try gpa.alloc(pool_mod.Entry, enabled),
.used = 0,
.doh = &.{},
.dot = &.{},
.doh_buf = &.{},
.dot_buf = &.{},
};
errdefer self.deinit(gpa);
self.doh = try gpa.alloc(doh_client.DohClient, enabled);
self.dot = try gpa.alloc(dot_client.DotClient, enabled);
self.doh_buf = try gpa.alloc(u8, enabled * (doh_request_buf_len + doh_transfer_buf_len));
self.dot_buf = try gpa.alloc(u8, enabled * 4 * chunk);
var doh_count: usize = 0;
var dot_count: usize = 0;
for (servers) |server| {
if (!server.enabled) continue;
const endpoint = transport.Endpoint.parse(server.url) catch {
log.warn("upstream '{s}' is not an https:// or tls:// endpoint; skipped", .{server.url});
continue;
};
const client: transport.Client = switch (endpoint.scheme) {
.doh => doh: {
const base = doh_count * (doh_request_buf_len + doh_transfer_buf_len);
const slot = &self.doh[doh_count];
slot.* = doh_client.DohClient.init(
http,
endpoint,
self.doh_buf[base..][0..doh_request_buf_len],
self.doh_buf[base + doh_request_buf_len ..][0..doh_transfer_buf_len],
) catch {
log.warn("upstream '{s}' is not a usable DoH url; skipped", .{server.url});
continue;
};
doh_count += 1;
break :doh slot.client();
},
.dot => dot: {
const base = dot_count * 4 * chunk;
const slot = &self.dot[dot_count];
slot.* = dot_client.DotClient.init(endpoint, server.tls_name, gpa, bundle, bundle_lock, .{
.tls_read = self.dot_buf[base..][0..chunk],
.tls_write = self.dot_buf[base + chunk ..][0..chunk],
.stream_read = self.dot_buf[base + 2 * chunk ..][0..chunk],
.stream_write = self.dot_buf[base + 3 * chunk ..][0..chunk],
});
dot_count += 1;
break :dot slot.client();
},
};
self.entries[self.used] = .{
.endpoint = endpoint,
.client = client,
.priority = server.priority,
.enabled = true,
.health = .init,
};
self.used += 1;
}
if (self.used == 0) return error.NoUsableUpstreams;
return self;
}
/// The prefix `Pool.init` is given. The rest of `entries` is allocated but
/// never filled, which is what keeps `deinit` able to free the whole block.
fn active(self: *Upstreams) []pool_mod.Entry {
return self.entries[0..self.used];
}
fn deinit(self: *Upstreams, gpa: Allocator) void {
gpa.free(self.dot_buf);
gpa.free(self.doh_buf);
gpa.free(self.dot);
gpa.free(self.doh);
gpa.free(self.entries);
self.* = undefined;
}
};
// ---------------------------------------------------------------------------
// listeners
// ---------------------------------------------------------------------------
/// The addresses actually bound, for the startup line. A null is a family this
/// process does not listen on separately — either because the system has no
/// IPv6, or because the IPv6 socket is dual-stack and already serves IPv4.
const Listeners = struct {
udp6: ?net.IpAddress,
udp4: ?net.IpAddress,
tcp6: ?net.IpAddress,
tcp4: ?net.IpAddress,
};
fn parseBind(r: cli.Runner, text: []const u8, port: u16, field: []const u8) !net.IpAddress {
return net.IpAddress.parse(text, port) catch {
r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {};
return error.BadBindAddress;
};
}
fn reportBind(r: cli.Runner, which: []const u8, addr: net.IpAddress, err: anyerror) anyerror {
r.err.print("cannot bind {s} {f}: {s}\n", .{ which, addr, @errorName(err) }) catch {};
return err;
}
/// The kernel has no IPv6 at all. Refusing to start would be the wrong answer
/// for the default configuration, which asks for both families.
fn ipv6Unavailable(err: anyerror) bool {
return switch (err) {
error.AddressFamilyUnsupported,
error.ProtocolUnsupportedBySystem,
error.ProtocolUnsupportedByAddressFamily,
=> true,
else => false,
};
}
fn isWildcard(addr: net.IpAddress) bool {
switch (addr) {
.ip4 => |a| {
for (a.bytes) |b| if (b != 0) return false;
},
.ip6 => |a| {
for (a.bytes) |b| if (b != 0) return false;
},
}
return true;
}
// ---------------------------------------------------------------------------
// startup line
// ---------------------------------------------------------------------------
/// One line, at info, naming what an operator needs to see in `journalctl`
/// right after a restart: where it listens, how many upstreams it has, and
/// whether filtering is live.
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
var buf: [256]u8 = undefined;
var w: Writer = .fixed(&buf);
appendBind(&w, "udp", bound.udp6);
appendBind(&w, "udp", bound.udp4);
appendBind(&w, "tcp", bound.tcp6);
appendBind(&w, "tcp", bound.tcp4);
// The generation is read under the snapshot handle's shared lock, which is
// the same lock the reload took to publish it.
if (manager.acquire(io)) |snapshot| {
defer snapshot.release(io);
log.info("nxdns {s} serving on{s}; {d} upstream(s); blocklist generation {d}", .{
version.string,
w.buffered(),
upstream_count,
manager.generation,
});
} else {
log.info("nxdns {s} serving on{s}; {d} upstream(s); unfiltered (no blocklist snapshot)", .{
version.string,
w.buffered(),
upstream_count,
});
}
}
/// Silent on overflow: a truncated startup line is not worth a failure path,
/// and 256 bytes hold four addresses.
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
const value = addr orelse return;
w.print(" {s} {f}", .{ which, value }) catch {};
}