//! 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); }