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