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
+4 -1
View File
@@ -283,7 +283,10 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
var pool: pool_mod.Pool = .init(
upstreams.active(),
.{},
.{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
.{
.attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
.total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
},
@truncate(@as(u96, @bitCast(std.Io.Clock.real.now(io).nanoseconds))),
);
+6 -4
View File
@@ -922,9 +922,11 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
const response_buf = try r.gpa.alloc(u8, transport.max_message_len);
defer r.gpa.free(response_buf);
const attempt_timeout: std.Io.Clock.Duration = .{
.raw = model.totalTimeout(cfg.upstream),
.clock = .awake,
// The same pair the server runs with, so a probe that passes here says
// something about the deadlines a real query will get.
const timeouts: pool.Timeouts = .{
.attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
.total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
};
// The pool jitters backoff from this; one probe per upstream never reaches
// backoff, so the value only has to be a value.
@@ -978,7 +980,7 @@ fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
.enabled = true,
.health = .init,
}};
var single: pool.Pool = .init(&entries, .{}, attempt_timeout, seed);
var single: pool.Pool = .init(&entries, .{}, timeouts, seed);
if (single.exchange(r.io, probe_query, response_buf)) |_| {
try r.out.print("OK upstreams[{d}] {f}\n", .{ i, safe_url.redact(server.url) });
+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 = "" };
+41
View File
@@ -275,6 +275,25 @@ pub fn extendedRcode(header_rcode: types.Rcode, opt: ?OptRecord) u12 {
return (@as(u12, o.extended_rcode) << 4) | low;
}
/// The write-side pair of `extendedRcode`: splits a 12-bit RCODE into the four
/// bits the header carries and the eight the OPT record carries. A reply that
/// needs a code above 15 (BADVERS, RFC 6891 §6.1.3) has no other way to say it.
pub fn splitRcode(rcode: u12) SplitRcode {
return .{
.header = @enumFromInt(@as(u4, @truncate(rcode))),
.extended = @truncate(rcode >> 4),
};
}
pub const SplitRcode = struct {
header: types.Rcode,
extended: u8,
};
/// RCODE 16, BADVERS (RFC 6891 §6.1.3): the query asked for an EDNS version
/// this server does not implement.
pub const badvers: u12 = 16;
const testing = std.testing;
/// An OPT record with a 4096-byte payload size, DO set, and no options.
@@ -700,3 +719,25 @@ test "extendedRcode composes the twelve bits" {
high.extended_rcode = 0xff;
try testing.expectEqual(@as(u12, 0xfff), extendedRcode(@enumFromInt(15), high));
}
test "splitRcode splits BADVERS the way RFC 6891 does" {
const split = splitRcode(badvers);
try testing.expectEqual(types.Rcode.no_error, split.header);
try testing.expectEqual(@as(u8, 1), split.extended);
}
test "splitRcode round-trips every twelve-bit rcode through extendedRcode" {
var rcode: u12 = 0;
while (true) : (rcode += 1) {
const split = splitRcode(rcode);
const opt: OptRecord = .{
.udp_payload_size = 4096,
.extended_rcode = split.extended,
.version = 0,
.do_bit = false,
.options = .{ .offset = 0, .len = 0 },
};
try testing.expectEqual(rcode, extendedRcode(split.header, opt));
if (rcode == std.math.maxInt(u12)) break;
}
}
+50 -5
View File
@@ -261,10 +261,11 @@ pub fn decrementTtls(bytes: []u8, elapsed_seconds: u32) ParseError!?u32 {
/// and which answers to add is the caller's policy.
///
/// Sections must be filled in wire order, so every `addAnswer` call has to
/// precede `addOptEcho` — the OPT record belongs to the additional section, and
/// an answer written after it would land in the wrong section. `addOptEcho`
/// also runs at most once, because RFC 6891 §6.1.1 allows one OPT record per
/// message. Both rules are programmer errors, so both are assertions.
/// precede the OPT record — it belongs to the additional section, and an answer
/// written after it would land in the wrong section. Only one of `addOptEcho`
/// and `addOptWithRcode` runs, and only once, because RFC 6891 §6.1.1 allows
/// one OPT record per message. Both rules are programmer errors, so both are
/// assertions.
pub const ResponseBuilder = struct {
writer: Writer,
header: header.Header,
@@ -348,10 +349,23 @@ pub const ResponseBuilder = struct {
/// comes back unchanged and the DO bit passes through. No options are
/// echoed — nxdns implements none of them.
pub fn addOptEcho(self: *ResponseBuilder, request_opt: edns.OptRecord, do_bit: bool) Error!void {
return self.addOptWithRcode(request_opt, do_bit, 0);
}
/// `addOptEcho` with the upper eight bits of a 12-bit RCODE, which only the
/// OPT record can carry (RFC 6891 §6.1.3). Pair it with the header half from
/// `edns.splitRcode`. The reply's EDNS version stays 0 either way: it states
/// the highest version this server implements, not the version asked for.
pub fn addOptWithRcode(
self: *ResponseBuilder,
request_opt: edns.OptRecord,
do_bit: bool,
extended_rcode: u8,
) Error!void {
std.debug.assert(!self.opt_added);
const opt: edns.OptRecord = .{
.udp_payload_size = request_opt.udp_payload_size,
.extended_rcode = 0,
.extended_rcode = extended_rcode,
.version = 0,
.do_bit = do_bit,
.options = .{ .offset = 0, .len = 0 },
@@ -791,6 +805,37 @@ test "ResponseBuilder passes the DO bit through" {
}
}
test "addOptWithRcode carries the upper eight bits of a twelve-bit rcode" {
const request = try parse(query_bytes);
const request_opt = try edns.parseOpt(query_bytes, findOptRecord(request).?);
const split = edns.splitRcode(edns.badvers);
var buf: [128]u8 = undefined;
var b = try ResponseBuilder.init(&buf, request.header, firstQuestion(request).?);
b.setRcode(split.header);
try b.addOptWithRcode(request_opt, false, split.extended);
const bytes = b.finish();
const p = try parse(bytes);
const opt = try edns.parseOpt(bytes, findOptRecord(p).?);
try testing.expectEqual(@as(u8, 0), opt.version);
try testing.expectEqual(@as(u12, edns.badvers), edns.extendedRcode(p.header.flags.rcode, opt));
}
test "addOptEcho leaves the extended rcode clear" {
const request = try parse(query_bytes);
const request_opt = try edns.parseOpt(query_bytes, findOptRecord(request).?);
var buf: [128]u8 = undefined;
var b = try ResponseBuilder.init(&buf, request.header, firstQuestion(request).?);
try b.addOptEcho(request_opt, false);
const bytes = b.finish();
const p = try parse(bytes);
const opt = try edns.parseOpt(bytes, findOptRecord(p).?);
try testing.expectEqual(@as(u8, 0), opt.extended_rcode);
}
test "ResponseBuilder sets an rcode and an empty answer section" {
const request = try parse(query_bytes);
+31
View File
@@ -4,6 +4,7 @@
//! contract as openapi.zig's route guard.
const std = @import("std");
const build_options = @import("build_options");
const docs = @import("docs_files");
const cli = @import("cli.zig");
const routes = @import("web/routes.zig");
@@ -61,3 +62,33 @@ test "every cli subcommand has its own reference heading in docs/reference/cli.m
}
}
}
test "no doc page pastes a concrete version into a transcript" {
const gpa = std.testing.allocator;
// Anchored on the `nxdns ` prefix the transcripts print, not on the bare
// version: a release numbered 1.0.0 would otherwise collide with the
// 1.0.0.1 upstream address the pages use as an example.
const built = try std.fmt.allocPrint(gpa, "nxdns {s}", .{build_options.version_string});
defer gpa.free(built);
for (docs.pages) |page| {
// The literal milestone 14 flagged, checked by name as well, so the
// guard still bites on a page written against the old default after
// `-Dversion-string` moves on.
for ([_][]const u8{ built, "0.1.0-dev" }) |needle| {
if (std.mem.indexOf(u8, page.text, needle) != null) {
std.debug.print("version literal '{s}' in {s}: use the <version> placeholder\n", .{ needle, page.path });
return error.VersionLiteralInDocPage;
}
}
}
}
test "every version transcript prints the placeholder" {
for (docs.version_transcript_pages) |page| {
if (std.mem.indexOf(u8, page.text, "nxdns <version>") == null) {
std.debug.print("transcript placeholder 'nxdns <version>' missing from {s}\n", .{page.path});
return error.VersionPlaceholderMissingFromDocPage;
}
}
}
+122 -1
View File
@@ -133,6 +133,9 @@ pub const Handler = struct {
dropped_malformed: std.atomic.Value(u64) = .init(0),
formerr: std.atomic.Value(u64) = .init(0),
notimp: std.atomic.Value(u64) = .init(0),
/// Queries asking for an EDNS version this server does not implement,
/// answered with BADVERS (RFC 6891 §6.1.3).
badvers: std.atomic.Value(u64) = .init(0),
servfail: std.atomic.Value(u64) = .init(0),
truncated: std.atomic.Value(u64) = .init(0),
refused: std.atomic.Value(u64) = .init(0),
@@ -239,6 +242,19 @@ pub const Handler = struct {
else
null;
// RFC 6891 §6.1.3: a query naming an EDNS version this server does not
// implement is answered with BADVERS, and the reply's OPT reports the
// highest version the server does implement. The check precedes the
// opcode check because the version governs the whole EDNS exchange,
// whatever the query asks for.
if (opt) |o| {
if (o.version != 0) {
bump(&self.stats.badvers);
const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null;
return .{ .reply = buildBadvers(hdr, echo, o, response_buf) };
}
}
if (hdr.flags.opcode != .query) {
return synthesize(hdr, null, opt, .not_imp, &self.stats.notimp, response_buf);
}
@@ -887,6 +903,22 @@ fn build(
return b.finish();
}
/// Encodes a BADVERS reply. `build` cannot: RCODE 16 does not fit the header's
/// four bits, so the code splits across the header and the OPT record and the
/// reply carries an OPT even though it echoes none of the query's options.
fn buildBadvers(
hdr: header.Header,
q: ?question.Question,
request_opt: edns.OptRecord,
buf: []u8,
) []u8 {
const split = edns.splitRcode(edns.badvers);
var b = packet.ResponseBuilder.init(buf, hdr, q) catch unreachable;
b.setRcode(split.header);
b.addOptWithRcode(request_opt, request_opt.do_bit, split.extended) catch unreachable;
return b.finish();
}
fn bump(counter: *std.atomic.Value(u64)) void {
_ = counter.fetchAdd(1, .monotonic);
}
@@ -972,8 +1004,22 @@ const response_bytes =
const opt_len = 11;
const query_with_opt_len = query_bytes.len + opt_len;
/// `query_bytes` plus an OPT record advertising `payload_size`.
/// The EDNS version byte, counted from the start of the OPT record: past the
/// root owner name, TYPE, CLASS and the extended-RCODE byte.
const opt_version_offset = 6;
/// `query_bytes` plus an OPT record advertising `payload_size`, EDNS version 0.
fn queryWithOpt(buf: *[query_with_opt_len]u8, payload_size: u16, do_bit: bool) []const u8 {
return queryWithOptVersion(buf, payload_size, do_bit, 0);
}
/// `queryWithOpt` for a chosen EDNS version.
fn queryWithOptVersion(
buf: *[query_with_opt_len]u8,
payload_size: u16,
do_bit: bool,
version: u8,
) []const u8 {
@memcpy(buf[0..query_bytes.len], query_bytes);
std.mem.writeInt(u16, buf[10..12], 1, .big); // arcount
@@ -981,6 +1027,7 @@ fn queryWithOpt(buf: *[query_with_opt_len]u8, payload_size: u16, do_bit: bool) [
@memset(opt, 0);
opt[2] = @intFromEnum(types.Type.opt);
std.mem.writeInt(u16, opt[3..5], payload_size, .big);
opt[opt_version_offset] = version;
if (do_bit) opt[7] = 0x80; // the DO bit is bit 15 of the TTL word
return buf;
}
@@ -1414,6 +1461,80 @@ test "the truncated reply echoes the OPT record" {
try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic));
}
test "an EDNS version this server does not implement gets BADVERS" {
var t: TestIo = .init();
defer t.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
var query_buf: [query_with_opt_len]u8 = undefined;
const query = queryWithOptVersion(&query_buf, 1232, true, 1);
var buf: [udp_limit_min]u8 = undefined;
const reply = try expectReply(udp(&h, t.io(), query, &buf));
const p = try packet.parse(reply);
const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?);
// RCODE 16 lives in neither half alone: the header carries 0 and the OPT
// record carries 1.
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
try testing.expectEqual(@as(u12, 16), edns.extendedRcode(p.header.flags.rcode, opt));
try testing.expectEqual(@as(u8, 0), opt.version);
try testing.expectEqual(true, opt.do_bit);
try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size);
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
try testing.expectEqual(true, p.header.flags.qr);
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
try testing.expectEqual(@as(u16, 0), p.header.ancount);
try testing.expectEqual(@as(u64, 1), h.stats.badvers.load(.monotonic));
// The query never reached the upstream.
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
}
test "an EDNS version 0 query is not answered with BADVERS" {
var t: TestIo = .init();
defer t.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
var query_buf: [query_with_opt_len]u8 = undefined;
const query = queryWithOptVersion(&query_buf, 1232, false, 0);
var buf: [udp_limit_min]u8 = undefined;
const reply = try expectReply(udp(&h, t.io(), query, &buf));
// The query is forwarded and the upstream's own answer comes back, so the
// BADVERS arm never ran.
const p = try packet.parse(reply);
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
try testing.expectEqual(@as(u16, 1), p.header.ancount);
try testing.expectEqual(@as(u64, 0), h.stats.badvers.load(.monotonic));
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
}
test "a malformed OPT record is FORMERR before the version check reads it" {
var t: TestIo = .init();
defer t.deinit();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
// The option list is broken and the EDNS version is 1. `parseOpt` fails
// first, so nothing trusts the version byte of an OPT that will not parse.
var query_buf: [query_with_bad_option.len]u8 = (query_with_bad_option ++ "").*;
query_buf[query_bytes.len + opt_version_offset] = 1;
var buf: [udp_limit_min]u8 = undefined;
const reply = try expectReply(udp(&h, t.io(), &query_buf, &buf));
const p = try packet.parse(reply);
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
try testing.expectEqual(@as(u64, 0), h.stats.badvers.load(.monotonic));
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
}
test "an OPT record with a non-root owner name gets FORMERR" {
var t: TestIo = .init();
defer t.deinit();
+6 -3
View File
@@ -2,8 +2,11 @@
//! the timestamp, so this file holds no clock, no `std.Io` operation and no
//! socket. `check` neither allocates nor fails.
//!
//! Not thread-safe. Phase 7 decides the locking when it wires the limiter into
//! the query path.
//! Not thread-safe. The handler owns the locking: every call goes through
//! `Handler.limiter_mutex` (handler.zig:124), taken uncancelably on the query
//! path — `handle` has no error union to carry `error.Canceled` out of — and
//! cancelably in the `runMaintenance` sweep that ages the table out
//! (app.zig, `maintenanceOnce`), which does.
//!
//! The window is fixed, not sliding (PLAN §10 reserves the token bucket for the
//! API limiter). A fixed window admits at most twice the limit across a window
@@ -116,7 +119,7 @@ pub const RateLimiter = struct {
/// Drops every entry whose window ended more than one full window before
/// `now`, that is `now - start_ns > 2 * window_ns`. Returns how many it
/// dropped. Phase 7 schedules it.
/// dropped. `app.runMaintenance` schedules it.
pub fn sweep(self: *RateLimiter, now: std.Io.Timestamp) u32 {
const stale_after = 2 * self.window_ns;
var stale_count: u32 = 0;
+6 -3
View File
@@ -77,9 +77,12 @@ const test_cfg: health.Config = .{
.max_backoff_ms = 60_000,
};
/// Nothing in this test is slow, so this budget only exists to stop a wedged
/// Nothing in this test is slow, so these budgets only exist to stop a wedged
/// attempt from hanging the run.
const attempt_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
const pool_timeouts: pool.Timeouts = .{
.attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
};
fn queryWithId(buf: *[query_bytes.len]u8, id: u16) []const u8 {
buf.* = query_bytes.*;
@@ -247,7 +250,7 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
testEntry("https://bad.example/dns-query", bad.client(), 10),
testEntry("tls://good.example", good.client(), 20),
};
var upstreams: pool.Pool = .init(&entries, test_cfg, attempt_timeout, 1);
var upstreams: pool.Pool = .init(&entries, test_cfg, pool_timeouts, 1);
var h = bareHandler(upstreams.client());
+2 -1
View File
@@ -58,7 +58,8 @@ pub fn wait(io: std.Io) std.Io.Cancelable!void {
}
/// The programmatic equivalent of the signal: what a test uses to shut the app
/// down, and what a Phase 8 restart endpoint would call.
/// down. No restart endpoint calls it — none exists, and none is planned; the
/// web API echoes `restart_required` and leaves the restart to the operator.
pub fn trigger(io: std.Io) void {
event.set(io);
}
+1 -1
View File
@@ -295,7 +295,7 @@ const rich_config =
\\ .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 },
\\ .{ .url = "tls://192.0.2.53:853", .priority = 20, .enabled = false },
\\ },
\\ .clients = .{ .{ .ip = "fd00::1", .name = "tablet", .group = "kids" } },
\\ .client_prefixes = .{ .{ .prefix = "192.168.1.0/24", .group = "kids", .priority = 50 } },
+150 -26
View File
@@ -1,9 +1,17 @@
//! Priority-ordered sequential failover across upstream endpoints, with a
//! per-attempt deadline, health tracking and backoff (PLAN §9).
//! Priority-ordered sequential failover across upstream endpoints, with two
//! deadlines, health tracking and backoff (PLAN §9).
//!
//! The pool is itself a `transport.Client`, so the handler above it sees one
//! interface and knows nothing about how many upstreams exist.
//!
//! Two deadlines, nested. `timeouts.attempt` bounds one exchange against one
//! entry; `timeouts.total` bounds the whole failover loop, every attempt
//! together. The outer one is what the client asking the question actually
//! waits for: without it, N unreachable upstreams cost N × attempt, and the
//! resolver above has already given up. The outer expiry is `error.Timeout`
//! unconditionally — the fast-failure paths (no entry enabled) return long
//! before the budget, so an expiry always means an attempt was in flight.
//!
//! Two passes, not one. Pass one walks the enabled entries that health says are
//! available. Pass two runs only when pass one attempted nothing, and skips the
//! backoff check: every endpoint being in backoff must not turn into SERVFAIL
@@ -81,7 +89,7 @@ pub const Entry = struct {
};
/// A copy of one entry's health, taken under the mutex. Feeds
/// `GET /api/upstream/health` in Phase 8.
/// `GET /api/upstream/health`.
pub const Snapshot = struct {
/// Whole, not redacted. `GET /api/upstream/health` returns this to a session
/// that `GET /api/upstreams` already serves the same url to in full, so
@@ -102,19 +110,30 @@ pub const Snapshot = struct {
backoff_until: ?std.Io.Timestamp,
};
/// The two budgets, named rather than positional: they are the same type, so
/// two parameters in a row could be swapped at a call site and still compile,
/// and the swap would be invisible until an operator watched a query take five
/// attempts of two and a half seconds each.
pub const Timeouts = struct {
/// Bounds one exchange against one entry.
attempt: std.Io.Clock.Duration,
/// Bounds the whole failover loop.
total: std.Io.Clock.Duration,
};
pub const Pool = struct {
/// Caller-owned, sorted ascending by priority in `init`.
entries: []Entry,
cfg: health.Config,
/// On the `.awake` clock, so a suspended Pi does not burn the budget.
attempt_timeout: std.Io.Clock.Duration,
/// Both on the `.awake` clock, so a suspended Pi does not burn a budget.
timeouts: Timeouts,
mutex: std.Io.Mutex,
rng: std.Random.DefaultPrng,
pub fn init(
entries: []Entry,
cfg: health.Config,
attempt_timeout: std.Io.Clock.Duration,
timeouts: Timeouts,
seed: u64,
) Pool {
std.debug.assert(entries.len > 0);
@@ -123,7 +142,7 @@ pub const Pool = struct {
return .{
.entries = entries,
.cfg = cfg,
.attempt_timeout = attempt_timeout,
.timeouts = timeouts,
.mutex = .init,
.rng = .init(seed),
};
@@ -150,12 +169,52 @@ pub const Pool = struct {
/// `response_buf` is handed to each attempt in turn, so a failed attempt
/// may have written into it. The returned slice is only meaningful on
/// success; on error the buffer's contents are undefined.
///
/// The failover loop runs raced against `timeouts.total`. Losing that race
/// cancels the loop, which unwinds whatever attempt was in flight: the
/// `Entry.busy` lock is taken cancelably on purpose and released by defer,
/// and the health bookkeeping around a completed exchange is uncancelable,
/// so a canceled attempt leaves no lock held and no counter half-written.
pub fn exchange(
self: *Pool,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]LoopOutcome = undefined;
var race: std.Io.Select(LoopOutcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.loop, exchangeLoopLen, .{
self, io, query, response_buf,
}) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.timeouts.total }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.loop => |result| return response_buf[0..try result],
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the budget ran out.
try result;
return error.Timeout;
},
}
}
/// The two-pass failover loop, as a raceable task. It returns the reply's
/// length rather than its slice for the reason `exchangeLen` in the tests
/// below does: `Io.concurrent` stores the future's return value, so the
/// bytes are read back out of the caller's `response_buf` by `exchange`.
fn exchangeLoopLen(
self: *Pool,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError!usize {
const now = std.Io.Clock.awake.now(io);
var last_fault: ?transport.ExchangeError = null;
var attempted = false;
@@ -170,8 +229,9 @@ pub const Pool = struct {
// waiting its turn on a busy upstream has done nothing that a
// cancellation could corrupt, so it gives up here rather than
// queueing behind an exchange it will not use. The wait itself
// is bounded by the holder's `attempt_timeout`; the waiter's own
// budget only starts once it has the lock.
// is bounded by the holder's `timeouts.attempt`; the waiter's
// own budget only starts once it has the lock, and the whole
// loop is bounded by `timeouts.total` regardless.
try entry.busy.lock(io);
defer entry.busy.unlock(io);
@@ -204,7 +264,7 @@ pub const Pool = struct {
};
self.recordSuccess(io, entry, completed_at);
return response;
return response.len;
}
if (attempted) break;
}
@@ -259,7 +319,7 @@ pub const Pool = struct {
}) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.attempt_timeout }) catch |err| switch (err) {
race.concurrent(.expiry, expire, .{ io, self.timeouts.attempt }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
@@ -312,6 +372,11 @@ const Outcome = union(enum) {
expiry: std.Io.Cancelable!void,
};
const LoopOutcome = union(enum) {
loop: transport.ExchangeError!usize,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
@@ -410,7 +475,12 @@ const test_cfg: health.Config = .{
.max_backoff_ms = 60_000,
};
const test_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
/// Long enough that nothing in a test reaches either budget unless the test is
/// about a budget, and short enough that a stuck test still ends.
const test_timeouts: Timeouts = .{
.attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
};
fn expectFailureLine(expected: []const u8, url: []const u8, err: transport.ExchangeError) !void {
var buf: [8 * safe_url.max_len]u8 = undefined;
@@ -451,7 +521,7 @@ test "Pool satisfies the Client interface" {
var fake: Fake = .{ .behavior = .{ .reply = response_bytes } };
var entries = [_]Entry{testEntry("https://a.example/dns-query", &fake, 10)};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
const reply = try pool.client().exchange(io, query_bytes, &buf);
@@ -470,7 +540,7 @@ test "entries are tried in ascending priority order" {
testEntry("https://high.example/dns-query", &high, 100),
testEntry("https://low.example/dns-query", &low, 10),
};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
try testing.expectEqual(@as(i32, 10), entries[0].priority);
@@ -491,7 +561,7 @@ test "a peer fault fails over to the next entry and is recorded" {
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
const reply = try pool.exchange(io, query_bytes, &buf);
@@ -514,7 +584,7 @@ test "an entry in backoff is skipped while another is available" {
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
// Two failures reach `failure_threshold` and open a backoff window.
@@ -539,7 +609,7 @@ test "every entry in backoff is still probed" {
testEntry("https://first.example/dns-query", &first, 10),
testEntry("https://second.example/dns-query", &second, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf));
@@ -564,7 +634,7 @@ test "a local resource error short-circuits and records nothing" {
testEntry("https://broke.example/dns-query", &broke, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
try testing.expectError(error.OutOfMemory, pool.exchange(io, query_bytes, &buf));
@@ -584,7 +654,7 @@ test "a cancellation short-circuits and records nothing" {
testEntry("https://canceled.example/dns-query", &canceled, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
try testing.expectError(error.Canceled, pool.exchange(io, query_bytes, &buf));
@@ -606,8 +676,12 @@ test "an attempt that outruns the budget is a recorded Timeout" {
testEntry("https://slow.example/dns-query", &slow, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
const budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
var pool: Pool = .init(&entries, test_cfg, budget, 1);
// A tight attempt budget under a loose total one, so the attempt deadline
// is the only one that can fire and the failover after it has room to run.
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(20), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
const reply = try pool.exchange(io, query_bytes, &buf);
@@ -619,7 +693,47 @@ test "an attempt that outruns the budget is a recorded Timeout" {
try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes);
}
test "every entry disabled yields ConnectFailed" {
test "two stalling upstreams cost the total budget, not one budget each" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
// Both entries stall past their own attempt budget, so without the outer
// race the exchange would take two attempt budgets and then some. The
// total is set below two attempts, which is what makes the assertion mean
// something: only the outer deadline can end this call in time.
var first: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var second: Fake = .{ .behavior = .{ .slow = .{
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
.reply = response_bytes,
} } };
var entries = [_]Entry{
testEntry("https://first.example/dns-query", &first, 10),
testEntry("https://second.example/dns-query", &second, 20),
};
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromMilliseconds(200), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(60), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
const started = std.Io.Clock.awake.now(io);
try testing.expectError(error.Timeout, pool.exchange(io, query_bytes, &buf));
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
// Under one attempt budget, so the outer deadline is provably what fired.
// The bound is generous against a loaded CI box; the failure it catches is
// a whole extra attempt, not a scheduling hiccup.
try testing.expect(elapsed_ns < @as(i96, 200) * std.time.ns_per_ms);
// The second entry was never reached: the loop was canceled inside the
// first attempt.
try testing.expectEqual(@as(usize, 0), second.calls);
}
test "every entry disabled yields ConnectFailed without waiting out the total budget" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
@@ -631,10 +745,20 @@ test "every entry disabled yields ConnectFailed" {
testEntry("https://two.example/dns-query", &two, 20),
};
for (&entries) |*entry| entry.enabled = false;
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
// A total budget long enough that waiting it out would be unmistakable.
// The outer expiry returns `error.Timeout` unconditionally, so this pins
// that the fast-failure path still wins its own race.
var pool: Pool = .init(&entries, test_cfg, .{
.attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .clock = .awake },
}, 1);
var buf: [512]u8 = undefined;
const started = std.Io.Clock.awake.now(io);
try testing.expectError(error.ConnectFailed, pool.exchange(io, query_bytes, &buf));
const elapsed_ns = std.Io.Clock.awake.now(io).nanoseconds - started.nanoseconds;
try testing.expect(elapsed_ns < @as(i96, 5) * std.time.ns_per_s);
try testing.expectEqual(@as(usize, 0), one.calls);
try testing.expectEqual(@as(usize, 0), two.calls);
}
@@ -650,7 +774,7 @@ test "snapshot reports the counters in pool order" {
testEntry("https://bad.example/dns-query", &bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf: [512]u8 = undefined;
_ = try pool.exchange(io, query_bytes, &buf);
@@ -705,7 +829,7 @@ test "concurrent exchanges through one entry do not overlap" {
.reply = response_bytes,
} } };
var entries = [_]Entry{testEntry("https://only.example/dns-query", &fake, 10)};
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
var pool: Pool = .init(&entries, test_cfg, test_timeouts, 1);
var buf_a: [512]u8 = undefined;
var buf_b: [512]u8 = undefined;
@@ -752,7 +876,7 @@ test "an entry that enters backoff while a task waits on it is not attempted" {
testEntry("https://slow-bad.example/dns-query", &slow_bad, 10),
testEntry("https://good.example/dns-query", &good, 20),
};
var pool: Pool = .init(&entries, cfg, test_timeout, 1);
var pool: Pool = .init(&entries, cfg, test_timeouts, 1);
var buf_a: [512]u8 = undefined;
var buf_b: [512]u8 = undefined;
+9 -4
View File
@@ -15,7 +15,9 @@
//!
//! The route is rate-limit exempt (a long-lived stream must not drain its
//! address's token bucket) but pays the per-address SSE connection cap, which
//! binds loopback too: hub slots are a fixed resource.
//! binds loopback too: hub slots are a fixed resource. The address it keys on is
//! `client_addr`, so behind a trusted proxy each remote client holds its own
//! budget rather than all of them sharing the proxy's.
const std = @import("std");
@@ -79,12 +81,15 @@ pub fn stream(
const hub = state.hub orelse
return http_util.respondError(request, .service_unavailable, "live stream unavailable");
const peer = address.NetAddress.fromIp(request.peer);
// The effective client, not the socket peer: behind a trusted proxy every
// stream would otherwise share one address's budget. Acquire and release
// read the same value, so a release can never miss the slot it took.
const client = address.NetAddress.fromIp(request.client_addr);
if (state.limiter) |limiter| {
if (!limiter.tryAcquireSse(io, std.Io.Clock.awake.now(io), peer))
if (!limiter.tryAcquireSse(io, std.Io.Clock.awake.now(io), client))
return http_util.respondError(request, .too_many_requests, "too many live streams from this address");
}
defer if (state.limiter) |limiter| limiter.releaseSse(io, peer);
defer if (state.limiter) |limiter| limiter.releaseSse(io, client);
const id = hub.subscribe(io) orelse
return http_util.respondError(request, .service_unavailable, "live stream is full");
+2
View File
@@ -213,6 +213,7 @@ const WebView = struct {
api_rate_limit_per_min: u32,
api_localhost_exempt: bool,
sse_max_connections_per_ip: u16,
trusted_proxies: []const u8,
/// Derived, not stored: the hash itself is never serialized, and the UI
/// still has to know whether a password is set.
auth_enabled: bool,
@@ -246,6 +247,7 @@ pub fn view(cfg: model.Config) View {
.api_rate_limit_per_min = cfg.web.api_rate_limit_per_min,
.api_localhost_exempt = cfg.web.api_localhost_exempt,
.sse_max_connections_per_ip = cfg.web.sse_max_connections_per_ip,
.trusted_proxies = cfg.web.trusted_proxies,
.auth_enabled = auth.authEnabled(cfg.web),
},
.doh_server = cfg.doh_server,
+4 -1
View File
@@ -88,7 +88,10 @@ fn testEntry(url: []const u8, enabled: bool) pool_mod.Entry {
}
fn testPool(entries: []pool_mod.Entry) pool_mod.Pool {
return .init(entries, .{}, .{ .raw = .fromMilliseconds(50), .clock = .awake }, 1);
return .init(entries, .{}, .{
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
}, 1);
}
test "every upstream is copied, counted and owned by the arena" {
+17
View File
@@ -38,6 +38,12 @@ pub const max_cookie_len: usize = 1024;
/// reads. Both are short; an over-long one is treated as absent.
pub const max_header_value_len: usize = 128;
/// How much of an `x-forwarded-for` header is kept. A trusted proxy appends its
/// own entry last, so the entry that names the real client always sits in the
/// final bytes; the copy therefore keeps the tail rather than the head (see
/// `server.copyHeaderSuffix`). 256 bytes hold an IPv6 entry many times over.
pub const max_xff_len: usize = 256;
/// A path deeper than this matches no route, so parsing can stop there.
pub const max_path_segments: usize = 8;
@@ -248,6 +254,17 @@ pub const Request = struct {
accept_encoding: []const u8,
if_none_match: []const u8,
peer: net.IpAddress,
/// Who the request is from, as far as every policy decision is concerned:
/// the socket peer, unless that peer is a configured trusted proxy and the
/// request carried an `x-forwarded-for`, in which case it is that header's
/// last entry. Computed once per request by the router, because the header
/// it derives from dies on the first body read.
///
/// Only the address is meaningful — a forwarded entry carries no port, so
/// the port reads 0 there. This file stays free of nxdns imports (the fuzz
/// target builds it as a module root), so the address type is the std one;
/// `address.NetAddress.fromIp` is what the policy sites key on.
client_addr: net.IpAddress,
/// Reset between requests on the same connection. Nothing allocated here
/// survives the response.
arena: Allocator,
+16
View File
@@ -730,6 +730,22 @@ test "every HELP line has a TYPE line and a sample, and every sample a name" {
try testing.expectEqual(helps, samples);
// `nxdns_up` plus every DNS counter: the families a bare state still has.
try testing.expectEqual(1 + dns_stat_fields.len + 7, samples);
// The reflective walk names the counters, so a renamed `Handler.Stats`
// field silently renames a scraped series. Pin the ones an operator alerts
// on by name.
for ([_][]const u8{
"nxdns_dns_queries_total",
"nxdns_dns_formerr_total",
"nxdns_dns_notimp_total",
"nxdns_dns_badvers_total",
"nxdns_dns_servfail_total",
"nxdns_dns_refused_total",
"nxdns_dns_blocked_total",
}) |metric| {
var line_buf: [128]u8 = undefined;
const line = try std.fmt.bufPrint(&line_buf, "# TYPE {s} counter\n", .{metric});
try testing.expect(std.mem.containsAtLeast(u8, text, 1, line));
}
}
test "an unwired collaborator omits its family rather than reporting zeros" {
+11 -2
View File
@@ -2092,8 +2092,9 @@ components:
properties:
upstream:
type: object
required: [read_timeout_ms, total_timeout_ms]
required: [attempt_timeout_ms, read_timeout_ms, total_timeout_ms]
properties:
attempt_timeout_ms: { type: integer }
read_timeout_ms: { type: integer }
total_timeout_ms: { type: integer }
dns:
@@ -2121,7 +2122,7 @@ components:
negative_ttl_max: { type: integer }
web:
type: object
required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, auth_enabled]
required: [enabled, bind, port, session_ttl_hours, api_rate_limit_per_min, api_localhost_exempt, sse_max_connections_per_ip, trusted_proxies, auth_enabled]
properties:
enabled: { type: boolean }
bind: { type: string }
@@ -2130,6 +2131,12 @@ components:
api_rate_limit_per_min: { type: integer }
api_localhost_exempt: { type: boolean }
sse_max_connections_per_ip: { type: integer }
trusted_proxies:
type: string
description: |
Comma-separated IP literals. A request from one of these peers
is identified by the last entry of its X-Forwarded-For header.
Empty trusts no proxy.
auth_enabled:
type: boolean
description: Derived, read-only; true iff a password hash is stored.
@@ -2208,6 +2215,7 @@ components:
upstream:
type: object
properties:
attempt_timeout_ms: { type: integer }
read_timeout_ms: { type: integer }
total_timeout_ms: { type: integer }
dns:
@@ -2243,6 +2251,7 @@ components:
api_rate_limit_per_min: { type: integer }
api_localhost_exempt: { type: boolean }
sse_max_connections_per_ip: { type: integer }
trusted_proxies: { type: string }
doh_server:
$ref: "#/components/schemas/TlsListenerPatch"
dot_server:
+155 -2
View File
@@ -61,7 +61,7 @@ const log = std.log.scoped(.web_server);
const recv_buffer_len = 8 * 1024;
const send_buffer_len = 4 * 1024;
/// Ruling 7. 64 slots at ~15.7 KiB each is ~1 MiB of fixed connection state.
/// Ruling 7. 64 slots at ~16 KiB each is ~1 MiB of fixed connection state.
pub const default_max_connections: u16 = 64;
/// How much per-request arena a connection keeps between requests. Enough that
@@ -206,10 +206,13 @@ pub fn sessionAuth(state: *WebState, io: std.Io, request: *const http_util.Reque
/// Ruling 19. No limiter wired means no limit: the limiter is a defence the
/// operator configures, and its absence must not refuse traffic.
///
/// Keyed on `client_addr`, not on the socket peer: behind a trusted proxy every
/// peer is the proxy, and one bucket for every remote user is no limiter at all.
pub fn bucketLimit(state: *WebState, io: std.Io, request: *const http_util.Request) LimitVerdict {
const limiter = state.limiter orelse return .ok;
const now = std.Io.Clock.awake.now(io);
return limiter.check(io, now, address.NetAddress.fromIp(request.peer));
return limiter.check(io, now, address.NetAddress.fromIp(request.client_addr));
}
/// Seam double: refuses nothing. For tests and for a server with no admin
@@ -281,6 +284,7 @@ pub const Server = struct {
cookie_buf: [http_util.max_cookie_len]u8,
accept_encoding_buf: [http_util.max_header_value_len]u8,
if_none_match_buf: [http_util.max_header_value_len]u8,
xff_buf: [http_util.max_xff_len]u8,
/// Per-request working memory, reset between requests on the same
/// connection so a keep-alive client cannot grow it without bound.
arena: std.heap.ArenaAllocator,
@@ -523,6 +527,19 @@ pub const Server = struct {
const accept_encoding = copyHeader(request, "accept-encoding", &conn.accept_encoding_buf);
const if_none_match = copyHeader(request, "if-none-match", &conn.if_none_match_buf);
const peer = address.NetAddress.fromIp(conn.peer);
const forwarded_for = copyHeaderSuffix(request, "x-forwarded-for", &conn.xff_buf);
const client_addr = switch (clientAddr(self.state.web.trusted_proxies, peer, forwarded_for)) {
.addr => |addr| addr,
.bad_forwarded_for => {
var view = bareRequest(request, conn, arena);
return http_util.respondError(&view, .bad_request, "malformed x-forwarded-for");
},
};
// A forwarded entry has no port of its own; the peer keeps the one it
// connected from.
const client_ip = if (client_addr.eql(peer)) conn.peer else client_addr.toIp(0);
// Decoding is destructive, so it runs on a copy: W8's asset lookup needs
// the raw path to match embedded file names byte for byte.
const decodable = arena.dupe(u8, raw_path) catch return error.OutOfMemory;
@@ -542,6 +559,7 @@ pub const Server = struct {
.accept_encoding = accept_encoding,
.if_none_match = if_none_match,
.peer = conn.peer,
.client_addr = client_ip,
.arena = arena,
};
return router.dispatch(self.state, io, &view);
@@ -560,6 +578,10 @@ pub const Server = struct {
.accept_encoding = "",
.if_none_match = "",
.peer = conn.peer,
// These responses are decided before the forwarded-for header is
// read, or because reading it failed; the socket peer is all that
// is known.
.client_addr = conn.peer,
.arena = arena,
};
}
@@ -629,6 +651,68 @@ fn copyHeader(request: *http.Server.Request, name: []const u8, buf: []u8) []cons
return buf[0..value.len];
}
/// Copies the **last** `buf.len` bytes of one header value into `buf`. Null
/// means the header is absent, which is a different answer from an empty value.
///
/// The tail is what matters for `x-forwarded-for`, and `copyHeader`'s
/// empty-on-overflow rule would be a security hole here: an empty value reads as
/// "no header", the effective client falls back to the socket peer, and behind
/// the same-box proxy this feature exists for that peer is loopback — which
/// `web.api_localhost_exempt` exempts from the API limiter by default. A client
/// would regain the exemption by sending an oversized header. Keeping the tail
/// closes that: the proxy appends its entry last, so the entry that names the
/// real client is always in the final bytes.
fn copyHeaderSuffix(request: *http.Server.Request, name: []const u8, buf: []u8) ?[]const u8 {
const value = headerValue(request, name) orelse return null;
const tail = if (value.len > buf.len) value[value.len - buf.len ..] else value;
@memcpy(buf[0..tail.len], tail);
return buf[0..tail.len];
}
/// Who a request is from, or the one way deriving that can fail.
const ClientAddr = union(enum) {
addr: address.NetAddress,
bad_forwarded_for,
};
/// Milestone-17 ruling 4. The socket peer, unless it is a trusted proxy that
/// forwarded the request, in which case the last entry of `x-forwarded-for` —
/// the one that proxy appended, and the only entry in the chain a client cannot
/// write.
///
/// A missing header falls back to the peer: a trusted proxy that adds no header
/// is a proxy nobody asked to trust anything about, and the peer is still true.
/// A header that is present but whose last entry is not an IP literal fails
/// closed with a 400 instead. Only a misconfigured proxy can produce that — a
/// client's spoofed entries can never terminate the chain — and falling back
/// there would hand the request the loopback identity this ruling exists to
/// take away.
fn clientAddr(
trusted_proxies: []const u8,
peer: address.NetAddress,
forwarded_for: ?[]const u8,
) ClientAddr {
if (!trustsPeer(trusted_proxies, peer)) return .{ .addr = peer };
const chain = forwarded_for orelse return .{ .addr = peer };
const start = if (std.mem.lastIndexOfScalar(u8, chain, ',')) |comma| comma + 1 else 0;
const last = std.mem.trim(u8, chain[start..], " \t");
const addr = address.NetAddress.parse(last) catch return .bad_forwarded_for;
return .{ .addr = addr };
}
/// Whether `peer` is one of the configured trusted proxies. An element that is
/// not an IP literal matches nothing; `validate.zig` is what tells the operator
/// about it, and refusing to serve over a typo here would be a worse answer.
fn trustsPeer(trusted_proxies: []const u8, peer: address.NetAddress) bool {
var it = model.trustedProxies(trusted_proxies);
while (it.next()) |element| {
const trusted = address.NetAddress.parse(element) catch continue;
if (trusted.eql(peer)) return true;
}
return false;
}
/// The first value sent under `name`, borrowed from the request head.
fn headerValue(request: *http.Server.Request, name: []const u8) ?[]const u8 {
var it = request.iterateHeaders();
@@ -825,6 +909,75 @@ fn testRequest() http_util.Request {
.accept_encoding = "",
.if_none_match = "",
.peer = .{ .ip4 = .loopback(0) },
.client_addr = .{ .ip4 = .loopback(0) },
.arena = testing.allocator,
};
}
fn ip(text: []const u8) address.NetAddress {
return address.NetAddress.parse(text) catch unreachable;
}
test "with no trusted proxy configured the socket peer is the client" {
const peer = ip("192.0.2.1");
try testing.expect(clientAddr("", peer, "203.0.113.9").addr.eql(peer));
}
test "a spoofed forwarded-for from an untrusted peer is ignored" {
const peer = ip("192.0.2.1");
try testing.expect(clientAddr("10.0.0.1", peer, "203.0.113.9").addr.eql(peer));
}
test "a trusted peer is identified by the last forwarded-for entry" {
const peer = ip("10.0.0.1");
const derived = clientAddr("10.0.0.1, 10.0.0.2", peer, "203.0.113.9, 198.51.100.7");
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
}
test "a trusted peer that forwards nothing stays itself" {
const peer = ip("10.0.0.1");
try testing.expect(clientAddr("10.0.0.1", peer, null).addr.eql(peer));
}
test "an IPv6 proxy and an IPv6 forwarded entry work the same way" {
const peer = ip("fd00::1");
const derived = clientAddr("fd00::1", peer, "2001:db8::5, 2001:db8::7");
try testing.expect(derived.addr.eql(ip("2001:db8::7")));
}
test "an oversized forwarded-for keeps the entry the proxy appended" {
var header: std.ArrayList(u8) = .empty;
defer header.deinit(testing.allocator);
while (header.items.len < 4096) try header.appendSlice(testing.allocator, "203.0.113.9, ");
try header.appendSlice(testing.allocator, "198.51.100.7");
// What the connection would copy: the tail alone, the head thrown away.
const tail = header.items[header.items.len - http_util.max_xff_len ..];
const derived = clientAddr("10.0.0.1", ip("10.0.0.1"), tail);
try testing.expect(derived.addr.eql(ip("198.51.100.7")));
}
test "a trusted peer whose forwarded-for has no valid last entry is refused" {
const peer = ip("10.0.0.1");
try testing.expectEqual(
ClientAddr.bad_forwarded_for,
std.meta.activeTag(clientAddr("10.0.0.1", peer, "203.0.113.9, not-an-ip")),
);
try testing.expectEqual(
ClientAddr.bad_forwarded_for,
std.meta.activeTag(clientAddr("10.0.0.1", peer, "")),
);
// An oversized header whose tail cuts the last entry in half is the same
// failure, and must not degrade to the exemptible socket peer.
try testing.expectEqual(
ClientAddr.bad_forwarded_for,
std.meta.activeTag(clientAddr("10.0.0.1", peer, "8.51.100.7000")),
);
}
test "a trusted-proxy element that is not an IP literal trusts nobody" {
const peer = ip("10.0.0.1");
try testing.expect(!trustsPeer("proxy.example", peer));
try testing.expect(trustsPeer("proxy.example, 10.0.0.1", peer));
try testing.expect(!trustsPeer("", peer));
}
+557 -2
View File
@@ -20,6 +20,7 @@
const std = @import("std");
const build_options = @import("build_options");
const contract_samples = @import("contract_samples");
const http = std.http;
const net = std.Io.net;
const Allocator = std.mem.Allocator;
@@ -261,6 +262,7 @@ const EnvOptions = struct {
rate_per_min: u32 = 100_000,
localhost_exempt: bool = true,
sse_max_per_ip: u16 = 3,
trusted_proxies: []const u8 = "",
fallback: ?router.HandlerFn = null,
};
@@ -356,7 +358,10 @@ const Env = struct {
.enabled = true,
.health = .init,
}};
self.pool = .init(&self.pool_entries, .{}, .{ .raw = .fromMilliseconds(50), .clock = .awake }, 1);
self.pool = .init(&self.pool_entries, .{}, .{
.attempt = .{ .raw = .fromMilliseconds(50), .clock = .awake },
.total = .{ .raw = .fromMilliseconds(100), .clock = .awake },
}, 1);
self.state = .{
.gpa = gpa,
@@ -365,6 +370,7 @@ const Env = struct {
.api_rate_limit_per_min = options.rate_per_min,
.api_localhost_exempt = options.localhost_exempt,
.sse_max_connections_per_ip = options.sse_max_per_ip,
.trusted_proxies = options.trusted_proxies,
},
.live_hash = .init(options.password_hash),
.pause = &self.pauser,
@@ -528,7 +534,7 @@ const TlsEndpointView = struct {
/// response never carries `web.password` or `web.password_hash` (ruling 16).
const SettingsView = struct {
settings: struct {
upstream: struct { read_timeout_ms: u32, total_timeout_ms: u32 },
upstream: struct { attempt_timeout_ms: u32, read_timeout_ms: u32, total_timeout_ms: u32 },
dns: struct {
bind_ipv4: []const u8,
bind_ipv6: []const u8,
@@ -546,6 +552,7 @@ const SettingsView = struct {
api_rate_limit_per_min: u32,
api_localhost_exempt: bool,
sse_max_connections_per_ip: u16,
trusted_proxies: []const u8,
auth_enabled: bool,
},
doh_server: TlsEndpointView,
@@ -1040,6 +1047,139 @@ test "W10 a drained bucket answers 429 with Retry-After and spares monitoring" {
try bounded(env.io(), default_budget, rateLimited, .{ env.io(), env });
}
fn proxiedRateLimit(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// The socket peer is loopback and `api_localhost_exempt` is on, so without
// the forwarded-for header the bucket is never consulted: capacity is 1 and
// three requests in a row all pass.
for (0..3) |_| {
try conn.request("GET", "/api/version", null, null);
const exempt = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), exempt.status);
}
// The same connection, now carrying what the trusted proxy appends: the
// remote client is no longer loopback, so it spends its own token and the
// second request is refused.
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
var response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 429), response.status);
// A second remote client behind the same proxy has its own bucket.
try conn.request("GET", "/api/version", "x-forwarded-for: 198.51.100.4", null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
// A chain whose last entry the proxy did not write is a 400, never a
// silent fall back to the exempt loopback peer.
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9, nonsense", null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 400), response.status);
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "x-forwarded-for"));
// The proxy itself is still exempt: its own unforwarded requests pass
// after every bucket above was drained.
try conn.request("GET", "/api/version", null, null);
response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
test "W10 milestone 17: a proxied client is rate limited while the proxy stays exempt" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{
.rate_per_min = 1,
.localhost_exempt = true,
.trusted_proxies = "127.0.0.1, ::1",
});
defer env.destroy();
try bounded(env.io(), default_budget, proxiedRateLimit, .{ env.io(), env });
}
fn spoofedForwardedFor(io: std.Io, env: *Env) anyerror!void {
var body_buf: [4096]u8 = undefined;
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
// No proxy is trusted, so the header is inert: the loopback peer keeps its
// exemption and no remote bucket is ever touched.
for (0..3) |_| {
try conn.request("GET", "/api/version", "x-forwarded-for: 203.0.113.9", null);
const response = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), response.status);
}
// Not even a chain nxdns would refuse from a trusted proxy.
try conn.request("GET", "/api/version", "x-forwarded-for: nonsense", null);
const ignored = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), ignored.status);
}
fn proxiedSseBudget(io: std.Io, env: *Env) anyerror!void {
var seen: std.ArrayList(u8) = .empty;
defer seen.deinit(env.gpa);
// One stream per address, and every connection arrives from loopback: keyed
// on the socket peer these two would be one client and the second would be
// refused.
var first: Conn = undefined;
try first.connect(io, env.addr);
defer first.close(io);
try first.request("GET", "/api/queries/live", "x-forwarded-for: 203.0.113.9", null);
try testing.expectEqual(@as(u16, 200), (try first.receiveHead()).status);
try first.readChunkedUntil(&seen, env.gpa, "retry: 3000");
var second: Conn = undefined;
try second.connect(io, env.addr);
defer second.close(io);
try second.request("GET", "/api/queries/live", "x-forwarded-for: 198.51.100.4", null);
try testing.expectEqual(@as(u16, 200), (try second.receiveHead()).status);
try second.readChunkedUntil(&seen, env.gpa, "retry: 3000");
// The first client's own budget is spent, though.
var again: Conn = undefined;
try again.connect(io, env.addr);
defer again.close(io);
var body_buf: [1024]u8 = undefined;
try again.request("GET", "/api/queries/live", "x-forwarded-for: 203.0.113.9", null);
const refused = try again.receive(&body_buf);
try testing.expectEqual(@as(u16, 429), refused.status);
}
test "W10 milestone 17: each proxied client holds its own SSE budget" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{
.sse_max_per_ip = 1,
.trusted_proxies = "127.0.0.1, ::1",
});
defer env.destroy();
try bounded(env.io(), default_budget, proxiedSseBudget, .{ env.io(), env });
}
test "W10 milestone 17: a forwarded-for from an untrusted peer changes nothing" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = true });
defer env.destroy();
try bounded(env.io(), default_budget, spoofedForwardedFor, .{ env.io(), env });
}
// ---------------------------------------------------------------------------
// SSE (ruling 20): preamble, event frame, heartbeat, per-address cap
// ---------------------------------------------------------------------------
@@ -1748,6 +1888,421 @@ test "drift guard a bites: methods swapped between two documented paths fail the
try testing.expectEqual(@as(usize, 2), swapped_routes);
}
// ---------------------------------------------------------------------------
// contract samples: the frontend's consumed shapes against real responses
// (milestone-17 ruling 5)
// ---------------------------------------------------------------------------
//
// The Zig side of the REST contract is already guarded: the table above parses
// every live response strictly, and the openapi guards below cover the routes.
// `web/src/lib/types.ts` was guarded by nothing — every frontend test stubs
// fetch, and types.ts is narrower than the wire in places (literal unions like
// `Health.status`), so re-parsing into Zig structs can never catch an
// out-of-union string.
//
// This walk drives the real `Env` server through every `.json` route the
// frontend reaches through an `api.ts` wrapper — the GETs and the JSON-returning
// writes — and renders the canonicalized bodies into a committed TypeScript
// file. TypeScript object literals get excess-property checking, so a server
// field missing from types.ts, a types.ts field missing from the wire, and an
// out-of-union literal all fail `npm run typecheck`.
/// One captured response. `ts_type` is the type argument `api.ts` hands to its
/// own `request<T>` for this endpoint — derived from that file, never invented.
const ContractSample = struct {
name: []const u8,
ts_type: []const u8,
method: []const u8,
target: []const u8,
body: ?[]const u8 = null,
status: u16,
};
/// Execution order is table order, and it is load-bearing twice over: a list
/// route runs after the create that gave it a row (an empty array witnesses no
/// field at all), and `POST /api/blocklists/update` runs while the only source
/// row is disabled, so the pass syncs its status without fetching anything.
const contract_sample_walk = [_]ContractSample{
.{ .name = "get_health", .ts_type = "Health", .method = "GET", .target = "/api/health", .status = 200 },
.{ .name = "get_version", .ts_type = "Version", .method = "GET", .target = "/api/version", .status = 200 },
.{ .name = "login", .ts_type = "LoginResponse", .method = "POST", .target = "/api/auth/login", .body = "{\"password\":\"\"}", .status = 200 },
.{ .name = "logout", .ts_type = "LogoutResponse", .method = "POST", .target = "/api/auth/logout", .body = "{}", .status = 200 },
// Blocklists. The row is created disabled so the refresh below has a status
// to report and still downloads nothing.
.{ .name = "create_blocklist", .ts_type = "BlocklistEcho", .method = "POST", .target = "/api/blocklists", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads\",\"enabled\":false}", .status = 201 },
.{ .name = "list_blocklists", .ts_type = "{ blocklists: Blocklist[] }", .method = "GET", .target = "/api/blocklists", .status = 200 },
.{ .name = "update_blocklist", .ts_type = "BlocklistEcho", .method = "PUT", .target = "/api/blocklists/1", .body = "{\"url\":\"https://lists.example/ads.txt\",\"name\":\"ads2\",\"enabled\":false}", .status = 200 },
.{ .name = "update_blocklists_now", .ts_type = "{ sources: SourceStatus[] }", .method = "POST", .target = "/api/blocklists/update", .body = "{}", .status = 202 },
// Groups. The migrated schema seeds `default` as id 1; the POST creates 2.
.{ .name = "list_groups", .ts_type = "{ groups: Group[] }", .method = "GET", .target = "/api/groups", .status = 200 },
.{ .name = "create_group", .ts_type = "Group", .method = "POST", .target = "/api/groups", .body = "{\"name\":\"kids\"}", .status = 201 },
.{ .name = "update_group", .ts_type = "Group", .method = "PUT", .target = "/api/groups/2", .body = "{\"name\":\"teens\",\"safe_search\":true}", .status = 200 },
.{ .name = "put_group_sources", .ts_type = "{ source_ids: number[] }", .method = "PUT", .target = "/api/groups/1/sources", .body = "{\"source_ids\":[1]}", .status = 200 },
.{ .name = "get_group_sources", .ts_type = "{ source_ids: number[] }", .method = "GET", .target = "/api/groups/1/sources", .status = 200 },
// Rules, then the lookup that the rule makes answer `blocked`.
.{ .name = "create_rule", .ts_type = "RuleEcho", .method = "POST", .target = "/api/rules", .body = "{\"group_id\":1,\"pattern\":\"ads.example\",\"kind\":\"exact\",\"action\":\"block\"}", .status = 201 },
.{ .name = "list_rules", .ts_type = "{ rules: Rule[] }", .method = "GET", .target = "/api/rules", .status = 200 },
.{ .name = "update_rule", .ts_type = "RuleEcho", .method = "PUT", .target = "/api/rules/1", .body = "{\"group_id\":1,\"pattern\":\"*.ads.example\",\"kind\":\"wildcard\",\"action\":\"block\"}", .status = 200 },
.{ .name = "get_lookup", .ts_type = "LookupResult", .method = "GET", .target = "/api/lookup?domain=sub.ads.example", .status = 200 },
// Local records.
.{ .name = "create_local_record", .ts_type = "LocalRecord", .method = "POST", .target = "/api/local-records", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.10\"}", .status = 201 },
.{ .name = "list_local_records", .ts_type = "{ local_records: LocalRecord[] }", .method = "GET", .target = "/api/local-records", .status = 200 },
.{ .name = "update_local_record", .ts_type = "LocalRecord", .method = "PUT", .target = "/api/local-records/1", .body = "{\"name\":\"nas.lan\",\"rtype\":\"A\",\"value\":\"192.168.1.11\",\"ttl\":120}", .status = 200 },
// Forward zones.
.{ .name = "create_forward_zone", .ts_type = "ForwardZone", .method = "POST", .target = "/api/forward-zones", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.1:53\"}", .status = 201 },
.{ .name = "list_forward_zones", .ts_type = "{ forward_zones: ForwardZone[] }", .method = "GET", .target = "/api/forward-zones", .status = 200 },
.{ .name = "update_forward_zone", .ts_type = "ForwardZone", .method = "PUT", .target = "/api/forward-zones/1", .body = "{\"zone\":\"lan\",\"resolver\":\"udp://10.0.0.2:53\"}", .status = 200 },
// Clients (row id 1 is seeded — clients have no POST, ruling 9).
.{ .name = "list_clients", .ts_type = "{ clients: Client[] }", .method = "GET", .target = "/api/clients", .status = 200 },
.{ .name = "update_client", .ts_type = "Client", .method = "PUT", .target = "/api/clients/1", .body = "{\"name\":\"laptop-renamed\",\"group_id\":1}", .status = 200 },
.{ .name = "put_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "PUT", .target = "/api/client-prefixes", .body = "{\"client_prefixes\":[{\"prefix\":\"192.168.1.0/24\",\"group_id\":1}]}", .status = 200 },
.{ .name = "list_client_prefixes", .ts_type = "{ client_prefixes: ClientPrefix[] }", .method = "GET", .target = "/api/client-prefixes", .status = 200 },
// Upstreams. Row id 1 is seeded; the PUT leaves its url alone so the
// conflict sample below can collide with it.
.{ .name = "list_upstreams", .ts_type = "{ upstreams: Upstream[] }", .method = "GET", .target = "/api/upstreams", .status = 200 },
.{ .name = "create_upstream", .ts_type = "UpstreamEcho", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns2.example/dns-query\"}", .status = 201 },
.{ .name = "update_upstream", .ts_type = "UpstreamEcho", .method = "PUT", .target = "/api/upstreams/1", .body = "{\"url\":\"https://dns.example/dns-query\",\"priority\":5}", .status = 200 },
.{ .name = "get_upstream_health", .ts_type = "UpstreamHealth", .method = "GET", .target = "/api/upstream/health", .status = 200 },
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
// the page carries both the null-bearing and the populated row shape.
.{ .name = "get_queries", .ts_type = "QueriesPage", .method = "GET", .target = "/api/queries?limit=5", .status = 200 },
.{ .name = "get_stats", .ts_type = "StatsTotals", .method = "GET", .target = "/api/stats?period=1h", .status = 200 },
.{ .name = "get_stats_timeseries", .ts_type = "StatsTimeseries", .method = "GET", .target = "/api/stats/timeseries?period=1h", .status = 200 },
// Pause: the GET before the POST, so one sample carries `until: null` and
// the other the deadline.
.{ .name = "get_pause", .ts_type = "PauseState", .method = "GET", .target = "/api/pause", .status = 200 },
.{ .name = "post_pause", .ts_type = "PauseState", .method = "POST", .target = "/api/pause", .body = "{\"paused\":true,\"duration_seconds\":600}", .status = 200 },
// Settings: the GET before any write, so `restart_required` is empty there
// and populated in the PUT's echo.
.{ .name = "get_settings", .ts_type = "SettingsEnvelope", .method = "GET", .target = "/api/settings", .status = 200 },
.{ .name = "put_settings", .ts_type = "SettingsEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"dns\":{\"port\":5353}}", .status = 200 },
// One sample per shared error class this environment can produce. 401 and
// 429 need their own environments and follow below.
.{ .name = "error_bad_request", .ts_type = "ErrorEnvelope", .method = "PUT", .target = "/api/settings", .body = "{\"logging\":{\"level\":\"chatty\"}}", .status = 400 },
.{ .name = "error_conflict", .ts_type = "ErrorEnvelope", .method = "POST", .target = "/api/upstreams", .body = "{\"url\":\"https://dns.example/dns-query\"}", .status = 409 },
.{ .name = "error_not_found", .ts_type = "ErrorEnvelope", .method = "GET", .target = "/api/nope", .status = 404 },
};
/// A session-authenticated environment answers this without a cookie.
const unauthorized_sample: ContractSample = .{
.name = "error_unauthorized",
.ts_type = "ErrorEnvelope",
.method = "GET",
.target = "/api/groups",
.status = 401,
};
/// The second request on a one-token bucket.
const rate_limited_sample: ContractSample = .{
.name = "error_rate_limited",
.ts_type = "ErrorEnvelope",
.method = "GET",
.target = "/api/version",
.status = 429,
};
/// `prettier` settings from web/package.json: tabs four columns wide, 120
/// columns. The generated file has to be a fixpoint of the repo's formatter or
/// CI's `npm run format:check` fails on it.
const ts_print_width = 120;
const ts_tab_width = 4;
/// Build identity, not contract data: `git_commit` comes from `-Dgit-commit`
/// and `zig_version` from the compiler that built the test, so keeping either
/// verbatim would pin the golden to one machine. Neither name occurs anywhere
/// else in the contract.
const volatile_string_keys = [_][]const u8{ "git_commit", "zig_version" };
fn writeTabs(w: *std.Io.Writer, depth: usize) !void {
for (0..depth) |_| try w.writeByte('\t');
}
/// True when `prettier` would print this object key without quotes.
fn isTsIdentifier(text: []const u8) bool {
if (text.len == 0) return false;
if (!std.ascii.isAlphabetic(text[0]) and text[0] != '_' and text[0] != '$') return false;
for (text[1..]) |byte| {
if (!std.ascii.isAlphanumeric(byte) and byte != '_' and byte != '$') return false;
}
return true;
}
fn lessThanKey(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
/// Object keys sorted, every number 0, strings and booleans verbatim. The
/// canonical form is what makes the golden byte-stable across runs: the seed is
/// fixed, so only the numbers (row ids, timestamps, uptimes) move.
fn writeCanonical(arena: Allocator, w: *std.Io.Writer, value: std.json.Value, depth: usize) anyerror!void {
switch (value) {
.null => try w.writeAll("null"),
.bool => |flag| try w.writeAll(if (flag) "true" else "false"),
.integer, .float, .number_string => try w.writeAll("0"),
.string => |text| try std.json.Stringify.value(text, .{}, w),
.array => |list| try writeCanonicalArray(arena, w, list.items, depth),
.object => |map| try writeCanonicalObject(arena, w, map, depth),
}
}
fn writeCanonicalObject(
arena: Allocator,
w: *std.Io.Writer,
map: std.json.ObjectMap,
depth: usize,
) anyerror!void {
if (map.count() == 0) return w.writeAll("{}");
const keys = try arena.dupe([]const u8, map.keys());
std.mem.sort([]const u8, keys, {}, lessThanKey);
// An object that starts with a newline stays expanded under `prettier`, so
// expanding every one of them is a fixpoint without measuring anything.
try w.writeAll("{\n");
for (keys) |key| {
try writeTabs(w, depth + 1);
if (isTsIdentifier(key)) try w.writeAll(key) else try std.json.Stringify.value(key, .{}, w);
try w.writeAll(": ");
var volatile_key = false;
for (volatile_string_keys) |name_| volatile_key = volatile_key or std.mem.eql(u8, name_, key);
if (volatile_key) {
try w.writeAll("\"<build>\"");
} else {
try writeCanonical(arena, w, map.get(key).?, depth + 1);
}
try w.writeAll(",\n");
}
try writeTabs(w, depth);
try w.writeAll("}");
}
fn writeCanonicalArray(
arena: Allocator,
w: *std.Io.Writer,
items: []const std.json.Value,
depth: usize,
) anyerror!void {
if (items.len == 0) return w.writeAll("[]");
// Elements that canonicalize identically witness the same shape, so only
// the first of each is kept: a 60-bucket timeseries is 60 copies of one
// object and would bury everything else in the file.
var kept: std.ArrayList([]const u8) = .empty;
var all_primitive = true;
for (items) |item| {
switch (item) {
.array, .object => all_primitive = false,
else => {},
}
var one: std.Io.Writer.Allocating = .init(arena);
try writeCanonical(arena, &one.writer, item, depth + 1);
const text = one.written();
var seen = false;
for (kept.items) |prior| seen = seen or std.mem.eql(u8, prior, text);
if (!seen) try kept.append(arena, text);
}
if (all_primitive) {
var width = depth * ts_tab_width + 2;
for (kept.items, 0..) |text, index| width += text.len + @as(usize, if (index == 0) 0 else 2);
if (width <= ts_print_width) {
try w.writeAll("[");
for (kept.items, 0..) |text, index| {
if (index != 0) try w.writeAll(", ");
try w.writeAll(text);
}
return w.writeAll("]");
}
}
try w.writeAll("[\n");
for (kept.items) |text| {
try writeTabs(w, depth + 1);
try w.writeAll(text);
try w.writeAll(",\n");
}
try writeTabs(w, depth);
try w.writeAll("]");
}
/// The pinned regeneration command, quoted verbatim in the file header and in
/// the failure message.
const regen_command =
"zig build test -Dintegration -Dcontract-samples-out=\"$PWD/" ++ contract_samples.path ++ "\"";
/// Every capitalised identifier in the sample table's type expressions, sorted:
/// exactly the import list the generated file needs.
fn writeSampleImports(arena: Allocator, w: *std.Io.Writer) !void {
var names: std.ArrayList([]const u8) = .empty;
for (contract_sample_walk ++ [_]ContractSample{ unauthorized_sample, rate_limited_sample }) |sample| {
var index: usize = 0;
while (index < sample.ts_type.len) {
if (!std.ascii.isUpper(sample.ts_type[index])) {
index += 1;
continue;
}
var end = index;
while (end < sample.ts_type.len and std.ascii.isAlphanumeric(sample.ts_type[end])) end += 1;
const word = sample.ts_type[index..end];
var seen = false;
for (names.items) |prior| seen = seen or std.mem.eql(u8, prior, word);
if (!seen) try names.append(arena, word);
index = end;
}
}
std.mem.sort([]const u8, names.items, {}, lessThanKey);
try w.writeAll("import type {\n");
for (names.items) |word| try w.print("\t{s},\n", .{word});
try w.writeAll("} from \"@/lib/types\";\n");
}
fn writeSampleHeader(arena: Allocator, w: *std.Io.Writer) !void {
try w.writeAll(
\\// Generated file — do not edit by hand.
\\//
\\// Every value below is a real response from the web server, captured by the
\\// contract-sample test in src/web/web_integration_test.zig and canonicalized:
\\// object keys sorted, every number 0, strings and booleans as the deterministic
\\// seed produced them, repeated array elements collapsed to the first. The type
\\// annotations are the ones api.ts hands to its own `request<T>`, so `tsc`
\\// refuses a field the wire does not send, a wire field types.ts does not
\\// declare, and a string outside a literal union.
\\//
\\// Regenerate with:
\\//
);
try w.print(" {s}\n\n", .{regen_command});
try writeSampleImports(arena, w);
}
/// Sends one sample's request on `conn` and appends its canonical rendering.
fn captureSample(
gpa: Allocator,
conn: *Conn,
out: *std.Io.Writer,
sample: ContractSample,
body_buf: []u8,
) anyerror!void {
try conn.request(sample.method, sample.target, null, sample.body);
const response = try conn.receive(body_buf);
if (response.status != sample.status) {
std.debug.print(
"contract sample {s} ({s} {s}): expected {d}, got {d} body {s}\n",
.{ sample.name, sample.method, sample.target, sample.status, response.status, response.body },
);
return error.TestUnexpectedResult;
}
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
const arena = arena_state.allocator();
const value = std.json.parseFromSliceLeaky(std.json.Value, arena, response.body, .{}) catch |err| {
std.debug.print("contract sample {s}: body is not JSON ({t}): {s}\n", .{ sample.name, err, response.body });
return err;
};
try out.print("\nexport const sample_{s}: {s} = ", .{ sample.name, sample.ts_type });
try writeCanonical(arena, out, value, 0);
try out.writeAll(";\n");
}
fn sampleWalk(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [128 * 1024]u8 = undefined;
for (contract_sample_walk) |sample| try captureSample(env.gpa, &conn, out, sample, &body_buf);
}
fn sampleUnauthorized(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [4096]u8 = undefined;
try captureSample(env.gpa, &conn, out, unauthorized_sample, &body_buf);
}
fn sampleRateLimited(io: std.Io, env: *Env, out: *std.Io.Writer) anyerror!void {
var conn: Conn = undefined;
try conn.connect(io, env.addr);
defer conn.close(io);
var body_buf: [4096]u8 = undefined;
// Capacity 1: the first counted request spends the only token.
try conn.request("GET", "/api/version", null, null);
const spent = try conn.receive(&body_buf);
try testing.expectEqual(@as(u16, 200), spent.status);
try captureSample(env.gpa, &conn, out, rate_limited_sample, &body_buf);
}
test "W10 milestone 17: the committed contract samples still describe live responses" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var arena_state: std.heap.ArenaAllocator = .init(gpa);
defer arena_state.deinit();
var rendered: std.Io.Writer.Allocating = .init(gpa);
defer rendered.deinit();
try writeSampleHeader(arena_state.allocator(), &rendered.writer);
{
var env = try Env.create(gpa, .{});
defer env.destroy();
try bounded(env.io(), default_budget, sampleWalk, .{ env.io(), env, &rendered.writer });
}
{
var hash_buf: [256]u8 = undefined;
const hash = try hashTestPassword(gpa, &hash_buf);
var env = try Env.create(gpa, .{ .password_hash = hash });
defer env.destroy();
try bounded(env.io(), default_budget, sampleUnauthorized, .{ env.io(), env, &rendered.writer });
}
{
var env = try Env.create(gpa, .{ .rate_per_min = 1, .localhost_exempt = false });
defer env.destroy();
try bounded(env.io(), default_budget, sampleRateLimited, .{ env.io(), env, &rendered.writer });
}
if (build_options.contract_samples_out.len != 0) {
var write_threaded: std.Io.Threaded = .init(gpa, .{});
defer write_threaded.deinit();
try std.Io.Dir.cwd().writeFile(write_threaded.io(), .{
.sub_path = build_options.contract_samples_out,
.data = rendered.written(),
});
std.debug.print("wrote {s}\n", .{build_options.contract_samples_out});
return;
}
if (!std.mem.eql(u8, contract_samples.bytes, rendered.written())) {
std.debug.print(
"{s} no longer matches the live responses.\n" ++
"The server and the frontend's types.ts have drifted, or the seed changed.\n" ++
"Regenerate, then read the diff and `npm run typecheck`:\n {s}\n",
.{ contract_samples.path, regen_command },
);
return error.TestUnexpectedResult;
}
}
test "drift guard b: openapi.yaml documents exactly as many operations as the router serves" {
const yaml = openapi.yaml;
const paths_start = std.mem.indexOf(u8, yaml, "\npaths:\n") orelse return error.TestUnexpectedResult;