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