Files
nxdns/src/config/model.zig
T
mokhtar ce143d1d87
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
db-mode config changes apply live in-process
settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
2026-08-24 00:04:28 +02:00

868 lines
32 KiB
Zig

//! The one *declarative* configuration model: loading, reconciliation, import,
//! export and the running server all speak this struct, and it is the whole
//! shape of a config file. It is not the only shape the repositories accept —
//! the API edits rows one at a time through narrower inputs such as
//! `RuleInput`, `ClientInput` and `ClientEdit`, so a field added here does not
//! reach those paths by itself.
//!
//! 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,
//! exception_count, skipped_regex_count, skipped_unsupported_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.
//!
//! Declarative configuration reaches the database through exactly one path:
//! `config/reconcile.zig`. `nxdns import` is a thin wrapper over it, and so is
//! `run --config`. Reconciliation asks what changed rather than replacing
//! wholesale, so a row the input still names keeps the runtime state attached
//! to it: a source matched by url keeps its id, checksum and counters, a rule
//! keeps its `created_at`, and a client keeps its first-seen and last-seen
//! stamps. The source id and checksum are the two that decide whether a
//! file-mode restart reuses the compiled bodies or downloads them again:
//! `loadSource` names the files after the id and accepts them only against the
//! stored checksum. The counters ride along as reported state.
const std = @import("std");
const Allocator = std.mem.Allocator;
pub const Config = struct {
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 Upstream = struct {
/// Bounds one attempt against one upstream inside the pool's failover loop.
attempt_timeout_ms: u32 = 2500,
/// The forward-zone client's read deadline, and nothing else. It bounds a
/// different subsystem from the two above (`src/local/forward_client.zig`),
/// so no cross-check relates it to them.
read_timeout_ms: u32 = 3000,
/// The whole-exchange budget: every failover attempt together, not one of
/// them. The pool races the entire loop against it.
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, never exported.
///
/// Optional because absence and emptiness are different declarations: null
/// means "the file says nothing about the password, keep the stored hash",
/// while a present value is an instruction to set one.
password: ?[]const u8 = null,
/// argon2id PHC string. Null means "the file says nothing, keep what is
/// stored"; an explicit `""` is the documented way to disable
/// authentication.
password_hash: ?[]const u8 = null,
session_ttl_hours: u16 = 24,
api_rate_limit_per_min: u32 = 300,
/// Requests from the box itself skip the API rate limit. On by default: a
/// local script or health probe is the operator's own traffic, not the
/// abuse the limiter defends against (PLAN §10).
api_localhost_exempt: bool = true,
sse_max_connections_per_ip: u16 = 3,
/// Comma-separated IP literals. A request arriving from one of these peers
/// is identified by the last entry of its `X-Forwarded-For` header instead
/// of by the socket peer, so the API limiter and the per-address SSE cap
/// bind the real client rather than the proxy. Empty means no proxy is
/// trusted and the socket peer is always the client. One string rather than
/// a list because the settings codec stores scalars only.
trusted_proxies: []const u8 = "",
};
/// Walks `web.trusted_proxies`. The validator and the web server both read the
/// setting, so what counts as one element — comma-separated, surrounding spaces
/// and tabs trimmed — is spelled out once here. An empty setting yields nothing;
/// an empty element (a stray comma) is yielded, so the validator can name it
/// rather than silently drop it.
pub fn trustedProxies(text: []const u8) TrustedProxyIterator {
return .{ .rest = text, .done = text.len == 0 };
}
pub const TrustedProxyIterator = struct {
rest: []const u8,
done: bool,
pub fn next(self: *TrustedProxyIterator) ?[]const u8 {
if (self.done) return null;
const end = std.mem.findScalar(u8, self.rest, ',') orelse {
const last = self.rest;
self.done = true;
return std.mem.trim(u8, last, " \t");
};
const element = self.rest[0..end];
self.rest = self.rest[end + 1 ..];
return std.mem.trim(u8, element, " \t");
}
};
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,
/// How long the query-log writer gathers entries before it commits them.
/// `0` does not wait at all: it flushes the entry that woke the writer plus
/// whatever is already queued.
query_log_flush_interval_s: u16 = 60,
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,
/// DoT only. The DNS name used for SNI and certificate verification while
/// the connection still dials the URL's host. `std.crypto.Certificate`
/// matches dNSName SANs only, so a `tls://` upstream written as an IP
/// literal cannot verify without one. Empty means "verify by the URL host".
tls_name: []const u8 = "",
};
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 };
/// The three spellings `CHECK(kind IN ('exact','wildcard','regex'))` admits
/// after migration step 4.
pub const RuleKind = enum {
exact,
wildcard,
regex,
pub fn toDb(self: RuleKind) []const u8 {
return switch (self) {
.exact => "exact",
.wildcard => "wildcard",
.regex => "regex",
};
}
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;
if (std.mem.eql(u8, text, "regex")) return .regex;
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 readTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
}
pub fn attemptTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.attempt_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 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";
}
/// The skip policy splits by direction, because encode and decode need
/// different sets.
///
/// `web.password` is operator input and is skipped both ways: it is hashed into
/// `web.password_hash` and discarded (S2.5).
///
/// `web.password_hash` is skipped on **encode only**. The reconcile engine owns
/// that settings row directly — ruling 4 of milestone 20 makes absence mean
/// "keep the stored hash", which a general encode pass cannot express. Skipping
/// it on decode as well would leave `cfg.web.password_hash` null on every read
/// path, turn `auth.authEnabled` false, and silently open the admin UI.
fn isEncodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
if (!std.mem.eql(u8, section, "web")) return false;
return std.mem.eql(u8, field, "password") or std.mem.eql(u8, field, "password_hash");
}
fn isDecodeSkipped(comptime section: []const u8, comptime field: []const u8) bool {
return std.mem.eql(u8, section, "web") and std.mem.eql(u8, field, "password");
}
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,
// A stored key is a present value, so an optional field decodes to a
// non-null one; the null stays reserved for the absent key, which never
// reaches this function at all.
.optional => |info| try decodeValue(info.child, text),
else => @compileError("unsupported setting field type " ++ @typeName(T)),
};
}
/// 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 !isEncodeSkipped(section_field.name, field.name)) {
const value = try encodeValue(field.type, @field(section, field.name), gpa);
errdefer gpa.free(value);
try out.append(gpa, .{ .key = section_field.name ++ "." ++ field.name, .value = value });
}
}
}
}
}
/// 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 !isDecodeSkipped(section_field.name, field.name)) {
if (std.mem.eql(u8, pair.key, section_field.name ++ "." ++ field.name)) {
@field(@field(cfg, section_field.name), field.name) =
try decodeValue(field.type, pair.value);
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.query_log_flush_interval_s",
"logging.retention_days",
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
"upstream.total_timeout_ms",
"web.api_localhost_exempt",
"web.api_rate_limit_per_min",
"web.bind",
"web.enabled",
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
"web.trusted_proxies",
};
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 = .{
.upstream = .{ .attempt_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,
.api_localhost_exempt = false,
.sse_max_connections_per_ip = 31,
.trusted_proxies = "10.0.0.9,fd00::9",
},
.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,
.query_log_flush_interval_s = 44,
.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 !isEncodeSkipped(section_field.name, field.name)) {
const a = @field(@field(original, section_field.name), field.name);
const b = @field(@field(restored, section_field.name), field.name);
if (comptime @typeInfo(field.type) == .pointer) {
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 "web.password_hash decodes from the settings table but is never encoded" {
const gpa = testing.allocator;
var pairs: std.ArrayList(SettingPair) = .empty;
defer {
freeSettings(gpa, pairs.items);
pairs.deinit(gpa);
}
// Encode: the reconciler owns that row, so no pass over the model emits it.
const hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
try toSettings(.{ .web = .{ .password_hash = hash } }, gpa, &pairs);
for (pairs.items) |pair| {
try testing.expect(!std.mem.eql(u8, pair.key, "web.password_hash"));
try testing.expect(!std.mem.eql(u8, pair.key, "web.password"));
}
// Decode: every read path still sees the stored hash, or `authEnabled`
// would read false on a box that has a password set.
var cfg: Config = .{};
var unknown: usize = 0;
const stored = [_]SettingPair{.{ .key = "web.password_hash", .value = hash }};
try fromSettings(&stored, &cfg, &unknown);
try testing.expectEqual(@as(usize, 0), unknown);
try testing.expectEqualStrings(hash, cfg.web.password_hash.?);
}
test "an optional settings field is null when absent and non-null when present" {
var absent: Config = .{};
var unknown: usize = 0;
const other = [_]SettingPair{.{ .key = "dns.port", .value = "5300" }};
try fromSettings(&other, &absent, &unknown);
try testing.expectEqual(@as(?[]const u8, null), absent.web.password_hash);
// An explicit empty string is a present value, not an absent key: it is how
// a config file disables authentication.
var empty: Config = .{};
const disabled = [_]SettingPair{.{ .key = "web.password_hash", .value = "" }};
try fromSettings(&disabled, &empty, &unknown);
try testing.expect(empty.web.password_hash != null);
try testing.expectEqualStrings("", empty.web.password_hash.?);
}
test "a malformed settings value is BadSettingValue" {
var cfg: Config = .{};
var unknown: usize = 0;
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(BlockResponse);
try expectEnumRoundTrip(EcsMode);
try expectEnumRoundTrip(LogLevel);
try expectEnumRoundTrip(LogOutput);
try expectEnumRoundTrip(RuleKind);
try expectEnumRoundTrip(RuleAction);
try expectEnumRoundTrip(RecordType);
}
test "RuleKind carries the third kind through export and import" {
try testing.expectEqualStrings("regex", RuleKind.regex.toDb());
try testing.expectEqual(RuleKind.regex, RuleKind.fromDb("regex").?);
try testing.expect(RuleKind.fromDb("Regex") == null);
}
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, 2500) * std.time.ns_per_ms,
attemptTimeout(.{}).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(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 = .{
.attempt_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,
readTimeout(max_upstream).nanoseconds,
);
try testing.expectEqual(
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
attemptTimeout(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(u64, std.math.maxInt(u32)) * 1024 * 1024,
maxLogBytes(.{ .max_size_mb = std.math.maxInt(u32) }),
);
}