1925 lines
75 KiB
Zig
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);
|
|
}
|