milestone 13 discrepancies: redact credentials from urls in logs, metrics and cli output

This commit is contained in:
2026-08-07 00:45:17 +02:00
parent 1ff727feb8
commit 8c3328562e
39 changed files with 5734 additions and 510 deletions
+657 -66
View File
@@ -20,9 +20,11 @@ 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");
@@ -30,6 +32,7 @@ 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;
@@ -436,7 +439,7 @@ pub fn runImport(r: Runner, args: ImportArgs) u8 {
// 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.problems.items.len));
return finish(r, failureExitCode(e, diags.failureCount()));
};
return finish(r, exit_ok);
}
@@ -458,32 +461,42 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
.{ .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 non-empty diagnostics list is the reliable
/// discriminator; the named errors below are the config faults that never reach
/// the validator.
/// 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.OutOfMemory` is matched first, before the list 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.
fn failureExitCode(e: anyerror, problems: usize) u8 {
/// `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 (problems != 0) return exit_check;
return switch (e) {
error.DatabaseNotEmpty,
error.ConfigTooLarge,
error.ParseZon,
error.PasswordAndHashBothSet,
=> exit_check,
else => 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;
}
// ---------------------------------------------------------------------------
@@ -520,21 +533,10 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
return checkFile(r, arena, args.paths.config, probe);
}
const config_db_path = try std.fs.path.join(arena, &.{ args.paths.data_dir, config_db_name });
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});
var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false);
defer data.close(r.io, r.gpa);
// A `check` on a database one schema version behind must still work,
// which is what an operator runs right after an upgrade.
var database = try data.openConfigDb(r.io);
defer database.close();
_ = try migrations.migrate(&database);
const cfg = try config_export.readConfig(&database, arena);
return checkConfig(r, cfg, probe);
return checkDatabase(r, arena, config_db_path, probe);
}
if (try pathExists(r.io, args.paths.config)) {
@@ -550,6 +552,121 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
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,
@@ -583,6 +700,19 @@ fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
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,
};
@@ -602,48 +732,83 @@ fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
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 `problems[0].err` — 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.
// 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 failures = diags.problems.items.len;
failures += try checkCertificates(r, cfg);
if (probe) failures += try probeUpstreams(r, cfg);
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 (failures != 0) return exit_check;
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) !usize {
return try checkTlsFiles(r, cfg.doh_server, "doh_server") +
try checkTlsFiles(r, cfg.dot_server, "dot_server");
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);
}
/// An unreadable certificate or key fails the run; a key readable by anyone
/// beyond its owner is a warning (PLAN §19) and leaves the exit code alone,
/// because the service still starts.
fn checkTlsFiles(r: Runner, endpoint: model.TlsEndpoint, comptime section: []const u8) !usize {
if (!endpoint.enabled) return 0;
var failures: usize = 0;
if (!try pathReadable(r.io, endpoint.cert_path)) {
try r.out.print("FAIL " ++ section ++ ".cert_path: '{s}' is not readable\n", .{endpoint.cert_path});
failures += 1;
}
/// 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, .{});
@@ -653,13 +818,46 @@ fn checkTlsFiles(r: Runner, endpoint: model.TlsEndpoint, comptime section: []con
"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;
}
} else {
try r.out.print("FAIL " ++ section ++ ".key_path: '{s}' is not readable\n", .{endpoint.key_path});
failures += 1;
}
return failures;
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
@@ -697,11 +895,19 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
// 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)));
for (cfg.upstreams) |server| {
// 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/<profile>`). `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 {s}: not an https:// or tls:// endpoint\n", .{server.url});
try r.out.print("FAIL upstreams[{d}] {f}: not an https:// or tls:// endpoint\n", .{ i, safe_url.redactQuoted(server.url) });
failures += 1;
continue;
};
@@ -713,7 +919,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
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 {s}: not a usable DoH url\n", .{server.url});
try r.out.print("FAIL upstreams[{d}] {f}: not a usable DoH url\n", .{ i, safe_url.redactQuoted(server.url) });
failures += 1;
continue;
};
@@ -740,7 +946,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
var single: pool.Pool = .init(&entries, .{}, attempt_timeout, seed);
if (single.exchange(r.io, probe_query, response_buf)) |_| {
try r.out.print("OK {s}\n", .{server.url});
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
@@ -748,7 +954,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
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 {s}: {s}\n", .{ server.url, detail });
try r.out.print("FAIL upstreams[{d}] {f}: {s}\n", .{ i, safe_url.redactQuoted(server.url), detail });
failures += 1;
}
}
@@ -1019,6 +1225,391 @@ test "an allocation failure while recording diagnostics exits 1, not 2" {
try testing.expect(saw_partial_report);
}
// `runCheck` against real paths, `runExport` and `runImport` all need 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.
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.