milestone 17: real deadlines, validator holes, upstream editor, trusted proxies, contract samples, badvers
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 4m55s
CI / frontend (push) Successful in 39s
CI / cross (push) Successful in 7m57s
CI / docker (push) Failing after 1h10m42s

This commit is contained in:
2026-08-07 17:55:59 +02:00
parent 9b12dbaaa0
commit c50c6d285a
57 changed files with 2926 additions and 126 deletions
+187 -6
View File
@@ -63,6 +63,7 @@ const Prefix = address.Prefix;
pub const ValidateError = error{
NoUpstreams,
BadUpstreamUrl,
UpstreamHostNotIpLiteral,
DuplicateUpstreamUrl,
BadTlsName,
TlsNameOnNonTlsUpstream,
@@ -89,11 +90,13 @@ pub const ValidateError = error{
BadPort,
BadTimeout,
BadTtl,
BadCacheSize,
BadRetention,
BadLogRotation,
BadDiskThresholds,
BadRateLimit,
BadBindAddress,
BadTrustedProxy,
MissingCertPath,
MissingKeyPath,
MissingLogPath,
@@ -310,17 +313,31 @@ const max_ttl_seconds = 86_400;
const max_record_ttl_seconds = 604_800;
const max_rate_window_seconds = 3_600;
/// The ceiling on every entry count nxdns allocates in full at boot: the query
/// log's ring and the DNS cache's slot array. Neither is grown on demand, so an
/// unchecked `maxInt(u32)` is a multi-terabyte allocation request during
/// startup — a `maxInt(u32)` query-log ring alone asks for about 1.8 TB. This
/// is a sanity bound, not a memory-fit guarantee: what actually fits depends on
/// the box, and nxdns does not try to know that.
const max_boot_entries = 1_000_000;
fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
const up = cfg.upstream;
try checkTimeout(diags, up.attempt_timeout_ms, "upstream.attempt_timeout_ms");
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) {
// The only cross-check that relates two knobs of one subsystem: the pool
// races one attempt against `attempt` and the whole failover loop against
// `total`, so an attempt budget above the total one can never be reached.
// `read_timeout_ms` belongs to the forward-zone client and is deliberately
// unrelated to both.
if (up.attempt_timeout_ms > up.total_timeout_ms) {
try diags.add(
error.BadTimeout,
"upstream.total_timeout_ms",
"upstream.attempt_timeout_ms",
.{},
"total budget {d}ms is below read {d}ms",
.{ up.total_timeout_ms, up.read_timeout_ms },
"attempt budget {d}ms is above the total budget {d}ms",
.{ up.attempt_timeout_ms, up.total_timeout_ms },
);
}
@@ -350,6 +367,19 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
);
}
if (cfg.cache.size < 1) {
try diags.add(error.BadCacheSize, "cache.size", .{}, "must be at least 1", .{});
}
if (cfg.cache.size > max_boot_entries) {
try diags.add(
error.BadCacheSize,
"cache.size",
.{},
"must be at most {d}, got {d}",
.{ max_boot_entries, cfg.cache.size },
);
}
if (cfg.cache.negative_ttl_max > max_ttl_seconds) {
try diags.add(
error.BadTtl,
@@ -381,6 +411,7 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
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 checkTrustedProxies(diags, cfg.web.trusted_proxies);
try checkTlsEndpoint(diags, cfg.doh_server, "doh_server");
try checkTlsEndpoint(diags, cfg.dot_server, "dot_server");
@@ -391,6 +422,15 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
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.query_log_buffer_max > max_boot_entries) {
try diags.add(
error.BadRetention,
"logging.query_log_buffer_max",
.{},
"must be at most {d}, got {d}",
.{ max_boot_entries, cfg.logging.query_log_buffer_max },
);
}
if (cfg.logging.max_size_mb < 1) {
try diags.add(error.BadLogRotation, "logging.max_size_mb", .{}, "must be at least 1", .{});
}
@@ -478,6 +518,25 @@ fn checkBind(
}
}
/// Every element must be an IP literal, because the web server compares the
/// socket peer against them byte for byte — a hostname here would silently
/// trust nothing, and an operator who wrote one would believe their proxy was
/// trusted while the API limiter kept seeing loopback.
fn checkTrustedProxies(diags: *Diagnostics, text: []const u8) error{OutOfMemory}!void {
var it = model.trustedProxies(text);
while (it.next()) |element| {
_ = NetAddress.parse(element) catch {
try diags.add(
error.BadTrustedProxy,
"web.trusted_proxies",
.{},
"{f} is not an IP address; the list is comma-separated IP literals",
.{safe_url.quoteText(element)},
);
};
}
}
fn checkTlsEndpoint(
diags: *Diagnostics,
endpoint: model.TlsEndpoint,
@@ -573,6 +632,39 @@ fn checkTlsName(
};
}
/// A `tls://` upstream's host must be an IP literal, because `DotClient` refuses
/// anything else on every dial (`upstream/dot_client.zig`'s `resolveAddress`:
/// resolving an upstream's own name is a bootstrap problem nxdns declines to
/// have). Without this check a hostname `tls://` config validates clean and then
/// fails at query time as a peer fault, which reads as "the upstream is down"
/// rather than "this line is wrong".
///
/// The mirror is exact: `NetAddress.parse` is `net.IpAddress.parse(text, 0)`
/// (`platform/address.zig`), the same call the client makes, so the two cannot
/// disagree about what a literal is. Note this is not `parseResolver` above —
/// that one validates a forward zone's resolver, a different field.
///
/// A `tls://` host is a hostname often enough that the message says what to do
/// instead rather than only what is wrong.
fn checkDotHost(
diags: *Diagnostics,
server: model.UpstreamServer,
endpoint: transport.Endpoint,
index: usize,
) error{OutOfMemory}!void {
if (endpoint.scheme != .dot) return;
_ = NetAddress.parse(endpoint.host) catch {
try diags.add(
error.UpstreamHostNotIpLiteral,
"upstreams[{d}].url",
.{index},
"tls:// upstreams take an IP literal host; {f} names a host nxdns will not resolve. " ++
"Use the address and put the name in tls_name, or configure the https:// form.",
.{safe_url.redactQuoted(server.url)},
);
};
}
fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{OutOfMemory}!void {
var group_names: IndexSet = .empty;
var has_default = false;
@@ -608,6 +700,7 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
// `BadUpstreamUrl`: what its scheme would have been is unknown.
if (transport.Endpoint.parse(server.url)) |endpoint| {
try checkTlsName(diags, server, endpoint.scheme, i);
try checkDotHost(diags, server, endpoint, i);
} else |_| {
try diags.add(
error.BadUpstreamUrl,
@@ -1114,6 +1207,36 @@ test "error.DuplicateUpstreamUrl" {
try expectProblem(cfg, error.DuplicateUpstreamUrl, "upstreams[1].url");
}
test "error.UpstreamHostNotIpLiteral on a hostname tls:// upstream" {
// The form an operator copies off the NextDNS setup page. It parses, so it
// used to validate clean and then fail every exchange as a peer fault.
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "tls://abcd12.dns.nextdns.io" }};
try expectProblem(cfg, error.UpstreamHostNotIpLiteral, "upstreams[0].url");
// A tls_name does not excuse it: the name is what the certificate is
// matched against, not what nxdns dials.
var named = baseConfig();
named.upstreams = &.{.{ .url = "tls://dns.quad9.net:853", .tls_name = "dns.quad9.net" }};
try expectProblem(named, error.UpstreamHostNotIpLiteral, "upstreams[0].url");
}
test "an https:// upstream may name a host" {
// The check is scheme-specific: DoH resolves through the HTTP client, so a
// hostname there is the normal form.
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "https://dns.nextdns.io/abcd12" }};
try expectClean(cfg);
}
test "an IPv6 literal tls:// upstream validates cleanly" {
// `Endpoint.parse` strips the brackets, so the host reaching the check is
// exactly what the client hands to the address parser.
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "tls://[2620:fe::fe]:853", .tls_name = "dns.quad9.net" }};
try expectClean(cfg);
}
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" }};
@@ -1701,9 +1824,37 @@ test "error.BadTimeout" {
cfg.upstream.read_timeout_ms = 10;
try expectProblem(cfg, error.BadTimeout, "upstream.read_timeout_ms");
var attempt = baseConfig();
attempt.upstream.attempt_timeout_ms = 10;
try expectProblem(attempt, error.BadTimeout, "upstream.attempt_timeout_ms");
// An attempt budget above the total one can never be reached: the pool
// cancels the whole loop when the total expires.
var budget = baseConfig();
budget.upstream = .{ .read_timeout_ms = 4000, .total_timeout_ms = 1000 };
try expectProblem(budget, error.BadTimeout, "upstream.total_timeout_ms");
budget.upstream = .{ .attempt_timeout_ms = 4000, .total_timeout_ms = 1000 };
try expectProblem(budget, error.BadTimeout, "upstream.attempt_timeout_ms");
// `read_timeout_ms` bounds the forward-zone client, a different subsystem,
// so it is deliberately free to sit above the pool's total budget.
var unrelated = baseConfig();
unrelated.upstream = .{ .attempt_timeout_ms = 500, .read_timeout_ms = 9000, .total_timeout_ms = 1000 };
try expectClean(unrelated);
}
test "error.BadCacheSize" {
var zero = baseConfig();
zero.cache.size = 0;
try expectProblem(zero, error.BadCacheSize, "cache.size");
// The slot array is allocated in full at boot, so an unchecked maxInt is a
// startup that asks the kernel for more memory than the box has.
var huge = baseConfig();
huge.cache.size = std.math.maxInt(u32);
try expectProblem(huge, error.BadCacheSize, "cache.size");
var edge = baseConfig();
edge.cache.size = max_boot_entries;
try expectClean(edge);
}
test "error.BadTtl" {
@@ -1720,6 +1871,16 @@ test "error.BadRetention" {
var cfg = baseConfig();
cfg.logging.retention_days = 0;
try expectProblem(cfg, error.BadRetention, "logging.retention_days");
var zero_buffer = baseConfig();
zero_buffer.logging.query_log_buffer_max = 0;
try expectProblem(zero_buffer, error.BadRetention, "logging.query_log_buffer_max");
// The ring is allocated in full at boot; an Entry is a few hundred bytes,
// so maxInt(u32) asks for terabytes before the first query arrives.
var huge_buffer = baseConfig();
huge_buffer.logging.query_log_buffer_max = 4_000_000_000;
try expectProblem(huge_buffer, error.BadRetention, "logging.query_log_buffer_max");
}
test "error.BadLogRotation" {
@@ -1756,6 +1917,26 @@ test "error.BadBindAddress on an IPv4 literal in dns.bind_ipv6" {
try expectProblem(cfg, error.BadBindAddress, "dns.bind_ipv6");
}
test "error.BadTrustedProxy" {
var cfg = baseConfig();
cfg.web.trusted_proxies = "127.0.0.1, proxy.example";
try expectProblem(cfg, error.BadTrustedProxy, "web.trusted_proxies");
// A stray comma is named rather than dropped, so the operator sees the typo.
var trailing = baseConfig();
trailing.web.trusted_proxies = "127.0.0.1,";
try expectProblem(trailing, error.BadTrustedProxy, "web.trusted_proxies");
}
test "a list of IP literals, and an empty one, both validate" {
var cfg = baseConfig();
cfg.web.trusted_proxies = " 127.0.0.1 , ::1,10.0.0.1 ";
try expectClean(cfg);
cfg.web.trusted_proxies = "";
try expectClean(cfg);
}
test "error.MissingCertPath" {
var cfg = baseConfig();
cfg.doh_server = .{ .enabled = true, .cert_path = "" };