Files
nxdns/src/config/validate.zig
T

1925 lines
75 KiB
Zig

//! The pure configuration validator.
//!
//! The controlling requirement: this validator must reject everything the
//! `config.db` schema would reject. An import that passes validation and then
//! dies on a `Constraint` mid-transaction hands the operator a SQLite message
//! instead of a line number, so every `UNIQUE` and every foreign key in
//! PLAN §11.2 has a check here.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The
//! only `std.Io` type used is `std.Io.Writer`, for rendering diagnostics. The
//! allocator exists for diagnostic text and scratch bookkeeping alone.
//!
//! Parsers are reused, never reimplemented: `transport.Endpoint.parse` for
//! upstream URLs, `NetAddress.parse` / `Prefix.parse` for addresses, and
//! `dns.name.fromText` for every domain-shaped string.
//!
//! **No url is ever printed whole.** Every diagnostic that names a url renders
//! it through `safe_url.redact`, which drops the userinfo, the query and the
//! fragment. A diagnostic goes to stderr from `nxdns run` and `nxdns check`,
//! which under systemd is journald, and a url an operator supplied can carry a
//! password in its userinfo or an api key in its query. That the validator is
//! *rejecting* the url changes nothing: the line is written either way, and
//! `error.BadUrl` inputs are exactly the ones a parser cannot help redact —
//! which is why `redact` scans rather than parses. Do not add a tenth
//! diagnostic that prints `{s}` of a url.
//!
//! **No operator-supplied text is ever quoted by a format string.** A group
//! name, a client address, a rule pattern and a blocklist source name all come
//! out of the configuration file or out of a database row, and none of the four
//! is restricted to a printable character set on the way in. A `\n` in one of
//! them forges a whole diagnostic line and a `'` in one of them closes the
//! quotes a format string put around it, which forges a field. Both reach an
//! operator: `cli.zig` prints these messages to stdout, and
//! `web/handlers/mutations.zig` returns `Problem.message` as the body of a 400.
//! So every such value prints as `{f}` of `safe_url.quoteText`, which writes its
//! own quotes and escapes what would close them — the quote and its escape stay
//! in one place, where they cannot drift apart. Do not write `'{s}'` in this
//! file.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const model = @import("model.zig");
const address = @import("../platform/address.zig");
const dns_name = @import("../dns/name.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("../upstream/transport.zig");
const Config = model.Config;
const NetAddress = address.NetAddress;
const Prefix = address.Prefix;
/// Every way this validator can say "the operator's configuration is wrong",
/// and nothing else. `config/faults.zig` consumes the set whole and calls every
/// member a configuration fault, so a member that is not a verdict on the
/// configuration would make that classification a lie.
///
/// `OutOfMemory` is therefore not here. The validator allocates diagnostic text
/// and can fail to, but a failed allocation says nothing about the file — it
/// says the report is incomplete — so it travels in `Error` below and exits 1
/// like every other resource failure.
pub const ValidateError = error{
NoUpstreams,
BadUpstreamUrl,
DuplicateUpstreamUrl,
BadTlsName,
TlsNameOnNonTlsUpstream,
MissingDefaultGroup,
DuplicateGroupName,
UnknownGroup,
EmptyGroupName,
BadClientIp,
DuplicateClientIp,
BadClientPrefix,
DuplicateClientPrefix,
BadSourceUrl,
DuplicateSourceUrl,
EmptySourceName,
UnknownSource,
DuplicateGroupSource,
BadRulePattern,
BadLocalRecordName,
BadLocalRecordValue,
DuplicateLocalRecord,
BadForwardZone,
DuplicateForwardZone,
BadResolverUrl,
BadPort,
BadTimeout,
BadTtl,
BadRetention,
BadLogRotation,
BadDiskThresholds,
BadRateLimit,
BadBindAddress,
MissingCertPath,
MissingKeyPath,
MissingLogPath,
PasswordAndHashBothSet,
};
/// What `validate` returns: a verdict on the configuration, or the allocation
/// failure that stopped it reaching one. Callers that must tell the two apart —
/// every one of them, because they map to different exit codes — match
/// `error.OutOfMemory` first and treat the rest as findings.
pub const Error = ValidateError || Allocator.Error;
/// What a `Problem` can carry: every semantic `ValidateError`, plus the one
/// syntactic failure (`ParseZon`) that `config/import.zig` routes through the
/// same channel so a syntax error's line/column reaches the operator's output,
/// plus the warnings — which are never returned by `validate` and so are not
/// `ValidateError` members.
pub const ProblemError = ValidateError || error{ ParseZon, SourceInNoGroup };
/// `.fail` rejects the configuration and is what an exit code is computed from.
/// `.warn` reports something legal that is almost certainly not what the
/// operator meant; it is printed and it never changes an exit code.
pub const Severity = enum { fail, warn };
pub const Problem = struct {
/// Dotted path into the config, e.g. "upstreams[2].url". Owned by `Diagnostics`.
path: []const u8,
/// Human-readable, e.g. "unknown group 'kids'". Owned by `Diagnostics`.
message: []const u8,
err: ProblemError,
severity: Severity,
};
pub const Diagnostics = struct {
gpa: Allocator,
problems: std.ArrayList(Problem),
pub fn init(gpa: Allocator) Diagnostics {
return .{ .gpa = gpa, .problems = .empty };
}
pub fn deinit(self: *Diagnostics) void {
for (self.problems.items) |problem| {
self.gpa.free(problem.path);
self.gpa.free(problem.message);
}
self.problems.deinit(self.gpa);
}
/// Records a failure. Both the path and the message are formatted, because a
/// path carries the offending element's index ("clients[1].ip") and a
/// message carries the offending value. `Diagnostics` owns both strings from
/// here on.
pub fn add(
self: *Diagnostics,
err: ProblemError,
comptime path_fmt: []const u8,
path_args: anytype,
comptime message_fmt: []const u8,
message_args: anytype,
) error{OutOfMemory}!void {
return self.record(.fail, err, path_fmt, path_args, message_fmt, message_args);
}
/// Records a warning: the same rendering, and no effect on any exit code.
pub fn addWarning(
self: *Diagnostics,
err: ProblemError,
comptime path_fmt: []const u8,
path_args: anytype,
comptime message_fmt: []const u8,
message_args: anytype,
) error{OutOfMemory}!void {
return self.record(.warn, err, path_fmt, path_args, message_fmt, message_args);
}
fn record(
self: *Diagnostics,
severity: Severity,
err: ProblemError,
comptime path_fmt: []const u8,
path_args: anytype,
comptime message_fmt: []const u8,
message_args: anytype,
) error{OutOfMemory}!void {
const path = try std.fmt.allocPrint(self.gpa, path_fmt, path_args);
errdefer self.gpa.free(path);
const message = try std.fmt.allocPrint(self.gpa, message_fmt, message_args);
errdefer self.gpa.free(message);
try self.problems.append(self.gpa, .{
.path = path,
.message = message,
.err = err,
.severity = severity,
});
}
/// How many problems reject the configuration. This is the number an exit
/// code is computed from; `warningCount` is reported and ignored.
pub fn failureCount(self: *const Diagnostics) usize {
return self.count(.fail);
}
pub fn warningCount(self: *const Diagnostics) usize {
return self.count(.warn);
}
fn count(self: *const Diagnostics, severity: Severity) usize {
var total: usize = 0;
for (self.problems.items) |problem| {
if (problem.severity == severity) total += 1;
}
return total;
}
/// The first problem that rejects the configuration, for a caller that
/// reports one line rather than the whole list. A warning is never it.
pub fn firstFailure(self: *const Diagnostics) ?Problem {
for (self.problems.items) |problem| {
if (problem.severity == .fail) return problem;
}
return null;
}
/// One "FAIL path: message" or "WARN path: message" line per problem, in the
/// order they were recorded.
pub fn writeAll(self: *const Diagnostics, w: *Writer) Writer.Error!void {
for (self.problems.items) |problem| {
const label = switch (problem.severity) {
.fail => "FAIL",
.warn => "WARN",
};
try w.print("{s} {s}: {s}\n", .{ label, problem.path, problem.message });
}
}
};
// ---------------------------------------------------------------------------
// Forward-zone resolvers
// ---------------------------------------------------------------------------
pub const ResolverScheme = enum { udp, tcp };
pub const Resolver = struct {
scheme: ResolverScheme,
addr: NetAddress,
port: u16,
};
pub const ResolverError = error{ UnsupportedScheme, BadHost, BadPort, BadUrl };
/// `udp://192.168.1.1:53`, `tcp://[fd00::1]:53`. PLAN §6.5 permits plain
/// transports for local infrastructure, which is why this exists at all;
/// `transport.Endpoint.parse` rejects both schemes by design.
///
/// The host must be an IP literal: a forward zone points at a box on the LAN,
/// and resolving the resolver's own name is a bootstrap problem nxdns declines
/// to have. The port is mandatory for the same reason a typo must not silently
/// become 53. Phase 5's `local/forward_zones.zig` imports this rather than
/// writing a second parser.
pub fn parseResolver(text: []const u8) ResolverError!Resolver {
const udp_prefix = "udp://";
const tcp_prefix = "tcp://";
const scheme: ResolverScheme, const rest = if (std.mem.startsWith(u8, text, udp_prefix))
.{ .udp, text[udp_prefix.len..] }
else if (std.mem.startsWith(u8, text, tcp_prefix))
.{ .tcp, text[tcp_prefix.len..] }
else
return error.UnsupportedScheme;
if (std.mem.findScalar(u8, rest, '/') != null) return error.BadUrl;
const authority = try parseAuthority(rest);
const port = authority.port orelse return error.BadPort;
const addr = NetAddress.parse(authority.host) catch return error.BadHost;
return .{ .scheme = scheme, .addr = addr, .port = port };
}
// ---------------------------------------------------------------------------
// validate
// ---------------------------------------------------------------------------
/// Collects EVERY problem into `diags`, then returns the first failure's `err`.
/// Checks run in a fixed order — the scalar sections in `Config` declaration
/// order, then the collections in `Config` declaration order — so the returned
/// error is deterministic for a given config.
///
/// A configuration whose only problems are warnings is a valid configuration:
/// this returns nothing, and the warnings are in `diags` for the caller to
/// print.
pub fn validate(cfg: Config, diags: *Diagnostics) Error!void {
var arena_state: std.heap.ArenaAllocator = .init(diags.gpa);
defer arena_state.deinit();
const scratch = arena_state.allocator();
// The caller may hand in a `Diagnostics` that already holds problems from
// another layer (import's ParseZon lines), so the returned error is the
// first failure THIS call recorded — always a `ValidateError`, which makes
// the cast checked-safe.
const first = diags.problems.items.len;
try checkScalars(cfg, diags);
try checkCollections(cfg, diags, scratch);
for (diags.problems.items[first..]) |problem| {
if (problem.severity == .fail) return @errorCast(problem.err);
}
}
const min_timeout_ms = 100;
const max_timeout_ms = 120_000;
const max_ttl_seconds = 86_400;
const max_record_ttl_seconds = 604_800;
const max_rate_window_seconds = 3_600;
fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
const up = cfg.upstream;
try checkTimeout(diags, up.read_timeout_ms, "upstream.read_timeout_ms");
try checkTimeout(diags, up.total_timeout_ms, "upstream.total_timeout_ms");
if (up.total_timeout_ms < up.read_timeout_ms) {
try diags.add(
error.BadTimeout,
"upstream.total_timeout_ms",
.{},
"total budget {d}ms is below read {d}ms",
.{ up.total_timeout_ms, up.read_timeout_ms },
);
}
try checkBind(diags, cfg.dns.bind_ipv4, "dns.bind_ipv4", .ip4);
try checkBind(diags, cfg.dns.bind_ipv6, "dns.bind_ipv6", .ip6);
try checkPort(diags, cfg.dns.port, "dns.port");
if (cfg.dns.rate_limit < 1) {
try diags.add(error.BadRateLimit, "dns.rate_limit", .{}, "must be at least 1", .{});
}
if (cfg.dns.rate_window_seconds < 1 or cfg.dns.rate_window_seconds > max_rate_window_seconds) {
try diags.add(
error.BadRateLimit,
"dns.rate_window_seconds",
.{},
"must be 1-{d}, got {d}",
.{ max_rate_window_seconds, cfg.dns.rate_window_seconds },
);
}
if (cfg.blocking.ttl > max_ttl_seconds) {
try diags.add(
error.BadTtl,
"blocking.ttl",
.{},
"must be at most {d}, got {d}",
.{ max_ttl_seconds, cfg.blocking.ttl },
);
}
if (cfg.cache.negative_ttl_max > max_ttl_seconds) {
try diags.add(
error.BadTtl,
"cache.negative_ttl_max",
.{},
"must be at most {d}, got {d}",
.{ max_ttl_seconds, cfg.cache.negative_ttl_max },
);
}
try checkBind(diags, cfg.web.bind, "web.bind", .any);
try checkPort(diags, cfg.web.port, "web.port");
if (cfg.web.password.len != 0 and cfg.web.password_hash.len != 0) {
try diags.add(
error.PasswordAndHashBothSet,
"web.password",
.{},
"password and password_hash are both set; ambiguity in a security setting is refused",
.{},
);
}
// A session TTL is a TTL; `BadTtl` is its bucket.
if (cfg.web.session_ttl_hours < 1) {
try diags.add(error.BadTtl, "web.session_ttl_hours", .{}, "must be at least 1", .{});
}
if (cfg.web.api_rate_limit_per_min < 1) {
try diags.add(error.BadRateLimit, "web.api_rate_limit_per_min", .{}, "must be at least 1", .{});
}
if (cfg.web.sse_max_connections_per_ip < 1) {
try diags.add(error.BadRateLimit, "web.sse_max_connections_per_ip", .{}, "must be at least 1", .{});
}
try checkTlsEndpoint(diags, cfg.doh_server, "doh_server");
try checkTlsEndpoint(diags, cfg.dot_server, "dot_server");
if (cfg.logging.retention_days < 1) {
try diags.add(error.BadRetention, "logging.retention_days", .{}, "must be at least 1", .{});
}
if (cfg.logging.query_log_buffer_max < 1) {
try diags.add(error.BadRetention, "logging.query_log_buffer_max", .{}, "must be at least 1", .{});
}
if (cfg.logging.max_size_mb < 1) {
try diags.add(error.BadLogRotation, "logging.max_size_mb", .{}, "must be at least 1", .{});
}
if (cfg.logging.max_files < 1) {
try diags.add(error.BadLogRotation, "logging.max_files", .{}, "must be at least 1", .{});
}
if (cfg.logging.output == .file and
(cfg.logging.file_path.len == 0 or cfg.logging.file_path[0] != '/'))
{
try diags.add(
error.MissingLogPath,
"logging.file_path",
.{},
"output is 'file' so file_path must be a non-empty absolute path, got {f}",
.{safe_url.quoteText(cfg.logging.file_path)},
);
}
if (cfg.disk.min_free_mb < 1 or cfg.disk.warn_free_mb < 1) {
try diags.add(
error.BadDiskThresholds,
"disk.min_free_mb",
.{},
"both thresholds must be at least 1, got min {d} and warn {d}",
.{ cfg.disk.min_free_mb, cfg.disk.warn_free_mb },
);
} else if (cfg.disk.min_free_mb > cfg.disk.warn_free_mb) {
try diags.add(
error.BadDiskThresholds,
"disk.min_free_mb",
.{},
"min_free_mb {d} is above warn_free_mb {d}",
.{ cfg.disk.min_free_mb, cfg.disk.warn_free_mb },
);
}
// An update interval is a duration in hours, like the session TTL above.
if (cfg.blocklist_update.interval_hours < 1) {
try diags.add(error.BadTtl, "blocklist_update.interval_hours", .{}, "must be at least 1", .{});
}
}
fn checkPort(diags: *Diagnostics, port: u16, comptime path: []const u8) error{OutOfMemory}!void {
if (port == 0) {
try diags.add(error.BadPort, path, .{}, "must be 1-65535, got 0", .{});
}
}
fn checkTimeout(diags: *Diagnostics, value: u32, comptime path: []const u8) error{OutOfMemory}!void {
if (value < min_timeout_ms or value > max_timeout_ms) {
try diags.add(
error.BadTimeout,
path,
.{},
"must be {d}-{d} ms, got {d}",
.{ min_timeout_ms, max_timeout_ms, value },
);
}
}
const BindFamily = enum { ip4, ip6, any };
/// `dns.bind_ipv4` and `dns.bind_ipv6` each name one socket of the dual-stack
/// pair, so each must be a literal of its own family: an IPv4 wildcard in
/// `bind_ipv6` would bind IPv4 as the "v6" socket and make the real IPv4 bind
/// fail with AddressInUse — the IPv6 service silently disappears.
fn checkBind(
diags: *Diagnostics,
text: []const u8,
comptime path: []const u8,
comptime family: BindFamily,
) error{OutOfMemory}!void {
const addr = NetAddress.parse(text) catch {
try diags.add(error.BadBindAddress, path, .{}, "{f} is not an IP address", .{safe_url.quoteText(text)});
return;
};
switch (family) {
.ip4 => if (std.meta.activeTag(addr) != NetAddress.ip4) {
try diags.add(error.BadBindAddress, path, .{}, "{f} is not an IPv4 address", .{safe_url.quoteText(text)});
},
.ip6 => if (std.meta.activeTag(addr) != NetAddress.ip6) {
try diags.add(error.BadBindAddress, path, .{}, "{f} is not an IPv6 address", .{safe_url.quoteText(text)});
},
.any => {},
}
}
fn checkTlsEndpoint(
diags: *Diagnostics,
endpoint: model.TlsEndpoint,
comptime section: []const u8,
) error{OutOfMemory}!void {
try checkBind(diags, endpoint.bind, section ++ ".bind", .any);
try checkPort(diags, endpoint.port, section ++ ".port");
if (!endpoint.enabled) return;
// Readability of the files is `nxdns check`'s job, not the pure validator's.
if (endpoint.cert_path.len == 0) {
try diags.add(
error.MissingCertPath,
section ++ ".cert_path",
.{},
section ++ " is enabled but cert_path is empty",
.{},
);
}
if (endpoint.key_path.len == 0) {
try diags.add(
error.MissingKeyPath,
section ++ ".key_path",
.{},
section ++ " is enabled but key_path is empty",
.{},
);
}
}
/// Each key mapped to the index of the entry that introduced it. The index is
/// there for the duplicate diagnostics: a message quoting the value cannot name
/// the entry a duplicate collides with, and for a url it cannot even show the
/// collision, because `safe_url.redact` drops the component two otherwise
/// identical urls differ in.
const IndexSet = std.StringHashMapUnmanaged(usize);
/// The index of the entry that first carried `key`, or null when `key` is new.
/// `key` must outlive `set`.
fn firstSeen(
set: *IndexSet,
scratch: Allocator,
key: []const u8,
index: usize,
) error{OutOfMemory}!?usize {
const gop = try set.getOrPut(scratch, key);
if (gop.found_existing) return gop.value_ptr.*;
gop.value_ptr.* = index;
return null;
}
/// Canonical text for an address, allocated from `scratch`. RFC 5952 form for
/// IPv6, dotted decimal for IPv4 — the same text import writes, so the
/// validator sees the collision `UNIQUE` would see.
fn canonical(scratch: Allocator, value: anytype) error{OutOfMemory}![]u8 {
// The longest form this writes is an IPv6 prefix, 45 + 4 bytes.
var buf: [64]u8 = undefined;
var w: Writer = .fixed(&buf);
value.format(&w) catch unreachable;
return scratch.dupe(u8, w.buffered());
}
/// A `tls_name` overrides SNI and certificate verification for a DoT upstream,
/// which is the only transport that needs it: DoH verifies by the url host and
/// the http client would ignore this field, so a `tls_name` there is a config
/// error rather than a setting with no effect.
fn checkTlsName(
diags: *Diagnostics,
server: model.UpstreamServer,
scheme: transport.Scheme,
index: usize,
) error{OutOfMemory}!void {
if (server.tls_name.len == 0) return;
if (scheme != .dot) {
try diags.add(
error.TlsNameOnNonTlsUpstream,
"upstreams[{d}].tls_name",
.{index},
"tls_name is only for a tls:// upstream; {f} verifies by its url host",
.{safe_url.redactQuoted(server.url)},
);
return;
}
_ = dns_name.fromText(server.tls_name) catch {
try diags.add(
error.BadTlsName,
"upstreams[{d}].tls_name",
.{index},
"{f} is not a valid domain name",
.{safe_url.quoteText(server.tls_name)},
);
};
}
fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{OutOfMemory}!void {
var group_names: IndexSet = .empty;
var has_default = false;
for (cfg.groups, 0..) |group, i| {
if (group.name.len == 0) {
try diags.add(error.EmptyGroupName, "groups[{d}].name", .{i}, "group name is empty", .{});
} else if (try firstSeen(&group_names, scratch, group.name, i)) |_| {
try diags.add(
error.DuplicateGroupName,
"groups[{d}].name",
.{i},
"duplicate group name {f}",
.{safe_url.quoteText(group.name)},
);
}
if (std.mem.eql(u8, group.name, "default")) has_default = true;
}
if (!has_default) {
try diags.add(
error.MissingDefaultGroup,
"groups",
.{},
"no group named 'default'; every unknown client is assigned to it",
.{},
);
}
var upstream_urls: IndexSet = .empty;
var enabled_upstreams: usize = 0;
for (cfg.upstreams, 0..) |server, i| {
// The scheme decides whether `tls_name` is meaningful, so the parse
// result is kept rather than discarded. An unparseable url reports only
// `BadUpstreamUrl`: what its scheme would have been is unknown.
if (transport.Endpoint.parse(server.url)) |endpoint| {
try checkTlsName(diags, server, endpoint.scheme, i);
} else |_| {
try diags.add(
error.BadUpstreamUrl,
"upstreams[{d}].url",
.{i},
"{f} is not an https:// or tls:// endpoint",
.{safe_url.redactQuoted(server.url)},
);
}
if (try firstSeen(&upstream_urls, scratch, server.url, i)) |first| {
try diags.add(
error.DuplicateUpstreamUrl,
"upstreams[{d}].url",
.{i},
"duplicate of upstreams[{d}]",
.{first},
);
}
if (server.enabled) enabled_upstreams += 1;
}
if (enabled_upstreams == 0) {
try diags.add(
error.NoUpstreams,
"upstreams",
.{},
"at least one upstream must be enabled",
.{},
);
}
var client_ips: IndexSet = .empty;
for (cfg.clients, 0..) |client, i| {
if (NetAddress.parse(client.ip)) |addr| {
const text = try canonical(scratch, addr);
if (try firstSeen(&client_ips, scratch, text, i)) |_| {
try diags.add(
error.DuplicateClientIp,
"clients[{d}].ip",
.{i},
"duplicate client ip {f} (canonical form {f})",
.{ safe_url.quoteText(client.ip), safe_url.quoteText(text) },
);
}
} else |_| {
try diags.add(
error.BadClientIp,
"clients[{d}].ip",
.{i},
"{f} is not an IP address",
.{safe_url.quoteText(client.ip)},
);
}
try checkGroupRef(diags, &group_names, client.group, "clients[{d}].group", .{i});
}
var client_prefixes: IndexSet = .empty;
for (cfg.client_prefixes, 0..) |entry, i| {
if (Prefix.parse(entry.prefix)) |prefix| {
const text = try canonical(scratch, prefix);
if (try firstSeen(&client_prefixes, scratch, text, i)) |_| {
try diags.add(
error.DuplicateClientPrefix,
"client_prefixes[{d}].prefix",
.{i},
"duplicate client prefix {f} (canonical form {f})",
.{ safe_url.quoteText(entry.prefix), safe_url.quoteText(text) },
);
}
} else |_| {
try diags.add(
error.BadClientPrefix,
"client_prefixes[{d}].prefix",
.{i},
"{f} is not a CIDR prefix",
.{safe_url.quoteText(entry.prefix)},
);
}
try checkGroupRef(diags, &group_names, entry.group, "client_prefixes[{d}].group", .{i});
}
var source_urls: IndexSet = .empty;
for (cfg.blocklist_sources, 0..) |source, i| {
if (!sourceUrlIsValid(source.url)) {
try diags.add(
error.BadSourceUrl,
"blocklist_sources[{d}].url",
.{i},
"{f} is not an http:// or https:// url with a host",
.{safe_url.redactQuoted(source.url)},
);
}
if (try firstSeen(&source_urls, scratch, source.url, i)) |first| {
try diags.add(
error.DuplicateSourceUrl,
"blocklist_sources[{d}].url",
.{i},
"duplicate of blocklist_sources[{d}]",
.{first},
);
}
if (source.name.len == 0) {
try diags.add(
error.EmptySourceName,
"blocklist_sources[{d}].name",
.{i},
"source name is empty",
.{},
);
}
}
var group_source_pairs: IndexSet = .empty;
var linked_sources: IndexSet = .empty;
for (cfg.group_sources, 0..) |link, i| {
try checkGroupRef(diags, &group_names, link.group, "group_sources[{d}].group", .{i});
_ = try firstSeen(&linked_sources, scratch, link.source_url, i);
if (!source_urls.contains(link.source_url)) {
try diags.add(
error.UnknownSource,
"group_sources[{d}].source_url",
.{i},
"unknown blocklist source {f}",
.{safe_url.redactQuoted(link.source_url)},
);
}
const key = try std.fmt.allocPrint(scratch, "{s}\x00{s}", .{ link.group, link.source_url });
if (try firstSeen(&group_source_pairs, scratch, key, i)) |_| {
try diags.add(
error.DuplicateGroupSource,
"group_sources[{d}]",
.{i},
"duplicate link from group {f} to source {f}",
.{ safe_url.quoteText(link.group), safe_url.redactQuoted(link.source_url) },
);
}
}
// A source no group links to is downloaded, parsed and compiled into
// nothing: `filter/compiler.zig` builds one set per group out of the
// `group_sources` links, so an unlinked source blocks not one domain. The
// operator sees a healthy source with a domain count and a filter that does
// nothing, which is exactly the shape of the tutorial session that found
// this. Legal configuration — a list may be staged before it is attached —
// so it warns and leaves the exit code alone.
for (cfg.blocklist_sources, 0..) |source, i| {
if (linked_sources.contains(source.url)) continue;
try diags.addWarning(
error.SourceInNoGroup,
"blocklist_sources[{d}]",
.{i},
"source {f} {f} belongs to no group, so nothing it lists is blocked; link it from group_sources",
.{ safe_url.quoteText(source.name), safe_url.redactQuoted(source.url) },
);
}
for (cfg.rules, 0..) |rule, i| {
try checkGroupRef(diags, &group_names, rule.group, "rules[{d}].group", .{i});
if (!try patternIsValid(scratch, rule.pattern, rule.kind)) {
try diags.add(
error.BadRulePattern,
"rules[{d}].pattern",
.{i},
"{f} is not a valid {s} pattern",
.{ safe_url.quoteText(rule.pattern), rule.kind.toDb() },
);
}
}
var local_records: IndexSet = .empty;
for (cfg.local_records, 0..) |record, i| {
_ = dns_name.fromText(record.name) catch {
try diags.add(
error.BadLocalRecordName,
"local_records[{d}].name",
.{i},
"{f} is not a valid domain name",
.{safe_url.quoteText(record.name)},
);
};
if (!recordValueIsValid(record.rtype, record.value)) {
try diags.add(
error.BadLocalRecordValue,
"local_records[{d}].value",
.{i},
"{f} is not a valid {s} value",
.{ safe_url.quoteText(record.value), record.rtype.toDb() },
);
}
if (record.ttl < 1 or record.ttl > max_record_ttl_seconds) {
try diags.add(
error.BadTtl,
"local_records[{d}].ttl",
.{i},
"must be 1-{d}, got {d}",
.{ max_record_ttl_seconds, record.ttl },
);
}
const key = try std.fmt.allocPrint(
scratch,
"{s}\x00{s}\x00{s}",
.{ record.name, record.rtype.toDb(), record.value },
);
if (try firstSeen(&local_records, scratch, key, i)) |_| {
try diags.add(
error.DuplicateLocalRecord,
"local_records[{d}]",
.{i},
"duplicate local record {f} {s} {f}",
.{ safe_url.quoteText(record.name), record.rtype.toDb(), safe_url.quoteText(record.value) },
);
}
}
var zones: IndexSet = .empty;
for (cfg.forward_zones, 0..) |zone, i| {
_ = dns_name.fromText(zone.zone) catch {
try diags.add(
error.BadForwardZone,
"forward_zones[{d}].zone",
.{i},
"{f} is not a valid domain name",
.{safe_url.quoteText(zone.zone)},
);
};
if (try firstSeen(&zones, scratch, zone.zone, i)) |_| {
try diags.add(
error.DuplicateForwardZone,
"forward_zones[{d}].zone",
.{i},
"duplicate forward zone {f}",
.{safe_url.quoteText(zone.zone)},
);
}
_ = parseResolver(zone.resolver) catch {
try diags.add(
error.BadResolverUrl,
"forward_zones[{d}].resolver",
.{i},
"{f} is not a udp:// or tcp:// resolver with an IP literal and a port",
.{safe_url.redactQuoted(zone.resolver)},
);
};
}
}
fn checkGroupRef(
diags: *Diagnostics,
group_names: *const IndexSet,
name: []const u8,
comptime path_fmt: []const u8,
path_args: anytype,
) error{OutOfMemory}!void {
if (group_names.contains(name)) return;
try diags.add(
error.UnknownGroup,
path_fmt,
path_args,
"unknown group {f}",
.{safe_url.quoteText(name)},
);
}
pub const Authority = struct {
/// The host with the IPv6 brackets removed, never empty.
host: []const u8,
/// The port when the authority carried one, always 1-65535.
port: ?u16,
/// True when the host arrived in `[…]` form, so it is an IPv6 literal.
bracketed: bool,
};
const AuthorityError = error{ BadHost, BadPort, BadUrl };
/// The one set of rules this file applies to the authority of a url. The same
/// rules live in `transport.Endpoint.parse` for the two schemes that parser
/// accepts; this parser exists because the blocklist and resolver schemes
/// (`http://`, `udp://`, `tcp://`) are outside its scheme set, not because the
/// rules differ. Do not add a third set.
///
/// The grammar, over the text before any `/`:
///
/// authority = ( "[" ipv6 "]" / host ) [ ":" port ]
/// host = 1*( any byte except ":" "@" "?" "#" "[" "]",
/// above 0x20 and below 0x7F )
/// port = 1*DIGIT, value 1-65535
///
/// An empty authority, and an empty host before a `:`, name no host to dial.
/// `@`, `?` and `#` open the userinfo, query and fragment components, and
/// nothing here implements them, so keeping one as literal host text would let
/// this host disagree with the authority an RFC 3986 parser reads out of the
/// same url. A space or a control byte is never legal in a url. A `[` opens an
/// IP literal, so it must be the first byte, it must have a `]`, the text
/// between them must be an IPv6 address, and only a port may follow the `]`.
fn parseAuthority(authority: []const u8) AuthorityError!Authority {
for (authority) |byte| switch (byte) {
'@', '?', '#' => return error.BadUrl,
// Everything outside printable ASCII: C0 controls and space, DEL, and
// the 0x80+ range (C1 controls and raw non-ASCII — a host is IDNA
// punycode by the time it is configuration text).
0...' ', 0x7F...0xFF => return error.BadUrl,
else => {},
};
if (authority.len == 0) return error.BadHost;
const host, const port_text, const bracketed = split: {
if (authority[0] == '[') {
const close = std.mem.findScalar(u8, authority, ']') orelse return error.BadUrl;
const tail = authority[close + 1 ..];
if (tail.len == 0) break :split .{ authority[1..close], null, true };
if (tail[0] != ':') return error.BadUrl;
break :split .{ authority[1..close], tail[1..], true };
}
const colon = std.mem.findScalar(u8, authority, ':') orelse
break :split .{ authority, null, false };
break :split .{ authority[0..colon], authority[colon + 1 ..], false };
};
if (host.len == 0) return error.BadHost;
if (bracketed) {
const addr = NetAddress.parse(host) catch return error.BadHost;
if (std.meta.activeTag(addr) != NetAddress.ip6) return error.BadHost;
} else if (std.mem.findAny(u8, host, "[]") != null) {
return error.BadUrl;
}
const port: ?u16 = if (port_text) |text| blk: {
if (text.len == 0) return error.BadPort;
for (text) |byte| {
if (byte < '0' or byte > '9') return error.BadPort;
}
const value = std.fmt.parseInt(u16, text, 10) catch return error.BadPort;
if (value == 0) return error.BadPort;
break :blk value;
} else null;
return .{ .host = host, .port = port, .bracketed = bracketed };
}
/// True for an `http://` or `https://` url whose authority passes
/// `parseAuthority` and whose remainder carries no space or control byte.
/// The plain scheme is allowed because a list can live on the LAN.
///
/// The path is not interpreted: a blocklist url is fetched, never dialed by
/// host and path separately, so a query string in it is the source's business.
fn sourceUrlIsValid(url: []const u8) bool {
const rest = if (std.mem.startsWith(u8, url, "http://"))
url["http://".len..]
else if (std.mem.startsWith(u8, url, "https://"))
url["https://".len..]
else
return false;
const end = std.mem.findScalar(u8, rest, '/') orelse rest.len;
_ = parseAuthority(rest[0..end]) catch return false;
for (rest[end..]) |byte| {
if (byte <= ' ' or byte == 0x7F) return false;
}
return true;
}
/// Syntax only. Matching semantics are Phase 5's: an exact pattern carries no
/// `*` at all, a wildcard pattern carries at least one label that is exactly
/// `*`, and every remaining label must survive `dns.name.fromText`.
fn patternIsValid(
scratch: Allocator,
pattern: []const u8,
kind: model.RuleKind,
) error{OutOfMemory}!bool {
switch (kind) {
.exact => {
if (std.mem.findScalar(u8, pattern, '*') != null) return false;
_ = dns_name.fromText(pattern) catch return false;
return true;
},
.wildcard => {
var stars: usize = 0;
var substituted: std.ArrayList(u8) = .empty;
var it = std.mem.splitScalar(u8, pattern, '.');
var first = true;
while (it.next()) |label| {
if (!first) try substituted.append(scratch, '.');
first = false;
if (std.mem.eql(u8, label, "*")) {
stars += 1;
try substituted.append(scratch, 'x');
} else {
try substituted.appendSlice(scratch, label);
}
}
if (stars == 0) return false;
_ = dns_name.fromText(substituted.items) catch return false;
return true;
},
}
}
fn recordValueIsValid(rtype: model.RecordType, value: []const u8) bool {
switch (rtype) {
.a => {
const addr = NetAddress.parse(value) catch return false;
return std.meta.activeTag(addr) == NetAddress.ip4;
},
.aaaa => {
const addr = NetAddress.parse(value) catch return false;
return std.meta.activeTag(addr) == NetAddress.ip6;
},
.cname => {
_ = dns_name.fromText(value) catch return false;
return true;
},
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
/// A config that validates cleanly. Every test below mutates one thing.
fn baseConfig() Config {
return .{
.groups = &.{.{ .name = "default" }},
.upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
};
}
fn expectProblem(cfg: Config, expected: ValidateError, expected_path: []const u8) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(expected, validate(cfg, &diags));
const failure = diags.firstFailure() orelse return error.TestExpectedFailure;
try testing.expectEqualStrings(expected_path, failure.path);
try testing.expectEqual(expected, failure.err);
}
/// Clean means no failures AND no warnings: a warning nobody asked for is a
/// false alarm in `nxdns check`, so it has to be as hard to add by accident as
/// a failure is.
fn expectClean(cfg: Config) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
const result = validate(cfg, &diags);
const failed = if (result) |_| false else |_| true;
if (failed or diags.problems.items.len != 0) {
var buf: [4096]u8 = undefined;
var w: Writer = .fixed(&buf);
diags.writeAll(&w) catch {};
std.debug.print("unexpected problems:\n{s}", .{w.buffered()});
}
try result;
try testing.expectEqual(@as(usize, 0), diags.problems.items.len);
}
test "a default config with one enabled upstream and a default group validates cleanly" {
try expectClean(baseConfig());
}
test "a fully populated config validates cleanly" {
var cfg = baseConfig();
cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } };
cfg.upstreams = &.{
.{ .url = "https://dns.example/dns-query" },
.{ .url = "tls://1.1.1.1", .priority = 200, .enabled = false },
};
cfg.clients = &.{
.{ .ip = "192.168.1.10", .name = "laptop" },
.{ .ip = "fd00::1", .group = "kids" },
};
cfg.client_prefixes = &.{.{ .prefix = "192.168.2.0/24", .group = "kids" }};
cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }};
cfg.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/hosts.txt" }};
cfg.rules = &.{
.{ .group = "kids", .pattern = "ads.example.com", .kind = .exact, .action = .block },
.{ .group = "default", .pattern = "*.tracker.example", .kind = .wildcard, .action = .allow },
};
cfg.local_records = &.{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::5" },
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan" },
};
cfg.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" }};
try expectClean(cfg);
}
test "error.NoUpstreams when nothing is enabled" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "https://dns.example/dns-query", .enabled = false }};
try expectProblem(cfg, error.NoUpstreams, "upstreams");
}
test "error.BadUpstreamUrl on an unsupported scheme" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "ftp://dns.example/" }};
try expectProblem(cfg, error.BadUpstreamUrl, "upstreams[0].url");
}
test "error.DuplicateUpstreamUrl" {
var cfg = baseConfig();
cfg.upstreams = &.{
.{ .url = "https://dns.example/dns-query" },
.{ .url = "https://dns.example/dns-query" },
};
try expectProblem(cfg, error.DuplicateUpstreamUrl, "upstreams[1].url");
}
test "a tls_name on a tls:// upstream validates cleanly" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "one.one.one.one" }};
try expectClean(cfg);
}
test "error.BadTlsName on a malformed name" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "one..one.one" }};
try expectProblem(cfg, error.BadTlsName, "upstreams[0].tls_name");
var too_long = baseConfig();
too_long.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = "a" ** 64 ++ ".example" }};
try expectProblem(too_long, error.BadTlsName, "upstreams[0].tls_name");
}
test "error.TlsNameOnNonTlsUpstream on a DoH upstream" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "https://dns.example/dns-query", .tls_name = "dns.example" }};
try expectProblem(cfg, error.TlsNameOnNonTlsUpstream, "upstreams[0].tls_name");
}
test "an empty tls_name is accepted on every scheme" {
var cfg = baseConfig();
cfg.upstreams = &.{
.{ .url = "https://dns.example/dns-query" },
.{ .url = "tls://9.9.9.9:853" },
};
try expectClean(cfg);
}
test "error.MissingDefaultGroup" {
var cfg = baseConfig();
cfg.groups = &.{.{ .name = "kids" }};
try expectProblem(cfg, error.MissingDefaultGroup, "groups");
}
test "error.DuplicateGroupName" {
var cfg = baseConfig();
cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids" }, .{ .name = "kids" } };
try expectProblem(cfg, error.DuplicateGroupName, "groups[2].name");
}
test "error.UnknownGroup" {
var cfg = baseConfig();
cfg.clients = &.{.{ .ip = "192.168.1.10", .group = "kids" }};
try expectProblem(cfg, error.UnknownGroup, "clients[0].group");
}
test "error.EmptyGroupName" {
var cfg = baseConfig();
cfg.groups = &.{ .{ .name = "default" }, .{ .name = "" } };
try expectProblem(cfg, error.EmptyGroupName, "groups[1].name");
}
test "error.BadClientIp" {
var cfg = baseConfig();
cfg.clients = &.{.{ .ip = "nonsense" }};
try expectProblem(cfg, error.BadClientIp, "clients[0].ip");
}
test "error.DuplicateClientIp" {
var cfg = baseConfig();
cfg.clients = &.{ .{ .ip = "192.168.1.10" }, .{ .ip = "192.168.1.10" } };
try expectProblem(cfg, error.DuplicateClientIp, "clients[1].ip");
}
test "error.BadClientPrefix" {
var cfg = baseConfig();
cfg.client_prefixes = &.{.{ .prefix = "192.168.1.0" }};
try expectProblem(cfg, error.BadClientPrefix, "client_prefixes[0].prefix");
}
test "error.DuplicateClientPrefix" {
var cfg = baseConfig();
cfg.client_prefixes = &.{
.{ .prefix = "192.168.1.0/24" },
.{ .prefix = "192.168.1.55/24" },
};
try expectProblem(cfg, error.DuplicateClientPrefix, "client_prefixes[1].prefix");
}
test "error.BadSourceUrl" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{.{ .url = "ftp://lists.example/hosts.txt", .name = "example" }};
try expectProblem(cfg, error.BadSourceUrl, "blocklist_sources[0].url");
}
test "a source url is rejected when its authority breaks an Endpoint.parse rule" {
const malformed = [_][]const u8{
"https://?x",
"https://user@h/l",
"https:///l",
"https://h h/l",
"https://#f",
"https://",
"http://",
"https://:443/list",
"https://[::1/l",
"https://[]:80/l",
"https://[::1]x:80/l",
"https://h:/l",
"https://h:abc/l",
"https://h:0/l",
"https://h:70000/l",
"https://[nothex]/l",
};
for (malformed) |url| {
var cfg = baseConfig();
const sources = [_]model.BlocklistSource{.{ .url = url, .name = "example" }};
cfg.blocklist_sources = &sources;
try expectProblem(cfg, error.BadSourceUrl, "blocklist_sources[0].url");
}
}
test "a source url is accepted over https and over http" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{
.{ .url = "https://lists.example/hosts.txt", .name = "secure" },
.{ .url = "http://nas.lan:8080/lists/hosts.txt?v=2", .name = "lan" },
.{ .url = "https://[fd00::1]/hosts.txt", .name = "literal" },
.{ .url = "https://[fd00::2]:8443/hosts.txt", .name = "literal with port" },
.{ .url = "https://lists.example", .name = "no path" },
};
// Each one is linked, because an unlinked source is a warning of its own.
cfg.group_sources = &.{
.{ .group = "default", .source_url = "https://lists.example/hosts.txt" },
.{ .group = "default", .source_url = "http://nas.lan:8080/lists/hosts.txt?v=2" },
.{ .group = "default", .source_url = "https://[fd00::1]/hosts.txt" },
.{ .group = "default", .source_url = "https://[fd00::2]:8443/hosts.txt" },
.{ .group = "default", .source_url = "https://lists.example" },
};
try expectClean(cfg);
}
/// Renders every diagnostic `cfg` produces and fails if the secret survived
/// anywhere in the text, naming the line that leaked it.
///
/// Over the whole report rather than one message, deliberately: a url reaches
/// nine diagnostics here, and a fix that redacts the line a test names while a
/// neighbouring line still prints the password is not a fix.
fn expectNoSecret(cfg: Config, secret: []const u8) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
validate(cfg, &diags) catch |e| switch (e) {
error.OutOfMemory => return e,
else => {},
};
try testing.expect(diags.problems.items.len != 0);
for (diags.problems.items) |problem| {
if (std.mem.containsAtLeast(u8, problem.message, 1, secret)) {
std.debug.print(
"'{s}' reached a diagnostic: {s}: {s}\n",
.{ secret, problem.path, problem.message },
);
return error.SecretInDiagnostic;
}
}
}
test "no diagnostic prints a url's userinfo or query" {
// Observed on the real path: a rejected source url put the operator's
// password on stderr, which under systemd is journald, where it stays.
// Rejecting the url is not a defence — the line is written either way.
const leaky_source = "http://lists:hunter2@127.0.0.1:9/hosts.txt";
var bad_source = baseConfig();
bad_source.blocklist_sources = &.{.{ .url = leaky_source, .name = "ads" }};
try expectNoSecret(bad_source, "hunter2");
// The same url twice: reported by `DuplicateSourceUrl`, and by the
// no-group warning that a legal one earns.
var duplicate_source = baseConfig();
duplicate_source.blocklist_sources = &.{
.{ .url = "https://token:hunter2@lists.example/hosts.txt", .name = "a" },
.{ .url = "https://token:hunter2@lists.example/hosts.txt", .name = "b" },
};
try expectNoSecret(duplicate_source, "hunter2");
// An api key in the query, through the link that names an undeclared source
// and the duplicate link beside it.
var link = baseConfig();
link.group_sources = &.{
.{ .group = "default", .source_url = "https://lists.example/h.txt?apikey=hunter2" },
.{ .group = "default", .source_url = "https://lists.example/h.txt?apikey=hunter2" },
};
try expectNoSecret(link, "hunter2");
// Upstreams carry the same shapes and print through the same channel.
var bad_upstream = baseConfig();
bad_upstream.upstreams = &.{.{ .url = "ftp://user:hunter2@dns.example/" }};
try expectNoSecret(bad_upstream, "hunter2");
var duplicate_upstream = baseConfig();
duplicate_upstream.upstreams = &.{
.{ .url = "https://dns.example/dns-query?key=hunter2" },
.{ .url = "https://dns.example/dns-query?key=hunter2" },
};
try expectNoSecret(duplicate_upstream, "hunter2");
// A `tls_name` on a DoH upstream reports the url it does not belong to.
var tls_name = baseConfig();
tls_name.upstreams = &.{.{
.url = "https://dns.example/dns-query?key=hunter2",
.tls_name = "dns.example",
}};
try expectNoSecret(tls_name, "hunter2");
// A resolver may not carry userinfo at all, which means the line that
// reports one is the line that would print it.
var resolver = baseConfig();
resolver.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "udp://user:hunter2@192.168.1.1:53" }};
try expectNoSecret(resolver, "hunter2");
}
/// Renders every diagnostic `cfg` produces and fails if any control byte
/// survived into the text, naming the line that carried it.
///
/// Over the whole report rather than one message, for the reason
/// `expectNoSecret` is: one operator-supplied name reaches several diagnostics,
/// and escaping the line a test names while a neighbouring line writes the raw
/// byte is not a fix.
fn expectNoControlByte(cfg: Config) !void {
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
validate(cfg, &diags) catch |e| switch (e) {
error.OutOfMemory => return e,
else => {},
};
try testing.expect(diags.problems.items.len != 0);
for (diags.problems.items) |problem| {
for (problem.message) |byte| {
if (byte >= 0x20 and byte != 0x7f) continue;
std.debug.print(
"a control byte reached a diagnostic: {s}: {s}\n",
.{ problem.path, problem.message },
);
return error.ControlByteInDiagnostic;
}
}
}
test "no diagnostic prints an operator's control characters" {
// Every text field an operator can write, carrying the byte that ends a
// line. A diagnostic goes to `nxdns check`'s stdout and to the body of a
// 400 from `POST /api/blocklists`, and neither is a `std.log` line, so
// nothing downstream escapes what these lines print.
const forged = "ads\n2026-01-01 ERROR nxdns: forged\x1b[2K";
var groups = baseConfig();
groups.groups = &.{ .{ .name = "default" }, .{ .name = forged }, .{ .name = forged } };
try expectNoControlByte(groups);
var unknown_group = baseConfig();
unknown_group.clients = &.{.{ .ip = "192.168.1.10", .group = forged }};
try expectNoControlByte(unknown_group);
var client = baseConfig();
client.clients = &.{.{ .ip = forged }};
try expectNoControlByte(client);
var prefix = baseConfig();
prefix.client_prefixes = &.{.{ .prefix = forged }};
try expectNoControlByte(prefix);
// The reported line: a name is printed by a warning that a legal
// configuration earns, so this one needs no invalid value to reach output.
var source = baseConfig();
source.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = forged }};
try expectNoControlByte(source);
var link = baseConfig();
link.group_sources = &.{.{ .group = forged, .source_url = "https://lists.example/hosts.txt" }};
try expectNoControlByte(link);
// A `*` is what makes this pattern invalid for an exact rule, and so what
// makes it reach the diagnostic: a label may hold any byte on the wire, so
// `dns.name.fromText` accepts a newline inside one and a control character
// alone does not reject a pattern.
var rule = baseConfig();
rule.rules = &.{.{ .group = "default", .pattern = "*" ++ forged, .kind = .exact, .action = .block }};
try expectNoControlByte(rule);
var record = baseConfig();
record.local_records = &.{.{ .name = forged, .rtype = .a, .value = forged }};
try expectNoControlByte(record);
var duplicate_record = baseConfig();
duplicate_record.local_records = &.{
.{ .name = forged, .rtype = .cname, .value = "nas.lan" },
.{ .name = forged, .rtype = .cname, .value = "nas.lan" },
};
try expectNoControlByte(duplicate_record);
var zone = baseConfig();
zone.forward_zones = &.{
.{ .zone = forged, .resolver = "udp://192.168.1.1:53" },
.{ .zone = forged, .resolver = "udp://192.168.1.1:53" },
};
try expectNoControlByte(zone);
// The empty label is what rejects this name, for the reason the rule
// pattern above needs a `*`.
var tls_name = baseConfig();
tls_name.upstreams = &.{.{ .url = "tls://1.1.1.1:853", .tls_name = ".." ++ forged }};
try expectNoControlByte(tls_name);
var bind = baseConfig();
bind.dns.bind_ipv4 = forged;
try expectNoControlByte(bind);
var log_path = baseConfig();
log_path.logging.output = .file;
log_path.logging.file_path = forged;
try expectNoControlByte(log_path);
}
test "a source name cannot forge a line or close the quotes around it" {
// The name is operator-supplied text out of the configuration file or out of
// a database row, printed beside a url. Unescaped, the `'` closes the quotes
// the message put around it and the `\n` ends the line, so the warning below
// would print a second line naming a url this source does not have.
var cfg = baseConfig();
cfg.blocklist_sources = &.{.{
.url = "https://lists.example/hosts.txt",
.name = "ads'\n2026-01-01 ERROR forged (https://decoy.example)",
}};
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
// Legal configuration: the name is ugly, not invalid.
try validate(cfg, &diags);
try testing.expectEqual(@as(usize, 1), diags.warningCount());
const problem = diags.problems.items[0];
// The whole message, so the assertion fails in both directions: it fails if
// an escape is dropped, and it fails if the quotes are written twice.
try testing.expectEqualStrings(
"source 'ads\\'\\n2026-01-01 ERROR forged (https://decoy.example)' " ++
"'https://lists.example' belongs to no group, so nothing it lists is blocked; " ++
"link it from group_sources",
problem.message,
);
// What the operator sees: one line, which is the property the escaping
// exists for. `writeAll` ends each problem with the only newline in it.
var buf: [512]u8 = undefined;
var w: Writer = .fixed(&buf);
try diags.writeAll(&w);
try testing.expectEqual(@as(usize, 1), std.mem.count(u8, w.buffered(), "\n"));
try testing.expect(std.mem.endsWith(u8, w.buffered(), "link it from group_sources\n"));
}
test "a redacted diagnostic still names the source the operator has to fix" {
// Redaction that costs the operator the identity of the offending entry
// would trade one failure for another. The message keeps the scheme, the
// host and the port — where the source points — and nothing else: the two
// sources here sit on one host and differ only in the path, so the message
// cannot tell them apart and is not asked to. What names the entry is
// `path`, which carries the index into the config, and that is the whole
// reason the url may be reduced to what does not carry a credential.
var cfg = baseConfig();
cfg.blocklist_sources = &.{
.{ .url = "http://127.0.0.1:9/good.txt", .name = "ok" },
.{ .url = "http://lists:hunter2@127.0.0.1:9/subscribe/tok3n/hosts.txt", .name = "leaky" },
};
cfg.group_sources = &.{
.{ .group = "default", .source_url = "http://127.0.0.1:9/good.txt" },
.{ .group = "default", .source_url = "http://lists:hunter2@127.0.0.1:9/subscribe/tok3n/hosts.txt" },
};
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.BadSourceUrl, validate(cfg, &diags));
const problem = diags.firstFailure() orelse return error.TestExpectedFailure;
try testing.expectEqualStrings("blocklist_sources[1].url", problem.path);
// The whole message, so the assertion fails in both directions: it fails if
// the userinfo or the path token comes back, and it fails if redaction
// over-reaches and stops saying where the source points.
try testing.expectEqualStrings(
"'http://127.0.0.1:9' is not an http:// or https:// url with a host",
problem.message,
);
try testing.expect(!std.mem.containsAtLeast(u8, problem.message, 1, "hunter2"));
try testing.expect(!std.mem.containsAtLeast(u8, problem.message, 1, "tok3n"));
}
test "a duplicate url names the entry it duplicates" {
// Two NextDNS profiles differ only in the path, which `safe_url.redact`
// drops, so a message quoting the url prints the same text for a colliding
// pair and for an unrelated one. The index is the evidence instead, and it
// is the first entry's index: `upstreams[2]` collides with `upstreams[0]`
// here, and nothing derived from the loop counter can produce that.
var cfg = baseConfig();
cfg.upstreams = &.{
.{ .url = "https://dns.nextdns.io/abcd12" },
.{ .url = "https://dns.nextdns.io/efgh34" },
.{ .url = "https://dns.nextdns.io/abcd12" },
};
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.DuplicateUpstreamUrl, validate(cfg, &diags));
try testing.expectEqual(@as(usize, 1), diags.failureCount());
const problem = diags.firstFailure() orelse return error.TestExpectedFailure;
try testing.expectEqualStrings("upstreams[2].url", problem.path);
try testing.expectEqualStrings("duplicate of upstreams[0]", problem.message);
}
test "a duplicate source url names the entry it duplicates" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{
.{ .url = "https://lists.example/d/tok3n/hosts.txt", .name = "first" },
.{ .url = "https://lists.example/d/other/hosts.txt", .name = "second" },
.{ .url = "https://lists.example/d/tok3n/hosts.txt", .name = "third" },
};
cfg.group_sources = &.{
.{ .group = "default", .source_url = "https://lists.example/d/tok3n/hosts.txt" },
.{ .group = "default", .source_url = "https://lists.example/d/other/hosts.txt" },
};
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.DuplicateSourceUrl, validate(cfg, &diags));
try testing.expectEqual(@as(usize, 1), diags.failureCount());
const problem = diags.firstFailure() orelse return error.TestExpectedFailure;
try testing.expectEqualStrings("blocklist_sources[2].url", problem.path);
try testing.expectEqualStrings("duplicate of blocklist_sources[0]", problem.message);
// The token the url carries is what the old wording printed, twice.
try testing.expect(!std.mem.containsAtLeast(u8, problem.message, 1, "tok3n"));
}
test "a blocklist source in no group is one warning and no failure" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "stevenblack" }};
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
// Legal configuration: no error, so no subcommand may fail on it.
try validate(cfg, &diags);
try testing.expectEqual(@as(usize, 0), diags.failureCount());
try testing.expectEqual(@as(usize, 1), diags.warningCount());
try testing.expectEqual(@as(?Problem, null), diags.firstFailure());
const problem = diags.problems.items[0];
try testing.expectEqual(Severity.warn, problem.severity);
try testing.expectEqual(ProblemError.SourceInNoGroup, problem.err);
try testing.expectEqualStrings("blocklist_sources[0]", problem.path);
try testing.expect(std.mem.containsAtLeast(u8, problem.message, 1, "stevenblack"));
var buf: [512]u8 = undefined;
var w: Writer = .fixed(&buf);
try diags.writeAll(&w);
try testing.expect(std.mem.startsWith(u8, w.buffered(), "WARN blocklist_sources[0]: "));
}
test "a source linked from any group is not warned about" {
var cfg = baseConfig();
cfg.groups = &.{ .{ .name = "default" }, .{ .name = "kids" } };
cfg.blocklist_sources = &.{
.{ .url = "https://lists.example/a.txt", .name = "a" },
.{ .url = "https://lists.example/b.txt", .name = "b" },
};
cfg.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/a.txt" }};
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try validate(cfg, &diags);
try testing.expectEqual(@as(usize, 1), diags.warningCount());
try testing.expectEqualStrings("blocklist_sources[1]", diags.problems.items[0].path);
}
test "a warning is reported next to a failure without becoming one" {
var cfg = baseConfig();
cfg.dns.port = 0;
cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "ads" }};
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.BadPort, validate(cfg, &diags));
try testing.expectEqual(@as(usize, 1), diags.failureCount());
try testing.expectEqual(@as(usize, 1), diags.warningCount());
var buf: [512]u8 = undefined;
var w: Writer = .fixed(&buf);
try diags.writeAll(&w);
try testing.expect(std.mem.startsWith(u8, w.buffered(), "FAIL dns.port: "));
try testing.expect(std.mem.containsAtLeast(u8, w.buffered(), 1, "\nWARN blocklist_sources[0]: "));
}
test "error.DuplicateSourceUrl" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{
.{ .url = "https://lists.example/hosts.txt", .name = "a" },
.{ .url = "https://lists.example/hosts.txt", .name = "b" },
};
try expectProblem(cfg, error.DuplicateSourceUrl, "blocklist_sources[1].url");
}
test "error.EmptySourceName" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "" }};
try expectProblem(cfg, error.EmptySourceName, "blocklist_sources[0].name");
}
test "error.UnknownSource" {
var cfg = baseConfig();
cfg.group_sources = &.{.{ .group = "default", .source_url = "https://lists.example/hosts.txt" }};
try expectProblem(cfg, error.UnknownSource, "group_sources[0].source_url");
}
test "error.DuplicateGroupSource" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }};
cfg.group_sources = &.{
.{ .group = "default", .source_url = "https://lists.example/hosts.txt" },
.{ .group = "default", .source_url = "https://lists.example/hosts.txt" },
};
try expectProblem(cfg, error.DuplicateGroupSource, "group_sources[1]");
}
test "error.BadRulePattern" {
var cfg = baseConfig();
cfg.rules = &.{.{ .group = "default", .pattern = "*.ads.example", .kind = .exact, .action = .block }};
try expectProblem(cfg, error.BadRulePattern, "rules[0].pattern");
}
test "error.BadLocalRecordName" {
const too_long = "a" ** 64;
var cfg = baseConfig();
cfg.local_records = &.{.{ .name = too_long ++ ".lan", .rtype = .a, .value = "192.168.1.5" }};
try expectProblem(cfg, error.BadLocalRecordName, "local_records[0].name");
}
test "error.BadLocalRecordValue" {
var cfg = baseConfig();
cfg.local_records = &.{.{ .name = "nas.lan", .rtype = .a, .value = "fd00::1" }};
try expectProblem(cfg, error.BadLocalRecordValue, "local_records[0].value");
}
test "error.DuplicateLocalRecord" {
var cfg = baseConfig();
cfg.local_records = &.{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
};
try expectProblem(cfg, error.DuplicateLocalRecord, "local_records[1]");
}
test "error.BadForwardZone" {
const too_long = "a" ** 64;
var cfg = baseConfig();
cfg.forward_zones = &.{.{ .zone = too_long ++ ".lan", .resolver = "udp://192.168.1.1:53" }};
try expectProblem(cfg, error.BadForwardZone, "forward_zones[0].zone");
}
test "error.DuplicateForwardZone" {
var cfg = baseConfig();
cfg.forward_zones = &.{
.{ .zone = "corp.lan", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "corp.lan", .resolver = "tcp://192.168.1.2:53" },
};
try expectProblem(cfg, error.DuplicateForwardZone, "forward_zones[1].zone");
}
test "error.BadResolverUrl" {
var cfg = baseConfig();
cfg.forward_zones = &.{.{ .zone = "corp.lan", .resolver = "https://resolver.example/" }};
try expectProblem(cfg, error.BadResolverUrl, "forward_zones[0].resolver");
}
test "error.BadPort" {
var cfg = baseConfig();
cfg.dns.port = 0;
try expectProblem(cfg, error.BadPort, "dns.port");
}
test "error.BadTimeout" {
var cfg = baseConfig();
cfg.upstream.read_timeout_ms = 10;
try expectProblem(cfg, error.BadTimeout, "upstream.read_timeout_ms");
var budget = baseConfig();
budget.upstream = .{ .read_timeout_ms = 4000, .total_timeout_ms = 1000 };
try expectProblem(budget, error.BadTimeout, "upstream.total_timeout_ms");
}
test "error.BadTtl" {
var cfg = baseConfig();
cfg.blocking.ttl = 90_000;
try expectProblem(cfg, error.BadTtl, "blocking.ttl");
var record = baseConfig();
record.local_records = &.{.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 0 }};
try expectProblem(record, error.BadTtl, "local_records[0].ttl");
}
test "error.BadRetention" {
var cfg = baseConfig();
cfg.logging.retention_days = 0;
try expectProblem(cfg, error.BadRetention, "logging.retention_days");
}
test "error.BadLogRotation" {
var cfg = baseConfig();
cfg.logging.max_files = 0;
try expectProblem(cfg, error.BadLogRotation, "logging.max_files");
}
test "error.BadDiskThresholds" {
var cfg = baseConfig();
cfg.disk = .{ .min_free_mb = 600, .warn_free_mb = 500 };
try expectProblem(cfg, error.BadDiskThresholds, "disk.min_free_mb");
}
test "error.BadRateLimit" {
var cfg = baseConfig();
cfg.dns.rate_limit = 0;
try expectProblem(cfg, error.BadRateLimit, "dns.rate_limit");
}
test "error.BadBindAddress" {
var cfg = baseConfig();
cfg.dns.bind_ipv4 = "::";
try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv4");
var web = baseConfig();
web.web.bind = "not an address";
try expectProblem(web, error.BadBindAddress, "web.bind");
}
test "error.BadBindAddress on an IPv4 literal in dns.bind_ipv6" {
var cfg = baseConfig();
cfg.dns.bind_ipv6 = "0.0.0.0";
try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv6");
}
test "error.MissingCertPath" {
var cfg = baseConfig();
cfg.doh_server = .{ .enabled = true, .cert_path = "" };
try expectProblem(cfg, error.MissingCertPath, "doh_server.cert_path");
}
test "error.MissingKeyPath" {
var cfg = baseConfig();
cfg.dot_server = .{ .enabled = true, .port = 853, .key_path = "" };
try expectProblem(cfg, error.MissingKeyPath, "dot_server.key_path");
}
test "error.MissingLogPath" {
var cfg = baseConfig();
cfg.logging.output = .file;
cfg.logging.file_path = "";
try expectProblem(cfg, error.MissingLogPath, "logging.file_path");
var relative = baseConfig();
relative.logging.output = .file;
relative.logging.file_path = "nxdns.log";
try expectProblem(relative, error.MissingLogPath, "logging.file_path");
}
test "error.PasswordAndHashBothSet" {
var cfg = baseConfig();
cfg.web.password = "hunter2";
cfg.web.password_hash = "$argon2id$v=19$m=19456,t=2,p=1$abc$def";
try expectProblem(cfg, error.PasswordAndHashBothSet, "web.password");
}
test "a config with five distinct problems yields five diagnostics and the first error" {
var cfg = baseConfig();
cfg.dns.port = 0; // BadPort, first in check order
cfg.blocking.ttl = 90_000; // BadTtl
cfg.logging.retention_days = 0; // BadRetention
cfg.disk = .{ .min_free_mb = 600, .warn_free_mb = 500 }; // BadDiskThresholds
cfg.clients = &.{.{ .ip = "nonsense" }}; // BadClientIp
var diags: Diagnostics = .init(testing.allocator);
defer diags.deinit();
try testing.expectError(error.BadPort, validate(cfg, &diags));
try testing.expectEqual(@as(usize, 5), diags.problems.items.len);
try testing.expectEqual(@as(usize, 5), diags.failureCount());
try testing.expectEqualStrings("dns.port", diags.problems.items[0].path);
var buf: [1024]u8 = undefined;
var w: Writer = .fixed(&buf);
try diags.writeAll(&w);
try testing.expect(std.mem.startsWith(u8, w.buffered(), "FAIL dns.port: "));
try testing.expectEqual(@as(usize, 5), std.mem.count(u8, w.buffered(), "\n"));
}
test "duplicate client ips are detected across canonical forms" {
var cfg = baseConfig();
cfg.clients = &.{ .{ .ip = "fd00::1" }, .{ .ip = "FD00:0:0:0:0:0:0:1" } };
try expectProblem(cfg, error.DuplicateClientIp, "clients[1].ip");
}
test "duplicate client prefixes are detected across canonical forms" {
var cfg = baseConfig();
cfg.client_prefixes = &.{
.{ .prefix = "fd00:abcd::/48" },
.{ .prefix = "FD00:ABCD:0:1234::5/48" },
};
try expectProblem(cfg, error.DuplicateClientPrefix, "client_prefixes[1].prefix");
}
test "an unknown group is reported from each of the four referencing collections" {
var clients = baseConfig();
clients.clients = &.{.{ .ip = "192.168.1.10", .group = "kids" }};
try expectProblem(clients, error.UnknownGroup, "clients[0].group");
var prefixes = baseConfig();
prefixes.client_prefixes = &.{.{ .prefix = "192.168.2.0/24", .group = "kids" }};
try expectProblem(prefixes, error.UnknownGroup, "client_prefixes[0].group");
var links = baseConfig();
links.blocklist_sources = &.{.{ .url = "https://lists.example/hosts.txt", .name = "example" }};
links.group_sources = &.{.{ .group = "kids", .source_url = "https://lists.example/hosts.txt" }};
try expectProblem(links, error.UnknownGroup, "group_sources[0].group");
var rules = baseConfig();
rules.rules = &.{.{ .group = "kids", .pattern = "ads.example", .kind = .exact, .action = .block }};
try expectProblem(rules, error.UnknownGroup, "rules[0].group");
}
test "an unknown source is reported from group_sources" {
var cfg = baseConfig();
cfg.blocklist_sources = &.{.{ .url = "https://lists.example/a.txt", .name = "a" }};
cfg.group_sources = &.{.{ .group = "default", .source_url = "https://lists.example/b.txt" }};
try expectProblem(cfg, error.UnknownSource, "group_sources[0].source_url");
}
test "rule patterns accept wildcards only when the kind says so" {
var wildcard = baseConfig();
wildcard.rules = &.{
.{ .group = "default", .pattern = "*.ads.example", .kind = .wildcard, .action = .block },
.{ .group = "default", .pattern = "*", .kind = .wildcard, .action = .allow },
};
try expectClean(wildcard);
// A wildcard kind needs a label that is exactly "*".
var partial = baseConfig();
partial.rules = &.{.{ .group = "default", .pattern = "ads*.example", .kind = .wildcard, .action = .block }};
try expectProblem(partial, error.BadRulePattern, "rules[0].pattern");
// Every non-star label still has to be a legal label.
const too_long = "a" ** 64;
var bad_label = baseConfig();
bad_label.rules = &.{.{ .group = "default", .pattern = "*." ++ too_long, .kind = .wildcard, .action = .block }};
try expectProblem(bad_label, error.BadRulePattern, "rules[0].pattern");
}
test "parseResolver accepts udp and tcp with an IP literal and a port" {
const udp4 = try parseResolver("udp://192.168.1.1:53");
try testing.expectEqual(ResolverScheme.udp, udp4.scheme);
try testing.expectEqual(@as(u16, 53), udp4.port);
try testing.expect(udp4.addr.eql(try NetAddress.parse("192.168.1.1")));
const tcp6 = try parseResolver("tcp://[fd00::1]:53");
try testing.expectEqual(ResolverScheme.tcp, tcp6.scheme);
try testing.expectEqual(@as(u16, 53), tcp6.port);
try testing.expect(tcp6.addr.eql(try NetAddress.parse("fd00::1")));
}
test "parseResolver rejects everything else" {
try testing.expectError(error.UnsupportedScheme, parseResolver("https://x/"));
try testing.expectError(error.UnsupportedScheme, parseResolver("192.168.1.1:53"));
try testing.expectError(error.BadHost, parseResolver("udp://host.name:53"));
try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1"));
try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:0"));
try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:70000"));
try testing.expectError(error.BadPort, parseResolver("tcp://[fd00::1]"));
try testing.expectError(error.BadUrl, parseResolver("udp://1.1.1.1:53/path"));
try testing.expectError(error.BadUrl, parseResolver("udp://user@1.1.1.1:53"));
try testing.expectError(error.BadHost, parseResolver("udp://:53"));
try testing.expectError(error.BadHost, parseResolver("udp://"));
try testing.expectError(error.BadUrl, parseResolver("udp://[fd00::1:53"));
try testing.expectError(error.BadHost, parseResolver("udp://[]:53"));
try testing.expectError(error.BadUrl, parseResolver("udp://[fd00::1]x:53"));
try testing.expectError(error.BadHost, parseResolver("udp://[192.168.1.1]:53"));
try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:"));
try testing.expectError(error.BadUrl, parseResolver("udp://1.1.1.1:5 3"));
try testing.expectError(error.BadPort, parseResolver("udp://1.1.1.1:+53"));
}
test "parseAuthority splits the host from the port" {
const plain = try parseAuthority("lists.example");
try testing.expectEqualStrings("lists.example", plain.host);
try testing.expectEqual(@as(?u16, null), plain.port);
try testing.expect(!plain.bracketed);
const with_port = try parseAuthority("nas.lan:8080");
try testing.expectEqualStrings("nas.lan", with_port.host);
try testing.expectEqual(@as(?u16, 8080), with_port.port);
const v6 = try parseAuthority("[fd00::1]:853");
try testing.expectEqualStrings("fd00::1", v6.host);
try testing.expectEqual(@as(?u16, 853), v6.port);
try testing.expect(v6.bracketed);
const v6_no_port = try parseAuthority("[fd00::1]");
try testing.expectEqualStrings("fd00::1", v6_no_port.host);
try testing.expectEqual(@as(?u16, null), v6_no_port.port);
}