storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli

This commit is contained in:
2026-08-01 14:21:44 +02:00
parent 17d0401f8a
commit 70bff22d75
23 changed files with 10142 additions and 47 deletions
+60
View File
@@ -0,0 +1,60 @@
//! 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;
//! - 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;
}
// Every path through `bootstrap` starts with a filesystem access, so all three
// outcomes are exercised in `src/storage/storage_integration_test.zig` (S7)
// against real files. There is nothing here that an in-memory test could reach.
+309
View File
@@ -0,0 +1,309 @@
//! `nxdns export`: the database rendered back as canonical ZON.
//!
//! Deterministic by construction. Every list arrives through a repository whose
//! `ORDER BY` ends in a unique column set, every scalar comes from the settings
//! map, and the header carries no timestamp, version or host name. That is what
//! makes `export` → `import` → `export` byte-identical, and it keeps a config
//! diff free of noise.
//!
//! Runtime columns are absent from the model on purpose, so two exports taken
//! minutes apart on a live server are identical too.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const db = @import("../storage/db.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const settings_repo = @import("../storage/repositories/settings_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
const model = @import("model.zig");
const log = std.log.scoped(.config_export);
/// What reading the database into a `Config` can fail with. A caller that only
/// wants the value never has to handle a filesystem error.
pub const ReadError = db.Error || model.SettingsError;
pub const Error = ReadError || Writer.Error ||
std.Io.Dir.CreateFileAtomicError || std.Io.File.SyncError || std.Io.File.Atomic.ReplaceError;
const header =
\\// nxdns configuration
\\// generated by `nxdns export` — the database is the source of truth
\\
;
/// Reads every repository into `arena` and assembles a `Config`. Every string in
/// the result belongs to `arena`; there is nothing else to free.
pub fn readConfig(database: *db.Db, arena: Allocator) ReadError!model.Config {
var cfg: model.Config = .{};
// The scalar sections first: an absent key keeps the model default, which is
// how a migration adds a setting with no data step. Unknown keys are counted
// and logged rather than refused — downgrading a binary must not make a
// config database unreadable.
const settings = try settings_repo.listSettings(database, arena);
var unknown_keys: usize = 0;
try model.fromSettings(settings.items, &cfg, &unknown_keys);
if (unknown_keys != 0) {
log.warn("{d} unknown settings key(s) were ignored while exporting", .{unknown_keys});
}
cfg.groups = (try groups_repo.listGroups(database, arena)).items;
cfg.upstreams = (try upstreams_repo.listUpstreams(database, arena)).items;
cfg.clients = (try clients_repo.listClients(database, arena)).items;
cfg.client_prefixes = (try clients_repo.listClientPrefixes(database, arena)).items;
cfg.blocklist_sources = (try sources_repo.listBlocklistSources(database, arena)).items;
cfg.group_sources = (try groups_repo.listGroupSources(database, arena)).items;
cfg.rules = (try rules_repo.listRules(database, arena)).items;
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 = "";
return cfg;
}
/// Canonical ZON: the fixed header, then the value with every default emitted.
/// Emitting defaults makes the file a complete record of the running
/// configuration and makes the round trip independent of a later change to a
/// default value.
pub fn writeConfig(cfg: model.Config, w: *Writer) Writer.Error!void {
try w.writeAll(header);
try std.zon.stringify.serialize(cfg, .{
.whitespace = true,
.emit_default_optional_fields = true,
}, w);
try w.writeAll("\n");
}
pub fn writeToWriter(gpa: Allocator, database: *db.Db, w: *Writer) Error!void {
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const cfg = try readConfig(database, arena_state.allocator());
return writeConfig(cfg, w);
}
/// Atomic and owner-only. The exported file carries `web.password_hash`, so 0600
/// is not optional.
///
/// `createFileAtomic` puts its temporary file in the destination's own directory
/// (verified in `Io/Threaded.zig`: `atomicFileInit` receives either `dir` or the
/// directory opened on `dirname(dest_path)`), so the final `replace` is a
/// same-filesystem `rename` and is genuinely atomic.
pub fn writeToFile(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
path: []const u8,
) Error!void {
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const cfg = try readConfig(database, arena_state.allocator());
var af = try dir.createFileAtomic(io, path, .{
.permissions = .fromMode(0o600),
.replace = true,
});
defer af.deinit(io);
var buf: [4096]u8 = undefined;
var fw = af.file.writer(io, &buf);
writeConfig(cfg, &fw.interface) catch |e| return reportWriteFailure(&fw, e);
fw.interface.flush() catch |e| return reportWriteFailure(&fw, e);
// Before `replace`, which closes the file: the rename must publish durable
// bytes, not an empty file with the content still in the page cache.
try af.file.sync(io);
try af.replace(io);
}
/// `Writer.Error` is a single `error.WriteFailed`; the cause lives on the
/// `File.Writer`. Logging it at `warn` is what turns "export failed" into
/// something an operator can act on.
fn reportWriteFailure(fw: *std.Io.File.Writer, e: Writer.Error) Writer.Error {
if (fw.err) |cause| log.warn("writing the export failed: {s}", .{@errorName(cause)});
return e;
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const migrations = @import("../storage/migrations.zig");
const import = @import("import.zig");
const validate = @import("validate.zig");
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;
}
const seed_source: [:0]const u8 =
\\.{
\\ .dns = .{ .port = 5353 },
\\ .logging = .{ .level = .err, .retention_days = 7 },
\\ .web = .{ .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$c2FsdHNhbHQ$aGFzaGhhc2g" },
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
\\ .upstreams = .{
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false },
\\ },
\\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
\\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
\\ .rules = .{
\\ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block },
\\ .{ .group = "default", .pattern = "allowed.example", .kind = .exact, .action = .allow },
\\ },
\\ .local_records = .{
\\ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 },
\\ .{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::10" },
\\ },
\\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
\\}
;
fn seed(io: std.Io, database: *db.Db, source: [:0]const u8) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return import.importSource(io, testing.allocator, database, source, .{}, &diags);
}
test "writeConfig emits the fixed header and re-parses into an equal config" {
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();
try seed(io, &database, seed_source);
var out: Writer.Allocating = .init(gpa);
defer out.deinit();
try writeToWriter(gpa, &database, &out.writer);
// The header is fixed text. Anything variable in it — a timestamp, a version
// or a host name — would break the byte-stable round trip.
const text = out.written();
try testing.expect(std.mem.startsWith(u8, text, header));
try testing.expectEqualStrings(header, text[0..header.len]);
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const source = try gpa.dupeZ(u8, text);
defer gpa.free(source);
const reparsed = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
source,
null,
.{},
);
try testing.expectEqual(@as(u16, 5353), reparsed.dns.port);
try testing.expectEqual(model.LogLevel.err, reparsed.logging.level);
}
test "readConfig, writeConfig, import and readConfig again produce an equal config" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var first = try openMigrated();
defer first.close();
try seed(io, &first, seed_source);
var text: Writer.Allocating = .init(gpa);
defer text.deinit();
try writeToWriter(gpa, &first, &text.writer);
const source = try text.toOwnedSliceSentinel(0);
defer gpa.free(source);
var second = try openMigrated();
defer second.close();
try seed(io, &second, source);
var arena_a: std.heap.ArenaAllocator = .init(gpa);
defer arena_a.deinit();
var arena_b: std.heap.ArenaAllocator = .init(gpa);
defer arena_b.deinit();
const a = try readConfig(&first, arena_a.allocator());
const b = try readConfig(&second, arena_b.allocator());
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.expectEqual(a.groups.len, b.groups.len);
try testing.expectEqual(a.rules.len, b.rules.len);
try testing.expectEqualStrings(a.clients[0].ip, b.clients[0].ip);
try testing.expectEqualStrings(a.forward_zones[0].resolver, b.forward_zones[0].resolver);
}
test "export is byte-stable across a re-import" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const gpa = testing.allocator;
var first = try openMigrated();
defer first.close();
try seed(io, &first, seed_source);
var a: Writer.Allocating = .init(gpa);
defer a.deinit();
try writeToWriter(gpa, &first, &a.writer);
const source = try gpa.dupeZ(u8, a.written());
defer gpa.free(source);
var second = try openMigrated();
defer second.close();
try seed(io, &second, source);
var b: Writer.Allocating = .init(gpa);
defer b.deinit();
try writeToWriter(gpa, &second, &b.writer);
try testing.expectEqualStrings(a.written(), b.written());
}
test "an exported password_hash survives a re-import unchanged" {
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();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try import.applyToDb(io, gpa, &database, cfg, 42, .{});
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$"));
}
+621
View File
@@ -0,0 +1,621 @@
//! `nxdns import`: a ZON file becomes the whole content of `config.db`.
//!
//! The order is the specification. Nothing reaches the database until the file
//! has been read, parsed and validated, and every write happens inside one
//! `BEGIN IMMEDIATE` transaction, so a failed import leaves the database
//! byte-for-byte as it was.
//!
//! No filesystem write happens anywhere in this file: the input is opened
//! read-only and the database is SQLite's business.
const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../storage/db.zig");
const config_schema = @import("../storage/config_schema.zig");
const context = @import("../storage/repositories/context.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const groups_repo = @import("../storage/repositories/groups_repo.zig");
const local_repo = @import("../storage/repositories/local_repo.zig");
const rules_repo = @import("../storage/repositories/rules_repo.zig");
const settings_repo = @import("../storage/repositories/settings_repo.zig");
const sources_repo = @import("../storage/repositories/sources_repo.zig");
const upstreams_repo = @import("../storage/repositories/upstreams_repo.zig");
const address = @import("../platform/address.zig");
const model = @import("model.zig");
const validate = @import("validate.zig");
const log = std.log.scoped(.config_import);
pub const Options = struct { force: bool = false };
pub const Error = db.Error || validate.ValidateError || std.Io.Dir.ReadFileAllocError ||
error{ DatabaseNotEmpty, ConfigTooLarge, ParseZon, PasswordAndHashBothSet };
pub const max_config_bytes = 4 * 1024 * 1024;
/// Holds any PHC-encoded argon2id string comfortably.
const hash_buf_len = 256;
/// The canonical text of an IPv6 prefix, the longest value canonicalised here.
const canonical_buf_len = 64;
// ---------------------------------------------------------------------------
// emptiness
// ---------------------------------------------------------------------------
/// A database is "never configured" when the migrations have run and nothing
/// else has. The migrations themselves create `schema_version` and seed
/// `groups(1, 'default')`, so "no rows anywhere" is the wrong test.
///
/// True iff every table in `config_schema.content_tables` is empty, `groups`
/// holds exactly one row, and that row is the seeded `(1, 'default', 0)`.
///
/// The client count here includes auto-materialised rows: a server that has
/// answered one query is configured enough that a bootstrap file must not
/// overwrite it.
pub fn isEmpty(database: *db.Db) db.Error!bool {
// `inline for` over a comptime table list: every statement below is a
// compile-time string, so no table name is ever concatenated at run time.
inline for (config_schema.content_tables) |table| {
if (try database.queryInt("SELECT count(*) FROM " ++ table) != 0) return false;
}
if (try database.queryInt("SELECT count(*) FROM groups") != 1) return false;
const seeded = try database.queryInt(
"SELECT count(*) FROM groups WHERE id = 1 AND name = 'default' AND safe_search = 0",
);
return seeded == 1;
}
// ---------------------------------------------------------------------------
// import
// ---------------------------------------------------------------------------
/// Reads, parses, validates, then replaces the database contents.
pub fn importFile(
io: std.Io,
gpa: Allocator,
database: *db.Db,
dir: std.Io.Dir,
path: []const u8,
options: Options,
diags: *validate.Diagnostics,
) Error!void {
// `std.zon.parse` needs a sentinel-terminated source and `readFileAlloc`
// cannot supply one.
const source = dir.readFileAllocOptions(
io,
path,
gpa,
.limited(max_config_bytes),
.of(u8),
0,
) catch |e| switch (e) {
error.StreamTooLong => return error.ConfigTooLarge,
else => |other| return other,
};
defer gpa.free(source);
return importSource(io, gpa, database, source, options, diags);
}
/// `importFile` minus the file. It exists because every step from the parse
/// onwards is testable without touching a filesystem, and `nxdns check` (S6)
/// needs the same parse-and-validate half.
pub fn importSource(
io: std.Io,
gpa: Allocator,
database: *db.Db,
source: [:0]const u8,
options: Options,
diags: *validate.Diagnostics,
) Error!void {
// The parsed `Config` 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.
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
var zon_diag: std.zon.parse.Diagnostics = .{};
const cfg = std.zon.parse.fromSliceAlloc(model.Config, arena, source, &zon_diag, .{}) catch |e| switch (e) {
error.OutOfMemory => return error.OutOfMemory,
error.ParseZon => {
try reportParseFailure(diags, &zon_diag);
return error.ParseZon;
},
};
try validate.validate(cfg, diags);
const now = std.Io.Clock.real.now(io).toSeconds();
return applyToDb(io, gpa, database, cfg, now, options);
}
/// 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 `nxdns import` already renders to stderr. 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.
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 half `bootstrap` reuses: an already-parsed, already-validated config into
/// the database, all or nothing. `now` is the caller's timestamp for the runtime
/// columns the model omits.
pub fn applyToDb(
io: std.Io,
gpa: Allocator,
database: *db.Db,
cfg: model.Config,
now: i64,
options: Options,
) Error!void {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
// Inside the transaction on purpose. Checking before `BEGIN IMMEDIATE`
// would leave a TOCTOU window against a concurrently starting process;
// `BEGIN IMMEDIATE` already holds the write lock, so the check and the
// writes are one atomic unit.
if (!options.force and !try isEmpty(database)) return error.DatabaseNotEmpty;
inline for (config_schema.delete_order) |table| {
try database.exec("DELETE FROM " ++ table ++ ";");
}
var group_ids: context.IdMap = .empty;
defer group_ids.deinit(gpa);
var source_ids: context.IdMap = .empty;
defer source_ids.deinit(gpa);
try insertGroups(database, gpa, cfg, &group_ids);
try insertSources(database, gpa, cfg, &source_ids);
const ctx: context.InsertContext = .{
.now = now,
.group_ids = &group_ids,
.source_ids = &source_ids,
};
for (cfg.clients) |client| {
var buf: [canonical_buf_len]u8 = undefined;
var canonical = client;
canonical.ip = try canonicalIp(client.ip, &buf);
try clients_repo.insertClient(database, canonical, ctx);
}
for (cfg.client_prefixes) |entry| {
var buf: [canonical_buf_len]u8 = undefined;
var canonical = entry;
canonical.prefix = try canonicalPrefix(entry.prefix, &buf);
try clients_repo.insertClientPrefix(database, canonical, ctx);
}
for (cfg.upstreams) |item| try upstreams_repo.insertUpstream(database, item, ctx);
for (cfg.group_sources) |item| try groups_repo.insertGroupSource(database, item, ctx);
for (cfg.rules) |item| try rules_repo.insertRule(database, item, ctx);
for (cfg.local_records) |item| try local_repo.insertLocalRecord(database, item, ctx);
for (cfg.forward_zones) |item| try local_repo.insertForwardZone(database, item, ctx);
// The buffer must outlive `toSettings`: `effective.web.password_hash` points
// into it.
var hash_buf: [hash_buf_len]u8 = undefined;
var effective = cfg;
if (cfg.web.password.len != 0) {
if (cfg.web.password_hash.len != 0) return error.PasswordAndHashBothSet;
effective.web.password_hash = try hashPassword(io, gpa, cfg.web.password, &hash_buf);
}
// Operator input, never stored. `toSettings` skips the field in both
// directions; clearing it here keeps the in-memory value honest too.
effective.web.password = "";
var pairs: std.ArrayList(model.SettingPair) = .empty;
defer {
model.freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try model.toSettings(effective, gpa, &pairs);
for (pairs.items) |pair| try settings_repo.insertSetting(database, pair, ctx);
try tx.commit();
}
/// `default` goes in first and takes rowid 1. §11.2 seeds group 1 as `default`
/// and §7.2's fallback assignment depends on it; letting an import renumber it
/// would silently move every unassigned client.
///
/// The repositories expose no insert-with-id, so the id is taken rather than
/// given: SQLite assigns rowid 1 to the first row of an empty table, and the
/// table was emptied a few statements ago. The result is checked, not assumed.
fn insertGroups(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void {
const default_index = indexOfGroup(cfg.groups, "default") orelse {
log.warn("the config declares no group named 'default'", .{});
return error.MissingDefaultGroup;
};
try insertGroup(database, gpa, cfg.groups[default_index], ids);
const default_id = ids.get("default").?;
if (default_id != 1) {
log.warn("group 'default' took id {d}, not 1", .{default_id});
return error.Unexpected;
}
for (cfg.groups, 0..) |group, i| {
if (i == default_index) continue;
try insertGroup(database, gpa, group, ids);
}
}
fn insertGroup(database: *db.Db, gpa: Allocator, group: model.Group, ids: *context.IdMap) Error!void {
try groups_repo.insertGroup(database, group, .{});
// The key borrows from `cfg`, which outlives the transaction.
try ids.put(gpa, group.name, database.lastInsertRowid());
}
fn insertSources(database: *db.Db, gpa: Allocator, cfg: model.Config, ids: *context.IdMap) Error!void {
for (cfg.blocklist_sources) |item| {
try sources_repo.insertBlocklistSource(database, item, .{});
try ids.put(gpa, item.url, database.lastInsertRowid());
}
}
fn indexOfGroup(groups: []const model.Group, name: []const u8) ?usize {
for (groups, 0..) |group, i| {
if (std.mem.eql(u8, group.name, name)) return i;
}
return null;
}
/// The validator compares client addresses after canonicalisation, so the row
/// this writes must be canonical too — otherwise `fd00::1` and
/// `FD00:0:0:0:0:0:0:1` pass validation as a duplicate pair and then collide on
/// the column's `UNIQUE`.
fn canonicalIp(text: []const u8, buf: []u8) error{BadClientIp}![]const u8 {
const addr = address.NetAddress.parse(text) catch return error.BadClientIp;
var w: std.Io.Writer = .fixed(buf);
addr.format(&w) catch return error.BadClientIp;
return w.buffered();
}
fn canonicalPrefix(text: []const u8, buf: []u8) error{BadClientPrefix}![]const u8 {
const prefix = address.Prefix.parse(text) catch return error.BadClientPrefix;
var w: std.Io.Writer = .fixed(buf);
prefix.format(&w) catch return error.BadClientPrefix;
return w.buffered();
}
/// argon2id with the OWASP parameters (t=2, m=19 MiB, p=1) rather than the
/// 64 MiB `interactive_2id`, because PLAN §18 budgets under 100 MB total on a
/// Pi 5.
///
/// `strHash`'s error set reaches beyond this module's (it carries
/// `std.Thread.SpawnError` and the PHC encoding errors), so anything that is
/// neither out of memory nor a cancellation is reported as `error.Unexpected`
/// with the real cause logged.
fn hashPassword(io: std.Io, gpa: Allocator, password: []const u8, buf: []u8) Error![]const u8 {
return std.crypto.pwhash.argon2.strHash(password, .{
.allocator = gpa,
.params = .owasp_2id,
.mode = .argon2id,
.encoding = .phc,
}, buf, io) catch |e| switch (e) {
error.OutOfMemory => error.OutOfMemory,
error.Canceled => error.Canceled,
else => {
log.warn("hashing web.password failed: {s}", .{@errorName(e)});
return error.Unexpected;
},
};
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const migrations = @import("../storage/migrations.zig");
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;
}
/// Every row of every config table, rendered in a stable order. Two dumps are
/// equal exactly when the database content is.
fn dump(database: *db.Db, gpa: Allocator) ![]u8 {
var out: std.Io.Writer.Allocating = .init(gpa);
errdefer out.deinit();
const w = &out.writer;
try w.writeAll("groups\n");
var stmt = try database.prepare("SELECT id, name, safe_search FROM groups ORDER BY id");
defer stmt.deinit();
while (try stmt.step()) {
try w.print(" {d} {s} {d}\n", .{ stmt.columnInt(0), stmt.columnText(1), stmt.columnInt(2) });
}
inline for (config_schema.content_tables) |table| {
try w.print("{s}\n", .{table});
var rows = try database.prepare("SELECT * FROM " ++ table ++ " ORDER BY 1, 2");
defer rows.deinit();
const columns = db.c.sqlite3_column_count(rows.handle);
while (try rows.step()) {
var col: c_int = 0;
while (col < columns) : (col += 1) {
try w.print(" {s}", .{rows.columnText(col)});
}
try w.writeAll("\n");
}
}
return out.toOwnedSlice();
}
/// The smallest config that validates: one enabled upstream and the `default`
/// group.
const minimal_source: [:0]const u8 =
\\.{
\\ .groups = .{ .{ .name = "default" } },
\\ .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
\\}
;
/// Exercises every collection and a few non-default scalars.
const full_source: [:0]const u8 =
\\.{
\\ .dns = .{ .port = 5353 },
\\ .logging = .{ .level = .err, .retention_days = 7 },
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
\\ .upstreams = .{
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false },
\\ },
\\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
\\ .blocklist_sources = .{ .{ .url = "https://lists.example/ads.txt", .name = "ads" } },
\\ .group_sources = .{ .{ .group = "kids", .source_url = "https://lists.example/ads.txt" } },
\\ .rules = .{ .{ .group = "kids", .pattern = "*.tracker.example", .kind = .wildcard, .action = .block } },
\\ .local_records = .{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 600 } },
\\ .forward_zones = .{ .{ .zone = "lan", .resolver = "udp://192.168.1.1:53" } },
\\}
;
fn importText(io: std.Io, database: *db.Db, source: [:0]const u8, options: Options) !void {
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
return importSource(io, testing.allocator, database, source, options, &diags);
}
test "isEmpty is true on a freshly migrated database" {
var database = try openMigrated();
defer database.close();
try testing.expect(try isEmpty(&database));
}
test "isEmpty is false once a settings row exists" {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO settings (key, value) VALUES ('dns.port', '53');");
try testing.expect(!try isEmpty(&database));
}
test "isEmpty is false once an auto-materialized client exists" {
var database = try openMigrated();
defer database.close();
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('192.168.1.5', NULL, 1, 0, 1, 1);
);
try testing.expect(!try isEmpty(&database));
}
test "isEmpty is false once the seeded group is changed" {
var database = try openMigrated();
defer database.close();
try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
try testing.expect(!try isEmpty(&database));
}
test "importSource seeds a migrated database and group 'default' keeps id 1" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
try importText(io, &database, full_source, .{});
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT id FROM groups WHERE name = 'default'"));
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM upstreams"));
// The v6 client address was written in canonical form, not as typed.
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM clients WHERE ip = 'fd00::1'"));
}
test "applyToDb without force refuses a configured database and changes nothing" {
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();
try importText(io, &database, full_source, .{});
const before = try dump(&database, gpa);
defer gpa.free(before);
const second: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://other.example/dns-query" }},
};
try testing.expectError(error.DatabaseNotEmpty, applyToDb(io, gpa, &database, second, 42, .{}));
const after = try dump(&database, gpa);
defer gpa.free(after);
try testing.expectEqualStrings(before, after);
}
test "applyToDb rolls back completely when an insert fails mid-way" {
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();
try importText(io, &database, full_source, .{});
const before = try dump(&database, gpa);
defer gpa.free(before);
// Two identical local records violate `UNIQUE(name, rtype, value)`. The
// validator would catch this, which is exactly why the test calls
// `applyToDb` directly: the all-or-nothing guarantee has to hold on its own.
const broken: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://other.example/dns-query" }},
.local_records = &.{
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
.{ .name = "dup.lan", .rtype = .a, .value = "10.0.0.1" },
},
};
try testing.expectError(error.Constraint, applyToDb(io, gpa, &database, broken, 42, .{ .force = true }));
const after = try dump(&database, gpa);
defer gpa.free(after);
try testing.expectEqualStrings(before, after);
}
test "importSource writes nothing when validation fails" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
// No `default` group and no enabled upstream.
const bad: [:0]const u8 =
\\.{ .groups = .{ .{ .name = "kids" } } }
;
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(
error.MissingDefaultGroup,
importSource(io, testing.allocator, &database, bad, .{}, &diags),
);
try testing.expect(diags.problems.items.len >= 2);
try testing.expect(try isEmpty(&database));
}
test "importSource reports a ZON syntax error and writes nothing" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var diags: validate.Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(
error.ParseZon,
importSource(io, testing.allocator, &database, ".{ .groups = ", .{}, &diags),
);
try testing.expect(try isEmpty(&database));
// The point of the diagnostic: what the CLI prints must name the line and the
// column, not just `ParseZon`.
try testing.expect(diags.problems.items.len >= 1);
var rendered: std.Io.Writer.Allocating = .init(testing.allocator);
defer rendered.deinit();
try diags.writeAll(&rendered.writer);
const text = rendered.written();
try testing.expect(std.mem.indexOf(u8, text, "1:14: error: ") != null);
}
test "a config omitting every optional field parses into an arena and leaks nothing" {
// The S5.1 rule as a test: `std.zon.parse.free` is never called, the arena
// is the only release, and `std.testing.allocator` fails the test if a
// defaulted rodata string were ever handed to the allocator.
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
defer arena_state.deinit();
const cfg = try std.zon.parse.fromSliceAlloc(
model.Config,
arena_state.allocator(),
minimal_source,
null,
.{},
);
try testing.expectEqualStrings("0.0.0.0", cfg.dns.bind_ipv4);
try testing.expectEqual(@as(u16, 53), cfg.dns.port);
try testing.expectEqual(@as(usize, 1), cfg.groups.len);
}
test "a password is hashed into web.password_hash and never stored verbatim" {
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();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "correct horse battery staple" },
};
try applyToDb(io, gpa, &database, cfg, 42, .{});
var stmt = try database.prepare("SELECT value FROM settings WHERE key = 'web.password_hash'");
defer stmt.deinit();
try testing.expect(try stmt.step());
try testing.expect(std.mem.startsWith(u8, stmt.columnText(0), "$argon2id$"));
try testing.expectEqual(
@as(i64, 0),
try database.queryInt("SELECT count(*) FROM settings WHERE key = 'web.password'"),
);
}
test "a password and a password_hash together are refused" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
const cfg: model.Config = .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
.web = .{ .password = "plaintext", .password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def" },
};
try testing.expectError(
error.PasswordAndHashBothSet,
applyToDb(io, testing.allocator, &database, cfg, 42, .{}),
);
try testing.expect(try isEmpty(&database));
}
+745
View File
@@ -0,0 +1,745 @@
//! The one configuration model. Bootstrap, import, export, the repositories and
//! the running server all speak this struct; nothing else describes nxdns
//! configuration.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The
//! only `std.Io` types that appear are `std.Io.Duration` as a conversion result.
//!
//! Runtime columns are deliberately absent. `clients.first_seen`,
//! `clients.last_seen`, `rules.created_at` and
//! `blocklist_sources.{last_updated, domain_count, wildcard_count,
//! skipped_regex_count, checksum}` are facts a running server produces, not
//! configuration. Including them would make two exports taken minutes apart
//! differ, which would make the byte-stable round trip untestable against a
//! live server. Import sets the timestamps to the import time and leaves the
//! counters at their column defaults.
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Config = struct {
runtime: Runtime = .{},
upstream: Upstream = .{},
dns: Dns = .{},
blocking: Blocking = .{},
cache: Cache = .{},
web: Web = .{},
doh_server: TlsEndpoint = .{},
dot_server: TlsEndpoint = .{ .port = 853 },
edns: Edns = .{},
logging: Logging = .{},
disk: Disk = .{},
blocklist_update: BlocklistUpdate = .{},
groups: []const Group = &.{},
upstreams: []const UpstreamServer = &.{},
clients: []const Client = &.{},
client_prefixes: []const ClientPrefix = &.{},
blocklist_sources: []const BlocklistSource = &.{},
group_sources: []const GroupSource = &.{},
rules: []const Rule = &.{},
local_records: []const LocalRecord = &.{},
forward_zones: []const ForwardZone = &.{},
};
pub const IoBackend = enum {
threaded,
evented,
pub fn toDb(self: IoBackend) []const u8 {
return switch (self) {
.threaded => "threaded",
.evented => "evented",
};
}
pub fn fromDb(text: []const u8) ?IoBackend {
if (std.mem.eql(u8, text, "threaded")) return .threaded;
if (std.mem.eql(u8, text, "evented")) return .evented;
return null;
}
};
pub const Runtime = struct { io_backend: IoBackend = .threaded };
pub const Upstream = struct {
connect_timeout_ms: u32 = 2000,
read_timeout_ms: u32 = 3000,
total_timeout_ms: u32 = 5000,
};
pub const Dns = struct {
bind_ipv4: []const u8 = "0.0.0.0",
bind_ipv6: []const u8 = "::",
port: u16 = 53,
rate_limit: u32 = 1000,
rate_window_seconds: u32 = 60,
};
pub const BlockResponse = enum {
zero,
nxdomain,
pub fn toDb(self: BlockResponse) []const u8 {
return switch (self) {
.zero => "zero",
.nxdomain => "nxdomain",
};
}
pub fn fromDb(text: []const u8) ?BlockResponse {
if (std.mem.eql(u8, text, "zero")) return .zero;
if (std.mem.eql(u8, text, "nxdomain")) return .nxdomain;
return null;
}
};
pub const Blocking = struct { response: BlockResponse = .zero, ttl: u32 = 5 };
pub const Cache = struct { size: u32 = 10000, negative_ttl_max: u32 = 3600 };
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 = "",
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
sse_max_connections_per_ip: u16 = 3,
};
pub const TlsEndpoint = struct {
enabled: bool = false,
bind: []const u8 = "0.0.0.0",
port: u16 = 443,
cert_path: []const u8 = "/etc/nxdns/cert.pem",
key_path: []const u8 = "/etc/nxdns/key.pem",
};
pub const EcsMode = enum {
strip,
forward,
pub fn toDb(self: EcsMode) []const u8 {
return switch (self) {
.strip => "strip",
.forward => "forward",
};
}
pub fn fromDb(text: []const u8) ?EcsMode {
if (std.mem.eql(u8, text, "strip")) return .strip;
if (std.mem.eql(u8, text, "forward")) return .forward;
return null;
}
};
pub const Edns = struct { ecs_mode: EcsMode = .strip };
pub const LogLevel = enum {
err,
warn,
info,
debug,
/// `.err` stores as "error": that is the operator-facing word, and the Zig
/// tag cannot be `error` because it is a keyword.
pub fn toDb(self: LogLevel) []const u8 {
return switch (self) {
.err => "error",
.warn => "warn",
.info => "info",
.debug => "debug",
};
}
pub fn fromDb(text: []const u8) ?LogLevel {
if (std.mem.eql(u8, text, "error")) return .err;
if (std.mem.eql(u8, text, "warn")) return .warn;
if (std.mem.eql(u8, text, "info")) return .info;
if (std.mem.eql(u8, text, "debug")) return .debug;
return null;
}
};
pub const LogOutput = enum {
stderr,
syslog,
file,
pub fn toDb(self: LogOutput) []const u8 {
return switch (self) {
.stderr => "stderr",
.syslog => "syslog",
.file => "file",
};
}
pub fn fromDb(text: []const u8) ?LogOutput {
if (std.mem.eql(u8, text, "stderr")) return .stderr;
if (std.mem.eql(u8, text, "syslog")) return .syslog;
if (std.mem.eql(u8, text, "file")) return .file;
return null;
}
};
pub const Logging = struct {
level: LogLevel = .info,
retention_days: u16 = 30,
query_log_buffer_max: u32 = 10000,
hide_domains: bool = false,
hide_client_ips: bool = false,
output: LogOutput = .stderr,
file_path: []const u8 = "/var/log/nxdns/nxdns.log",
max_size_mb: u32 = 50,
max_files: u8 = 5,
};
pub const Disk = struct { min_free_mb: u32 = 200, warn_free_mb: u32 = 500 };
pub const BlocklistUpdate = struct { enabled: bool = true, interval_hours: u16 = 24 };
pub const Group = struct { name: []const u8, safe_search: bool = false };
pub const UpstreamServer = struct { url: []const u8, priority: i32 = 100, enabled: bool = true };
pub const Client = struct { ip: []const u8, name: []const u8 = "", group: []const u8 = "default" };
pub const ClientPrefix = struct { prefix: []const u8, group: []const u8 = "default", priority: i32 = 100 };
pub const BlocklistSource = struct {
url: []const u8,
name: []const u8,
enabled: bool = true,
is_suggested: bool = false,
};
pub const GroupSource = struct { group: []const u8, source_url: []const u8 };
pub const RuleKind = enum {
exact,
wildcard,
pub fn toDb(self: RuleKind) []const u8 {
return switch (self) {
.exact => "exact",
.wildcard => "wildcard",
};
}
pub fn fromDb(text: []const u8) ?RuleKind {
if (std.mem.eql(u8, text, "exact")) return .exact;
if (std.mem.eql(u8, text, "wildcard")) return .wildcard;
return null;
}
};
pub const RuleAction = enum {
allow,
block,
pub fn toDb(self: RuleAction) []const u8 {
return switch (self) {
.allow => "allow",
.block => "block",
};
}
pub fn fromDb(text: []const u8) ?RuleAction {
if (std.mem.eql(u8, text, "allow")) return .allow;
if (std.mem.eql(u8, text, "block")) return .block;
return null;
}
};
pub const Rule = struct { group: []const u8, pattern: []const u8, kind: RuleKind, action: RuleAction };
/// Tag names are lowercase because ZON enum literals are; the DB text is
/// uppercase because `CHECK(rtype IN ('A','AAAA','CNAME'))` says so.
pub const RecordType = enum {
a,
aaaa,
cname,
pub fn toDb(self: RecordType) []const u8 {
return switch (self) {
.a => "A",
.aaaa => "AAAA",
.cname => "CNAME",
};
}
pub fn fromDb(text: []const u8) ?RecordType {
if (std.mem.eql(u8, text, "A")) return .a;
if (std.mem.eql(u8, text, "AAAA")) return .aaaa;
if (std.mem.eql(u8, text, "CNAME")) return .cname;
return null;
}
};
pub const LocalRecord = struct { name: []const u8, rtype: RecordType, value: []const u8, ttl: u32 = 300 };
pub const ForwardZone = struct { zone: []const u8, resolver: []const u8 };
// ---------------------------------------------------------------------------
// Unit conversions (S2.4)
// ---------------------------------------------------------------------------
/// Fails the build unless `FieldType`'s maximum times `factor` fits `Dest`.
/// Overflow is made impossible by the types rather than checked at runtime,
/// which is why none of the conversions below can return an error.
pub fn assertFits(comptime FieldType: type, comptime factor: comptime_int, comptime Dest: type) void {
if (@as(u128, std.math.maxInt(FieldType)) * factor > @as(u128, std.math.maxInt(Dest))) {
@compileError("unit conversion overflows " ++ @typeName(Dest) ++ ": " ++
@typeName(FieldType) ++ " times the conversion factor does not fit");
}
}
comptime {
assertFits(u32, std.time.ns_per_ms, i96); // timeouts
assertFits(u16, 3600, i64); // session ttl, update interval
assertFits(u16, 86400, i64); // retention
assertFits(u32, 1024 * 1024, u64); // MiB conversions
}
pub fn connectTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.connect_timeout_ms) * std.time.ns_per_ms };
}
pub fn readTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
}
pub fn totalTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.total_timeout_ms) * std.time.ns_per_ms };
}
pub fn sessionTtlSeconds(w: Web) i64 {
return @as(i64, w.session_ttl_hours) * 3600;
}
pub fn retentionSeconds(l: Logging) i64 {
return @as(i64, l.retention_days) * 86400;
}
pub fn maxLogBytes(l: Logging) u64 {
return @as(u64, l.max_size_mb) * 1024 * 1024;
}
pub fn minFreeBytes(d: Disk) u64 {
return @as(u64, d.min_free_mb) * 1024 * 1024;
}
pub fn warnFreeBytes(d: Disk) u64 {
return @as(u64, d.warn_free_mb) * 1024 * 1024;
}
pub fn updateIntervalSeconds(b: BlocklistUpdate) i64 {
return @as(i64, b.interval_hours) * 3600;
}
// ---------------------------------------------------------------------------
// settings(key, value) bridge (S2.3)
// ---------------------------------------------------------------------------
pub const SettingPair = struct { key: []const u8, value: []const u8 };
pub const SettingsError = error{ BadSettingValue, OutOfMemory };
/// The scalar sections are exactly the `Config` fields whose type is a struct;
/// the collections are slices. Deriving the list this way means a new section
/// joins the settings mapping automatically and cannot drift out of it.
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 {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
fn encodeValue(comptime T: type, value: T, gpa: Allocator) error{OutOfMemory}![]u8 {
return switch (@typeInfo(T)) {
.bool => try gpa.dupe(u8, if (value) "true" else "false"),
.int => try std.fmt.allocPrint(gpa, "{d}", .{value}),
.@"enum" => try gpa.dupe(u8, value.toDb()),
.pointer => try gpa.dupe(u8, value),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
/// Decoding an integer uses the field's declared type, so a stored value out of
/// that range is `error.BadSettingValue` and never a truncating cast.
fn decodeValue(comptime T: type, text: []const u8) error{BadSettingValue}!T {
return switch (@typeInfo(T)) {
.bool => if (std.mem.eql(u8, text, "true"))
true
else if (std.mem.eql(u8, text, "false"))
false
else
error.BadSettingValue,
.int => std.fmt.parseInt(T, text, 10) catch error.BadSettingValue,
.@"enum" => T.fromDb(text) orelse error.BadSettingValue,
.pointer => text,
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
/// Frees the `value` of every pair. Keys are comptime strings and are never
/// freed.
pub fn freeSettings(gpa: Allocator, pairs: []const SettingPair) void {
for (pairs) |pair| gpa.free(pair.value);
}
/// Writes every scalar field of `cfg` as a key/value pair into `out`. Keys are
/// comptime strings (never freed); values are allocated from `gpa` and belong
/// to the caller, which frees them with `freeSettings`. On failure nothing this
/// call appended survives.
pub fn toSettings(cfg: Config, gpa: Allocator, out: *std.ArrayList(SettingPair)) error{OutOfMemory}!void {
const start = out.items.len;
errdefer {
freeSettings(gpa, out.items[start..]);
out.shrinkRetainingCapacity(start);
}
inline for (@typeInfo(Config).@"struct".fields) |section_field| {
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)) {
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 });
}
}
}
}
}
/// Applies `pairs` onto `cfg`, which the caller has initialized to `.{}`.
/// An absent key keeps the default — that is how a migration adds a setting
/// with no data step. An unknown key is logged at `warn` and counted in
/// `unknown_keys`; it is never an error, because downgrading a binary must not
/// brick a config database.
///
/// String values are borrowed from `pairs`, so `cfg` lives no longer than the
/// storage the pairs point into.
pub fn fromSettings(pairs: []const SettingPair, cfg: *Config, unknown_keys: *usize) SettingsError!void {
for (pairs) |pair| {
var matched = false;
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 (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);
matched = true;
}
}
}
}
}
if (!matched) {
unknown_keys.* += 1;
std.log.warn("unknown settings key '{s}' ignored", .{pair.key});
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
/// Every key `toSettings` produces on a default `Config`, sorted. A field added
/// without updating this list breaks the test below, which is the point.
const expected_keys = [_][]const u8{
"blocking.response",
"blocking.ttl",
"blocklist_update.enabled",
"blocklist_update.interval_hours",
"cache.negative_ttl_max",
"cache.size",
"disk.min_free_mb",
"disk.warn_free_mb",
"dns.bind_ipv4",
"dns.bind_ipv6",
"dns.port",
"dns.rate_limit",
"dns.rate_window_seconds",
"doh_server.bind",
"doh_server.cert_path",
"doh_server.enabled",
"doh_server.key_path",
"doh_server.port",
"dot_server.bind",
"dot_server.cert_path",
"dot_server.enabled",
"dot_server.key_path",
"dot_server.port",
"edns.ecs_mode",
"logging.file_path",
"logging.hide_client_ips",
"logging.hide_domains",
"logging.level",
"logging.max_files",
"logging.max_size_mb",
"logging.output",
"logging.query_log_buffer_max",
"logging.retention_days",
"runtime.io_backend",
"upstream.connect_timeout_ms",
"upstream.read_timeout_ms",
"upstream.total_timeout_ms",
"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",
};
fn lessThanKey(_: void, a: SettingPair, b: SettingPair) bool {
return std.mem.lessThan(u8, a.key, b.key);
}
test "toSettings on a default config produces exactly the expected key list" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(.{}, gpa, &pairs);
std.mem.sort(SettingPair, pairs.items, {}, lessThanKey);
try testing.expectEqual(expected_keys.len, pairs.items.len);
for (expected_keys, pairs.items) |expected, pair| {
try testing.expectEqualStrings(expected, pair.key);
}
}
test "toSettings never emits web.password" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(.{ .web = .{ .password = "hunter2" } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
}
test "toSettings and fromSettings round-trip a non-default config" {
const gpa = testing.allocator;
const original: Config = .{
.runtime = .{ .io_backend = .evented },
.upstream = .{ .connect_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
.dns = .{
.bind_ipv4 = "127.0.0.1",
.bind_ipv6 = "::1",
.port = 5353,
.rate_limit = 7,
.rate_window_seconds = 11,
},
.blocking = .{ .response = .nxdomain, .ttl = 13 },
.cache = .{ .size = 17, .negative_ttl_max = 19 },
.web = .{
.enabled = false,
.bind = "10.0.0.1",
.port = 9090,
.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def",
.session_ttl_hours = 23,
.api_rate_limit_per_min = 29,
.sse_max_connections_per_ip = 31,
},
.doh_server = .{
.enabled = true,
.bind = "10.0.0.2",
.port = 4443,
.cert_path = "/a/cert.pem",
.key_path = "/a/key.pem",
},
.dot_server = .{
.enabled = true,
.bind = "10.0.0.3",
.port = 8853,
.cert_path = "/b/cert.pem",
.key_path = "/b/key.pem",
},
.edns = .{ .ecs_mode = .forward },
.logging = .{
.level = .err,
.retention_days = 41,
.query_log_buffer_max = 43,
.hide_domains = true,
.hide_client_ips = true,
.output = .file,
.file_path = "/var/log/x.log",
.max_size_mb = 47,
.max_files = 53,
},
.disk = .{ .min_free_mb = 59, .warn_free_mb = 61 },
.blocklist_update = .{ .enabled = false, .interval_hours = 67 },
};
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
try toSettings(original, gpa, &pairs);
var restored: Config = .{};
var unknown: usize = 0;
try fromSettings(pairs.items, &restored, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
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)) {
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) {
try testing.expectEqualStrings(a, b);
} else {
try testing.expectEqual(a, b);
}
}
}
}
}
}
test "an unknown settings key is counted and not an error" {
var cfg: Config = .{};
var unknown: usize = 0;
const pairs = [_]SettingPair{
.{ .key = "dns.port", .value = "5300" },
.{ .key = "future.setting", .value = "whatever" },
.{ .key = "web.password", .value = "never a row" },
};
try fromSettings(&pairs, &cfg, &unknown);
try testing.expectEqual(@as(u16, 5300), cfg.dns.port);
// `web.password` is skipped in both directions, so it counts as unknown.
try testing.expectEqual(@as(usize, 2), unknown);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
const bad_int = [_]SettingPair{.{ .key = "dns.port", .value = "not a number" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_int, &cfg, &unknown));
// 70000 does not fit u16: an out-of-range value is refused, not truncated.
const out_of_range = [_]SettingPair{.{ .key = "dns.port", .value = "70000" }};
try testing.expectError(error.BadSettingValue, fromSettings(&out_of_range, &cfg, &unknown));
const bad_bool = [_]SettingPair{.{ .key = "web.enabled", .value = "yes" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_bool, &cfg, &unknown));
const bad_enum = [_]SettingPair{.{ .key = "logging.level", .value = "verbose" }};
try testing.expectError(error.BadSettingValue, fromSettings(&bad_enum, &cfg, &unknown));
}
test "an absent key keeps the default" {
var cfg: Config = .{};
var unknown: usize = 0;
const pairs = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&pairs, &cfg, &unknown);
try testing.expectEqual(@as(u32, 1000), cfg.dns.rate_limit);
try testing.expectEqual(LogLevel.info, cfg.logging.level);
}
test "LogLevel.err encodes as error and decodes back" {
try testing.expectEqualStrings("error", LogLevel.err.toDb());
try testing.expectEqual(LogLevel.err, LogLevel.fromDb("error").?);
try testing.expect(LogLevel.fromDb("err") == null);
}
fn expectEnumRoundTrip(comptime E: type) !void {
inline for (@typeInfo(E).@"enum".fields) |field| {
const value: E = @enumFromInt(field.value);
try testing.expectEqual(value, E.fromDb(value.toDb()).?);
}
try testing.expect(E.fromDb("nonsense") == null);
try testing.expect(E.fromDb("") == null);
}
test "every toDb and fromDb enum pair round-trips over all tags" {
try expectEnumRoundTrip(IoBackend);
try expectEnumRoundTrip(BlockResponse);
try expectEnumRoundTrip(EcsMode);
try expectEnumRoundTrip(LogLevel);
try expectEnumRoundTrip(LogOutput);
try expectEnumRoundTrip(RuleKind);
try expectEnumRoundTrip(RuleAction);
try expectEnumRoundTrip(RecordType);
}
test "RecordType stores the uppercase DDL spelling" {
try testing.expectEqualStrings("A", RecordType.a.toDb());
try testing.expectEqualStrings("AAAA", RecordType.aaaa.toDb());
try testing.expectEqualStrings("CNAME", RecordType.cname.toDb());
try testing.expect(RecordType.fromDb("a") == null);
}
test "unit conversions" {
try testing.expectEqual(
@as(i96, 2000) * std.time.ns_per_ms,
connectTimeout(.{}).nanoseconds,
);
try testing.expectEqual(
@as(i96, 3000) * std.time.ns_per_ms,
readTimeout(.{}).nanoseconds,
);
try testing.expectEqual(
@as(i96, 5000) * std.time.ns_per_ms,
totalTimeout(.{}).nanoseconds,
);
try testing.expectEqual(@as(i64, 24 * 3600), sessionTtlSeconds(.{}));
try testing.expectEqual(@as(i64, 30 * 86400), retentionSeconds(.{}));
try testing.expectEqual(@as(u64, 50 * 1024 * 1024), maxLogBytes(.{}));
try testing.expectEqual(@as(u64, 200 * 1024 * 1024), minFreeBytes(.{}));
try testing.expectEqual(@as(u64, 500 * 1024 * 1024), warnFreeBytes(.{}));
try testing.expectEqual(@as(i64, 24 * 3600), updateIntervalSeconds(.{}));
}
test "unit conversions at the field maximum do not overflow" {
const max_upstream: Upstream = .{
.connect_timeout_ms = std.math.maxInt(u32),
.read_timeout_ms = std.math.maxInt(u32),
.total_timeout_ms = std.math.maxInt(u32),
};
try testing.expectEqual(
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
connectTimeout(max_upstream).nanoseconds,
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 3600,
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 86400,
retentionSeconds(.{ .retention_days = std.math.maxInt(u16) }),
);
try testing.expectEqual(
@as(u64, std.math.maxInt(u32)) * 1024 * 1024,
maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
);
}
File diff suppressed because it is too large Load Diff