storage and config: sqlite wrapper, migrations, querylog policy, repositories, zon config with import/export/check cli
This commit is contained in:
@@ -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.
|
||||
@@ -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$"));
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user