milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
+6 -2
View File
@@ -58,9 +58,12 @@ pub const cookie_attributes = "HttpOnly; SameSite=Lax; Path=/";
pub const max_password_len = 256;
/// Authentication is on exactly when a hash exists (ruling 17). An empty hash
/// is the documented "no password set" state, not a misconfiguration.
/// is the documented "no password set" state, not a misconfiguration; a null
/// one means the settings table holds no hash row at all, which is the same
/// answer.
pub fn authEnabled(web: model.Web) bool {
return web.password_hash.len != 0;
const hash = web.password_hash orelse return false;
return hash.len != 0;
}
pub const Outcome = enum {
@@ -453,6 +456,7 @@ fn tokenOf(n: u8) [token_bytes]u8 {
test "authEnabled follows the presence of a hash" {
try testing.expect(!authEnabled(.{}));
try testing.expect(!authEnabled(.{ .password_hash = null }));
try testing.expect(!authEnabled(.{ .password_hash = "" }));
try testing.expect(authEnabled(.{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" }));
}
+110 -1
View File
@@ -24,6 +24,7 @@ const Allocator = std.mem.Allocator;
const address = @import("../../platform/address.zig");
const clients_repo = @import("../../storage/repositories/clients_repo.zig");
const db = @import("../../storage/db.zig");
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
@@ -155,7 +156,76 @@ const resource = mutations.Resource(.{
pub const list = resource.list;
pub const get = resource.get;
pub const remove = resource.remove;
/// What file authority found when it went to delete a row.
pub const ObservedDelete = enum { deleted, declared, absent };
/// Reads `hand_edited` and acts on it inside one `BEGIN IMMEDIATE`, because the
/// two halves are a single decision. Split across two statements, a concurrent
/// `nxdns import` — which takes the same write lock for its own reconcile — can
/// promote the row between the read and the DELETE, and file authority would
/// delete a client the file had just declared. Holding the write lock across
/// both makes the promotion wait, and it then sees the row already gone or
/// still there, never half of each.
///
/// A read-only outcome commits an empty transaction, which costs nothing and
/// keeps the one exit path.
fn deleteIfObserved(database: *db.Db, arena: Allocator, id: i64) db.Error!ObservedDelete {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
const row = try clients_repo.getClient(database, arena, id);
const verdict: ObservedDelete = if (row) |found|
(if (found.hand_edited) .declared else .deleted)
else
.absent;
if (verdict == .deleted) try clients_repo.deleteClient(database, id);
try tx.commit();
return verdict;
}
/// DELETE is a `runtime_action` in the route table (milestone-20 ruling 7), so
/// file authority lets it through: an observed row is runtime state the file
/// never declared, and without a way to remove it a mis-identified or departed
/// device would be immortal — the file can promote an IP, never forget one.
/// A row the file *declares* is configuration, and deleting it would contradict
/// the file, so it answers the same 403 the router answers elsewhere. This is
/// the one policy decision that needs a row read, which is why it is here and
/// not a table column.
///
/// A row that is not there is a 404, exactly as in database mode: file
/// authority must not turn a missing row into a policy verdict.
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const path = switch (state.authority) {
.database => return resource.remove(state, io, request),
.managed_file => |managed| managed,
};
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, delete_what);
state.config_lock.lockUncancelable(io);
const outcome = deleteIfObserved(database, request.arena, request.id.?);
state.config_lock.unlock(io);
switch (outcome catch |err| return mutations.respondFailure(
request,
mutations.dbFailure(err, group_conflict),
delete_what,
)) {
.absent => return mutations.respondFailure(request, .not_found, ""),
.declared => return http_util.respondManagedByFile(request, path),
.deleted => {},
}
if (mutations.reload(state, io)) |failure| {
return mutations.respondFailure(request, failure, delete_what);
}
return http_util.respondEmpty(request, .no_content);
}
const delete_what = "deleting a client";
/// The prefixes are one list resource with no `/{id}` route: the whole set is
/// read and replaced (ruling 9), so there is nothing to get or delete by id.
@@ -280,6 +350,45 @@ test "deleting a client removes the row and announces the change" {
try testing.expectEqual(@as(usize, 1), bench.reloads);
}
test "file authority deletes an observed client and refuses a declared one" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
try bench.exec(
\\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen)
\\VALUES (2, '192.168.1.11', 1, 1, 100, 200);
);
// The declared row is configuration; it survives, and nothing is written.
try testing.expectEqual(ObservedDelete.declared, try deleteIfObserved(&bench.database, bench.arena(), 2));
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 2"));
try testing.expectEqual(ObservedDelete.deleted, try deleteIfObserved(&bench.database, bench.arena(), 1));
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
try testing.expectEqual(ObservedDelete.absent, try deleteIfObserved(&bench.database, bench.arena(), 999));
}
test "the observed check and the delete are one transaction" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try seedClient(&bench);
// SQLite refuses a `BEGIN IMMEDIATE` inside an open transaction, so a held
// transaction is what proves this takes the write lock rather than reading
// and deleting through two unsynchronised statements — the window a
// concurrent `nxdns import` would promote the row in. Without the
// transaction both statements run and the row is gone.
var tx = try db.Tx.begin(&bench.database);
try testing.expectError(error.Unexpected, deleteIfObserved(&bench.database, bench.arena(), 1));
tx.rollback();
// The row is untouched: the refusal happened before any statement ran.
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM clients WHERE id = 1"));
}
test "the prefix list is replaced whole" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
+65 -14
View File
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
/// Enums arrive as the words the database stores, so they are parsed from text
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
///
/// An optional model field collapses to its child, because `Partial` wraps
/// every field in one optional of its own and that optional already carries the
/// only meaning a PUT has for absence — "leave it". A double optional would be
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
fn FieldType(comptime T: type) type {
return switch (@typeInfo(T)) {
.@"enum" => []const u8,
.optional => |info| FieldType(info.child),
else => T,
};
}
@@ -321,16 +327,17 @@ pub fn applyPut(
// The password never becomes a row: the hash made above is what the merged
// configuration — and therefore the settings table — carries.
const previous_hash = cfg.web.password_hash;
const previous_hash = cfg.web.password_hash orelse "";
if (password != null) cfg.web.password_hash = new_hash;
cfg.web.password = "";
cfg.web.password = null;
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
// The gpa copy the live holder will own, made before the write so a
// committed transaction can never be followed by a failed revocation.
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
const merged_hash = cfg.web.password_hash orelse "";
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash);
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
for (pairs.items) |pair| {
try settings_repo.putSetting(database, pair.key, pair.value);
}
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
// reconcile engine owns that row, because only it can tell "the file said
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
// merged configuration is the whole truth — so this handler writes the row
// itself rather than losing the password change.
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
try tx.commit();
}
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// routes
// ---------------------------------------------------------------------------
/// Which source governs this process's configuration, and when it last read
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
/// read-only — declaratively, rather than by probing a route for a 403.
///
/// It rides `GET /api/settings` because that route needs a session: the
/// managed path is a filesystem path and must never reach the open
/// `/api/version` or `/api/health`.
///
/// `reconciled_at` means exactly "this process loaded the file at T". A file
/// whose mtime is newer has not been loaded by the running process. It cannot
/// answer "is the file what the server uses" — a stepped clock or a preserved
/// mtime defeats the comparison in either direction, and the database can move
/// under `nxdns import` without either timestamp moving.
const AuthorityView = struct {
mode: []const u8,
path: ?[]const u8,
reconciled_at: ?i64,
};
fn authorityView(state: *const server.WebState) AuthorityView {
return switch (state.authority) {
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
.managed_file => |path| .{
.mode = "managed_file",
.path = path,
.reconciled_at = state.reconciled_at,
},
};
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
const cfg = loaded catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
return respondSettings(request, .ok, cfg);
return respondSettings(request, state, .ok, cfg);
}
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
return switch (try applyPut(state, io, request.arena, parsed.value)) {
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
.config => |cfg| respondSettings(request, .ok, cfg),
.config => |cfg| respondSettings(request, state, .ok, cfg),
};
}
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
fn respondSettings(
request: *Request,
state: *const server.WebState,
status: std.http.Status,
cfg: model.Config,
) HandlerError!void {
return http_util.respondJson(request, status, .{
.settings = view(cfg),
.restart_required = restart_required_keys,
.authority = authorityView(state),
}, &.{});
}
@@ -498,8 +547,10 @@ const testing = std.testing;
const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" {
// `model.toSettings` is the other half of the same fact: the keys the
// database stores, minus the hash the API never serializes.
// `model.toSettings` is the other half of the same fact. The two lists are
// now equal rather than off by one: `toSettings` stopped emitting
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
// it, so both exclude the hash and the plaintext.
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(testing.allocator, pairs.items);
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
}
try model.toSettings(.{}, testing.allocator, &pairs);
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
for (restart_required_keys) |key| {
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password"));
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
patch.web = .{ .password = "correct horse battery staple" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
// The plain password is nowhere in the table, and the hash is.
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
);
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
try testing.expectEqual(
auth.Outcome.ok,
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
);
}
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
patch.web = .{ .password = "" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expectEqualStrings("", outcome.config.web.password_hash);
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
}
test "reading the settings with no database is unavailable" {
+25 -10
View File
@@ -307,23 +307,38 @@ pub fn parseBody(comptime T: type, request: *Request) (BodyError || error{BadJso
/// Ruling 8's envelope. `message` is operator-facing text, never a raw internal
/// error string for a 500 (PLAN §19: details go to the log, not the wire).
///
/// Built on the request arena, like `respondJson` below. It used to build into
/// a 512-byte stack buffer and fall back to `text/plain` when the message
/// overflowed it, which made the documented JSON envelope a function of message
/// length — a long managed-file path (milestone-20 ruling 7) was enough to
/// demote it. The envelope is `application/json` at every length now.
pub fn respondError(
request: *Request,
status: http.Status,
message: []const u8,
) HandlerError!void {
var buf: [512]u8 = undefined;
var writer: std.Io.Writer = .fixed(&buf);
var stringify: std.json.Stringify = .{ .writer = &writer };
stringify.beginObject() catch return respondPlain(request, status, message);
stringify.objectField("error") catch return respondPlain(request, status, message);
stringify.write(message) catch return respondPlain(request, status, message);
stringify.endObject() catch return respondPlain(request, status, message);
return respondBytes(request, status, writer.buffered(), content_type_json, &.{});
var allocating: std.Io.Writer.Allocating = .init(request.arena);
defer allocating.deinit();
var stringify: std.json.Stringify = .{ .writer = &allocating.writer };
stringify.beginObject() catch return error.OutOfMemory;
stringify.objectField("error") catch return error.OutOfMemory;
stringify.write(message) catch return error.OutOfMemory;
stringify.endObject() catch return error.OutOfMemory;
return respondBytes(request, status, allocating.written(), content_type_json, &.{});
}
fn respondPlain(request: *Request, status: http.Status, message: []const u8) HandlerError!void {
return respondBytes(request, status, message, content_type_text, &.{});
/// Milestone-20 ruling 7's rejection: a configuration write under file
/// authority. One function, because the router rejects most of them and the
/// clients handler rejects the one that needs a row read — two wordings would
/// be two contracts.
pub fn respondManagedByFile(request: *Request, path: []const u8) HandlerError!void {
const message = try std.fmt.allocPrint(
request.arena,
"configuration is managed by {s}; edit the file and restart",
.{path},
);
return respondError(request, .forbidden, message);
}
/// Serialises `value` and responds. The document is built in the request arena
+99 -1
View File
@@ -29,6 +29,15 @@ info:
- Mutations to groups, blocklists, rules, local records, forward zones,
clients and client prefixes take effect live. Upstreams and
`/api/settings` are restart-required.
- nxdns runs under one of two configuration authorities. Started with
`--config=<file>`, that file is the sole declarative source, and every
operation that writes configuration answers 403 with the same error
envelope, naming the file. Operations that change runtime state —
`/api/pause`, `POST /api/blocklists/update`, `/api/certs/reload`, the
login and the logout — stay live, as does `DELETE /api/clients/{id}`
for a client the file does not declare. `GET /api/settings` reports
the live authority, so a client reads the mode rather than
discovering it from a rejection.
servers:
- url: /
@@ -391,6 +400,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -444,6 +455,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -466,6 +479,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -519,6 +534,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -575,6 +592,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -655,6 +674,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -674,6 +695,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -728,6 +751,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -780,6 +805,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -799,6 +826,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -853,6 +882,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -905,6 +936,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -924,6 +957,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -978,6 +1013,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1030,6 +1067,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1049,6 +1088,8 @@ paths:
description: Deleted; applied live.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1133,6 +1174,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1222,6 +1265,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1277,6 +1322,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"409":
$ref: "#/components/responses/Conflict"
"413":
@@ -1330,6 +1377,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1350,6 +1399,8 @@ paths:
description: Deleted; takes effect on restart.
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"404":
$ref: "#/components/responses/NotFound"
"409":
@@ -1454,6 +1505,8 @@ paths:
$ref: "#/components/responses/BadRequest"
"401":
$ref: "#/components/responses/Unauthorized"
"403":
$ref: "#/components/responses/ManagedByFile"
"413":
$ref: "#/components/responses/BodyTooLarge"
"429":
@@ -1526,6 +1579,16 @@ components:
application/json:
schema:
$ref: "#/components/schemas/Error"
ManagedByFile:
description: |
nxdns is running under file authority and this operation writes
configuration. The message names the file. Authentication is checked
first, so an unauthenticated request to a protected route still
answers 401 rather than disclosing that the route exists.
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
NotFound:
description: No row has this id.
content:
@@ -2193,7 +2256,7 @@ components:
SettingsEnvelope:
type: object
required: [settings, restart_required]
required: [settings, restart_required, authority]
properties:
settings:
$ref: "#/components/schemas/Settings"
@@ -2203,6 +2266,41 @@ components:
description: |
Every `section.field` key that needs a restart to take effect —
currently all of them.
authority:
$ref: "#/components/schemas/Authority"
Authority:
type: object
description: |
Which source governs this process's configuration. This is how a
client learns that configuration is read-only; it never has to probe
a write route for a 403. The block rides this authenticated endpoint
because `path` is a filesystem path, and never appears on the open
`/api/version` or `/api/health`.
required: [mode, path, reconciled_at]
properties:
mode:
type: string
enum: [database, managed_file]
description: |
`database` when nxdns runs without `--config`; `managed_file`
when it runs with it, in which case every configuration write
answers 403.
path:
type: string
nullable: true
description: The managed file, or null in `database` mode.
reconciled_at:
type: integer
nullable: true
description: |
When this process loaded the managed file, in epoch seconds, and
null in `database` mode. It means exactly that: a file whose
mtime is newer has not been loaded by the running process. It
cannot answer whether the file matches what the server serves —
a stepped clock or a preserved mtime defeats the comparison
either way, and `nxdns import` can move the database without
moving either timestamp.
SettingsPatch:
type: object
+78
View File
@@ -48,6 +48,84 @@ test "every served route appears textually in the document" {
}
}
// Drift guard for milestone-20 ruling 7: a route classified `config_write` can
// answer 403 under file authority, so its operation must say so — and a route
// that cannot must not claim it. Textual, like the coverage test above: the
// document has no parser here, and the two facts it compares are one line each.
test "every config write documents the file-authority 403, and nothing else does" {
for (router.routes) |route| {
const operation = try operationBlock(route.pattern, route.method);
const documented = std.mem.containsAtLeast(u8, operation, 1, "\n \"403\":\n");
if (documented != (route.policy == .config_write)) {
std.debug.print(
"{t} {s} is {t} but {s} a 403\n",
.{ route.method, route.pattern, route.policy, if (documented) "documents" else "does not document" },
);
return error.TestUnexpectedResult;
}
}
}
/// The body of one operation: everything under `pattern`'s `method` key.
///
/// The path block is bounded *before* the method is looked for. Searching the
/// rest of the document instead would let a later path's `delete:` answer for a
/// path that has none, and the guard above would pass on an operation nobody
/// documented.
fn operationBlock(pattern: []const u8, method: std.http.Method) ![]const u8 {
var key_buf: [128]u8 = undefined;
const path_key = try std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{pattern});
const path_at = std.mem.indexOf(u8, yaml, path_key) orelse return error.PathNotDocumented;
const path_body = blockUnder(yaml[path_at + path_key.len ..], 2);
var method_buf: [16]u8 = undefined;
const method_key = try std.fmt.bufPrint(&method_buf, " {s}:\n", .{@tagName(method)});
_ = std.ascii.lowerString(&method_buf, method_key);
const key = method_buf[0..method_key.len];
// Anchored at a line start: a `get:` nested deeper inside a description
// contains the four-space key as a substring.
var offset: usize = 0;
while (offset < path_body.len) {
if (std.mem.startsWith(u8, path_body[offset..], key)) {
return blockUnder(path_body[offset + key.len ..], 4);
}
offset = (std.mem.indexOfScalarPos(u8, path_body, offset, '\n') orelse path_body.len) + 1;
}
return error.MethodNotDocumented;
}
/// The run of lines at the start of `body` indented deeper than `indent` — what
/// belongs to the key that just ended. `body` starts at a line boundary. Blank
/// lines belong to whatever surrounds them and never close a block.
fn blockUnder(body: []const u8, indent: usize) []const u8 {
var offset: usize = 0;
while (offset < body.len) {
const line_end = std.mem.indexOfScalarPos(u8, body, offset, '\n') orelse body.len;
if (line_end != offset) {
const depth = for (body[offset..line_end], 0..) |c, i| {
if (c != ' ') break i;
} else line_end - offset;
if (depth <= indent) return body[0..offset];
}
offset = line_end + 1;
}
return body;
}
test "an operation block stops at its own path and its own method" {
// `/api/groups` has no DELETE. An unbounded search answers with the one
// under `/api/groups/{id}`, and the 403 guard then grades the wrong
// operation — silently passing for a route nobody documented.
try testing.expectError(error.MethodNotDocumented, operationBlock("/api/groups", .DELETE));
// A block it does have never reaches into its neighbour under the same
// path either.
const list_groups = try operationBlock("/api/groups", .GET);
try testing.expect(std.mem.containsAtLeast(u8, list_groups, 1, "List groups"));
try testing.expect(!std.mem.containsAtLeast(u8, list_groups, 1, "Create a group"));
}
test "the document names the contract's fixed points" {
for ([_][]const u8{
"openapi: 3.0.3",
+55 -7
View File
@@ -37,12 +37,20 @@ pub const Auth = enum { open, session };
/// monitoring endpoints so a Prometheus scrape can never be throttled.
pub const RateLimit = enum { counted, exempt };
/// What a route does to the configuration, and therefore whether file
/// authority may allow it (milestone-20 ruling 7). `config_write` changes the
/// declarative state the managed file owns; `runtime_action` changes runtime
/// state the file never declares; `read` changes nothing.
pub const Policy = enum { read, config_write, runtime_action };
pub const RouteInfo = struct {
method: http.Method,
/// Segments separated by `/`, with at most one `{id}` capture, which must
/// be a positive integer row id.
pattern: []const u8,
auth: Auth,
/// No default: a new route states its class or does not compile.
policy: Policy,
handler: HandlerFn,
rate_limit: RateLimit = .counted,
};
@@ -158,6 +166,17 @@ pub fn dispatch(
return http_util.respondError(request, .unauthorized, "authentication required");
}
// Milestone-20 ruling 7, and it runs *after* the auth check on purpose:
// rejecting before authenticating would tell an anonymous caller which
// routes exist. An unauthenticated request to a protected route answers
// 401 in both authority modes.
if (found.route.policy == .config_write) {
switch (state.authority) {
.database => {},
.managed_file => |path| return http_util.respondManagedByFile(request, path),
}
}
return found.route.handler(state, io, request);
}
@@ -196,13 +215,14 @@ fn noopHandler(
}
const test_table = [_]RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = noopHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = noopHandler },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = noopHandler },
};
fn matchPath(method: http.Method, path: []const u8) Match {
@@ -263,6 +283,34 @@ test "the allow header lists every method the path accepts" {
try testing.expectEqualStrings("GET, PUT, DELETE", formatAllow(&test_table, item.segments(), &buf));
}
test "matching carries the class the table declares, per route and not per prefix" {
const cases = [_]struct { method: http.Method, path: []const u8, policy: Policy }{
.{ .method = .GET, .path = "/api/groups", .policy = .read },
.{ .method = .GET, .path = "/api/groups/7", .policy = .read },
.{ .method = .POST, .path = "/api/groups", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .DELETE, .path = "/api/groups/7", .policy = .config_write },
.{ .method = .PUT, .path = "/api/groups/7/sources", .policy = .config_write },
// Same prefix, different class: the column is per route.
.{ .method = .POST, .path = "/api/pause", .policy = .runtime_action },
};
for (cases) |case| {
try testing.expectEqual(case.policy, matchPath(case.method, case.path).found.route.policy);
}
}
test "the shipped route table classifies /api/blocklists by route, not by prefix" {
var refresh: ?Policy = null;
var create: ?Policy = null;
for (routes) |route| {
if (route.method != .POST) continue;
if (std.mem.eql(u8, route.pattern, "/api/blocklists/update")) refresh = route.policy;
if (std.mem.eql(u8, route.pattern, "/api/blocklists")) create = route.policy;
}
try testing.expectEqual(Policy.runtime_action, refresh.?);
try testing.expectEqual(Policy.config_write, create.?);
}
test "the shipped route table is the one the router matches against" {
try testing.expectEqual(routes_table.table.ptr, routes.ptr);
try testing.expectEqual(routes_table.table.len, routes.len);
+132 -56
View File
@@ -18,6 +18,17 @@
//! bucket. The static assets are ruling 18's remaining exemption; they are
//! not routes — the router sends unmatched non-`/api` paths to
//! `WebState.fallback` before any policy check.
//!
//! `policy` is the third such column, and milestone-20 ruling 7's contract:
//! under file authority the file is the sole declarative source, so a
//! `config_write` answers 403 and a `runtime_action` stays live. It has no
//! default value on purpose — a route added without a stated class must not
//! inherit one. Classification is per route, not per prefix:
//! `POST /api/blocklists/update` is a refresh, a `runtime_action`, while its
//! CRUD siblings write configuration. `DELETE /api/clients/{id}` is a
//! `runtime_action` here because deleting an *observed* row discards runtime
//! state the file never declared; the declared case needs a row read and the
//! clients handler answers it.
const router = @import("router.zig");
@@ -43,85 +54,85 @@ const version = @import("handlers/version.zig");
pub const table: []const router.RouteInfo = &.{
// Monitoring and contract (ruling 18's open set, ruling 19's exemptions).
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .handler = metrics.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = health.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .handler = version.handle },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .handler = openapi.handle },
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .handler = metrics.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = health.handle, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .handler = version.handle },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .handler = openapi.handle },
// Authentication.
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .handler = auth.login },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .handler = auth.logout },
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .handler = auth.login },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .handler = auth.logout },
// Query log, stats, live stream, lookup.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .handler = live.stream, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .handler = upstream_health.handle },
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .handler = upstream_health.handle },
// Groups.
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = groups.list },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = groups.create },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.get },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.update },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .handler = groups.remove },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.getSources },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .handler = groups.putSources },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = groups.list },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = groups.create },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .handler = groups.get },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.update },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = groups.remove },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .handler = groups.getSources },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .handler = groups.putSources },
// Blocklist sources. `/api/blocklists/update` is a literal segment; it
// cannot collide with `{id}`, which only matches a positive integer.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.list },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .handler = blocklists.create },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .handler = blocklists.refresh },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.get },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.update },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .handler = blocklists.remove },
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .handler = blocklists.list },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .handler = blocklists.create },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .handler = blocklists.refresh },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .handler = blocklists.get },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.update },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .handler = blocklists.remove },
// Rules.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .handler = rules.list },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .handler = rules.create },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.get },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.update },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .handler = rules.remove },
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .handler = rules.list },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .handler = rules.create },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .handler = rules.get },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.update },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .handler = rules.remove },
// Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .handler = local.listRecords },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .handler = local.createRecord },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.getRecord },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.updateRecord },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .handler = local.removeRecord },
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .handler = local.listRecords },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .handler = local.createRecord },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .handler = local.getRecord },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.updateRecord },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .handler = local.removeRecord },
// Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .handler = local.listZones },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .handler = local.createZone },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.getZone },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.updateZone },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .handler = local.removeZone },
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .handler = local.listZones },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .handler = local.createZone },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .handler = local.getZone },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.updateZone },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .handler = local.removeZone },
// Clients (no POST — rows come from DNS activity or import, ruling 9).
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .handler = clients.list },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.get },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.update },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .handler = clients.remove },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.listPrefixes },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .handler = clients.putPrefixes },
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .handler = clients.list },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .handler = clients.get },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .handler = clients.update },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .handler = clients.remove },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .handler = clients.listPrefixes },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .handler = clients.putPrefixes },
// Upstreams (restart-required resource).
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.list },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .handler = upstreams.create },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.get },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.update },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .handler = upstreams.remove },
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .handler = upstreams.list },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .handler = upstreams.create },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .handler = upstreams.get },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.update },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .handler = upstreams.remove },
// Pause and settings.
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .handler = pause.get },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .handler = pause.post },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .handler = settings.put },
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .handler = pause.get },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .handler = pause.post },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .handler = settings.get },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .handler = settings.put },
// Certificates (milestone-10 ruling 8).
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .handler = certs.post },
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .handler = certs.post },
};
// ---------------------------------------------------------------------------
@@ -187,6 +198,71 @@ test "the limiter exemptions are the monitoring endpoints and the live stream" {
try testing.expectEqual(exempt.len, found);
}
test "the config writes are exactly the declarative mutations" {
const writes = [_][]const u8{
"POST /api/groups",
"PUT /api/groups/{id}",
"DELETE /api/groups/{id}",
"PUT /api/groups/{id}/sources",
"POST /api/blocklists",
"PUT /api/blocklists/{id}",
"DELETE /api/blocklists/{id}",
"POST /api/rules",
"PUT /api/rules/{id}",
"DELETE /api/rules/{id}",
"POST /api/local-records",
"PUT /api/local-records/{id}",
"DELETE /api/local-records/{id}",
"POST /api/forward-zones",
"PUT /api/forward-zones/{id}",
"DELETE /api/forward-zones/{id}",
"PUT /api/clients/{id}",
"PUT /api/client-prefixes",
"POST /api/upstreams",
"PUT /api/upstreams/{id}",
"DELETE /api/upstreams/{id}",
"PUT /api/settings",
};
try expectClass(.config_write, &writes);
}
test "the runtime actions are exactly ruling 7's list" {
const actions = [_][]const u8{
"POST /api/auth/login",
"POST /api/auth/logout",
"POST /api/blocklists/update",
"DELETE /api/clients/{id}",
"POST /api/pause",
"POST /api/certs/reload",
};
try expectClass(.runtime_action, &actions);
}
test "every read is a GET and every GET is a read" {
for (table) |route| {
try testing.expectEqual(route.method == .GET, route.policy == .read);
}
}
/// Asserts that the routes classified `policy` are exactly `expected`, each
/// written `METHOD /pattern`.
fn expectClass(policy: router.Policy, expected: []const []const u8) !void {
var buf: [64]u8 = undefined;
var found: usize = 0;
for (table) |route| {
if (route.policy != policy) continue;
found += 1;
const label = try std.fmt.bufPrint(&buf, "{t} {s}", .{ route.method, route.pattern });
var listed = false;
for (expected) |name| listed = listed or std.mem.eql(u8, label, name);
if (!listed) {
std.debug.print("{s} is {t}, and the list does not say so\n", .{ label, policy });
return error.TestUnexpectedResult;
}
}
try testing.expectEqual(expected.len, found);
}
test "item routes capture one id and collection routes capture none" {
for (table) |route| {
const captures = std.mem.count(u8, route.pattern, "{id}");
+21
View File
@@ -102,10 +102,31 @@ pub const ReloadFn = *const fn (state: *WebState, io: std.Io) anyerror!void;
/// false` means several of them are never opened at all (ruling 6). A handler
/// that finds the collaborator it needs missing answers 503, the same way it
/// answers a missing snapshot.
/// Which of the two sources governs this process's configuration (milestone-20
/// ruling 1). Per-process state, never persisted: authority lives in the
/// invocation, and the database carries no record of who wrote it.
///
/// The `managed_file` path is owned by `serve`'s arena, which outlives every
/// `WebState`, so nothing here copies it.
pub const Authority = union(enum) {
database,
managed_file: []const u8,
};
pub const WebState = struct {
gpa: Allocator,
web: model.Web = .{},
/// Defaults to `.database`: a `WebState` nobody told about a managed file
/// governs nothing declaratively, which is the safe reading — the mutation
/// routes stay live rather than a half-wired server refusing every write.
authority: Authority = .database,
/// When this process loaded the managed file, in epoch seconds. Null in
/// database mode, which never reconciles. It answers exactly "this process
/// loaded the file at T" and nothing more: a file whose mtime is newer has
/// not been loaded by the running process.
reconciled_at: ?i64 = null,
handler: ?*dns_handler.Handler = null,
pause: ?*pause_mod.Pause = null,
tracker: ?*clients.Tracker = null,
+5 -5
View File
@@ -90,11 +90,11 @@ fn bodyThenPathHandler(
}
const test_routes = [_]router.RouteInfo{
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .handler = echoDomainHandler },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .handler = okHandler, .rate_limit = .exempt },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .handler = okHandler },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .handler = echoLengthHandler },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .handler = bodyThenPathHandler },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .open, .policy = .read, .handler = echoDomainHandler },
};
fn denyAll(state: *server.WebState, io: std.Io, request: *const http_util.Request) bool {
+313 -56
View File
@@ -264,6 +264,10 @@ const EnvOptions = struct {
sse_max_per_ip: u16 = 3,
trusted_proxies: []const u8 = "",
fallback: ?router.HandlerFn = null,
/// Milestone-20 ruling 7. `.database` is what every pre-existing test
/// wants; the file-authority tests below name a path.
authority: server.Authority = .database,
reconciled_at: ?i64 = null,
};
/// Heap-allocated because `state` and the listener hold pointers into it.
@@ -372,6 +376,8 @@ const Env = struct {
.sse_max_connections_per_ip = options.sse_max_per_ip,
.trusted_proxies = options.trusted_proxies,
},
.authority = options.authority,
.reconciled_at = options.reconciled_at,
.live_hash = .init(options.password_hash),
.pause = &self.pauser,
.manager = &self.mgr,
@@ -573,6 +579,7 @@ const SettingsView = struct {
blocklist_update: struct { enabled: bool, interval_hours: u16 },
},
restart_required: []const []const u8,
authority: struct { mode: []const u8, path: ?[]const u8, reconciled_at: ?i64 },
};
const Contract = struct {
@@ -580,6 +587,9 @@ const Contract = struct {
/// Must equal a `routes.zig` pattern; the coverage test enforces it.
pattern: []const u8,
auth: router.Auth,
/// Milestone-20 ruling 7's class, restated here so the coverage test can
/// hold the served table to it. No default, like the route table.
policy: router.Policy,
rate_limit: router.RateLimit = .counted,
/// The concrete request target the walk sends.
target: []const u8,
@@ -597,96 +607,96 @@ const Contract = struct {
/// create that made the row, and deletes come last for their resource.
const contract = [_]Contract{
// Monitoring and contract.
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
.{ .method = .GET, .pattern = "/metrics", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/metrics", .status = 200, .kind = .raw, .needle = "nxdns_up 1" },
.{ .method = .GET, .pattern = "/api/health", .auth = .open, .policy = .read, .rate_limit = .exempt, .target = "/api/health", .status = 200, .check = jsonShape(handlers_health.Body) },
.{ .method = .GET, .pattern = "/api/version", .auth = .open, .policy = .read, .target = "/api/version", .status = 200, .check = jsonShape(handlers_version.Body) },
.{ .method = .GET, .pattern = "/api/openapi.yaml", .auth = .open, .policy = .read, .target = "/api/openapi.yaml", .status = 200, .kind = .raw, .needle = "openapi: 3.0.3" },
// Authentication (auth is disabled in the walk's environment; the on/off
// matrix has its own test).
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
.{ .method = .POST, .pattern = "/api/auth/login", .auth = .open, .policy = .runtime_action, .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200, .check = jsonShape(LoginView) },
.{ .method = .POST, .pattern = "/api/auth/logout", .auth = .session, .policy = .runtime_action, .target = "/api/auth/logout", .status = 200, .check = jsonShape(LogoutView) },
// Refresh-all before any source row exists: nothing to fetch, 202 anyway.
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
.{ .method = .POST, .pattern = "/api/blocklists/update", .auth = .session, .policy = .runtime_action, .target = "/api/blocklists/update", .status = 202, .check = jsonShape(StatusList) },
// Query log, stats, live stream, upstream health.
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
.{ .method = .GET, .pattern = "/api/upstream/health", .auth = .session, .policy = .read, .target = "/api/upstream/health", .status = 200, .check = jsonShape(handlers_upstream_health.Body) },
// Groups. The migrated schema seeds `default` as id 1; the POST creates
// id 2, which the delete at the end of the walk removes.
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .GET, .pattern = "/api/groups", .auth = .session, .policy = .read, .target = "/api/groups", .status = 200, .check = jsonShape(GroupsList) },
.{ .method = .POST, .pattern = "/api/groups", .auth = .session, .policy = .config_write, .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}", .auth = .session, .policy = .read, .target = "/api/groups/2", .status = 200, .check = jsonShape(groups_repo.GroupRow) },
.{ .method = .PUT, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200, .check = jsonShape(GroupEcho) },
.{ .method = .GET, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .read, .target = "/api/groups/1/sources", .status = 200, .check = jsonShape(SourceIds) },
.{ .method = .PUT, .pattern = "/api/groups/{id}/sources", .auth = .session, .policy = .config_write, .target = "/api/groups/1/sources", .body = "{\"source_ids\":[]}", .status = 200, .check = jsonShape(SourceIds) },
// Blocklist sources. The POST runs after the refresh above, so the created
// row's url is never fetched.
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .target = "/api/blocklists/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/blocklists", .auth = .session, .policy = .read, .target = "/api/blocklists", .status = 200, .check = jsonShape(SourcesList) },
.{ .method = .POST, .pattern = "/api/blocklists", .auth = .session, .policy = .config_write, .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\"}", .status = 201, .check = jsonShape(SourceEcho) },
.{ .method = .GET, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .read, .target = "/api/blocklists/1", .status = 200, .check = jsonShape(sources_repo.SourceRow) },
.{ .method = .PUT, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200, .check = jsonShape(SourceEcho) },
.{ .method = .DELETE, .pattern = "/api/blocklists/{id}", .auth = .session, .policy = .config_write, .target = "/api/blocklists/1", .status = 204, .kind = .none },
// Rules. The lookup below wants the blocking rule still in place, so the
// rule's delete follows it.
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .target = "/api/rules/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/rules", .auth = .session, .policy = .read, .target = "/api/rules", .status = 200, .check = jsonShape(RulesList) },
.{ .method = .POST, .pattern = "/api/rules", .auth = .session, .policy = .config_write, .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/rules/{id}", .auth = .session, .policy = .read, .target = "/api/rules/1", .status = 200, .check = jsonShape(RuleShape) },
.{ .method = .PUT, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 200, .check = jsonShape(RuleEcho) },
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .target = "/api/lookup?domain=ads.example", .status = 200, .check = jsonShape(handlers_lookup.Body) },
.{ .method = .DELETE, .pattern = "/api/rules/{id}", .auth = .session, .policy = .config_write, .target = "/api/rules/1", .status = 204, .kind = .none },
// Local records.
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .target = "/api/local-records/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/local-records", .auth = .session, .policy = .read, .target = "/api/local-records", .status = 200, .check = jsonShape(RecordsList) },
.{ .method = .POST, .pattern = "/api/local-records", .auth = .session, .policy = .config_write, .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201, .check = jsonShape(RecordShape) },
.{ .method = .GET, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .read, .target = "/api/local-records/1", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .PUT, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200, .check = jsonShape(RecordShape) },
.{ .method = .DELETE, .pattern = "/api/local-records/{id}", .auth = .session, .policy = .config_write, .target = "/api/local-records/1", .status = 204, .kind = .none },
// Forward zones.
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/forward-zones", .auth = .session, .policy = .read, .target = "/api/forward-zones", .status = 200, .check = jsonShape(ZonesList) },
.{ .method = .POST, .pattern = "/api/forward-zones", .auth = .session, .policy = .config_write, .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .GET, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .read, .target = "/api/forward-zones/1", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .PUT, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200, .check = jsonShape(local_repo.ForwardZoneRow) },
.{ .method = .DELETE, .pattern = "/api/forward-zones/{id}", .auth = .session, .policy = .config_write, .target = "/api/forward-zones/1", .status = 204, .kind = .none },
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .target = "/api/clients/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .GET, .pattern = "/api/clients", .auth = .session, .policy = .read, .target = "/api/clients", .status = 200, .check = jsonShape(ClientsList) },
.{ .method = .GET, .pattern = "/api/clients/{id}", .auth = .session, .policy = .read, .target = "/api/clients/1", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .PUT, .pattern = "/api/clients/{id}", .auth = .session, .policy = .config_write, .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200, .check = jsonShape(clients_repo.ClientRow) },
.{ .method = .DELETE, .pattern = "/api/clients/{id}", .auth = .session, .policy = .runtime_action, .target = "/api/clients/1", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/client-prefixes", .auth = .session, .policy = .read, .target = "/api/client-prefixes", .status = 200, .check = jsonShape(PrefixesList) },
.{ .method = .PUT, .pattern = "/api/client-prefixes", .auth = .session, .policy = .config_write, .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200, .check = jsonShape(PrefixesList) },
// Upstreams. Row id 1 is seeded; the POST creates id 2, whose delete
// cannot collide with the last-enabled-upstream guard.
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .target = "/api/upstreams/2", .status = 204, .kind = .none },
.{ .method = .GET, .pattern = "/api/upstreams", .auth = .session, .policy = .read, .target = "/api/upstreams", .status = 200, .check = jsonShape(UpstreamsList) },
.{ .method = .POST, .pattern = "/api/upstreams", .auth = .session, .policy = .config_write, .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201, .check = jsonShape(UpstreamEcho) },
.{ .method = .GET, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .read, .target = "/api/upstreams/1", .status = 200, .check = jsonShape(upstreams_repo.UpstreamRow) },
.{ .method = .PUT, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200, .check = jsonShape(UpstreamEcho) },
.{ .method = .DELETE, .pattern = "/api/upstreams/{id}", .auth = .session, .policy = .config_write, .target = "/api/upstreams/2", .status = 204, .kind = .none },
// Pause and settings. The pause POST leaves filtering running; the
// settings PUT is a real change, echoed by the same response shape.
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .GET, .pattern = "/api/pause", .auth = .session, .policy = .read, .target = "/api/pause", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .POST, .pattern = "/api/pause", .auth = .session, .policy = .runtime_action, .target = "/api/pause", .body = "{\"paused\":false}", .status = 200, .check = jsonShape(handlers_pause.View) },
.{ .method = .GET, .pattern = "/api/settings", .auth = .session, .policy = .read, .target = "/api/settings", .status = 200, .check = jsonShape(SettingsView) },
.{ .method = .PUT, .pattern = "/api/settings", .auth = .session, .policy = .config_write, .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200, .check = jsonShape(SettingsView) },
// Certificates. The walk's environment wires no cert store, so both
// endpoints report disabled — and the reload still answers 200 (m10
// ruling 8: the outcome is the payload).
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
.{ .method = .POST, .pattern = "/api/certs/reload", .auth = .session, .policy = .runtime_action, .target = "/api/certs/reload", .status = 200, .check = jsonShape(handlers_certs.View) },
// The walk's last delete returns the groups table to its seeded shape.
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .target = "/api/groups/2", .status = 204, .kind = .none },
.{ .method = .DELETE, .pattern = "/api/groups/{id}", .auth = .session, .policy = .config_write, .target = "/api/groups/2", .status = 204, .kind = .none },
};
// Drift guard: the contract table covers the served route table exactly —
@@ -707,6 +717,7 @@ test "the contract table covers every served route with the served policy" {
covered[index] = true;
try testing.expectEqual(route.auth, entry.auth);
try testing.expectEqual(route.rate_limit, entry.rate_limit);
try testing.expectEqual(route.policy, entry.policy);
found = true;
break;
}
@@ -933,6 +944,252 @@ test "W10 auth off: an empty hash leaves every route open" {
try bounded(env.io(), default_budget, authOff, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// file authority (milestone-20 ruling 7)
// ---------------------------------------------------------------------------
const managed_path = "/etc/nxdns/config.zon";
const managed_body = "{\"error\":\"configuration is managed by " ++ managed_path ++
"; edit the file and restart\"}";
/// Long enough that the envelope could not be built in the 512-byte stack
/// buffer `respondError` used before this milestone. Nested bind mounts really
/// do produce paths like this, and the old code answered them in `text/plain`.
const long_managed_path = "/mnt/" ++ ("deeply-nested-bind-mount/" ** 24) ++ "config.zon";
fn fileModeClasses(io: std.Io, env: *Env) anyerror!void {
var body_buf: [8192]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// A read is untouched.
try conn.request("GET", "/api/groups", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
// Every class of configuration write answers the one envelope.
const writes = [_]struct { method: []const u8, target: []const u8, body: ?[]const u8 }{
.{ .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}" },
.{ .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}" },
.{ .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"x\",\"group_id\":1}" },
.{ .method = "DELETE", .target = "/api/upstreams/1", .body = null },
};
for (writes) |write| {
try conn.request(write.method, write.target, null, write.body);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expectEqualStrings(managed_body, response.body);
try testing.expectEqualStrings("application/json", response.header("content-type").?);
}
// Rejected before the handler, not after it: the group was never created.
try conn.request("GET", "/api/groups", null, null);
response = try conn.receive(&body_buf);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "kids"));
// Runtime actions stay live.
try conn.request("POST", "/api/pause", null, "{\"paused\":false}");
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try conn.request("POST", "/api/blocklists/update", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 202), response.status);
try conn.request("POST", "/api/certs/reload", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
test "W10 milestone 20: file authority rejects configuration writes and spares the rest" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
defer env.destroy();
try bounded(env.io(), default_budget, fileModeClasses, .{ env.io(), env });
}
fn fileModeClientDelete(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// The declared row contradicts the file, so it stays.
try conn.request("DELETE", "/api/clients/2", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expectEqualStrings(managed_body, response.body);
// The observed row is runtime state the file never declared; without this
// a departed device would be immortal, since the file can only promote an
// address, never forget one.
try conn.request("DELETE", "/api/clients/1", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 204), response.status);
// An id no client holds is still a 404, not a policy verdict.
try conn.request("DELETE", "/api/clients/999", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 404), response.status);
}
test "W10 milestone 20: file authority deletes an observed client and refuses a declared one" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = managed_path } });
defer env.destroy();
// Row 1 is seeded observed (`hand_edited = 0`); row 2 is what the file
// declares.
try env.config_db.exec(
\\INSERT INTO clients (id, ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (2, '192.168.1.51', 'nas', 1, 1, 1700000000, 1700000000)
);
try bounded(env.io(), default_budget, fileModeClientDelete, .{ env.io(), env });
}
fn longPathEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [8192]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 403), response.status);
try testing.expect(response.body.len > 512);
try testing.expectEqualStrings("application/json", response.header("content-type").?);
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, long_managed_path));
// Still the documented envelope, not a truncation and not plain text.
const parsed = try std.json.parseFromSlice(
struct { @"error": []const u8 },
env.gpa,
response.body,
.{},
);
defer parsed.deinit();
}
test "W10 milestone 20: an error longer than the old 512-byte buffer stays application/json" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .authority = .{ .managed_file = long_managed_path } });
defer env.destroy();
try bounded(env.io(), default_budget, longPathEnvelope, .{ env.io(), env });
}
fn fileModeUnauthenticated(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// Policy runs after authentication: a caller with no session learns that
// it needs one, never that the route exists and is managed by a file whose
// path the envelope would otherwise disclose.
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 401), response.status);
try testing.expectEqualStrings("{\"error\":\"authentication required\"}", response.body);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
}
test "W10 milestone 20: an unauthenticated configuration write is 401, never 403" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var hash_buf: [256]u8 = undefined;
const hash = try hashTestPassword(gpa, &hash_buf);
var env = try Env.create(gpa, .{
.password_hash = hash,
.authority = .{ .managed_file = managed_path },
});
defer env.destroy();
try bounded(env.io(), default_budget, fileModeUnauthenticated, .{ env.io(), env });
}
fn authorityEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
defer parsed.deinit();
try testing.expectEqualStrings("managed_file", parsed.value.authority.mode);
try testing.expectEqualStrings(managed_path, parsed.value.authority.path.?);
try testing.expectEqual(@as(?i64, 1_700_000_042), parsed.value.authority.reconciled_at);
// The path is a filesystem path and must not reach the open routes.
for ([_][]const u8{ "/api/version", "/api/health" }) |target| {
try conn.request("GET", target, null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, managed_path));
try testing.expect(!std.mem.containsAtLeast(u8, response.body, 1, "authority"));
}
}
test "W10 milestone 20: the settings envelope reports the authority and the open routes do not" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{
.authority = .{ .managed_file = managed_path },
.reconciled_at = 1_700_000_042,
});
defer env.destroy();
try bounded(env.io(), default_budget, authorityEnvelope, .{ env.io(), env });
}
fn databaseAuthorityEnvelope(io: std.Io, env: *Env) anyerror!void {
var body_buf: [16384]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
try conn.request("GET", "/api/settings", null, null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
const parsed = try std.json.parseFromSlice(SettingsView, env.gpa, response.body, .{});
defer parsed.deinit();
try testing.expectEqualStrings("database", parsed.value.authority.mode);
try testing.expectEqual(@as(?[]const u8, null), parsed.value.authority.path);
try testing.expectEqual(@as(?i64, null), parsed.value.authority.reconciled_at);
// And nothing is rejected.
try conn.request("POST", "/api/groups", null, "{\"name\":\"kids\"}");
const created = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 201), created.status);
}
test "W10 milestone 20: database authority reports null and writes normally" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, databaseAuthorityEnvelope, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// oversized cookie headers (ruling 7 of milestone 16)
// ---------------------------------------------------------------------------