//! 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 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"); 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 faults = @import("config/faults.zig"); const fetcher = @import("filter/fetcher.zig"); const forward_zones = @import("local/forward_zones.zig"); const handler = @import("server/handler.zig"); const http_util = @import("web/http_util.zig"); const local_records = @import("local/records.zig"); const local_tables = @import("server/local_tables.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 query_sink = @import("server/query_sink.zig"); const rate_limiter = @import("server/rate_limiter.zig"); const retention_mod = @import("storage/retention.zig"); const safe_url = @import("safe_url.zig"); const shutdown = @import("server/shutdown.zig"); const sse = @import("web/sse.zig"); const static = @import("web/static.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 web_server = @import("web/server.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; pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 { const code = serve(runner, args) catch |err| code: { runner.err.print("nxdns run failed: {s}\n", .{@errorName(err)}) catch {}; const mapped = failureExitCode(err); if (mapped == cli.exit_check) { runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {}; } break :code mapped; }; // 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; } /// The one classification, from `config/faults.zig`. This file keeps no list of /// its own: `run` exiting 1 on a seed file `check` and `import` exit 2 on was /// exactly the cost of the second list that used to be here. fn failureExitCode(err: anyerror) u8 { return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime; } /// First run only: the file seeds an empty database and is ignored forever /// after. Its diagnostics are the operator's one chance to see what the file /// said, so they are printed the way `check` and `import` print them. /// /// Printed on the way out either way. A seed file can be accepted and still /// carry warnings — a blocklist source in no group is downloaded and compiled /// into nothing — and a warning that only appears when the start fails is a /// warning nobody ever reads: the start it describes is the one that worked. /// `check` reported it and `run` did not, which left the same file graded two /// ways. /// /// The runner's error writer, not `std.log`: this runs before /// `logging.install`, and one rendering of a diagnostic across `run`, `check` /// and `import` is the point of `Diagnostics.writeAll`. /// /// Flushed here rather than left to `run`'s exit flush. That writer is buffered /// (`main` gives it 4 KiB) and `serve` does not return for as long as the /// service runs, so a line left in the buffer reaches the operator when the /// process stops — days after the start it describes. A failure path flushes /// anyway because it returns immediately; the successful start is the one that /// needs this. fn seedFromFile( r: cli.Runner, config_db: *db.Db, dir: std.Io.Dir, config_path: []const u8, ) bootstrap.Error!bootstrap.Outcome { var diags: validate.Diagnostics = .init(r.gpa); defer diags.deinit(); const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags); // Neither discard is an oversight, and the two answer different questions. // // On a rejected seed file, `result` is returned untouched: the operator gets // the reason the start failed, never a writer error standing in front of it. // A broken stderr is not why the configuration was refused. // // On a seed that worked, a failure here does not stop the start. The trade // is one lost warning line against a household with no name resolution, and // `run` before this point is the only stretch of this program where an // output failure could take DNS down at all — ruling 4 already says nothing // after it is fatal. Nor could the failure be reported: this writer *is* the // error channel, and `logging.install` has not run yet, so `std.log` resolves // to the same stderr a diagnostic about it would have to travel down. // // It is not lost from the process either. A failed drain consumes nothing, // so whatever the buffer held it still holds — that half is observed, in // "a broken error writer does not stop a first start that succeeded" below, // which reads the retained warning back out of the same writer. // // What happens to those bytes afterwards is derived, not watched, and is // labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0` // and `run`'s exit flush maps a failure to exit 1, so a stderr still broken // at shutdown should carry the condition out in the exit code, and one that // recovered should deliver the line late. No test drives `run` that far. // Two limits come with the derivation: an empty buffer flushes clean and // reports nothing at all, and a failure that recovers ends at exit 0 with a // line the operator reads days after the start it describes. diags.writeAll(r.err) catch {}; r.err.flush() catch {}; return result; } fn serve(r: cli.Runner, args: cli.RunArgs) !u8 { const io = r.io; const gpa = r.gpa; const paths = args.paths; // ----------------------------------------------------------------------- // 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); _ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config); // 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; // The API limiter and the session store assert these are nonzero // (`validate` refuses such a config, but nothing validates a database an // operator edited by hand), and a fault the operator can fix must exit 2, // not trip an assertion. if (cfg.web.enabled and (cfg.web.api_rate_limit_per_min == 0 or cfg.web.session_ttl_hours == 0)) { return error.BadRateLimit; } // ----------------------------------------------------------------------- // local answers // ----------------------------------------------------------------------- // Ruling 12: the tables are published through the holder the API swaps, so // the holder owns them from here on and frees whichever generation is // current at shutdown. var tables: local_tables.LocalTables = .empty; defer tables.deinit(gpa); tables.records = try local_records.Records.build(gpa, cfg.local_records); tables.zones = try forward_zones.Zones.build(gpa, cfg.forward_zones); // ----------------------------------------------------------------------- // 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); // Milestone 8 fans every logged query out to the SSE hub as well. The hub // exists only when the web interface does (ruling 6) — without it the sink // costs the query path one null check. Its rings are ~900 KiB, so it lives // on the heap and initializes in place; a by-value init would copy the // whole of it through this frame. var hub: ?*sse.Hub = null; defer if (hub) |hub_ptr| gpa.destroy(hub_ptr); if (cfg.web.enabled) { const created = try gpa.create(sse.Hub); created.init(); hub = created; } var sink: query_sink.QuerySink = .init(&query_logger, hub); // ----------------------------------------------------------------------- // 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(); // The web task's own two connections (ruling 26; m7 ruling 21: one SQLite // connection per task), opened only when the web interface is (ruling 6). // `reopenQuerylogDb` requires the file `openQuerylogDb` established above. var web_config_db: ?db.Db = null; defer if (web_config_db) |*database| database.close(); var web_querylog_db: ?db.Db = null; defer if (web_querylog_db) |*database| database.close(); if (cfg.web.enabled) { web_config_db = try data.openConfigDb(io); web_querylog_db = try data.reopenQuerylogDb(io); } var sessions: ?auth.Sessions = if (cfg.web.enabled) .init(cfg.web.session_ttl_hours) else null; var web_limiter: ?api_limiter.ApiLimiter = null; defer if (web_limiter) |*limiter_ptr| limiter_ptr.deinit(); if (cfg.web.enabled) { web_limiter = try api_limiter.ApiLimiter.init(gpa, .{ .rate_per_min = cfg.web.api_rate_limit_per_min, .localhost_exempt = cfg.web.api_localhost_exempt, .sse_max_per_ip = cfg.web.sse_max_connections_per_ip, }); } // ----------------------------------------------------------------------- // 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, .local_tables = &tables, .cache = &cache, .negative_ttl_max = cfg.cache.negative_ttl_max, .limiter = &limiter, .sink = &sink, .pause = &paused, .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) // ----------------------------------------------------------------------- // Everything the web layer borrows lives above; the group below cancels the // web task before any of it is released. With the web interface disabled // the state stays in its null-defaulted shape and no task reads it. if (args.web_dev) |dir| web_dev_dir = dir; var web_state: web_server.WebState = .{ .gpa = gpa }; // The live hash may own a gpa replacement after a settings PUT; this defer // runs after `group.cancel` below, so no web task can still read it. defer web_state.live_hash.deinit(gpa); if (cfg.web.enabled) web_state = .{ .gpa = gpa, .web = cfg.web, .live_hash = .init(cfg.web.password_hash), .handler = &h, .pause = &paused, .tracker = &tracker, .manager = &manager, .pool = &pool, .monitor = &monitor, .local_tables = &tables, .logger = &query_logger, .retention = &retention, .sessions = if (sessions) |*s| s else null, .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, .started_unix = std.Io.Clock.real.now(io).toSeconds(), // Ruling 24: `--web-dev` serves from disk with no cache headers; // otherwise the embedded assets answer every non-/api miss. .fallback = if (args.web_dev != null) serveWebDev else static.fallback, .reload_fn = reloadManager, }; const v6_bind = parseBind(r, cfg.dns.bind_ipv6, cfg.dns.port, "dns.bind_ipv6", .ip6) catch |err| return err; const v4_bind = parseBind(r, cfg.dns.bind_ipv4, cfg.dns.port, "dns.bind_ipv4", .ip4) 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 }); 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 }); 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 }); // Started last (ruling 26), canceled by the same `group.cancel`; its inner // connection group is canceled, not awaited (ruling 4), so an idle // keep-alive client cannot hold shutdown open. A web bind failure is not // fatal: `web_server.serve` warns and returns, and the DNS side — the thing // this box exists for — keeps serving. if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, 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; } // --------------------------------------------------------------------------- // web seams // --------------------------------------------------------------------------- /// Ruling 12: mutations to rules, blocklists, groups, clients and prefixes /// rebuild the blocklist snapshot so the change is live on the next query. A /// state without a manager has nothing to rebuild. fn reloadManager(state: *web_server.WebState, io: std.Io) anyerror!void { const manager = state.manager orelse return; try manager.reload(io); } /// The `--web-dev` directory. `WebState.fallback` is a bare function pointer /// with no closure to carry the path, and one process runs one composition /// root, so the directory lives here: written once by `serve` before the web /// task starts, read only by `serveWebDev`. var web_dev_dir: []const u8 = ""; /// Dev-mode asset serving (ruling 24): straight from disk, no cache headers, /// so an edit shows up on the next reload. fn serveWebDev( state: *web_server.WebState, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { _ = state; 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 // --------------------------------------------------------------------------- /// 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 || error{NoUsableUpstreams})!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 {f} is not an https:// or tls:// endpoint; skipped", .{safe_url.redactQuoted(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 {f} is not a usable DoH url; skipped", .{safe_url.redactQuoted(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, }; const BindFamily = enum { ip4, ip6 }; /// `config/validate.checkBind` enforces the same family rule on import, check /// and settings PUT — but not on a config.db written before the rule existed, /// and `serve` loads that DB without re-validating. Boot is the last seam: a /// cross-family literal here would bind the wrong family's socket and make the /// real one fail with AddressInUse, silently losing a family. fn parseBind( r: cli.Runner, text: []const u8, port: u16, field: []const u8, family: BindFamily, ) !net.IpAddress { const addr = net.IpAddress.parse(text, port) catch { r.err.print("{s}: '{s}' is not an IP address\n", .{ field, text }) catch {}; return error.BadBindAddress; }; const matches = switch (addr) { .ip4 => family == .ip4, .ip6 => family == .ip6, }; if (!matches) { const digit: u8 = if (family == .ip4) '4' else '6'; r.err.print( "{s}: '{s}' is not an IPv{c} address; re-import the configuration or correct it with a settings PUT\n", .{ field, text, digit }, ) catch {}; return error.BadBindAddress; } return addr; } test "parseBind refuses a bind address of the wrong family" { var out_buf: [8]u8 = undefined; var err_buf: [256]u8 = undefined; var out: Writer = .fixed(&out_buf); var err_writer: Writer = .fixed(&err_buf); const r: cli.Runner = .{ .io = std.testing.io, .gpa = std.testing.allocator, .out = &out, .err = &err_writer, }; _ = try parseBind(r, "0.0.0.0", 53, "dns.bind_ipv4", .ip4); _ = try parseBind(r, "::", 53, "dns.bind_ipv6", .ip6); try std.testing.expectError( error.BadBindAddress, parseBind(r, "0.0.0.0", 53, "dns.bind_ipv6", .ip6), ); try std.testing.expectError( error.BadBindAddress, parseBind(r, "::", 53, "dns.bind_ipv4", .ip4), ); const printed = err_writer.buffered(); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv6 address")); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "is not an IPv4 address")); } test "run maps a rejected configuration to exit 2 and everything else to exit 1" { try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MissingDefaultGroup)); try std.testing.expectEqual(cli.exit_check, failureExitCode(error.ParseZon)); try std.testing.expectEqual(cli.exit_check, failureExitCode(error.NoUsableUpstreams)); try std.testing.expectEqual(cli.exit_check, failureExitCode(error.BadCertificate)); try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.AccessDenied)); try std.testing.expectEqual(cli.exit_runtime, failureExitCode(error.OutOfMemory)); } test "run, check and import agree on a seed file with no default group" { const config_import = @import("config/import.zig"); var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const gpa = std.testing.allocator; // The file from discrepancy D1: parseable, one upstream, no group named // 'default'. const source: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "kids" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} ; // `run`: `serve` seeds through `bootstrap`, which is a wrapper over this // exact call, so this is the error `run` classifies. var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); var diags: validate.Diagnostics = .init(gpa); defer diags.deinit(); try std.testing.expectError( error.MissingDefaultGroup, config_import.importSource(io, gpa, &database, source, .{}, &diags), ); try std.testing.expectEqual(cli.exit_check, failureExitCode(error.MissingDefaultGroup)); // `import`: `cli.failureExitCode` reaches exit 2 by either route — the // recorded failures, or the classification `run` just used. try std.testing.expect(diags.failureCount() != 0); try std.testing.expect(faults.isConfigFault(error.MissingDefaultGroup)); // `check`: the same file, through the code `nxdns check` runs. var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena_state.allocator(), source, null, .{}); var out_buf: [2048]u8 = undefined; var err_buf: [256]u8 = undefined; var out: Writer = .fixed(&out_buf); var err_writer: Writer = .fixed(&err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer }; try std.testing.expectEqual(cli.exit_check, try cli.checkConfig(r, cfg, false)); } test "a configuration whose blocklist source is in no group imports and checks clean" { const config_import = @import("config/import.zig"); var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const gpa = std.testing.allocator; // A source is created before it is attached — `docs/tutorial/first-run.md` // POSTs the blocklist and then PUTs the group's sources — so an unattached // source is a legal intermediate state on every write path. It warns; it // never fails. const source: [:0]const u8 = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\ .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } }, \\} ; var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); var diags: validate.Diagnostics = .init(gpa); defer diags.deinit(); try config_import.importSource(io, gpa, &database, source, .{}, &diags); try std.testing.expectEqual(@as(usize, 0), diags.failureCount()); try std.testing.expectEqual(@as(usize, 1), diags.warningCount()); var arena_state: std.heap.ArenaAllocator = .init(gpa); defer arena_state.deinit(); const cfg = try std.zon.parse.fromSliceAlloc(model.Config, arena_state.allocator(), source, null, .{}); var check_diags: validate.Diagnostics = .init(gpa); defer check_diags.deinit(); // No error means no subcommand may reject it; the warning is report-only. try validate.validate(cfg, &check_diags); try std.testing.expectEqual(@as(usize, 0), check_diags.failureCount()); try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount()); } test "a first start that seeds from a file prints the warnings the file earned" { // D5, second half. The seed file is read once in the life of a database, so // a warning it earns is printed on that start or never. `run` printed // diagnostics only when the file was rejected, which made a successful first // start the one place the finding could not surface. var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\ .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } }, \\} }); var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); // A buffered file writer, the shape `main` builds over stderr, and not // `Writer.fixed`: a fixed writer's flush is a no-op, so it counts a line // still sitting in the buffer as delivered. Reading the file back is the // only way to ask what the operator can actually see. var out_buf: [64]u8 = undefined; var out: Writer = .fixed(&out_buf); var err_file = try tmp.dir.createFile(io, "stderr.txt", .{}); defer err_file.close(io); var err_buf: [4096]u8 = undefined; var err_writer = err_file.writer(io, &err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface }; // The file is valid, so the start succeeds and the database is seeded. try std.testing.expectEqual( bootstrap.Outcome.seeded, try seedFromFile(r, &database, tmp.dir, "config.zon"), ); try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); // Nothing flushes here on purpose. In production `serve` runs from this // point until the service stops, so a line that has not reached the file by // now is a line the operator does not get for days. const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192)); defer gpa.free(printed); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "WARN blocklist_sources[0]: ")); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "belongs to no group")); // A warning is not a rejection: nothing here claims the start failed. try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL")); } /// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with /// its drain switched to the mode that fails. `Writer.fixed` cannot stand in — /// its flush is `noopFlush`, so it has no failure to report and its `written()` /// counts a line still sitting in the buffer as delivered. /// /// The file stays empty for as long as the mode is `.failure`, which is what /// lets a test tell "the writer really failed" from "the writer worked". fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writer { var w = file.writer(io, buffer); w.mode = .failure; return w; } test "a broken error writer does not replace the reason a seed file was rejected" { // The operator has to see why seeding failed, and a broken stderr is not // that reason. var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // No group named 'default': rejected, and it records a FAIL line on the way. try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = \\.{ \\ .groups = .{ .{ .name = "kids" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\} }); var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); var out_buf: [64]u8 = undefined; var out: Writer = .fixed(&out_buf); var err_file = try tmp.dir.createFile(io, "stderr.txt", .{}); defer err_file.close(io); // Eight bytes: no FAIL line fits, so `writeAll` must drain mid-line and is // itself the call that fails. The test below covers the other discard, where // the line fits and only the flush fails. var err_buf: [8]u8 = undefined; var err_writer = brokenErrWriter(io, err_file, &err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface }; try std.testing.expectError( error.MissingDefaultGroup, seedFromFile(r, &database, tmp.dir, "config.zon"), ); // Empty, so the writer did fail — without this the assertion above would // hold just as well against a writer that worked. const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192)); defer gpa.free(printed); try std.testing.expectEqual(@as(usize, 0), printed.len); } test "a broken error writer does not stop a first start that succeeded" { // The call this file makes: a DNS server for a household does not refuse to // resolve because stderr is broken. What it must not do is drop the warning // on the floor, so the second half checks the buffer still holds it. var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); // Valid, and it earns one warning: the source belongs to no group. try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = \\.{ \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } }, \\ .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } }, \\} }); var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); var out_buf: [64]u8 = undefined; var out: Writer = .fixed(&out_buf); var err_file = try tmp.dir.createFile(io, "stderr.txt", .{}); defer err_file.close(io); // 4 KiB, the buffer `main` gives the real stderr writer: the warning fits, // so `writeAll` succeeds into the buffer and the flush is what fails. That // is the shape production hits. var err_buf: [4096]u8 = undefined; var err_writer = brokenErrWriter(io, err_file, &err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface }; try std.testing.expectEqual( bootstrap.Outcome.seeded, try seedFromFile(r, &database, tmp.dir, "config.zon"), ); try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams")); // Nothing reached the file, so the flush really did fail. const undelivered = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192)); defer gpa.free(undelivered); try std.testing.expectEqual(@as(usize, 0), undelivered.len); // And the warning is still buffered rather than dropped: this is the same // writer, and these are the bytes `run`'s exit flush meets on the way out. err_writer.mode = .positional; try err_writer.interface.flush(); const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192)); defer gpa.free(printed); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "WARN blocklist_sources[0]: ")); } 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 {}; }