milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
+443 -70
View File
@@ -32,7 +32,6 @@ const tls = std.crypto.tls;
const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const bootstrap = @import("config/bootstrap.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const clients = @import("server/clients.zig");
@@ -49,6 +48,7 @@ const fetcher = @import("filter/fetcher.zig");
const forward_zones = @import("local/forward_zones.zig");
const handler = @import("server/handler.zig");
const http_util = @import("web/http_util.zig");
const loader = @import("config/loader.zig");
const local_records = @import("local/records.zig");
const local_tables = @import("server/local_tables.zig");
const logger_mod = @import("storage/logger.zig");
@@ -60,6 +60,7 @@ const pause = @import("server/pause.zig");
const pool_mod = @import("upstream/pool.zig");
const query_sink = @import("server/query_sink.zig");
const rate_limiter = @import("server/rate_limiter.zig");
const reconcile = @import("config/reconcile.zig");
const retention_mod = @import("storage/retention.zig");
const safe_url = @import("safe_url.zig");
const shutdown = @import("server/shutdown.zig");
@@ -95,6 +96,12 @@ pub fn run(runner: cli.Runner, args: cli.RunArgs) u8 {
const mapped = failureExitCode(err);
if (mapped == cli.exit_check) {
runner.err.writeAll("run `nxdns check` to see the configuration in full\n") catch {};
// Ruling 6: after bootstrap seeding died, a fresh database fails
// validation naturally (`NoUsableUpstreams`) and the operator needs
// to be told how a database gets a configuration at all. In file
// mode they already have a file, and the diagnostics above name what
// is wrong with it.
if (args.config == null) cli.writeDbSourceHint(runner.err);
}
break :code mapped;
};
@@ -113,70 +120,201 @@ fn failureExitCode(err: anyerror) u8 {
return if (faults.isConfigFault(err)) cli.exit_check else cli.exit_runtime;
}
/// First run only: the file seeds an empty database and is ignored forever
/// after. Its diagnostics are the operator's one chance to see what the file
/// said, so they are printed the way `check` and `import` print them.
/// File authority (ruling 2): read the file the operator named, validate it, and
/// converge the database onto it — on this start and on every start after it.
/// Returns the wall clock the reconcile ran at, which becomes the settings
/// envelope's `reconciled_at`.
///
/// Printed on the way out either way. A seed file can be accepted and still
/// carry warnings — a blocklist source in no group is downloaded and compiled
/// into nothing — and a warning that only appears when the start fails is a
/// warning nobody ever reads: the start it describes is the one that worked.
/// `check` reported it and `run` did not, which left the same file graded two
/// ways.
/// A missing, unreadable or invalid file fails the start. There is no fallback
/// to the database under any failure: a fallback turns a deploy typo into a
/// silently stale configuration, which is the failure mode the whole mode exists
/// to prevent.
///
/// The runner's error writer, not `std.log`: this runs before
/// `logging.install`, and one rendering of a diagnostic across `run`, `check`
/// and `import` is the point of `Diagnostics.writeAll`.
/// Diagnostics are printed on the way out either way. A file can be accepted and
/// still carry warnings — a blocklist source in no group is downloaded and
/// compiled into nothing — and a warning that only appears when the start fails
/// is a warning nobody ever reads: the start it describes is the one that
/// worked.
///
/// Flushed here rather than left to `run`'s exit flush. That writer is buffered
/// (`main` gives it 4 KiB) and `serve` does not return for as long as the
/// service runs, so a line left in the buffer reaches the operator when the
/// The runner's writers, not `std.log`: this runs before `logging.install`, and
/// one rendering of a diagnostic across `run`, `check` and `import` is the point
/// of `Diagnostics.writeAll`.
///
/// Flushed here rather than left to `run`'s exit flush. Both writers are
/// buffered (`main` gives them 4 KiB) and `serve` does not return for as long as
/// the service runs, so a line left in the buffer reaches the operator when the
/// process stops — days after the start it describes. A failure path flushes
/// anyway because it returns immediately; the successful start is the one that
/// needs this.
fn seedFromFile(
fn reconcileFromFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
) bootstrap.Error!bootstrap.Outcome {
) !i64 {
return reconcileFromFileAt(
r,
config_db,
dir,
config_path,
std.Io.Clock.real.now(r.io).toSeconds(),
);
}
/// `pass_now` is what the engine stamps into the runtime columns of the rows it
/// inserts (`first_seen`, `last_seen`, `created_at`), and it is deliberately not
/// the value this returns.
///
/// The two clocks answer different questions, and conflating them was a real
/// defect: `reconciled_at` means "this process loaded the file at T" and is
/// compared against the file's mtime to detect a restart-pending state
/// (ruling 7). A stamp taken *before* the read makes a file written during the
/// read look newer than the process that loaded it — a false "restart pending"
/// in the UI for a file that is fully applied. So this returns a clock read
/// taken immediately after the commit, and `pass_now` never leaves the engine.
///
/// Split from `reconcileFromFile` so the two are separable in a test: pin
/// `pass_now` and the returned stamp must still be the real clock.
fn reconcileFromFileAt(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
pass_now: i64,
) !i64 {
var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
defer arena_state.deinit();
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
const result = bootstrap.bootstrap(r.io, r.gpa, config_db, dir, config_path, &diags);
const result = applyManagedFile(r, config_db, dir, config_path, arena_state.allocator(), &diags, pass_now);
// Neither discard is an oversight, and the two answer different questions.
//
// On a rejected seed file, `result` is returned untouched: the operator gets
// the reason the start failed, never a writer error standing in front of it.
// A broken stderr is not why the configuration was refused.
//
// On a seed that worked, a failure here does not stop the start. The trade
// is one lost warning line against a household with no name resolution, and
// `run` before this point is the only stretch of this program where an
// output failure could take DNS down at all — ruling 4 already says nothing
// after it is fatal. Nor could the failure be reported: this writer *is* the
// error channel, and `logging.install` has not run yet, so `std.log` resolves
// to the same stderr a diagnostic about it would have to travel down.
//
// It is not lost from the process either. A failed drain consumes nothing,
// so whatever the buffer held it still holds — that half is observed, in
// "a broken error writer does not stop a first start that succeeded" below,
// which reads the retained warning back out of the same writer.
//
// What happens to those bytes afterwards is derived, not watched, and is
// labelled so deliberately. `Io.Writer.defaultFlush` drains while `end != 0`
// and `run`'s exit flush maps a failure to exit 1, so a stderr still broken
// at shutdown should carry the condition out in the exit code, and one that
// recovered should deliver the line late. No test drives `run` that far.
// Two limits come with the derivation: an empty buffer flushes clean and
// reports nothing at all, and a failure that recovers ends at exit 0 with a
// line the operator reads days after the start it describes.
// Both discards are deliberate, and they answer different questions. On a
// rejected file the operator gets the reason the start failed, never a
// writer error standing in front of it — a broken stderr is not why the
// configuration was refused. On a file that applied, a failure here does not
// stop the start: the trade is one lost warning line against a household
// with no name resolution, and this writer *is* the error channel, so the
// failure has nowhere to be reported anyway.
diags.writeAll(r.err) catch {};
r.err.flush() catch {};
return result;
}
/// Returns the moment the transaction committed, which is what the settings
/// envelope reports as `reconciled_at`.
fn applyManagedFile(
r: cli.Runner,
config_db: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
arena: Allocator,
diags: *validate.Diagnostics,
now: i64,
) !i64 {
const cfg = try loader.load(r.io, arena, dir, config_path, diags);
try validate.validate(cfg, diags);
// The keys this pass wrote, never their values (ruling 8). Duplicated into
// `gpa` by the engine, so this frame frees them.
var changed: std.ArrayList([]const u8) = .empty;
defer {
for (changed.items) |key| r.gpa.free(key);
changed.deinit(r.gpa);
}
var pass = reconcile.begin(r.io, r.gpa, config_db, cfg, now, .{
.changed_settings = &changed,
}) catch |err| {
// `begin` has already rolled its own transaction back. What the operator
// needs is the cause: a full SD card must read as "disk", not as a bare
// exit 1, so the SQLite condition is named.
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
errdefer pass.rollback();
pass.commit() catch |err| {
reportReconcileFailure(r, config_db, config_path, err);
return err;
};
// Read here and nowhere earlier: the file is loaded once this line runs, and
// not one statement before it.
const reconciled_at = std.Io.Clock.real.now(r.io).toSeconds();
printSummary(r, config_path, pass.summary, changed.items) catch {};
return reconciled_at;
}
/// SQLite conditions an operator acts on differently. `@errorName` alone would
/// say `Full`, which is not a word anyone can search for; the primary result
/// code's own name is.
fn sqliteCodeName(err: anyerror) ?[]const u8 {
return switch (err) {
error.Full => "SQLITE_FULL",
error.Busy => "SQLITE_BUSY",
error.IoErr => "SQLITE_IOERR",
error.ReadOnly => "SQLITE_READONLY",
error.Corrupt => "SQLITE_CORRUPT",
error.Constraint => "SQLITE_CONSTRAINT",
else => null,
};
}
fn reportReconcileFailure(r: cli.Runner, config_db: *db.Db, config_path: []const u8, err: anyerror) void {
var buf: [256]u8 = undefined;
const detail = config_db.lastError(&buf);
const code = sqliteCodeName(err) orelse @errorName(err);
r.err.print("reconciling '{s}' failed: {s}: {s}\n", .{ config_path, code, detail }) catch {};
r.err.flush() catch {};
}
/// What that restart changed, without opening sqlite (ruling 8): per-table
/// counts, the settings keys that moved — never their values — and an
/// authentication change, which is never a silent line item in a count.
fn printSummary(
r: cli.Runner,
config_path: []const u8,
summary: reconcile.Summary,
changed_settings: []const []const u8,
) !void {
try r.out.print("reconciled '{s}':", .{config_path});
if (summary.isNoOp()) {
try r.out.writeAll(" no changes\n");
} else {
inline for (@typeInfo(reconcile.Summary).@"struct".fields) |field| {
if (field.type == reconcile.TableCounts) {
const counts = @field(summary, field.name);
if (counts.total() != 0) {
try r.out.print(" {s} +{d} ~{d} -{d};", .{
field.name,
counts.inserted,
counts.updated,
counts.deleted,
});
}
}
}
try r.out.writeAll("\n");
if (changed_settings.len != 0) {
try r.out.writeAll("settings keys changed:");
for (changed_settings) |key| try r.out.print(" {s}", .{key});
try r.out.writeAll("\n");
}
switch (summary.auth_transition) {
.none => {},
.enabled => try r.out.writeAll("web authentication is now enabled\n"),
.disabled => try r.out.writeAll("web authentication is now disabled\n"),
.rotated => try r.out.writeAll("the web password changed\n"),
}
}
try r.out.flush();
}
fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
const io = r.io;
const gpa = r.gpa;
@@ -193,7 +331,15 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer config_db.close();
_ = try migrations.migrate(&config_db);
_ = try seedFromFile(r, &config_db, std.Io.Dir.cwd(), paths.config);
// Ruling 1: the presence of `--config` is the whole authority decision. With
// it, the file is the sole declarative source and the database is converged
// onto it here, before anything reads the database. Without it the database
// is authority and this step does not exist — a file on disk that no flag
// names changes nothing.
const reconciled_at: ?i64 = if (args.config) |config_path|
try reconcileFromFile(r, &config_db, std.Io.Dir.cwd(), config_path)
else
null;
// Every string in `cfg` points into this arena, and the pool's endpoints,
// the handler's records and the monitor's paths all keep such strings. It
@@ -203,6 +349,17 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
defer arena_state.deinit();
const arena = arena_state.allocator();
// The web layer reads the managed path out of `WebState` for the whole life
// of the process, so it takes a copy from the arena that outlives it rather
// than borrowing `argv`.
const authority: web_server.Authority = if (args.config) |config_path|
.{ .managed_file = try arena.dupe(u8, config_path) }
else
.database;
// The database stays the runtime substrate and the effective-config read
// path in both modes: in file mode the reconcile above has just made it
// agree with the file.
const cfg = try config_export.readConfig(&config_db, arena);
// From here on `std.log` goes wherever the operator asked. Before this call
@@ -472,7 +629,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
if (cfg.web.enabled) web_state = .{
.gpa = gpa,
.web = cfg.web,
.live_hash = .init(cfg.web.password_hash),
.authority = authority,
.reconciled_at = reconciled_at,
.live_hash = .init(cfg.web.password_hash orelse ""),
.handler = &h,
.pause = &paused,
.tracker = &tracker,
@@ -602,7 +761,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
// this box exists for — keeps serving.
if (cfg.web.enabled) try group.concurrent(io, web_server.serve, .{ &web_state, io });
logStartup(io, &manager, upstreams.active().len, .{
logStartup(io, authority, &manager, upstreams.active().len, .{
.udp6 = if (udp6) |*s| s.boundAddress() else null,
.udp4 = if (udp4) |*s| s.boundAddress() else null,
.tcp6 = if (tcp6) |*s| s.boundAddress() else null,
@@ -1005,8 +1164,8 @@ test "run, check and import agree on a seed file with no default group" {
\\}
;
// `run`: `serve` seeds through `bootstrap`, which is a wrapper over this
// exact call, so this is the error `run` classifies.
// `run --config`: `serve` validates through this exact call before it
// reconciles, so this is the error `run` classifies.
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
@@ -1082,11 +1241,11 @@ test "a configuration whose blocklist source is in no group imports and checks c
try std.testing.expectEqual(@as(usize, 1), check_diags.warningCount());
}
test "a first start that seeds from a file prints the warnings the file earned" {
// D5, second half. The seed file is read once in the life of a database, so
// a warning it earns is printed on that start or never. `run` printed
// diagnostics only when the file was rejected, which made a successful first
// start the one place the finding could not surface.
test "a start in file mode prints the warnings the file earned" {
// D5, second half. `run` printed diagnostics only when the file was
// rejected, which made a successful start the one place a finding could not
// surface. Under file authority the file is read on every start, so this is
// the line an operator sees after every restart, not only the first.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -1119,11 +1278,10 @@ test "a first start that seeds from a file prints the warnings the file earned"
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
// The file is valid, so the start succeeds and the database is seeded.
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
// The file is valid, so the start succeeds and the database converges onto
// it. The returned stamp is what the settings envelope reports.
const reconciled_at = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expect(reconciled_at > 0);
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing flushes here on purpose. In production `serve` runs from this
@@ -1138,6 +1296,164 @@ test "a first start that seeds from a file prints the warnings the file earned"
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "FAIL"));
}
test "run with a missing managed file exits 2 with the path, and never serves from the database" {
// Ruling 2: file mode fails closed. The database below is a perfectly good
// one — migrated, and the run would have reached the listeners on it in db
// mode — so a fallback would show up here as exit 0.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
var data_buf: [160]u8 = undefined;
const data_dir = try std.fmt.bufPrint(&data_buf, ".zig-cache/tmp/{s}/data", .{tmp.sub_path});
var missing_buf: [160]u8 = undefined;
const missing = try std.fmt.bufPrint(&missing_buf, ".zig-cache/tmp/{s}/nope.zon", .{tmp.sub_path});
// Real buffered `File.Writer`s, not `Writer.fixed`: this asserts the
// operator received the lines, and a fixed writer's flush is a no-op that
// counts a buffered line as delivered.
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var err_file = try tmp.dir.createFile(io, "stderr.txt", .{});
defer err_file.close(io);
var out_buf: [4096]u8 = undefined;
var err_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_writer = err_file.writer(io, &err_buf);
const r: cli.Runner = .{
.io = io,
.gpa = gpa,
.out = &out_writer.interface,
.err = &err_writer.interface,
};
try std.testing.expectEqual(cli.exit_check, run(r, .{
.paths = .{ .data_dir = data_dir },
.config = missing,
}));
const printed = try tmp.dir.readFileAlloc(io, "stderr.txt", gpa, .limited(8192));
defer gpa.free(printed);
// The path is in the message: an error name alone tells the operator nothing
// about which file the deploy got wrong.
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, missing));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no such file"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "nxdns check"));
// The db-mode remediation hint belongs to db mode: in file mode the operator
// has a file, and the diagnostic above says what is wrong with it.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "make a file the source of truth"));
}
test "reconciled_at is stamped after the commit, not from the clock the pass wrote with" {
// Ruling 7: `reconciled_at` means "this process loaded the file at T", and
// the UI compares it against the file's mtime to say whether a restart is
// pending. A stamp taken before the read makes a file written while the read
// ran look newer than the process that loaded it — a restart-pending banner
// over a configuration that is fully applied.
//
// The pass clock is pinned to 1970 here, which the engine really does use:
// the inserted client below carries it. If the two were one value, the
// returned stamp would be 1970 too.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .clients = .{ .{ .ip = "192.168.1.5", .name = "tablet" } },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_buf: [4096]u8 = undefined;
var err_buf: [1024]u8 = undefined;
var out: Writer = .fixed(&out_buf);
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err };
const pass_now: i64 = 42;
const before = std.Io.Clock.real.now(io).toSeconds();
const reconciled_at = try reconcileFromFileAt(r, &database, tmp.dir, "config.zon", pass_now);
// The pinned clock reached the engine, so the two values really are separate
// inputs rather than the same read twice.
try std.testing.expectEqual(pass_now, try database.queryInt(
"SELECT first_seen FROM clients WHERE ip = '192.168.1.5'",
));
try std.testing.expect(reconciled_at != pass_now);
try std.testing.expect(reconciled_at >= before);
}
test "the startup summary reports what the reconcile changed, then that nothing changed" {
// Ruling 8: the answer to "what did that restart change" without opening
// sqlite. Also ruling 5 from the operator's side — the second start of an
// unchanged file says so.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = std.testing.allocator;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
\\}
});
var database = try db.Db.open(":memory:", .{ .mode = .memory });
defer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
var out_file = try tmp.dir.createFile(io, "stdout.txt", .{});
defer out_file.close(io);
var out_buf: [4096]u8 = undefined;
var out_writer = out_file.writer(io, &out_buf);
var err_buf: [1024]u8 = undefined;
var err: Writer = .fixed(&err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out_writer.interface, .err = &err };
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
// Read back through the file: `serve` does not return for as long as the
// service runs, so a summary still in the buffer is a summary nobody
// reads.
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "reconciled 'config.zon':"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "upstreams +1 ~0 -0"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "settings keys changed:"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "web.password_hash"));
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "authentication is now enabled"));
// The keys, never the values: the hash the file set must not be echoed.
try std.testing.expectEqual(@as(usize, 0), std.mem.count(u8, printed, "$argon2id$"));
}
try tmp.dir.writeFile(io, .{ .sub_path = "stdout.txt", .data = "" });
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
{
const printed = try tmp.dir.readFileAlloc(io, "stdout.txt", gpa, .limited(8192));
defer gpa.free(printed);
try std.testing.expect(std.mem.containsAtLeast(u8, printed, 1, "no changes"));
}
}
/// A broken stderr, in the shape `main` builds: a buffered `File.Writer`, with
/// its drain switched to the mode that fails. `Writer.fixed` cannot stand in —
/// its flush is `noopFlush`, so it has no failure to report and its `written()`
@@ -1151,8 +1467,8 @@ fn brokenErrWriter(io: std.Io, file: std.Io.File, buffer: []u8) std.Io.File.Writ
return w;
}
test "a broken error writer does not replace the reason a seed file was rejected" {
// The operator has to see why seeding failed, and a broken stderr is not
test "a broken error writer does not replace the reason a managed file was rejected" {
// The operator has to see why the start failed, and a broken stderr is not
// that reason.
var threaded: std.Io.Threaded = .init(std.testing.allocator, .{});
defer threaded.deinit();
@@ -1187,7 +1503,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectError(
error.MissingDefaultGroup,
seedFromFile(r, &database, tmp.dir, "config.zon"),
reconcileFromFile(r, &database, tmp.dir, "config.zon"),
);
// Empty, so the writer did fail — without this the assertion above would
@@ -1197,7 +1513,7 @@ test "a broken error writer does not replace the reason a seed file was rejected
try std.testing.expectEqual(@as(usize, 0), printed.len);
}
test "a broken error writer does not stop a first start that succeeded" {
test "a broken error writer does not stop a start whose file applied" {
// The call this file makes: a DNS server for a household does not refuse to
// resolve because stderr is broken. What it must not do is drop the warning
// on the floor, so the second half checks the buffer still holds it.
@@ -1233,10 +1549,7 @@ test "a broken error writer does not stop a first start that succeeded" {
var err_writer = brokenErrWriter(io, err_file, &err_buf);
const r: cli.Runner = .{ .io = io, .gpa = gpa, .out = &out, .err = &err_writer.interface };
try std.testing.expectEqual(
bootstrap.Outcome.seeded,
try seedFromFile(r, &database, tmp.dir, "config.zon"),
);
_ = try reconcileFromFile(r, &database, tmp.dir, "config.zon");
try std.testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Nothing reached the file, so the flush really did fail.
@@ -1289,7 +1602,20 @@ fn isWildcard(addr: net.IpAddress) bool {
/// One line, at info, naming what an operator needs to see in `journalctl`
/// right after a restart: where it listens, how many upstreams it has, and
/// whether filtering is live.
fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize, bound: Listeners) void {
///
/// Preceded by the authority (ruling 8), because every other question about a
/// restart — why a UI edit vanished, why a file edit did not apply — starts with
/// which of the two governs this process, and the journal is where an operator
/// looks for it.
fn logStartup(
io: std.Io,
authority: web_server.Authority,
manager: *manager_mod.Manager,
upstream_count: usize,
bound: Listeners,
) void {
log.info("authority: {f}", .{AuthorityText{ .authority = authority }});
var buf: [256]u8 = undefined;
var w: Writer = .fixed(&buf);
appendBind(&w, "udp", bound.udp6);
@@ -1316,6 +1642,25 @@ fn logStartup(io: std.Io, manager: *manager_mod.Manager, upstream_count: usize,
}
}
/// Which source governs this process, in the words the journal carries.
///
/// A formatter rather than a rendering into a buffer of this file's own: a
/// managed path is bounded only by `Dir.max_path_bytes`, and nested bind mounts
/// make long ones ordinary, so a fixed buffer here would silently drop exactly
/// the half of the line an operator came for. Writing straight to the log sink's
/// writer leaves the one documented, counted truncation in `platform/logging.zig`
/// as the only limit.
const AuthorityText = struct {
authority: web_server.Authority,
pub fn format(self: AuthorityText, w: *Writer) Writer.Error!void {
switch (self.authority) {
.database => try w.writeAll("database"),
.managed_file => |path| try w.print("file ({s})", .{path}),
}
}
};
/// Silent on overflow: a truncated startup line is not worth a failure path,
/// and 256 bytes hold four addresses.
fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
@@ -1323,6 +1668,34 @@ fn appendBind(w: *Writer, which: []const u8, addr: ?net.IpAddress) void {
w.print(" {s} {f}", .{ which, value }) catch {};
}
test "the startup line names which source governs this process, path and all" {
// Ruling 8. Every other question about a restart starts here, so the answer
// is in the journal rather than derived from the unit file by whoever is
// reading at 2am.
const gpa = std.testing.allocator;
var short: Writer.Allocating = .init(gpa);
defer short.deinit();
try short.writer.print("{f}", .{AuthorityText{ .authority = .database }});
try std.testing.expectEqualStrings("database", short.written());
var named: Writer.Allocating = .init(gpa);
defer named.deinit();
try named.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = "/etc/nxdns/config.zon" } }});
try std.testing.expectEqualStrings("file (/etc/nxdns/config.zon)", named.written());
// A path past any buffer this file could reasonably have picked. Nested bind
// mounts produce paths like this, and the path is the half of the line the
// operator came for — dropping it to keep the line short is the wrong trade.
const long_path = "/mnt/" ++ ("deeply-nested-mount/" ** 20) ++ "config.zon";
try std.testing.expect(long_path.len > 256);
var long: Writer.Allocating = .init(gpa);
defer long.deinit();
try long.writer.print("{f}", .{AuthorityText{ .authority = .{ .managed_file = long_path } }});
try std.testing.expect(std.mem.containsAtLeast(u8, long.written(), 1, long_path));
}
const test_address = @import("platform/address.zig");
test "one maintenance pass drops the api limiter's stale buckets" {
+165 -106
View File
@@ -22,6 +22,7 @@ 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 loader = @import("config/loader.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const cert_store = @import("server/cert_store.zig");
@@ -50,19 +51,31 @@ pub const querylog_db_name = "querylog.db";
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 };
/// Milestone-20 ruling 1: `--config` has no default path, and its presence is
/// the whole of the authority decision. Null means the database is authority —
/// for `run` that is today's appliance behaviour, for `check` it is the database
/// that gets graded. A file sitting at a well-known path that no flag names
/// changes nothing.
pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, force: bool = false };
/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes
/// declarative rows (ruling 6).
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false };
/// `config` names the managed configuration file and, by being present at all,
/// makes that file the sole declarative source of truth: it is read, validated
/// and reconciled into the database on every start.
///
/// `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 RunArgs = struct {
paths: Paths = .{},
config: ?[]const u8 = null,
web_dev: ?[]const u8 = null,
};
pub const Command = union(enum) {
run: RunArgs,
@@ -176,7 +189,7 @@ fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
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 = try flagValue(flag, argv, &i);
} else if (eql(flag.name, "web-dev")) {
args.web_dev = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
@@ -192,8 +205,7 @@ fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
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;
args.config = try flagValue(flag, argv, &i);
} else return error.UnknownFlag;
}
return args;
@@ -215,7 +227,7 @@ fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
var paths: Paths = .{};
var force = false;
var allow_delete = false;
var file: ?[]const u8 = null;
var i: usize = 0;
@@ -227,18 +239,18 @@ fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
};
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.
} else if (eql(flag.name, "allow-delete")) {
// A boolean flag takes no value, so `--allow-delete=1` is not a
// spelling of any flag this program has.
if (flag.attached != null) return error.UnknownFlag;
force = true;
allow_delete = true;
} else return error.UnknownFlag;
}
return .{
.paths = paths,
.file = file orelse return error.MissingArgument,
.force = force,
.allow_delete = allow_delete,
};
}
@@ -379,14 +391,30 @@ const usage_text =
\\
\\options:
\\ --data-dir DIR data directory (default /var/lib/nxdns)
\\ --config FILE configuration file (default /etc/nxdns/config.zon)
\\ --config FILE run: make FILE the sole source of configuration and
\\ reconcile the database onto it at every start;
\\ check: grade FILE instead of the database
\\ --out FILE write the export to FILE instead of stdout
\\ --force let import replace a database that already has content
\\ --allow-delete let import apply a file whose diff deletes rows
\\ --web-dev DIR run only: serve the web interface from DIR instead of
\\ the embedded assets
\\
;
/// The one remediation line for a database that holds no usable configuration.
/// Both routes to that state print it: `run` refusing an unconfigured database
/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all.
///
/// It lives here, beside the exit-code mapping, because a Zig error carries no
/// text and `config/validate.zig` must stay blind to which source it is grading
/// — the same file validates a database reading and a managed file.
pub const db_source_hint =
"load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`\n";
pub fn writeDbSourceHint(w: *Writer) void {
w.writeAll(db_source_hint) catch {};
}
/// 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 {
@@ -493,7 +521,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
&database,
std.Io.Dir.cwd(),
args.file,
.{ .force = args.force },
.{ .allow_delete = args.allow_delete },
diags,
);
@@ -514,9 +542,10 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
/// 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.DestructiveImport` is the one exception, and it is deliberate: it
/// reports what the diff would do to 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
@@ -530,7 +559,7 @@ fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
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;
if (e == error.DestructiveImport) return exit_check;
return if (faults.isConfigFault(e)) exit_check else exit_runtime;
}
@@ -563,28 +592,31 @@ fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
// 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);
//
// Ruling 1: the invocation decides, and nothing else. The heuristic that
// used to live here — probe for `config.db`, fall back to probing
// `/etc/nxdns/config.zon`, grade whichever exists — made the answer a
// function of what happened to be on disk, which is the ambient inference
// that made seed-once bootstrap a source of documentation lies. A file no
// flag names is not graded.
if (args.config) |path| {
try r.out.print("checking configuration file {s}\n", .{path});
return checkFile(r, arena, path, 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, config_db_path)) {
// The deleted heuristic's "nothing to check" branch, replaced rather
// than dropped: an operator running `check` on a box that has never
// been configured gets the same exit code and one line saying what to
// do about it.
try r.out.print("no config database at {s}\n", .{config_db_path});
try r.out.writeAll(db_source_hint);
return exit_check;
}
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;
try r.out.print("checking database {s}\n", .{config_db_path});
return checkDatabase(r, arena, config_db_path, probe);
}
/// `check` reads `config.db` and writes nothing to it (F-c): no create, no
@@ -722,52 +754,31 @@ fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
return true;
}
/// Grades the file `--config` named, through `config/loader.zig` — the same
/// read and the same classification `nxdns run --config` uses. That shared
/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches
/// exactly the read, size and parse faults `run` would reach, and validation
/// below is the same call on the same `Config`.
///
/// 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.
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,
};
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
// 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) {
const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) 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. It is
// multi-line, and `check` promises one line per problem, so it goes
// through the same `Diagnostics` channel `nxdns import` uses rather than
// into one `FAIL` record with newlines inside it.
error.ParseZon => {
var diags: validate.Diagnostics = .init(r.gpa);
defer diags.deinit();
try import.reportParseFailure(&diags, &zon_diag);
error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => {
// One line per problem, the promise the rest of `check` keeps: a
// multi-line ZON rendering is several problems, not one `FAIL`
// record with newlines inside it.
try diags.writeAll(r.out);
return exit_check;
},
// A box fault — fd exhaustion, an I/O error — is not a verdict on the
// configuration and keeps its own name at exit 1.
else => |other| return other,
};
return checkConfig(r, cfg, probe);
@@ -1013,17 +1024,22 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const testing = std.testing;
test "parseArgs accepts run with no flags" {
test "run without --config selects database authority" {
// Ruling 1: authority is the invocation. No default path, so nothing on
// disk can make a bare `run` read a file.
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.config);
try testing.expectEqual(@as(?[]const u8, null), command.run.web_dev);
}
test "parseArgs accepts run with --data-dir and --config" {
test "run --config selects file authority and the path lands in the run args" {
const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" });
try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.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);
try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?);
}
test "parseArgs accepts run with --web-dev in both spellings" {
@@ -1049,13 +1065,12 @@ test "parseArgs accepts --data-dir with and without an equals sign" {
try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
}
test "parseArgs records whether check was given an explicit --config" {
test "bare check grades the database and check --config grades the file" {
const implicit = try parseArgs(&.{"check"});
try testing.expect(!implicit.check.config_explicit);
try testing.expectEqual(@as(?[]const u8, null), implicit.check.config);
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);
try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
}
test "parseArgs accepts export with --out" {
@@ -1066,17 +1081,21 @@ test "parseArgs accepts export with --out" {
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" });
test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
try testing.expectEqualStrings("c.zon", command.import_.file);
try testing.expect(command.import_.force);
try testing.expect(command.import_.allow_delete);
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);
try testing.expect(!command.import_.allow_delete);
}
test "the renamed import flag replaces --force rather than joining it" {
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" }));
}
test "parseArgs accepts version" {
@@ -1091,7 +1110,7 @@ test "parseArgs accepts help, --help and -h" {
test "parseArgs rejects import without a file" {
try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--force" }));
try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" }));
}
test "parseArgs rejects --out without a value" {
@@ -1101,7 +1120,7 @@ test "parseArgs rejects --out without a value" {
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" }));
try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" }));
}
test "parseArgs rejects an unknown command" {
@@ -1134,6 +1153,17 @@ test "usage_text lists every command in command_names" {
}
}
test "usage_text names the flags this milestone renamed and describes --config" {
// The flag an operator reaches for is the one the help text names. `--force`
// is gone rather than aliased (greenfield rules), and `--config` no longer
// advertises a default path, because there is none: its presence is the
// whole authority decision.
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, " --allow-delete "));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force"));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon"));
try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration"));
}
test "usage writes non-empty text" {
var out: Writer.Allocating = .init(testing.allocator);
defer out.deinit();
@@ -1246,7 +1276,7 @@ test "runUsageError names the fault and prints the usage text" {
}
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.DestructiveImport, 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));
@@ -1302,9 +1332,13 @@ test "failureExitCode keeps no list of its own and classifies through config/fau
}
// 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));
// what the diff would do to the database, not the content of a file.
try testing.expect(!faults.isConfigFault(error.DestructiveImport));
try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
// The managed file goes the other way: `config/loader.zig` converts the
// path class, so the classification — not this function — carries it.
try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0));
}
const fixtures = @import("test_fixtures");
@@ -1457,10 +1491,7 @@ test "check --config naming a missing file is a reported failure at exit 2" {
defer captured.deinit();
const r = captured.runner();
const code = runCheck(r, .{
.paths = .{ .config = env.missing_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = env.missing_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
@@ -1469,6 +1500,37 @@ test "check --config naming a missing file is a reported failure at exit 2" {
try testing.expectEqualStrings("", captured.err.written());
}
test "bare check with no config database exits 2 and says how to make one" {
// The deleted heuristic's "nothing to check" branch, replaced. The valid
// file sitting in the same directory is the other half of ruling 1: bare
// `check` grades the database, and a file no flag named is not consulted —
// if it were, this run would print "OK: no problems found" instead.
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" } },
\\}
});
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, "no config database at "));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK"));
try testing.expectEqualStrings("", captured.err.written());
}
test "check renders a multi-line ZON failure as one FAIL line per message" {
// The rendering used to go inline into a single `FAIL` record, which put
// newlines mid-line and broke the one-line-per-problem promise the rest of
@@ -1486,10 +1548,7 @@ test "check renders a multi-line ZON failure as one FAIL line per message" {
var path_buf: [160]u8 = undefined;
const config_path = try env.path(&path_buf, "config.zon");
const code = runCheck(r, .{
.paths = .{ .config = config_path },
.config_explicit = true,
}, false);
const code = runCheck(r, .{ .config = config_path }, false);
try testing.expectEqual(exit_check, code);
const text = captured.out.written();
-141
View File
@@ -1,141 +0,0 @@
//! First-start seeding (PLAN §3.5).
//!
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
//! one code path from a config file into the database, so bootstrap and
//! `nxdns import` cannot drift apart.
//!
//! The policy is three lines long:
//!
//! - no file → normal steady state, keep the database as it is;
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
//! Configured means an operator put something there. A database that has only
//! answered queries is not configured, however many client rows the DNS path
//! materialised into it, and `import.isEmpty` is where that line is drawn;
//! - otherwise → import it, and a file that is unreadable, unparseable or
//! invalid is an error. The operator wrote that file and meant it; starting
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
//! prevent.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../storage/db.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");
const log = std.log.scoped(.config_bootstrap);
pub const Outcome = enum { seeded, db_already_configured, no_config_file };
pub const Error = import.Error || std.Io.Dir.AccessError;
/// Called by `nxdns run` before serving.
pub fn bootstrap(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
diags: *validate.Diagnostics,
) Error!Outcome {
dir.access(io, config_path, .{}) catch |e| switch (e) {
error.FileNotFound => {
log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
return .no_config_file;
},
else => |other| return other,
};
// Deliberately before the read: on every start after the first, the file is
// not even opened.
if (!try import.isEmpty(database)) {
log.info("configuration file ignored; the database is already configured", .{});
return .db_already_configured;
}
try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
log.info("seeded the database from '{s}'", .{config_path});
return .seeded;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// All three outcomes are exercised end to end in
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
// What the two cases below add is the one distinction that decides which outcome
// an operator gets, and it is too important to leave behind a `-Dintegration`
// flag: whether the database has been *configured*, not whether it has been
// *used*.
const testing = std.testing;
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const migrations = @import("../storage/migrations.zig");
const seed_source =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// Unparseable on purpose: a call that succeeds proves the file was never read.
const broken_source = ".{ .groups = ";
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
test "a server that has answered queries still seeds from its configuration file" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });
var database = try openMigrated();
defer database.close();
// The unattended first boot: the server came up on defaults, answered
// traffic, and the operator dropped a config file in afterwards.
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Seeding did not cost the operator the device list they had been watching.
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
test "a client the operator has customised keeps the configuration file out" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });
var database = try openMigrated();
defer database.close();
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
// The name and the flag the operator set are still theirs.
try testing.expectEqual(@as(i64, 1), try database.queryInt(
"SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
));
}
+57 -10
View File
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
const header =
\\// nxdns configuration
\\// generated by `nxdns export` the database is the source of truth
\\// generated by `nxdns export` from the running configuration
\\
;
@@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
// `web.password` is operator input and is never stored; the exported file
// always carries an empty one. This is exactly what makes the round trip
// stable: re-importing takes the "password is empty" branch and stores the
// same hash.
cfg.web.password = "";
// `web.password` is operator input and is never stored, so the exported
// file always states it as absent. Absent rather than `""`: a present empty
// password is refused by `validate` (ruling 4), so exporting one would make
// every export fail its own rules. It is also what makes the round trip
// stable — re-applying the file takes the "password_hash written verbatim"
// branch and stores the same hash.
cfg.web.password = null;
return cfg;
}
@@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
try testing.expectEqual(a.dns.port, b.dns.port);
try testing.expectEqual(a.logging.level, b.logging.level);
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
try testing.expectEqual(a.groups.len, b.groups.len);
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
for (a.upstreams, b.upstreams) |left, right| {
@@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" {
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const exported = try readConfig(&database, arena_state.allocator());
try testing.expectEqualStrings("", exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
}
test "the exported password form is the one validate accepts" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
var apply_diags: validate.Diagnostics = .init(gpa);
defer apply_diags.deinit();
try import.apply(io, gpa, &database, .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
}, 42, .{}, &apply_diags);
var out: Writer.Allocating = .init(gpa);
defer out.deinit();
try writeToWriter(gpa, &database, &out.writer);
// The literal form matters: an export carrying `password = ""` beside a
// stored hash would trip `EmptyWebPassword` on the way back in, so export
// would produce a file its own validator refuses.
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
const source = try gpa.dupeZ(u8, out.written());
defer gpa.free(source);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const reparsed = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
source,
null,
.{},
);
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try validate.validate(reparsed, &diags);
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
}
+27 -10
View File
@@ -13,17 +13,26 @@ const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a
/// variant added to the validator cannot silently fall through to exit 1. The
/// four extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate
/// load (`BadCertificate`).
/// five extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
/// file the operator named and this process cannot open
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
/// upstream build (`NoUsableUpstreams`) and its certificate load
/// (`BadCertificate`).
///
/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of
/// the database rather than the content of a file, and is the one config-shaped
/// exit 2 `cli` decides for itself.
/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
/// configuration fault, and it is deliberately not `FileNotFound` itself: the
/// operator named that path on the command line, so it is theirs to fix, while a
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
/// the conversion and the closed set of open errors that qualify.
///
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
/// would do to the database rather than the content of a file, and is the one
/// config-shaped exit 2 `cli` decides for itself.
const ConfigFault = validate.ValidateError || error{
ParseZon,
ConfigTooLarge,
ManagedConfigUnreadable,
NoUsableUpstreams,
BadCertificate,
};
@@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" {
try testing.expect(isConfigFault(error.BadCertificate));
}
test "a managed file the operator named and this process cannot open is exit 2" {
// Ruling 2. The general rule below still holds — a bare `FileNotFound` is a
// runtime failure — and this is the one converted form, produced only by
// `config/loader.zig` for a path `--config` named.
try testing.expect(isConfigFault(error.ManagedConfigUnreadable));
try testing.expect(!isConfigFault(error.FileNotFound));
}
test "the seed-file errors that used to exit 1 from run are configuration faults" {
// D1 verbatim: these three reached `run` from a rejected seed file and were
// classified as runtime failures.
@@ -107,9 +124,9 @@ test "a runtime failure is not a configuration fault" {
try testing.expect(!isConfigFault(error.AccessDenied));
try testing.expect(!isConfigFault(error.FileNotFound));
try testing.expect(!isConfigFault(error.AddressInUse));
// A state conflict, not a bad file: `import` refuses to overwrite a
// configured database and decides that exit code itself.
try testing.expect(!isConfigFault(error.DatabaseNotEmpty));
// A verdict on the diff, not on the file: `import` refuses a run that would
// delete rows and decides that exit code itself.
try testing.expect(!isConfigFault(error.DestructiveImport));
// Only ever a warning, so it never reaches an exit code by this route.
try testing.expect(!isConfigFault(error.SourceInNoGroup));
}
+268 -675
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
//! The one way a configuration file becomes a `model.Config`.
//!
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
//! same file the same way, so the read, the error classification and the parse
//! live here rather than once per subcommand. `config/faults.zig` exists for the
//! same reason one layer up: two copies of a rule are two rules.
//!
//! **The classification.** `faults.isConfigFault` deliberately excludes
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
//! broken box, not a wrong configuration. The managed file is the one place
//! where the opposite holds: the operator named that path, so a path that does
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
//! step). Only the path class converts:
//!
//! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
//! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
//!
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
//! stop the service permanently on a fault that would have cleared itself.
//!
//! The mapping is a named error set switched exhaustively with
//! `else => |other| return other`, so an error a Zig upgrade adds to
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("model.zig");
const validate = @import("validate.zig");
/// The ceiling on a configuration file. A file above it is a configuration
/// fault, not a resource failure: nothing an operator writes by hand comes near
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
pub const max_config_bytes = 4 * 1024 * 1024;
/// Every error `readFileAllocOptions` can hand back, plus the parse.
pub const ReadError = std.Io.Dir.ReadFileAllocError;
/// The open failures that mean the operator's path is wrong rather than the box
/// being broken. Spelled out as a set rather than as switch prongs so that the
/// list is one thing a reader can find and a test can enumerate.
pub const PathFault = error{
FileNotFound,
AccessDenied,
PermissionDenied,
NotDir,
IsDir,
SymLinkLoop,
NameTooLong,
BadPathName,
};
pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };
/// The classification itself, pure and testable on its own: a path-class
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
/// `ConfigTooLarge`, and every other member travels unchanged.
pub fn mapReadError(e: ReadError) Error {
return switch (e) {
error.StreamTooLong => error.ConfigTooLarge,
error.FileNotFound,
error.AccessDenied,
error.PermissionDenied,
error.NotDir,
error.IsDir,
error.SymLinkLoop,
error.NameTooLong,
error.BadPathName,
=> error.ManagedConfigUnreadable,
else => |other| other,
};
}
/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
/// returned them: `nxdns import` reads an operator-supplied argument, not a
/// managed file, and its exit codes are its own.
pub fn readSource(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
) ReadError![:0]u8 {
return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
}
/// `readSource` under the managed-file classification, with the reason recorded
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
/// go and fix reaches them through `Diagnostics` — the same channel every other
/// configuration problem travels down, and the reason `check` and `run` print
/// these in one shape.
pub fn readManaged(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error![:0]u8 {
return readSource(io, gpa, dir, path) catch |e| {
switch (e) {
error.StreamTooLong => try diags.add(
error.ConfigTooLarge,
"{s}",
.{path},
"larger than {d} bytes",
.{max_config_bytes},
),
error.FileNotFound => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"no such file",
.{},
),
error.AccessDenied, error.PermissionDenied => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"not readable",
.{},
),
error.IsDir => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"is a directory, not a configuration file",
.{},
),
error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"cannot be opened ({s})",
.{@errorName(e)},
),
// A box fault. It exits 1 with its own name and records nothing: a
// diagnostic would file it under "the configuration is wrong".
else => {},
}
return mapReadError(e);
};
}
/// The line and column of a ZON syntax error are the only thing the operator can
/// act on, so they travel the same channel as every other config problem: the
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
/// global log is not that channel — an operator reading command output would see
/// a bare `ParseZon` and nothing else.
///
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order. Rendering them inline would
/// put newlines inside a single `FAIL` record.
pub fn reportParseFailure(
diags: *validate.Diagnostics,
zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void {
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
defer diags.gpa.free(rendered);
var lines = std.mem.splitScalar(u8, rendered, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
}
}
/// The parse, with its failure rendered. The result is arena-owned and
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
/// absent field by copying the struct's default straight through
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
/// non-empty string defaults — points into the binary's read-only data.
/// `parse.free` keeps no record of which fields were parsed and which were
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
/// only correct release.
pub fn parse(
arena: Allocator,
source: [:0]const u8,
diags: *validate.Diagnostics,
) error{ ParseZon, OutOfMemory }!model.Config {
var zon_diag: std.zon.parse.Diagnostics = .{};
return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.ParseZon => {
try reportParseFailure(diags, &zon_diag);
return error.ParseZon;
},
};
}
/// Read and parse the managed file, everything a caller needs before
/// `validate.validate`. Validation is deliberately left to the caller: `check`
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
/// both call the same validator on the same `Config`, which is what makes the
/// two agree.
///
/// `arena` owns both the source text and the returned configuration.
pub fn load(
io: std.Io,
arena: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error!model.Config {
const source = try readManaged(io, arena, dir, path, diags);
return parse(arena, source, diags);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "every path-class open failure is a managed-config fault" {
inline for (@typeInfo(PathFault).error_set.?) |member| {
const e = @field(ReadError, member.name);
try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
}
}
test "a box fault outside the path class propagates unmapped" {
// Each of these exits 1: a retry can clear them, and the shipped unit's
// `RestartPreventExitStatus=2 64` would make exit 2 permanent.
try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
}
test "the size limit is its own fault, not an unreadable path" {
try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
}
test "the path class is exactly the eight members the ruling names" {
// A member added to `PathFault` without a decision recorded in the spec
// fails here rather than quietly moving an exit code from 1 to 2.
const expected = [_][]const u8{
"FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
"IsDir", "SymLinkLoop", "NameTooLong", "BadPathName",
};
const members = @typeInfo(PathFault).error_set.?;
try testing.expectEqual(expected.len, members.len);
inline for (members) |member| {
var found = false;
for (expected) |name| {
if (std.mem.eql(u8, name, member.name)) found = true;
}
try testing.expect(found);
}
}
test "a missing managed file records the path and the reason" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
}
test "a directory named as the managed file is a configuration fault, not a crash" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(testing.io, "sub");
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
}
test "load parses a valid file and renders a syntax error line by line" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
});
try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var good_diags: validate.Diagnostics = .init(testing.allocator);
defer good_diags.deinit();
const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);
var bad_diags: validate.Diagnostics = .init(testing.allocator);
defer bad_diags.deinit();
try testing.expectError(
error.ParseZon,
load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
);
try testing.expect(bad_diags.failureCount() >= 1);
try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
}
+76 -11
View File
@@ -87,10 +87,16 @@ pub const Web = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0",
port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "".
password: []const u8 = "",
/// argon2id PHC string; "" disables authentication.
password_hash: []const u8 = "",
/// Operator input only. Never a settings row, never exported.
///
/// Optional because absence and emptiness are different declarations: null
/// means "the file says nothing about the password, keep the stored hash",
/// while a present value is an instruction to set one.
password: ?[]const u8 = null,
/// argon2id PHC string. Null means "the file says nothing, keep what is
/// stored"; an explicit `""` is the documented way to disable
/// authentication.
password_hash: ?[]const u8 = null,
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
/// Requests from the box itself skip the API rate limit. On by default: a
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
/// `web.password` is operator input, never a settings row: it is hashed into
/// `web.password_hash` at import time and discarded (S2.5).
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
/// The skip policy splits by direction, because encode and decode need
/// different sets.
///
/// `web.password` is operator input and is skipped both ways: it is hashed into
/// `web.password_hash` and discarded (S2.5).
///
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
/// "keep the stored hash", which a general encode pass cannot express. Skipping
/// it on decode as well would leave `cfg.web.password_hash` null on every read
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
if (!std.mem.eql(u8, section, "web")) return false;
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
}
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
// A stored key is a present value, so an optional field decodes to a
// non-null one; the null stays reserved for the absent key, which never
// reaches this function at all.
.optional => |info| try decodeValue(info.child, text),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.password_hash",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
try testing.expectEqual(@as(usize, 2), unknown);
}
test "web.password_hash decodes from the settings table but is never encoded" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
// Encode: the reconciler owns that row, so no pass over the model emits it.
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
// Decode: every read path still sees the stored hash, or `authEnabled`
// would read false on a box that has a password set.
var cfg: Config = .{};
var unknown: usize = 0;
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
try fromSettings(&stored, &cfg, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
}
test "an optional settings field is null when absent and non-null when present" {
var absent: Config = .{};
var unknown: usize = 0;
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&other, &absent, &unknown);
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
// An explicit empty string is a present value, not an absent key: it is how
// a config file disables authentication.
var empty: Config = .{};
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
try fromSettings(&disabled, &empty, &unknown);
try testing.expect(empty.web.password_hash != null);
try testing.expectEqualStrings("", empty.web.password_hash.?);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
File diff suppressed because it is too large Load Diff
+56 -2
View File
@@ -101,6 +101,7 @@ pub const ValidateError = error{
MissingKeyPath,
MissingLogPath,
PasswordAndHashBothSet,
EmptyWebPassword,
};
/// What `validate` returns: a verdict on the configuration, or the allocation
@@ -114,7 +115,19 @@ pub const Error = ValidateError || Allocator.Error;
/// same channel so a syntax error's line/column reaches the operator's output,
/// plus the warnings — which are never returned by `validate` and so are not
/// `ValidateError` members.
pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup };
/// Wider than `ValidateError`: the diagnostic channel also carries the problems
/// found before the validator ever sees a `Config` — the ZON parse, the managed
/// file that would not open (`config/loader.zig`), the file above the size limit
/// — and the one found after it, an import whose diff would delete rows. The
/// validator itself records only `ValidateError` members, which is what makes
/// `validate`'s `@errorCast` of its own findings checked-safe.
pub const ProblemError = ValidateError || error{
ParseZon,
SourceInNoGroup,
ManagedConfigUnreadable,
ConfigTooLarge,
DestructiveImport,
};
/// `.fail` rejects the configuration and is what an exit code is computed from.
/// `.warn` reports something legal that is almost certainly not what the
@@ -392,7 +405,10 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
try checkBind(diags, cfg.web.bind, "web.bind", .any);
try checkPort(diags, cfg.web.port, "web.port");
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
// Both fields are optional, and absence is the third state: a file that
// states neither keeps the stored hash. So the test is on presence, not on
// length.
if (cfg.web.password != null and cfg.web.password_hash != null) {
try diags.add(
error.PasswordAndHashBothSet,
"web.password",
@@ -401,6 +417,22 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
.{},
);
}
// A present-but-empty password would hash the empty string into a non-empty
// PHC — authentication on — while every login with an empty password is
// refused: authentication on and unreachable. The remedy is named, because
// the operator who wrote this meant one of two other things.
if (cfg.web.password) |password| {
if (password.len == 0) {
try diags.add(
error.EmptyWebPassword,
"web.password",
.{},
"password is set to the empty string; omit the field to keep the stored password, " ++
"or set password_hash = \"\" to disable authentication",
.{},
);
}
}
// A session TTL is a TTL; `BadTtl` is its bucket.
if (cfg.web.session_ttl_hours < 1) {
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
@@ -1968,6 +2000,28 @@ test "error.PasswordAndHashBothSet" {
try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
}
test "error.EmptyWebPassword names password_hash as the way to disable auth" {
var cfg = baseConfig();
cfg.web.password = "";
try expectProblem(cfg, error.EmptyWebPassword, "web.password");
// The remedy has to be in the text: the operator who wrote `password = ""`
// meant either "keep the current one" or "turn authentication off", and the
// diagnostic is the only place that distinction is spelled out.
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags));
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null);
// Absence is not emptiness: a file that states no password is legal and
// means "keep the stored hash".
var absent = baseConfig();
absent.web.password = null;
var quiet: Diagnostics = .init(testing.allocator);
defer quiet.deinit();
try validate(absent, &quiet);
}
test "a config with five distinct problems yields five diagnostics and the first error" {
var cfg = baseConfig();
cfg.dns.port = 0; // BadPort, first in check order
+113
View File
@@ -22,6 +22,7 @@ const build_options = @import("build_options");
const net = std.Io.net;
const model = @import("../config/model.zig");
const reconcile = @import("../config/reconcile.zig");
const db = @import("../storage/db.zig");
const migrations = @import("../storage/migrations.zig");
const context = @import("../storage/repositories/context.zig");
@@ -400,6 +401,10 @@ const HttpFixture = struct {
server: net.Server,
body: []const u8,
route: std.atomic.Value(u8),
/// Connections accepted, whatever came over them. A test that claims a pass
/// downloaded nothing reads this rather than the route counters: a refetch
/// that failed on the wire is still a refetch, and this counts it.
accepted: std.atomic.Value(u32),
/// How many parts the `chunked` route has flushed. The test reads it to
/// prove the reply really left this server in pieces, because a `Writer`
/// reports a buffered part as written and would otherwise hide a fixture
@@ -422,6 +427,7 @@ const HttpFixture = struct {
.server = try local.listen(io, .{ .reuse_address = true }),
.body = body,
.route = .init(@intFromEnum(Route.body)),
.accepted = .init(0),
.flushed_parts = .init(0),
.stall_reached = .unset,
.stall_release = .unset,
@@ -449,6 +455,7 @@ const HttpFixture = struct {
while (true) {
var stream = self.server.accept(io) catch return;
defer stream.close(io);
_ = self.accepted.fetchAdd(1, .monotonic);
var read_buf: [8192]u8 = undefined;
var write_buf: [8192]u8 = undefined;
@@ -1372,6 +1379,112 @@ test "10d: a source deleted mid-refresh does not take the refresh's temporary fi
}
}
// ---------------------------------------------------------------------------
// 10e: the restart invariant, across the config engine and the filter layer
// ---------------------------------------------------------------------------
test "10e: a reconcile then a restart reuses the compiled files and downloads nothing" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
const env = try Env.create(gpa);
defer env.destroy();
const io = env.io();
var fixture = try HttpFixture.init(io, http_body);
defer fixture.deinit(io);
var group: std.Io.Group = .init;
defer group.cancel(io);
try group.concurrent(io, HttpFixture.serve, .{ &fixture, io });
var url_buf: [64]u8 = undefined;
const url = try fixture.url(&url_buf);
const id = try seedSource(&env.database, url);
// One real download, so the compiled artifacts exist and are named after
// the row id the rest of this test is about.
try testing.expect(try refreshOnce(env, url));
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
var dir = try env.blocklistDir();
defer dir.close(io);
var list_buf: [64]u8 = undefined;
var wild_buf: [64]u8 = undefined;
const list_name = try std.fmt.bufPrint(&list_buf, "{d}.list", .{id});
const wild_name = try std.fmt.bufPrint(&wild_buf, "{d}.wild", .{id});
const list_before = try dir.statFile(io, list_name, .{});
const wild_before = try dir.statFile(io, wild_name, .{});
// File mode, declaring exactly what the database already holds. The engine
// has to recognise the source by its url and leave the row where it is:
// the compiled files are named after that id, and the manager looks for
// them under the same number.
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.blocklist_sources = &.{.{ .url = url, .name = source_name }},
.group_sources = &.{.{ .group = "default", .source_url = url }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
};
var pass = try reconcile.begin(
io,
gpa,
&env.database,
cfg,
std.Io.Clock.real.now(io).toSeconds(),
.{},
);
errdefer pass.rollback();
// Committed before the manager comes up, which is the ordering the invariant
// rests on: a manager that read the table mid-transaction could see either
// half of a source it is about to look for on disk.
try pass.commit();
// The restart. The old manager is gone and a new one comes up over the same
// directory and the same database with nothing carried across in memory.
// `runScheduler` is the boot sequence the server runs — the orphan sweep,
// then the startup pass — and a disabled update makes it return rather than
// wait out an interval.
env.mgr.deinit(io);
env.mgr = try manager.Manager.init(
gpa,
&env.database,
.{ .dir = env.tmp.dir },
&env.f,
.{ .enabled = false },
budget,
);
try env.mgr.runScheduler(io);
// Nothing was downloaded. The server is still listening, so this is a
// decision the pass made rather than a connection it could not have opened.
try testing.expectEqual(@as(u32, 1), fixture.accepted.load(.monotonic));
// The same two files: not recompiled, and not swept as orphans and written
// back.
const list_after = try dir.statFile(io, list_name, .{});
const wild_after = try dir.statFile(io, wild_name, .{});
try testing.expectEqual(list_before.inode, list_after.inode);
try testing.expectEqual(list_before.mtime, list_after.mtime);
try testing.expectEqual(wild_before.inode, wild_after.inode);
try testing.expectEqual(wild_before.mtime, wild_after.mtime);
// The row kept the id those files are named after, and the snapshot the
// restart published is the one compiled from them.
var rows = try listRows(&env.database);
defer rows.deinit();
try testing.expectEqual(id, (try rows.byUrl(url)).id);
try testing.expectEqual(manager.State.ok, (try env.status(id)).state);
// Asserted last, after the behaviour it explains: the engine wrote no row
// at all, which is why the id above survived and why the restart above had
// files to find.
try testing.expectEqual(@as(u32, 0), pass.summary.sources.total());
const decision, _ = try env.evaluate("ads.example.com");
try testing.expect(decision.blocked);
try testing.expectEqual(matcher.Reason.blocklist_domain, decision.reason);
}
// ---------------------------------------------------------------------------
// 1112: local records, from the database to the wire
// ---------------------------------------------------------------------------
+42
View File
@@ -1199,6 +1199,14 @@ pub const Manager = struct {
if (state != .ok) return true;
const last = row.last_updated orelse return true;
// The Pi has no RTC, so a fetch stamped while the clock ran ahead of
// real time (a pre-NTP boot, a restored image) leaves a `last_updated`
// in the future. Plain interval arithmetic would then suspend every
// refresh until real time caught up with the poison stamp, and the
// reconcile engine preserves runtime columns faithfully, so nothing
// else would ever clear it. A stamp from the future is not evidence of
// a recent fetch.
if (last > now) return true;
return now - last >= model.updateIntervalSeconds(self.update);
}
@@ -1674,6 +1682,40 @@ test "acquire before any reload returns null and holds no lock" {
manager.lock.unlock(io);
}
test "needsRefresh treats a last_updated in the future as due" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var f: fetcher.Fetcher = undefined;
var manager = try testManager(&database, &f);
defer manager.deinit(io);
// `.ok` is the only state that consults the clock at all; every other one
// is already due, so the arithmetic below would be unreachable without it.
var statuses = [_]SourceStatus{.{ .id = 1, .state = .ok }};
manager.statuses = &statuses;
defer manager.statuses = &.{};
const row = testRow(1, true);
const stamp = row.last_updated.?;
const interval = model.updateIntervalSeconds(manager.update);
// The ordinary cases still hold: fresh is not due, stale is.
try testing.expect(!manager.needsRefresh(io, row, stamp + 1));
try testing.expect(manager.needsRefresh(io, row, stamp + interval));
// The Pi has no RTC. A fetch stamped while the clock ran ahead of real
// time leaves `now - last` negative, which reads as "fetched moments ago"
// and suspends every refresh until real time catches the poison stamp —
// for a whole day here, and for as long as the clock was wrong in general.
try testing.expect(manager.needsRefresh(io, row, stamp - 1));
try testing.expect(manager.needsRefresh(io, row, stamp - 86_400));
}
test "the disk gate skips a scheduled refresh only while writes are critical" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
+5 -3
View File
@@ -965,10 +965,12 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
shutdown.reset();
defer shutdown.reset();
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{
.data_dir = root,
// `--config` present: the file is authority and the database is converged
// onto it before the listeners bind (milestone-20 ruling 1).
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{
.paths = .{ .data_dir = root },
.config = config_path,
} } });
} });
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
const client = try client_address.bind(test_io, .{ .mode = .dgram });
+24 -21
View File
@@ -1,5 +1,5 @@
//! The `config.db` schema, verbatim from PLAN §11.2, plus the two table orders
//! every other storage session needs.
//! The `config.db` schema, verbatim from PLAN §11.2, plus the table lists every
//! other storage session needs.
//!
//! The DDL text is data, not code: `migrations.zig` carries it as step 1 and
//! never edits it in place. A schema change is a *new* step with new DDL, so
@@ -89,8 +89,10 @@ pub const ddl_v1: [:0]const u8 =
\\CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
;
/// Child-before-parent. Used by import's wipe step; correct under
/// `foreign_keys = ON`.
/// Child-before-parent, and correct under `foreign_keys = ON`. The reconcile
/// engine deletes in this order so that every declarative child of a dying
/// parent is removed — and counted — before the parent goes, which keeps the FK
/// cascades a safety net rather than the accountant.
///
/// `upstreams`, `local_records`, `forward_zones` and `settings` have no foreign
/// keys, so their position is free; `groups` and `blocklist_sources` must come
@@ -102,18 +104,28 @@ pub const delete_order = [_][]const u8{
"blocklist_sources", "groups",
};
/// Every table whose emptiness defines "the database has never been configured"
/// (S5.2). `groups` is absent because migration step 1 seeds `(1, 'default')`,
/// so an empty database still holds one group row; `schema_version` is absent
/// for the same reason.
pub const content_tables = [_][]const u8{
"clients", "client_prefixes", "upstreams", "blocklist_sources",
"group_sources", "rules", "local_records", "forward_zones",
"settings",
/// Every table that holds configuration, in a fixed order. It includes
/// `groups`, because a byte-stability dump has to be able to see a reconcile
/// that renumbered a group.
///
/// `schema_version` is absent: it is the migration's, not the operator's.
pub const table_names = [_][]const u8{
"groups", "clients", "client_prefixes", "upstreams",
"blocklist_sources", "group_sources", "rules", "local_records",
"forward_zones", "settings",
};
const testing = std.testing;
test "table_names names exactly the tables delete_order does" {
try testing.expectEqual(delete_order.len, table_names.len);
for (delete_order) |name| {
try testing.expect(indexOf(&table_names, name) != null);
}
try testing.expect(indexOf(&table_names, "groups") != null);
try testing.expect(indexOf(&table_names, "schema_version") == null);
}
test "delete_order lists every referrer before the table it references" {
// The two parents in the schema. Every child that references them must be
// deleted first, or `foreign_keys = ON` turns import's wipe into a
@@ -130,15 +142,6 @@ test "delete_order lists every referrer before the table it references" {
}
}
test "content_tables is delete_order without groups" {
try testing.expectEqual(delete_order.len - 1, content_tables.len);
for (content_tables) |name| {
try testing.expect(indexOf(&delete_order, name) != null);
}
try testing.expect(indexOf(&content_tables, "groups") == null);
try testing.expect(indexOf(&content_tables, "schema_version") == null);
}
fn indexOf(haystack: []const []const u8, needle: []const u8) ?usize {
for (haystack, 0..) |item, i| {
if (std.mem.eql(u8, item, needle)) return i;
+10
View File
@@ -57,6 +57,7 @@ pub const c = struct {
pub extern fn sqlite3_column_bytes(stmt: *c.Stmt, col: c_int) c_int;
pub extern fn sqlite3_last_insert_rowid(db: *Sqlite3) i64;
pub extern fn sqlite3_changes(db: *Sqlite3) c_int;
pub extern fn sqlite3_total_changes(db: *Sqlite3) c_int;
};
/// Result codes, from the vendored `sqlite3.h` (3.53.4).
@@ -429,6 +430,15 @@ pub const Db = struct {
pub fn changes(self: *Db) i64 {
return c.sqlite3_changes(self.handle);
}
/// Every row this connection has inserted, updated or deleted since it was
/// opened. Monotonic, so a caller proves "this call wrote nothing" by
/// reading it either side and comparing — which is stronger than comparing
/// content, because an UPDATE that rewrites identical values still moves
/// this counter.
pub fn totalChanges(self: *Db) i64 {
return c.sqlite3_total_changes(self.handle);
}
};
fn openHandle(filename: [:0]const u8, flags: c_int) Error!*c.Sqlite3 {
+2 -2
View File
@@ -353,7 +353,7 @@ test "readVersion reads a file database through an immutable open, writing nothi
try testing.expectEqual(@as(u32, 1), try readVersion(&database));
}
test "delete_order and content_tables name exactly the tables the schema creates" {
test "delete_order and table_names name exactly the tables the schema creates" {
var database = try openMigrated();
defer database.close();
_ = try migrate(&database);
@@ -361,7 +361,7 @@ test "delete_order and content_tables name exactly the tables the schema creates
for (config_schema.delete_order) |name| {
try testing.expect(try tableExists(&database, name));
}
for (config_schema.content_tables) |name| {
for (config_schema.table_names) |name| {
try testing.expect(try tableExists(&database, name));
}
// delete_order covers every table except `schema_version`.
+69 -7
View File
@@ -3,13 +3,14 @@
//! `listClients` returns only `hand_edited = 1` rows. A client the server
//! materialised from live traffic is runtime state, not configuration, and must
//! not appear in an export. `hand_edited` is the only marker of operator intent
//! in this table, so it also decides what `import.isEmpty` counts: a database
//! carrying nothing but materialised rows has never been configured, and a seed
//! file must still be able to fill it. `countClients` counts **all** rows and is
//! a test helper — it deliberately does not answer that question.
//! in this table, so it also decides what the reconcile engine may delete: a
//! declared row the file drops is removed, an observed row is kept whatever the
//! file says, and declaring an observed address promotes that row in place.
//! `countClients` counts **all** rows and is a test helper.
//!
//! The import path is list / insert / deleteAll / count, plus the two runtime
//! calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s tracker owns.
//! The configuration path is list / insert / update / delete / count, plus the
//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s
//! tracker owns.
//! The REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included.
@@ -126,7 +127,7 @@ pub fn deleteAllClients(database: *db.Db) db.Error!void {
}
/// Counts every row, including the materialised ones `listClients` filters out.
/// Used by tests; `import.isEmpty` counts operator intent instead.
/// Used by tests.
pub fn countClients(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM clients");
}
@@ -407,6 +408,67 @@ pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput)
try tx.commit();
}
// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `replaceClientPrefixes` above is the REST list resource: one atomic swap of
// the whole table, in a transaction of its own. The reconcile engine cannot use
// it — it runs inside a transaction already, and rewriting every row would
// forfeit the row ids and the zero-writes property the engine exists for — so
// it edits and removes prefixes one at a time instead.
/// Writes the two columns a prefix row carries besides its identity.
///
/// `error.NotFound`: no prefix holds `id`. `error.Constraint`:
/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group.
pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void {
var stmt = try database.prepare(
"UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.prefix);
try stmt.bindInt(3, item.group_id);
try stmt.bindInt(4, item.priority);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no prefix holds `id`. Nothing references
/// `client_prefixes`, so a delete cannot violate a constraint.
pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
/// Moves the observed clients of one group to another, and reports how many
/// rows moved.
///
/// `clients.group_id` references `groups(id)` with no `ON DELETE` action
/// (config_schema.zig:26), so a group that any client still sits in cannot be
/// deleted. When a configuration stops declaring a group, its *declared*
/// clients go with it, but the devices the DNS path materialised into it did
/// not come from the configuration and must not be deleted for a decision that
/// was never about them. They move to the default group, which is also the
/// semantics the operator asked for: they un-declared the group, not the
/// devices.
///
/// `hand_edited = 1` rows are untouched — those are configuration, and the
/// reconcile engine has already accounted for them.
pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 {
var stmt = try database.prepare(
"UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, from_group_id);
try stmt.bindInt(2, to_group_id);
try stmt.exec();
const moved = database.changes();
return @intCast(@min(moved, std.math.maxInt(u32)));
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+35
View File
@@ -254,6 +254,41 @@ pub fn setGroupSources(database: *db.Db, group_id: i64, source_ids: []const i64)
try tx.commit();
}
// ---------------------------------------------------------------------------
// reconcile surface (milestone 20)
// ---------------------------------------------------------------------------
//
// `group_sources` has no row id — the pair *is* the identity — so the reconcile
// engine matches on the pair and needs the ids the name-keyed list above
// resolves away.
pub const GroupSourcePair = struct { group_id: i64, source_id: i64 };
/// Every assignment as the pair of ids it is. Nothing to free.
pub fn listGroupSourcePairs(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(GroupSourcePair) {
return crud.listRows(
GroupSourcePair,
database,
gpa,
"SELECT group_id, source_id FROM group_sources ORDER BY group_id, source_id",
readGroupSourcePair,
);
}
fn readGroupSourcePair(stmt: *db.Stmt, gpa: Allocator) db.Error!GroupSourcePair {
_ = gpa;
return .{ .group_id = stmt.columnInt(0), .source_id = stmt.columnInt(1) };
}
/// Removes one assignment. `error.NotFound`: no row holds the pair.
pub fn deleteGroupSourcePair(database: *db.Db, pair: GroupSourcePair) db.Error!void {
var stmt = try database.prepare("DELETE FROM group_sources WHERE group_id = ?1 AND source_id = ?2");
defer stmt.deinit();
try stmt.bindInt(1, pair.group_id);
try stmt.bindInt(2, pair.source_id);
return crud.execStrict(database, &stmt);
}
fn groupExists(database: *db.Db, id: i64) db.Error!bool {
var stmt = try database.prepare("SELECT 1 FROM groups WHERE id = ?1");
defer stmt.deinit();
@@ -88,6 +88,18 @@ pub fn putSetting(database: *db.Db, key: []const u8, value: []const u8) db.Error
try stmt.exec();
}
/// Removes one key. Silent about a key that is not stored: the reconcile
/// engine's sweep computes the set of keys to drop from a list it has already
/// read, so "no such row" is not a caller error the way it is for a by-id
/// mutation, and `execStrict` would turn a harmless race into a failed
/// transaction.
pub fn deleteSetting(database: *db.Db, key: []const u8) db.Error!void {
var stmt = try database.prepare("DELETE FROM settings WHERE key = ?1");
defer stmt.deinit();
try stmt.bindText(1, key);
return stmt.exec();
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
+20 -135
View File
@@ -22,7 +22,6 @@ const build_options = @import("build_options");
const Writer = std.Io.Writer;
const cli = @import("../cli.zig");
const bootstrap = @import("../config/bootstrap.zig");
const config_export = @import("../config/export.zig");
const import = @import("../config/import.zig");
const model = @import("../config/model.zig");
@@ -127,7 +126,7 @@ fn openMigrated(f: *const Fixture, name: []const u8) !Data {
return .{ .dir = dir, .database = database };
}
fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !void {
fn importInto(f: *const Fixture, data: *Data, file: []const u8, allow_delete: bool) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return import.importFile(
@@ -136,7 +135,7 @@ fn importInto(f: *const Fixture, data: *Data, file: []const u8, force: bool) !vo
&data.database,
f.tmp.dir,
file,
.{ .force = force },
.{ .allow_delete = allow_delete },
&diags,
);
}
@@ -655,7 +654,7 @@ test "S7 case 10: a config.db stamped one version ahead is refused and left alon
}
// ---------------------------------------------------------------------------
// case 11-19: export, import and bootstrap on real files
// case 11-19: export and import on real files
// ---------------------------------------------------------------------------
test "S7 case 11: an exported file is mode 0600 and starts with the header comment" {
@@ -704,7 +703,7 @@ test "S7 case 12: export, import and export again are byte-identical files" {
try testing.expectEqualStrings(a, b);
}
test "S7 case 13: import refuses a configured database unless --force is given" {
test "S7 case 13: import refuses a diff that deletes rows unless --allow-delete is given" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
@@ -716,7 +715,10 @@ test "S7 case 13: import refuses a configured database unless --force is given"
defer data.deinit();
try importInto(&f, &data, "first.zon", false);
try testing.expectError(error.DatabaseNotEmpty, importInto(&f, &data, "second.zon", false));
// The two files name different upstream urls, and a url is the upstream's
// identity: applying the second deletes the first's row, which is what the
// gate exists to stop.
try testing.expectError(error.DestructiveImport, importInto(&f, &data, "second.zon", false));
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
@@ -753,14 +755,15 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
&data.database,
f.tmp.dir,
"config.zon",
.{ .force = false },
.{ .allow_delete = false },
&diags,
)) |_| {
return error.TestUnexpectedResult;
} else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
try testing.expect(try import.isEmpty(&data.database));
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM upstreams"));
try testing.expectEqual(@as(i64, 0), try data.database.queryInt("SELECT count(*) FROM settings"));
// Nothing beyond the database and its sidecars was created.
var dir = try f.tmp.dir.openDir(io, "data", .{ .iterate = true });
@@ -771,129 +774,6 @@ test "S7 case 14: an invalid import reports every problem and writes nothing" {
}
}
test "S7 case 15: bootstrap with no configuration file leaves the database empty" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.no_config_file, outcome);
try testing.expect(try import.isEmpty(&data.database));
}
test "S7 case 16: bootstrap seeds an empty database from the configuration file" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", rich_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM groups"));
try testing.expectEqual(@as(i64, 2), try data.database.queryInt("SELECT count(*) FROM upstreams"));
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"),
);
try testing.expectEqual(
@as(i64, 5353),
try data.database.queryInt("SELECT CAST(value AS INTEGER) FROM settings WHERE key = 'dns.port'"),
);
}
test "S7 case 17: bootstrap on a configured database never reads the file" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("seed.zon", minimal_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
try importInto(&f, &data, "seed.zon", false);
// Unparseable on purpose: the call can only succeed if the file is never
// opened.
try f.write("config.zon", broken_zon);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
);
try testing.expectEqual(bootstrap.Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
try testing.expectEqual(
@as(i64, 1),
try data.database.queryInt(
"SELECT count(*) FROM upstreams WHERE url = 'https://dns.example/dns-query'",
),
);
}
test "S7 case 18: bootstrap with an invalid configuration file fails and writes nothing" {
if (!build_options.integration) return error.SkipZigTest;
var f: Fixture = .init();
defer f.deinit();
try f.write("config.zon", two_problem_config);
var data = try openMigrated(&f, "data");
defer data.deinit();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
if (bootstrap.bootstrap(
io,
testing.allocator,
&data.database,
f.tmp.dir,
"config.zon",
&diags,
)) |outcome| {
std.debug.print("bootstrap unexpectedly returned .{s}\n", .{@tagName(outcome)});
return error.TestUnexpectedResult;
} else |_| {}
try testing.expectEqual(@as(usize, 2), diags.problems.items.len);
try testing.expect(try import.isEmpty(&data.database));
}
test "S7 case 19: writeToFile replaces an existing file and restores mode 0600" {
if (!build_options.integration) return error.SkipZigTest;
@@ -988,11 +868,13 @@ test "S7 case 21: runCheck passes a seeded database and reports two stored probl
try importInto(&f, &data, "config.zon", false);
}
{
// `applyToDb` rather than an import: the validator would refuse this
// `apply` rather than an import: the validator would refuse this
// configuration, and the case needs the problems to reach the database.
var data = try openMigrated(&f, "bad");
defer data.deinit();
try import.applyToDb(io, testing.allocator, &data.database, two_problem_model, 42, .{});
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try import.apply(io, testing.allocator, &data.database, two_problem_model, 42, .{}, &diags);
}
{
@@ -1045,13 +927,16 @@ test "S7 case 22: runCheck probes a real upstream and prints an OK line" {
const code = cli.runCheck(
captured.runner(),
.{ .paths = .{ .config = config_path }, .config_explicit = true },
.{ .config = config_path },
true,
);
try testing.expectEqual(cli.exit_ok, code);
// Milestone 13 changed the probe line to the redacted `OK upstreams[i]`
// form; this expectation went stale unnoticed because nothing ran -Dlive
// between then and milestone 20.
try testing.expect(std.mem.count(
u8,
captured.out.written(),
"OK https://cloudflare-dns.com/dns-query\n",
"OK upstreams[0] https://cloudflare-dns.com\n",
) == 1);
}
+2 -1
View File
@@ -49,7 +49,8 @@ comptime {
_ = @import("storage/repositories/settings_repo.zig");
_ = @import("config/export.zig");
_ = @import("config/import.zig");
_ = @import("config/bootstrap.zig");
_ = @import("config/loader.zig");
_ = @import("config/reconcile.zig");
_ = @import("cli.zig");
_ = @import("storage/storage_integration_test.zig");
_ = @import("filter/parsers.zig");
+6 -2
View File
@@ -58,9 +58,12 @@ pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";
pub const max_password_len = 256;
/// Authentication is on exactly when a hash exists (ruling 17). An empty hash
/// is the documented "no password set" state, not a misconfiguration.
/// is the documented "no password set" state, not a misconfiguration; a null
/// one means the settings table holds no hash row at all, which is the same
/// answer.
pub fn authEnabled(web: model.Web) bool {
return web.password_hash.len != 0;
const hash = web.password_hash orelse return false;
return hash.len != 0;
}
pub const Outcome = enum {
@@ -453,6 +456,7 @@ fn tokenOf(n: u8) [token_bytes]u8 {
test "authEnabled follows the presence of a hash" {
try testing.expect(!authEnabled(.{}));
try testing.expect(!authEnabled(.{ .password_hash = null }));
try testing.expect(!authEnabled(.{ .password_hash = "" }));
try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
}
+110 -1
View File
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
const address = @import("../../platform/address.zig");
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
/// What file authority found when it went to delete a row.
pub const ObservedDelete = enum { deleted, declared, absent };
/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
/// two halves are a single decision. Split across two statements, a concurrent
/// `nxdns import` — which takes the same write lock for its own reconcile — can
/// promote the row between the read and the DELETE, and file authority would
/// delete a client the file had just declared. Holding the write lock across
/// both makes the promotion wait, and it then sees the row already gone or
/// still there, never half of each.
///
/// A read-only outcome commits an empty transaction, which costs nothing and
/// keeps the one exit path.
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
const row = try clients_repo.getClient(database, arena, id);
const verdict: ObservedDelete = if (row) |found|
(if (found.hand_edited) .declared else .deleted)
else
.absent;
if (verdict == .deleted) try clients_repo.deleteClient(database, id);
try tx.commit();
return verdict;
}
/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
/// file authority lets it through: an observed row is runtime state the file
/// never declared, and without a way to remove it a mis-identified or departed
/// device would be immortal — the file can promote an IP, never forget one.
/// A row the file *declares* is configuration, and deleting it would contradict
/// the file, so it answers the same 403 the router answers elsewhere. This is
/// the one policy decision that needs a row read, which is why it is here and
/// not a table column.
///
/// A row that is not there is a 404, exactly as in database mode: file
/// authority must not turn a missing row into a policy verdict.
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const path = switch (state.authority) {
.database => return resource.remove(state, io, request),
.managed_file => |managed| managed,
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, delete_what);
state.config_lock.lockUncancelable(io);
const outcome = deleteIfObserved(database, request.arena, request.id.?);
state.config_lock.unlock(io);
switch (outcome catch |err| return mutations.respondFailure(
request,
mutations.dbFailure(err, group_conflict),
delete_what,
)) {
.absent => return mutations.respondFailure(request, .not_found, ""),
.declared => return http_util.respondManagedByFile(request, path),
.deleted => {},
}
if (mutations.reload(state, io)) |failure| {
return mutations.respondFailure(request, failure, delete_what);
}
return http_util.respondEmpty(request, .no_content);
}
const delete_what = "deleting a client";
/// The prefixes are one list resource with no `/{id}` route: the whole set is
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
@@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" {
try testing.expectEqual(@as(usize, 1), bench.reloads);
}
test "file authority deletes an observed client and refuses a declared one" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
try bench.exec(
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
\\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
);
// The declared row is configuration; it survives, and nothing is written.
try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));
try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
}
test "the observed check and the delete are one transaction" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
// SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
// transaction is what proves this takes the write lock rather than reading
// and deleting through two unsynchronised statements — the window a
// concurrent `nxdns import` would promote the row in. Without the
// transaction both statements run and the row is gone.
var tx = try db.Tx.begin(&bench.database);
try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
tx.rollback();
// The row is untouched: the refusal happened before any statement ran.
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
}
test "the prefix list is replaced whole" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
+65 -14
View File
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
/// Enums arrive as the words the database stores, so they are parsed from text
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
///
/// An optional model field collapses to its child, because `Partial` wraps
/// every field in one optional of its own and that optional already carries the
/// only meaning a PUT has for absence — "leave it". A double optional would be
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
fn FieldType(comptime T: type) type {
return switch (@typeInfo(T)) {
.@"enum" => []const u8,
.optional => |info| FieldType(info.child),
else => T,
};
}
@@ -321,16 +327,17 @@ pub fn applyPut(
// The password never becomes a row: the hash made above is what the merged
// configuration — and therefore the settings table — carries.
const previous_hash = cfg.web.password_hash;
const previous_hash = cfg.web.password_hash orelse "";
if (password != null) cfg.web.password_hash = new_hash;
cfg.web.password = "";
cfg.web.password = null;
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
// The gpa copy the live holder will own, made before the write so a
// committed transaction can never be followed by a failed revocation.
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
const merged_hash = cfg.web.password_hash orelse "";
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash);
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
for (pairs.items) |pair| {
try settings_repo.putSetting(database, pair.key, pair.value);
}
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
// reconcile engine owns that row, because only it can tell "the file said
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
// merged configuration is the whole truth — so this handler writes the row
// itself rather than losing the password change.
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
try tx.commit();
}
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// routes
// ---------------------------------------------------------------------------
/// Which source governs this process's configuration, and when it last read
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
/// read-only — declaratively, rather than by probing a route for a 403.
///
/// It rides `GET /api/settings` because that route needs a session: the
/// managed path is a filesystem path and must never reach the open
/// `/api/version` or `/api/health`.
///
/// `reconciled_at` means exactly "this process loaded the file at T". A file
/// whose mtime is newer has not been loaded by the running process. It cannot
/// answer "is the file what the server uses" — a stepped clock or a preserved
/// mtime defeats the comparison in either direction, and the database can move
/// under `nxdns import` without either timestamp moving.
const AuthorityView = struct {
mode: []const u8,
path: ?[]const u8,
reconciled_at: ?i64,
};
fn authorityView(state: *const server.WebState) AuthorityView {
return switch (state.authority) {
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
.managed_file => |path| .{
.mode = "managed_file",
.path = path,
.reconciled_at = state.reconciled_at,
},
};
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
const cfg = loaded catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
return respondSettings(request, .ok, cfg);
return respondSettings(request, state, .ok, cfg);
}
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
return switch (try applyPut(state, io, request.arena, parsed.value)) {
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
.config => |cfg| respondSettings(request, .ok, cfg),
.config => |cfg| respondSettings(request, state, .ok, cfg),
};
}
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
fn respondSettings(
request: *Request,
state: *const server.WebState,
status: std.http.Status,
cfg: model.Config,
) HandlerError!void {
return http_util.respondJson(request, status, .{
.settings = view(cfg),
.restart_required = restart_required_keys,
.authority = authorityView(state),
}, &.{});
}
@@ -498,8 +547,10 @@ const testing = std.testing;
const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" {
// `model.toSettings` is the other half of the same fact: the keys the
// database stores, minus the hash the API never serializes.
// `model.toSettings` is the other half of the same fact. The two lists are
// now equal rather than off by one: `toSettings` stopped emitting
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
// it, so both exclude the hash and the plaintext.
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(testing.allocator, pairs.items);
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
}
try model.toSettings(.{}, testing.allocator, &pairs);
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
for (restart_required_keys) |key| {
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password"));
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
patch.web = .{ .password = "correct horse battery staple" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
// The plain password is nowhere in the table, and the hash is.
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
);
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
try testing.expectEqual(
auth.Outcome.ok,
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
);
}
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
patch.web = .{ .password = "" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expectEqualStrings("", outcome.config.web.password_hash);
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
}
test "reading the settings with no database is unavailable" {
+25 -10
View File
@@ -307,23 +307,38 @@ pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJso
/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal
/// error string for a 500 (PLAN §19: details go to the log, not the wire).
///
/// Built on the request arena, like `respondJson` below. It used to build into
/// a 512-byte stack buffer and fall back to `text/plain` when the message
/// overflowed it, which made the documented JSON envelope a function of message
/// length — a long managed-file path (milestone-20 ruling 7) was enough to
/// demote it. The envelope is `application/json` at every length now.
pub fn respondError(
request: *Request,
status: http.Status,
message: []const u8,
) HandlerError!void {
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var stringify: std.json.Stringify = .{ .writer = &writer };
stringify.beginObject() catch return respondPlain(request, status, message);
stringify.objectField("error") catch return respondPlain(request, status, message);
stringify.write(message) catch return respondPlain(request, status, message);
stringify.endObject() catch return respondPlain(request, status, message);
return respondBytes(request, status, writer.buffered(), content_type_json, &.{});
var allocating: std.Io.Writer.Allocating = .init(request.arena);
defer allocating.deinit();
var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
stringify.beginObject() catch return error.OutOfMemory;
stringify.objectField("error") catch return error.OutOfMemory;
stringify.write(message) catch return error.OutOfMemory;
stringify.endObject() catch return error.OutOfMemory;
return respondBytes(request, status, allocating.written(), content_type_json, &.{});
}
fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void {
return respondBytes(request, status, message, content_type_text, &.{});
/// Milestone-20 ruling 7's rejection: a configuration write under file
/// authority. One function, because the router rejects most of them and the
/// clients handler rejects the one that needs a row read — two wordings would
/// be two contracts.
pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void {
const message = try std.fmt.allocPrint(
request.arena,
"configuration is managed by {s}; edit the file and restart",
.{path},
);
return respondError(request, .forbidden, message);
}
/// Serialises `value` and responds. The document is built in the request arena
+99 -1
View File
@@ -29,6 +29,15 @@ info:
- Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and
`/api/settings` are restart-required.
- nxdns runs under one of two configuration authorities. Started with
`--config=<file>`, that file is the sole declarative source, and every
operation that writes configuration answers 403 with the same error
envelope, naming the file. Operations that change runtime state —
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
login and the logout — stay live, as does `DELETE /api/clients/{id}`
for a client the file does not declare. `GET /api/settings` reports
the live authority, so a client reads the mode rather than
discovering it from a rejection.
servers:
- url: /
@@ -391,6 +400,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -444,6 +455,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -466,6 +479,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -519,6 +534,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -575,6 +592,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -655,6 +674,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -674,6 +695,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -728,6 +751,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -780,6 +805,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -799,6 +826,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -853,6 +882,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -905,6 +936,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -924,6 +957,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -978,6 +1013,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1030,6 +1067,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1049,6 +1088,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1133,6 +1174,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1222,6 +1265,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1277,6 +1322,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1330,6 +1377,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1350,6 +1399,8 @@ paths:
description: Deleted; takes effect on restart.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1454,6 +1505,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"413":
$ref: "#/components/responses/BodyTooLarge"
"429":
@@ -1526,6 +1579,16 @@ components:
application/json:
schema:
$ref: "#/components/schemas/Error"
ManagedByFile:
description: |
nxdns is running under file authority and this operation writes
configuration. The message names the file. Authentication is checked
first, so an unauthenticated request to a protected route still
answers 401 rather than disclosing that the route exists.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: No row has this id.
content:
@@ -2193,7 +2256,7 @@ components:
SettingsEnvelope:
type: object
required: [settings, restart_required]
required: [settings, restart_required, authority]
properties:
settings:
$ref: "#/components/schemas/Settings"
@@ -2203,6 +2266,41 @@ components:
description: |
Every `section.field` key that needs a restart to take effect —
currently all of them.
authority:
$ref: "#/components/schemas/Authority"
Authority:
type: object
description: |
Which source governs this process's configuration. This is how a
client learns that configuration is read-only; it never has to probe
a write route for a 403. The block rides this authenticated endpoint
because `path` is a filesystem path, and never appears on the open
`/api/version` or `/api/health`.
required: [mode, path, reconciled_at]
properties:
mode:
type: string
enum: [database, managed_file]
description: |
`database` when nxdns runs without `--config`; `managed_file`
when it runs with it, in which case every configuration write
answers 403.
path:
type: string
nullable: true
description: The managed file, or null in `database` mode.
reconciled_at:
type: integer
nullable: true
description: |
When this process loaded the managed file, in epoch seconds, and
null in `database` mode. It means exactly that: a file whose
mtime is newer has not been loaded by the running process. It
cannot answer whether the file matches what the server serves —
a stepped clock or a preserved mtime defeats the comparison
either way, and `nxdns import` can move the database without
moving either timestamp.
SettingsPatch:
type: object
+78
View File
@@ -48,6 +48,84 @@ test "every served route appears textually in the document" {
}
}
// Drift guard for milestone-20 ruling 7: a route classified `config_write` can
// answer 403 under file authority, so its operation must say so — and a route
// that cannot must not claim it. Textual, like the coverage test above: the
// document has no parser here, and the two facts it compares are one line each.
test "every config write documents the file-authority 403, and nothing else does" {
for (router.routes) |route| {
const operation = try operationBlock(route.pattern, route.method);
const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n");
if (documented != (route.policy == .config_write)) {
std.debug.print(
"{t} {s} is {t} but {s} a 403\n",
.{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" },
);
return error.TestUnexpectedResult;
}
}
}
/// The body of one operation: everything under `pattern`'s `method` key.
///
/// The path block is bounded *before* the method is looked for. Searching the
/// rest of the document instead would let a later path's `delete:` answer for a
/// path that has none, and the guard above would pass on an operation nobody
/// documented.
fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 {
var key_buf: [128]u8 = undefined;
const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern});
const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented;
const path_body = blockUnder(yaml[path_at + path_key.len ..], 2);
var method_buf: [16]u8 = undefined;
const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)});
_ = std.ascii.lowerString(&method_buf, method_key);
const key = method_buf[0..method_key.len];
// Anchored at a line start: a `get:` nested deeper inside a description
// contains the four-space key as a substring.
var offset: usize = 0;
while (offset < path_body.len) {
if (std.mem.startsWith(u8, path_body[offset..], key)) {
return blockUnder(path_body[offset + key.len ..], 4);
}
offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1;
}
return error.MethodNotDocumented;
}
/// The run of lines at the start of `body` indented deeper than `indent` — what
/// belongs to the key that just ended. `body` starts at a line boundary. Blank
/// lines belong to whatever surrounds them and never close a block.
fn blockUnder(body: []const u8, indent: usize) []const u8 {
var offset: usize = 0;
while (offset < body.len) {
const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len;
if (line_end != offset) {
const depth = for (body[offset..line_end], 0..) |c, i| {
if (c != ' ') break i;
} else line_end - offset;
if (depth <= indent) return body[0..offset];
}
offset = line_end + 1;
}
return body;
}
test "an operation block stops at its own path and its own method" {
// `/api/groups` has no DELETE. An unbounded search answers with the one
// under `/api/groups/{id}`, and the 403 guard then grades the wrong
// operation — silently passing for a route nobody documented.
try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE));
// A block it does have never reaches into its neighbour under the same
// path either.
const list_groups = try operationBlock("/api/groups", .GET);
try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups"));
try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group"));
}
test "the document names the contract's fixed points" {
for ([_][]const u8{
"openapi: 3.0.3",
+55 -7
View File
@@ -37,12 +37,20 @@ pub const Auth = enum { open, session };
/// monitoring endpoints so a Prometheus scrape can never be throttled.
pub const RateLimit = enum { counted, exempt };
/// What a route does to the configuration, and therefore whether file
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
/// declarative state the managed file owns; `runtime_action` changes runtime
/// state the file never declares; `read` changes nothing.
pub const Policy = enum { read, config_write, runtime_action };
pub const RouteInfo = struct {
method: http.Method,
/// Segments separated by `/`, with at most one `{id}` capture, which must
/// be a positive integer row id.
pattern: []const u8,
auth: Auth,
/// No default: a new route states its class or does not compile.
policy: Policy,
handler: HandlerFn,
rate_limit: RateLimit = .counted,
};
@@ -158,6 +166,17 @@ pub fn dispatch(
return http_util.respondError(request, .unauthorized, "authentication required");
}
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
// rejecting before authenticating would tell an anonymous caller which
// routes exist. An unauthenticated request to a protected route answers
// 401 in both authority modes.
if (found.route.policy == .config_write) {
switch (state.authority) {
.database => {},
.managed_file => |path| return http_util.respondManagedByFile(request, path),
}
}
return found.route.handler(state, io, request);
}
@@ -196,13 +215,14 @@ fn noopHandler(
}
const test_table = [_]RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
};
fn matchPath(method: http.Method, path: []const u8) Match {
@@ -263,6 +283,34 @@ test "the allow header lists every method the path accepts" {
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
}
test "matching carries the class the table declares, per route and not per prefix" {
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
.{ .method = .GET, .path = "/api/groups", .policy = .read },
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
// Same prefix, different class: the column is per route.
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
};
for (cases) |case| {
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
}
}
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
var refresh: ?Policy = null;
var create: ?Policy = null;
for (routes) |route| {
if (route.method != .POST) continue;
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
}
try testing.expectEqual(Policy.runtime_action, refresh.?);
try testing.expectEqual(Policy.config_write, create.?);
}
test "the shipped route table is the one the router matches against" {
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
try testing.expectEqual(routes_table.table.len, routes.len);
+132 -56
View File
@@ -18,6 +18,17 @@
//! bucket. The static assets are ruling 18's remaining exemption; they are
//! not routes — the router sends unmatched non-`/api` paths to
//! `WebState.fallback` before any policy check.
//!
//! `policy` is the third such column, and milestone-20 ruling 7's contract:
//! under file authority the file is the sole declarative source, so a
//! `config_write` answers 403 and a `runtime_action` stays live. It has no
//! default value on purpose — a route added without a stated class must not
//! inherit one. Classification is per route, not per prefix:
//! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its
//! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a
//! `runtime_action` here because deleting an *observed* row discards runtime
//! state the file never declared; the declared case needs a row read and the
//! clients handler answers it.
const router = @import("router.zig");
@@ -43,85 +54,85 @@ const version = @import("handlers/version.zig");
pub const table: []const router.RouteInfo = &.{
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions).
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle },
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle },
// Authentication.
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout },
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
// Query log, stats, live stream, lookup.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle },
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
// Groups.
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources },
// Blocklist sources. `/api/blocklists/update` is a literal segment; it
// cannot collide with `{id}`, which only matches a positive integer.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove },
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove },
// Rules.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove },
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove },
// Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord },
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord },
// Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone },
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone },
// Clients (no POST — rows come from DNS activity or import, ruling 9).
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes },
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes },
// Upstreams (restart-required resource).
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove },
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove },
// Pause and settings.
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put },
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
// Certificates (milestone-10 ruling 8).
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post },
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
};
// ---------------------------------------------------------------------------
@@ -187,6 +198,71 @@ test "the limiter exemptions are the monitoring endpoints and the live stream" {
try testing.expectEqual(exempt.len, found);
}
test "the config writes are exactly the declarative mutations" {
const writes = [_][]const u8{
"POST /api/groups",
"PUT /api/groups/{id}",
"DELETE /api/groups/{id}",
"PUT /api/groups/{id}/sources",
"POST /api/blocklists",
"PUT /api/blocklists/{id}",
"DELETE /api/blocklists/{id}",
"POST /api/rules",
"PUT /api/rules/{id}",
"DELETE /api/rules/{id}",
"POST /api/local-records",
"PUT /api/local-records/{id}",
"DELETE /api/local-records/{id}",
"POST /api/forward-zones",
"PUT /api/forward-zones/{id}",
"DELETE /api/forward-zones/{id}",
"PUT /api/clients/{id}",
"PUT /api/client-prefixes",
"POST /api/upstreams",
"PUT /api/upstreams/{id}",
"DELETE /api/upstreams/{id}",
"PUT /api/settings",
};
try expectClass(.config_write, &writes);
}
test "the runtime actions are exactly ruling 7's list" {
const actions = [_][]const u8{
"POST /api/auth/login",
"POST /api/auth/logout",
"POST /api/blocklists/update",
"DELETE /api/clients/{id}",
"POST /api/pause",
"POST /api/certs/reload",
};
try expectClass(.runtime_action, &actions);
}
test "every read is a GET and every GET is a read" {
for (table) |route| {
try testing.expectEqual(route.method == .GET, route.policy == .read);
}
}
/// Asserts that the routes classified `policy` are exactly `expected`, each
/// written `METHOD /pattern`.
fn expectClass(policy: router.Policy, expected: []const []const u8) !void {
var buf: [64]u8 = undefined;
var found: usize = 0;
for (table) |route| {
if (route.policy != policy) continue;
found += 1;
const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern });
var listed = false;
for (expected) |name| listed = listed or std.mem.eql(u8, label, name);
if (!listed) {
std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy });
return error.TestUnexpectedResult;
}
}
try testing.expectEqual(expected.len, found);
}
test "item routes capture one id and collection routes capture none" {
for (table) |route| {
const captures = std.mem.count(u8, route.pattern, "{id}");
+21
View File
@@ -102,10 +102,31 @@ pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void;
/// false` means several of them are never opened at all (ruling 6). A handler
/// that finds the collaborator it needs missing answers 503, the same way it
/// answers a missing snapshot.
/// Which of the two sources governs this process's configuration (milestone-20
/// ruling 1). Per-process state, never persisted: authority lives in the
/// invocation, and the database carries no record of who wrote it.
///
/// The `managed_file` path is owned by `serve`'s arena, which outlives every
/// `WebState`, so nothing here copies it.
pub const Authority = union(enum) {
database,
managed_file: []const u8,
};
pub const WebState = struct {
gpa: Allocator,
web: model.Web = .{},
/// Defaults to `.database`: a `WebState` nobody told about a managed file
/// governs nothing declaratively, which is the safe reading — the mutation
/// routes stay live rather than a half-wired server refusing every write.
authority: Authority = .database,
/// When this process loaded the managed file, in epoch seconds. Null in
/// database mode, which never reconciles. It answers exactly "this process
/// loaded the file at T" and nothing more: a file whose mtime is newer has
/// not been loaded by the running process.
reconciled_at: ?i64 = null,
handler: ?*dns_handler.Handler = null,
pause: ?*pause_mod.Pause = null,
tracker: ?*clients.Tracker = null,
+5 -5
View File
@@ -90,11 +90,11 @@ fn bodyThenPathHandler(
}
const test_routes = [_]router.RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .policy = .read, .handler = echoDomainHandler },
};
fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool {
+313 -56
View File
@@ -264,6 +264,10 @@ const EnvOptions = struct {
sse_max_per_ip: u16 = 3,
trusted_proxies: []const u8 = "",
fallback: ?router.HandlerFn = null,
/// Milestone-20 ruling 7. `.database` is what every pre-existing test
/// wants; the file-authority tests below name a path.
authority: server.Authority = .database,
reconciled_at: ?i64 = null,
};
/// Heap-allocated because `state` and the listener hold pointers into it.
@@ -372,6 +376,8 @@ const Env = struct {
.sse_max_connections_per_ip = options.sse_max_per_ip,
.trusted_proxies = options.trusted_proxies,
},
.authority = options.authority,
.reconciled_at = options.reconciled_at,
.live_hash = .init(options.password_hash),
.pause = &self.pauser,
.manager = &self.mgr,
@@ -573,6 +579,7 @@ const SettingsView = struct {
blocklist_update: struct { enabled: bool, interval_hours: u16 },
},
restart_required: []const []const u8,
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
};
const Contract = struct {
@@ -580,6 +587,9 @@ const Contract = struct {
/// Must equal a `routes.zig` pattern; the coverage test enforces it.
pattern: []const u8,
auth: router.Auth,
/// Milestone-20 ruling 7's class, restated here so the coverage test can
/// hold the served table to it. No default, like the route table.
policy: router.Policy,
rate_limit: router.RateLimit = .counted,
/// The concrete request target the walk sends.
target: []const u8,
@@ -597,96 +607,96 @@ const Contract = struct {
/// create that made the row, and deletes come last for their resource.
const contract = [_]Contract{
// Monitoring and contract.
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
// Authentication (auth is disabled in the walk's environment; the on/off
// matrix has its own test).
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
// Query log, stats, live stream, upstream health.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
// Groups. The migrated schema seeds `default` as id 1; the POST creates
// id 2, which the delete at the end of the walk removes.
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
// Blocklist sources. The POST runs after the refresh above, so the created
// row's url is never fetched.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .status = 204, .kind = .none },
// Rules. The lookup below wants the blocking rule still in place, so the
// rule's delete follows it.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none },
// Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none },
// Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/clients/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
// Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete
// cannot collide with the last-enabled-upstream guard.
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/2", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/2", .status = 204, .kind = .none },
// Pause and settings. The pause POST leaves filtering running; the
// settings PUT is a real change, echoed by the same response shape.
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
// Certificates. The walk's environment wires no cert store, so both
// endpoints report disabled — and the reload still answers 200 (m10
// ruling 8: the outcome is the payload).
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
// The walk's last delete returns the groups table to its seeded shape.
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none },
};
// Drift guard: the contract table covers the served route table exactly —
@@ -707,6 +717,7 @@ test "the contract table covers every served route with the served policy" {
covered[index] = true;
try testing.expectEqual(route.auth, entry.auth);
try testing.expectEqual(route.rate_limit, entry.rate_limit);
try testing.expectEqual(route.policy, entry.policy);
found = true;
break;
}
@@ -933,6 +944,252 @@ test "W10 auth off: an empty hash leaves every route open" {
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// file authority (milestone-20 ruling 7)
// ---------------------------------------------------------------------------
const managed_path = "/etc/nxdns/config.zon";
const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++
"; edit the file and restart\"}";
/// Long enough that the envelope could not be built in the 512-byte stack
/// buffer `respondError` used before this milestone. Nested bind mounts really
/// do produce paths like this, and the old code answered them in `text/plain`.
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
var body_buf: [8192]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// A read is untouched.
try conn.request("GET", "/api/groups", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
// Every class of configuration write answers the one envelope.
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
};
for (writes) |write| {
try conn.request(write.method, write.target, null, write.body);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expectEqualStrings(managed_body, response.body);
try testing.expectEqualStrings("application/json", response.header("content-type").?);
}
// Rejected before the handler, not after it: the group was never created.
try conn.request("GET", "/api/groups", null, null);
response = try conn.receive(&body_buf);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids"));
// Runtime actions stay live.
try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try conn.request("POST", "/api/blocklists/update", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 202), response.status);
try conn.request("POST", "/api/certs/reload", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
test "W10 milestone 20: file authority rejects configuration writes and spares the rest" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
defer env.destroy();
try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
}
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// The declared row contradicts the file, so it stays.
try conn.request("DELETE", "/api/clients/2", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expectEqualStrings(managed_body, response.body);
// The observed row is runtime state the file never declared; without this
// a departed device would be immortal, since the file can only promote an
// address, never forget one.
try conn.request("DELETE", "/api/clients/1", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 204), response.status);
// An id no client holds is still a 404, not a policy verdict.
try conn.request("DELETE", "/api/clients/999", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 404), response.status);
}
test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
defer env.destroy();
// Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file
// declares.
try env.config_db.exec(
\\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000)
);
try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env });
}
fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [8192]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expect(response.body.len > 512);
try testing.expectEqualStrings("application/json", response.header("content-type").?);
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path));
// Still the documented envelope, not a truncation and not plain text.
const parsed = try std.json.parseFromSlice(
struct { @"error": []const u8 },
env.gpa,
response.body,
.{},
);
defer parsed.deinit();
}
test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } });
defer env.destroy();
try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env });
}
fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// Policy runs after authentication: a caller with no session learns that
// it needs one, never that the route exists and is managed by a file whose
// path the envelope would otherwise disclose.
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 401), response.status);
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
}
test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var hash_buf: [256]u8 = undefined;
const hash = try hashTestPassword(gpa, &hash_buf);
var env = try Env.create(gpa, .{
.password_hash = hash,
.authority = .{ .managed_file = managed_path },
});
defer env.destroy();
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
}
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
defer parsed.deinit();
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
// The path is a filesystem path and must not reach the open routes.
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
try conn.request("GET", target, null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
}
}
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{
.authority = .{ .managed_file = managed_path },
.reconciled_at = 1_700_000_042,
});
defer env.destroy();
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
}
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
defer parsed.deinit();
try testing.expectEqualStrings("database", parsed.value.authority.mode);
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
// And nothing is rejected.
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const created = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 201), created.status);
}
test "W10 milestone 20: database authority reports null and writes normally" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// oversized cookie headers (ruling 7 of milestone 16)
// ---------------------------------------------------------------------------