//! 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 api_limiter = @import("web/api_limiter.zig"); const auth = @import("web/auth.zig"); const cert_store = @import("server/cert_store.zig"); const cli = @import("cli.zig"); const client_names = @import("server/client_names.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_server = @import("server/doh_server.zig"); const dot_server = @import("server/dot_server.zig"); const events = @import("storage/events.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 loader = @import("config/loader.zig"); const local_records = @import("local/records.zig"); const local_tables = @import("server/local_tables.zig"); const logger_controller = @import("storage/logger_controller.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 queries_repo = @import("storage/repositories/queries_repo.zig"); const query_sink = @import("server/query_sink.zig"); const querylog_schema = @import("storage/querylog_schema.zig"); const rate_limiter = @import("server/rate_limiter.zig"); const reconcile = @import("config/reconcile.zig"); const retention_mod = @import("storage/retention.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 upstream_owner = @import("upstream/owner.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; 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 {}; // Ruling 6: after bootstrap seeding died, a fresh database fails // validation naturally (`NoUsableUpstreams`) and the operator needs // to be told how a database gets a configuration at all. In file // mode they already have a file, and the diagnostics above name what // is wrong with it. if (args.config == null) cli.writeDbSourceHint(runner.err); } 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; } /// File authority (ruling 2): read the file the operator named, validate it, and /// converge the database onto it — on this start and on every start after it. /// Returns the wall clock the reconcile ran at, which becomes the settings /// envelope's `reconciled_at`. /// /// A missing, unreadable or invalid file fails the start. There is no fallback /// to the database under any failure: a fallback turns a deploy typo into a /// silently stale configuration, which is the failure mode the whole mode exists /// to prevent. /// /// Diagnostics are printed on the way out either way. A 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. /// /// The runner's writers, 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. Both writers are /// buffered (`main` gives them 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 reconcileFromFile( r: cli.Runner, config_db: *db.Db, dir: std.Io.Dir, config_path: []const u8, config_load: ?*ConfigLoad, ) !i64 { return reconcileFromFileAt( r, config_db, dir, config_path, std.Io.Clock.real.now(r.io).toSeconds(), config_load, ); } /// `pass_now` is what the engine stamps into the runtime columns of the rows it /// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not /// the value this returns. /// /// The two clocks answer different questions, and conflating them was a real /// defect: `reconciled_at` means "this process loaded the file at T" and is /// compared against the file's mtime to detect a restart-pending state /// (ruling 7). A stamp taken *before* the read makes a file written during the /// read look newer than the process that loaded it — a false "restart pending" /// in the UI for a file that is fully applied. So this returns a clock read /// taken immediately after the commit, and `pass_now` never leaves the engine. /// /// Split from `reconcileFromFile` so the two are separable in a test: pin /// `pass_now` and the returned stamp must still be the real clock. fn reconcileFromFileAt( r: cli.Runner, config_db: *db.Db, dir: std.Io.Dir, config_path: []const u8, pass_now: i64, config_load: ?*ConfigLoad, ) !i64 { var arena_state: std.heap.ArenaAllocator = .init(r.gpa); defer arena_state.deinit(); var diags: validate.Diagnostics = .init(r.gpa); defer diags.deinit(); const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now); // Both discards are deliberate, and they answer different questions. On a // rejected file 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 file that applied, a failure here does not // stop the start: the trade is one lost warning line against a household // with no name resolution, and this writer *is* the error channel, so the // failure has nowhere to be reported anyway. diags.writeAll(r.err) catch {}; r.err.flush() catch {}; // Warnings only. A `.fail` rejects the file and the process exits, so there // is nobody left to read a diagnostics row about it; a warning is the case // where the box serves on with a setting the operator did not mean. if (config_load) |collector| { for (diags.problems.items) |problem| { if (problem.severity != .warn) continue; collector.note(problem.path, problem.path, problem.message); } } return result; } /// Boot's replay of one upstream finding. The rendering is the owner's, shared /// with the runtime reconciler so a warning raised at boot and the same warning /// raised by a settings PUT are byte-identical. fn noteUpstream(config_load: *ConfigLoad, url: []const u8, message: []const u8) void { var rendered: upstream_owner.Rendered = .{}; rendered.render(.{ .url = url, .message = message }); config_load.note(url, rendered.label, rendered.detail); } /// Returns the moment the transaction committed, which is what the settings /// envelope reports as `reconciled_at`. fn applyManagedFile( r: cli.Runner, config_db: *db.Db, dir: std.Io.Dir, config_path: []const u8, arena: Allocator, diags: *validate.Diagnostics, now: i64, ) !i64 { const cfg = try loader.load(r.io, arena, dir, config_path, diags); try validate.validate(cfg, diags); // The keys this pass wrote, never their values (ruling 8). Duplicated into // `gpa` by the engine, so this frame frees them. var changed: std.ArrayList([]const u8) = .empty; defer { for (changed.items) |key| r.gpa.free(key); changed.deinit(r.gpa); } var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{ .changed_settings = &changed, }) catch |err| { // `begin` has already rolled its own transaction back. What the operator // needs is the cause: a full SD card must read as "disk", not as a bare // exit 1, so the SQLite condition is named. reportReconcileFailure(r, config_db, config_path, err); return err; }; errdefer pass.rollback(); pass.commit() catch |err| { reportReconcileFailure(r, config_db, config_path, err); return err; }; // Read here and nowhere earlier: the file is loaded once this line runs, and // not one statement before it. const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds(); printSummary(r, config_path, pass.summary, changed.items) catch {}; return reconciled_at; } /// SQLite conditions an operator acts on differently. `@errorName` alone would /// say `Full`, which is not a word anyone can search for; the primary result /// code's own name is. fn sqliteCodeName(err: anyerror) ?[]const u8 { return switch (err) { error.Full => "SQLITE_FULL", error.Busy => "SQLITE_BUSY", error.IoErr => "SQLITE_IOERR", error.ReadOnly => "SQLITE_READONLY", error.Corrupt => "SQLITE_CORRUPT", error.Constraint => "SQLITE_CONSTRAINT", else => null, }; } fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void { var buf: [256]u8 = undefined; const detail = config_db.lastError(&buf); const code = sqliteCodeName(err) orelse @errorName(err); r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {}; r.err.flush() catch {}; } /// What that restart changed, without opening sqlite (ruling 8): per-table /// counts, the settings keys that moved — never their values — and an /// authentication change, which is never a silent line item in a count. fn printSummary( r: cli.Runner, config_path: []const u8, summary: reconcile.Summary, changed_settings: []const []const u8, ) !void { try r.out.print("reconciled '{s}':", .{config_path}); if (summary.isNoOp()) { try r.out.writeAll(" no changes\n"); } else { inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| { if (field.type == reconcile.TableCounts) { const counts = @field(summary, field.name); if (counts.total() != 0) { try r.out.print(" {s} +{d} ~{d} -{d};", .{ field.name, counts.inserted, counts.updated, counts.deleted, }); } } } try r.out.writeAll("\n"); if (changed_settings.len != 0) { try r.out.writeAll("settings keys changed:"); for (changed_settings) |key| try r.out.print(" {s}", .{key}); try r.out.writeAll("\n"); } switch (summary.auth_transition) { .none => {}, .enabled => try r.out.writeAll("web authentication is now enabled\n"), .disabled => try r.out.writeAll("web authentication is now disabled\n"), .rotated => try r.out.writeAll("the web password changed\n"), } } try r.out.flush(); } /// The `configuration.load` findings of one boot. /// /// This code is boot-finalized: nothing during the run can fix a setting, so a /// restart is its recovery. Every finding is reported as it is made and its key /// kept, and one `resolveExcept` after the last of them closes the episodes of /// settings that were wrong last boot and are not wrong now. const ConfigLoad = struct { store: ?*events.Store, io: std.Io, now_s: i64, keys: [events.Store.max_kept_keys][events.Store.max_subject_key_len]u8 = undefined, lens: [events.Store.max_kept_keys]u16 = @splat(0), len: usize = 0, /// Set when a boot produced more distinct findings than `resolveExcept` /// carries. The bulk resolve is then refused rather than truncated: a stale /// episode left open is honest, and one that is still true closed is not. /// The refusal goes through the store, so it is counted and latched. overflowed: bool = false, fn note(self: *ConfigLoad, key: []const u8, label: []const u8, detail: []const u8) void { const store = self.store orelse return; store.report(self.io, self.now_s, .configuration_load, key, label, .warning, detail); self.keep(key); } fn keep(self: *ConfigLoad, key: []const u8) void { var canon_buf: [events.Store.max_subject_key_len]u8 = undefined; const canon = events.canonicalKey(key, &canon_buf); for (0..self.len) |i| { if (std.mem.eql(u8, self.keys[i][0..self.lens[i]], canon)) return; } if (self.len == self.keys.len) { self.overflowed = true; return; } @memcpy(self.keys[self.len][0..canon.len], canon); self.lens[self.len] = @intCast(canon.len); self.len += 1; } fn finalize(self: *ConfigLoad) void { const store = self.store orelse return; if (self.overflowed) return store.refuseResolveExcept(self.io, self.now_s); var kept: [events.Store.max_kept_keys][]const u8 = undefined; for (0..self.len) |i| kept[i] = self.keys[i][0..self.lens[i]]; store.resolveExcept(self.io, self.now_s, .configuration_load, kept[0..self.len]); } }; 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); // Opened unconditionally, not gated on `cfg.web.enabled`: diagnostics record // what went wrong whether or not anyone is running the UI, and the store // owns this connection outright (`storage/events.zig`). var events_db = try data.openConfigDb(io); defer events_db.close(); const boot_now_s = std.Io.Clock.real.now(io).toSeconds(); var event_store_storage: ?events.Store = events.Store.init(io, &events_db, boot_now_s) catch |err| blk: { // No store rather than a store on a mirror it could not verify: the // latter answers `resolve` with confident no-ops. `/api/health` reports // the absence as `unavailable` and degrades on it. log.warn("diagnostics store unavailable: {s}", .{@errorName(err)}); break :blk null; }; const event_store: ?*events.Store = if (event_store_storage) |*s| s else null; var config_load: ConfigLoad = .{ .store = event_store, .io = io, .now_s = boot_now_s }; // Ruling 1: the presence of `--config` is the whole authority decision. With // it, the file is the sole declarative source and the database is converged // onto it here, before anything reads the database. Without it the database // is authority and this step does not exist — a file on disk that no flag // names changes nothing. const reconciled_at: ?i64 = if (args.config) |config_path| try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path, &config_load) else null; // 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(); // The web layer reads the managed path out of `WebState` for the whole life // of the process, so it takes a copy from the arena that outlives it rather // than borrowing `argv`. const authority: web_server.Authority = if (args.config) |config_path| .{ .managed_file = try arena.dupe(u8, config_path) } else .database; // The database stays the runtime substrate and the effective-config read // path in both modes: in file mode the reconcile above has just made it // agree with the file. 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; const upstream_generation = try upstream_owner.build(.{ .gpa = gpa, .io = io, .servers = cfg.upstreams, .http = &dns_http, .bundle = &bundle, .bundle_lock = &bundle_lock, .timeouts = .{ .attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake }, .total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake }, }, .seed = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))), .diagnostics = event_store, }); // `build` writes no event rows, so that a settings PUT can prepare a // candidate that is never published without leaving a trace. Boot has no // such candidate: this generation is the one that serves, and replaying its // report through the collector is what keeps the `configuration.load` // episodes — and the keys `finalize` below spares — exactly what they were // when this composition lived in this file. for (upstream_generation.report().notes) |finding| { noteUpstream(&config_load, finding.url, finding.message); } const upstream_count = upstream_generation.activeCount(); var upstreams: upstream_owner.Owner = .init(upstream_generation); defer upstreams.deinit(io); // ----------------------------------------------------------------------- // per-query state // ----------------------------------------------------------------------- var paused: pause.Pause = .{}; // One cell for both retention consumers, owned here so a settings apply // moves the daily query-log prune and the stale-client prune together. var retention_days: retention_mod.RetentionDays = .init(cfg.logging.retention_days); var tracker: clients.Tracker = .init(&retention_days); tracker.diagnostics = event_store; // Naming rides the tracker's pass, on the tracker's task and connection // (milestone-25 ruling 1), and reads the live forward zones. var client_names_resolver: client_names.Resolver = .init(&tables); client_names_resolver.diagnostics = event_store; // 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; } // ----------------------------------------------------------------------- // 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 (logging.logDirname(cfg.logging)) |dir| try arena.dupeZ(u8, dir) else null; var monitor: disk_monitor.Monitor = .init(cfg.disk, data.dir, data_path, log_dir_path); // Frees whatever log-directory generation a settings apply installed; boot's // path is borrowed from the arena and owned by nobody here. defer monitor.deinit(io); // Ruling 17. The scheduler consults it before every scheduled pass; the // startup `reload` below is an operator action and stays ungated. manager.monitor = &monitor; manager.diagnostics = event_store; // 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, event_store, boot_now_s); var retention: retention_mod.Retention = .init(&retention_days); var querylog_opened = try data.openQuerylogDb(io); var querylog_writer_db = querylog_opened.database; reportQuerylogRecreated(event_store, io, boot_now_s, &querylog_opened, &querylog_writer_db); // The controller adopts that first connection and owns the query logger // from here: the buffer, the `Logger`, the writer task and the connection // are one generation, and `logging.query_log_buffer_max` can replace all // four while the server runs (milestone 34 §S4). Its writer starts now and // parks on an empty queue, which is where it would be anyway — no producer // exists until the listeners below start serving. var log_controller: logger_controller.Controller = undefined; try log_controller.init(io, .{ .gpa = gpa, .database = querylog_writer_db, .source = .{ .dir = std.Io.Dir.cwd(), .path = data.querylog_db_path }, .logging = cfg.logging, .monitor = &monitor, .diagnostics = event_store, }); defer log_controller.deinit(io); var sink: query_sink.QuerySink = .init(&log_controller, hub); 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)}, ); // No manager lock is held here, so this reports directly rather than // through the manager's collector. if (event_store) |store| { var buf: [events.Store.max_detail_len]u8 = undefined; const detail = std.fmt.bufPrint( &buf, "loading the blocklist snapshot failed: {s}", .{@errorName(err)}, ) catch buf[0..]; store.report(io, boot_now_s, .blocklist_snapshot, "snapshot", "blocklist snapshot", .@"error", detail); } }; // ----------------------------------------------------------------------- // listeners // ----------------------------------------------------------------------- // Both are on the heap and both are freed through the handler rather than // through this frame: a `cache.size` or `dns.rate_limit` change swaps in a // replacement built with this same `gpa` and frees what it displaced, so // what the handler holds at shutdown is not necessarily what boot built. // Both are built inside one block so their errdefers end with it: after // the block the handler is the sole owner, and the teardown defers below // are what free them. An errdefer that outlived the block would free the // same object those defers free. const both = blk: { const c = try gpa.create(dns_cache.DnsCache); errdefer gpa.destroy(c); c.* = try .init(gpa, cfg.cache); errdefer c.deinit(); const l = try gpa.create(rate_limiter.RateLimiter); errdefer gpa.destroy(l); l.* = try .init(gpa, .{ .limit = cfg.dns.rate_limit, .window_seconds = cfg.dns.rate_window_seconds, }); break :blk .{ .cache = c, .limiter = l }; }; const cache = both.cache; const limiter = both.limiter; var h: handler.Handler = .{ .upstream = &upstreams, .policy = .{ .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 }, .negative_ttl_max = cfg.cache.negative_ttl_max, }, .manager = &manager, .local_tables = &tables, .cache = cache, .limiter = limiter, .sink = &sink, .pause = &paused, .tracker = &tracker, }; // These run after `group.cancel` below, so no query is inside either table. defer if (h.replaceCache(io, null)) |live| { live.deinit(); gpa.destroy(live); }; defer if (h.replaceRateLimiter(io, null)) |live| { live.deinit(); gpa.destroy(live); }; // ----------------------------------------------------------------------- // 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); doh_certs.?.kind = .doh; doh_certs.?.diagnostics = event_store; } 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); dot_certs.?.kind = .dot; dot_certs.?.diagnostics = event_store; } // 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 failed_listeners: [2][]const u8 = undefined; var failed_listener_count: usize = 0; var doh: ?doh_server.DohServer = null; defer if (doh) |*server| server.deinit(io); if (doh_certs) |*store| { doh = bindDoh(gpa, io, cfg.doh_server, &h, store, event_store, boot_now_s); if (doh == null) { failed_listeners[failed_listener_count] = "doh"; failed_listener_count += 1; } } 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, event_store, boot_now_s); if (dot == null) { failed_listeners[failed_listener_count] = "dot"; failed_listener_count += 1; } } // Boot-finalized: one call closes whatever the last boot left open for an // endpoint that started clean this time — including an endpoint now // disabled, which contributes no key and so is not kept. if (event_store) |store| { store.resolveExcept(io, boot_now_s, .listener_start, failed_listeners[0..failed_listener_count]); } // ----------------------------------------------------------------------- // 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. 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); // Same argument as the live hash: a settings PUT may have installed an // owned generation, and this runs after `group.cancel`. defer web_state.proxies.deinit(gpa); if (cfg.web.enabled) web_state = .{ .gpa = gpa, .web = cfg.web, .proxies = .init(cfg.web.trusted_proxies), .authority = authority, .reconciled_at = reconciled_at, .live_hash = .init(cfg.web.password_hash orelse ""), .handler = &h, .pause = &paused, .tracker = &tracker, .client_names = &client_names_resolver, .manager = &manager, .upstreams = &upstreams, .upstream_build = .{ .http = &dns_http, .bundle = &bundle, .bundle_lock = &bundle_lock, }, .monitor = &monitor, .local_tables = &tables, .logger = &log_controller, .retention = &retention, .retention_days = &retention_days, .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, .events = event_store, .version = version.string, .started_unix = std.Io.Clock.real.now(io).toSeconds(), // Ruling 24: `--admin-dev` serves from disk with no cache headers; // otherwise the embedded assets answer every non-/api miss. .fallback = if (args.admin_dev != null) serveAdminDev else static.fallback, .dev_dir = args.admin_dev orelse "", .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", .{}); config_load.note("dns.bind_ipv6", "dns.bind_ipv6", "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(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(io); // Every `configuration.load` finding of this boot is in by here: the managed // file, the upstream table and the IPv6 bind above. Finalizing any earlier // would resolve an episode this boot is about to reopen, so consecutive // IPv6-less boots would read as a new episode each time. config_load.finalize(); // Ruling 13: `/metrics` sums each transport's listeners into one family, so // the web state carries pointers to whichever of the four came up. The // arrays are declared here rather than beside `web_state` because a // listener that failed to bind is not in them; `group.cancel` below runs // before this frame is released, so no web task can outlive them. var udp_listeners: [2]*udp_server.UdpServer = undefined; var udp_count: usize = 0; if (udp6) |*s| { udp_listeners[udp_count] = s; udp_count += 1; } if (udp4) |*s| { udp_listeners[udp_count] = s; udp_count += 1; } web_state.udp_listeners = udp_listeners[0..udp_count]; var tcp_listeners: [2]*tcp_server.TcpServer = undefined; var tcp_count: usize = 0; if (tcp6) |*s| { tcp_listeners[tcp_count] = s; tcp_count += 1; } if (tcp4) |*s| { tcp_listeners[tcp_count] = s; tcp_count += 1; } web_state.tcp_listeners = tcp_listeners[0..tcp_count]; // ----------------------------------------------------------------------- // run // ----------------------------------------------------------------------- shutdown.install(io); // Declared after everything it borrows, so the teardown below — whose // `cancel` 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; // The gate every non-essential write consults. Reading it before the // monitor's own task has sampled is safe: a fresh `Monitor` publishes `.ok` // (disk_monitor.zig:63), so nothing is refused for want of a sample. const gate: ?*disk_monitor.Monitor = &monitor; // The query-log writers are deliberately *not* in `group`, and the live one // started before every producer. Inside the group their lives would end // with the same `cancel` that stops the producers, and cancellation would // race the queue's close: whichever landed first decided whether the batch // a writer was holding reached the database or was counted as dropped. The // controller owns their futures instead, so they outlive the producers by // construction. // // Ruling 4's shutdown order, on the one path every exit from here takes: // every producer stops and is joined, then `Controller.shutdown` closes // each queue and awaits each writer — so the last batch is written rather // than raced. A writer the disk gate will not let write counts its batch as // dropped instead of holding the exit open (`logger.zig`), so this wait // always ends. // // A `defer` and not straight-line code after `shutdown.wait`, because a // `concurrent` spawn below can fail with the DNS listeners already // serving; an orderly error teardown owes the operator the same drain a // signal gets. defer { group.cancel(io); log_controller.shutdown(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 }); try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate, event_store }); try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io, event_store }); try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io }); try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate, &client_names_resolver }); try group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, 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, authority, &manager, upstream_count, .{ .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 returning into the teardown deferred above // is how it stops. shutdown.wait(io) catch {}; log.info("shutting down", .{}); 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); } /// Dev-mode asset serving (ruling 24): straight from disk, no cache headers, /// so an edit shows up on the next reload. fn serveAdminDev( state: *web_server.WebState, io: std.Io, request: *http_util.Request, ) http_util.HandlerError!void { return static.serveFromDisk(state.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; }; } /// An error, not a warning: an endpoint the operator enabled is not serving. fn reportListener( store: ?*events.Store, io: std.Io, now_s: i64, kind: []const u8, comptime fmt: []const u8, args: anytype, ) void { const s = store orelse return; var buf: [events.Store.max_detail_len]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); w.print("{s} listener ", .{kind}) catch {}; w.print(fmt, args) catch {}; s.report(io, now_s, .listener_start, kind, kind, .@"error", w.buffered()); } /// 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, diagnostics: ?*events.Store, now_s: i64, ) ?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}); reportListener(diagnostics, io, now_s, "doh", "bind '{s}' is not an IP address", .{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 }); reportListener(diagnostics, io, now_s, "doh", "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, diagnostics: ?*events.Store, now_s: i64, ) ?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}); reportListener(diagnostics, io, now_s, "dot", "bind '{s}' is not an IP address", .{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 }); reportListener(diagnostics, io, now_s, "dot", "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, the DNS rate-limiter table and the API rate-limiter table. /// The first two 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 API limiter needs the same schedule for /// the same reason: its table holds 4096 addresses, and once it is full every /// unknown address pays an eviction scan. /// /// 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, api: ?*api_limiter.ApiLimiter, 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); try maintenanceOnce(h, api, io); } } /// One sweep of each table. Separate from the loop so a test can run a pass /// without waiting out `maintenance_interval_s`. fn maintenanceOnce( h: *handler.Handler, api: ?*api_limiter.ApiLimiter, io: std.Io, ) std.Io.Cancelable!void { // Both pointers are read inside the mutex that guards their replacement, // the same discipline the query path follows: a load taken before the lock // could sweep a table `replaceCache`/`replaceRateLimiter` has just freed. { const now_s = std.Io.Clock.real.now(io).toSeconds(); try h.cache_mutex.lock(io); defer h.cache_mutex.unlock(io); if (h.cache) |cache| _ = cache.sweep(now_s); } { const now = std.Io.Clock.awake.now(io); try h.limiter_mutex.lock(io); defer h.limiter_mutex.unlock(io); if (h.limiter) |limiter| _ = limiter.sweep(now); } // The API limiter takes its own mutex, unlike the two above, which are the // handler's. Nothing here holds a lock across the call. if (api) |limiter| _ = limiter.sweep(io, std.Io.Clock.awake.now(io)); } // --------------------------------------------------------------------------- // 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; } /// Files the one-shot `query_log.recreated` event for a boot that replaced the /// query log. /// /// One-shot and already over: the file was recreated during this boot, and /// there is nothing to recover from. Never emitted for `.missing` — a first /// creation renames nothing aside, so the event would carry an aside path that /// does not exist and would greet every fresh install with a warning. /// /// `database` is the connection to the file that was just created; the coverage /// start is read from it rather than recomputed, so the event states the value /// the API will. fn reportQuerylogRecreated( store: ?*events.Store, io: std.Io, now_s: i64, opened: *const querylog_schema.OpenResult, database: *db.Db, ) void { const cause = opened.recreated orelse return; if (cause == .missing) return; const s = store orelse return; // The coverage start belongs in this detail: the recreate is exactly the // moment the history the operator had stops existing, and the watermark is // the answer to "from when can I still ask?". const coverage_start: ?i64 = queries_repo.availableSince(database) catch null; var detail_buf: [events.Store.max_detail_len]u8 = undefined; const detail = recreatedDetail(&detail_buf, opened.aside(), coverage_start); s.reportResolved(io, now_s, .query_log_recreated, @tagName(cause), @tagName(cause), .warning, detail); } /// The `query_log.recreated` detail line: what was kept, and from when the new /// file can answer. /// /// The coverage start is the operator's actual remedy information — the event /// says "this history is gone" and this says "and here is where the new history /// begins". Null only when the fresh file would not answer, which is already a /// separate failure; the line still names the aside rather than saying nothing. /// /// The aside is a full path under the data directory, which can be longer than /// the whole detail column, so the two facts compete for the buffer. The /// watermark always wins and the name degrades in whole steps: full path, then /// basename — which the event's own database directory disambiguates — then no /// name at all. Never a path cut mid-string, which names no file on disk and /// reads as if it did. fn recreatedDetail( buf: *[events.Store.max_detail_len]u8, aside: []const u8, coverage_start: ?i64, ) []const u8 { const names = [_][]const u8{ aside, std.fs.path.basename(aside) }; const since = coverage_start orelse { for (names) |name| { return std.fmt.bufPrint(buf, "previous file kept as '{s}'", .{name}) catch continue; } return "previous file kept aside"; }; for (names) |name| { return std.fmt.bufPrint( buf, "previous file kept as '{s}'; query history is available from {d}", .{ name, since }, ) catch continue; } // The buffer is `max_detail_len`, which no i64 can overrun on its own. return std.fmt.bufPrint(buf, "query history is available from {d}", .{since}) catch unreachable; } const events_fixture = @import("storage/events_fixture.zig"); const testing = std.testing; test "the recreated detail names the aside and the new coverage start" { var buf: [events.Store.max_detail_len]u8 = undefined; try std.testing.expectEqualStrings( "previous file kept as 'querylog.db.schema-changed-1700000000'; " ++ "query history is available from 1700000001", recreatedDetail(&buf, "querylog.db.schema-changed-1700000000", 1700000001), ); // A fresh file that will not answer is a separate failure; the line still // says what was kept rather than reporting nothing. try std.testing.expectEqualStrings( "previous file kept as 'querylog.db.corrupt-1700000000'", recreatedDetail(&buf, "querylog.db.corrupt-1700000000", null), ); // A data directory deep enough that its path alone would fill the column: // the watermark is complete and the name degrades to the basename, which // still names a real file. const deep = "/srv/" ++ ("d" ** 60 ++ "/") ** 8 ++ "querylog.db.corrupt-1700000000"; try std.testing.expectEqualStrings( "previous file kept as 'querylog.db.corrupt-1700000000'; " ++ "query history is available from 1700000001", recreatedDetail(&buf, deep, 1700000001), ); try std.testing.expectEqualStrings( "previous file kept as 'querylog.db.corrupt-1700000000'", recreatedDetail(&buf, deep, null), ); // No filesystem produces a name this long, but a truncated one would name // nothing: the watermark survives alone rather than half-named. const unnameable = "/srv/" ++ "n" ** 500; try std.testing.expectEqualStrings( "query history is available from 1700000001", recreatedDetail(&buf, unnameable, 1700000001), ); try std.testing.expectEqualStrings( "previous file kept aside", recreatedDetail(&buf, unnameable, null), ); } /// The `querylog.db` schema as milestone 29 shipped it, verbatim from /// `querylog_schema.zig` at commit fa323c7. A literal and not this build's DDL /// with the deleted tables appended: the appended form drifts the moment the /// surviving tables change, and its fingerprint was never the one an m29 file /// on disk actually carries. The transition under test is that exact byte /// sequence meeting this build. /// The `PRAGMA user_version` an m29 file on disk carries, written down rather /// than recomputed from the literal below. A CRC taken over the fixture /// validates whatever the fixture happens to say, so a slip in the "byte-exact" /// literal would still self-certify; pinning the historical number turns that /// slip into a failure. Its value is `Crc32` over `querylog_schema.ddl` at /// commit fa323c7. const m29_fingerprint: i32 = 603440875; /// A watermark from long before this test runs. Both schemas seed /// `available_since` from `unixepoch()`, so a fixture left at its own default /// would satisfy "the new file's coverage is not older" even if recreation /// copied the replaced file's promise straight across. const m29_available_since: i64 = 1_600_000_000; const m29_ddl: [:0]const u8 = \\CREATE TABLE domains ( \\ id INTEGER PRIMARY KEY, \\ domain TEXT NOT NULL UNIQUE \\); \\ \\CREATE TABLE query_log ( \\ id INTEGER PRIMARY KEY, \\ timestamp INTEGER NOT NULL, \\ domain_id INTEGER NOT NULL REFERENCES domains(id), \\ client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts \\ qtype INTEGER, \\ blocked INTEGER NOT NULL, \\ response_time_us INTEGER, \\ cache_hit INTEGER, \\ upstream TEXT, \\ qclass INTEGER NOT NULL, \\ rcode INTEGER NOT NULL, \\ group_id INTEGER, -- text/id pairs, not FKs: a renamed \\ group_name TEXT, -- group must not rewrite history \\ policy_action TEXT NOT NULL, \\ policy_reason TEXT NOT NULL, \\ matched TEXT, \\ source_id INTEGER, \\ source_name TEXT, \\ cname_target TEXT, \\ safe_search_target TEXT, \\ route_kind TEXT NOT NULL, \\ forward_zone TEXT, \\ CHECK (rcode BETWEEN 0 AND 4095) -- twelve bits (RFC 6891 6.1.3) \\); \\CREATE INDEX idx_query_log_ts ON query_log(timestamp); \\CREATE INDEX idx_query_log_client ON query_log(client_ip); \\CREATE INDEX idx_query_log_domain ON query_log(domain_id); \\ \\CREATE TABLE upstream_targets ( \\ id INTEGER PRIMARY KEY, \\ url TEXT NOT NULL UNIQUE -- the historical identity: config.db ids cannot cross database files \\); \\ \\CREATE TABLE upstream_minute ( \\ upstream_id INTEGER NOT NULL REFERENCES upstream_targets(id), \\ minute_ts INTEGER NOT NULL, \\ successes INTEGER NOT NULL, \\ failures INTEGER NOT NULL, \\ last_failure_ts INTEGER, \\ last_error TEXT, \\ PRIMARY KEY (upstream_id, minute_ts), \\ CHECK (successes >= 0), \\ CHECK (failures >= 0) \\) WITHOUT ROWID; \\CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts); \\ \\CREATE TABLE querylog_meta ( \\ id INTEGER PRIMARY KEY CHECK (id = 1), -- one row, enforced by the schema \\ created_at INTEGER NOT NULL, \\ available_since INTEGER NOT NULL \\); \\INSERT INTO querylog_meta (id, created_at, available_since) \\VALUES (1, unixepoch(), unixepoch() + 1); ; test "an m29 query log is set aside and recreated without the upstream-history tables" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var path_buf: [256]u8 = undefined; const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path}); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); // The fixture is only worth anything while it is still a *different* // schema from this build's, and one that carries the deleted tables. try testing.expect(!std.mem.eql(u8, m29_ddl, querylog_schema.ddl)); try testing.expect(std.mem.indexOf(u8, m29_ddl, "CREATE TABLE upstream_minute") != null); try testing.expect(std.mem.indexOf(u8, querylog_schema.ddl, "upstream_minute") == null); // And only while it is still m29's bytes: this is the one check that an // edit to the literal cannot satisfy by changing what it is compared to. try testing.expectEqual(m29_fingerprint, @as(i32, @bitCast(std.hash.Crc32.hash(m29_ddl)))); // A healthy m29 file, stamped with the fingerprint m29's own DDL produced // and backdated so its coverage promise is visibly the older one. const m29_coverage = blk: { var m29 = try db.Db.open(path, .{ .mode = .read_write_create }); defer m29.close(); try db.applyPragmas(&m29, .{}); try m29.exec(m29_ddl); try m29.exec("INSERT INTO upstream_targets (url) VALUES ('https://dns.example/dns-query');"); var meta_buf: [128]u8 = undefined; try m29.exec(try std.fmt.bufPrintZ( &meta_buf, "UPDATE querylog_meta SET created_at = {d}, available_since = {d};", .{ m29_available_since, m29_available_since }, )); var version_buf: [64]u8 = undefined; try m29.exec(try std.fmt.bufPrintZ( &version_buf, "PRAGMA user_version = {d};", .{m29_fingerprint}, )); break :blk try m29.queryInt("SELECT available_since FROM querylog_meta"); }; try testing.expectEqual(m29_available_since, m29_coverage); var opened = try querylog_schema.open(io, std.Io.Dir.cwd(), path); defer opened.database.close(); // Set aside under the name that says the file was healthy and this build // moved, and still on disk for an operator who wants it. try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, opened.recreated.?); try testing.expect(std.mem.indexOf(u8, opened.aside(), ".schema-changed-") != null); try tmp.dir.access(io, std.fs.path.basename(opened.aside()), .{}); // The two tables are gone from the file this process will write to. for ([_][]const u8{ "upstream_targets", "upstream_minute", "idx_upstream_minute_ts" }) |name| { var stmt = try opened.database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1"); defer stmt.deinit(); try stmt.bindText(1, name); try testing.expect(try stmt.step()); try testing.expectEqual(@as(i64, 0), stmt.columnInt(0)); } // Coverage restarts: the new file does not inherit the replaced one's // promise about what it can answer. Strictly newer, not merely not-older — // a recreation that copied the watermark across would pass the weaker test. const coverage = try queries_repo.availableSince(&opened.database); try testing.expect(coverage > m29_coverage); reportQuerylogRecreated(&fx.store, io, 2000, &opened, &opened.database); try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events")); try testing.expectEqualStrings( "fingerprint_mismatch", try fx.text("SELECT subject_key FROM operational_events"), ); } test "a fingerprint recreate files a resolved event naming the real aside and watermark" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); var path_buf: [256]u8 = undefined; const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path}); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); // A fresh install: the file was missing, nothing was set aside, and the // event would name a path that does not exist. var created = try querylog_schema.open(io, std.Io.Dir.cwd(), path); reportQuerylogRecreated(&fx.store, io, 1000, &created, &created.database); created.database.close(); try testing.expectEqual(@as(i64, 0), try fx.count("SELECT count(*) FROM operational_events")); // A healthy file this build's DDL no longer matches, which is what an // upgrade that edits the schema produces. { var stamped = try db.Db.open(path, .{ .mode = .read_write_existing }); defer stamped.close(); var sql_buf: [64]u8 = undefined; try stamped.exec(try std.fmt.bufPrintZ( &sql_buf, "PRAGMA user_version = {d};", .{querylog_schema.fingerprint +% 1}, )); } var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path); defer recreated.database.close(); try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, recreated.recreated.?); reportQuerylogRecreated(&fx.store, io, 2000, &recreated, &recreated.database); try testing.expectEqualStrings("query_log.recreated", try fx.text("SELECT code FROM operational_events")); try testing.expectEqualStrings("fingerprint_mismatch", try fx.text("SELECT subject_key FROM operational_events")); try testing.expectEqualStrings("warning", try fx.text("SELECT severity FROM operational_events")); // One-shot: already over when it is filed, so it never becomes an open // episode `/api/health` counts. try testing.expectEqual( @as(i64, 0), try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"), ); // The detail carries the path that is actually on disk and the watermark // the API will serve, both read back from the recreate rather than from // the arguments the event was built with. try tmp.dir.access(io, std.fs.path.basename(recreated.aside()), .{}); const coverage = try queries_repo.availableSince(&recreated.database); var expected_buf: [events.Store.max_detail_len]u8 = undefined; const expected = try std.fmt.bufPrint( &expected_buf, "previous file kept as '{s}'; query history is available from {d}", .{ recreated.aside(), coverage }, ); try testing.expectEqualStrings(expected, try fx.text("SELECT detail FROM operational_events")); // A boot with no diagnostics store configured is not a failure path. reportQuerylogRecreated(null, io, 2000, &recreated, &recreated.database); try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events")); } test "a recreate under a long data directory keeps the watermark and a usable name" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); // Deep enough that the aside outgrows the detail column, shallow enough // that SQLite's unix VFS still opens the file: it caps a path at 512 bytes. const nested = ("d" ** 60 ++ "/") ** 6 ++ "d" ** 60; try tmp.dir.createDirPath(io, nested); var path_buf: [1024]u8 = undefined; const path = try std.fmt.bufPrintZ( &path_buf, ".zig-cache/tmp/{s}/{s}/querylog.db", .{ tmp.sub_path, nested }, ); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); { var created = try querylog_schema.open(io, std.Io.Dir.cwd(), path); defer created.database.close(); var sql_buf: [64]u8 = undefined; try created.database.exec(try std.fmt.bufPrintZ( &sql_buf, "PRAGMA user_version = {d};", .{querylog_schema.fingerprint +% 1}, )); } var recreated = try querylog_schema.open(io, std.Io.Dir.cwd(), path); defer recreated.database.close(); try testing.expectEqual(querylog_schema.RecreateReason.fingerprint_mismatch, recreated.recreated.?); const line_overhead = "previous file kept as ''; query history is available from ".len; try testing.expect(recreated.aside().len + line_overhead > events.Store.max_detail_len); reportQuerylogRecreated(&fx.store, io, 2000, &recreated, &recreated.database); const detail = try fx.text("SELECT detail FROM operational_events"); const coverage = try queries_repo.availableSince(&recreated.database); const name = std.fs.path.basename(recreated.aside()); var expected_buf: [events.Store.max_detail_len]u8 = undefined; const expected = try std.fmt.bufPrint( &expected_buf, "previous file kept as '{s}'; query history is available from {d}", .{ name, coverage }, ); // The watermark is whole — the fact that would be lost to a mid-string cut // — and the name it kept is the file's, not a prefix of its path. try testing.expectEqualStrings(expected, detail); try testing.expect(detail.len <= events.Store.max_detail_len); var deep = try tmp.dir.openDir(io, nested, .{}); defer deep.close(io); try deep.access(io, name, .{}); } 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 --config`: `serve` validates through this exact call before it // reconciles, 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 start in file mode prints the warnings the file earned" { // D5, second half. `run` printed diagnostics only when the file was // rejected, which made a successful start the one place a finding could not // surface. Under file authority the file is read on every start, so this is // the line an operator sees after every restart, not only the first. 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 converges onto // it. The returned stamp is what the settings envelope reports. const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon", null); try std.testing.expect(reconciled_at > 0); 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")); } test "run with a missing managed file exits 2 with the path, and never serves from the database" { // Ruling 2: file mode fails closed. The database below is a perfectly good // one — migrated, and the run would have reached the listeners on it in db // mode — so a fallback would show up here as exit 0. 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(); var data_buf: [160]u8 = undefined; const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path}); var missing_buf: [160]u8 = undefined; const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path}); // Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the // operator received the lines, and a fixed writer's flush is a no-op that // counts a buffered line as delivered. var out_file = try tmp.dir.createFile(io, "stdout.txt", .{}); defer out_file.close(io); var err_file = try tmp.dir.createFile(io, "stderr.txt", .{}); defer err_file.close(io); var out_buf: [4096]u8 = undefined; var err_buf: [4096]u8 = undefined; var out_writer = out_file.writer(io, &out_buf); var err_writer = err_file.writer(io, &err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err_writer.interface, }; try std.testing.expectEqual(cli.exit_check, run(r, .{ .paths = .{ .data_dir = data_dir }, .config = missing, })); const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192)); defer gpa.free(printed); // The path is in the message: an error name alone tells the operator nothing // about which file the deploy got wrong. try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing)); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file")); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check")); // The db-mode remediation hint belongs to db mode: in file mode the operator // has a file, and the diagnostic above says what is wrong with it. try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth")); } test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" { // Ruling 7: `reconciled_at` means "this process loaded the file at T", and // the UI compares it against the file's mtime to say whether a restart is // pending. A stamp taken before the read makes a file written while the read // ran look newer than the process that loaded it — a restart-pending banner // over a configuration that is fully applied. // // The pass clock is pinned to 1970 here, which the engine really does use: // the inserted client below carries it. If the two were one value, the // returned stamp would be 1970 too. 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" } }, \\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } }, \\} }); var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); var out_buf: [4096]u8 = undefined; var err_buf: [1024]u8 = undefined; var out: Writer = .fixed(&out_buf); var err: Writer = .fixed(&err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err }; const pass_now: i64 = 42; const before = std.Io.Clock.real.now(io).toSeconds(); const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now, null); // The pinned clock reached the engine, so the two values really are separate // inputs rather than the same read twice. try std.testing.expectEqual(pass_now, try database.queryInt( "SELECT first_seen FROM clients WHERE ip = '192.168.1.5'", )); try std.testing.expect(reconciled_at != pass_now); try std.testing.expect(reconciled_at >= before); } test "the startup summary reports what the reconcile changed, then that nothing changed" { // Ruling 8: the answer to "what did that restart change" without opening // sqlite. Also ruling 5 from the operator's side — the second start of an // unchanged file says so. 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" } }, \\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }, \\} }); var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); var out_file = try tmp.dir.createFile(io, "stdout.txt", .{}); defer out_file.close(io); var out_buf: [4096]u8 = undefined; var out_writer = out_file.writer(io, &out_buf); var err_buf: [1024]u8 = undefined; var err: Writer = .fixed(&err_buf); const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err }; _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon", null); { // Read back through the file: `serve` does not return for as long as the // service runs, so a summary still in the buffer is a summary nobody // reads. const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192)); defer gpa.free(printed); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':")); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0")); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:")); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash")); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled")); // The keys, never the values: the hash the file set must not be echoed. try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$")); } try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" }); _ = try reconcileFromFile(r, &database, tmp.dir, "config.zon", null); { const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192)); defer gpa.free(printed); try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes")); } } /// 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 managed file was rejected" { // The operator has to see why the start 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, reconcileFromFile(r, &database, tmp.dir, "config.zon", null), ); // 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 start whose file applied" { // 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 reconcileFromFile(r, &database, tmp.dir, "config.zon", null); 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. /// /// Preceded by the authority (ruling 8), because every other question about a /// restart — why a UI edit vanished, why a file edit did not apply — starts with /// which of the two governs this process, and the journal is where an operator /// looks for it. fn logStartup( io: std.Io, authority: web_server.Authority, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners, ) void { log.info("authority: {f}", .{AuthorityText{ .authority = authority }}); 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, }); } } /// Which source governs this process, in the words the journal carries. /// /// A formatter rather than a rendering into a buffer of this file's own: a /// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts /// make long ones ordinary, so a fixed buffer here would silently drop exactly /// the half of the line an operator came for. Writing straight to the log sink's /// writer leaves the one documented, counted truncation in `platform/logging.zig` /// as the only limit. const AuthorityText = struct { authority: web_server.Authority, pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void { switch (self.authority) { .database => try w.writeAll("database"), .managed_file => |path| try w.print("file ({s})", .{path}), } } }; /// 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 {}; } test "the startup line names which source governs this process, path and all" { // Ruling 8. Every other question about a restart starts here, so the answer // is in the journal rather than derived from the unit file by whoever is // reading at 2am. const gpa = std.testing.allocator; var short: Writer.Allocating = .init(gpa); defer short.deinit(); try short.writer.print("{f}", .{AuthorityText{ .authority = .database }}); try std.testing.expectEqualStrings("database", short.written()); var named: Writer.Allocating = .init(gpa); defer named.deinit(); try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }}); try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written()); // A path past any buffer this file could reasonably have picked. Nested bind // mounts produce paths like this, and the path is the half of the line the // operator came for — dropping it to keep the line short is the wrong trade. const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon"; try std.testing.expect(long_path.len > 256); var long: Writer.Allocating = .init(gpa); defer long.deinit(); try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }}); try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path)); } const test_address = @import("platform/address.zig"); test "one maintenance pass drops the api limiter's stale buckets" { var threaded: std.Io.Threaded = .init(std.testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var limiter = try api_limiter.ApiLimiter.init(std.testing.allocator, .{ .rate_per_min = 60, .localhost_exempt = false, .sse_max_per_ip = 3, }); defer limiter.deinit(); // A bucket last touched a full window ago has refilled to capacity, so a // fresh bucket would answer identically and the sweep may drop it. The // pass reads the real `.awake` clock, so the bucket is aged by dating the // request rather than by waiting. const now = std.Io.Clock.awake.now(io); const window_ns = @as(i96, api_limiter.window_seconds) * std.time.ns_per_s; const client: test_address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 10 } }; _ = limiter.check(io, .{ .nanoseconds = now.nanoseconds - 2 * window_ns }, client); try std.testing.expectEqual(@as(u32, 1), limiter.trackedClients(io)); // Nothing here exchanges: the pass only sweeps the two tables. var unreachable_upstream: upstream_owner.Borrowed = .{}; var h: handler.Handler = .{ .upstream = unreachable_upstream.client(.{ .ptr = undefined, .exchangeFn = undefined }), .policy = .{ .blocking = .{ .mode = .zero, .ttl = 5 }, .forward_read_timeout = .{ .raw = .fromMilliseconds(50), .clock = .awake } }, }; try maintenanceOnce(&h, &limiter, io); // Before ruling 12 the sweep had no production caller, so the table kept // this bucket until the process restarted. try std.testing.expectEqual(@as(u32, 0), limiter.trackedClients(io)); // A limiter the app did not build is not a reason for the pass to fail. try maintenanceOnce(&h, null, io); } test "a failing listener bind opens an error episode a clean boot closes" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); reportListener(&fx.store, io, 1000, "dot", "cannot listen on {s}:{d}: {t}", .{ "0.0.0.0", @as(u16, 853), error.AddressInUse, }); try testing.expectEqualStrings("listener.start", try fx.text("SELECT code FROM operational_events")); try testing.expectEqualStrings("dot", try fx.text("SELECT subject_key FROM operational_events")); try testing.expectEqualStrings("error", try fx.text("SELECT severity FROM operational_events")); // The next boot: DoH failed, DoT started clean. One `resolveExcept` over // the failed keys closes the stale DoT episode and leaves the DoH one open. reportListener(&fx.store, io, 1100, "doh", "cannot listen on {s}:{d}: {t}", .{ "0.0.0.0", @as(u16, 443), error.AddressInUse, }); fx.store.resolveExcept(io, 1100, .listener_start, &.{"doh"}); try testing.expectEqual( @as(i64, 1), try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"), ); try testing.expectEqualStrings("doh", try fx.text( "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL", )); } test "a boot with no listener finding closes every listener episode" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); fx.store.report(io, 900, .listener_start, "doh", "doh", .@"error", "stale"); fx.store.report(io, 900, .listener_start, "dot", "dot", .@"error", "stale"); fx.store.resolveExcept(io, 1000, .listener_start, &.{}); try testing.expectEqual( @as(i64, 0), try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"), ); } test "a configuration finding is reported once and finalize closes the rest" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); // Two stale episodes from a previous boot. fx.store.report(io, 900, .configuration_load, "dns.bind_ipv6", "dns.bind_ipv6", .warning, "stale"); fx.store.report(io, 900, .configuration_load, "upstreams[0].url", "upstreams[0].url", .warning, "stale"); var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 }; collector.note("dns.bind_ipv6", "dns.bind_ipv6", "this system has no IPv6; serving IPv4 only"); // The same finding twice is one episode and one kept key. collector.note("dns.bind_ipv6", "dns.bind_ipv6", "this system has no IPv6; serving IPv4 only"); try testing.expectEqual(@as(usize, 1), collector.len); collector.finalize(); try testing.expectEqual( @as(i64, 1), try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"), ); try testing.expectEqualStrings("dns.bind_ipv6", try fx.text( "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL", )); // The stale episode is still the same one: this boot bumped it twice. try testing.expectEqual(@as(i64, 3), try fx.count( "SELECT occurrences FROM operational_events WHERE resolved_at IS NULL", )); } test "the upstream build's report replays into the same rows the boot path used to write" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); // Last boot's finding for a row this boot has fixed. Only `finalize` // closes it, which is why the replay has to run before it. fx.store.report(io, 900, .configuration_load, "ftp://fixed.example", "ftp://fixed.example", .warning, "stale"); var http: std.http.Client = .{ .allocator = testing.allocator, .io = io }; defer http.deinit(); var bundle: Certificate.Bundle = .empty; defer bundle.deinit(testing.allocator); var bundle_lock: std.Io.RwLock = .init; const generation = try upstream_owner.build(.{ .gpa = testing.allocator, .io = io, // The credential in the bad row is why the key and the rendered text // differ: the key is the whole url, and both rendered forms drop it. .servers = &.{ .{ .url = "ftp://user:hunter2@nope.example" }, .{ .url = "https://good.example/dns-query" }, }, .http = &http, .bundle = &bundle, .bundle_lock = &bundle_lock, .timeouts = .{ .attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake }, .total = .{ .raw = .fromMilliseconds(200), .clock = .awake }, }, .seed = 1, }); var upstreams: upstream_owner.Owner = .init(generation); defer upstreams.deinit(io); // `build` itself wrote nothing: a candidate a settings PUT never publishes // must leave the diagnostics log exactly as it found it. try testing.expectEqual(@as(i64, 1), try fx.count("SELECT count(*) FROM operational_events")); var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 }; for (generation.report().notes) |finding| { noteUpstream(&collector, finding.url, finding.message); } collector.finalize(); // The whole url is the key, the redaction is the label, and the detail is // the sentence `noteUpstream` has always written — byte for byte. try testing.expectEqual( @as(i64, 1), try fx.count("SELECT count(*) FROM operational_events WHERE resolved_at IS NULL"), ); try testing.expectEqualStrings("ftp://user:hunter2@nope.example", try fx.text( "SELECT subject_key FROM operational_events WHERE resolved_at IS NULL", )); try testing.expectEqualStrings("ftp://nope.example", try fx.text( "SELECT subject_label FROM operational_events WHERE resolved_at IS NULL", )); try testing.expectEqualStrings( "upstream 'ftp://nope.example' not an https:// or tls:// endpoint; skipped", try fx.text("SELECT detail FROM operational_events WHERE resolved_at IS NULL"), ); // The row that was wrong last boot and is not wrong now is closed by the // same `finalize` as ever: the replay is what puts the keys in front of it. try testing.expectEqual(@as(i64, 1), try fx.count( "SELECT count(*) FROM operational_events WHERE subject_key = 'ftp://fixed.example' AND resolved_at IS NOT NULL", )); } test "an over-long boot finding list refuses to finalize rather than truncate" { var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); var fx: events_fixture.Fixture = .{}; try fx.init(io, 1000); defer fx.deinit(); fx.store.report(io, 900, .configuration_load, "stale.setting", "stale.setting", .warning, "stale"); var collector: ConfigLoad = .{ .store = &fx.store, .io = io, .now_s = 1000 }; var key_buf: [32]u8 = undefined; for (0..events.Store.max_kept_keys + 1) |i| { const key = try std.fmt.bufPrint(&key_buf, "upstreams[{d}].url", .{i}); collector.note(key, key, "not a usable url"); } try testing.expect(collector.overflowed); collector.finalize(); // Closing more than the boot meant would resolve episodes that are still // true, so the stale one stays open instead. try testing.expectEqual(@as(i64, 1), try fx.count( "SELECT count(*) FROM operational_events WHERE subject_key = 'stale.setting' AND resolved_at IS NULL", )); // Refused, and refused out loud: the store counted and latched it, so // `/api/health` reports the diagnostics log as not recording. try testing.expect(fx.store.writeFailed()); try testing.expectEqual(@as(u64, 1), fx.store.writeFailures()); }