milestone 20: declarative configuration for iac
This commit is contained in:
+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