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
+1 -1
View File
@@ -161,7 +161,7 @@ const seed_source: [:0]const u8 =
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
\\ .upstreams = .{
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
\\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
\\ },
\\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
+1 -1
View File
@@ -507,7 +507,7 @@ const full_source: [:0]const u8 =
\\ .groups = .{ .{ .name = "default" }, .{ .name = "kids", .safe_search = true } },
\\ .upstreams = .{
\\ .{ .url = "https://dns.example/dns-query", .priority = 10 },
\\ .{ .url = "tls://dot.example:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
\\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false, .tls_name = "dot.example" },
\\ },
\\ .clients = .{ .{ .ip = "FD00:0:0:0:0:0:0:1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
+57 -1
View File
@@ -42,7 +42,14 @@ pub const Config = struct {
};
pub const Upstream = struct {
/// Bounds one attempt against one upstream inside the pool's failover loop.
attempt_timeout_ms: u32 = 2500,
/// The forward-zone client's read deadline, and nothing else. It bounds a
/// different subsystem from the two above (`src/local/forward_client.zig`),
/// so no cross-check relates it to them.
read_timeout_ms: u32 = 3000,
/// The whole-exchange budget: every failover attempt together, not one of
/// them. The pool races the entire loop against it.
total_timeout_ms: u32 = 5000,
};
@@ -91,6 +98,39 @@ pub const Web = struct {
/// abuse the limiter defends against (PLAN §10).
api_localhost_exempt: bool = true,
sse_max_connections_per_ip: u16 = 3,
/// Comma-separated IP literals. A request arriving from one of these peers
/// is identified by the last entry of its `X-Forwarded-For` header instead
/// of by the socket peer, so the API limiter and the per-address SSE cap
/// bind the real client rather than the proxy. Empty means no proxy is
/// trusted and the socket peer is always the client. One string rather than
/// a list because the settings codec stores scalars only.
trusted_proxies: []const u8 = "",
};
/// Walks `web.trusted_proxies`. The validator and the web server both read the
/// setting, so what counts as one element — comma-separated, surrounding spaces
/// and tabs trimmed — is spelled out once here. An empty setting yields nothing;
/// an empty element (a stray comma) is yielded, so the validator can name it
/// rather than silently drop it.
pub fn trustedProxies(text: []const u8) TrustedProxyIterator {
return .{ .rest = text, .done = text.len == 0 };
}
pub const TrustedProxyIterator = struct {
rest: []const u8,
done: bool,
pub fn next(self: *TrustedProxyIterator) ?[]const u8 {
if (self.done) return null;
const end = std.mem.findScalar(u8, self.rest, ',') orelse {
const last = self.rest;
self.done = true;
return std.mem.trim(u8, last, " \t");
};
const element = self.rest[0..end];
self.rest = self.rest[end + 1 ..];
return std.mem.trim(u8, element, " \t");
}
};
pub const TlsEndpoint = struct {
@@ -300,6 +340,10 @@ pub fn readTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.read_timeout_ms) * std.time.ns_per_ms };
}
pub fn attemptTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.attempt_timeout_ms) * std.time.ns_per_ms };
}
pub fn totalTimeout(u: Upstream) std.Io.Duration {
return .{ .nanoseconds = @as(i96, u.total_timeout_ms) * std.time.ns_per_ms };
}
@@ -480,6 +524,7 @@ const expected_keys = [_][]const u8{
"logging.output",
"logging.query_log_buffer_max",
"logging.retention_days",
"upstream.attempt_timeout_ms",
"upstream.read_timeout_ms",
"upstream.total_timeout_ms",
"web.api_localhost_exempt",
@@ -490,6 +535,7 @@ const expected_keys = [_][]const u8{
"web.port",
"web.session_ttl_hours",
"web.sse_max_connections_per_ip",
"web.trusted_proxies",
};
fn lessThanKey(_: void, a: SettingPair, b: SettingPair) bool {
@@ -530,7 +576,7 @@ test "toSettings never emits web.password" {
test "toSettings and fromSettings round-trip a non-default config" {
const gpa = testing.allocator;
const original: Config = .{
.upstream = .{ .read_timeout_ms = 222, .total_timeout_ms = 333 },
.upstream = .{ .attempt_timeout_ms = 111, .read_timeout_ms = 222, .total_timeout_ms = 333 },
.dns = .{
.bind_ipv4 = "127.0.0.1",
.bind_ipv6 = "::1",
@@ -549,6 +595,7 @@ test "toSettings and fromSettings round-trip a non-default config" {
.api_rate_limit_per_min = 29,
.api_localhost_exempt = false,
.sse_max_connections_per_ip = 31,
.trusted_proxies = "10.0.0.9,fd00::9",
},
.doh_server = .{
.enabled = true,
@@ -685,6 +732,10 @@ test "RecordType stores the uppercase DDL spelling" {
}
test "unit conversions" {
try testing.expectEqual(
@as(i96, 2500) * std.time.ns_per_ms,
attemptTimeout(.{}).nanoseconds,
);
try testing.expectEqual(
@as(i96, 3000) * std.time.ns_per_ms,
readTimeout(.{}).nanoseconds,
@@ -703,6 +754,7 @@ test "unit conversions" {
test "unit conversions at the field maximum do not overflow" {
const max_upstream: Upstream = .{
.attempt_timeout_ms = std.math.maxInt(u32),
.read_timeout_ms = std.math.maxInt(u32),
.total_timeout_ms = std.math.maxInt(u32),
};
@@ -710,6 +762,10 @@ test "unit conversions at the field maximum do not overflow" {
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
readTimeout(max_upstream).nanoseconds,
);
try testing.expectEqual(
@as(i96, std.math.maxInt(u32)) * std.time.ns_per_ms,
attemptTimeout(max_upstream).nanoseconds,
);
try testing.expectEqual(
@as(i64, std.math.maxInt(u16)) * 3600,
sessionTtlSeconds(.{ .session_ttl_hours = std.math.maxInt(u16) }),
+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 = "" };