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
-141
View File
@@ -1,141 +0,0 @@
//! First-start seeding (PLAN §3.5).
//!
//! A policy wrapper over `import.importFile`, and nothing more. There is exactly
//! one code path from a config file into the database, so bootstrap and
//! `nxdns import` cannot drift apart.
//!
//! The policy is three lines long:
//!
//! - no file → normal steady state, keep the database as it is;
//! - database already configured → the file is ignored, as PLAN §3.5 requires.
//! Configured means an operator put something there. A database that has only
//! answered queries is not configured, however many client rows the DNS path
//! materialised into it, and `import.isEmpty` is where that line is drawn;
//! - otherwise → import it, and a file that is unreadable, unparseable or
//! invalid is an error. The operator wrote that file and meant it; starting
//! with silent defaults instead is the exact failure mode PLAN §1.3 exists to
//! prevent.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../storage/db.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");
const log = std.log.scoped(.config_bootstrap);
pub const Outcome = enum { seeded, db_already_configured, no_config_file };
pub const Error = import.Error || std.Io.Dir.AccessError;
/// Called by `nxdns run` before serving.
pub fn bootstrap(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
config_path: []const u8,
diags: *validate.Diagnostics,
) Error!Outcome {
dir.access(io, config_path, .{}) catch |e| switch (e) {
error.FileNotFound => {
log.info("no configuration file at '{s}'; using the database as it is", .{config_path});
return .no_config_file;
},
else => |other| return other,
};
// Deliberately before the read: on every start after the first, the file is
// not even opened.
if (!try import.isEmpty(database)) {
log.info("configuration file ignored; the database is already configured", .{});
return .db_already_configured;
}
try import.importFile(io, gpa, database, dir, config_path, .{ .force = false }, diags);
log.info("seeded the database from '{s}'", .{config_path});
return .seeded;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
//
// All three outcomes are exercised end to end in
// `src/storage/storage_integration_test.zig` (S7) against a real data directory.
// What the two cases below add is the one distinction that decides which outcome
// an operator gets, and it is too important to leave behind a `-Dintegration`
// flag: whether the database has been *configured*, not whether it has been
// *used*.
const testing = std.testing;
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const migrations = @import("../storage/migrations.zig");
const seed_source =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// Unparseable on purpose: a call that succeeds proves the file was never read.
const broken_source = ".{ .groups = ";
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
test "a server that has answered queries still seeds from its configuration file" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = seed_source });
var database = try openMigrated();
defer database.close();
// The unattended first boot: the server came up on defaults, answered
// traffic, and the operator dropped a config file in afterwards.
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.seeded, outcome);
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM upstreams"));
// Seeding did not cost the operator the device list they had been watching.
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
test "a client the operator has customised keeps the configuration file out" {
const io = testing.io;
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "config.zon", .data = broken_source });
var database = try openMigrated();
defer database.close();
try clients_repo.upsertSeen(&database, "192.168.1.5", 1700000000);
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.5'");
try clients_repo.updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
const outcome = try bootstrap(io, testing.allocator, &database, tmp.dir, "config.zon", &diags);
try testing.expectEqual(Outcome.db_already_configured, outcome);
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
// The name and the flag the operator set are still theirs.
try testing.expectEqual(@as(i64, 1), try database.queryInt(
"SELECT count(*) FROM clients WHERE name = 'tv' AND hand_edited = 1",
));
}
+57 -10
View File
@@ -34,7 +34,7 @@ pub const Error = ReadError || Writer.Error ||
const header =
\\// nxdns configuration
\\// generated by `nxdns export` the database is the source of truth
\\// generated by `nxdns export` from the running configuration
\\
;
@@ -64,11 +64,13 @@ pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
cfg.local_records = (try local_repo.listLocalRecords(database, arena)).items;
cfg.forward_zones = (try local_repo.listForwardZones(database, arena)).items;
// `web.password` is operator input and is never stored; the exported file
// always carries an empty one. This is exactly what makes the round trip
// stable: re-importing takes the "password is empty" branch and stores the
// same hash.
cfg.web.password = "";
// `web.password` is operator input and is never stored, so the exported
// file always states it as absent. Absent rather than `""`: a present empty
// password is refused by `validate` (ruling 4), so exporting one would make
// every export fail its own rules. It is also what makes the round trip
// stable — re-applying the file takes the "password_hash written verbatim"
// branch and stores the same hash.
cfg.web.password = null;
return cfg;
}
@@ -250,7 +252,7 @@ test "readConfig, writeConfig, import and readConfig again produce an equal conf
try testing.expectEqual(a.dns.port, b.dns.port);
try testing.expectEqual(a.logging.level, b.logging.level);
try testing.expectEqualStrings(a.web.password_hash, b.web.password_hash);
try testing.expectEqualStrings(a.web.password_hash.?, b.web.password_hash.?);
try testing.expectEqual(a.groups.len, b.groups.len);
try testing.expectEqual(a.upstreams.len, b.upstreams.len);
for (a.upstreams, b.upstreams) |left, right| {
@@ -303,12 +305,57 @@ test "an exported password_hash survives a re-import unchanged" {
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try import.apply(io, gpa, &database, cfg, 42, .{}, &diags);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const exported = try readConfig(&database, arena_state.allocator());
try testing.expectEqualStrings("", exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash, "$argon2id$"));
try testing.expectEqual(@as(?[]const u8, null), exported.web.password);
try testing.expect(std.mem.startsWith(u8, exported.web.password_hash.?, "$argon2id$"));
}
test "the exported password form is the one validate accepts" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var database = try openMigrated();
defer database.close();
var apply_diags: validate.Diagnostics = .init(gpa);
defer apply_diags.deinit();
try import.apply(io, gpa, &database, .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
}, 42, .{}, &apply_diags);
var out: Writer.Allocating = .init(gpa);
defer out.deinit();
try writeToWriter(gpa, &database, &out.writer);
// The literal form matters: an export carrying `password = ""` beside a
// stored hash would trip `EmptyWebPassword` on the way back in, so export
// would produce a file its own validator refuses.
try testing.expect(std.mem.indexOf(u8, out.written(), ".password = null,") != null);
const source = try gpa.dupeZ(u8, out.written());
defer gpa.free(source);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const reparsed = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
source,
null,
.{},
);
var diags: validate.Diagnostics = .init(gpa);
defer diags.deinit();
try validate.validate(reparsed, &diags);
try testing.expectEqual(@as(?[]const u8, null), reparsed.web.password);
try testing.expect(std.mem.startsWith(u8, reparsed.web.password_hash.?, "$argon2id$"));
}
+27 -10
View File
@@ -13,17 +13,26 @@ const validate = @import("validate.zig");
/// `ValidateError` enters as a whole set rather than variant by variant, so a
/// variant added to the validator cannot silently fall through to exit 1. The
/// four extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the seed-file size limit (`ConfigTooLarge`), the
/// composition root's upstream build (`NoUsableUpstreams`) and its certificate
/// load (`BadCertificate`).
/// five extras are the configuration faults raised outside the validator: the
/// ZON reader (`ParseZon`), the file size limit (`ConfigTooLarge`), the managed
/// file the operator named and this process cannot open
/// (`ManagedConfigUnreadable`, milestone-20 ruling 2), the composition root's
/// upstream build (`NoUsableUpstreams`) and its certificate load
/// (`BadCertificate`).
///
/// Not here on purpose: `error.DatabaseNotEmpty`, which reports the state of
/// the database rather than the content of a file, and is the one config-shaped
/// exit 2 `cli` decides for itself.
/// `ManagedConfigUnreadable` is the only place a missing or unreadable path is a
/// configuration fault, and it is deliberately not `FileNotFound` itself: the
/// operator named that path on the command line, so it is theirs to fix, while a
/// missing file anywhere else stays a runtime failure. `config/loader.zig` owns
/// the conversion and the closed set of open errors that qualify.
///
/// Not here on purpose: `error.DestructiveImport`, which reports what an import
/// would do to the database rather than the content of a file, and is the one
/// config-shaped exit 2 `cli` decides for itself.
const ConfigFault = validate.ValidateError || error{
ParseZon,
ConfigTooLarge,
ManagedConfigUnreadable,
NoUsableUpstreams,
BadCertificate,
};
@@ -94,6 +103,14 @@ test "the faults raised outside the validator are configuration faults" {
try testing.expect(isConfigFault(error.BadCertificate));
}
test "a managed file the operator named and this process cannot open is exit 2" {
// Ruling 2. The general rule below still holds — a bare `FileNotFound` is a
// runtime failure — and this is the one converted form, produced only by
// `config/loader.zig` for a path `--config` named.
try testing.expect(isConfigFault(error.ManagedConfigUnreadable));
try testing.expect(!isConfigFault(error.FileNotFound));
}
test "the seed-file errors that used to exit 1 from run are configuration faults" {
// D1 verbatim: these three reached `run` from a rejected seed file and were
// classified as runtime failures.
@@ -107,9 +124,9 @@ test "a runtime failure is not a configuration fault" {
try testing.expect(!isConfigFault(error.AccessDenied));
try testing.expect(!isConfigFault(error.FileNotFound));
try testing.expect(!isConfigFault(error.AddressInUse));
// A state conflict, not a bad file: `import` refuses to overwrite a
// configured database and decides that exit code itself.
try testing.expect(!isConfigFault(error.DatabaseNotEmpty));
// A verdict on the diff, not on the file: `import` refuses a run that would
// delete rows and decides that exit code itself.
try testing.expect(!isConfigFault(error.DestructiveImport));
// Only ever a warning, so it never reaches an exit code by this route.
try testing.expect(!isConfigFault(error.SourceInNoGroup));
}
+268 -675
View File
File diff suppressed because it is too large Load Diff
+323
View File
@@ -0,0 +1,323 @@
//! The one way a configuration file becomes a `model.Config`.
//!
//! `nxdns run --config <file>` and `nxdns check --config <file>` must grade the
//! same file the same way, so the read, the error classification and the parse
//! live here rather than once per subcommand. `config/faults.zig` exists for the
//! same reason one layer up: two copies of a rule are two rules.
//!
//! **The classification.** `faults.isConfigFault` deliberately excludes
//! `FileNotFound` and `AccessDenied` in general — a missing file is usually a
//! broken box, not a wrong configuration. The managed file is the one place
//! where the opposite holds: the operator named that path, so a path that does
//! not resolve is a configuration fault (exit 2, `nxdns check` is the next
//! step). Only the path class converts:
//!
//! `FileNotFound`, `AccessDenied`, `PermissionDenied`, `NotDir`, `IsDir`,
//! `SymLinkLoop`, `NameTooLong`, `BadPathName` → `ManagedConfigUnreadable`
//!
//! Everything else `readFileAllocOptions` can return — `SystemResources`, the
//! two fd-quota errors, I/O failures, `OutOfMemory` — propagates unmapped and
//! exits 1. Those are box faults a retry can clear, and the shipped unit carries
//! `RestartPreventExitStatus=2 64`: mapping a transient failure to exit 2 would
//! stop the service permanently on a fault that would have cleared itself.
//!
//! The mapping is a named error set switched exhaustively with
//! `else => |other| return other`, so an error a Zig upgrade adds to
//! `ReadFileAllocError` defaults to exit 1 rather than silently to exit 2.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("model.zig");
const validate = @import("validate.zig");
/// The ceiling on a configuration file. A file above it is a configuration
/// fault, not a resource failure: nothing an operator writes by hand comes near
/// 4 MiB, so this is a typo or a wrong path rather than a real config.
pub const max_config_bytes = 4 * 1024 * 1024;
/// Every error `readFileAllocOptions` can hand back, plus the parse.
pub const ReadError = std.Io.Dir.ReadFileAllocError;
/// The open failures that mean the operator's path is wrong rather than the box
/// being broken. Spelled out as a set rather than as switch prongs so that the
/// list is one thing a reader can find and a test can enumerate.
pub const PathFault = error{
FileNotFound,
AccessDenied,
PermissionDenied,
NotDir,
IsDir,
SymLinkLoop,
NameTooLong,
BadPathName,
};
pub const Error = ReadError || error{ ManagedConfigUnreadable, ConfigTooLarge, ParseZon };
/// The classification itself, pure and testable on its own: a path-class
/// failure becomes `ManagedConfigUnreadable`, the size limit becomes
/// `ConfigTooLarge`, and every other member travels unchanged.
pub fn mapReadError(e: ReadError) Error {
return switch (e) {
error.StreamTooLong => error.ConfigTooLarge,
error.FileNotFound,
error.AccessDenied,
error.PermissionDenied,
error.NotDir,
error.IsDir,
error.SymLinkLoop,
error.NameTooLong,
error.BadPathName,
=> error.ManagedConfigUnreadable,
else => |other| other,
};
}
/// The file, NUL-terminated because `std.zon.parse` needs a sentinel and
/// `readFileAlloc` cannot supply one. Errors travel exactly as the filesystem
/// returned them: `nxdns import` reads an operator-supplied argument, not a
/// managed file, and its exit codes are its own.
pub fn readSource(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
) ReadError![:0]u8 {
return dir.readFileAllocOptions(io, path, gpa, .limited(max_config_bytes), .of(u8), 0);
}
/// `readSource` under the managed-file classification, with the reason recorded
/// as a diagnostic. A Zig error carries no text, so the path an operator has to
/// go and fix reaches them through `Diagnostics` — the same channel every other
/// configuration problem travels down, and the reason `check` and `run` print
/// these in one shape.
pub fn readManaged(
io: std.Io,
gpa: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error![:0]u8 {
return readSource(io, gpa, dir, path) catch |e| {
switch (e) {
error.StreamTooLong => try diags.add(
error.ConfigTooLarge,
"{s}",
.{path},
"larger than {d} bytes",
.{max_config_bytes},
),
error.FileNotFound => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"no such file",
.{},
),
error.AccessDenied, error.PermissionDenied => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"not readable",
.{},
),
error.IsDir => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"is a directory, not a configuration file",
.{},
),
error.NotDir, error.SymLinkLoop, error.NameTooLong, error.BadPathName => try diags.add(
error.ManagedConfigUnreadable,
"{s}",
.{path},
"cannot be opened ({s})",
.{@errorName(e)},
),
// A box fault. It exits 1 with its own name and records nothing: a
// diagnostic would file it under "the configuration is wrong".
else => {},
}
return mapReadError(e);
};
}
/// The line and column of a ZON syntax error are the only thing the operator can
/// act on, so they travel the same channel as every other config problem: the
/// caller's `Diagnostics`, which `run`, `check` and `import` all render. The
/// global log is not that channel — an operator reading command output would see
/// a bare `ParseZon` and nothing else.
///
/// `std.zon.parse.Diagnostics` renders one "line:column: error: text" line per
/// problem, plus a "note:" line each, so each rendered line becomes one
/// `Problem` and the list keeps the parser's order. Rendering them inline would
/// put newlines inside a single `FAIL` record.
pub fn reportParseFailure(
diags: *validate.Diagnostics,
zon_diag: *const std.zon.parse.Diagnostics,
) error{OutOfMemory}!void {
const rendered = try std.fmt.allocPrint(diags.gpa, "{f}", .{zon_diag});
defer diags.gpa.free(rendered);
var lines = std.mem.splitScalar(u8, rendered, '\n');
while (lines.next()) |line| {
if (line.len == 0) continue;
try diags.add(error.ParseZon, "config", .{}, "{s}", .{line});
}
}
/// The parse, with its failure rendered. The result is arena-owned and
/// `std.zon.parse.free` is NEVER called on it: `Parser.parseStruct` fills an
/// absent field by copying the struct's default straight through
/// (parse.zig:874), so a defaulted `[]const u8` — and this model has many
/// non-empty string defaults — points into the binary's read-only data.
/// `parse.free` keeps no record of which fields were parsed and which were
/// defaulted, so it would `@memset` and free rodata. Freeing the arena is the
/// only correct release.
pub fn parse(
arena: Allocator,
source: [:0]const u8,
diags: *validate.Diagnostics,
) error{ ParseZon, OutOfMemory }!model.Config {
var zon_diag: std.zon.parse.Diagnostics = .{};
return std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.ParseZon => {
try reportParseFailure(diags, &zon_diag);
return error.ParseZon;
},
};
}
/// Read and parse the managed file, everything a caller needs before
/// `validate.validate`. Validation is deliberately left to the caller: `check`
/// runs it beside its certificate and upstream probes, `run` runs it alone, and
/// both call the same validator on the same `Config`, which is what makes the
/// two agree.
///
/// `arena` owns both the source text and the returned configuration.
pub fn load(
io: std.Io,
arena: Allocator,
dir: std.Io.Dir,
path: []const u8,
diags: *validate.Diagnostics,
) Error!model.Config {
const source = try readManaged(io, arena, dir, path, diags);
return parse(arena, source, diags);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "every path-class open failure is a managed-config fault" {
inline for (@typeInfo(PathFault).error_set.?) |member| {
const e = @field(ReadError, member.name);
try testing.expectEqual(error.ManagedConfigUnreadable, mapReadError(e));
}
}
test "a box fault outside the path class propagates unmapped" {
// Each of these exits 1: a retry can clear them, and the shipped unit's
// `RestartPreventExitStatus=2 64` would make exit 2 permanent.
try testing.expectEqual(error.SystemResources, mapReadError(error.SystemResources));
try testing.expectEqual(error.ProcessFdQuotaExceeded, mapReadError(error.ProcessFdQuotaExceeded));
try testing.expectEqual(error.SystemFdQuotaExceeded, mapReadError(error.SystemFdQuotaExceeded));
try testing.expectEqual(error.OutOfMemory, mapReadError(error.OutOfMemory));
try testing.expectEqual(error.InputOutput, mapReadError(error.InputOutput));
}
test "the size limit is its own fault, not an unreadable path" {
try testing.expectEqual(error.ConfigTooLarge, mapReadError(error.StreamTooLong));
}
test "the path class is exactly the eight members the ruling names" {
// A member added to `PathFault` without a decision recorded in the spec
// fails here rather than quietly moving an exit code from 1 to 2.
const expected = [_][]const u8{
"FileNotFound", "AccessDenied", "PermissionDenied", "NotDir",
"IsDir", "SymLinkLoop", "NameTooLong", "BadPathName",
};
const members = @typeInfo(PathFault).error_set.?;
try testing.expectEqual(expected.len, members.len);
inline for (members) |member| {
var found = false;
for (expected) |name| {
if (std.mem.eql(u8, name, member.name)) found = true;
}
try testing.expect(found);
}
}
test "a missing managed file records the path and the reason" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "nope.zon", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
try testing.expectEqualStrings("nope.zon", diags.problems.items[0].path);
try testing.expectEqualStrings("no such file", diags.problems.items[0].message);
}
test "a directory named as the managed file is a configuration fault, not a crash" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.createDirPath(testing.io, "sub");
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
try testing.expectError(
error.ManagedConfigUnreadable,
load(testing.io, arena_state.allocator(), tmp.dir, "sub", &diags),
);
try testing.expectEqual(@as(usize, 1), diags.failureCount());
}
test "load parses a valid file and renders a syntax error line by line" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(testing.io, .{ .sub_path = "good.zon", .data =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
});
try tmp.dir.writeFile(testing.io, .{ .sub_path = "bad.zon", .data = ".{ .groups = " });
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
var good_diags: validate.Diagnostics = .init(testing.allocator);
defer good_diags.deinit();
const cfg = try load(testing.io, arena, tmp.dir, "good.zon", &good_diags);
try testing.expectEqual(@as(usize, 0), good_diags.problems.items.len);
try testing.expectEqual(@as(usize, 1), cfg.upstreams.len);
var bad_diags: validate.Diagnostics = .init(testing.allocator);
defer bad_diags.deinit();
try testing.expectError(
error.ParseZon,
load(testing.io, arena, tmp.dir, "bad.zon", &bad_diags),
);
try testing.expect(bad_diags.failureCount() >= 1);
try testing.expectEqualStrings("config", bad_diags.problems.items[0].path);
}
+76 -11
View File
@@ -87,10 +87,16 @@ pub const Web = struct {
enabled: bool = true,
bind: []const u8 = "0.0.0.0",
port: u16 = 8080,
/// Operator input only. Never a settings row, always exported as "".
password: []const u8 = "",
/// argon2id PHC string; "" disables authentication.
password_hash: []const u8 = "",
/// Operator input only. Never a settings row, never exported.
///
/// Optional because absence and emptiness are different declarations: null
/// means "the file says nothing about the password, keep the stored hash",
/// while a present value is an instruction to set one.
password: ?[]const u8 = null,
/// argon2id PHC string. Null means "the file says nothing, keep what is
/// stored"; an explicit `""` is the documented way to disable
/// authentication.
password_hash: ?[]const u8 = null,
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
/// Requests from the box itself skip the API rate limit. On by default: a
@@ -387,9 +393,23 @@ fn isScalarSection(comptime T: type) bool {
return @typeInfo(T) == .@"struct";
}
/// `web.password` is operator input, never a settings row: it is hashed into
/// `web.password_hash` at import time and discarded (S2.5).
fn isSkipped(comptime section: []const u8, comptime field: []const u8) bool {
/// The skip policy splits by direction, because encode and decode need
/// different sets.
///
/// `web.password` is operator input and is skipped both ways: it is hashed into
/// `web.password_hash` and discarded (S2.5).
///
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
/// "keep the stored hash", which a general encode pass cannot express. Skipping
/// it on decode as well would leave `cfg.web.password_hash` null on every read
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
if (!std.mem.eql(u8, section, "web")) return false;
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
}
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
@@ -416,6 +436,10 @@ fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
// A stored key is a present value, so an optional field decodes to a
// non-null one; the null stays reserved for the absent key, which never
// reaches this function at all.
.optional => |info| try decodeValue(info.child, text),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
@@ -441,7 +465,7 @@ pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair))
if (comptime isScalarSection(section_field.type)) {
const section = @field(cfg, section_field.name);
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
@@ -465,7 +489,7 @@ pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usi
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isDecodeSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
@@ -531,7 +555,6 @@ const expected_keys = [_][]const u8{
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.password_hash",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
@@ -642,7 +665,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
if (comptime isScalarSection(section_field.type)) {
inline for (@typeInfo(section_field.type).@"struct".fields) |field| {
if (comptime !isSkipped(section_field.name, field.name)) {
if (comptime !isEncodeSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
@@ -671,6 +694,48 @@ test "an unknown settings key is counted and not an error" {
try testing.expectEqual(@as(usize, 2), unknown);
}
test "web.password_hash decodes from the settings table but is never encoded" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
// Encode: the reconciler owns that row, so no pass over the model emits it.
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
// Decode: every read path still sees the stored hash, or `authEnabled`
// would read false on a box that has a password set.
var cfg: Config = .{};
var unknown: usize = 0;
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
try fromSettings(&stored, &cfg, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
}
test "an optional settings field is null when absent and non-null when present" {
var absent: Config = .{};
var unknown: usize = 0;
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&other, &absent, &unknown);
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
// An explicit empty string is a present value, not an absent key: it is how
// a config file disables authentication.
var empty: Config = .{};
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
try fromSettings(&disabled, &empty, &unknown);
try testing.expect(empty.web.password_hash != null);
try testing.expectEqualStrings("", empty.web.password_hash.?);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
File diff suppressed because it is too large Load Diff
+56 -2
View File
@@ -101,6 +101,7 @@ pub const ValidateError = error{
MissingKeyPath,
MissingLogPath,
PasswordAndHashBothSet,
EmptyWebPassword,
};
/// What `validate` returns: a verdict on the configuration, or the allocation
@@ -114,7 +115,19 @@ pub const Error = ValidateError || Allocator.Error;
/// same channel so a syntax error's line/column reaches the operator's output,
/// plus the warnings — which are never returned by `validate` and so are not
/// `ValidateError` members.
pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup };
/// Wider than `ValidateError`: the diagnostic channel also carries the problems
/// found before the validator ever sees a `Config` — the ZON parse, the managed
/// file that would not open (`config/loader.zig`), the file above the size limit
/// — and the one found after it, an import whose diff would delete rows. The
/// validator itself records only `ValidateError` members, which is what makes
/// `validate`'s `@errorCast` of its own findings checked-safe.
pub const ProblemError = ValidateError || error{
ParseZon,
SourceInNoGroup,
ManagedConfigUnreadable,
ConfigTooLarge,
DestructiveImport,
};
/// `.fail` rejects the configuration and is what an exit code is computed from.
/// `.warn` reports something legal that is almost certainly not what the
@@ -392,7 +405,10 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
try checkBind(diags, cfg.web.bind, "web.bind", .any);
try checkPort(diags, cfg.web.port, "web.port");
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
// Both fields are optional, and absence is the third state: a file that
// states neither keeps the stored hash. So the test is on presence, not on
// length.
if (cfg.web.password != null and cfg.web.password_hash != null) {
try diags.add(
error.PasswordAndHashBothSet,
"web.password",
@@ -401,6 +417,22 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
.{},
);
}
// A present-but-empty password would hash the empty string into a non-empty
// PHC — authentication on — while every login with an empty password is
// refused: authentication on and unreachable. The remedy is named, because
// the operator who wrote this meant one of two other things.
if (cfg.web.password) |password| {
if (password.len == 0) {
try diags.add(
error.EmptyWebPassword,
"web.password",
.{},
"password is set to the empty string; omit the field to keep the stored password, " ++
"or set password_hash = \"\" to disable authentication",
.{},
);
}
}
// A session TTL is a TTL; `BadTtl` is its bucket.
if (cfg.web.session_ttl_hours < 1) {
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
@@ -1968,6 +2000,28 @@ test "error.PasswordAndHashBothSet" {
try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
}
test "error.EmptyWebPassword names password_hash as the way to disable auth" {
var cfg = baseConfig();
cfg.web.password = "";
try expectProblem(cfg, error.EmptyWebPassword, "web.password");
// The remedy has to be in the text: the operator who wrote `password = ""`
// meant either "keep the current one" or "turn authentication off", and the
// diagnostic is the only place that distinction is spelled out.
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.EmptyWebPassword, validate(cfg, &diags));
try testing.expect(std.mem.indexOf(u8, diags.problems.items[0].message, "password_hash = \"\"") != null);
// Absence is not emptiness: a file that states no password is legal and
// means "keep the stored hash".
var absent = baseConfig();
absent.web.password = null;
var quiet: Diagnostics = .init(testing.allocator);
defer quiet.deinit();
try validate(absent, &quiet);
}
test "a config with five distinct problems yields five diagnostics and the first error" {
var cfg = baseConfig();
cfg.dns.port = 0; // BadPort, first in check order