1616 lines
66 KiB
Zig
1616 lines
66 KiB
Zig
//! 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 <dir>`: 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 <command> [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/<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 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.
|