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
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.
868 lines
32 KiB
Zig
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) }),
|
|
);
|
|
}
|