milestone 20: declarative configuration for iac
This commit is contained in:
+443
-70
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 11–12: local records, from the database to the wire
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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
@@ -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" }));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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}");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user