milestone 20: declarative configuration for iac

This commit is contained in:
2026-08-11 23:31:40 +02:00
parent 2f29121e27
commit d76afc147a
74 changed files with 6722 additions and 1949 deletions
+65 -14
View File
@@ -125,9 +125,15 @@ fn Partial(comptime Section: type, comptime section_name: []const u8) type {
/// Enums arrive as the words the database stores, so they are parsed from text
/// rather than by tag name (`logging.level` is `error`, whose tag cannot be).
///
/// An optional model field collapses to its child, because `Partial` wraps
/// every field in one optional of its own and that optional already carries the
/// only meaning a PUT has for absence — "leave it". A double optional would be
/// two ways to say the same thing, and `std.json` cannot parse the outer one.
fn FieldType(comptime T: type) type {
return switch (@typeInfo(T)) {
.@"enum" => []const u8,
.optional => |info| FieldType(info.child),
else => T,
};
}
@@ -321,16 +327,17 @@ pub fn applyPut(
// The password never becomes a row: the hash made above is what the merged
// configuration — and therefore the settings table — carries.
const previous_hash = cfg.web.password_hash;
const previous_hash = cfg.web.password_hash orelse "";
if (password != null) cfg.web.password_hash = new_hash;
cfg.web.password = "";
cfg.web.password = null;
if (try problem(arena, cfg)) |text| return .{ .fail = .{ .invalid = text } };
// The gpa copy the live holder will own, made before the write so a
// committed transaction can never be followed by a failed revocation.
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, cfg.web.password_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, cfg.web.password_hash) else null;
const merged_hash = cfg.web.password_hash orelse "";
const hash_changed = password != null and !std.mem.eql(u8, previous_hash, merged_hash);
const replacement: ?[]u8 = if (hash_changed) try state.gpa.dupe(u8, merged_hash) else null;
writeSettings(arena, database, cfg) catch |err| {
if (replacement) |hash| state.gpa.free(hash);
@@ -365,6 +372,12 @@ fn writeSettings(arena: Allocator, database: *db.Db, cfg: model.Config) db.Error
for (pairs.items) |pair| {
try settings_repo.putSetting(database, pair.key, pair.value);
}
// `toSettings` stops at `web.password_hash` (ruling 4 of milestone 20: the
// reconcile engine owns that row, because only it can tell "the file said
// nothing" from "the file said empty"). A PUT has no such ambiguity — the
// merged configuration is the whole truth — so this handler writes the row
// itself rather than losing the password change.
try settings_repo.putSetting(database, "web.password_hash", cfg.web.password_hash orelse "");
try tx.commit();
}
@@ -455,6 +468,36 @@ pub const hash_stall_control = if (builtin.is_test) struct {
// routes
// ---------------------------------------------------------------------------
/// Which source governs this process's configuration, and when it last read
/// it (milestone-20 ruling 7). This is how the UI learns that configuration is
/// read-only — declaratively, rather than by probing a route for a 403.
///
/// It rides `GET /api/settings` because that route needs a session: the
/// managed path is a filesystem path and must never reach the open
/// `/api/version` or `/api/health`.
///
/// `reconciled_at` means exactly "this process loaded the file at T". A file
/// whose mtime is newer has not been loaded by the running process. It cannot
/// answer "is the file what the server uses" — a stepped clock or a preserved
/// mtime defeats the comparison in either direction, and the database can move
/// under `nxdns import` without either timestamp moving.
const AuthorityView = struct {
mode: []const u8,
path: ?[]const u8,
reconciled_at: ?i64,
};
fn authorityView(state: *const server.WebState) AuthorityView {
return switch (state.authority) {
.database => .{ .mode = "database", .path = null, .reconciled_at = state.reconciled_at },
.managed_file => |path| .{
.mode = "managed_file",
.path = path,
.reconciled_at = state.reconciled_at,
},
};
}
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const database = mutations.requireConfigDb(state) catch
return mutations.respondFailure(request, mutations.no_config_db, "reading the settings");
@@ -470,7 +513,7 @@ pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
const cfg = loaded catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading the settings");
return respondSettings(request, .ok, cfg);
return respondSettings(request, state, .ok, cfg);
}
pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
@@ -479,14 +522,20 @@ pub fn put(state: *server.WebState, io: std.Io, request: *Request) HandlerError!
return switch (try applyPut(state, io, request.arena, parsed.value)) {
.fail => |failure| mutations.respondFailure(request, failure, "writing the settings"),
.config => |cfg| respondSettings(request, .ok, cfg),
.config => |cfg| respondSettings(request, state, .ok, cfg),
};
}
fn respondSettings(request: *Request, status: std.http.Status, cfg: model.Config) HandlerError!void {
fn respondSettings(
request: *Request,
state: *const server.WebState,
status: std.http.Status,
cfg: model.Config,
) HandlerError!void {
return http_util.respondJson(request, status, .{
.settings = view(cfg),
.restart_required = restart_required_keys,
.authority = authorityView(state),
}, &.{});
}
@@ -498,8 +547,10 @@ const testing = std.testing;
const auth_handlers = @import("auth.zig");
test "the restart-required table lists every settings key and no secret" {
// `model.toSettings` is the other half of the same fact: the keys the
// database stores, minus the hash the API never serializes.
// `model.toSettings` is the other half of the same fact. The two lists are
// now equal rather than off by one: `toSettings` stopped emitting
// `web.password_hash` (milestone 20 ruling 4) and this table never listed
// it, so both exclude the hash and the plaintext.
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(testing.allocator, pairs.items);
@@ -507,7 +558,7 @@ test "the restart-required table lists every settings key and no secret" {
}
try model.toSettings(.{}, testing.allocator, &pairs);
try testing.expectEqual(pairs.items.len - 1, restart_required_keys.len);
try testing.expectEqual(pairs.items.len, restart_required_keys.len);
for (restart_required_keys) |key| {
try testing.expect(!std.mem.eql(u8, key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, key, "web.password"));
@@ -641,7 +692,7 @@ test "a new password is stored as a hash and ends every session" {
patch.web = .{ .password = "correct horse battery staple" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, outcome.config.web.password_hash.?, "$argon2id$"));
try testing.expect(!sessions.validateAt(bench.io(), &cookie, 1_001));
// The plain password is nowhere in the table, and the hash is.
@@ -650,10 +701,10 @@ test "a new password is stored as a hash and ends every session" {
try bench.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
);
const stored = try mutations.loadConfig(bench.arena(), &bench.database);
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash, "$argon2id$"));
try testing.expect(std.mem.startsWith(u8, stored.web.password_hash.?, "$argon2id$"));
try testing.expectEqual(
auth.Outcome.ok,
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash, "correct horse battery staple"),
try auth.verifyPassword(bench.io(), testing.allocator, stored.web.password_hash.?, "correct horse battery staple"),
);
}
@@ -713,7 +764,7 @@ test "an empty password is not a password change" {
patch.web = .{ .password = "" };
const outcome = try applyPut(&bench.state, bench.io(), bench.arena(), patch);
try testing.expectEqualStrings("", outcome.config.web.password_hash);
try testing.expectEqualStrings("", outcome.config.web.password_hash orelse "");
}
test "reading the settings with no database is unavailable" {