//! The command line, everything except the process shell around it. //! //! `main.zig` parses `argv`, dispatches and returns an exit code; every command //! body lives here and takes its writers as parameters, so a test can capture //! what an operator would see into a `std.Io.Writer.Allocating`. //! //! Exit codes (milestone-1 convention, extended): //! //! - `0` success; //! - `1` a runtime failure — I/O, database, out of memory; //! - `2` a configuration the operator can fix, or a `check` that found a //! problem; //! - `64` a usage error. const std = @import("std"); const Allocator = std.mem.Allocator; const Writer = std.Io.Writer; const Certificate = std.crypto.Certificate; const tls = std.crypto.tls; const app = @import("app.zig"); const config_export = @import("config/export.zig"); const faults = @import("config/faults.zig"); const import = @import("config/import.zig"); const model = @import("config/model.zig"); const validate = @import("config/validate.zig"); const cert_store = @import("server/cert_store.zig"); const db = @import("storage/db.zig"); const migrations = @import("storage/migrations.zig"); const querylog_schema = @import("storage/querylog_schema.zig"); const doh_client = @import("upstream/doh_client.zig"); const dot_client = @import("upstream/dot_client.zig"); const pool = @import("upstream/pool.zig"); const transport = @import("upstream/transport.zig"); const safe_url = @import("safe_url.zig"); const version = @import("version.zig"); pub const exit_ok: u8 = 0; pub const exit_runtime: u8 = 1; pub const exit_check: u8 = 2; pub const exit_usage: u8 = 64; pub const config_db_name = "config.db"; pub const querylog_db_name = "querylog.db"; // --------------------------------------------------------------------------- // argument parsing (pure) // --------------------------------------------------------------------------- pub const Paths = struct { /// PLAN §3.13. data_dir: []const u8 = "/var/lib/nxdns", config: []const u8 = "/etc/nxdns/config.zon", }; /// `config_explicit` records whether `--config` was given, because `check` has /// to tell "the operator named a file" from "the default path happens to /// exist". pub const CheckArgs = struct { paths: Paths = .{}, config_explicit: bool = false }; pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null }; pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false }; /// `web_dev` is milestone-8 ruling 24's `--web-dev `: serve the web /// interface from that directory instead of the embedded assets. pub const RunArgs = struct { paths: Paths = .{}, web_dev: ?[]const u8 = null }; pub const Command = union(enum) { run: RunArgs, check: CheckArgs, export_: ExportArgs, import_: ImportArgs, version, help, }; pub const ParseError = error{ UnknownCommand, UnknownFlag, MissingValue, MissingArgument, TooManyArguments, }; /// `argv` excludes the program name. Every slice in the result borrows from /// `argv`. pub fn parseArgs(argv: []const []const u8) ParseError!Command { if (argv.len == 0) return error.UnknownCommand; const command = argv[0]; const rest = argv[1..]; if (eql(command, "version")) { if (rest.len != 0) return error.TooManyArguments; return .version; } if (eql(command, "help") or eql(command, "--help") or eql(command, "-h")) { if (rest.len != 0) return error.TooManyArguments; return .help; } if (eql(command, "run")) return .{ .run = try parseRunArgs(rest) }; if (eql(command, "check")) return .{ .check = try parseCheckArgs(rest) }; if (eql(command, "export")) return .{ .export_ = try parseExportArgs(rest) }; if (eql(command, "import")) return .{ .import_ = try parseImportArgs(rest) }; return error.UnknownCommand; } const Flag = struct { /// Without the leading `--`. name: []const u8, /// The `value` of `--flag=value`, absent for `--flag`. attached: ?[]const u8, }; fn splitFlag(arg: []const u8) ?Flag { if (!std.mem.startsWith(u8, arg, "--")) return null; const body = arg[2..]; if (std.mem.findScalar(u8, body, '=')) |at| { return .{ .name = body[0..at], .attached = body[at + 1 ..] }; } return .{ .name = body, .attached = null }; } /// `--flag=value` and `--flag value` both work. An empty attached value is a /// missing value, not an empty path. fn flagValue(flag: Flag, argv: []const []const u8, i: *usize) ParseError![]const u8 { if (flag.attached) |attached| { if (attached.len == 0) return error.MissingValue; return attached; } if (i.* + 1 >= argv.len) return error.MissingValue; i.* += 1; return argv[i.*]; } /// `run` takes `check`'s two flags plus `--web-dev`, which only a process that /// serves has any use for; `check` deliberately rejects it. fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs { var args: RunArgs = .{}; var i: usize = 0; while (i < argv.len) : (i += 1) { const flag = splitFlag(argv[i]) orelse return error.TooManyArguments; if (eql(flag.name, "data-dir")) { args.paths.data_dir = try flagValue(flag, argv, &i); } else if (eql(flag.name, "config")) { args.paths.config = try flagValue(flag, argv, &i); } else if (eql(flag.name, "web-dev")) { args.web_dev = try flagValue(flag, argv, &i); } else return error.UnknownFlag; } return args; } fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs { var args: CheckArgs = .{}; var i: usize = 0; while (i < argv.len) : (i += 1) { const flag = splitFlag(argv[i]) orelse return error.TooManyArguments; if (eql(flag.name, "data-dir")) { args.paths.data_dir = try flagValue(flag, argv, &i); } else if (eql(flag.name, "config")) { args.paths.config = try flagValue(flag, argv, &i); args.config_explicit = true; } else return error.UnknownFlag; } return args; } fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs { var args: ExportArgs = .{}; var i: usize = 0; while (i < argv.len) : (i += 1) { const flag = splitFlag(argv[i]) orelse return error.TooManyArguments; if (eql(flag.name, "data-dir")) { args.paths.data_dir = try flagValue(flag, argv, &i); } else if (eql(flag.name, "out")) { args.out = try flagValue(flag, argv, &i); } else return error.UnknownFlag; } return args; } fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs { var paths: Paths = .{}; var force = false; var file: ?[]const u8 = null; var i: usize = 0; while (i < argv.len) : (i += 1) { const flag = splitFlag(argv[i]) orelse { if (file != null) return error.TooManyArguments; file = argv[i]; continue; }; if (eql(flag.name, "data-dir")) { paths.data_dir = try flagValue(flag, argv, &i); } else if (eql(flag.name, "force")) { // A boolean flag takes no value, so `--force=1` is not a spelling of // any flag this program has. if (flag.attached != null) return error.UnknownFlag; force = true; } else return error.UnknownFlag; } return .{ .paths = paths, .file = file orelse return error.MissingArgument, .force = force, }; } fn eql(a: []const u8, b: []const u8) bool { return std.mem.eql(u8, a, b); } // --------------------------------------------------------------------------- // path assembly // --------------------------------------------------------------------------- pub const DataDir = struct { dir: std.Io.Dir, config_db_path: [:0]const u8, querylog_db_path: [:0]const u8, /// Creates `data_dir` and its parents at mode 0700 when `create` is set, /// then opens it. Plain `createDirPath` hardcodes 0777 (Dir.zig:843) and /// must not be used here: this directory holds `config.db`, which holds /// `web.password_hash`. pub fn open(io: std.Io, gpa: Allocator, data_dir: []const u8, create: bool) !DataDir { if (create) { _ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700)); } // `.iterate`: the disk monitor sizes the databases by scanning this // directory, and an fd opened without it cannot be read as a directory. var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{ .iterate = true }); errdefer dir.close(io); // SQLite opens by path, not by directory handle, so both paths are // joined and NUL-terminated here rather than resolved through `dir`. const config_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, config_db_name }); errdefer gpa.free(config_db_path); const querylog_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, querylog_db_name }); return .{ .dir = dir, .config_db_path = config_db_path, .querylog_db_path = querylog_db_path, }; } pub fn close(self: *DataDir, io: std.Io, gpa: Allocator) void { self.dir.close(io); gpa.free(self.config_db_path); gpa.free(self.querylog_db_path); } /// The order of these three steps is the specification, not a style: /// /// 1. `db.Db.open` creates `config.db`, at `0644 & ~umask` — SQLite's /// choice, not ours. /// 2. chmod 0600. /// 3. `applyPragmas` turns on WAL, which is what creates `config.db-wal` /// and `config.db-shm`. SQLite copies the main database file's /// permissions onto both sidecars, so setting 0600 first gets them for /// free. The other order leaves them world-readable, and the WAL of a /// database holding `web.password_hash` is as sensitive as the database. pub fn openConfigDb(self: *const DataDir, io: std.Io) !db.Db { var database = try db.Db.open(self.config_db_path, .{}); errdefer database.close(); try self.dir.setFilePermissions(io, config_db_name, .fromMode(0o600), .{}); try db.applyPragmas(&database, .{}); return database; } /// The first connection to `querylog.db`: `querylog_schema.open` creates the /// file when it is missing and recreates it when it is unusable, so this is /// the call that establishes the schema. `reopenQuerylogDb` is for the /// connections that follow. /// /// The 0600 chmod cannot come first the way `openConfigDb` does it — the /// file may not exist yet, and a recreate replaces it — so the main file and /// both WAL sidecars are locked down afterwards instead. A query log holds /// every domain every client asked for, which is as sensitive as anything in /// `config.db`. /// /// `querylog_schema.open` resolves the path through SQLite's VFS as well as /// through the directory handle, so it is given `cwd` and the joined path /// rather than `self.dir` and a name (see its doc comment). pub fn openQuerylogDb(self: *const DataDir, io: std.Io) !db.Db { const opened = try querylog_schema.open(io, std.Io.Dir.cwd(), self.querylog_db_path); var database = opened.database; errdefer database.close(); try self.restrictQuerylogPermissions(io); return database; } /// An additional connection to a `querylog.db` that `openQuerylogDb` has /// already established. Phase 7 needs two — the log writer and the retention /// pass each own one (`retention.zig`'s contract). pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db { _ = io; var database = try db.Db.open(self.querylog_db_path, .{ .mode = .read_write_existing }); errdefer database.close(); try db.applyPragmas(&database, .{}); return database; } /// A sidecar that does not exist yet is not a failure: `-wal` and `-shm` /// appear when SQLite first writes, and the next call catches them. fn restrictQuerylogPermissions(self: *const DataDir, io: std.Io) !void { for ([_][]const u8{ querylog_db_name, querylog_db_name ++ "-wal", querylog_db_name ++ "-shm", }) |entry| { self.dir.setFilePermissions(io, entry, .fromMode(0o600), .{}) catch |e| switch (e) { error.FileNotFound => {}, else => |other| return other, }; } } }; // --------------------------------------------------------------------------- // entry functions // --------------------------------------------------------------------------- pub const Runner = struct { io: std.Io, gpa: Allocator, out: *Writer, err: *Writer, }; const usage_text = \\usage: nxdns [options] \\ \\commands: \\ run serve DNS \\ check validate the configuration \\ export write the configuration to stdout, or to --out \\ import FILE replace the configuration with FILE \\ version print version information \\ help print this message \\ \\options: \\ --data-dir DIR data directory (default /var/lib/nxdns) \\ --config FILE configuration file (default /etc/nxdns/config.zon) \\ --out FILE write the export to FILE instead of stdout \\ --force let import replace a database that already has content \\ --web-dev DIR run only: serve the web interface from DIR instead of \\ the embedded assets \\ ; /// Returns nothing, so a writer failure here has nowhere to go. Every caller /// flushes afterwards and reports that failure instead. pub fn usage(w: *Writer) void { w.writeAll(usage_text) catch {}; } /// Both `main` and the tests end a command the same way: flush, then report. /// A flush that fails is a runtime failure even when the command succeeded — /// output the operator never received is not output. fn finish(r: Runner, code: u8) u8 { r.out.flush() catch return exit_runtime; r.err.flush() catch return exit_runtime; return code; } pub fn runUsageError(r: Runner, e: ParseError) u8 { r.err.print("{s}\n\n", .{parseErrorMessage(e)}) catch {}; usage(r.err); return finish(r, exit_usage); } fn parseErrorMessage(e: ParseError) []const u8 { return switch (e) { error.UnknownCommand => "unknown command", error.UnknownFlag => "unknown flag", error.MissingValue => "a flag was given without its value", error.MissingArgument => "a required argument is missing", error.TooManyArguments => "too many arguments", }; } pub fn runHelp(r: Runner) u8 { usage(r.out); return finish(r, exit_ok); } pub fn runVersion(r: Runner) u8 { r.out.print("nxdns {s} ({s})\nzig {s}\n", .{ version.string, version.git_commit, version.zig_version_string, }) catch return finish(r, exit_runtime); return finish(r, exit_ok); } /// Serves DNS until SIGINT or SIGTERM. The whole of it lives in `app.zig`, /// which is where the composition root belongs; this stays the entry point so /// that `main` dispatches every command the same way. pub fn runRun(r: Runner, args: RunArgs) u8 { return app.run(r, args); } pub fn runExport(r: Runner, args: ExportArgs) u8 { exportImpl(r, args) catch |e| { r.err.print("export failed: {s}\n", .{@errorName(e)}) catch {}; return finish(r, exit_runtime); }; return finish(r, exit_ok); } fn exportImpl(r: Runner, args: ExportArgs) !void { var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false); defer data.close(r.io, r.gpa); var database = try data.openConfigDb(r.io); defer database.close(); _ = try migrations.migrate(&database); const path = args.out orelse return config_export.writeToWriter(r.gpa, &database, r.out); // Relative to the working directory, not to the data directory: an operator // running `nxdns export --out backup.zon` means the shell's directory. // `writeToFile` is the atomic, 0600 path — the file carries // `web.password_hash`. try config_export.writeToFile(r.io, r.gpa, &database, std.Io.Dir.cwd(), path); try r.out.print("wrote {s}\n", .{path}); } pub fn runImport(r: Runner, args: ImportArgs) u8 { var diags: validate.Diagnostics = .init(r.gpa); defer diags.deinit(); importImpl(r, args, &diags) catch |e| { // Every problem, not just the first: an operator fixing a config file // should need one run to see the whole list. diags.writeAll(r.err) catch {}; r.err.print("import failed: {s}\n", .{@errorName(e)}) catch {}; return finish(r, failureExitCode(e, diags.failureCount())); }; return finish(r, exit_ok); } fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void { var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, true); defer data.close(r.io, r.gpa); var database = try data.openConfigDb(r.io); defer database.close(); _ = try migrations.migrate(&database); try import.importFile( r.io, r.gpa, &database, std.Io.Dir.cwd(), args.file, .{ .force = args.force }, diags, ); // A configuration that imports cleanly can still have recorded warnings — a // blocklist source in no group is the one that found this. `validate` // returns nothing for a warning, so printing diagnostics on the failure path // alone made `import` the command that read the finding and threw it away, // while `check` printed it from the same file. Only warnings can be here: // any recorded failure returned above. try diags.writeAll(r.out); try r.out.print("imported {s}\n", .{args.file}); } /// A configuration the operator can fix exits 2; everything else is a runtime /// failure. `validate` records a diagnostic for every error it returns and then /// returns the first one, so a recorded failure is the reliable discriminator; /// `config/faults.zig` classifies the errors that never reach the validator. /// That file is the only list — this function keeps none of its own, which is /// what stops `run`, `check` and `import` drifting apart again (D1). /// /// `error.DatabaseNotEmpty` is the one exception, and it is deliberate: it /// reports the state of the database rather than the content of a file, so it /// is not a configuration fault, and `import` alone decides it is exit 2. /// /// `error.OutOfMemory` is matched first, before anything else is consulted. /// Both recording paths — `validate` and import's per-line rendering of a ZON /// syntax error — add one problem at a time and can run out of memory partway, /// which leaves problems recorded for a run whose real outcome is a resource /// failure. A partial report is not a verdict on the configuration, so the /// runtime exit code wins. /// /// `failures` counts recorded failures, never warnings: a warning never changes /// an exit code (F-b). fn failureExitCode(e: anyerror, failures: usize) u8 { if (e == error.OutOfMemory) return exit_runtime; if (failures != 0) return exit_check; if (e == error.DatabaseNotEmpty) return exit_check; return if (faults.isConfigFault(e)) exit_check else exit_runtime; } // --------------------------------------------------------------------------- // check // --------------------------------------------------------------------------- /// An A query for example.com: id 0x1234, RD set, one question. The probe wants /// a name every resolver on earth answers, so a FAIL line means the upstream is /// unreachable rather than that the name is odd. const probe_query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ "\x07example\x03com\x00\x00\x01\x00\x01"; /// The point of `check` is that an operator sees every problem in one run, so /// this never returns early on a finding. `probe` is false in unit tests and /// true from `main`: the probe leaves the machine. pub fn runCheck(r: Runner, args: CheckArgs, probe: bool) u8 { const code = checkImpl(r, args, probe) catch |e| { r.err.print("check failed: {s}\n", .{@errorName(e)}) catch {}; return finish(r, exit_runtime); }; return finish(r, code); } fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 { var arena_state: std.heap.ArenaAllocator = .init(r.gpa); defer arena_state.deinit(); const arena = arena_state.allocator(); // Which source was used is printed in every branch, so the answer is never // ambiguous about what it checked. if (args.config_explicit) { try r.out.print("checking configuration file {s}\n", .{args.paths.config}); return checkFile(r, arena, args.paths.config, probe); } const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name }); if (try pathExists(r.io, config_db_path)) { try r.out.print("checking database {s}\n", .{config_db_path}); return checkDatabase(r, arena, config_db_path, probe); } if (try pathExists(r.io, args.paths.config)) { try r.out.print("checking configuration file {s}\n", .{args.paths.config}); return checkFile(r, arena, args.paths.config, probe); } try r.out.print("nothing to check: no {s} in {s} and no {s}\n", .{ config_db_name, args.paths.data_dir, args.paths.config, }); return exit_check; } /// `check` reads `config.db` and writes nothing to it (F-c): no create, no /// chmod, no `applyPragmas` — which is what would turn WAL on and leave /// `config.db-wal` and `config.db-shm` behind — and above all no `migrate`. A /// database behind this binary's schema is reported here; upgrading it is /// `nxdns run`'s job, and a command that claims to validate without writing may /// not commit a schema step. /// /// `.immutable` is the enforcement, not a convention: SQLite refuses every /// statement that would write, and the mode's `immutable=1` also stops the /// pager building a wal-index, so no `config.db-wal` and no `config.db-shm` /// appear beside the file. `.read_only` alone creates both and cannot delete /// them on close, which is how a "validate without writing" command left two /// files behind. fn checkDatabase(r: Runner, arena: Allocator, path: [:0]const u8, probe: bool) !u8 { var database = db.Db.open(path, .{ .mode = .{ .immutable = r.io } }) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.WalPending => return walPendingFailure(r, path), else => return unreadable(r, path, e), }; defer database.close(); const reading = try readDatabase(&database, arena); // `immutable=1` takes no lock — that is what keeps it from building a // wal-index and leaving two sidecars behind — so a writer was free to append // to the log or checkpoint into the main file for the whole of the read // above. Proved before one word of it is reported: a stale answer and a torn // read both look exactly like an ordinary finding, which is how this failure // stays invisible. database.verifyImmutable() catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, error.WalPending => return walPendingFailure(r, path), else => return unreadable(r, path, e), }; switch (reading) { .version_unreadable => |e| { try r.out.print("FAIL {s}: the schema version cannot be read ({s})\n", .{ path, @errorName(e) }); return exit_check; }, // Naming both numbers is the point: "at 1, expects 2" tells an operator // to run `nxdns run`, where a bare SQLite complaint about a missing // column tells them nothing. .version_mismatch => |stamped| { const fix = if (stamped < migrations.target_version) "`nxdns run` migrates it, `check` will not" else "it was written by a newer nxdns"; try r.out.print( "FAIL {s}: schema version {d}, this nxdns expects {d}; {s}\n", .{ path, stamped, migrations.target_version, fix }, ); return exit_check; }, .config_unreadable => { // The schema version is already known good, so whatever this is, // the SQLite message is the only thing that narrows it down. // `verifyImmutable` makes no SQLite call, so this is still the // message from the read. var buf: [256]u8 = undefined; try r.out.print("FAIL {s}: cannot be read ({s})\n", .{ path, database.lastError(&buf) }); return exit_check; }, .config => |cfg| return checkConfig(r, cfg, probe), } } /// Everything read out of `config.db`, held rather than reported, because a /// value from an unlocked read is only worth reporting once `verifyImmutable` /// has said the files it came from stood still. A wrong schema-version line is /// as misleading as a wrong setting. const DbReading = union(enum) { config: model.Config, version_unreadable: anyerror, version_mismatch: u32, config_unreadable, }; /// Reads only, so an immutable handle serves it. fn readDatabase(database: *db.Db, arena: Allocator) error{OutOfMemory}!DbReading { const stamped = migrations.readVersion(database) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return .{ .version_unreadable = e }, }; if (stamped != migrations.target_version) return .{ .version_mismatch = stamped }; const cfg = config_export.readConfig(database, arena) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => return .config_unreadable, }; return .{ .config = cfg }; } /// One wording for `error.WalPending`, whether the log was already there when /// the read opened or arrived while it ran: from the operator's side those are /// the same situation, a writer holding changes this read cannot see. /// /// `immutable=1` ignores the write-ahead log, so the newest committed settings /// would be invisible and `check` would quietly grade the older ones in the main /// file. The guard is deliberately conservative — a live writer, an interrupted /// process and a checkpointed log that was simply kept all look the same from /// outside — so the line says what to do and does not claim anything is damaged. fn walPendingFailure(r: Runner, path: []const u8) !u8 { try r.out.print( "FAIL {s}: uncheckpointed changes are waiting in {s}{s}, and reading without writing would answer from the older settings in the main file; `nxdns run` applies them. A running nxdns normally holds this log, which is the usual reason to see this line.\n", .{ path, path, db.wal_suffix }, ); return exit_check; } fn unreadable(r: Runner, path: []const u8, e: anyerror) !u8 { try r.out.print("FAIL {s}: cannot be opened for reading ({s})\n", .{ path, @errorName(e) }); return exit_check; } fn pathExists(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool { std.Io.Dir.cwd().access(io, path, .{}) catch |e| switch (e) { error.FileNotFound => return false, else => |other| return other, }; return true; } /// `AccessOptions.read` is the `R_OK` bit of `faccessat` (`Io/Threaded.zig` /// `dirAccessPosix`); the default `.{}` sends mode 0, which is `F_OK` and tests /// existence only. A file that exists but denies this user a read is exactly the /// case `check` has to catch, so it needs the bit set. fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool { std.Io.Dir.cwd().access(io, path, .{ .read = true }) catch |e| switch (e) { error.FileNotFound, error.AccessDenied, error.PermissionDenied => return false, else => |other| return other, }; return true; } fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 { const source = std.Io.Dir.cwd().readFileAllocOptions( r.io, path, arena, .limited(import.max_config_bytes), .of(u8), 0, ) catch |e| switch (e) { error.StreamTooLong => { try r.out.print("FAIL {s}: larger than {d} bytes\n", .{ path, import.max_config_bytes }); return exit_check; }, // D4: a named file that is missing or unreadable is the same // operator-fixable condition as one that fails to parse, so it is // reported as a finding rather than escaping as a runtime failure. The // implicit path already exits 2 when it finds nothing to check; naming // the file must not change the code. error.FileNotFound => { try r.out.print("FAIL {s}: no such file\n", .{path}); return exit_check; }, error.AccessDenied, error.PermissionDenied => { try r.out.print("FAIL {s}: not readable\n", .{path}); return exit_check; }, else => |other| return other, }; // Arena-owned and never handed to `std.zon.parse.free`; see the rule and its // `parse.zig:874` citation in `config/import.zig`. var zon_diag: std.zon.parse.Diagnostics = .{}; const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, // The rendering carries the line and column, which is the whole value of // running `check` against a file the operator just edited. error.ParseZon => { try r.out.print("FAIL {s}: {f}\n", .{ path, &zon_diag }); return exit_check; }, }; return checkConfig(r, cfg, probe); } /// What one part of `check` found. Failures set exit 2; warnings are printed, /// counted for the summary, and never change an exit code (F-b) — the service /// starts either way. const Tally = struct { failures: usize = 0, warnings: usize = 0, fn plus(self: Tally, other: Tally) Tally { return .{ .failures = self.failures + other.failures, .warnings = self.warnings + other.warnings, }; } }; /// The half of `check` that has a `Config` already: validate, report every /// diagnostic, then the certificate and upstream checks. With TLS disabled and /// `probe` false it touches neither the filesystem nor the network, which is /// what makes it unit-testable. /// /// The summary never contradicts the lines above it (D2): a run that printed /// WARN lines says so instead of claiming no problems were found, and still /// exits 0. pub fn checkConfig(r: Runner, cfg: model.Config, probe: bool) !u8 { var diags: validate.Diagnostics = .init(r.gpa); defer diags.deinit(); // The returned error is the first recorded failure — one of the lines about // to be printed — so it carries nothing the report does not. Only an // allocation failure means the report itself is incomplete. validate.validate(cfg, &diags) catch |e| switch (e) { error.OutOfMemory => return error.OutOfMemory, else => {}, }; // `writeAll` prints the "FAIL "/"WARN " prefix itself; the lines below add // their own because they are not diagnostics. try diags.writeAll(r.out); var tally: Tally = .{ .failures = diags.failureCount(), .warnings = diags.warningCount() }; tally = tally.plus(try checkCertificates(r, cfg)); if (probe) tally.failures += try probeUpstreams(r, cfg); if (tally.failures != 0) return exit_check; if (tally.warnings != 0) { try r.out.print("OK: no failures found, {d} warning{s}\n", .{ tally.warnings, if (tally.warnings == 1) "" else "s", }); return exit_ok; } try r.out.writeAll("OK: no problems found\n"); return exit_ok; } fn checkCertificates(r: Runner, cfg: model.Config) !Tally { const doh = try checkTlsFiles(r, cfg.doh_server, "doh_server"); const dot = try checkTlsFiles(r, cfg.dot_server, "dot_server"); return doh.plus(dot); } /// Proves the pair rather than the paths (D3). `CertStore.init` is the load the /// listeners boot with: it reads both PEM files and builds a /// `tls_server.ServerContext`, which is where Mbed TLS parses the chain, parses /// the key and checks that the key belongs to the leaf. Testing readability /// alone let `check` exit 0 on a certificate and key that do not pair, seconds /// before `run` exited 2 on `BadCertificate`. /// /// No listener is bound and nothing is published: the context is built and /// freed. `alpn` is null because ALPN is negotiated per connection and plays no /// part in loading a pair; every other input is the server's. /// /// A key readable by anyone beyond its owner stays a warning (PLAN §19) and is /// reported even when the pair itself fails, because it is a separate finding /// about a file that exists. fn checkTlsFiles(r: Runner, endpoint: model.TlsEndpoint, comptime section: []const u8) !Tally { if (!endpoint.enabled) return .{}; var tally: Tally = .{}; if (try pathReadable(r.io, endpoint.key_path)) { const stat = try std.Io.Dir.cwd().statFile(r.io, endpoint.key_path, .{}); const mode = stat.permissions.toMode() & 0o777; if (mode & 0o077 != 0) { try r.out.print( "WARN " ++ section ++ ".key_path: '{s}' is mode {o}; a TLS key must be readable by its owner only\n", .{ endpoint.key_path, mode }, ); tally.warnings += 1; } } var store = cert_store.CertStore.init( r.gpa, r.io, endpoint.cert_path, endpoint.key_path, null, ) catch |e| { const at: struct { field: []const u8, path: []const u8 } = switch (e) { error.OutOfMemory => return error.OutOfMemory, // Not a verdict on the configuration: the platform's entropy source // failed, which is the same failure `run` would hit. error.EntropyFailed => return error.EntropyFailed, error.CertUnreadable, error.CertTooLarge, error.CertParse, // Mbed TLS rejected the server configuration built from this pair, // so the certificate is what the operator has to look at. error.ConfigFailed, => .{ .field = "cert_path", .path = endpoint.cert_path }, error.KeyUnreadable, error.KeyTooLarge, error.KeyParse, error.KeyMismatch, => .{ .field = "key_path", .path = endpoint.key_path }, }; try r.out.print("FAIL " ++ section ++ ".{s}: '{s}': {s}\n", .{ at.field, at.path, cert_store.humanMessage(e), }); tally.failures += 1; return tally; }; store.deinit(r.io); return tally; } /// One `Pool` per upstream, never one pool over all of them. The pool's job is /// failover: a shared pool would report success as soon as any upstream /// answered, and a broken upstream would stay invisible — the exact thing /// `check` exists to surface. Driving the real pool rather than the client /// directly keeps the deadline, cancellation and health machinery identical to /// what the server will do. fn probeUpstreams(r: Runner, cfg: model.Config) !usize { var failures: usize = 0; var http: std.http.Client = .{ .allocator = r.gpa, .io = r.io }; defer http.deinit(); // Shared across every DoT endpoint: the scan is expensive and the trust // store does not vary per upstream. var bundle: Certificate.Bundle = .empty; defer bundle.deinit(r.gpa); var bundle_lock: std.Io.RwLock = .init; const chunk = tls.Client.min_buffer_len; const tls_buffers = try r.gpa.alloc(u8, 4 * chunk); defer r.gpa.free(tls_buffers); var request_buf: [1024]u8 = undefined; var transfer_buf: [4096]u8 = undefined; const response_buf = try r.gpa.alloc(u8, transport.max_message_len); defer r.gpa.free(response_buf); const attempt_timeout: std.Io.Clock.Duration = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake, }; // The pool jitters backoff from this; one probe per upstream never reaches // backoff, so the value only has to be a value. const seed: u64 = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(r.io).nanoseconds))); // Every line below names the upstream by its index into the configuration // as well as by its redacted url, and the index is the config index rather // than a count of the upstreams probed — a disabled entry still occupies // one. The url alone no longer identifies an entry: `safe_url.redact` drops // the path, and two upstreams on one host commonly differ only there (a // NextDNS profile is `https://dns.nextdns.io/`). `upstreams[N]` is // the same name `config/validate.zig` gives the entry, so a FAIL line here // and a FAIL line from the validator point at the same place. for (cfg.upstreams, 0..) |server, i| { if (!server.enabled) continue; const endpoint = transport.Endpoint.parse(server.url) catch { try r.out.print("FAIL upstreams[{d}] {f}: not an https:// or tls:// endpoint\n", .{ i, safe_url.redactQuoted(server.url) }); failures += 1; continue; }; // Both clients are pinned for the pool's lifetime: `Entry.client` is an // erased pointer into one of them. var doh: doh_client.DohClient = undefined; var dot: dot_client.DotClient = undefined; const client: transport.Client = switch (endpoint.scheme) { .doh => doh: { doh = doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf) catch { try r.out.print("FAIL upstreams[{d}] {f}: not a usable DoH url\n", .{ i, safe_url.redactQuoted(server.url) }); failures += 1; continue; }; break :doh doh.client(); }, .dot => dot: { dot = dot_client.DotClient.init(endpoint, server.tls_name, r.gpa, &bundle, &bundle_lock, .{ .tls_read = tls_buffers[0..chunk], .tls_write = tls_buffers[chunk .. 2 * chunk], .stream_read = tls_buffers[2 * chunk .. 3 * chunk], .stream_write = tls_buffers[3 * chunk ..], }); break :dot dot.client(); }, }; var entries = [_]pool.Entry{.{ .endpoint = endpoint, .client = client, .priority = server.priority, .enabled = true, .health = .init, }}; var single: pool.Pool = .init(&entries, .{}, attempt_timeout, seed); if (single.exchange(r.io, probe_query, response_buf)) |_| { try r.out.print("OK upstreams[{d}] {f}\n", .{ i, safe_url.redact(server.url) }); } else |_| { // The concrete cause lives in the entry's health, which is where the // pool put it; `@errorName` of the pool's return value would only // repeat the last attempt's classification. var snapshots: [1]pool.Snapshot = undefined; const taken = try single.snapshot(r.io, &snapshots); const detail = if (taken == 1) snapshots[0].last_error else "no detail recorded"; try r.out.print("FAIL upstreams[{d}] {f}: {s}\n", .{ i, safe_url.redactQuoted(server.url), detail }); failures += 1; } } return failures; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; test "parseArgs accepts run with no flags" { const command = try parseArgs(&.{"run"}); try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir); try testing.expectEqualStrings("/etc/nxdns/config.zon", command.run.paths.config); try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev); } test "parseArgs accepts run with --data-dir and --config" { const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" }); try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir); try testing.expectEqualStrings("/tmp/c.zon", command.run.paths.config); } test "parseArgs accepts run with --web-dev in both spellings" { const attached = try parseArgs(&.{ "run", "--web-dev=web/dist" }); try testing.expectEqualStrings("web/dist", attached.run.web_dev.?); const separate = try parseArgs(&.{ "run", "--web-dev", "web/dist" }); try testing.expectEqualStrings("web/dist", separate.run.web_dev.?); } test "parseArgs rejects --web-dev without a value and outside run" { try testing.expectError(error.MissingValue, parseArgs(&.{ "run", "--web-dev" })); try testing.expectError(error.MissingValue, parseArgs(&.{ "run", "--web-dev=" })); try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--web-dev", "web/dist" })); try testing.expectError(error.UnknownFlag, parseArgs(&.{ "export", "--web-dev", "web/dist" })); } test "parseArgs accepts --data-dir with and without an equals sign" { const attached = try parseArgs(&.{ "check", "--data-dir=/srv/nx" }); try testing.expectEqualStrings("/srv/nx", attached.check.paths.data_dir); const separate = try parseArgs(&.{ "check", "--data-dir", "/srv/nx" }); try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir); } test "parseArgs records whether check was given an explicit --config" { const implicit = try parseArgs(&.{"check"}); try testing.expect(!implicit.check.config_explicit); const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" }); try testing.expect(explicit.check.config_explicit); try testing.expectEqualStrings("/tmp/c.zon", explicit.check.paths.config); } test "parseArgs accepts export with --out" { const command = try parseArgs(&.{ "export", "--out", "backup.zon" }); try testing.expectEqualStrings("backup.zon", command.export_.out.?); const bare = try parseArgs(&.{"export"}); try testing.expectEqual(@as(?[]const u8, null), bare.export_.out); } test "parseArgs accepts import with a file, --force and --data-dir" { const command = try parseArgs(&.{ "import", "c.zon", "--force", "--data-dir=/srv/nx" }); try testing.expectEqualStrings("c.zon", command.import_.file); try testing.expect(command.import_.force); try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir); } test "parseArgs accepts import with the file after the flags" { const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" }); try testing.expectEqualStrings("c.zon", command.import_.file); try testing.expect(!command.import_.force); } test "parseArgs accepts version" { try testing.expectEqual(Command.version, try parseArgs(&.{"version"})); } test "parseArgs accepts help, --help and -h" { try testing.expectEqual(Command.help, try parseArgs(&.{"help"})); try testing.expectEqual(Command.help, try parseArgs(&.{"--help"})); try testing.expectEqual(Command.help, try parseArgs(&.{"-h"})); } test "parseArgs rejects import without a file" { try testing.expectError(error.MissingArgument, parseArgs(&.{"import"})); try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" })); } test "parseArgs rejects --out without a value" { try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out" })); try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out=" })); } test "parseArgs rejects an unknown flag" { try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" })); try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force=1" })); } test "parseArgs rejects an unknown command" { try testing.expectError(error.UnknownCommand, parseArgs(&.{"frobnicate"})); } test "parseArgs rejects an empty argument list" { try testing.expectError(error.UnknownCommand, parseArgs(&.{})); } test "parseArgs rejects an extra positional argument" { try testing.expectError(error.TooManyArguments, parseArgs(&.{ "export", "extra" })); try testing.expectError(error.TooManyArguments, parseArgs(&.{ "version", "extra" })); try testing.expectError(error.TooManyArguments, parseArgs(&.{ "import", "a.zon", "b.zon" })); try testing.expectError(error.TooManyArguments, parseArgs(&.{ "help", "extra" })); try testing.expectError(error.TooManyArguments, parseArgs(&.{ "--help", "extra" })); try testing.expectError(error.TooManyArguments, parseArgs(&.{ "-h", "extra" })); } test "usage writes non-empty text" { var out: Writer.Allocating = .init(testing.allocator); defer out.deinit(); usage(&out.writer); try testing.expect(out.written().len > 0); try testing.expect(std.mem.startsWith(u8, out.written(), "usage: nxdns")); } const Captured = struct { threaded: std.Io.Threaded, out: Writer.Allocating, err: Writer.Allocating, fn init(gpa: Allocator) Captured { return .{ .threaded = .init(gpa, .{}), .out = .init(gpa), .err = .init(gpa), }; } fn deinit(self: *Captured) void { self.out.deinit(); self.err.deinit(); self.threaded.deinit(); } fn runner(self: *Captured) Runner { return .{ .io = self.threaded.io(), .gpa = testing.allocator, .out = &self.out.writer, .err = &self.err.writer, }; } }; fn countLines(text: []const u8) usize { return std.mem.count(u8, text, "\n"); } test "checkConfig returns 0 for a clean configuration" { var captured: Captured = .init(testing.allocator); defer captured.deinit(); const cfg: model.Config = .{ .groups = &.{.{ .name = "default" }}, .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, }; try testing.expectEqual(exit_ok, try checkConfig(captured.runner(), cfg, false)); try testing.expectEqualStrings("OK: no problems found\n", captured.out.written()); } test "checkConfig returns 2 and prints one line per problem" { var captured: Captured = .init(testing.allocator); defer captured.deinit(); // Three problems: no group named 'default', no enabled upstream, and a // zero port. const cfg: model.Config = .{ .dns = .{ .port = 0 } }; try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false)); const text = captured.out.written(); try testing.expectEqual(@as(usize, 3), countLines(text)); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "dns.port:")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "groups:")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "upstreams:")); } test "checkConfig reports every problem rather than stopping at the first" { var captured: Captured = .init(testing.allocator); defer captured.deinit(); const cfg: model.Config = .{ .groups = &.{.{ .name = "default" }}, .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, .clients = &.{ .{ .ip = "not-an-ip" }, .{ .ip = "also-not-an-ip" }, .{ .ip = "192.168.1.1", .group = "missing" }, }, }; try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false)); try testing.expectEqual(@as(usize, 3), countLines(captured.out.written())); } test "runVersion prints the milestone-1 version lines and exits 0" { var captured: Captured = .init(testing.allocator); defer captured.deinit(); try testing.expectEqual(exit_ok, runVersion(captured.runner())); try testing.expect(std.mem.startsWith(u8, captured.out.written(), "nxdns ")); try testing.expectEqual(@as(usize, 2), countLines(captured.out.written())); } // `runRun` now binds sockets and serves until a signal arrives, so it has no // unit test: booting it is `src/server/phase7_integration_test.zig`'s case 11. test "runUsageError names the fault and prints the usage text" { var captured: Captured = .init(testing.allocator); defer captured.deinit(); try testing.expectEqual(exit_usage, runUsageError(captured.runner(), error.UnknownFlag)); const text = captured.err.written(); try testing.expect(std.mem.startsWith(u8, text, "unknown flag\n")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "usage: nxdns")); } test "failureExitCode separates a fixable configuration from a runtime failure" { try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0)); try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0)); try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1)); try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0)); try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 0)); // A partial report from an allocation failure is a runtime failure, not a // finding about the configuration. try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 3)); } test "an allocation failure while recording diagnostics exits 1, not 2" { // The recording path for real: `validate` adds one problem per fault, so an // allocator that fails partway leaves problems in the list AND returns // `error.OutOfMemory`. How many allocations one problem costs is // `Diagnostics.add`'s business, so the failure point is swept rather than // guessed. const cfg: model.Config = .{ .dns = .{ .port = 0 } }; var saw_partial_report = false; var fail_index: usize = 0; while (fail_index < 32) : (fail_index += 1) { var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = fail_index }); var diags: validate.Diagnostics = .init(failing.allocator()); defer diags.deinit(); validate.validate(cfg, &diags) catch |e| { if (e != error.OutOfMemory) continue; const problems = diags.problems.items.len; if (problems == 0) continue; saw_partial_report = true; try testing.expectEqual(exit_runtime, failureExitCode(e, problems)); }; } try testing.expect(saw_partial_report); } test "failureExitCode keeps no list of its own and classifies through config/faults.zig" { // D1: every one of these reached `import` from a rejected seed file and was // classified as a runtime failure by the private list this function used to // carry, while `check` called the same file a configuration fault. try testing.expectEqual(exit_check, failureExitCode(error.MissingDefaultGroup, 0)); try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 0)); try testing.expectEqual(exit_check, failureExitCode(error.BadUpstreamUrl, 0)); try testing.expectEqual(exit_check, failureExitCode(error.NoUsableUpstreams, 0)); try testing.expectEqual(exit_check, failureExitCode(error.BadCertificate, 0)); // Every member of the shared classification, so a variant added to // `ValidateError` cannot exit 1 from `import` while exiting 2 from `run`. inline for (@typeInfo(validate.ValidateError).error_set.?) |member| { const err = @field(anyerror, member.name); const expected: u8 = if (faults.isConfigFault(err)) exit_check else exit_runtime; try testing.expectEqual(expected, failureExitCode(err, 0)); } // The one config-shaped exit 2 `cli` still decides for itself: it reports // the state of the database, not the content of a file. try testing.expect(!faults.isConfigFault(error.DatabaseNotEmpty)); try testing.expectEqual(exit_check, failureExitCode(error.DatabaseNotEmpty, 0)); } const fixtures = @import("test_fixtures"); /// A tmp directory holding the fixture PEM pair and a data directory, addressed /// by cwd-relative paths the same way an operator's configuration names them. /// Must not move after `init`: the slices point into the buffers. const CheckEnv = struct { tmp: testing.TmpDir, cert_path_buf: [160]u8, key_path_buf: [160]u8, data_dir_buf: [160]u8, missing_path_buf: [160]u8, cert_path: []const u8, key_path: []const u8, data_dir: []const u8, /// A path inside the tmp directory that is never created. missing_path: []const u8, fn init(env: *CheckEnv) !void { env.tmp = testing.tmpDir(.{}); errdefer env.tmp.cleanup(); env.cert_path = try env.path(&env.cert_path_buf, "cert.pem"); env.key_path = try env.path(&env.key_path_buf, "key.pem"); env.data_dir = try env.path(&env.data_dir_buf, "data"); env.missing_path = try env.path(&env.missing_path_buf, "no-such-config.zon"); } fn deinit(env: *CheckEnv) void { env.tmp.cleanup(); } fn path(env: *const CheckEnv, buf: []u8, name: []const u8) ![]const u8 { return std.fmt.bufPrint(buf, ".zig-cache/tmp/{s}/{s}", .{ env.tmp.sub_path, name }); } /// The key lands at 0600, so only a test that asks for the permission /// warning gets one. fn writePair(env: *CheckEnv, io: std.Io, key_pem: []const u8) !void { try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem }); try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = key_pem }); try env.tmp.dir.setFilePermissions(io, "key.pem", .fromMode(0o600), .{}); } /// Valid but for whatever the test broke about the TLS pair. fn tlsConfig(env: *const CheckEnv) model.Config { return .{ .groups = &.{.{ .name = "default" }}, .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, .doh_server = .{ .enabled = true, .cert_path = env.cert_path, .key_path = env.key_path, }, }; } fn expectAbsent(env: *CheckEnv, io: std.Io, name: []const u8) !void { env.tmp.dir.access(io, name, .{}) catch |e| switch (e) { error.FileNotFound => return, else => |other| return other, }; std.debug.print("expected '{s}' not to exist\n", .{name}); return error.TestUnexpectedResult; } }; test "check fails a certificate and a key that do not pair" { // D3: readability alone passed this configuration, and `run` then exited 2 // on `BadCertificate` seconds later. var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); try env.writePair(r.io, fixtures.mismatched_key_pem); try testing.expectEqual(exit_check, try checkConfig(r, env.tlsConfig(), false)); const text = captured.out.written(); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL doh_server.key_path")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "does not belong to the certificate")); try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK:")); } test "check fails a certificate file that does not parse" { var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); try env.writePair(r.io, fixtures.key_pem); try env.tmp.dir.writeFile(r.io, .{ .sub_path = "cert.pem", .data = "not a certificate\n" }); try testing.expectEqual(exit_check, try checkConfig(r, env.tlsConfig(), false)); const text = captured.out.written(); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL doh_server.cert_path")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "could not be parsed")); } test "check passes a certificate and a key that pair" { var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); try env.writePair(r.io, fixtures.key_pem); try testing.expectEqual(exit_ok, try checkConfig(r, env.tlsConfig(), false)); try testing.expectEqualStrings("OK: no problems found\n", captured.out.written()); } test "a check whose only findings are warnings exits 0 and says so" { // D2: the summary used to read "OK: no problems found" directly under the // WARN line it was contradicting. var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); try env.writePair(r.io, fixtures.key_pem); try env.tmp.dir.setFilePermissions(r.io, "key.pem", .fromMode(0o644), .{}); // A warning never changes an exit code: the service still starts. try testing.expectEqual(exit_ok, try checkConfig(r, env.tlsConfig(), false)); const text = captured.out.written(); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "WARN doh_server.key_path")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "OK: no failures found, 1 warning\n")); try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "no problems found")); } test "check --config naming a missing file is a reported failure at exit 2" { // D4: this escaped `checkImpl` as `check failed: FileNotFound` at exit 1, // while the implicit path exits 2 for the same operator-fixable condition. var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); const code = runCheck(r, .{ .paths = .{ .config = env.missing_path }, .config_explicit = true, }, false); try testing.expectEqual(exit_check, code); const text = captured.out.written(); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no such file")); try testing.expectEqualStrings("", captured.err.written()); } test "check reads config.db without writing to it" { // D6: the database branch opened read/write, chmod'ed 0600, turned WAL on — // which is what creates the two sidecars — and committed migration steps, // from a command whose contract is that it validates without writing. var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); { var data = try DataDir.open(r.io, r.gpa, env.data_dir, true); defer data.close(r.io, r.gpa); var database = try data.openConfigDb(r.io); defer database.close(); _ = try migrations.migrate(&database); } // A mode `openConfigDb` would overwrite, so its chmod cannot hide. try env.tmp.dir.setFilePermissions(r.io, "data/config.db", .fromMode(0o644), .{}); const before = try env.tmp.dir.statFile(r.io, "data/config.db", .{}); _ = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false); try testing.expect(std.mem.containsAtLeast(u8, captured.out.written(), 1, "checking database")); // Byte for byte the database the writer left, at the mode the writer left: // no migration step committed, no chmod, no `PRAGMA journal_mode`. const after = try env.tmp.dir.statFile(r.io, "data/config.db", .{}); try testing.expectEqual(before.size, after.size); try testing.expectEqual(before.mtime.nanoseconds, after.mtime.nanoseconds); try testing.expectEqual( @as(@TypeOf(after.permissions.toMode()), 0o644), after.permissions.toMode() & 0o777, ); // Nothing beside it either. A `.read_only` open recreates the wal-index of // a database whose header says WAL and cannot delete it on close, which // left `config.db-wal` and `config.db-shm` behind; `.immutable` builds no // wal-index at all. try env.expectAbsent(r.io, "data/config.db-wal"); try env.expectAbsent(r.io, "data/config.db-shm"); } test "check refuses a database whose write-ahead log still holds changes" { // `immutable=1` ignores the log, so grading the main file would silently // report settings the operator already replaced. var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); { var data = try DataDir.open(r.io, r.gpa, env.data_dir, true); defer data.close(r.io, r.gpa); var database = try data.openConfigDb(r.io); defer database.close(); _ = try migrations.migrate(&database); } // Bytes are all the guard reads, and it never gets as far as opening this // as a log: `db.Db.open` refuses on the size alone. try env.tmp.dir.writeFile(r.io, .{ .sub_path = "data/config.db" ++ db.wal_suffix, .data = "uncheckpointed frames", }); const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false); try testing.expectEqual(exit_check, code); const text = captured.out.written(); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "uncheckpointed changes")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "config.db" ++ db.wal_suffix)); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns run")); // Not a claim about damage: an operator reading this must not reach for a // recovery tool. try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "corrupt")); } test "check reports a database behind the schema rather than migrating it" { var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); // Zero bytes is a valid, empty SQLite database: schema version 0, which is // exactly what an upgrade leaves behind when a step has not run yet. _ = try std.Io.Dir.cwd().createDirPathStatus(r.io, env.data_dir, .fromMode(0o700)); try env.tmp.dir.writeFile(r.io, .{ .sub_path = "data/config.db", .data = "" }); const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false); try testing.expectEqual(exit_check, code); const text = captured.out.written(); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns run")); // Both real numbers, not a generic SQLite complaint: an empty database is // at version 0, and the expected one comes from the migration list rather // than a literal, so adding a step cannot make this line lie. var expected_buf: [96]u8 = undefined; const expected = try std.fmt.bufPrint( &expected_buf, "schema version 0, this nxdns expects {d};", .{migrations.target_version}, ); try testing.expect(std.mem.containsAtLeast(u8, text, 1, expected)); // Not upgraded: the schema `migrate` would have committed is still absent. const after = try env.tmp.dir.statFile(r.io, "data/config.db", .{}); try testing.expectEqual(@as(u64, 0), after.size); try env.expectAbsent(r.io, "data/config.db-wal"); try env.expectAbsent(r.io, "data/config.db-shm"); } test "the upstream probe redacts a url it cannot parse, without leaving the machine" { // No socket is opened on this branch: `Endpoint.parse` rejects the `@` // before the loop builds a client, so the leak is reachable in a required // test rather than only behind a live probe. // // It is also the only probe branch a credential can reach. `Endpoint.parse` // refuses `@`, `?` and `#` in the authority and `?`/`#` in the path, so a // url carrying userinfo or a query never gets as far as `DohClient.init` // and its "not a usable DoH url" line. That line's redaction is defensive. // // The two upstreams are the NextDNS shape, where the profile id in the path // is the account credential and is the only thing telling two entries // apart. Both halves of the contract are asserted at once: neither the // userinfo nor the profile id may reach stdout, and what is left has to // still say which of the two entries the operator must go and fix. The // disabled entry ahead of them is there because the index has to be the // index into `upstreams`, not a count of the entries probed. var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); const cfg: model.Config = .{ .groups = &.{.{ .name = "default" }}, .upstreams = &.{ .{ .url = "https://dns.example/dns-query", .enabled = false }, .{ .url = "https://lists:hunter2@dns.nextdns.io/abcd12" }, .{ .url = "https://lists:hunter2@dns.nextdns.io/efgh34" }, }, }; try testing.expectEqual(@as(usize, 2), try probeUpstreams(r, cfg)); const text = captured.out.written(); try testing.expectEqualStrings( "FAIL upstreams[1] 'https://dns.nextdns.io': not an https:// or tls:// endpoint\n" ++ "FAIL upstreams[2] 'https://dns.nextdns.io': not an https:// or tls:// endpoint\n", text, ); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "hunter2")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "abcd12")); try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "efgh34")); } test "a successful import prints the warnings the file earned" { // D5, second half. `check` printed this WARN and `import` recorded it and // threw it away, because diagnostics were written on the failure path only. // The operator who imported the file learned nothing about the source they // had just added, which blocks nothing until a group links it. var env: CheckEnv = undefined; try env.init(); defer env.deinit(); var captured: Captured = .init(testing.allocator); defer captured.deinit(); const r = captured.runner(); try env.tmp.dir.writeFile(r.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 file_buf: [160]u8 = undefined; const file = try env.path(&file_buf, "config.zon"); const code = runImport(r, .{ .paths = .{ .data_dir = env.data_dir }, .file = file }); // A warning never changes an exit code, and the import really happened. try testing.expectEqual(exit_ok, code); const text = captured.out.written(); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "WARN blocklist_sources[0]: ")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "belongs to no group")); try testing.expect(std.mem.containsAtLeast(u8, text, 1, "imported ")); try testing.expectEqualStrings("", captured.err.written()); } // `runExport` needs a real data directory and the upstream probe leaves the // machine. Those cases are S7's: // `src/storage/storage_integration_test.zig` cases 20-22.