From 17d0401f8ab0fdedf89e7ed32a4fc95bb4011c79 Mon Sep 17 00:00:00 2001 From: m5r Date: Sat, 1 Aug 2026 12:36:50 +0200 Subject: [PATCH] resolver transport: udp/tcp servers, doh/dot clients, pool failover with health --- specs/milestone-3.md | 1013 ++++++++++++++++++++ src/server/handler.zig | 825 ++++++++++++++++ src/server/resolver_integration_test.zig | 289 ++++++ src/server/tcp_server.zig | 501 ++++++++++ src/server/tcp_server_integration_test.zig | 281 ++++++ src/server/udp_server.zig | 300 ++++++ src/server/udp_server_integration_test.zig | 217 +++++ src/tests.zig | 13 + src/upstream/doh_client.zig | 271 ++++++ src/upstream/doh_client_live_test.zig | 93 ++ src/upstream/dot_client.zig | 478 +++++++++ src/upstream/dot_client_live_test.zig | 119 +++ src/upstream/health.zig | 442 +++++++++ src/upstream/pool.zig | 723 ++++++++++++++ src/upstream/transport.zig | 497 ++++++++++ 15 files changed, 6062 insertions(+) create mode 100644 specs/milestone-3.md create mode 100644 src/server/handler.zig create mode 100644 src/server/resolver_integration_test.zig create mode 100644 src/server/tcp_server.zig create mode 100644 src/server/tcp_server_integration_test.zig create mode 100644 src/server/udp_server.zig create mode 100644 src/server/udp_server_integration_test.zig create mode 100644 src/upstream/doh_client.zig create mode 100644 src/upstream/doh_client_live_test.zig create mode 100644 src/upstream/dot_client.zig create mode 100644 src/upstream/dot_client_live_test.zig create mode 100644 src/upstream/health.zig create mode 100644 src/upstream/pool.zig create mode 100644 src/upstream/transport.zig diff --git a/specs/milestone-3.md b/specs/milestone-3.md new file mode 100644 index 0000000..ed651f7 --- /dev/null +++ b/specs/milestone-3.md @@ -0,0 +1,1013 @@ +# Milestone 3: Resolver Transport + +Goal (PLAN §16 Phase 3): UDP server, TCP server, DoH + DoT upstream clients, upstream pool with +priority failover, backoff and health. Exit: A/AAAA forwarding over UDP and TCP; health populated. + +Read first: `AGENTS.md` (values), `specs/research/zig-0.16-api-notes.md` (verified stdlib facts — +pre-0.16 knowledge is stale and MUST NOT be used), `specs/milestone-1.md` and `specs/milestone-2.md` +(module conventions and the "As built" notes). The Zig source of truth is `/home/mokhtar/app/zig` at +tag `0.16.0`. RFCs: 1035 §4.2.2 (TCP length prefix), 7766 (DNS over TCP), 7858 (DoT), 8484 (DoH), +6891 (EDNS(0)), 4343 (name case-insensitivity), 9619 (QDCOUNT must be 1). + +## What already exists (do not respecify, import it) + +- `src/dns/*` — pure wire format. Used here: `packet.parse`, `packet.Packet`, `packet.firstQuestion`, + `packet.findOptRecord`, `packet.ResponseBuilder`, `packet.setId`, `header.parse`, `header.Flags`, + `question.Question`, `name.eqlIgnoreCase`, `edns.parseOpt`, `types.Rcode`, `types.Type`. +- `src/platform/address.zig` — `NetAddress`, `Prefix`, `matchLongest`. +- `src/platform/tls_client.zig` — `TlsStream` (pinned struct; `init`, `reader`, `writer`, `close`), + `classify`, `ErrorClass`. DoT builds on this; do not open `std.crypto.tls.Client` directly. +- `src/platform/tls_server.zig` — server-side TLS. **Not used in this milestone** (local DoH/DoT + endpoints are Phase 9). +- `build.zig` — `-Dintegration` (hermetic, loopback only, PR-blocking) and `-Dlive` (leaves the + machine, manual workflow only) reach test files through `@import("build_options")`. + +## Sessions + +``` +S1 (upstream/transport.zig + upstream/health.zig) + | + +--> S2 (doh_client) S3 (dot_client) S4 (pool) S5 (handler) [parallel] + | + +--> S6 (udp_server + tcp_server) + | + +--> S7 (e2e integration test) +``` + +S1 defines every type the other sessions share, so S2–S5 can be written against the spec alone. +S6 depends on S5's `Handler` type; S7 depends on everything. + +The orchestrator — not any session — wires `src/tests.zig` imports and any `build.zig` change. A +session that needs a build change reports the exact change in its completion report. + +## Design invariants (all sessions) + +- The pure core stays pure. `src/dns/` gains nothing. All `std.Io` use lives in `src/server/` and + `src/upstream/`. `upstream/health.zig` and the validation half of `upstream/transport.zig` are + themselves pure (timestamps and randomness arrive as parameters), so they are unit-testable + without a backend. +- **Failures are classified into three disjoint groups**: peer fault, local resource, cancellation. + Health and backoff count peer faults ONLY. A local `OutOfMemory` must never mark an upstream sick, + and `error.Canceled` must never be recorded at all. +- **QDCOUNT must be exactly 1** (RFC 9619) — for inbound client queries (else FORMERR) and for + upstream responses (else the response is a peer fault). +- Every response from an upstream is validated against the request it answers before it reaches a + client: ID equal, QDCOUNT 1 on both sides, QR set, question name equal **case-insensitively** + (RFC 4343), qtype and qclass equal. +- TCP and DoT messages carry a 2-byte big-endian length prefix (RFC 1035 §4.2.2). DoH does not. +- No stream read or write in 0.16.0 accepts a timeout (verified: `Io.Operation` has no net-stream + variants). Bound them by running the work under `io.concurrent` / `std.Io.Select` and cancelling + the loser, exactly as `src/platform/tls_client_integration_test.zig` does. UDP `receive` is the one + exception — `Socket.receiveTimeout` is implemented on the POSIX Threaded backend (verified: + `Io/Threaded.zig:2779` handles `net_receive` in the pollable batch path). +- Never set `net.IpAddress.ConnectOptions.timeout` — the Threaded backend panics + (`Io/Threaded.zig:12077`). +- Every dropped datagram, refused connection and failed send increments a named counter. No silent + drops (AGENTS.md). +- Unit tests live in-file. Tests that touch loopback sockets live in a separate `*_integration_test.zig` + file guarded by `if (!build_options.integration) return error.SkipZigTest;`. Tests that leave the + machine are guarded by `build_options.live`. This mirrors milestone 1 — the guard is a runtime + return so the body is always compiled and cannot rot. + +--- + +## Session S1: `src/upstream/transport.zig`, `src/upstream/health.zig` + +Foundation. No sockets are opened in this session; `transport.zig` names the `Io`-taking interface +that S2–S4 implement, and everything else in both files is pure. + +### S1.1 transport.zig — endpoint parsing + +```zig +pub const max_message_len = 65535; // RFC 1035 §4.2.2 length prefix is 16-bit +pub const doh_default_port = 443; +pub const dot_default_port = 853; // RFC 7858 §3.1 +pub const doh_default_path = "/dns-query"; // RFC 8484 §4.1 well-known template + +pub const Scheme = enum { doh, dot }; + +/// Borrowed view over the configured URL text; the caller owns the string. +pub const Endpoint = struct { + scheme: Scheme, + url: []const u8, // the original text, for logs and the health API + host: []const u8, // no brackets, no port; SNI and certificate verification name + port: u16, + path: []const u8, // DoH only; always starts with '/'; `doh_default_path` when absent + + pub const ParseError = error{ UnsupportedScheme, MissingHost, BadPort, BadUrl }; + + /// `https://…` => .doh, `tls://…` => .dot (PLAN §9). Accepts `[v6]:port` bracket form. + pub fn parse(url: []const u8) ParseError!Endpoint; +}; +``` + +Hand-written parse (a `std.Uri` round trip would hand back percent-encoded components that need +re-decoding for one household-scale config value). Rules: reject any other scheme, reject an empty +host, reject a port that is empty, non-numeric or > 65535, reject a `tls://` URL that carries a path +other than `/` or nothing. + +Tests: `https://cloudflare-dns.com/dns-query`, `https://dns.example/x` (path preserved), +`https://dns.example` (path defaults), `tls://dns.google:853`, `tls://dns.google` (port defaults to +853), `https://[2606:4700:4700::1111]:8443/dns-query` (host without brackets, port 8443), +`udp://1.1.1.1:53` → `UnsupportedScheme`, `https://` → `MissingHost`, `https://h:99999/` → `BadPort`. + +### S1.2 transport.zig — error groups + +```zig +/// The upstream misbehaved, timed out, or was unreachable. Only these count against health. +pub const PeerFault = error{ + ConnectFailed, + TlsFailed, + SendFailed, + ReceiveFailed, + Timeout, + BadResponse, // unparseable, not a response, or QDCOUNT != 1 + ResponseMismatch, // ID or question does not match the query + ResponseTooLarge, // does not fit the caller's buffer + HttpStatus, // DoH: status other than 200 + HttpContentType, // DoH: content-type other than application/dns-message +}; + +/// This process ran out of something. Never the upstream's fault. +pub const LocalResource = error{ + OutOfMemory, + SystemResources, + ProcessFdQuotaExceeded, + SystemFdQuotaExceeded, + BufferTooSmall, // caller-supplied buffer cannot hold even a query + Unexpected, +}; + +pub const Cancellation = error{Canceled}; + +pub const ExchangeError = PeerFault || LocalResource || Cancellation; + +pub const Group = enum { peer_fault, local_resource, cancellation }; + +/// Exhaustive switch over `ExchangeError` — no `else` arm. A new error member must break +/// the build here, so no failure can silently land in the wrong group. +pub fn group(err: ExchangeError) Group; +``` + +The three sets are disjoint by construction; a test asserts it by walking +`@typeInfo(PeerFault).error_set.?` and friends and checking no name appears twice. + +`pub fn mapLocal(err: anyerror) ?ExchangeError` — helper for S2/S3: returns the `LocalResource` or +`Cancellation` member matching a foreign stdlib error by name, `null` when the caller should treat +the error as a peer fault. Implemented with an explicit switch over the named errors listed above +plus `error.Canceled`; this is the ONLY place a foreign error set is folded in. + +### S1.3 transport.zig — the client interface + +```zig +/// A thing that sends one DNS message and returns one validated DNS message. +/// Implemented by DohClient, DotClient, Pool, and test fakes. +pub const Client = struct { + ptr: *anyopaque, + exchangeFn: *const fn ( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) ExchangeError![]u8, + + /// Returns a prefix of `response_buf`. The returned message has already passed + /// `validateResponse` against `query`. + pub fn exchange( + self: Client, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) ExchangeError![]u8 { + return self.exchangeFn(self.ptr, io, query, response_buf); + } +}; +``` + +### S1.4 transport.zig — response validation (pure) + +```zig +pub const ValidateError = error{ BadResponse, ResponseMismatch }; + +/// RFC 9619: exactly one question on both sides. RFC 4343: names compare case-insensitively, +/// so a case-mangling (0x20) upstream still matches. Does not inspect the answer section — +/// content policy is not this layer's business. +pub fn validateResponse(query: []const u8, response: []const u8) ValidateError!void; +``` + +Order of checks: response parses (`packet.parse` failure → `BadResponse`); response header +`flags.qr` is true (else `BadResponse`); response `qdcount == 1` (else `BadResponse`); query parses +and has `qdcount == 1` (else `BadResponse` — a caller bug, but never a crash); IDs equal (else +`ResponseMismatch`); question `qtype` and `qclass` equal and `name.eqlIgnoreCase` (else +`ResponseMismatch`). + +Tests (hand-built byte fixtures, reuse the shape of `src/dns/packet.zig`'s fixtures): matching pair +passes; mixed-case question name passes; wrong ID → `ResponseMismatch`; different qtype → +`ResponseMismatch`; different name → `ResponseMismatch`; QR clear → `BadResponse`; response with +QDCOUNT 0 → `BadResponse`; response with QDCOUNT 2 → `BadResponse`; truncated garbage → +`BadResponse`. + +### S1.5 health.zig — pure health and backoff state + +```zig +pub const Config = struct { + /// Consecutive peer faults before the endpoint is put in backoff. + failure_threshold: u8 = 2, + base_backoff_ms: u32 = 500, + max_backoff_ms: u32 = 60_000, +}; + +pub const window_len = 32; // rolling success-rate window + +pub const State = struct { + consecutive_failures: u32, + total_successes: u64, + total_failures: u64, + last_success_at: ?std.Io.Timestamp, + last_error_at: ?std.Io.Timestamp, + last_error_buf: [48]u8, + last_error_len: u8, // @errorName of the last peer fault, truncated + backoff_until: ?std.Io.Timestamp, + window: u32, // bitset, 1 = success, LSB = most recent + window_filled: u8, + + pub const init: State = ...; + + pub fn recordSuccess(self: *State, at: std.Io.Timestamp) void; + + /// `err_name` is `@errorName` of a PeerFault member. `rand` supplies jitter; the caller + /// owns the RNG so this stays pure and the test is deterministic. + pub fn recordFailure( + self: *State, + at: std.Io.Timestamp, + err_name: []const u8, + cfg: Config, + rand: u32, + ) void; + + pub fn available(self: *const State, now: std.Io.Timestamp) bool; + pub fn successRate(self: *const State) f32; // over the filled part of the window; 1.0 when empty + pub fn lastError(self: *const State) []const u8; +}; +``` + +Rules: + +1. `recordSuccess` clears `consecutive_failures` and `backoff_until`, pushes a 1 into the window, + and sets `last_success_at` to `@max(existing, at)` by nanoseconds. +2. `recordFailure` increments `consecutive_failures` and `total_failures`, pushes a 0, copies + `err_name` (truncated to the buffer), sets `last_error_at` to `@max(existing, at)`. When + `consecutive_failures >= cfg.failure_threshold`, it computes + `delay_ms = min(max_backoff_ms, base_backoff_ms << shift)` where + `shift = min(consecutive_failures - failure_threshold, 20)`, applies jitter + `jittered = delay_ms/2 + rand % (delay_ms/2 + 1)`, and sets + `backoff_until = @max(existing, at + jittered)`. +3. **Out-of-order completions are the normal case**, not an edge case: two concurrent exchanges + against the same endpoint complete in either order, so `at` can move backwards between calls. + Every timestamp field therefore updates through `@max` on `.nanoseconds`, and `backoff_until` is + only ever extended, never shortened. `available` must never compute a negative duration. +4. `State` carries no lock. The pool owns the mutex. + +Tests: threshold not reached → `available(now)` stays true; threshold reached → unavailable until +`backoff_until`, available one nanosecond after; consecutive failures grow the delay and it saturates +at `max_backoff_ms` (walk 40 failures with `rand = 0`); success resets consecutive count, window and +backoff; jitter with `rand = 0` and `rand = maxInt(u32)` both land inside `[delay/2, delay]`; an +out-of-order pair — `recordSuccess(t=100)` then `recordSuccess(t=50)` — leaves `last_success_at` at +100; `recordFailure(t=50)` after `recordFailure(t=100)` does not shorten `backoff_until`; +`successRate` over a half-success window is 0.5; `lastError` returns the last recorded name and is +truncated, not overflowed, by a name longer than the buffer. + +### S1.6 Acceptance criteria + +- [ ] `zig test src/upstream/transport.zig` and `zig test src/upstream/health.zig` pass. +- [ ] `group` compiles with no `else` arm over `ExchangeError`. +- [ ] The disjointness test over the three error sets passes. +- [ ] Every listed validation and health case has a test. +- [ ] `zig fmt --check` clean on both files. + +--- + +## Session S2: `src/upstream/doh_client.zig` (+ live test) + +DoH per RFC 8484 over `std.http.Client` (HTTP/1.1; HTTP/2 is permanently out of scope, PLAN §2.2). + +### S2.1 API + +```zig +pub const DohClient = struct { + http: *std.http.Client, // caller-owned; shared across endpoints, pools connections + endpoint: transport.Endpoint, + uri: std.Uri, // built once in init from `endpoint` + request_buf: []u8, // caller-owned; sendBodyComplete needs a mutable body + transfer_buf: []u8, // caller-owned; HTTP body transfer buffer + + pub const InitError = error{BadUrl}; + + pub fn init( + http: *std.http.Client, + endpoint: transport.Endpoint, + request_buf: []u8, // asserted >= 512 + transfer_buf: []u8, // asserted >= 1024 + ) InitError!DohClient; + + pub fn client(self: *DohClient) transport.Client; +}; +``` + +`self.uri` is derived from `endpoint` with `std.Uri.parse(endpoint.url)`; a parse failure is +`error.BadUrl`. `endpoint` remains the source of truth for host/port/scheme in logs. + +### S2.2 Exchange + +1. `if (query.len > self.request_buf.len) return error.BufferTooSmall;` then copy `query` into + `request_buf` — `Request.sendBodyComplete` takes `[]u8`, not `[]const u8` (verified, + `std/http/Client.zig:935`). +2. ```zig + var req = try self.http.request(.POST, self.uri, .{ + .keep_alive = true, + .redirect_behavior = .not_allowed, + .headers = .{ + .content_type = .{ .override = "application/dns-message" }, + // Compressed bodies would need the decompressing reader; identity keeps the + // response body byte-exact for `validateResponse`. + .accept_encoding = .{ .override = "identity" }, + }, + .extra_headers = &.{.{ .name = "accept", .value = "application/dns-message" }}, + }); + defer req.deinit(); + ``` + `Request.Headers` has no `accept` field (verified, `std/http/Client.zig:845`), hence + `extra_headers`. +3. `try req.sendBodyComplete(self.request_buf[0..query.len]);` +4. `var resp = try req.receiveHead(&.{});` — an empty redirect buffer is legal with + `.not_allowed`. +5. `if (resp.head.status != .ok) return error.HttpStatus;` +6. Content-type: take `resp.head.content_type orelse return error.HttpContentType`, cut at the + first `;`, trim ASCII whitespace, `std.ascii.eqlIgnoreCase` against `"application/dns-message"`, + else `error.HttpContentType`. +7. `const body = resp.reader(self.transfer_buf);` then fill `response_buf` with repeated + `readSliceShort` until it returns 0. If the body has not ended when `response_buf` is full → + `error.ResponseTooLarge`. A `content_length` larger than `response_buf.len` short-circuits to the + same error. +8. `try transport.validateResponse(query, response_buf[0..len]);` mapping `ValidateError` members + straight through (both are `PeerFault` members). +9. Return `response_buf[0..len]`. + +The query ID is sent unchanged. RFC 8484 §4.1 suggests ID 0 for HTTP cache friendliness; nxdns puts +no HTTP cache in this path, and keeping the ID preserves the request/response binding that +`validateResponse` enforces. State that reason in a comment. + +Error mapping: one `fn mapError(err: anyerror) transport.ExchangeError` that first tries +`transport.mapLocal(err)`, and otherwise maps by phase — `request`/connect errors → +`error.ConnectFailed`, TLS-named errors (`@errorName` starting with `"Tls"` or `"Certificate"`) → +`error.TlsFailed`, `sendBodyComplete` errors → `error.SendFailed`, `receiveHead` and body-read +errors → `error.ReceiveFailed`. Do the phase distinction at the call site (each `catch` knows its +phase), not by guessing from the error name. + +### S2.3 Tests + +- In-file unit: `init` with a `tls://` endpoint is rejected before any I/O (assert or `BadUrl`); + `init` asserts on an undersized `request_buf`; the content-type matcher accepts + `"application/dns-message"`, `"Application/DNS-Message"` and + `"application/dns-message; charset=utf-8"`, and rejects `"text/html"` and a missing header. + Factor the matcher into `fn contentTypeOk(value: ?[]const u8) bool` so it is testable without a + server. +- `src/upstream/doh_client_live_test.zig`, guarded by `build_options.live`: POST an A query for + `example.com` to `https://cloudflare-dns.com/dns-query`, assert the reply validates and carries at + least one answer record. Whole exchange raced against a 10 s budget with `std.Io.Select`, the + pattern in `src/platform/tls_client_integration_test.zig`. This host's IPv6 egress is broken (API + notes) and DoH needs name resolution — if the test stalls on AAAA, report it as an environment + finding rather than working around it; the test is non-blocking by construction. + +### S2.4 Acceptance criteria + +- [ ] `zig test src/upstream/doh_client.zig` passes (unit tests, no network). +- [ ] `DohClient` satisfies `transport.Client` (a compile-time test takes `client()` and calls + `exchange` through the interface against a `std.http.Client` that is never driven — verify by + instantiation, not by running an exchange). +- [ ] The live test compiles in every build and skips without `-Dlive`. +- [ ] `zig fmt --check` clean. + +--- + +## Session S3: `src/upstream/dot_client.zig` (+ live test) + +DoT per RFC 7858: TLS on port 853, DNS messages framed exactly as over TCP (RFC 1035 §4.2.2). + +### S3.1 API + +```zig +pub const DotClient = struct { + endpoint: transport.Endpoint, + gpa: std.mem.Allocator, + bundle: *std.crypto.Certificate.Bundle, // caller-owned, shared + bundle_lock: *std.Io.RwLock, // caller-owned, shared + buffers: Buffers, // caller-owned; one DotClient is used by one task + + pub const Buffers = struct { + tls_read: []u8, // >= std.crypto.tls.Client.min_buffer_len + tls_write: []u8, // >= std.crypto.tls.Client.min_buffer_len + stream_read: []u8, // >= std.crypto.tls.Client.min_buffer_len + stream_write: []u8, // >= std.crypto.tls.Client.min_buffer_len + }; + + pub fn init( + endpoint: transport.Endpoint, + gpa: std.mem.Allocator, + bundle: *std.crypto.Certificate.Bundle, + bundle_lock: *std.Io.RwLock, + buffers: Buffers, + ) DotClient; // asserts endpoint.scheme == .dot and every buffer length + + pub fn client(self: *DotClient) transport.Client; +}; +``` + +### S3.2 Exchange + +One TCP connection and one TLS handshake per exchange, closed before returning. Connection reuse is +not built: at household query rates the pool's per-attempt budget and the failover path stay simple +and every failure is attributable to one exchange, which is worth more here than the saved round +trips. Say that in a comment — it is a decision, not a stub. + +1. Resolve the endpoint to an `std.Io.net.IpAddress` with `net.IpAddress.parse(endpoint.host, + endpoint.port)`. **A non-literal host is `error.ConnectFailed`** with a log line naming the host: + name resolution for upstreams is not in this milestone's scope, and a silent fallback would hide + it. (`tls://dns.google:853` from PLAN §12.1 therefore needs an IP literal in config; note this in + the completion report so the orchestrator can carry it into the Phase 4 config validator.) +2. `var stream = try address.connect(io, .{ .mode = .stream });` — no `timeout` field, ever. + `defer` a close that runs under cancel protection (below). +3. `var tls_stream: tls_client.TlsStream = undefined;` then `tls_stream.init(io, &stream, bundle, + bundle_lock, gpa, .{ .host = endpoint.host, .ca = .system, … })`. `TlsStream` is pinned — do not + copy or move it after init. +4. Write `[2]u8` big-endian length then `query` to `tls_stream.writer()`, then `flush()`. +5. Read exactly 2 bytes, then that many bytes, from `tls_stream.reader()`. `len == 0` → + `error.BadResponse`. `len > response_buf.len` → `error.ResponseTooLarge`. A short read or EOF → + `error.ReceiveFailed`. +6. `try transport.validateResponse(query, response_buf[0..len]);` +7. `tls_stream.close();` then `stream.close(io);`. + +Cleanup under cancellation: the pool cancels this task on timeout, and the next cancelable `Io` call +in the `defer` chain would return `error.Canceled` and skip the socket close. Wrap the close path in +`const prev = io.swapCancelProtection(.blocked); defer _ = io.swapCancelProtection(prev);` +(verified: `std/Io.zig:1342`) so the socket is always released. + +Error mapping: `connect` → `error.ConnectFailed`; `TlsStream.init` → `error.TlsFailed` (log +`tls_client.classify(err)` alongside — that is what milestone 1 built it for); write/flush → +`error.SendFailed`; read → `error.ReceiveFailed`; `transport.mapLocal` first at every site. + +### S3.3 Tests + +- In-file unit: `framePrefix(len: u16) [2]u8` and `parsePrefix(bytes: [2]u8) u16` round-trip, + including 0 and 65535, big-endian byte order asserted explicitly; `init` asserts on a `.doh` + endpoint; a non-literal host maps to `error.ConnectFailed` without touching the network (call the + address-resolution helper directly). +- `src/upstream/dot_client_live_test.zig`, guarded by `build_options.live`: exchange an A query for + `example.com` against `tls://1.1.1.1:853` with `endpoint.host` overridden to `"1.1.1.1"` — this + machine's IPv6 egress is dead, so the documented anycast literal is used, and Cloudflare's + certificate carries `1.1.1.1` as an IP SAN. If the stdlib verifier rejects an IP-literal SAN, do + NOT weaken verification: report the finding. Raced against a 10 s budget with `std.Io.Select`. + +### S3.4 Acceptance criteria + +- [ ] `zig test src/upstream/dot_client.zig` passes (unit tests, no network). +- [ ] `DotClient` satisfies `transport.Client`. +- [ ] The live test compiles in every build and skips without `-Dlive`. +- [ ] No direct `std.crypto.tls.Client` use — everything goes through `platform/tls_client.zig`. +- [ ] `zig fmt --check` clean. + +--- + +## Session S4: `src/upstream/pool.zig` + +Priority-ordered sequential failover with per-attempt deadline, health tracking and backoff +(PLAN §9). The pool is itself a `transport.Client`, so the handler sees one interface. + +### S4.1 API + +```zig +pub const Entry = struct { + endpoint: transport.Endpoint, + client: transport.Client, + priority: i32, // lower first (PLAN §11.2 `upstreams.priority`) + enabled: bool, + health: health.State, +}; + +pub const Snapshot = struct { + url: []const u8, + enabled: bool, + available: bool, + consecutive_failures: u32, + total_successes: u64, + total_failures: u64, + success_rate: f32, + last_success_at: ?std.Io.Timestamp, + last_error_at: ?std.Io.Timestamp, + last_error: []const u8, // borrowed from the entry; valid until the next failure + backoff_until: ?std.Io.Timestamp, +}; + +pub const Pool = struct { + entries: []Entry, // caller-owned, sorted ascending by priority in `init` + cfg: health.Config, + attempt_timeout: std.Io.Clock.Duration, + mutex: std.Io.Mutex, + rng: std.Random.DefaultPrng, + + pub fn init( + entries: []Entry, + cfg: health.Config, + attempt_timeout: std.Io.Clock.Duration, + seed: u64, + ) Pool; // asserts entries.len > 0 + + pub fn client(self: *Pool) transport.Client; + + pub fn exchange( + self: *Pool, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8; + + /// Copies health into `out` in pool order; returns the number written. + /// Feeds `GET /api/upstream/health` in Phase 8. + pub fn snapshot(self: *Pool, io: std.Io, out: []Snapshot) std.Io.Cancelable!usize; +}; +``` + +`attempt_timeout` uses the `.awake` clock (`.{ .raw = .fromMilliseconds(2000), .clock = .awake }`) so +a suspended Pi does not burn the budget. + +### S4.2 Failover algorithm + +1. Take `now = std.Io.Clock.awake.now(io)`. +2. **Pass one**: walk `entries` in order; skip `!enabled`; skip entries whose + `health.available(now)` is false. +3. **Pass two**, only if pass one attempted nothing: walk again, skipping only `!enabled`. Every + endpoint being in backoff must not turn into `SERVFAIL` for every client — a probe is better than + a guaranteed failure, and it is how backoff recovers. Test this explicitly. +4. For each candidate, run `attempt`: + - Race `entry.client.exchange(io, query, response_buf)` against + `attempt_timeout.sleep(io)` using `std.Io.Select` with a two-arm union, then + `race.cancelDiscard()` on the way out (the milestone-1 pattern). The sleep arm winning yields + `error.Timeout`. + - On success: lock the mutex, `entry.health.recordSuccess(completed_at)`, unlock, return the + slice. + - On error, switch on `transport.group(err)`: + - `.peer_fault` → lock, `recordFailure(completed_at, @errorName(err), self.cfg, self.rng.random().int(u32))`, + unlock, remember it as `last_fault`, continue to the next candidate. + - `.local_resource` → return the error immediately. Do not record. Do not try another upstream: + the next one will hit the same wall. + - `.cancellation` → return `error.Canceled` immediately. Do not record. + - `completed_at` is `std.Io.Clock.awake.now(io)` taken after the attempt returns. +5. All candidates exhausted → return `last_fault` (guaranteed non-null: `entries.len > 0` and pass + two attempts every enabled entry). If every entry is disabled, return `error.ConnectFailed`. + +`response_buf` is handed to each attempt in turn; a failed attempt may have written into it, so the +returned slice is only meaningful on success. Note this in a comment. + +Concurrency: several handler tasks share one `Pool`. The mutex guards health mutation and +`snapshot` only — never an in-flight exchange, so a slow upstream cannot block bookkeeping. Entry +`client` values must therefore be safe for concurrent `exchange` calls; `DohClient` and `DotClient` +own per-instance buffers, so **one `Entry` per concurrent task, or one shared pool with per-entry +serialization, is a wiring decision for the orchestrator**. Record in the completion report which +one this session assumed (the specified `Entry` layout assumes each entry's client is used by one +task at a time; the e2e test in S7 exercises exactly one in-flight query at a time). + +### S4.3 Tests (in-file, no sockets — fake clients implement `transport.Client`) + +- Priority order: entry with priority 10 is tried before priority 100. +- Failover: first fake returns `error.Timeout`, second returns a valid response → the pool returns + the second one's bytes; first entry's `consecutive_failures == 1`, second's `total_successes == 1`. +- Backoff skip: drive a fake past `failure_threshold`, then assert the next `exchange` does not call + that fake (a call counter in the fake) while another entry is available. +- All-backed-off probe: every entry in backoff → pass two still calls them. +- `.local_resource` short-circuit: a fake returning `error.OutOfMemory` makes `exchange` return + `error.OutOfMemory`, the second fake is never called, and the first entry's `total_failures` + stays 0. +- `.cancellation` short-circuit: a fake returning `error.Canceled` returns `error.Canceled` and + records nothing. +- Attempt timeout: a fake that sleeps longer than `attempt_timeout` yields a recorded + `error.Timeout` peer fault and moves on. (Needs a live `Io` — use `std.Io.Threaded` inside the + test; no sockets are involved, so this stays in the default `zig build test`.) +- All entries disabled → `error.ConnectFailed`. +- `snapshot` reports the counters set by the preceding cases. + +### S4.4 Acceptance criteria + +- [ ] `zig test src/upstream/pool.zig` passes, including the timeout test on a `Threaded` backend. +- [ ] Every bullet in S4.3 exists as a named test. +- [ ] `Pool` satisfies `transport.Client`. +- [ ] `zig fmt --check` clean. + +--- + +## Session S5: `src/server/handler.zig` + +The serving handler: bytes in from a listener, validated bytes out. It owns no socket and no clock +beyond what it passes to the upstream. Phase 7 extends this file with filtering, cache, local +records and logging — nothing of that appears here. + +### S5.1 API + +```zig +pub const Transport = enum { udp, tcp }; + +pub const Handler = struct { + upstream: transport.Client, // in production `pool.client()` + stats: Stats = .{}, + + pub const Stats = struct { + queries: std.atomic.Value(u64) = .init(0), + dropped_malformed: std.atomic.Value(u64) = .init(0), + formerr: std.atomic.Value(u64) = .init(0), + notimp: std.atomic.Value(u64) = .init(0), + servfail: std.atomic.Value(u64) = .init(0), + truncated: std.atomic.Value(u64) = .init(0), + }; + + pub const Outcome = union(enum) { + reply: []u8, // a prefix of `response_buf` + drop, // no response is possible or appropriate + }; + + /// `response_buf.len` must be >= 512 and <= 65535 (ResponseBuilder asserts the upper bound). + pub fn handle( + self: *Handler, + io: std.Io, + which: Transport, + query: []const u8, + response_buf: []u8, + ) Outcome; +}; +``` + +`handle` never returns an error: every failure is either a DNS response or a counted drop. That is +the "every failure mode visible" rule applied to the hot path. + +### S5.2 Behavior + +1. `header.parse(query)` fails (< 12 bytes) → `dropped_malformed`, `.drop` (PLAN §6.1: severely + truncated is a silent drop). +2. `hdr.flags.qr == true` → a response arriving on a listener port; `dropped_malformed`, `.drop`. +3. `packet.parse(query)`: + - `error.Truncated` → `dropped_malformed`, `.drop`. + - any other `WalkError` → FORMERR reply built from `hdr` with **no** echoed question (the + question is what failed to parse); `formerr`. +4. `hdr.flags.opcode != .query` → NOTIMP reply echoing nothing; `notimp`. +5. `hdr.qdcount != 1` → FORMERR (RFC 9619 makes any other value invalid, in both directions); + `formerr`. +6. Forward: `self.upstream.exchange(io, query, response_buf)`. + - success → `queries` incremented; then the UDP size check in §5.3; `.reply`. + - `transport.group(err) == .cancellation` → `.drop` (the process is shutting down; nothing to + say). + - `.peer_fault` or `.local_resource` → SERVFAIL reply echoing the question; `servfail`. +7. Every synthesized reply preserves the request ID, opcode and RD bit, sets QR and RA, and echoes + the question when one parsed — all of that is already `packet.ResponseBuilder.init`'s contract. + When the query carried an OPT record (`packet.findOptRecord` + `edns.parseOpt`), the reply ends + with `addOptEcho(opt, opt.do_bit)` so the DO bit passes through (PLAN §6.1). + +### S5.3 UDP size limit and truncation + +The upstream answer can exceed what the client will accept over UDP. + +- `pub fn udpLimit(query_packet: packet.Packet) u16` — the query's OPT `udp_payload_size` clamped to + `[512, 4096]`, or 512 when there is no OPT record (RFC 1035 §4.2.1 / RFC 6891 §6.2.3). +- `which == .udp` and the upstream reply is longer than `udpLimit` → build a reply with `TC = 1`, + RCODE NOERROR, the question echoed, no answer records, OPT echoed when present; increment + `truncated`. The client then retries over TCP, which is exactly what RFC 1035 §4.2.1 prescribes. +- `which == .tcp` → no size limit beyond `response_buf`. + +The upstream reply lands in `response_buf`; building a truncated reply over it needs a second +buffer. Give `handle` no extra parameter: build the truncated reply in a stack `[512]u8` inside +`handle` and copy it into `response_buf` before returning. 512 is enough — the message is a header, +one question and at most one OPT record, and a question longer than ~270 bytes cannot exist +(name ≤ 255). + +### S5.4 Tests (in-file, fake upstream implementing `transport.Client`) + +Build queries with hand-encoded bytes or `packet.ResponseBuilder`; the fake upstream answers by +copying a fixture and calling `packet.setId`. + +- A query for `example.com` A over UDP returns the fake's bytes unchanged. +- A response (QR set) sent to the handler → `.drop`, `dropped_malformed == 1`. +- An 8-byte query → `.drop`. +- A query with QDCOUNT 0 → FORMERR reply, question absent, ID preserved. +- A query with QDCOUNT 2 → FORMERR. +- A structurally broken question (name pointer loop) → FORMERR, `formerr == 1`. +- Opcode `update` → NOTIMP. +- Fake returns `error.Timeout` → SERVFAIL with the question echoed and RD preserved. +- Fake returns `error.OutOfMemory` → SERVFAIL (not a drop). +- Fake returns `error.Canceled` → `.drop`. +- `udpLimit`: no OPT → 512; OPT 1232 → 1232; OPT 200 → 512; OPT 9000 → 4096. +- Over UDP, a fake reply of 900 bytes with no OPT in the query → TC=1 reply, ancount 0, question + echoed, `truncated == 1`; the same reply over TCP passes through untouched. +- DO bit set in the query's OPT → set in the SERVFAIL reply's OPT. +- Every synthesized reply re-parses with `packet.parse`. + +### S5.5 Acceptance criteria + +- [ ] `zig test src/server/handler.zig` passes. +- [ ] Every bullet in S5.4 exists as a named test. +- [ ] `handle` has no error union in its return type. +- [ ] `zig fmt --check` clean. + +--- + +## Session S6: `src/server/udp_server.zig`, `src/server/tcp_server.zig` + +Listeners. Both take a `*handler.Handler`, both use a bounded pool of in-flight tasks so one slow +upstream cannot stall the listener, and both count every failure. + +### S6.1 udp_server.zig + +```zig +pub const max_datagram = 4096; // EDNS ceiling this server advertises + +pub const Options = struct { + max_in_flight: u16 = 64, +}; + +pub const Stats = struct { + received: std.atomic.Value(u64) = .init(0), + dropped_oversize: std.atomic.Value(u64) = .init(0), // datagram arrived with flags.trunc + dropped_no_slot: std.atomic.Value(u64) = .init(0), + receive_errors: std.atomic.Value(u64) = .init(0), + send_errors: std.atomic.Value(u64) = .init(0), +}; + +pub const UdpServer = struct { + socket: std.Io.net.Socket, + handler: *handler.Handler, + slots: []Slot, // allocated in `bind`, freed in `deinit` + free: std.DynamicBitSetUnmanaged or a u64 free-list — implementer's call, guarded by `mutex` + mutex: std.Io.Mutex, + stats: Stats, + + pub const Slot = struct { + query: [max_datagram]u8, + reply: [max_datagram]u8, + from: std.Io.net.IpAddress, + len: usize, + }; + + pub const BindError = std.Io.net.IpAddress.BindError || error{OutOfMemory}; + + pub fn bind( + gpa: std.mem.Allocator, + io: std.Io, + address: std.Io.net.IpAddress, + h: *handler.Handler, + options: Options, + ) BindError!UdpServer; + + /// The kernel-assigned address; port 0 in `bind` resolves here (needed by tests). + pub fn boundAddress(self: *const UdpServer) std.Io.net.IpAddress; + + /// Receive loop. Returns when the task is canceled or the socket is closed. + pub fn serve(self: *UdpServer, io: std.Io) void; + + pub fn deinit(self: *UdpServer, gpa: std.mem.Allocator, io: std.Io) void; +}; +``` + +Loop: +1. `const msg = self.socket.receive(io, slot_query_buf)` into a claimed slot. `error.Canceled` → + return. Any other error → `receive_errors`, log, continue (a per-datagram error must not kill the + listener). +2. `msg.flags.trunc` → `dropped_oversize`, release the slot, continue. A truncated datagram cannot + be parsed reliably and answering it would be a guess. +3. No free slot → `dropped_no_slot`, continue. Do not queue: an unbounded queue is the cloudflared + failure mode (PLAN §1) in a different costume. +4. `group.concurrent(io, respondOne, .{ self, io, slot_index })` — an `std.Io.Group` owned by + `serve`. `respondOne` calls `self.handler.handle(io, .udp, query, reply)`, sends `.reply` with + `self.socket.send(io, &slot.from, bytes)` (a send failure increments `send_errors` and is logged + at debug, deduplicated by the caller later), then releases the slot. +5. `serve` awaits the group before returning, under cancel protection so in-flight replies are not + abandoned mid-send. + +`deinit` closes the socket and frees the slots. Closing the socket makes a blocked `receive` fail; +the loop treats that as a stop signal. + +### S6.2 tcp_server.zig + +```zig +pub const Options = struct { + max_connections: u16 = 64, + /// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance. + idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, +}; + +pub const Stats = struct { + accepted: std.atomic.Value(u64) = .init(0), + rejected_at_capacity: std.atomic.Value(u64) = .init(0), + accept_errors: std.atomic.Value(u64) = .init(0), + connection_errors: std.atomic.Value(u64) = .init(0), + idle_timeouts: std.atomic.Value(u64) = .init(0), +}; + +pub const TcpServer = struct { + server: std.Io.net.Server, + handler: *handler.Handler, + conns: []Conn, // fixed slots, same bounded-capacity rule as UDP + mutex: std.Io.Mutex, + stats: Stats, + + pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory}; + + pub fn listen(gpa, io, address, h: *handler.Handler, options: Options) ListenError!TcpServer; + pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress; + pub fn serve(self: *TcpServer, io: std.Io) void; + pub fn deinit(self: *TcpServer, gpa: std.mem.Allocator, io: std.Io) void; +}; +``` + +Accept loop: `self.server.accept(io)`; `error.Canceled` or `error.SocketNotListening` → return +(`deinit` shuts the listening socket down, which is the documented way to unblock a pending accept — +API notes). At capacity → close the stream immediately, `rejected_at_capacity`. Otherwise a group +task per connection. + +Per connection (RFC 7766 §6.2.1.1 — a connection may carry several queries, handled serially): +1. Read the 2-byte length prefix. The read is raced against `options.idle_timeout` with + `std.Io.Select`; the sleep arm winning means `idle_timeouts` and a clean close. +2. `len == 0` → close (`connection_errors`). `len > transport.max_message_len` is impossible (u16). +3. Read exactly `len` bytes; a short read → close, `connection_errors`. +4. `handler.handle(io, .tcp, query, reply)`; `.drop` closes the connection (there is nothing to + frame); `.reply` writes the 2-byte prefix plus the bytes and flushes. +5. Loop back to step 1. +6. Close under cancel protection and release the slot. + +Buffers: each `Conn` owns `query: [transport.max_message_len]u8` and +`reply: [transport.max_message_len]u8` plus the stream read/write buffers. That is 128 KiB+ per +connection slot; with the default 64 slots that is ~8 MiB, inside the PLAN §18 budget. If a session +finds a materially smaller layout that keeps the 65535-byte ceiling, take it and say so. + +### S6.3 Tests + +Unit tests that need no socket go in-file (length-prefix framing helper, capacity accounting). +The loopback tests live in `src/server/udp_server_integration_test.zig` and +`src/server/tcp_server_integration_test.zig`, both guarded by +`if (!build_options.integration) return error.SkipZigTest;`: + +- UDP: bind `127.0.0.1:0`, run `serve` in an `Io.Group` task, send a query from a second bound + socket with a fake-upstream handler, assert the reply's ID and question match; then send a 5-byte + datagram and assert no reply arrives within a short budget and `dropped_malformed` moved. +- TCP: listen on `127.0.0.1:0`, connect, send two length-prefixed queries on one connection, read + two length-prefixed replies, assert both match, then close; assert `accepted == 1`. +- TCP idle timeout: connect, send nothing, assert the server closes the connection and + `idle_timeouts == 1` (use a short `idle_timeout` in the test options). +- Both: `deinit` while `serve` is blocked ends `serve` without a hang — the group awaits cleanly. + +### S6.4 Acceptance criteria + +- [ ] `zig test src/server/udp_server.zig` and `.../tcp_server.zig` pass (unit tests). +- [ ] Both integration files compile in every build, skip without `-Dintegration`, and pass with it. +- [ ] No unbounded queue, no unbounded allocation per datagram or per connection. +- [ ] `zig fmt --check` clean. + +--- + +## Session S7: end-to-end integration test + +`src/server/resolver_integration_test.zig`, guarded by +`if (!build_options.integration) return error.SkipZigTest;`. Hermetic: loopback sockets and an +in-process fake upstream, no external network. + +### S7.1 Fake upstream + +In this file: a struct implementing `transport.Client` that parses the incoming query, echoes the +question and appends one A record (`93.184.216.34`, TTL 300) with `packet.ResponseBuilder`, and +counts calls. A second variant returns a configured `transport.PeerFault` on the first N calls. + +### S7.2 The test + +1. `std.Io.Threaded` backend, `io`. +2. Two `Pool` entries: entry 0 = always-fails fake (priority 10), entry 1 = good fake (priority 20). +3. `Handler` over `pool.client()`. +4. `UdpServer` bound to `127.0.0.1:0` and `TcpServer` on `127.0.0.1:0`, both served in one + `Io.Group`. +5. Client side: send an A query for `example.com` over UDP; assert the reply parses, ID matches, + RCODE is NOERROR, ancount is 1, and the A record's rdata is `93.184.216.34`. +6. Send the same query length-prefixed over TCP; assert the same. +7. Assert failover happened: entry 0's `consecutive_failures >= 1` and `backoff_until != null`; + entry 1's `total_successes >= 2`. Read them through `pool.snapshot`. +8. Send a third query and assert entry 0's call counter did not grow — it is in backoff. +9. `deinit` both servers, cancel and await the group, `threaded.deinit()` — the test must not hang. + +### S7.3 Acceptance criteria + +- [ ] `zig build test -Dintegration` runs this test and it passes. +- [ ] It skips (not fails) under plain `zig build test`. +- [ ] It opens no socket outside `127.0.0.1` and reaches no external host. +- [ ] It completes in bounded time — every wait carries a budget; no unbounded blocking read. +- [ ] `zig fmt --check` clean. + +--- + +## Module Layout + +``` +src/upstream/transport.zig S1 Endpoint, error groups, Client, validateResponse +src/upstream/health.zig S1 pure health/backoff state +src/upstream/doh_client.zig S2 RFC 8484 over std.http.Client +src/upstream/doh_client_live_test.zig S2 -Dlive +src/upstream/dot_client.zig S3 RFC 7858 over platform/tls_client.zig +src/upstream/dot_client_live_test.zig S3 -Dlive +src/upstream/pool.zig S4 priority failover, per-attempt deadline, health +src/server/handler.zig S5 query -> upstream -> validated reply +src/server/udp_server.zig S6 UDP/53 listener +src/server/tcp_server.zig S6 TCP/53 listener, length-prefixed +src/server/udp_server_integration_test.zig S6 -Dintegration +src/server/tcp_server_integration_test.zig S6 -Dintegration +src/server/resolver_integration_test.zig S7 -Dintegration, end to end +``` + +## File Ownership + +| Files | Owner | Notes | +|---|---|---| +| `src/upstream/transport.zig`, `src/upstream/health.zig` | S1 | frozen after S1 verifies | +| `src/upstream/doh_client.zig`, `src/upstream/doh_client_live_test.zig` | S2 | | +| `src/upstream/dot_client.zig`, `src/upstream/dot_client_live_test.zig` | S3 | | +| `src/upstream/pool.zig` | S4 | | +| `src/server/handler.zig` | S5 | | +| `src/server/udp_server.zig`, `src/server/tcp_server.zig`, both `*_server_integration_test.zig` | S6 | | +| `src/server/resolver_integration_test.zig` | S7 | | +| `build.zig`, `src/tests.zig` | orchestrator | no session edits these | + +No session touches milestone 1 or milestone 2 files. A needed change there is reported, not made. + +## Acceptance Criteria (Milestone 3 Complete) + +- [ ] `zig build test` exits 0 with every new file wired into `src/tests.zig`. +- [ ] `zig build test -Dintegration` exits 0: the milestone-1 loopback TLS echo, both listener + integration tests, and the end-to-end resolver test all pass. +- [ ] `zig build test -Dintegration -Dlive` exits 0 locally, or the DoH/DoT live failures are + reported as environment findings with the exact error (a live-network failure is not a gate). +- [ ] `zig build cross` still produces two statically linked executables. +- [ ] `transport.group` is exhaustive over `ExchangeError` with no `else` arm. +- [ ] `pool.zig`'s failover, backoff-skip, all-backed-off probe, local-resource short-circuit and + cancellation short-circuit each have a passing named test. +- [ ] `handler.zig`'s FORMERR (QDCOUNT 0 and 2), NOTIMP, SERVFAIL, drop and UDP-truncation paths + each have a passing named test. +- [ ] `zig fmt --check` clean repo-wide; GPG-signed lowercase commits. + +## Anti-Requirements + +- No caching of any kind (Phase 6). The handler forwards every query. +- No filtering, blocklists, rules, or blocked-response synthesis (Phase 5). +- No query logging, no SQLite, no storage, no config file or ZON parsing (Phase 4). Timeouts, + ports and pool entries arrive as parameters or named constants. +- No rate limiting (Phase 6) — `src/server/rate_limiter.zig` is not created here. +- No local DoH or DoT **server** endpoints (Phase 9). Only upstream DoH/DoT clients are in scope; + `platform/tls_server.zig` is untouched. +- No local records, no conditional forwarding, no CNAME uncloaking (Phase 5/7). +- No web UI, no REST API, no `/metrics`. `Pool.snapshot` exists so Phase 8 has something to read; + it is not exposed anywhere yet. +- No signal handling, no `src/server/shutdown.zig`, no `main.zig` wiring. Servers stop through + `deinit` plus group cancellation; process lifetime is Phase 7's problem. +- No DNS name resolution for upstream hosts — DoT endpoints take IP literals, DoH resolution is + `std.http.Client`'s business. +- No HTTP/2, no DoQ, no connection reuse for DoT, no DNSSEC validation (DO bit passes through). +- No 0x20 query-name randomization. Case-insensitive comparison per RFC 4343 is required; generating + mixed-case queries is not. +- No changes to `src/dns/`. If a helper is missing there, report it — do not add transport-aware + code to the pure core. + +## As built + +The implementation matches the spec with these review-driven refinements (three +review rounds; findings went 9 → 3 → 0): + +- `pool.zig`: each `Entry` carries a `busy: std.Io.Mutex` held for the whole + attempt against that entry, so one entry's client (and its buffers/TLS state) + is never used by two tasks at once. Pass one re-checks `available` with a + fresh `now` after acquiring `busy` (the wait can outlive the health it was + admitted under); pass two probes without a re-check by design. Lock order is + always `busy` then `Pool.mutex`; the pool mutex still guards health and + `snapshot` only. +- `health.zig`: staleness rules are symmetric. The newest outcome is the @max + of `last_success_at`/`last_error_at`. A stale success counts into + totals/window and @max-updates `last_success_at`, but does not clear + `consecutive_failures`/`backoff_until`. A stale failure counts into + totals/window and @max-updates `last_error_at`, but only increments + `consecutive_failures`/extends backoff when no newer success exists, and + never overwrites a newer failure's error text. +- `udp_server.zig`: each slot's reply buffer is + `[transport.max_message_len]u8` (not `max_datagram`) so an oversized upstream + reply reaches the handler and becomes a TC=1 reply instead of SERVFAIL + (64 slots ≈ 4.4 MiB, inside the PLAN §18 budget). A `dropped_handler` stat + counts `.drop` outcomes from the handler. +- `tcp_server.zig`: a shutdown flag, set under the slot mutex before the + active-connection scan, closes the accept-vs-deinit race — a stream accepted + during shutdown is closed immediately instead of claimed. +- `handler.zig`: OPT validation walks every record section. OPT in answer or + authority, more than one OPT, a non-root OPT owner, or an unparseable OPT → + FORMERR (RFC 6891); only a genuinely absent OPT takes the no-OPT path. +- `dot_client.zig`: handshake `ReadFailed`/`WriteFailed` unwrap the stream's + stored cause through `transport.mapLocal` first, and certificate-bundle + `OutOfMemory` is a local resource, not a peer fault. +- `transport.zig`: `Endpoint.parse` rejects userinfo/query/fragment delimiters + (`@`, `?`, `#`) in the authority, and `?`/`#` in a DoH path. diff --git a/src/server/handler.zig b/src/server/handler.zig new file mode 100644 index 0000000..3fd221a --- /dev/null +++ b/src/server/handler.zig @@ -0,0 +1,825 @@ +//! The serving handler: raw bytes from a listener in, bytes to send back out. +//! It owns no socket, no clock and no allocator — the listener supplies the +//! buffers and the upstream supplies the answer. +//! +//! `handle` returns no error union. Every failure is either a DNS response the +//! client can act on or a counted drop, because a listener has nothing useful +//! to do with an error value: it cannot retry and it must not die. That is the +//! "every failure mode is visible" rule applied to the hot path — the counters +//! are the failure surface. +//! +//! Phase 7 extends this file with filtering, cache, local records and query +//! logging. None of that is here: this handler forwards every query. + +const std = @import("std"); +const edns = @import("../dns/edns.zig"); +const header = @import("../dns/header.zig"); +const packet = @import("../dns/packet.zig"); +const question = @import("../dns/question.zig"); +const types = @import("../dns/types.zig"); +const transport = @import("../upstream/transport.zig"); + +/// Which listener a query arrived on. Only the UDP size limit depends on it. +pub const Transport = enum { udp, tcp }; + +/// RFC 1035 §4.2.1: 512 bytes is what a client accepts over UDP without EDNS, +/// and RFC 6891 §6.2.3 says a smaller advertised size must not be honoured +/// below that floor. +pub const udp_limit_min: u16 = types.max_udp_payload; + +/// The ceiling nxdns advertises and accepts. Above this, fragmentation and +/// reflection amplification cost more than a TCP retry. +pub const udp_limit_max: u16 = 4096; + +/// A synthesized reply is a header, at most one question and at most one OPT +/// record: 12 + (255 + 4) + 11 = 282 bytes worst case. Every buffer this file +/// builds into is at least `udp_limit_min`, so the builder cannot run out of +/// room and its `Writer.Error` is unreachable. +const max_synthetic_len = types.header_len + types.max_name_len + 4 + 11; + +comptime { + std.debug.assert(max_synthetic_len <= udp_limit_min); +} + +pub const Handler = struct { + /// In production this is `pool.client()`. + upstream: transport.Client, + stats: Stats = .{}, + + pub const Stats = struct { + queries: std.atomic.Value(u64) = .init(0), + dropped_malformed: std.atomic.Value(u64) = .init(0), + formerr: std.atomic.Value(u64) = .init(0), + notimp: std.atomic.Value(u64) = .init(0), + servfail: std.atomic.Value(u64) = .init(0), + truncated: std.atomic.Value(u64) = .init(0), + }; + + pub const Outcome = union(enum) { + /// A prefix of `response_buf`. + reply: []u8, + /// No response is possible or appropriate. + drop, + }; + + /// `response_buf.len` must be >= 512 and <= 65535. + pub fn handle( + self: *Handler, + io: std.Io, + which: Transport, + query: []const u8, + response_buf: []u8, + ) Outcome { + std.debug.assert(response_buf.len >= udp_limit_min); + std.debug.assert(response_buf.len <= transport.max_message_len); + + // Fewer than 12 bytes: there is no ID and no question, so no reply can + // be addressed to this query (PLAN §6.1). + const hdr = header.parse(query) catch { + bump(&self.stats.dropped_malformed); + return .drop; + }; + + // A response on a listener port is either a misdirected reply or a + // reflection attempt. Answering it would make this server the amplifier. + if (hdr.flags.qr) { + bump(&self.stats.dropped_malformed); + return .drop; + } + + const p = packet.parse(query) catch |err| switch (err) { + error.Truncated => { + bump(&self.stats.dropped_malformed); + return .drop; + }, + // The header is intact but a section is not. The question is what + // failed to parse, so nothing is echoed and no OPT is trusted. + else => return synthesize(hdr, null, null, .form_err, &self.stats.formerr, response_buf), + }; + + // RFC 6891 §6.1.1 puts the OPT record in the additional section, and §7 + // answers a violation with FORMERR. `packet.parse` already rejects a + // second OPT wherever it sits; the case it accepts and this walk does + // not is a lone OPT in the answer or authority section, which + // `findOptRecord` cannot see. + if (containsOpt(packet.answers(p)) or containsOpt(packet.authorities(p))) { + const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null; + return synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf); + } + + // RFC 6891 §6.1.1 also gives the OPT record a root owner name. An OPT + // that is present but unusable must not be forwarded as if the query + // carried none: the reply's OPT echo would then disappear with nothing + // to say it ever existed. + const opt: ?edns.OptRecord = if (packet.findOptRecord(p)) |rec| + edns.parseOpt(query, rec) catch { + const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null; + return synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf); + } + else + null; + + if (hdr.flags.opcode != .query) { + return synthesize(hdr, null, opt, .not_imp, &self.stats.notimp, response_buf); + } + + // RFC 9619: exactly one question, in both directions. Any other count + // has no valid interpretation, so there is no question to echo either. + if (hdr.qdcount != 1) { + return synthesize(hdr, null, opt, .form_err, &self.stats.formerr, response_buf); + } + + // `parse` already walked the question section, so a packet with + // qdcount 1 always has a first question. The fallback keeps a + // hand-assembled `Packet` from turning into a crash. + const q = packet.firstQuestion(p) orelse + return synthesize(hdr, null, opt, .form_err, &self.stats.formerr, response_buf); + + const reply = self.upstream.exchange(io, query, response_buf) catch |err| switch (transport.group(err)) { + // The process is shutting down. There is nothing to say, and the + // client is about to lose the socket anyway; the listener's own + // counters record the abandoned datagram. + .cancellation => return .drop, + .peer_fault, .local_resource => return synthesize( + hdr, + q, + opt, + .serv_fail, + &self.stats.servfail, + response_buf, + ), + }; + + bump(&self.stats.queries); + + if (which == .udp and reply.len > udpLimit(p)) { + // The upstream answer occupies `response_buf`, so the replacement + // is built beside it and copied over. + bump(&self.stats.truncated); + var scratch: [udp_limit_min]u8 = undefined; + const message = build(hdr, q, opt, .no_error, true, &scratch); + @memcpy(response_buf[0..message.len], message); + return .{ .reply = response_buf[0..message.len] }; + } + + return .{ .reply = reply }; + } +}; + +/// Counts one synthesized reply and encodes it. The counter is passed in +/// because the counter *is* the record that this failure mode happened. +fn synthesize( + hdr: header.Header, + q: ?question.Question, + opt: ?edns.OptRecord, + rcode: types.Rcode, + counter: *std.atomic.Value(u64), + response_buf: []u8, +) Handler.Outcome { + bump(counter); + return .{ .reply = build(hdr, q, opt, rcode, false, response_buf) }; +} + +/// Whether the section holds a record of TYPE=OPT. A section that will not walk +/// counts as holding one: the caller's only answer to either condition is +/// FORMERR, and a `Packet` from `packet.parse` walks in full, so the fallback is +/// reachable only for a `Packet` assembled by hand around unvalidated bytes. +fn containsOpt(section: packet.RecordIterator) bool { + var it = section; + while (it.next() catch return true) |rec| { + if (rec.rtype == .opt) return true; + } + return false; +} + +/// The query's advertised UDP payload size, clamped to `[512, 4096]`, or 512 +/// when the query carries no usable OPT record. +pub fn udpLimit(query_packet: packet.Packet) u16 { + const rec = packet.findOptRecord(query_packet) orelse return udp_limit_min; + const opt = edns.parseOpt(query_packet.bytes, rec) catch return udp_limit_min; + return std.math.clamp(opt.udp_payload_size, udp_limit_min, udp_limit_max); +} + +/// Encodes one synthesized reply. `ResponseBuilder.init` copies the request's +/// ID, opcode and RD bit and sets QR and RA, so every reply built here binds to +/// its request. `buf` is at least `udp_limit_min`, which `max_synthetic_len` +/// proves is enough, so the writer cannot fail. +fn build( + hdr: header.Header, + q: ?question.Question, + opt: ?edns.OptRecord, + rcode: types.Rcode, + tc: bool, + buf: []u8, +) []u8 { + var b = packet.ResponseBuilder.init(buf, hdr, q) catch unreachable; + b.setRcode(rcode); + b.header.flags.tc = tc; + // An EDNS query gets an EDNS reply, and the DO bit passes through + // untouched — nxdns validates no signatures, so it must not claim the + // client asked for none (PLAN §6.1). + if (opt) |o| b.addOptEcho(o, o.do_bit) catch unreachable; + return b.finish(); +} + +fn bump(counter: *std.atomic.Value(u64)) void { + _ = counter.fetchAdd(1, .monotonic); +} + +const testing = std.testing; + +/// A query for example.com A: id 0x1234, RD set, one question, no OPT. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// The matching response: the question echoed plus one A record. +const response_bytes = + "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + +const opt_len = 11; +const query_with_opt_len = query_bytes.len + opt_len; + +/// `query_bytes` plus an OPT record advertising `payload_size`. +fn queryWithOpt(buf: *[query_with_opt_len]u8, payload_size: u16, do_bit: bool) []const u8 { + @memcpy(buf[0..query_bytes.len], query_bytes); + std.mem.writeInt(u16, buf[10..12], 1, .big); // arcount + + const opt = buf[query_bytes.len..][0..opt_len]; + @memset(opt, 0); + opt[2] = @intFromEnum(types.Type.opt); + std.mem.writeInt(u16, opt[3..5], payload_size, .big); + if (do_bit) opt[7] = 0x80; // the DO bit is bit 15 of the TTL word + return buf; +} + +/// `query_bytes` plus an OPT record whose owner name is `com.` instead of the +/// root, which RFC 6891 §6.1.1 forbids. +const query_with_named_opt = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\x03com\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + +/// `query_bytes` plus an OPT record whose RDATA ends in a three-byte option +/// header. The record itself fits the packet; only the option list is broken. +const query_with_bad_option = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x03\x00\x08\x00"; + +/// A bare OPT record: root owner, TYPE 41, 4096-byte payload, empty RDATA. +const opt_record = "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00"; + +/// `query_bytes` with the OPT record counted into the answer section, which +/// RFC 6891 §6.1.1 forbids. +const query_with_opt_in_answer = + "\x12\x34\x01\x00\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + opt_record; + +/// The same OPT record, counted into the authority section instead. +const query_with_opt_in_authority = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x01\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + opt_record; + +/// Two OPT records in the additional section. RFC 6891 §6.1.1 allows one. +const query_with_two_opts = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x02" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + opt_record ++ opt_record; + +const FakeUpstream = struct { + reply: []const u8 = &.{}, + err: ?transport.ExchangeError = null, + calls: usize = 0, + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = io; + const self: *FakeUpstream = @ptrCast(@alignCast(ptr)); + self.calls += 1; + if (self.err) |e| return e; + if (self.reply.len > response_buf.len) return error.ResponseTooLarge; + @memcpy(response_buf[0..self.reply.len], self.reply); + const bytes = response_buf[0..self.reply.len]; + if (bytes.len >= types.header_len) { + packet.setId(bytes, (header.parse(query) catch unreachable).id); + } + return bytes; + } + + fn client(self: *FakeUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +fn expectReply(outcome: Handler.Outcome) ![]u8 { + return switch (outcome) { + .reply => |bytes| bytes, + .drop => error.TestUnexpectedDrop, + }; +} + +test "a udp query is forwarded and the upstream reply is returned unchanged" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf)); + + try testing.expectEqualSlices(u8, response_bytes, reply); + try testing.expectEqual(@as(usize, 1), fake.calls); + try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic)); + // A query that carries no OPT at all is not an EDNS violation. + try testing.expectEqual(@as(u64, 0), h.stats.formerr.load(.monotonic)); +} + +test "a response arriving on the listener port is dropped" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + try testing.expectEqual(Handler.Outcome.drop, h.handle(undefined, .udp, response_bytes, &buf)); + try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "a query shorter than a header is dropped" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + const short = query_bytes[0..8]; + try testing.expectEqual(Handler.Outcome.drop, h.handle(undefined, .udp, short, &buf)); + try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "a query whose question runs off the end gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + // The header promises a question; the packet ends after 12 bytes plus a + // partial name, which `packet.parse` reports as a section overrun. + var buf: [udp_limit_min]u8 = undefined; + const overrun = query_bytes[0 .. query_bytes.len - 3]; + const outcome = h.handle(undefined, .udp, overrun, &buf); + const reply = try expectReply(outcome); + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); +} + +test "a query with QDCOUNT 0 gets FORMERR with no question" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, no_question, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(@as(u16, 0), p.header.qdcount); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(true, p.header.flags.ra); + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "a query with QDCOUNT 2 gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + const two = + "\x12\x34\x01\x00\x00\x02\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\x07example\x03com\x00\x00\x1c\x00\x01"; + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, two, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0), p.header.qdcount); + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "a structurally broken question gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + // A compression pointer to itself: the name never terminates. + const loop = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\xc0\x0c\x00\x01\x00\x01"; + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, loop, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(@as(u16, 0), p.header.qdcount); + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "an unimplemented opcode gets NOTIMP" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + var update: [query_bytes.len]u8 = query_bytes.*; + std.mem.writeInt(u16, update[2..4], 0x2900, .big); // opcode update, RD set + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, &update, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.not_imp, p.header.flags.rcode); + try testing.expectEqual(types.Opcode.update, p.header.flags.opcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(@as(u16, 0), p.header.qdcount); + try testing.expectEqual(@as(u64, 1), h.stats.notimp.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "a peer fault becomes SERVFAIL with the question echoed" { + var fake: FakeUpstream = .{ .err = error.Timeout }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(true, p.header.flags.rd); + 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); + + const echoed = packet.firstQuestion(p).?; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + try testing.expectEqual(types.Type.a, echoed.qtype); + + try testing.expectEqual(@as(u64, 1), h.stats.servfail.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); +} + +test "a local resource failure becomes SERVFAIL, not a drop" { + var fake: FakeUpstream = .{ .err = error.OutOfMemory }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + try testing.expectEqual(@as(u64, 1), h.stats.servfail.load(.monotonic)); +} + +test "a canceled exchange is dropped" { + var fake: FakeUpstream = .{ .err = error.Canceled }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + try testing.expectEqual(Handler.Outcome.drop, h.handle(undefined, .udp, query_bytes, &buf)); + try testing.expectEqual(@as(u64, 0), h.stats.servfail.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), h.stats.dropped_malformed.load(.monotonic)); +} + +test "udpLimit clamps the advertised payload size" { + try testing.expectEqual(@as(u16, 512), udpLimit(try packet.parse(query_bytes))); + + const cases = [_]struct { advertised: u16, expected: u16 }{ + .{ .advertised = 1232, .expected = 1232 }, + .{ .advertised = 200, .expected = 512 }, + .{ .advertised = 9000, .expected = 4096 }, + .{ .advertised = 512, .expected = 512 }, + .{ .advertised = 4096, .expected = 4096 }, + }; + for (cases) |c| { + var buf: [query_with_opt_len]u8 = undefined; + const q = queryWithOpt(&buf, c.advertised, false); + try testing.expectEqual(c.expected, udpLimit(try packet.parse(q))); + } +} + +/// A valid response for `query_bytes` padded past 512 bytes with A records. +fn oversizeResponse(buf: []u8) []u8 { + const request = packet.parse(query_bytes) catch unreachable; + const q = packet.firstQuestion(request).?; + var b = packet.ResponseBuilder.init(buf, request.header, q) catch unreachable; + var i: usize = 0; + while (i < 32) : (i += 1) { + b.addAnswer(q.name, .a, .in, 300, "\x5d\xb8\xd8\x22") catch unreachable; + } + return b.finish(); +} + +test "an oversize udp reply is replaced by a truncated one" { + var reply_buf: [2048]u8 = undefined; + const oversize = oversizeResponse(&reply_buf); + try testing.expect(oversize.len > udp_limit_min); + + var fake: FakeUpstream = .{ .reply = oversize }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [4096]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(true, p.header.flags.tc); + try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0), p.header.ancount); + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(true, p.header.flags.rd); + + const echoed = packet.firstQuestion(p).?; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + + try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); +} + +test "the same oversize reply passes through untouched over tcp" { + var reply_buf: [2048]u8 = undefined; + const oversize = oversizeResponse(&reply_buf); + + var fake: FakeUpstream = .{ .reply = oversize }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [4096]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .tcp, query_bytes, &buf)); + + try testing.expectEqualSlices(u8, oversize, reply); + try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); +} + +test "a reply within the advertised EDNS limit is not truncated" { + var reply_buf: [2048]u8 = undefined; + const oversize = oversizeResponse(&reply_buf); + + var fake: FakeUpstream = .{ .reply = oversize }; + var h: Handler = .{ .upstream = fake.client() }; + + var query_buf: [query_with_opt_len]u8 = undefined; + const query = queryWithOpt(&query_buf, 4096, false); + + var buf: [4096]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query, &buf)); + try testing.expectEqual(oversize.len, reply.len); + try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic)); +} + +test "the DO bit passes through into a synthesized reply" { + for ([_]bool{ false, true }) |do_bit| { + var fake: FakeUpstream = .{ .err = error.Timeout }; + var h: Handler = .{ .upstream = fake.client() }; + + var query_buf: [query_with_opt_len]u8 = undefined; + const query = queryWithOpt(&query_buf, 1232, do_bit); + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode); + const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?); + try testing.expectEqual(do_bit, opt.do_bit); + try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size); + } +} + +test "the truncated reply echoes the OPT record" { + var reply_buf: [2048]u8 = undefined; + const oversize = oversizeResponse(&reply_buf); + + var fake: FakeUpstream = .{ .reply = oversize }; + var h: Handler = .{ .upstream = fake.client() }; + + // 512 is the advertised size, so the oversize answer still does not fit. + var query_buf: [query_with_opt_len]u8 = undefined; + const query = queryWithOpt(&query_buf, 512, true); + + var buf: [4096]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(true, p.header.flags.tc); + const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?); + try testing.expectEqual(true, opt.do_bit); + try testing.expectEqual(@as(u16, 512), opt.udp_payload_size); + try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic)); +} + +test "an OPT record with a non-root owner name gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_with_named_opt, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(true, p.header.flags.ra); + + // The question parsed, so it is echoed; the OPT did not, so none is. + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + const echoed = packet.firstQuestion(p).?; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + try testing.expect(packet.findOptRecord(p) == null); + + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "an OPT record with a malformed option list gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + // The packet as a whole is well-formed: only `parseOpt` rejects it. + const p_query = try packet.parse(query_with_bad_option); + try testing.expectError( + error.BadOption, + edns.parseOpt(query_with_bad_option, packet.findOptRecord(p_query).?), + ); + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_with_bad_option, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expect(packet.findOptRecord(p) == null); + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "an OPT record whose rdata runs off the end gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + // RDLENGTH claims three bytes and one follows, which `packet.parse` reports + // as a section overrun before the OPT is ever read. + const truncated = query_with_bad_option[0 .. query_with_bad_option.len - 2]; + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, truncated, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "an OPT record in the answer section gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + // The message walks: only its placement of the OPT is illegal, so the + // handler's own section walk is what has to reject it. + const p_query = try packet.parse(query_with_opt_in_answer); + try testing.expect(packet.findOptRecord(p_query) == null); + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_with_opt_in_answer, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + + // The question parsed, so it is echoed; the OPT is not echoed anywhere. + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + const echoed = packet.firstQuestion(p).?; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + try testing.expect(packet.findOptRecord(p) == null); + try testing.expectEqual(@as(u16, 0), p.header.arcount); + + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "an OPT record in the authority section gets FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + const p_query = try packet.parse(query_with_opt_in_authority); + try testing.expect(packet.findOptRecord(p_query) == null); + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_with_opt_in_authority, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + try testing.expect(packet.findOptRecord(p) == null); + + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "two OPT records in the additional section get FORMERR" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + // Here the rejection comes from `packet.parse`, one section walk earlier. + try testing.expectError(error.MultipleOptRecords, packet.parse(query_with_two_opts)); + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query_with_two_opts, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expect(packet.findOptRecord(p) == null); + + try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic)); + try testing.expectEqual(@as(usize, 0), fake.calls); +} + +test "a query with a valid OPT record is still forwarded" { + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: Handler = .{ .upstream = fake.client() }; + + var query_buf: [query_with_opt_len]u8 = undefined; + const query = queryWithOpt(&query_buf, 1232, true); + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, query, &buf)); + + try testing.expectEqualSlices(u8, response_bytes, reply); + try testing.expectEqual(@as(usize, 1), fake.calls); + try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), h.stats.formerr.load(.monotonic)); + + // The same query, answered from the SERVFAIL path, still echoes the OPT. + var fail: FakeUpstream = .{ .err = error.Timeout }; + var h2: Handler = .{ .upstream = fail.client() }; + const synthesized = try expectReply(h2.handle(undefined, .udp, query, &buf)); + const p = try packet.parse(synthesized); + const opt = try edns.parseOpt(synthesized, packet.findOptRecord(p).?); + try testing.expectEqual(true, opt.do_bit); + try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size); + try testing.expectEqual(@as(u64, 0), h2.stats.formerr.load(.monotonic)); +} + +test "every synthesized reply re-parses and binds to its request" { + const Case = struct { + query: []const u8, + err: ?transport.ExchangeError, + rcode: types.Rcode, + }; + const broken_question = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\xc0\x0c\x00\x01\x00\x01"; + const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + const update = "\x12\x34\x29\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + + const cases = [_]Case{ + .{ .query = broken_question, .err = null, .rcode = .form_err }, + .{ .query = no_question, .err = null, .rcode = .form_err }, + .{ .query = query_with_named_opt, .err = null, .rcode = .form_err }, + .{ .query = query_with_bad_option, .err = null, .rcode = .form_err }, + .{ .query = query_with_opt_in_answer, .err = null, .rcode = .form_err }, + .{ .query = query_with_opt_in_authority, .err = null, .rcode = .form_err }, + .{ .query = query_with_two_opts, .err = null, .rcode = .form_err }, + .{ .query = update, .err = null, .rcode = .not_imp }, + .{ .query = query_bytes, .err = error.BadResponse, .rcode = .serv_fail }, + .{ .query = query_bytes, .err = error.Unexpected, .rcode = .serv_fail }, + }; + + for (cases) |c| { + var fake: FakeUpstream = .{ .reply = response_bytes, .err = c.err }; + var h: Handler = .{ .upstream = fake.client() }; + + var buf: [udp_limit_min]u8 = undefined; + const reply = try expectReply(h.handle(undefined, .udp, c.query, &buf)); + + const p = try packet.parse(reply); + try testing.expectEqual(c.rcode, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(true, p.header.flags.ra); + try testing.expectEqual(@as(u16, 0), p.header.ancount); + try testing.expectEqual(@as(u16, 0), p.header.nscount); + } +} diff --git a/src/server/resolver_integration_test.zig b/src/server/resolver_integration_test.zig new file mode 100644 index 0000000..4dbadda --- /dev/null +++ b/src/server/resolver_integration_test.zig @@ -0,0 +1,289 @@ +//! End-to-end test for the whole resolver path: a client socket on the +//! loopback, the real UDP and TCP listeners, the real handler, the real pool, +//! and two in-process fake upstreams. +//! +//! This lives in its own file because it needs `@import("build_options")`, which +//! only exists when the compilation is driven by build.zig. The body is compiled +//! by every `zig build test` run, so it cannot rot, and skips at run time unless +//! `-Dintegration` is passed. +//! +//! Hermetic: every socket is on 127.0.0.1 and the upstreams are structs, so +//! nothing leaves the machine. No stream read in 0.16.0 takes a timeout, so the +//! TCP client side runs as one task raced against a budget and nothing can hang. + +const std = @import("std"); +const build_options = @import("build_options"); +const net = std.Io.net; + +const handler = @import("handler.zig"); +const tcp_server = @import("tcp_server.zig"); +const udp_server = @import("udp_server.zig"); +const packet = @import("../dns/packet.zig"); +const record = @import("../dns/record.zig"); +const types = @import("../dns/types.zig"); +const health = @import("../upstream/health.zig"); +const pool = @import("../upstream/pool.zig"); +const transport = @import("../upstream/transport.zig"); + +const testing = std.testing; + +/// Long enough that a loopback round trip cannot lose to scheduling, short +/// enough that a broken server fails the run instead of hanging it. +const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } }; +const budget_duration: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake }; + +/// A query for example.com A: RD set, one question, no OPT. Each step of the +/// test rewrites the ID so a reply can only match the query it belongs to. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +const udp_id: u16 = 0x1234; +const tcp_id: u16 = 0x5678; +const backoff_id: u16 = 0x9abc; + +/// The address every fake upstream answers with, and its TTL. +const answer_rdata = [4]u8{ 93, 184, 216, 34 }; +const answer_ttl: u32 = 300; + +/// One failure is enough to open a backoff window, so the third query has a +/// single deterministic outcome. The window outlives the test many times over. +const test_cfg: health.Config = .{ + .failure_threshold = 1, + .base_backoff_ms = 60_000, + .max_backoff_ms = 60_000, +}; + +/// Nothing in this test is slow, so this budget only exists to stop a wedged +/// attempt from hanging the run. +const attempt_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }; + +fn queryWithId(buf: *[query_bytes.len]u8, id: u16) []const u8 { + buf.* = query_bytes.*; + packet.setId(buf, id); + return buf; +} + +/// Echoes the question and appends one A record. This is the smallest thing a +/// real upstream could return that the handler forwards unchanged, so the +/// assertions below check bytes that travelled the whole path. +fn answerQuery(query: []const u8, response_buf: []u8) transport.ExchangeError![]u8 { + const request = packet.parse(query) catch return error.BadResponse; + const q = packet.firstQuestion(request) orelse return error.BadResponse; + + var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch + return error.ResponseTooLarge; + b.addAnswer(q.name, .a, .in, answer_ttl, &answer_rdata) catch + return error.ResponseTooLarge; + return b.finish(); +} + +/// The healthy upstream. `calls` is atomic because the listener tasks run on +/// other threads than the one asserting. +const GoodUpstream = struct { + calls: std.atomic.Value(u64) = .init(0), + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = io; + const self: *GoodUpstream = @ptrCast(@alignCast(ptr)); + _ = self.calls.fetchAdd(1, .monotonic); + return answerQuery(query, response_buf); + } + + fn client(self: *GoodUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +/// Returns `fault` on the first `fail_first` calls and answers after that. +/// `fail_first` is `maxInt` for an upstream that never recovers. +const FaultyUpstream = struct { + fault: transport.PeerFault, + fail_first: u64, + calls: std.atomic.Value(u64) = .init(0), + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = io; + const self: *FaultyUpstream = @ptrCast(@alignCast(ptr)); + const seen = self.calls.fetchAdd(1, .monotonic); + if (seen < self.fail_first) return self.fault; + return answerQuery(query, response_buf); + } + + fn client(self: *FaultyUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +fn testEntry(url: []const u8, upstream_client: transport.Client, priority: i32) pool.Entry { + return .{ + .endpoint = transport.Endpoint.parse(url) catch unreachable, + .client = upstream_client, + .priority = priority, + .enabled = true, + .health = .init, + }; +} + +const Outcome = union(enum) { + work: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +/// Runs the client side under a budget so a server that never answers fails the +/// test instead of hanging the run. +fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void { + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + try race.concurrent(.work, f, args); + try race.concurrent(.expiry, expire, .{ io, budget_duration }); + + switch (try race.await()) { + .work => |result| return result, + .expiry => |result| { + try result; + return error.TestTimedOut; + }, + } +} + +/// Everything the client can check about one answer: it belongs to the query it +/// was sent for, it succeeded, and it carries the fake upstream's A record. +fn expectAnswer(reply: []const u8, id: u16) !void { + const p = try packet.parse(reply); + try testing.expectEqual(id, p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + try testing.expectEqual(@as(u16, 1), p.header.ancount); + + const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + try testing.expectEqual(types.Type.a, echoed.qtype); + + var it = packet.answers(p); + const rec = (try it.next()) orelse return error.TestMissingAnswer; + try testing.expectEqual(types.Type.a, rec.rtype); + try testing.expectEqual(answer_ttl, rec.ttl); + try testing.expectEqual(answer_rdata, try record.rdataA(reply, rec)); +} + +/// One length-prefixed query and its answer on a fresh connection +/// (RFC 1035 §4.2.2). +fn tcpQuery(io: std.Io, address: net.IpAddress, id: u16) anyerror!void { + var stream = try address.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var read_buf: [1024]u8 = undefined; + var write_buf: [1024]u8 = undefined; + var reader = stream.reader(io, &read_buf); + var writer = stream.writer(io, &write_buf); + + var query_buf: [query_bytes.len]u8 = undefined; + const query = queryWithId(&query_buf, id); + + try writer.interface.writeAll(&tcp_server.framePrefix(@intCast(query.len))); + try writer.interface.writeAll(query); + try writer.interface.flush(); + + const len = tcp_server.parsePrefix((try reader.interface.takeArray(tcp_server.prefix_len)).*); + try expectAnswer(try reader.interface.take(len), id); +} + +test "the whole resolver answers over udp and tcp and fails over to a healthy upstream" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var bad: FaultyUpstream = .{ + .fault = error.ConnectFailed, + .fail_first = std.math.maxInt(u64), + }; + var good: GoodUpstream = .{}; + var entries = [_]pool.Entry{ + 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 h: handler.Handler = .{ .upstream = upstreams.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 }); + var tcp = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 }); + const udp_address = udp.boundAddress(); + const tcp_address = tcp.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, udp_server.UdpServer.serve, .{ &udp, io }); + try group.concurrent(io, tcp_server.TcpServer.serve, .{ &tcp, io }); + + const client_address: net.IpAddress = try .parse("127.0.0.1", 0); + var client = try client_address.bind(io, .{ .mode = .dgram }); + defer client.close(io); + + { + var query_buf: [query_bytes.len]u8 = undefined; + const query = queryWithId(&query_buf, udp_id); + try client.send(io, &udp_address, query); + + var buf: [udp_server.max_datagram]u8 = undefined; + const msg = try client.receiveTimeout(io, &buf, budget); + try expectAnswer(msg.data, udp_id); + } + + try bounded(io, tcpQuery, .{ io, tcp_address, tcp_id }); + + try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), h.stats.servfail.load(.monotonic)); + + var snapshots: [2]pool.Snapshot = undefined; + try testing.expectEqual(@as(usize, 2), try upstreams.snapshot(io, &snapshots)); + + try testing.expectEqualStrings("https://bad.example/dns-query", snapshots[0].url); + try testing.expect(snapshots[0].consecutive_failures >= 1); + try testing.expect(snapshots[0].backoff_until != null); + try testing.expect(!snapshots[0].available); + + try testing.expectEqualStrings("tls://good.example", snapshots[1].url); + try testing.expect(snapshots[1].total_successes >= 2); + + // The failing entry is in backoff, so the third query must reach the + // healthy entry without touching it. + const bad_calls = bad.calls.load(.monotonic); + { + var query_buf: [query_bytes.len]u8 = undefined; + const query = queryWithId(&query_buf, backoff_id); + try client.send(io, &udp_address, query); + + var buf: [udp_server.max_datagram]u8 = undefined; + const msg = try client.receiveTimeout(io, &buf, budget); + try expectAnswer(msg.data, backoff_id); + } + try testing.expectEqual(bad_calls, bad.calls.load(.monotonic)); + try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic)); + + udp.deinit(gpa, io); + tcp.deinit(gpa, io); + group.cancel(io); +} diff --git a/src/server/tcp_server.zig b/src/server/tcp_server.zig new file mode 100644 index 0000000..be57e95 --- /dev/null +++ b/src/server/tcp_server.zig @@ -0,0 +1,501 @@ +//! The TCP/53 listener. +//! +//! RFC 1035 §4.2.2 frames every message with a 2-byte big-endian length, and +//! RFC 7766 §6.2.1.1 lets one connection carry several queries. Both are +//! implemented here: a connection is answered serially until the client closes +//! it or the idle budget runs out. +//! +//! Connection slots are fixed and pre-allocated. Over capacity the listener +//! closes the new stream immediately and counts it; it never queues, and it +//! never allocates per connection. +//! +//! No stream read or write in 0.16.0 accepts a timeout, so every per-connection +//! operation is raced against `Options.idle_timeout` through `std.Io.Select` and +//! the loser is canceled. + +const std = @import("std"); +const handler = @import("handler.zig"); +const transport = @import("../upstream/transport.zig"); + +const log = std.log.scoped(.tcp_server); + +/// RFC 1035 §4.2.2: the message length prefix is two bytes, big-endian. +pub const prefix_len = 2; + +/// The stream buffers only stage the framing bytes. A message longer than this +/// is read straight into `Conn.query` and written straight from `Conn.reply`, +/// so making them larger would buy nothing. +const stream_buffer_len = 1024; + +/// How long the accept loop waits after an unexpected accept failure, so a +/// persistent one cannot turn the loop into a spin. +const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; + +pub const Options = struct { + max_connections: u16 = 64, + /// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance. + idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, +}; + +pub const Stats = struct { + accepted: std.atomic.Value(u64) = .init(0), + rejected_at_capacity: std.atomic.Value(u64) = .init(0), + rejected_at_shutdown: std.atomic.Value(u64) = .init(0), + accept_errors: std.atomic.Value(u64) = .init(0), + connection_errors: std.atomic.Value(u64) = .init(0), + idle_timeouts: std.atomic.Value(u64) = .init(0), +}; + +/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes +/// `.closing`, and the two meet at `stopped` so no task touches a connection +/// slot after it is freed. +const State = enum(u32) { idle, serving, closing }; + +/// `.closing` exists so `deinit` never shuts down a descriptor that its own +/// task is about to close: the transition to `.closing` happens under the mutex +/// before the close, and `deinit` only touches `.active` slots. +const ConnState = enum { free, active, closing }; + +/// What the accept loop does with a stream it has just accepted. +const Claim = union(enum) { + /// The stream owns `conns[index]`. + slot: usize, + /// Every slot is taken. The stream is closed and the loop continues. + at_capacity, + /// `deinit` has started. The stream is closed and the loop returns. + shutting_down, +}; + +pub const TcpServer = struct { + server: std.Io.net.Server, + handler: *handler.Handler, + conns: []Conn, + mutex: std.Io.Mutex, + /// Guarded by `mutex`. `deinit` sets it in the same critical section that + /// shuts the active connections down, so a stream that arrives after that + /// scan can never claim a slot the scan will not visit again. + shutdown_begun: bool, + options: Options, + stats: Stats, + state: std.atomic.Value(State), + stopped: std.Io.Event, + + /// One slot is ~131 KiB, so the default 64 connections cost ~8.4 MiB, which + /// is inside the PLAN §18 budget. The two message buffers cannot be shared + /// or shrunk: the handler holds the query while the reply is built, and + /// both ceilings are the 65535 bytes the length prefix can express. + pub const Conn = struct { + query: [transport.max_message_len]u8, + reply: [transport.max_message_len]u8, + read_buf: [stream_buffer_len]u8, + write_buf: [stream_buffer_len]u8, + stream: std.Io.net.Stream, + /// Guarded by `TcpServer.mutex`. + state: ConnState, + }; + + pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory}; + + pub fn listen( + gpa: std.mem.Allocator, + io: std.Io, + address: std.Io.net.IpAddress, + h: *handler.Handler, + options: Options, + ) ListenError!TcpServer { + std.debug.assert(options.max_connections > 0); + + const conns = try gpa.alloc(Conn, options.max_connections); + errdefer gpa.free(conns); + for (conns) |*conn| conn.state = .free; + + const local = address; + const server = try local.listen(io, .{ .reuse_address = true }); + + return .{ + .server = server, + .handler = h, + .conns = conns, + .mutex = .init, + .shutdown_begun = false, + .options = options, + .stats = .{}, + .state = .init(.idle), + .stopped = .unset, + }; + } + + /// The kernel-assigned address. A port of 0 in `listen` resolves here. + pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress { + return self.server.socket.address; + } + + /// Accept loop. Returns when the task is canceled or `deinit` stops it. + pub fn serve(self: *TcpServer, io: std.Io) void { + if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; + + var group: std.Io.Group = .init; + self.acceptLoop(io, &group); + + // A reply that is half written is worse than no reply, so the live + // connections are awaited even when this task is being canceled. + const prev = io.swapCancelProtection(.blocked); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + _ = io.swapCancelProtection(prev); + + self.stopped.set(io); + } + + pub fn deinit(self: *TcpServer, gpa: std.mem.Allocator, io: std.Io) void { + const was_serving = self.state.swap(.closing, .acq_rel) == .serving; + + // Shutting the listening socket down is the documented way to unblock a + // pending `accept`: it fails with `error.SocketNotListening`. + const listener: std.Io.net.Stream = .{ .socket = self.server.socket }; + listener.shutdown(io, .both) catch |err| { + log.debug("tcp listener shutdown failed: {t}", .{err}); + }; + + // A live connection is blocked in a read that only the idle budget + // would end, which is seconds away. Shutting each one down bounds this, + // and the same critical section closes the door on new connections. + self.beginShutdown(io); + + if (was_serving) self.stopped.waitUncancelable(io); + + self.server.deinit(io); + gpa.free(self.conns); + self.* = undefined; + } + + fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) void { + while (self.state.load(.acquire) == .serving) { + const stream = self.server.accept(io) catch |err| switch (err) { + error.Canceled, error.SocketNotListening => return, + else => { + bump(&self.stats.accept_errors); + log.debug("tcp accept failed: {t}", .{err}); + retry_delay.sleep(io) catch return; + continue; + }, + }; + + const index = switch (self.claim(io, stream)) { + .slot => |index| index, + // Refusing now is honest; a queue would only hide the overload. + .at_capacity => { + bump(&self.stats.rejected_at_capacity); + stream.close(io); + continue; + }, + // `deinit` will not see this stream in any slot, so serving it + // would hold `deinit` for the whole idle budget. + .shutting_down => { + bump(&self.stats.rejected_at_shutdown); + stream.close(io); + return; + }, + }; + + group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + bump(&self.stats.rejected_at_capacity); + self.finish(io, index); + continue; + }, + }; + + bump(&self.stats.accepted); + } + } + + fn serveConn(self: *TcpServer, io: std.Io, index: usize) void { + defer self.finish(io, index); + + const conn = &self.conns[index]; + var reader = conn.stream.reader(io, &conn.read_buf); + var writer = conn.stream.writer(io, &conn.write_buf); + const budget = self.options.idle_timeout; + + while (true) { + var prefix: [prefix_len]u8 = undefined; + var got: usize = 0; + switch (race(io, budget, readPrefix, .{ &reader.interface, &prefix, &got })) { + .ok => {}, + .timed_out => { + bump(&self.stats.idle_timeouts); + return; + }, + .canceled => return, + .failed => { + bump(&self.stats.connection_errors); + return; + }, + } + + // A client that closes between messages has finished asking, which + // is the normal end of a connection, not a failure. + if (got == 0) return; + if (got != prefix_len) { + bump(&self.stats.connection_errors); + return; + } + + // RFC 1035 §4.2.2 gives no meaning to a zero-length message, and + // the prefix is a u16 so it can never exceed `max_message_len`. + const len = parsePrefix(prefix); + if (len == 0) { + bump(&self.stats.connection_errors); + return; + } + + switch (race(io, budget, readBody, .{ &reader.interface, conn.query[0..len] })) { + .ok => {}, + .canceled => return, + // A half-sent message is a broken peer, not an idle one. + .timed_out, .failed => { + bump(&self.stats.connection_errors); + return; + }, + } + + const bytes = switch (self.handler.handle(io, .tcp, conn.query[0..len], &conn.reply)) { + // There is no framing for "no answer", so the connection ends. + .drop => return, + .reply => |b| b, + }; + + const out = framePrefix(@intCast(bytes.len)); + switch (race(io, budget, writeReply, .{ &writer.interface, &out, bytes })) { + .ok => {}, + .canceled => return, + .timed_out, .failed => { + bump(&self.stats.connection_errors); + return; + }, + } + } + } + + fn claim(self: *TcpServer, io: std.Io, stream: std.Io.net.Stream) Claim { + // Uncancelable: this section takes no Io and never blocks on a peer, so + // it cannot deadlock, and losing the lock mid-update would leak a slot. + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const outcome = decideClaim(self.conns, self.shutdown_begun); + switch (outcome) { + .slot => |index| { + self.conns[index].stream = stream; + self.conns[index].state = .active; + }, + .at_capacity, .shutting_down => {}, + } + return outcome; + } + + fn finish(self: *TcpServer, io: std.Io, index: usize) void { + const conn = &self.conns[index]; + + self.mutex.lockUncancelable(io); + conn.state = .closing; + self.mutex.unlock(io); + + // The socket is released even when this task is being torn down: the + // next cancelable call would otherwise skip the close. + const prev = io.swapCancelProtection(.blocked); + conn.stream.close(io); + _ = io.swapCancelProtection(prev); + + self.mutex.lockUncancelable(io); + conn.state = .free; + self.mutex.unlock(io); + } + + /// Closes the door on new connections and unblocks the live ones. Both + /// happen under one hold of the mutex: a `claim` that runs before this + /// leaves an `.active` slot the loop below shuts down, and a `claim` that + /// runs after it reads `shutdown_begun` and takes no slot at all. + fn beginShutdown(self: *TcpServer, io: std.Io) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + self.shutdown_begun = true; + + for (self.conns) |*conn| { + if (conn.state != .active) continue; + conn.stream.shutdown(io, .both) catch |err| { + log.debug("tcp connection shutdown failed: {t}", .{err}); + }; + } + } +}; + +/// RFC 1035 §4.2.2: the message length as a 2-byte big-endian integer. +pub fn framePrefix(len: u16) [prefix_len]u8 { + var out: [prefix_len]u8 = undefined; + std.mem.writeInt(u16, &out, len, .big); + return out; +} + +pub fn parsePrefix(bytes: [prefix_len]u8) u16 { + return std.mem.readInt(u16, &bytes, .big); +} + +/// The capacity rule, without the mutex, so it is testable without a backend. +fn firstFree(conns: []const TcpServer.Conn) ?usize { + for (conns, 0..) |*conn, index| { + if (conn.state == .free) return index; + } + return null; +} + +/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free +/// slot is still refused once `deinit` has passed the connections. +fn decideClaim(conns: []const TcpServer.Conn, shutdown_begun: bool) Claim { + if (shutdown_begun) return .shutting_down; + const index = firstFree(conns) orelse return .at_capacity; + return .{ .slot = index }; +} + +const Outcome = union(enum) { + op: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +const Result = enum { ok, timed_out, failed, canceled }; + +/// Runs one connection operation against the idle budget and cancels the loser. +fn race( + io: std.Io, + budget: std.Io.Clock.Duration, + comptime f: anytype, + args: std.meta.ArgsTuple(@TypeOf(f)), +) Result { + var outcomes: [2]Outcome = undefined; + var select: std.Io.Select(Outcome) = .init(io, &outcomes); + defer select.cancelDiscard(); + + select.concurrent(.op, f, args) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return .failed, + }; + + return switch (select.await() catch return .canceled) { + .op => |result| if (result) |_| .ok else |err| switch (err) { + error.Canceled => .canceled, + else => .failed, + }, + // A canceled sleep means this task is being torn down, not that the + // client went idle. + .expiry => |result| if (result) |_| .timed_out else |_| .canceled, + }; +} + +fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void { + return budget.sleep(io); +} + +/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client +/// that closed cleanly between messages, and only a partial prefix is an error. +fn readPrefix(reader: *std.Io.Reader, buf: *[prefix_len]u8, out_len: *usize) anyerror!void { + out_len.* = try reader.readSliceShort(buf); +} + +fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void { + return reader.readSliceAll(buf); +} + +fn writeReply(writer: *std.Io.Writer, prefix: *const [prefix_len]u8, bytes: []const u8) anyerror!void { + try writer.writeAll(prefix); + try writer.writeAll(bytes); + try writer.flush(); +} + +fn bump(counter: *std.atomic.Value(u64)) void { + _ = counter.fetchAdd(1, .monotonic); +} + +const testing = std.testing; + +test "the length prefix is big-endian and round-trips" { + try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0)); + try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256)); + try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535)); + + for ([_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 }) |len| { + try testing.expectEqual(len, parsePrefix(framePrefix(len))); + } +} + +test "the prefix ceiling is the message ceiling" { + try testing.expectEqual(@as(u16, transport.max_message_len), parsePrefix(.{ 0xff, 0xff })); +} + +fn testConns(count: usize) ![]TcpServer.Conn { + const conns = try testing.allocator.alloc(TcpServer.Conn, count); + for (conns) |*conn| conn.state = .free; + return conns; +} + +test "the connection pool hands out every slot once" { + const conns = try testConns(3); + defer testing.allocator.free(conns); + + for (0..conns.len) |expected| { + const index = firstFree(conns) orelse return error.TestUnexpectedResult; + try testing.expectEqual(expected, index); + conns[index].state = .active; + } +} + +test "a full connection pool refuses instead of growing" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + for (conns) |*conn| conn.state = .active; + try testing.expectEqual(@as(?usize, null), firstFree(conns)); +} + +test "a closing slot is not reused until it is free" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + conns[0].state = .active; + conns[1].state = .closing; + try testing.expectEqual(@as(?usize, null), firstFree(conns)); + + conns[1].state = .free; + try testing.expectEqual(@as(?usize, 1), firstFree(conns)); +} + +test "a claim takes the first free slot before shutdown" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + conns[0].state = .active; + try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot); +} + +test "a claim after shutdown is refused even with a free slot" { + const conns = try testConns(2); + defer testing.allocator.free(conns); + + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); + + // The refusal must not consume the slot: `deinit` frees it, nothing else. + try testing.expectEqual(@as(?usize, 0), firstFree(conns)); +} + +test "shutdown outranks capacity" { + const conns = try testConns(1); + defer testing.allocator.free(conns); + + conns[0].state = .active; + try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false))); + try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true))); +} diff --git a/src/server/tcp_server_integration_test.zig b/src/server/tcp_server_integration_test.zig new file mode 100644 index 0000000..edef8ee --- /dev/null +++ b/src/server/tcp_server_integration_test.zig @@ -0,0 +1,281 @@ +//! Loopback tests for `tcp_server.zig`. +//! +//! This lives in its own file because it needs `@import("build_options")`, which +//! only exists when the compilation is driven by build.zig. The body is compiled +//! by every `zig build test` run, so it cannot rot, and skips at run time unless +//! `-Dintegration` is passed. +//! +//! Hermetic: one listener and one client on 127.0.0.1 and an in-process fake +//! upstream. No stream read in 0.16.0 takes a timeout, so the whole client side +//! of each test runs as one task raced against a budget and nothing can hang. + +const std = @import("std"); +const build_options = @import("build_options"); +const net = std.Io.net; + +const handler = @import("handler.zig"); +const tcp_server = @import("tcp_server.zig"); +const header = @import("../dns/header.zig"); +const packet = @import("../dns/packet.zig"); +const types = @import("../dns/types.zig"); +const transport = @import("../upstream/transport.zig"); + +const testing = std.testing; + +const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake }; + +/// Short enough to keep the idle-timeout test quick, long enough that a +/// loopback connect cannot lose to scheduling and time out on its own. +const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake }; + +/// A query for example.com A: id 0x1234, RD set, one question, no OPT. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// The matching response: the question echoed plus one A record. +const response_bytes = + "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + +/// Answers from a fixture and rewrites the ID, which is all the server needs +/// from an upstream. The real clients are exercised by their own tests. +const FakeUpstream = struct { + reply: []const u8, + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = io; + const self: *FakeUpstream = @ptrCast(@alignCast(ptr)); + if (self.reply.len > response_buf.len) return error.ResponseTooLarge; + @memcpy(response_buf[0..self.reply.len], self.reply); + const bytes = response_buf[0..self.reply.len]; + packet.setId(bytes, (header.parse(query) catch unreachable).id); + return bytes; + } + + fn client(self: *FakeUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +const Outcome = union(enum) { + work: anyerror!void, + expiry: std.Io.Cancelable!void, +}; + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +/// Runs the client side under a budget so a server that never answers fails the +/// test instead of hanging the run. +fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void { + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + try race.concurrent(.work, f, args); + try race.concurrent(.expiry, expire, .{ io, budget }); + + switch (try race.await()) { + .work => |result| return result, + .expiry => |result| { + try result; + return error.TestTimedOut; + }, + } +} + +fn expectAnswersQuery(reply: []const u8) !void { + const p = try packet.parse(reply); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 1), p.header.ancount); + + const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + try testing.expectEqual(types.Type.a, echoed.qtype); +} + +/// RFC 7766 §6.2.1.1: two queries on one connection, answered in order. +fn twoQueriesOnOneConnection(io: std.Io, address: net.IpAddress) anyerror!void { + const remote = address; + var stream = try remote.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var read_buf: [1024]u8 = undefined; + var write_buf: [1024]u8 = undefined; + var reader = stream.reader(io, &read_buf); + var writer = stream.writer(io, &write_buf); + + for (0..2) |_| { + try writer.interface.writeAll(&tcp_server.framePrefix(@intCast(query_bytes.len))); + try writer.interface.writeAll(query_bytes); + try writer.interface.flush(); + + const len = tcp_server.parsePrefix((try reader.interface.takeArray(tcp_server.prefix_len)).*); + try expectAnswersQuery(try reader.interface.take(len)); + } +} + +/// The server must close an idle connection on its own, which the client sees +/// as end of stream. +fn waitForServerClose(io: std.Io, address: net.IpAddress) anyerror!void { + const remote = address; + var stream = try remote.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var read_buf: [64]u8 = undefined; + var reader = stream.reader(io, &read_buf); + + var sink: [64]u8 = undefined; + const n = try reader.interface.readSliceShort(&sink); + if (n != 0) return error.TestUnexpectedBytes; +} + +test "two length-prefixed queries share one connection" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io }); + + try bounded(io, twoQueriesOnOneConnection, .{ io, server_address }); + + try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), server.stats.rejected_at_capacity.load(.monotonic)); + try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic)); + + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "an idle connection is closed and counted" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ + .max_connections = 2, + .idle_timeout = short_idle, + }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io }); + + try bounded(io, waitForServerClose, .{ io, server_address }); + + try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), server.stats.idle_timeouts.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), server.stats.connection_errors.load(.monotonic)); + + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "a zero-length message is a connection error" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ + .max_connections = 2, + .idle_timeout = short_idle, + }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io }); + + try bounded(io, sendZeroLength, .{ io, server_address }); + + try testing.expectEqual(@as(u64, 1), server.stats.connection_errors.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), server.stats.idle_timeouts.load(.monotonic)); + + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +/// A prefix of 0 announces a message RFC 1035 §4.2.2 gives no meaning to, so +/// the server closes rather than waiting for bytes that will never mean +/// anything. +fn sendZeroLength(io: std.Io, address: net.IpAddress) anyerror!void { + const remote = address; + var stream = try remote.connect(io, .{ .mode = .stream }); + defer stream.close(io); + + var write_buf: [64]u8 = undefined; + var writer = stream.writer(io, &write_buf); + try writer.interface.writeAll(&tcp_server.framePrefix(0)); + try writer.interface.flush(); + + var read_buf: [64]u8 = undefined; + var reader = stream.reader(io, &read_buf); + var sink: [64]u8 = undefined; + const n = try reader.interface.readSliceShort(&sink); + if (n != 0) return error.TestUnexpectedBytes; +} + +test "deinit ends a serve loop that is blocked on accept" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 }); + + var group: std.Io.Group = .init; + try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io }); + + // No client ever connects, so `serve` is inside an accept when this runs. + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} diff --git a/src/server/udp_server.zig b/src/server/udp_server.zig new file mode 100644 index 0000000..225f4a3 --- /dev/null +++ b/src/server/udp_server.zig @@ -0,0 +1,300 @@ +//! The UDP/53 listener. +//! +//! One receive loop feeds a bounded pool of in-flight tasks. A datagram that +//! finds no free slot is dropped and counted, never queued: an unbounded queue +//! only moves the failure from "visible drop" to "invisible latency", which is +//! the failure mode PLAN §1 names. +//! +//! Every slot carries its own query and reply buffer, so answering a datagram +//! allocates nothing. The pool is sized once in `bind` and never grows. + +const std = @import("std"); +const handler = @import("handler.zig"); +const transport = @import("../upstream/transport.zig"); + +const log = std.log.scoped(.udp_server); + +/// The EDNS ceiling this server advertises. A larger datagram arrives +/// truncated and is dropped rather than guessed at. +pub const max_datagram = 4096; + +comptime { + // `handler.handle` asserts this range on the buffers it is given. + std.debug.assert(max_datagram >= handler.udp_limit_min); + std.debug.assert(max_datagram >= handler.udp_limit_max); + std.debug.assert(transport.max_message_len >= handler.udp_limit_min); +} + +/// How long a receive waits before the loop re-reads the stop flag. `deinit` +/// therefore returns within this much time, without depending on a blocked +/// `receive` being unblocked by a close — a guarantee POSIX does not make. +const poll_interval: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake }; + +pub const Options = struct { + max_in_flight: u16 = 64, +}; + +pub const Stats = struct { + received: std.atomic.Value(u64) = .init(0), + /// The datagram arrived with `flags.trunc`: the kernel discarded its tail. + dropped_oversize: std.atomic.Value(u64) = .init(0), + dropped_no_slot: std.atomic.Value(u64) = .init(0), + /// The handler answered `.drop` — an upstream exchange canceled by + /// shutdown. The handler counts its own malformed drops; this counter is + /// what keeps the listener's "every dropped datagram is counted" rule true + /// for the drops it cannot see. + dropped_handler: std.atomic.Value(u64) = .init(0), + receive_errors: std.atomic.Value(u64) = .init(0), + send_errors: std.atomic.Value(u64) = .init(0), +}; + +/// Lifecycle of the receive loop. `serve` claims `.serving`, `deinit` publishes +/// `.closing`, and the two meet at `stopped` so no task touches the slots after +/// they are freed. +const State = enum(u32) { idle, serving, closing }; + +pub const UdpServer = struct { + socket: std.Io.net.Socket, + handler: *handler.Handler, + slots: []Slot, + mutex: std.Io.Mutex, + stats: Stats, + state: std.atomic.Value(State), + stopped: std.Io.Event, + + pub const Slot = struct { + query: [max_datagram]u8, + /// The buffer the handler receives the upstream answer into, so it + /// holds a whole DNS message, not a whole datagram. A reply between + /// `max_datagram` and `transport.max_message_len` must reach the + /// handler intact: only the handler can turn it into the TC=1 reply + /// that makes the client retry over TCP (S5.3). A 4096-byte buffer + /// would instead fail the exchange with `error.ResponseTooLarge` and + /// answer SERVFAIL, which is wrong. + /// + /// The datagram actually sent stays bounded by `udpLimit` inside the + /// handler, so nothing larger than 4096 bytes leaves this socket. + /// + /// Cost: 4096 + 65535 ≈ 68 KiB per slot, so the default 64 slots hold + /// ≈ 4.3 MiB. The PLAN §18 budget is 100 MB with ~1M blocked domains, + /// so this pool takes about 4% of it. + reply: [transport.max_message_len]u8, + from: std.Io.net.IpAddress, + len: usize, + /// Guarded by `UdpServer.mutex`. + in_use: bool, + }; + + pub const BindError = std.Io.net.IpAddress.BindError || error{OutOfMemory}; + + pub fn bind( + gpa: std.mem.Allocator, + io: std.Io, + address: std.Io.net.IpAddress, + h: *handler.Handler, + options: Options, + ) BindError!UdpServer { + std.debug.assert(options.max_in_flight > 0); + + const slots = try gpa.alloc(Slot, options.max_in_flight); + errdefer gpa.free(slots); + for (slots) |*slot| slot.in_use = false; + + const local = address; + const socket = try local.bind(io, .{ .mode = .dgram }); + + return .{ + .socket = socket, + .handler = h, + .slots = slots, + .mutex = .init, + .stats = .{}, + .state = .init(.idle), + .stopped = .unset, + }; + } + + /// The kernel-assigned address. A port of 0 in `bind` resolves here. + pub fn boundAddress(self: *const UdpServer) std.Io.net.IpAddress { + return self.socket.address; + } + + /// Receive loop. Returns when the task is canceled or `deinit` stops it. + pub fn serve(self: *UdpServer, io: std.Io) void { + if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return; + + var group: std.Io.Group = .init; + self.receiveLoop(io, &group); + + // A reply that is half sent is worse than no reply, so the in-flight + // tasks are awaited even when this task is being canceled. + const prev = io.swapCancelProtection(.blocked); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; + _ = io.swapCancelProtection(prev); + + self.stopped.set(io); + } + + pub fn deinit(self: *UdpServer, gpa: std.mem.Allocator, io: std.Io) void { + // `serve` reads this between receives, so it stops on its own and the + // socket is closed only once nothing can be reading from it. + if (self.state.swap(.closing, .acq_rel) == .serving) self.stopped.waitUncancelable(io); + self.socket.close(io); + gpa.free(self.slots); + self.* = undefined; + } + + fn receiveLoop(self: *UdpServer, io: std.Io, group: *std.Io.Group) void { + // A datagram that finds no free slot still has to leave the socket + // buffer, or the loop would spin on the same undeliverable packet. + var overflow: [max_datagram]u8 = undefined; + + while (self.state.load(.acquire) == .serving) { + const claimed = self.claim(io); + const buffer = if (claimed) |index| &self.slots[index].query else &overflow; + + const msg = self.socket.receiveTimeout(io, buffer, .{ .duration = poll_interval }) catch |err| { + if (claimed) |index| self.release(io, index); + switch (err) { + error.Canceled => return, + // The stop flag is re-read at the top of the loop. + error.Timeout => continue, + else => { + bump(&self.stats.receive_errors); + log.debug("udp receive failed: {t}", .{err}); + // One bad datagram must not kill the listener, and a + // persistent error must not turn it into a spin. + poll_interval.sleep(io) catch return; + continue; + }, + } + }; + + bump(&self.stats.received); + + const index = claimed orelse { + bump(&self.stats.dropped_no_slot); + continue; + }; + + // The kernel already threw the tail away, so the message cannot be + // parsed and any answer would be a guess. + if (msg.flags.trunc) { + bump(&self.stats.dropped_oversize); + self.release(io, index); + continue; + } + + const slot = &self.slots[index]; + slot.from = msg.from; + slot.len = msg.data.len; + + group.concurrent(io, respondOne, .{ self, io, index }) catch |err| switch (err) { + error.ConcurrencyUnavailable => { + bump(&self.stats.dropped_no_slot); + self.release(io, index); + }, + }; + } + } + + fn respondOne(self: *UdpServer, io: std.Io, index: usize) void { + const slot = &self.slots[index]; + defer self.release(io, index); + + switch (self.handler.handle(io, .udp, slot.query[0..slot.len], &slot.reply)) { + .drop => bump(&self.stats.dropped_handler), + .reply => |bytes| self.socket.send(io, &slot.from, bytes) catch |err| { + bump(&self.stats.send_errors); + log.debug("udp send to a client failed: {t}", .{err}); + }, + } + } + + fn claim(self: *UdpServer, io: std.Io) ?usize { + // Uncancelable: this section takes no Io and never blocks on a peer, so + // it cannot deadlock, and losing the lock mid-update would leak a slot. + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + + const index = firstFree(self.slots) orelse return null; + self.slots[index].in_use = true; + return index; + } + + fn release(self: *UdpServer, io: std.Io, index: usize) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + self.slots[index].in_use = false; + } +}; + +/// The capacity rule, without the mutex, so it is testable without a backend. +fn firstFree(slots: []const UdpServer.Slot) ?usize { + for (slots, 0..) |*slot, index| { + if (!slot.in_use) return index; + } + return null; +} + +fn bump(counter: *std.atomic.Value(u64)) void { + _ = counter.fetchAdd(1, .monotonic); +} + +const testing = std.testing; + +fn testSlots(count: usize) ![]UdpServer.Slot { + const slots = try testing.allocator.alloc(UdpServer.Slot, count); + for (slots) |*slot| slot.in_use = false; + return slots; +} + +test "the slot pool hands out every index once" { + const slots = try testSlots(3); + defer testing.allocator.free(slots); + + for (0..slots.len) |expected| { + const index = firstFree(slots) orelse return error.TestUnexpectedResult; + try testing.expectEqual(expected, index); + slots[index].in_use = true; + } +} + +test "a full slot pool refuses instead of growing" { + const slots = try testSlots(2); + defer testing.allocator.free(slots); + + for (slots) |*slot| slot.in_use = true; + try testing.expectEqual(@as(?usize, null), firstFree(slots)); +} + +test "a slot's reply buffer holds a whole DNS message" { + const slots = try testSlots(1); + defer testing.allocator.free(slots); + + // The handler asserts both ends of this range on the buffer it is given, + // and an upstream reply of up to 65535 bytes has to fit so the handler can + // truncate it instead of the exchange failing. + try testing.expectEqual(@as(usize, transport.max_message_len), slots[0].reply.len); + try testing.expect(slots[0].reply.len >= handler.udp_limit_min); + try testing.expect(slots[0].reply.len > max_datagram); +} + +test "the default slot pool stays inside the memory budget" { + // 4096 + 65535 ≈ 68 KiB per slot; 64 slots ≈ 4.3 MiB, against the 100 MB + // of PLAN §18. + const options: Options = .{}; + const pool_bytes = @sizeOf(UdpServer.Slot) * @as(usize, options.max_in_flight); + try testing.expect(pool_bytes < 8 * 1024 * 1024); +} + +test "a released slot is handed out again" { + const slots = try testSlots(2); + defer testing.allocator.free(slots); + + for (slots) |*slot| slot.in_use = true; + slots[1].in_use = false; + try testing.expectEqual(@as(?usize, 1), firstFree(slots)); +} diff --git a/src/server/udp_server_integration_test.zig b/src/server/udp_server_integration_test.zig new file mode 100644 index 0000000..27b50c6 --- /dev/null +++ b/src/server/udp_server_integration_test.zig @@ -0,0 +1,217 @@ +//! Loopback tests for `udp_server.zig`. +//! +//! This lives in its own file because it needs `@import("build_options")`, which +//! only exists when the compilation is driven by build.zig. The body is compiled +//! by every `zig build test` run, so it cannot rot, and skips at run time unless +//! `-Dintegration` is passed. +//! +//! Hermetic: two sockets on 127.0.0.1 and an in-process fake upstream. Nothing +//! leaves the machine, and every wait carries a budget. + +const std = @import("std"); +const build_options = @import("build_options"); +const net = std.Io.net; + +const handler = @import("handler.zig"); +const udp_server = @import("udp_server.zig"); +const header = @import("../dns/header.zig"); +const packet = @import("../dns/packet.zig"); +const types = @import("../dns/types.zig"); +const transport = @import("../upstream/transport.zig"); + +const testing = std.testing; + +/// Long enough that a loopback round trip cannot lose to scheduling, short +/// enough that a broken server fails the run instead of hanging it. +const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } }; + +/// How long the test waits to be convinced that no reply is coming. A dropped +/// datagram produces nothing, so this budget is spent in full on every run. +const silence: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(300), .clock = .awake } }; + +/// A query for example.com A: id 0x1234, RD set, one question, no OPT. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// The matching response: the question echoed plus one A record. +const response_bytes = + "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + +/// Answers from a fixture and rewrites the ID, which is all the server needs +/// from an upstream. The real clients are exercised by their own tests. +const FakeUpstream = struct { + reply: []const u8, + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = io; + const self: *FakeUpstream = @ptrCast(@alignCast(ptr)); + if (self.reply.len > response_buf.len) return error.ResponseTooLarge; + @memcpy(response_buf[0..self.reply.len], self.reply); + const bytes = response_buf[0..self.reply.len]; + packet.setId(bytes, (header.parse(query) catch unreachable).id); + return bytes; + } + + fn client(self: *FakeUpstream) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +fn expectAnswersQuery(reply: []const u8) !void { + const p = try packet.parse(reply); + try testing.expectEqual(@as(u16, 0x1234), p.header.id); + try testing.expectEqual(true, p.header.flags.qr); + try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); + try testing.expectEqual(@as(u16, 1), p.header.qdcount); + + const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion; + const asked = packet.firstQuestion(try packet.parse(query_bytes)).?; + try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire()); + try testing.expectEqual(types.Type.a, echoed.qtype); +} + +test "a udp query is answered on the loopback" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io }); + + const client_address: net.IpAddress = try .parse("127.0.0.1", 0); + var client = try client_address.bind(io, .{ .mode = .dgram }); + defer client.close(io); + + try client.send(io, &server_address, query_bytes); + + var buf: [udp_server.max_datagram]u8 = undefined; + const msg = try client.receiveTimeout(io, &buf, budget); + try expectAnswersQuery(msg.data); + + try testing.expectEqual(@as(u64, 1), server.stats.received.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), server.stats.send_errors.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic)); + + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "a runt datagram is dropped and no reply is sent" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io }); + + const client_address: net.IpAddress = try .parse("127.0.0.1", 0); + var client = try client_address.bind(io, .{ .mode = .dgram }); + defer client.close(io); + + try client.send(io, &server_address, query_bytes[0..5]); + + var buf: [udp_server.max_datagram]u8 = undefined; + try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence)); + try testing.expectEqual(@as(u64, 1), server.stats.received.load(.monotonic)); + try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic)); + + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "an oversize datagram arrives truncated and is dropped" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 }); + const server_address = server.boundAddress(); + + var group: std.Io.Group = .init; + try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io }); + + const client_address: net.IpAddress = try .parse("127.0.0.1", 0); + var client = try client_address.bind(io, .{ .mode = .dgram }); + defer client.close(io); + + // The server receives into a `max_datagram` buffer, so the kernel discards + // the tail of this one and reports it through `flags.trunc`. + const oversize = try gpa.alloc(u8, udp_server.max_datagram * 2); + defer gpa.free(oversize); + @memcpy(oversize[0..query_bytes.len], query_bytes); + @memset(oversize[query_bytes.len..], 0); + try client.send(io, &server_address, oversize); + + var buf: [udp_server.max_datagram]u8 = undefined; + try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence)); + try testing.expectEqual(@as(u64, 1), server.stats.dropped_oversize.load(.monotonic)); + try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic)); + + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} + +test "deinit ends a serve loop that is blocked on receive" { + if (!build_options.integration) return error.SkipZigTest; + + const gpa = testing.allocator; + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var fake: FakeUpstream = .{ .reply = response_bytes }; + var h: handler.Handler = .{ .upstream = fake.client() }; + + const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); + var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 }); + + var group: std.Io.Group = .init; + try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io }); + + // No datagram ever arrives, so `serve` is inside a receive when this runs. + server.deinit(gpa, io); + group.await(io) catch |err| switch (err) { + error.Canceled => unreachable, + }; +} diff --git a/src/tests.zig b/src/tests.zig index 6b7a484..5dca584 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -14,6 +14,19 @@ comptime { _ = @import("platform/tls_client.zig"); _ = @import("platform/tls_client_integration_test.zig"); _ = @import("platform/tls_server.zig"); + _ = @import("upstream/transport.zig"); + _ = @import("upstream/health.zig"); + _ = @import("upstream/doh_client.zig"); + _ = @import("upstream/doh_client_live_test.zig"); + _ = @import("upstream/pool.zig"); + _ = @import("upstream/dot_client.zig"); + _ = @import("upstream/dot_client_live_test.zig"); + _ = @import("server/handler.zig"); + _ = @import("server/udp_server.zig"); + _ = @import("server/tcp_server.zig"); + _ = @import("server/udp_server_integration_test.zig"); + _ = @import("server/tcp_server_integration_test.zig"); + _ = @import("server/resolver_integration_test.zig"); } extern fn sqlite3_libversion() [*:0]const u8; diff --git a/src/upstream/doh_client.zig b/src/upstream/doh_client.zig new file mode 100644 index 0000000..4cdd15b --- /dev/null +++ b/src/upstream/doh_client.zig @@ -0,0 +1,271 @@ +//! RFC 8484 DNS over HTTPS upstream client. +//! +//! One `DohClient` wraps a caller-owned `std.http.Client`, which owns the +//! connection pool and the CA bundle. Several `DohClient` values can share one +//! `std.http.Client`, so a pool of DoH upstreams keeps one TLS connection per +//! host without this file knowing anything about pooling. +//! +//! HTTP/1.1 only — HTTP/2 is permanently out of scope (PLAN §2.2). +//! +//! Every buffer is caller-owned. Nothing here allocates, so an exchange cannot +//! fail for a reason this file invented. + +const std = @import("std"); +const transport = @import("transport.zig"); + +pub const media_type = "application/dns-message"; + +/// The smallest `request_buf` that can hold a real query: a header, a +/// maximum-length name, qtype/qclass and an OPT record all fit in 512 bytes. +pub const min_request_buf = 512; +pub const min_transfer_buf = 1024; + +pub const DohClient = struct { + /// Caller-owned; shared across endpoints, pools connections. + http: *std.http.Client, + endpoint: transport.Endpoint, + /// Built once in `init` from `endpoint`. + uri: std.Uri, + /// Caller-owned. `Request.sendBodyComplete` takes `[]u8`, so the query has + /// to be copied out of the caller's const slice before it can be sent. + request_buf: []u8, + /// Caller-owned HTTP body transfer buffer. + transfer_buf: []u8, + + pub const InitError = error{BadUrl}; + + /// `endpoint` stays the source of truth for host, port and scheme in logs; + /// `uri` exists only because `std.http.Client.request` takes one. + /// + /// A non-DoH endpoint is `error.BadUrl` rather than an assert: the scheme + /// comes from configuration, so wiring a `tls://` URL to this client is a + /// config fault that must surface as a value, not a panic. + pub fn init( + http: *std.http.Client, + endpoint: transport.Endpoint, + request_buf: []u8, + transfer_buf: []u8, + ) InitError!DohClient { + std.debug.assert(request_buf.len >= min_request_buf); + std.debug.assert(transfer_buf.len >= min_transfer_buf); + if (endpoint.scheme != .doh) return error.BadUrl; + const uri = std.Uri.parse(endpoint.url) catch return error.BadUrl; + return .{ + .http = http, + .endpoint = endpoint, + .uri = uri, + .request_buf = request_buf, + .transfer_buf = transfer_buf, + }; + } + + pub fn client(self: *DohClient) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + const self: *DohClient = @ptrCast(@alignCast(ptr)); + return self.exchange(io, query, response_buf); + } + + /// Returns a prefix of `response_buf` holding a message that has already + /// passed `transport.validateResponse` against `query`. + /// + /// The query ID is sent unchanged. RFC 8484 §4.1 suggests ID 0 so that HTTP + /// caches can share a response; nxdns puts no HTTP cache in this path, and + /// keeping the ID preserves the request/response binding that + /// `validateResponse` checks. + pub fn exchange( + self: *DohClient, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + // `std.http.Client` carries the `std.Io` it was constructed with and + // takes none per request, so the interface's `io` is unused here. It + // stays in the signature because DoT and the pool need it. + _ = io; + + if (query.len > self.request_buf.len) return error.BufferTooSmall; + @memcpy(self.request_buf[0..query.len], query); + + var req = self.http.request(.POST, self.uri, .{ + .keep_alive = true, + .redirect_behavior = .not_allowed, + .headers = .{ + .content_type = .{ .override = media_type }, + // A compressed body would need the decompressing reader and + // would stop being byte-exact, which `validateResponse` needs. + .accept_encoding = .{ .override = "identity" }, + }, + // `Request.Headers` has no `accept` field, so this one goes in by + // hand. + .extra_headers = &.{.{ .name = "accept", .value = media_type }}, + }) catch |err| return mapError(err, .connect); + defer req.deinit(); + + req.sendBodyComplete(self.request_buf[0..query.len]) catch |err| + return mapError(err, .send); + + // An empty redirect buffer is legal under `.not_allowed`: a redirect + // is an error before the location is ever read. + var resp = req.receiveHead(&.{}) catch |err| return mapError(err, .receive); + + if (resp.head.status != .ok) return error.HttpStatus; + // `head.content_type` points into memory that `resp.reader` invalidates, + // so the check happens before the body stream starts. + if (!contentTypeOk(resp.head.content_type)) return error.HttpContentType; + if (resp.head.content_length) |declared| { + if (declared > response_buf.len) return error.ResponseTooLarge; + } + + const body = resp.reader(self.transfer_buf); + var len: usize = 0; + var ended = false; + while (len < response_buf.len) { + const n = body.readSliceShort(response_buf[len..]) catch |err| + return mapError(err, .receive); + len += n; + if (n == 0) { + ended = true; + break; + } + } + if (!ended) { + // `response_buf` filled exactly. One more read separates a message + // that fits from one that was cut off. + var probe: [1]u8 = undefined; + const n = body.readSliceShort(&probe) catch |err| return mapError(err, .receive); + if (n != 0) return error.ResponseTooLarge; + } + + try transport.validateResponse(query, response_buf[0..len]); + return response_buf[0..len]; + } +}; + +/// Which call failed. The phase is what decides the peer fault, and only the +/// call site knows it — guessing it from an error name would be wrong the first +/// time two phases shared an error. +const Phase = enum { connect, send, receive }; + +fn mapError(err: anyerror, phase: Phase) transport.ExchangeError { + if (transport.mapLocal(err)) |local| return local; + const err_name = @errorName(err); + if (std.mem.startsWith(u8, err_name, "Tls") or + std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed; + return switch (phase) { + .connect => error.ConnectFailed, + .send => error.SendFailed, + .receive => error.ReceiveFailed, + }; +} + +/// RFC 8484 §6: the response media type is `application/dns-message`. The +/// header may carry parameters (`; charset=…`) and the type is case-insensitive +/// per RFC 9110 §8.3.1. +pub fn contentTypeOk(value: ?[]const u8) bool { + const raw = value orelse return false; + const without_params = if (std.mem.findScalar(u8, raw, ';')) |semi| raw[0..semi] else raw; + return std.ascii.eqlIgnoreCase(std.mem.trim(u8, without_params, " \t"), media_type); +} + +const testing = std.testing; + +test "init rejects a DoT endpoint before any io" { + var http: std.http.Client = undefined; + var request_buf: [min_request_buf]u8 = undefined; + var transfer_buf: [min_transfer_buf]u8 = undefined; + const endpoint = try transport.Endpoint.parse("tls://dns.google:853"); + try testing.expectError( + error.BadUrl, + DohClient.init(&http, endpoint, &request_buf, &transfer_buf), + ); +} + +test "init builds a uri from the endpoint url" { + var http: std.http.Client = undefined; + var request_buf: [min_request_buf]u8 = undefined; + var transfer_buf: [min_transfer_buf]u8 = undefined; + const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query"); + const doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf); + var component_buf: [256]u8 = undefined; + try testing.expectEqualStrings("https", doh.uri.scheme); + try testing.expectEqualStrings("dns.example", try doh.uri.host.?.toRaw(&component_buf)); + try testing.expectEqualStrings("/dns-query", try doh.uri.path.toRaw(&component_buf)); + try testing.expectEqualStrings("dns.example", doh.endpoint.host); +} + +test "DohClient satisfies the transport.Client interface" { + var http: std.http.Client = undefined; + var request_buf: [min_request_buf]u8 = undefined; + var transfer_buf: [min_transfer_buf]u8 = undefined; + const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query"); + var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf); + + // Instantiation is the check: the vtable is built from `exchangeFn`, so a + // signature drift is a compile error here. The `std.http.Client` above is + // never driven, and no exchange runs. + const c: transport.Client = doh.client(); + try testing.expectEqual(@as(*anyopaque, @ptrCast(&doh)), c.ptr); + try testing.expectEqual( + @as(@TypeOf(c.exchangeFn), DohClient.exchangeFn), + c.exchangeFn, + ); +} + +test "exchange rejects a query larger than the request buffer" { + var http: std.http.Client = undefined; + var request_buf: [min_request_buf]u8 = undefined; + var transfer_buf: [min_transfer_buf]u8 = undefined; + const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query"); + var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf); + + const oversized: [min_request_buf + 1]u8 = @splat(0); + var response_buf: [512]u8 = undefined; + // The size check precedes every use of `http`, so nothing is driven. + try testing.expectError( + error.BufferTooSmall, + doh.exchange(undefined, &oversized, &response_buf), + ); +} + +test "contentTypeOk accepts the RFC 8484 media type" { + try testing.expect(contentTypeOk("application/dns-message")); + try testing.expect(contentTypeOk("Application/DNS-Message")); + try testing.expect(contentTypeOk("application/dns-message; charset=utf-8")); + try testing.expect(contentTypeOk("application/dns-message ; charset=utf-8")); + try testing.expect(contentTypeOk(" application/dns-message ")); +} + +test "contentTypeOk rejects anything else" { + try testing.expect(!contentTypeOk(null)); + try testing.expect(!contentTypeOk("text/html")); + try testing.expect(!contentTypeOk("application/json")); + try testing.expect(!contentTypeOk("")); + try testing.expect(!contentTypeOk("application/dns-message-extra")); +} + +test "mapError maps local errors before phase errors" { + try testing.expectEqual(error.OutOfMemory, mapError(error.OutOfMemory, .connect)); + try testing.expectEqual(error.Canceled, mapError(error.Canceled, .receive)); + try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send)); +} + +test "mapError maps tls errors regardless of phase" { + try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect)); + try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive)); + try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect)); +} + +test "mapError maps remaining errors by phase" { + try testing.expectEqual(error.ConnectFailed, mapError(error.ConnectionRefused, .connect)); + try testing.expectEqual(error.SendFailed, mapError(error.WriteFailed, .send)); + try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive)); + try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive)); +} diff --git a/src/upstream/doh_client_live_test.zig b/src/upstream/doh_client_live_test.zig new file mode 100644 index 0000000..8065caa --- /dev/null +++ b/src/upstream/doh_client_live_test.zig @@ -0,0 +1,93 @@ +//! Network-dependent test for `doh_client.zig`. +//! +//! It lives in its own file because it needs `@import("build_options")`, which +//! only exists when the compilation is driven by build.zig. The test is +//! compiled by every `zig build test` run, so it cannot rot, and it skips at run +//! time unless `-Dlive` is passed. (`-Dintegration` stays hermetic: loopback +//! only. `-Dlive` is the gate for tests that leave the machine.) + +const std = @import("std"); +const build_options = @import("build_options"); + +const doh_client = @import("doh_client.zig"); +const transport = @import("transport.zig"); +const packet = @import("../dns/packet.zig"); + +/// No HTTP call in 0.16.0 takes a deadline, so the whole exchange runs as one +/// task raced against a sleep and the loser is canceled. +const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }; + +const Outcome = union(enum) { + exchange: anyerror!usize, + expiry: std.Io.Cancelable!void, +}; + +/// A query for example.com A: id 0x1234, RD set, one question. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +const Params = struct { + gpa: std.mem.Allocator, + response_buf: []u8, +}; + +/// Returns the reply length; the bytes stay in the caller's `response_buf` so +/// they outlive this task. +fn runExchange(io: std.Io, params: Params) anyerror!usize { + var http: std.http.Client = .{ .allocator = params.gpa, .io = io }; + defer http.deinit(); + + var request_buf: [1024]u8 = undefined; + var transfer_buf: [4096]u8 = undefined; + + const endpoint = try transport.Endpoint.parse("https://cloudflare-dns.com/dns-query"); + var doh = try doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf); + + const reply = try doh.client().exchange(io, query_bytes, params.response_buf); + return reply.len; +} + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +test "live DoH exchange against cloudflare-dns.com" { + if (!build_options.live) return error.SkipZigTest; + + const gpa = std.testing.allocator; + + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var response_buf: [4096]u8 = undefined; + + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + try race.concurrent(.exchange, runExchange, .{ io, Params{ + .gpa = gpa, + .response_buf = &response_buf, + } }); + try race.concurrent(.expiry, expire, .{ io, budget }); + + const len = switch (try race.await()) { + .exchange => |result| result catch |err| { + std.debug.print("DoH exchange failed: {s}\n", .{@errorName(err)}); + return err; + }, + .expiry => |result| { + try result; + return error.DohExchangeTimedOut; + }, + }; + + // `exchange` already ran `validateResponse`; re-run it so the assertion is + // in the test and not only in the code under test. + try transport.validateResponse(query_bytes, response_buf[0..len]); + + const reply = try packet.parse(response_buf[0..len]); + try std.testing.expect(reply.header.ancount >= 1); +} diff --git a/src/upstream/dot_client.zig b/src/upstream/dot_client.zig new file mode 100644 index 0000000..7b78db0 --- /dev/null +++ b/src/upstream/dot_client.zig @@ -0,0 +1,478 @@ +//! DNS over TLS upstream client (RFC 7858). +//! +//! DoT is DNS over TCP with a TLS layer in between: the same 2-byte big-endian +//! length prefix as RFC 1035 §4.2.2, carried on the plaintext side of the TLS +//! stream. All TLS work goes through `platform/tls_client.zig`; this file never +//! touches `std.crypto.tls.Client` directly. +//! +//! Every failure is classified by the phase it happened in — connect, handshake, +//! send, receive — after `transport.mapLocal` has had a chance to claim it. A +//! local resource error or a cancellation must never reach the pool as a peer +//! fault, so the concrete error is unwrapped from `error.ReadFailed` / +//! `error.WriteFailed` before it is classified. + +const std = @import("std"); +const net = std.Io.net; +const tls = std.crypto.tls; +const Certificate = std.crypto.Certificate; + +const transport = @import("transport.zig"); +const tls_client = @import("../platform/tls_client.zig"); + +const log = std.log.scoped(.dot_client); + +/// RFC 1035 §4.2.2 length prefix, shared by DNS over TCP and DNS over TLS. +pub const prefix_len = 2; + +pub fn framePrefix(len: u16) [prefix_len]u8 { + var out: [prefix_len]u8 = undefined; + std.mem.writeInt(u16, &out, len, .big); + return out; +} + +pub fn parsePrefix(bytes: [prefix_len]u8) u16 { + return std.mem.readInt(u16, &bytes, .big); +} + +pub const ResolveError = error{ConnectFailed}; + +/// DoT endpoints take IP literals. Name resolution for upstreams is out of +/// scope for this milestone, and resolving silently would hide a config error +/// behind a slow, confusing failure, so a non-literal host fails immediately. +pub fn resolveAddress(endpoint: transport.Endpoint) ResolveError!net.IpAddress { + return net.IpAddress.parse(endpoint.host, endpoint.port) catch error.ConnectFailed; +} + +pub const DotClient = struct { + endpoint: transport.Endpoint, + gpa: std.mem.Allocator, + /// Caller-owned, shared across endpoints. + bundle: *Certificate.Bundle, + /// Caller-owned, guards `bundle`. + bundle_lock: *std.Io.RwLock, + /// Caller-owned. One `DotClient` is used by one task at a time. + buffers: Buffers, + + pub const Buffers = struct { + /// Plaintext read buffer. + tls_read: []u8, + /// Plaintext write buffer. + tls_write: []u8, + /// Ciphertext read buffer. + stream_read: []u8, + /// Ciphertext write buffer. + stream_write: []u8, + }; + + /// A `.doh` endpoint or an undersized buffer is a wiring bug in this + /// process, not a runtime condition, so both are assertions. + pub fn init( + endpoint: transport.Endpoint, + gpa: std.mem.Allocator, + bundle: *Certificate.Bundle, + bundle_lock: *std.Io.RwLock, + buffers: Buffers, + ) DotClient { + std.debug.assert(endpoint.scheme == .dot); + std.debug.assert(buffers.tls_read.len >= tls.Client.min_buffer_len); + std.debug.assert(buffers.tls_write.len >= tls.Client.min_buffer_len); + std.debug.assert(buffers.stream_read.len >= tls.Client.min_buffer_len); + std.debug.assert(buffers.stream_write.len >= tls.Client.min_buffer_len); + return .{ + .endpoint = endpoint, + .gpa = gpa, + .bundle = bundle, + .bundle_lock = bundle_lock, + .buffers = buffers, + }; + } + + pub fn client(self: *DotClient) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + const self: *DotClient = @ptrCast(@alignCast(ptr)); + return self.exchange(io, query, response_buf); + } + + /// One TCP connection and one TLS handshake per exchange, both closed + /// before returning. + /// + /// Connection reuse is deliberately not built. At household query rates the + /// saved round trips are worth less than what a per-exchange connection + /// buys: the pool's per-attempt budget stays a plain race against one task, + /// the failover path never has to reason about a half-dead pooled socket, + /// and every failure is attributable to exactly one exchange. + pub fn exchange( + self: *DotClient, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + // The length prefix is 16-bit, so a longer query cannot be framed. No + // listener in this process can produce one; a caller that does gets a + // local error rather than a silently truncated frame. + if (query.len > transport.max_message_len) return error.BufferTooSmall; + + const address = resolveAddress(self.endpoint) catch |err| { + log.warn("dot upstream {s}: host \"{s}\" is not an IP literal", .{ + self.endpoint.url, + self.endpoint.host, + }); + return err; + }; + + try self.ensureBundle(io); + + var stream = address.connect(io, .{ .mode = .stream }) catch |err| { + log.debug("dot upstream {s}: connect failed: {s}", .{ + self.endpoint.url, + @errorName(err), + }); + return mapPhase(err, error.ConnectFailed); + }; + defer closeStream(io, &stream); + + // `TlsStream` is pinned: it holds its reader and writer by value and the + // TLS client points at them, so it must not move after `init`. + var tls_stream: tls_client.TlsStream = undefined; + // `concreteHandshake` reads these two fields when the handshake reports + // `error.ReadFailed` / `error.WriteFailed`. `TlsStream.init` sets them + // before it can produce either error, but clearing them here keeps that + // out of this file's correctness argument. + tls_stream.stream_reader.err = null; + tls_stream.stream_writer.err = null; + tls_stream.init(io, &stream, self.bundle, self.bundle_lock, self.gpa, .{ + .host = self.endpoint.host, + .ca = .system, + .read_buffer = self.buffers.tls_read, + .write_buffer = self.buffers.tls_write, + .stream_read_buffer = self.buffers.stream_read, + .stream_write_buffer = self.buffers.stream_write, + }) catch |err| { + const cause = concreteHandshake(&tls_stream, err); + log.warn("dot upstream {s}: TLS handshake failed: {s} ({t})", .{ + self.endpoint.url, + @errorName(cause), + tls_client.classify(cause), + }); + return mapPhase(cause, error.TlsFailed); + }; + defer closeTls(io, &tls_stream); + + const writer = tls_stream.writer(); + const prefix = framePrefix(@intCast(query.len)); + writer.writeAll(&prefix) catch |err| return sendFailure(&tls_stream, err); + writer.writeAll(query) catch |err| return sendFailure(&tls_stream, err); + writer.flush() catch |err| return sendFailure(&tls_stream, err); + + const reader = tls_stream.reader(); + var prefix_bytes: [prefix_len]u8 = undefined; + reader.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&tls_stream, err); + + const len = parsePrefix(prefix_bytes); + if (len == 0) return error.BadResponse; + if (len > response_buf.len) return error.ResponseTooLarge; + reader.readSliceAll(response_buf[0..len]) catch |err| + return receiveFailure(&tls_stream, err); + + try transport.validateResponse(query, response_buf[0..len]); + return response_buf[0..len]; + } + + /// Loads the system CA bundle before the handshake, so that a failure to + /// read it keeps its concrete cause. + /// + /// `TlsStream.init` loads the bundle as well and returns early once + /// `bundle` holds entries, so this runs the scan at most once per process. + /// It exists because `TlsStream.init` folds every scan failure except + /// cancellation into `error.CertificateBundleLoadFailure`. That name cannot + /// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a + /// local resource failure that must not count against the upstream's + /// health. Scanning here keeps the concrete error for `mapPhase`. + fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void { + { + try self.bundle_lock.lockShared(io); + defer self.bundle_lock.unlockShared(io); + if (self.bundle.map.count() != 0) return; + } + + try self.bundle_lock.lock(io); + defer self.bundle_lock.unlock(io); + if (self.bundle.map.count() != 0) return; + + // A partial scan leaves entries in `map`, which the check above would + // read as "already loaded". Reset so the next exchange scans again. + self.bundle.rescan(self.gpa, io, std.Io.Clock.real.now(io)) catch |err| { + self.bundle.deinit(self.gpa); + self.bundle.* = .empty; + log.warn("dot upstream {s}: CA bundle load failed: {s}", .{ + self.endpoint.url, + @errorName(err), + }); + return mapPhase(err, error.TlsFailed); + }; + } +}; + +/// The pool cancels this task when the attempt budget expires. The next +/// cancelable `Io` call in the `defer` chain would then return `error.Canceled` +/// and skip the close, leaking the socket, so the close runs with cancellation +/// blocked. +fn closeStream(io: std.Io, stream: *net.Stream) void { + const prev = io.swapCancelProtection(.blocked); + defer _ = io.swapCancelProtection(prev); + stream.close(io); +} + +fn closeTls(io: std.Io, stream: *tls_client.TlsStream) void { + const prev = io.swapCancelProtection(.blocked); + defer _ = io.swapCancelProtection(prev); + stream.close(); +} + +fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError { + return transport.mapLocal(err) orelse phase; +} + +/// The handshake reads and writes through the socket reader and writer, so a +/// cancelled or resource-starved handshake surfaces as `error.ReadFailed` / +/// `error.WriteFailed` with the cause stashed on those two. Without this, +/// `error.Canceled` and `error.SystemResources` would reach the pool as +/// `error.TlsFailed` and count against the upstream's health. +/// +/// Only the socket reader and writer are consulted: `tls.Client.init` returns +/// its error before `TlsStream.client` is assigned, so `client.read_err` does +/// not exist yet on this path. +fn concreteHandshake(stream: *tls_client.TlsStream, err: anyerror) anyerror { + return switch (err) { + error.ReadFailed => stream.stream_reader.err orelse err, + error.WriteFailed => stream.stream_writer.err orelse err, + else => err, + }; +} + +/// `Io.Reader` collapses everything to `error.ReadFailed` and stashes the cause. +/// Unwrapping it is what keeps `error.Canceled` and the local resource errors +/// out of the health counters. +fn concreteRead(stream: *tls_client.TlsStream, err: anyerror) anyerror { + if (err != error.ReadFailed) return err; + if (stream.client.read_err) |cause| return cause; + if (stream.stream_reader.err) |cause| return cause; + return err; +} + +fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror { + if (err != error.WriteFailed) return err; + if (stream.stream_writer.err) |cause| return cause; + return err; +} + +fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError { + return mapPhase(concreteWrite(stream, err), error.SendFailed); +} + +fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError { + return mapPhase(concreteRead(stream, err), error.ReceiveFailed); +} + +const testing = std.testing; + +test "framePrefix writes the length big-endian" { + try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0)); + try testing.expectEqualSlices(u8, &.{ 0x00, 0x1d }, &framePrefix(29)); + try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256)); + try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535)); +} + +test "parsePrefix reads the length big-endian" { + try testing.expectEqual(@as(u16, 0), parsePrefix(.{ 0x00, 0x00 })); + try testing.expectEqual(@as(u16, 29), parsePrefix(.{ 0x00, 0x1d })); + try testing.expectEqual(@as(u16, 256), parsePrefix(.{ 0x01, 0x00 })); + try testing.expectEqual(@as(u16, 65535), parsePrefix(.{ 0xff, 0xff })); +} + +test "framePrefix and parsePrefix round-trip" { + const cases = [_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 }; + for (cases) |len| { + try testing.expectEqual(len, parsePrefix(framePrefix(len))); + } +} + +test "resolveAddress accepts IP literals" { + const v4 = try resolveAddress(try .parse("tls://1.1.1.1:853")); + try testing.expectEqual(@as(u16, 853), v4.ip4.port); + try testing.expectEqualSlices(u8, &.{ 1, 1, 1, 1 }, &v4.ip4.bytes); + + const v6 = try resolveAddress(try .parse("tls://[2606:4700:4700::1111]")); + try testing.expectEqual(@as(u16, transport.dot_default_port), v6.ip6.port); +} + +test "resolveAddress rejects a non-literal host without touching the network" { + try testing.expectError( + error.ConnectFailed, + resolveAddress(try .parse("tls://dns.google:853")), + ); + try testing.expectError( + error.ConnectFailed, + resolveAddress(try .parse("tls://one.one.one.one")), + ); +} + +/// Only the four `err` fields the unwrap helpers read are set; the rest of a +/// `TlsStream` is a socket reader, a socket writer and a TLS client, none of +/// which the helpers touch. +fn stubStream( + read_err: ?net.Stream.Reader.Error, + write_err: ?net.Stream.Writer.Error, + tls_read_err: ?tls.Client.ReadError, +) tls_client.TlsStream { + var stream: tls_client.TlsStream = undefined; + stream.stream_reader.err = read_err; + stream.stream_writer.err = write_err; + stream.client.read_err = tls_read_err; + return stream; +} + +test "the handshake unwrap keeps a cancelled read out of the peer fault group" { + var stream = stubStream(error.Canceled, null, null); + const mapped = mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed); + try testing.expectEqual(transport.ExchangeError.Canceled, mapped); + try testing.expectEqual(transport.Group.cancellation, transport.group(mapped)); +} + +test "the handshake unwrap keeps a local resource write failure out of the peer fault group" { + var stream = stubStream(null, error.SystemResources, null); + const mapped = mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed); + try testing.expectEqual(transport.ExchangeError.SystemResources, mapped); + try testing.expectEqual(transport.Group.local_resource, transport.group(mapped)); +} + +test "the handshake unwrap reports a peer side cause as a TLS fault" { + var reset = stubStream(error.ConnectionResetByPeer, null, null); + try testing.expectEqual( + transport.ExchangeError.TlsFailed, + mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed), + ); + + var refused = stubStream(null, error.ConnectionRefused, null); + try testing.expectEqual( + transport.ExchangeError.TlsFailed, + mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed), + ); +} + +test "the handshake unwrap reports a TLS fault when no cause was stored" { + var stream = stubStream(null, null, null); + try testing.expectEqual(error.ReadFailed, concreteHandshake(&stream, error.ReadFailed)); + try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed)); + try testing.expectEqual( + transport.ExchangeError.TlsFailed, + mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed), + ); +} + +test "the handshake unwrap passes other errors through untouched" { + var stream = stubStream(error.Canceled, error.Canceled, error.TlsAlert); + try testing.expectEqual( + error.CertificateExpired, + concreteHandshake(&stream, error.CertificateExpired), + ); + try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled)); + try testing.expectEqual( + transport.ExchangeError.TlsFailed, + mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed), + ); + try testing.expectEqual( + transport.ExchangeError.Canceled, + mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed), + ); +} + +test "the send and receive unwraps prefer the stored cause" { + var send = stubStream(null, error.Canceled, null); + try testing.expectEqual( + transport.ExchangeError.Canceled, + sendFailure(&send, error.WriteFailed), + ); + + // The TLS client's own error wins over the socket reader's. + var receive = stubStream(error.ConnectionResetByPeer, null, error.TlsAlert); + try testing.expectEqual( + transport.ExchangeError.ReceiveFailed, + receiveFailure(&receive, error.ReadFailed), + ); + + var socket = stubStream(error.SystemResources, null, null); + try testing.expectEqual( + transport.ExchangeError.SystemResources, + receiveFailure(&socket, error.ReadFailed), + ); +} + +test "a CA bundle scan failure keeps local resource errors out of the peer fault group" { + // `Certificate.Bundle.rescan` reaches these through `Allocator.Error`, + // `Io.File.OpenError` and `Io.UnexpectedError`. + const local = [_]anyerror{ + error.OutOfMemory, + error.SystemResources, + error.ProcessFdQuotaExceeded, + error.SystemFdQuotaExceeded, + error.Unexpected, + }; + for (local) |err| { + try testing.expectEqual( + transport.Group.local_resource, + transport.group(mapPhase(err, error.TlsFailed)), + ); + } + + try testing.expectEqual( + transport.ExchangeError.Canceled, + mapPhase(error.Canceled, error.TlsFailed), + ); + + // A missing or corrupt bundle is not this process running out of anything, + // so it stays a TLS fault. + try testing.expectEqual( + transport.ExchangeError.TlsFailed, + mapPhase(error.FileNotFound, error.TlsFailed), + ); + try testing.expectEqual( + transport.ExchangeError.TlsFailed, + mapPhase(error.MissingEndCertificateMarker, error.TlsFailed), + ); +} + +test "DotClient satisfies the Client interface" { + const gpa = testing.allocator; + + const buffer = try gpa.alloc(u8, 4 * tls.Client.min_buffer_len); + defer gpa.free(buffer); + const chunk = tls.Client.min_buffer_len; + + var bundle: Certificate.Bundle = .empty; + defer bundle.deinit(gpa); + var bundle_lock: std.Io.RwLock = .init; + + // `init` asserts `endpoint.scheme == .dot`; a `.doh` endpoint trips + // `std.debug.assert`, which a test cannot catch in-process. + var dot: DotClient = .init(try .parse("tls://9.9.9.9:853"), gpa, &bundle, &bundle_lock, .{ + .tls_read = buffer[0..chunk], + .tls_write = buffer[chunk .. 2 * chunk], + .stream_read = buffer[2 * chunk .. 3 * chunk], + .stream_write = buffer[3 * chunk ..], + }); + + try testing.expectEqual(transport.Scheme.dot, dot.endpoint.scheme); + try testing.expectEqualStrings("9.9.9.9", dot.endpoint.host); + + const iface: transport.Client = dot.client(); + try testing.expectEqual(@as(*anyopaque, @ptrCast(&dot)), iface.ptr); +} diff --git a/src/upstream/dot_client_live_test.zig b/src/upstream/dot_client_live_test.zig new file mode 100644 index 0000000..f35ce06 --- /dev/null +++ b/src/upstream/dot_client_live_test.zig @@ -0,0 +1,119 @@ +//! Network-dependent test for `dot_client.zig`. +//! +//! Separate file because it needs `@import("build_options")`, which only exists +//! when build.zig drives the compilation. It is compiled by every +//! `zig build test` run, so it cannot rot, and skips at run time without +//! `-Dlive`. (`-Dintegration` stays hermetic; `-Dlive` is the gate for tests +//! that leave the machine.) + +const std = @import("std"); +const build_options = @import("build_options"); +const tls = std.crypto.tls; +const Certificate = std.crypto.Certificate; + +const dot_client = @import("dot_client.zig"); +const transport = @import("transport.zig"); +const packet = @import("../dns/packet.zig"); + +/// Neither `connect` nor a TLS stream read accepts a timeout in 0.16.0, so the +/// whole exchange runs as one task raced against a sleep and the loser is +/// canceled. +const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }; + +/// An A query for example.com: id 0x1234, RD set, one question. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// This machine's IPv6 egress is dead and upstream name resolution is out of +/// scope, so the documented anycast IPv4 literal is used. Cloudflare's +/// certificate carries 1.1.1.1 as an IP SAN, so full verification still applies. +const upstream_url = "tls://1.1.1.1:853"; + +const Outcome = union(enum) { + exchange: anyerror!usize, + expiry: std.Io.Cancelable!void, +}; + +const Params = struct { + gpa: std.mem.Allocator, + bundle: *Certificate.Bundle, + bundle_lock: *std.Io.RwLock, + buffers: dot_client.DotClient.Buffers, + response_buf: []u8, +}; + +fn runExchange(io: std.Io, params: Params) anyerror!usize { + const endpoint: transport.Endpoint = try .parse(upstream_url); + var client: dot_client.DotClient = .init( + endpoint, + params.gpa, + params.bundle, + params.bundle_lock, + params.buffers, + ); + const reply = try client.client().exchange(io, query_bytes, params.response_buf); + return reply.len; +} + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +test "live DoT exchange against 1.1.1.1" { + if (!build_options.live) return error.SkipZigTest; + + const gpa = std.testing.allocator; + + var threaded: std.Io.Threaded = .init(gpa, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var bundle: Certificate.Bundle = .empty; + defer bundle.deinit(gpa); + var bundle_lock: std.Io.RwLock = .init; + + const chunk = tls.Client.min_buffer_len; + const scratch = try gpa.alloc(u8, 4 * chunk); + defer gpa.free(scratch); + + var response_buf: [transport.max_message_len]u8 = undefined; + + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + try race.concurrent(.exchange, runExchange, .{ io, Params{ + .gpa = gpa, + .bundle = &bundle, + .bundle_lock = &bundle_lock, + .buffers = .{ + .tls_read = scratch[0..chunk], + .tls_write = scratch[chunk .. 2 * chunk], + .stream_read = scratch[2 * chunk .. 3 * chunk], + .stream_write = scratch[3 * chunk ..], + }, + .response_buf = &response_buf, + } }); + try race.concurrent(.expiry, expire, .{ io, budget }); + + const len = switch (try race.await()) { + .exchange => |result| result catch |err| { + std.debug.print("DoT exchange with {s} failed: {s}\n", .{ + upstream_url, + @errorName(err), + }); + return err; + }, + .expiry => |result| { + try result; + return error.DotExchangeTimedOut; + }, + }; + + // `exchange` already ran `transport.validateResponse`, so the id, question + // and QR bit are known good. What is left to check is that the upstream + // actually answered the question. + const reply = try packet.parse(response_buf[0..len]); + try std.testing.expect(reply.header.ancount >= 1); +} diff --git a/src/upstream/health.zig b/src/upstream/health.zig new file mode 100644 index 0000000..1dc2902 --- /dev/null +++ b/src/upstream/health.zig @@ -0,0 +1,442 @@ +//! Per-endpoint health and exponential backoff. Pure: timestamps and jitter +//! arrive as parameters, so the pool owns the clock and the RNG and this file +//! is testable without a backend. +//! +//! Only peer faults reach this file. `transport.group` decides that; a local +//! resource error or a cancellation must never be recorded, or a full disk +//! would take every upstream out of service. +//! +//! Out-of-order completions are the normal case, not an edge case: two +//! concurrent exchanges against the same endpoint finish in either order, so +//! `at` moves backwards between calls routinely. Every timestamp field +//! therefore updates through `@max` on `.nanoseconds`, and `backoff_until` is +//! only ever extended. A late-arriving success must not shorten a backoff that +//! a later failure already set. +//! +//! Changes to state that describe the endpoint *now* are therefore +//! conditional, while the accumulated counters are not. Both record functions +//! split the same way: +//! +//! * Data is data. `total_successes`, `total_failures`, the window and the +//! `@max` of `last_success_at` / `last_error_at` always update, however old +//! `at` is. +//! * `consecutive_failures`, `backoff_until` and `last_error_buf` describe +//! the present, so a newer recorded outcome overrules a stale call. +//! +//! `recordSuccess` clears `consecutive_failures` and `backoff_until` only when +//! its `at` is the newest outcome the state has seen, that is `at >= +//! max(last_success_at, last_error_at)` before the update. A stale success +//! leaves the backoff of a newer failure alone. +//! +//! `recordFailure` is the mirror image. See the rule at that function. +//! +//! `State` carries no lock. The pool owns the mutex. + +const std = @import("std"); + +pub const Config = struct { + /// Consecutive peer faults before the endpoint is put in backoff. + failure_threshold: u8 = 2, + base_backoff_ms: u32 = 500, + max_backoff_ms: u32 = 60_000, +}; + +/// Rolling success-rate window, in samples. Equal to the bit width of +/// `State.window`. +pub const window_len = 32; + +/// The shift is capped so `base_backoff_ms << shift` cannot run away; by then +/// `max_backoff_ms` has clamped the result many doublings ago. +const max_shift = 20; + +pub const State = struct { + consecutive_failures: u32, + total_successes: u64, + total_failures: u64, + last_success_at: ?std.Io.Timestamp, + last_error_at: ?std.Io.Timestamp, + last_error_buf: [48]u8, + /// Length of the `@errorName` held in `last_error_buf`, truncated to fit. + last_error_len: u8, + backoff_until: ?std.Io.Timestamp, + /// Bitset, 1 = success, LSB = most recent. + window: u32, + window_filled: u8, + + pub const init: State = .{ + .consecutive_failures = 0, + .total_successes = 0, + .total_failures = 0, + .last_success_at = null, + .last_error_at = null, + .last_error_buf = @splat(0), + .last_error_len = 0, + .backoff_until = null, + .window = 0, + .window_filled = 0, + }; + + pub fn recordSuccess(self: *State, at: std.Io.Timestamp) void { + if (self.isNewestOutcome(at)) { + self.consecutive_failures = 0; + self.backoff_until = null; + } + self.total_successes += 1; + self.push(1); + self.last_success_at = later(self.last_success_at, at); + } + + /// True when no recorded outcome is newer than `at`. Both timestamp fields + /// update through `@max`, so their maximum is the newest outcome the state + /// has seen and no separate field is needed. + fn isNewestOutcome(self: *const State, at: std.Io.Timestamp) bool { + return !newerThan(self.last_success_at, at) and !newerThan(self.last_error_at, at); + } + + /// `err_name` is `@errorName` of a PeerFault member. `rand` supplies + /// jitter; the caller owns the RNG so this stays pure and the test is + /// deterministic. + /// + /// A stale failure, that is one whose `at` is older than an outcome already + /// recorded, is held to the mirror image of the stale-success rule. Three + /// cases, decided on the state *before* this call updates it: + /// + /// 1. A newer success exists. The endpoint answered after this failure, + /// so this failure cannot make it "consecutively failing" now: leave + /// `consecutive_failures` and `backoff_until` alone. A run of failures + /// that a later success ended is over. + /// 2. No newer success, but a newer failure exists. The run of failures + /// is unbroken and this call is part of it, so the count and the + /// backoff update as usual; order inside a run does not matter. The + /// newer failure already wrote `last_error_buf`, so its text stays. + /// 3. `at` is the newest outcome. Everything updates. + /// + /// `backoff_until` only ever extends through `@max`, so case 2 can lengthen + /// a backoff but never shorten one. + pub fn recordFailure( + self: *State, + at: std.Io.Timestamp, + err_name: []const u8, + cfg: Config, + rand: u32, + ) void { + const newer_success = newerThan(self.last_success_at, at); + const newer_failure = newerThan(self.last_error_at, at); + + if (!newer_failure) { + const copied = @min(err_name.len, self.last_error_buf.len); + @memcpy(self.last_error_buf[0..copied], err_name[0..copied]); + self.last_error_len = @intCast(copied); + } + + self.total_failures += 1; + self.push(0); + self.last_error_at = later(self.last_error_at, at); + + if (newer_success) return; + + self.consecutive_failures +|= 1; + if (self.consecutive_failures < cfg.failure_threshold) return; + + const delay_ms = backoffDelayMs(self.consecutive_failures, cfg); + const half = delay_ms / 2; + const jittered = half + rand % (half + 1); + const deadline: std.Io.Timestamp = .{ + .nanoseconds = at.nanoseconds + @as(i96, jittered) * std.time.ns_per_ms, + }; + self.backoff_until = later(self.backoff_until, deadline); + } + + pub fn available(self: *const State, now: std.Io.Timestamp) bool { + const until = self.backoff_until orelse return true; + return now.nanoseconds > until.nanoseconds; + } + + /// Over the filled part of the window; 1.0 when the window is empty, so a + /// fresh endpoint is not reported as failing. + pub fn successRate(self: *const State) f32 { + if (self.window_filled == 0) return 1.0; + const filled: u6 = @intCast(@min(self.window_filled, window_len)); + const mask: u32 = if (filled == window_len) + std.math.maxInt(u32) + else + (@as(u32, 1) << @intCast(filled)) - 1; + const successes = @popCount(self.window & mask); + return @as(f32, @floatFromInt(successes)) / @as(f32, @floatFromInt(filled)); + } + + pub fn lastError(self: *const State) []const u8 { + return self.last_error_buf[0..self.last_error_len]; + } + + fn push(self: *State, bit: u1) void { + self.window = (self.window << 1) | bit; + self.window_filled = @min(self.window_filled + 1, window_len); + } +}; + +/// True when `existing` is set and strictly newer than `at`. Equal timestamps +/// are not stale: a call at the timestamp of the newest outcome still counts as +/// current. +fn newerThan(existing: ?std.Io.Timestamp, at: std.Io.Timestamp) bool { + const previous = existing orelse return false; + return previous.nanoseconds > at.nanoseconds; +} + +fn later(existing: ?std.Io.Timestamp, candidate: std.Io.Timestamp) std.Io.Timestamp { + const previous = existing orelse return candidate; + return .{ .nanoseconds = @max(previous.nanoseconds, candidate.nanoseconds) }; +} + +/// `min(max_backoff_ms, base_backoff_ms << shift)` with +/// `shift = min(consecutive_failures - failure_threshold, max_shift)`. The +/// arithmetic runs in u64 so a large `base_backoff_ms` cannot wrap before the +/// clamp applies. +fn backoffDelayMs(consecutive_failures: u32, cfg: Config) u32 { + const over = consecutive_failures - cfg.failure_threshold; + const shift: u6 = @intCast(@min(over, max_shift)); + const shifted = @as(u64, cfg.base_backoff_ms) << shift; + return @intCast(@min(@as(u64, cfg.max_backoff_ms), shifted)); +} + +const testing = std.testing; + +fn ts(nanoseconds: i96) std.Io.Timestamp { + return .{ .nanoseconds = nanoseconds }; +} + +fn ms(count: i96) i96 { + return count * std.time.ns_per_ms; +} + +test "a failure below the threshold leaves the endpoint available" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(0), "Timeout", cfg, 0); + + try testing.expectEqual(@as(u32, 1), state.consecutive_failures); + try testing.expectEqual(@as(u64, 1), state.total_failures); + try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until); + try testing.expect(state.available(ts(0))); +} + +test "reaching the threshold puts the endpoint in backoff until the deadline" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(0), "Timeout", cfg, 0); + state.recordFailure(ts(0), "Timeout", cfg, 0); + + // Two failures, threshold 2, shift 0: delay 500 ms, jitter 0 => 250 ms. + const until = state.backoff_until.?; + try testing.expectEqual(ms(250), until.nanoseconds); + try testing.expect(!state.available(ts(0))); + try testing.expect(!state.available(until)); + try testing.expect(state.available(ts(until.nanoseconds + 1))); +} + +test "consecutive failures grow the delay and saturate at max_backoff_ms" { + const cfg: Config = .{}; + var state: State = .init; + + var previous: i96 = -1; + var i: usize = 0; + while (i < 40) : (i += 1) { + state.recordFailure(ts(0), "Timeout", cfg, 0); + if (state.backoff_until) |until| { + try testing.expect(until.nanoseconds >= previous); + previous = until.nanoseconds; + } + } + + // Jitter 0 halves the delay, and the delay itself is clamped. + try testing.expectEqual(ms(cfg.max_backoff_ms / 2), state.backoff_until.?.nanoseconds); + try testing.expectEqual(@as(u32, 500), backoffDelayMs(2, cfg)); + try testing.expectEqual(@as(u32, 1000), backoffDelayMs(3, cfg)); + try testing.expectEqual(@as(u32, 2000), backoffDelayMs(4, cfg)); + try testing.expectEqual(cfg.max_backoff_ms, backoffDelayMs(40, cfg)); +} + +test "a success resets the consecutive count, the window and the backoff" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(0), "Timeout", cfg, 0); + state.recordFailure(ts(0), "Timeout", cfg, 0); + try testing.expect(state.backoff_until != null); + + state.recordSuccess(ts(ms(1))); + try testing.expectEqual(@as(u32, 0), state.consecutive_failures); + try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until); + try testing.expectEqual(@as(u64, 1), state.total_successes); + try testing.expectEqual(@as(u32, 1), state.window & 1); + try testing.expect(state.available(ts(0))); +} + +test "jitter stays inside half the delay and the whole delay" { + const cfg: Config = .{}; + const delay = backoffDelayMs(2, cfg); + + for ([_]u32{ 0, std.math.maxInt(u32), 1, 12345 }) |rand| { + var state: State = .init; + state.recordFailure(ts(0), "Timeout", cfg, rand); + state.recordFailure(ts(0), "Timeout", cfg, rand); + const offset = state.backoff_until.?.nanoseconds; + try testing.expect(offset >= ms(delay / 2)); + try testing.expect(offset <= ms(delay)); + } +} + +test "an out-of-order success does not move last_success_at backwards" { + var state: State = .init; + state.recordSuccess(ts(100)); + state.recordSuccess(ts(50)); + try testing.expectEqual(@as(i96, 100), state.last_success_at.?.nanoseconds); +} + +test "an out-of-order failure does not shorten the backoff" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + const until = state.backoff_until.?.nanoseconds; + + state.recordFailure(ts(ms(50)), "Timeout", cfg, 0); + try testing.expect(state.backoff_until.?.nanoseconds >= until); + try testing.expectEqual(@as(i96, ms(100)), state.last_error_at.?.nanoseconds); +} + +test "an out-of-order success does not clear the backoff of a newer failure" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + const until = state.backoff_until.?.nanoseconds; + + state.recordSuccess(ts(ms(50))); + try testing.expectEqual(@as(i96, until), state.backoff_until.?.nanoseconds); + try testing.expectEqual(@as(u32, 2), state.consecutive_failures); + try testing.expectEqual(@as(u64, 1), state.total_successes); + try testing.expectEqual(@as(u32, 1), state.window & 1); + try testing.expectEqual(@as(i96, ms(50)), state.last_success_at.?.nanoseconds); +} + +test "a stale failure behind a newer success does not raise the consecutive count" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(10)), "Timeout", cfg, 0); + state.recordSuccess(ts(ms(100))); + state.recordFailure(ts(ms(20)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(30)), "Timeout", cfg, 0); + + try testing.expectEqual(@as(u32, 0), state.consecutive_failures); + try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until); + try testing.expect(state.available(ts(ms(31)))); +} + +test "a stale failure behind a newer success still counts into the totals" { + const cfg: Config = .{}; + var state: State = .init; + state.recordSuccess(ts(ms(100))); + state.recordFailure(ts(ms(20)), "Timeout", cfg, 0); + + try testing.expectEqual(@as(u64, 1), state.total_failures); + try testing.expectEqual(@as(u8, 2), state.window_filled); + try testing.expectEqual(@as(u32, 0), state.window & 1); + try testing.expectEqual(@as(i96, ms(20)), state.last_error_at.?.nanoseconds); + try testing.expectEqualStrings("Timeout", state.lastError()); +} + +test "a stale failure with no newer success still counts as consecutive" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(50)), "Timeout", cfg, 0); + + try testing.expectEqual(@as(u32, 2), state.consecutive_failures); + try testing.expect(state.backoff_until != null); + try testing.expectEqual(@as(i96, ms(100)), state.last_error_at.?.nanoseconds); +} + +test "a stale failure does not overwrite the error of a newer failure" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(50)), "ConnectFailed", cfg, 0); + + try testing.expectEqualStrings("Timeout", state.lastError()); +} + +test "the newest failure records its own error and extends the backoff" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(50)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(100)), "ConnectFailed", cfg, 0); + + try testing.expectEqualStrings("ConnectFailed", state.lastError()); + try testing.expectEqual(@as(u32, 2), state.consecutive_failures); + try testing.expectEqual(ms(100) + ms(250), state.backoff_until.?.nanoseconds); +} + +test "the newest success clears the backoff of an older failure" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + try testing.expect(state.backoff_until != null); + + state.recordSuccess(ts(ms(101))); + try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until); + try testing.expectEqual(@as(u32, 0), state.consecutive_failures); +} + +test "a success at the timestamp of the newest failure clears the backoff" { + const cfg: Config = .{}; + var state: State = .init; + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + state.recordFailure(ts(ms(100)), "Timeout", cfg, 0); + + state.recordSuccess(ts(ms(100))); + try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until); + try testing.expectEqual(@as(u32, 0), state.consecutive_failures); +} + +test "successRate over a half-success window is 0.5" { + const cfg: Config = .{}; + var state: State = .init; + try testing.expectEqual(@as(f32, 1.0), state.successRate()); + + var i: usize = 0; + while (i < 4) : (i += 1) { + state.recordSuccess(ts(0)); + state.recordFailure(ts(0), "Timeout", cfg, 0); + } + try testing.expectEqual(@as(u8, 8), state.window_filled); + try testing.expectEqual(@as(f32, 0.5), state.successRate()); +} + +test "successRate counts only the filled part of the window" { + var state: State = .init; + state.recordSuccess(ts(0)); + try testing.expectEqual(@as(f32, 1.0), state.successRate()); + + var i: usize = 0; + while (i < window_len * 2) : (i += 1) state.recordSuccess(ts(0)); + try testing.expectEqual(@as(u8, window_len), state.window_filled); + try testing.expectEqual(@as(f32, 1.0), state.successRate()); +} + +test "lastError returns the last recorded name, truncated not overflowed" { + const cfg: Config = .{}; + var state: State = .init; + try testing.expectEqualStrings("", state.lastError()); + + state.recordFailure(ts(0), "ConnectFailed", cfg, 0); + try testing.expectEqualStrings("ConnectFailed", state.lastError()); + + state.recordFailure(ts(0), "Timeout", cfg, 0); + try testing.expectEqualStrings("Timeout", state.lastError()); + + const long = "A" ** 200; + state.recordFailure(ts(0), long, cfg, 0); + try testing.expectEqual(@as(usize, 48), state.lastError().len); + try testing.expectEqualStrings(long[0..48], state.lastError()); +} diff --git a/src/upstream/pool.zig b/src/upstream/pool.zig new file mode 100644 index 0000000..ec863de --- /dev/null +++ b/src/upstream/pool.zig @@ -0,0 +1,723 @@ +//! Priority-ordered sequential failover across upstream endpoints, with a +//! per-attempt deadline, 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 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 +//! for every client. A probe is better than a guaranteed failure, and the probe +//! is how backoff recovers. +//! +//! Concurrency (PLAN §9, spec S4.2): several handler tasks share one `Pool`, +//! and they hold two different locks at two different scopes. +//! +//! `Pool.mutex` guards health mutation, the jitter RNG and `snapshot`. It is +//! never held across an exchange, so a slow upstream cannot block a health read +//! or an attempt on some other entry. +//! +//! `Entry.busy` guards one entry's `client`. A task holds it for a whole +//! attempt against that entry — the exchange and the timeout race around it — +//! and drops it before the failover loop moves to the next candidate. This is +//! what makes the pool safe to share: `DohClient` owns a request buffer and a +//! transfer buffer, `DotClient` owns four TLS buffers and the stream state +//! built on them, so one client may be inside `exchange` only once at a time. +//! +//! Because that wait is serializing, pass one re-reads health after it takes +//! `busy`: the attempts a task queued behind can fail the entry into backoff +//! while it waits, and pass one must not attempt an entry that is unavailable by +//! the time it gets its turn. Lock order is always `busy` then `Pool.mutex`, +//! never the reverse. +//! +//! The guarantee is therefore: at most one in-flight exchange per entry, any +//! number of entries in flight at once, and bookkeeping that never waits on a +//! peer. Two concurrent queries that resolve to the same sole upstream do +//! serialize. At household scale that is the right trade — the alternative is a +//! client instance per listener task, which multiplies TLS buffers and +//! connections for a query rate that never needed them. + +const std = @import("std"); + +const health = @import("health.zig"); +const transport = @import("transport.zig"); + +const log = std.log.scoped(.upstream); + +pub const Entry = struct { + endpoint: transport.Endpoint, + client: transport.Client, + /// Lower is tried first (PLAN §11.2 `upstreams.priority`). + priority: i32, + enabled: bool, + health: health.State, + /// Held for the whole of one attempt against this entry, so `client` is + /// never re-entered while it is using its own buffers. Defaulted because + /// `init` sorts `entries` by value, which may only copy unlocked mutexes, + /// and it runs before the pool is reachable by any task. + busy: std.Io.Mutex = .init, +}; + +/// A copy of one entry's health, taken under the mutex. Feeds +/// `GET /api/upstream/health` in Phase 8. +pub const Snapshot = struct { + url: []const u8, + enabled: bool, + available: bool, + consecutive_failures: u32, + total_successes: u64, + total_failures: u64, + success_rate: f32, + last_success_at: ?std.Io.Timestamp, + last_error_at: ?std.Io.Timestamp, + /// Borrowed from the entry; valid until that entry's next failure. + last_error: []const u8, + backoff_until: ?std.Io.Timestamp, +}; + +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, + mutex: std.Io.Mutex, + rng: std.Random.DefaultPrng, + + pub fn init( + entries: []Entry, + cfg: health.Config, + attempt_timeout: std.Io.Clock.Duration, + seed: u64, + ) Pool { + std.debug.assert(entries.len > 0); + // Stable, so entries sharing a priority keep their configured order. + std.mem.sort(Entry, entries, {}, byPriority); + return .{ + .entries = entries, + .cfg = cfg, + .attempt_timeout = attempt_timeout, + .mutex = .init, + .rng = .init(seed), + }; + } + + fn byPriority(_: void, a: Entry, b: Entry) bool { + return a.priority < b.priority; + } + + pub fn client(self: *Pool) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeErased }; + } + + fn exchangeErased( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + const self: *Pool = @ptrCast(@alignCast(ptr)); + return self.exchange(io, query, response_buf); + } + + /// `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. + pub fn exchange( + self: *Pool, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + const now = std.Io.Clock.awake.now(io); + var last_fault: ?transport.ExchangeError = null; + var attempted = false; + + var pass: u8 = 0; + while (pass < 2) : (pass += 1) { + for (self.entries) |*entry| { + if (!entry.enabled) continue; + if (pass == 0 and !self.entryAvailable(io, entry, now)) continue; + + // Cancelable, unlike the health-bookkeeping locks below: a task + // 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. + try entry.busy.lock(io); + defer entry.busy.unlock(io); + + // The check above ran before the wait, and the attempts this one + // queued behind may have failed the entry into backoff while it + // waited. Pass one must not touch an entry that is unavailable + // now, so re-read health against a fresh `now` and move on if it + // is. Pass two skips this on purpose: it probes regardless of + // backoff, which is how backoff recovers. + if (pass == 0) { + const recheck = std.Io.Clock.awake.now(io); + if (!self.entryAvailable(io, entry, recheck)) continue; + } + attempted = true; + + const result = self.attempt(io, entry.client, query, response_buf); + const completed_at = std.Io.Clock.awake.now(io); + + const response = result catch |err| switch (transport.group(err)) { + .peer_fault => { + log.debug("upstream {s} failed: {t}", .{ entry.endpoint.url, err }); + self.recordFailure(io, entry, completed_at, err); + last_fault = err; + continue; + }, + // The next upstream would hit the same wall, and this says + // nothing about any peer, so it is never recorded. + .local_resource => return err, + .cancellation => return error.Canceled, + }; + + self.recordSuccess(io, entry, completed_at); + return response; + } + if (attempted) break; + } + + // Guaranteed non-null whenever any entry is enabled: pass two attempts + // every enabled entry regardless of backoff. + if (last_fault) |err| return err; + return error.ConnectFailed; + } + + /// Copies health into `out` in pool order; returns the number written. + pub fn snapshot(self: *Pool, io: std.Io, out: []Snapshot) std.Io.Cancelable!usize { + const now = std.Io.Clock.awake.now(io); + try self.mutex.lock(io); + defer self.mutex.unlock(io); + + const count = @min(self.entries.len, out.len); + for (self.entries[0..count], out[0..count]) |*entry, *slot| { + slot.* = .{ + .url = entry.endpoint.url, + .enabled = entry.enabled, + .available = entry.enabled and entry.health.available(now), + .consecutive_failures = entry.health.consecutive_failures, + .total_successes = entry.health.total_successes, + .total_failures = entry.health.total_failures, + .success_rate = entry.health.successRate(), + .last_success_at = entry.health.last_success_at, + .last_error_at = entry.health.last_error_at, + .last_error = entry.health.lastError(), + .backoff_until = entry.health.backoff_until, + }; + } + return count; + } + + /// One exchange raced against the per-attempt budget. No stream read or + /// write in 0.16.0 takes a timeout, so the budget is a second task and the + /// loser is canceled. + fn attempt( + self: *Pool, + io: std.Io, + entry_client: transport.Client, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + var outcomes: [2]Outcome = undefined; + var race: std.Io.Select(Outcome) = .init(io, &outcomes); + defer race.cancelDiscard(); + + race.concurrent(.exchange, transport.Client.exchange, .{ + entry_client, io, query, response_buf, + }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SystemResources, + }; + race.concurrent(.expiry, expire, .{ io, self.attempt_timeout }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SystemResources, + }; + + switch (try race.await()) { + .exchange => |result| return result, + .expiry => |result| { + // A canceled sleep means this whole task is being torn down, + // not that the upstream is slow. + try result; + return error.Timeout; + }, + } + } + + fn entryAvailable( + self: *Pool, + io: std.Io, + entry: *const Entry, + now: std.Io.Timestamp, + ) bool { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + return entry.health.available(now); + } + + fn recordSuccess(self: *Pool, io: std.Io, entry: *Entry, at: std.Io.Timestamp) void { + // Uncancelable: this section takes no Io and never blocks on a peer. + // Losing the bookkeeping for a completed exchange to a cancellation + // that arrives one instruction later would corrupt health for good. + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + entry.health.recordSuccess(at); + } + + fn recordFailure( + self: *Pool, + io: std.Io, + entry: *Entry, + at: std.Io.Timestamp, + err: transport.ExchangeError, + ) void { + self.mutex.lockUncancelable(io); + defer self.mutex.unlock(io); + entry.health.recordFailure(at, @errorName(err), self.cfg, self.rng.random().int(u32)); + } +}; + +const Outcome = union(enum) { + exchange: transport.ExchangeError![]u8, + expiry: std.Io.Cancelable!void, +}; + +fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void { + return duration.sleep(io); +} + +const testing = std.testing; + +/// A query for example.com A: id 0x1234, RD set, one question. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// The matching response: the question echoed plus one A record. +const response_bytes = + "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + +/// Stands in for a DoH or DoT client. Every behaviour the pool has to react to +/// is one variant, and every call is counted so a test can assert that an entry +/// in backoff was not touched. +const Fake = struct { + behavior: Behavior, + calls: usize = 0, + in_flight: std.atomic.Value(u32) = .init(0), + /// The most tasks ever inside `exchangeFn` at once. The per-entry lock is + /// only doing its job while this stays at 1. + peak_in_flight: std.atomic.Value(u32) = .init(0), + + const Behavior = union(enum) { + /// Copy these bytes into the caller's buffer and return them. + reply: []const u8, + /// Fail with this error. + fail: transport.ExchangeError, + /// Sleep, then reply. Used to outrun the pool's attempt budget. + slow: struct { duration: std.Io.Clock.Duration, reply: []const u8 }, + /// Sleep, then fail. Holds the entry's lock long enough for a second + /// task to queue on it before the failure opens a backoff window. + slow_fail: struct { duration: std.Io.Clock.Duration, err: transport.ExchangeError }, + }; + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) transport.ExchangeError![]u8 { + _ = query; + const self: *Fake = @ptrCast(@alignCast(ptr)); + const entrants = self.in_flight.fetchAdd(1, .acq_rel) + 1; + defer _ = self.in_flight.fetchSub(1, .acq_rel); + _ = self.peak_in_flight.fetchMax(entrants, .acq_rel); + + self.calls += 1; + switch (self.behavior) { + .reply => |bytes| return copy(bytes, response_buf), + .fail => |err| return err, + .slow => |slow| { + try slow.duration.sleep(io); + return copy(slow.reply, response_buf); + }, + .slow_fail => |slow| { + try slow.duration.sleep(io); + return slow.err; + }, + } + } + + fn copy(bytes: []const u8, response_buf: []u8) transport.ExchangeError![]u8 { + if (bytes.len > response_buf.len) return error.ResponseTooLarge; + @memcpy(response_buf[0..bytes.len], bytes); + return response_buf[0..bytes.len]; + } + + fn client(self: *Fake) transport.Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } +}; + +fn testEntry(url: []const u8, fake: *Fake, priority: i32) Entry { + return .{ + .endpoint = Endpoint.parse(url) catch unreachable, + .client = fake.client(), + .priority = priority, + .enabled = true, + .health = .init, + }; +} + +const Endpoint = transport.Endpoint; + +/// Long enough that no test can outlive a backoff it just set, and short enough +/// that nothing waits on it. +const test_cfg: health.Config = .{ + .failure_threshold = 2, + .base_backoff_ms = 60_000, + .max_backoff_ms = 60_000, +}; + +const test_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }; + +test "Pool satisfies the Client interface" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + 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 buf: [512]u8 = undefined; + const reply = try pool.client().exchange(io, query_bytes, &buf); + try testing.expectEqualSlices(u8, response_bytes, reply); +} + +test "entries are tried in ascending priority order" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var low: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var high: Fake = .{ .behavior = .{ .reply = response_bytes } }; + // Registered out of order: priority, not position, decides. + var entries = [_]Entry{ + 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); + + try testing.expectEqual(@as(i32, 10), entries[0].priority); + + var buf: [512]u8 = undefined; + _ = try pool.exchange(io, query_bytes, &buf); + try testing.expectEqual(@as(usize, 1), low.calls); + try testing.expectEqual(@as(usize, 0), high.calls); +} + +test "a peer fault fails over to the next entry and is recorded" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } }; + var good: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + 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 buf: [512]u8 = undefined; + const reply = try pool.exchange(io, query_bytes, &buf); + try testing.expectEqualSlices(u8, response_bytes, reply); + + try testing.expectEqual(@as(u32, 1), entries[0].health.consecutive_failures); + try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures); + try testing.expectEqualStrings("Timeout", entries[0].health.lastError()); + try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes); +} + +test "an entry in backoff is skipped while another is available" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var bad: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } }; + var good: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + 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 buf: [512]u8 = undefined; + // Two failures reach `failure_threshold` and open a backoff window. + _ = try pool.exchange(io, query_bytes, &buf); + _ = try pool.exchange(io, query_bytes, &buf); + try testing.expectEqual(@as(usize, 2), bad.calls); + try testing.expect(entries[0].health.backoff_until != null); + + _ = try pool.exchange(io, query_bytes, &buf); + try testing.expectEqual(@as(usize, 2), bad.calls); + try testing.expectEqual(@as(usize, 3), good.calls); +} + +test "every entry in backoff is still probed" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var first: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } }; + var second: Fake = .{ .behavior = .{ .fail = error.BadResponse } }; + 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, test_timeout, 1); + + var buf: [512]u8 = undefined; + try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf)); + try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf)); + try testing.expect(entries[0].health.backoff_until != null); + try testing.expect(entries[1].health.backoff_until != null); + + // Pass one now has no candidate at all. Pass two probes both anyway. + try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf)); + try testing.expectEqual(@as(usize, 3), first.calls); + try testing.expectEqual(@as(usize, 3), second.calls); +} + +test "a local resource error short-circuits and records nothing" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var broke: Fake = .{ .behavior = .{ .fail = error.OutOfMemory } }; + var good: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + 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 buf: [512]u8 = undefined; + try testing.expectError(error.OutOfMemory, pool.exchange(io, query_bytes, &buf)); + try testing.expectEqual(@as(usize, 0), good.calls); + try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures); + try testing.expectEqual(@as(u32, 0), entries[0].health.consecutive_failures); +} + +test "a cancellation short-circuits and records nothing" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var canceled: Fake = .{ .behavior = .{ .fail = error.Canceled } }; + var good: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + 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 buf: [512]u8 = undefined; + try testing.expectError(error.Canceled, pool.exchange(io, query_bytes, &buf)); + try testing.expectEqual(@as(usize, 0), good.calls); + try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures); +} + +test "an attempt that outruns the budget is a recorded Timeout" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var slow: Fake = .{ .behavior = .{ .slow = .{ + .duration = .{ .raw = .fromSeconds(30), .clock = .awake }, + .reply = response_bytes, + } } }; + var good: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + 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); + + var buf: [512]u8 = undefined; + const reply = try pool.exchange(io, query_bytes, &buf); + try testing.expectEqualSlices(u8, response_bytes, reply); + + try testing.expectEqual(@as(usize, 1), slow.calls); + try testing.expectEqual(@as(u32, 1), entries[0].health.consecutive_failures); + try testing.expectEqualStrings("Timeout", entries[0].health.lastError()); + try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes); +} + +test "every entry disabled yields ConnectFailed" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var one: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var two: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + testEntry("https://one.example/dns-query", &one, 10), + testEntry("https://two.example/dns-query", &two, 20), + }; + for (&entries) |*entry| entry.enabled = false; + var pool: Pool = .init(&entries, test_cfg, test_timeout, 1); + + var buf: [512]u8 = undefined; + try testing.expectError(error.ConnectFailed, pool.exchange(io, query_bytes, &buf)); + try testing.expectEqual(@as(usize, 0), one.calls); + try testing.expectEqual(@as(usize, 0), two.calls); +} + +test "snapshot reports the counters in pool order" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } }; + var good: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + 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 buf: [512]u8 = undefined; + _ = try pool.exchange(io, query_bytes, &buf); + _ = try pool.exchange(io, query_bytes, &buf); + + var out: [4]Snapshot = undefined; + const written = try pool.snapshot(io, &out); + try testing.expectEqual(@as(usize, 2), written); + + try testing.expectEqualStrings("https://bad.example/dns-query", out[0].url); + try testing.expect(out[0].enabled); + try testing.expect(!out[0].available); + try testing.expectEqual(@as(u32, 2), out[0].consecutive_failures); + try testing.expectEqual(@as(u64, 2), out[0].total_failures); + try testing.expectEqual(@as(u64, 0), out[0].total_successes); + try testing.expectEqual(@as(f32, 0.0), out[0].success_rate); + try testing.expectEqualStrings("Timeout", out[0].last_error); + try testing.expect(out[0].last_error_at != null); + try testing.expect(out[0].backoff_until != null); + + try testing.expectEqualStrings("https://good.example/dns-query", out[1].url); + try testing.expect(out[1].available); + try testing.expectEqual(@as(u64, 2), out[1].total_successes); + try testing.expectEqual(@as(f32, 1.0), out[1].success_rate); + try testing.expect(out[1].last_success_at != null); + try testing.expectEqual(@as(?std.Io.Timestamp, null), out[1].backoff_until); + + // A short `out` truncates rather than overflowing. + var one: [1]Snapshot = undefined; + try testing.expectEqual(@as(usize, 1), try pool.snapshot(io, &one)); +} + +/// `Io.concurrent` stores the return value in the future, so the reply slice is +/// reduced to its length here and the bytes are read back out of the caller's +/// buffer. Returning a `usize` also lets the test discard a result with +/// `catch 0`. +fn exchangeLen(pool: *Pool, io: std.Io, buf: []u8) transport.ExchangeError!usize { + const reply = try pool.exchange(io, query_bytes, buf); + return reply.len; +} + +test "concurrent exchanges through one entry do not overlap" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + // Slow enough that an unserialized second task would still be inside the + // fake when the first one is, and short enough to stay far under the + // 10-second attempt budget even when the two run back to back. + var fake: Fake = .{ .behavior = .{ .slow = .{ + .duration = .{ .raw = .fromMilliseconds(50), .clock = .awake }, + .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 buf_a: [512]u8 = undefined; + var buf_b: [512]u8 = undefined; + + var first = io.concurrent(exchangeLen, .{ &pool, io, &buf_a }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SkipZigTest, + }; + defer _ = first.await(io) catch 0; + var second = io.concurrent(exchangeLen, .{ &pool, io, &buf_b }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SkipZigTest, + }; + defer _ = second.await(io) catch 0; + + const len_a = try first.await(io); + const len_b = try second.await(io); + + try testing.expectEqualSlices(u8, response_bytes, buf_a[0..len_a]); + try testing.expectEqualSlices(u8, response_bytes, buf_b[0..len_b]); + try testing.expectEqual(@as(usize, 2), fake.calls); + try testing.expectEqual(@as(u32, 1), fake.peak_in_flight.load(.acquire)); + try testing.expectEqual(@as(u64, 2), entries[0].health.total_successes); +} + +test "an entry that enters backoff while a task waits on it is not attempted" { + var threaded: std.Io.Threaded = .init(testing.allocator, .{}); + defer threaded.deinit(); + const io = threaded.io(); + + // One failure is enough to open the backoff window, so the first task's + // failure makes the entry unavailable to the second task, which by then is + // already past its own availability check and waiting on `busy`. + const cfg: health.Config = .{ + .failure_threshold = 1, + .base_backoff_ms = 60_000, + .max_backoff_ms = 60_000, + }; + + var slow_bad: Fake = .{ .behavior = .{ .slow_fail = .{ + .duration = .{ .raw = .fromMilliseconds(50), .clock = .awake }, + .err = error.ConnectFailed, + } } }; + var good: Fake = .{ .behavior = .{ .reply = response_bytes } }; + var entries = [_]Entry{ + 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 buf_a: [512]u8 = undefined; + var buf_b: [512]u8 = undefined; + + var first = io.concurrent(exchangeLen, .{ &pool, io, &buf_a }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SkipZigTest, + }; + defer _ = first.await(io) catch 0; + var second = io.concurrent(exchangeLen, .{ &pool, io, &buf_b }) catch |err| switch (err) { + error.ConcurrencyUnavailable => return error.SkipZigTest, + }; + defer _ = second.await(io) catch 0; + + const len_a = try first.await(io); + const len_b = try second.await(io); + + // Both tasks fail over to the healthy entry and get an answer. + try testing.expectEqualSlices(u8, response_bytes, buf_a[0..len_a]); + try testing.expectEqualSlices(u8, response_bytes, buf_b[0..len_b]); + try testing.expectEqual(@as(usize, 2), good.calls); + + // The point of the test: the entry was attempted once, not twice. Without + // the re-check the second task would take the lock and attempt it anyway. + try testing.expectEqual(@as(usize, 1), slow_bad.calls); + try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures); + try testing.expect(entries[0].health.backoff_until != null); +} diff --git a/src/upstream/transport.zig b/src/upstream/transport.zig new file mode 100644 index 0000000..258b46c --- /dev/null +++ b/src/upstream/transport.zig @@ -0,0 +1,497 @@ +//! Shared vocabulary for every upstream client: endpoint URLs, the three +//! disjoint failure groups, the `Client` interface, and response validation. +//! +//! Everything here except the `Client` vtable is pure. `validateResponse` takes +//! two byte slices and returns; `Endpoint.parse` takes text. No socket, no +//! clock, no allocator. The transport implementations (DoH, DoT) and the pool +//! own all of the `std.Io` work. +//! +//! The failure classification is the reason this file exists. Health and +//! backoff must count only what the peer did wrong: a local `OutOfMemory` says +//! nothing about the upstream, and `error.Canceled` says nothing at all. The +//! three error sets below are disjoint by construction and `group` switches +//! over them exhaustively, so a new failure mode cannot silently land in the +//! wrong bucket. + +const std = @import("std"); +const packet = @import("../dns/packet.zig"); +const name = @import("../dns/name.zig"); + +/// RFC 1035 §4.2.2: the TCP length prefix is 16-bit, so no DNS message can be +/// larger than this on any transport nxdns speaks. +pub const max_message_len = 65535; +pub const doh_default_port = 443; +pub const dot_default_port = 853; // RFC 7858 §3.1 +pub const doh_default_path = "/dns-query"; // RFC 8484 §4.1 well-known template + +pub const Scheme = enum { doh, dot }; + +/// Borrowed view over the configured URL text; the caller owns the string. +pub const Endpoint = struct { + scheme: Scheme, + /// The original text, for logs and the health API. + url: []const u8, + /// No brackets, no port. Used for SNI and certificate verification. + host: []const u8, + port: u16, + /// DoH only; always starts with '/'; `doh_default_path` when absent. A DoT + /// endpoint has no request path, so it carries "/" and nothing reads it. + path: []const u8, + + pub const ParseError = error{ UnsupportedScheme, MissingHost, BadPort, BadUrl }; + + const doh_prefix = "https://"; + const dot_prefix = "tls://"; + + /// `https://…` => .doh, `tls://…` => .dot (PLAN §9). Accepts `[v6]:port`. + /// + /// Hand-written rather than a `std.Uri` round trip: `std.Uri` hands back + /// percent-encoded components that would need re-decoding into a caller + /// buffer, which is a lot of machinery for one household-scale config + /// value that is an IP literal or a hostname. + pub fn parse(url: []const u8) ParseError!Endpoint { + const scheme: Scheme, const rest = if (std.mem.startsWith(u8, url, doh_prefix)) + .{ .doh, url[doh_prefix.len..] } + else if (std.mem.startsWith(u8, url, dot_prefix)) + .{ .dot, url[dot_prefix.len..] } + else + return error.UnsupportedScheme; + + const authority, const path = split: { + const slash = std.mem.findScalar(u8, rest, '/') orelse break :split .{ rest, "" }; + break :split .{ rest[0..slash], rest[slash..] }; + }; + + try rejectDelimiters(authority, "@?#"); + try rejectDelimiters(path, "?#"); + + const host, const port_text = try splitAuthority(authority); + if (host.len == 0) return error.MissingHost; + + const port: u16 = if (port_text) |text| blk: { + if (text.len == 0) return error.BadPort; + break :blk std.fmt.parseInt(u16, text, 10) catch return error.BadPort; + } else switch (scheme) { + .doh => doh_default_port, + .dot => dot_default_port, + }; + + return switch (scheme) { + .doh => .{ + .scheme = .doh, + .url = url, + .host = host, + .port = port, + .path = if (path.len == 0) doh_default_path else path, + }, + .dot => blk: { + // RFC 7858 frames DNS directly on the TLS stream; a path would + // be config the transport cannot honour, so it is rejected + // rather than ignored. + if (path.len != 0 and !std.mem.eql(u8, path, "/")) return error.BadUrl; + break :blk .{ + .scheme = .dot, + .url = url, + .host = host, + .port = port, + .path = "/", + }; + }, + }; + } + + /// `@`, `?` and `#` open a userinfo, query or fragment component. This + /// parser implements none of them, so keeping one as literal host or path + /// text would let `host` disagree with the authority an RFC 3986 parser + /// reads out of the same URL — the name verified against the certificate + /// would not be the name dialed. A household config has no use for them, + /// so they are a config error rather than something to strip. + fn rejectDelimiters(text: []const u8, comptime delimiters: []const u8) ParseError!void { + inline for (delimiters) |delimiter| { + if (std.mem.findScalar(u8, text, delimiter) != null) return error.BadUrl; + } + } + + /// Returns the host without brackets and the port text when one is present. + fn splitAuthority(authority: []const u8) ParseError!struct { []const u8, ?[]const u8 } { + if (authority.len != 0 and authority[0] == '[') { + const close = std.mem.findScalar(u8, authority, ']') orelse return error.BadUrl; + const host = authority[1..close]; + const tail = authority[close + 1 ..]; + if (tail.len == 0) return .{ host, null }; + if (tail[0] != ':') return error.BadUrl; + return .{ host, tail[1..] }; + } + const colon = std.mem.findScalar(u8, authority, ':') orelse return .{ authority, null }; + return .{ authority[0..colon], authority[colon + 1 ..] }; + } +}; + +/// The upstream misbehaved, timed out, or was unreachable. Only these count +/// against health. +pub const PeerFault = error{ + ConnectFailed, + TlsFailed, + SendFailed, + ReceiveFailed, + Timeout, + /// Unparseable, not a response, or QDCOUNT != 1. + BadResponse, + /// ID or question does not match the query. + ResponseMismatch, + /// Does not fit the caller's buffer. + ResponseTooLarge, + /// DoH: status other than 200. + HttpStatus, + /// DoH: content-type other than application/dns-message. + HttpContentType, +}; + +/// This process ran out of something. Never the upstream's fault, so never +/// recorded against an endpoint's health. +pub const LocalResource = error{ + OutOfMemory, + SystemResources, + ProcessFdQuotaExceeded, + SystemFdQuotaExceeded, + /// A caller-supplied buffer cannot hold even a query. + BufferTooSmall, + Unexpected, +}; + +pub const Cancellation = error{Canceled}; + +pub const ExchangeError = PeerFault || LocalResource || Cancellation; + +pub const Group = enum { peer_fault, local_resource, cancellation }; + +/// Exhaustive switch over `ExchangeError` — no `else` arm. A new error member +/// must break the build here, so no failure can silently land in the wrong +/// group. +pub fn group(err: ExchangeError) Group { + return switch (err) { + error.ConnectFailed, + error.TlsFailed, + error.SendFailed, + error.ReceiveFailed, + error.Timeout, + error.BadResponse, + error.ResponseMismatch, + error.ResponseTooLarge, + error.HttpStatus, + error.HttpContentType, + => .peer_fault, + + error.OutOfMemory, + error.SystemResources, + error.ProcessFdQuotaExceeded, + error.SystemFdQuotaExceeded, + error.BufferTooSmall, + error.Unexpected, + => .local_resource, + + error.Canceled => .cancellation, + }; +} + +/// Folds a foreign stdlib error into `ExchangeError` when its name matches a +/// `LocalResource` or `Cancellation` member; `null` means the caller should +/// classify the error as a peer fault from the phase it occurred in. +/// +/// This is the only place a foreign error set is folded in. Everywhere else +/// the call site names the peer fault it means, because the call site is what +/// knows whether it was connecting, sending or receiving. +pub fn mapLocal(err: anyerror) ?ExchangeError { + return switch (err) { + error.OutOfMemory => error.OutOfMemory, + error.SystemResources => error.SystemResources, + error.ProcessFdQuotaExceeded => error.ProcessFdQuotaExceeded, + error.SystemFdQuotaExceeded => error.SystemFdQuotaExceeded, + error.BufferTooSmall => error.BufferTooSmall, + error.Unexpected => error.Unexpected, + error.Canceled => error.Canceled, + else => null, + }; +} + +/// A thing that sends one DNS message and returns one validated DNS message. +/// Implemented by DohClient, DotClient, Pool, and test fakes. +pub const Client = struct { + ptr: *anyopaque, + exchangeFn: *const fn ( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) ExchangeError![]u8, + + /// Returns a prefix of `response_buf`. The returned message has already + /// passed `validateResponse` against `query`. + pub fn exchange( + self: Client, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) ExchangeError![]u8 { + return self.exchangeFn(self.ptr, io, query, response_buf); + } +}; + +pub const ValidateError = error{ BadResponse, ResponseMismatch }; + +/// RFC 9619: exactly one question on both sides. RFC 4343: names compare +/// case-insensitively, so a case-mangling (0x20) upstream still matches. Does +/// not inspect the answer section — content policy is not this layer's +/// business. +pub fn validateResponse(query: []const u8, response: []const u8) ValidateError!void { + const resp = packet.parse(response) catch return error.BadResponse; + if (!resp.header.flags.qr) return error.BadResponse; + if (resp.header.qdcount != 1) return error.BadResponse; + + // A malformed query here is a bug in this process, not in the upstream. + // It still may not crash, so it reports the same structural error. + const req = packet.parse(query) catch return error.BadResponse; + if (req.header.qdcount != 1) return error.BadResponse; + + if (resp.header.id != req.header.id) return error.ResponseMismatch; + + const rq = packet.firstQuestion(resp) orelse return error.BadResponse; + const qq = packet.firstQuestion(req) orelse return error.BadResponse; + if (rq.qtype != qq.qtype) return error.ResponseMismatch; + if (rq.qclass != qq.qclass) return error.ResponseMismatch; + if (!name.eqlIgnoreCase(rq.name, qq.name)) return error.ResponseMismatch; +} + +const testing = std.testing; + +test "parse a DoH url with an explicit path" { + const e = try Endpoint.parse("https://cloudflare-dns.com/dns-query"); + try testing.expectEqual(Scheme.doh, e.scheme); + try testing.expectEqualStrings("cloudflare-dns.com", e.host); + try testing.expectEqual(@as(u16, 443), e.port); + try testing.expectEqualStrings("/dns-query", e.path); + try testing.expectEqualStrings("https://cloudflare-dns.com/dns-query", e.url); +} + +test "parse preserves a non-default DoH path" { + const e = try Endpoint.parse("https://dns.example/x"); + try testing.expectEqualStrings("dns.example", e.host); + try testing.expectEqualStrings("/x", e.path); +} + +test "parse defaults the DoH path" { + const e = try Endpoint.parse("https://dns.example"); + try testing.expectEqualStrings("dns.example", e.host); + try testing.expectEqual(@as(u16, 443), e.port); + try testing.expectEqualStrings(doh_default_path, e.path); +} + +test "parse a DoT url with an explicit port" { + const e = try Endpoint.parse("tls://dns.google:853"); + try testing.expectEqual(Scheme.dot, e.scheme); + try testing.expectEqualStrings("dns.google", e.host); + try testing.expectEqual(@as(u16, 853), e.port); +} + +test "parse defaults the DoT port" { + const e = try Endpoint.parse("tls://dns.google"); + try testing.expectEqual(Scheme.dot, e.scheme); + try testing.expectEqualStrings("dns.google", e.host); + try testing.expectEqual(@as(u16, dot_default_port), e.port); +} + +test "parse strips IPv6 brackets and keeps the port" { + const e = try Endpoint.parse("https://[2606:4700:4700::1111]:8443/dns-query"); + try testing.expectEqualStrings("2606:4700:4700::1111", e.host); + try testing.expectEqual(@as(u16, 8443), e.port); + try testing.expectEqualStrings("/dns-query", e.path); +} + +test "parse rejects an unsupported scheme" { + try testing.expectError(error.UnsupportedScheme, Endpoint.parse("udp://1.1.1.1:53")); + try testing.expectError(error.UnsupportedScheme, Endpoint.parse("http://dns.example/")); + try testing.expectError(error.UnsupportedScheme, Endpoint.parse("1.1.1.1")); +} + +test "parse rejects an empty host" { + try testing.expectError(error.MissingHost, Endpoint.parse("https://")); + try testing.expectError(error.MissingHost, Endpoint.parse("https://:443/dns-query")); + try testing.expectError(error.MissingHost, Endpoint.parse("tls://")); +} + +test "parse rejects a bad port" { + try testing.expectError(error.BadPort, Endpoint.parse("https://h:99999/")); + try testing.expectError(error.BadPort, Endpoint.parse("https://h:/")); + try testing.expectError(error.BadPort, Endpoint.parse("https://h:abc/")); + try testing.expectError(error.BadPort, Endpoint.parse("tls://[::1]:70000")); +} + +test "parse rejects a malformed url" { + try testing.expectError(error.BadUrl, Endpoint.parse("https://[::1")); + try testing.expectError(error.BadUrl, Endpoint.parse("https://[::1]x")); + // A DoT endpoint has no request path. + try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google/dns-query")); + // A bare trailing slash is not a path, so it is accepted. + try testing.expectEqual(Scheme.dot, (try Endpoint.parse("tls://dns.google/")).scheme); +} + +test "parse rejects userinfo" { + try testing.expectError(error.BadUrl, Endpoint.parse("https://user@host/")); + try testing.expectError(error.BadUrl, Endpoint.parse("https://user:pass@host/dns-query")); + try testing.expectError(error.BadUrl, Endpoint.parse("tls://user@dns.google:853")); +} + +test "parse rejects a query string" { + try testing.expectError(error.BadUrl, Endpoint.parse("https://host?x")); + try testing.expectError(error.BadUrl, Endpoint.parse("https://host/dns-query?x=1")); + try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google/?x")); +} + +test "parse rejects a fragment" { + try testing.expectError(error.BadUrl, Endpoint.parse("https://host#f")); + try testing.expectError(error.BadUrl, Endpoint.parse("https://host/dns-query#f")); + try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google#f")); +} + +test "the three error groups are disjoint" { + const sets = .{ PeerFault, LocalResource, Cancellation }; + inline for (sets, 0..) |a, i| { + inline for (sets, 0..) |b, j| { + if (i >= j) continue; + inline for (@typeInfo(a).error_set.?) |member_a| { + inline for (@typeInfo(b).error_set.?) |member_b| { + try testing.expect(!std.mem.eql(u8, member_a.name, member_b.name)); + } + } + } + } + + // Every member of the union belongs to exactly one group, which `group` + // proves by being an exhaustive switch. Assert the counts line up so a + // member added to two sets at once cannot pass unnoticed. + const total = @typeInfo(PeerFault).error_set.?.len + + @typeInfo(LocalResource).error_set.?.len + + @typeInfo(Cancellation).error_set.?.len; + try testing.expectEqual(total, @typeInfo(ExchangeError).error_set.?.len); +} + +test "group classifies each member" { + try testing.expectEqual(Group.peer_fault, group(error.Timeout)); + try testing.expectEqual(Group.peer_fault, group(error.HttpContentType)); + try testing.expectEqual(Group.local_resource, group(error.OutOfMemory)); + try testing.expectEqual(Group.local_resource, group(error.BufferTooSmall)); + try testing.expectEqual(Group.cancellation, group(error.Canceled)); +} + +test "mapLocal folds only local and cancellation errors" { + try testing.expectEqual(@as(?ExchangeError, error.OutOfMemory), mapLocal(error.OutOfMemory)); + try testing.expectEqual(@as(?ExchangeError, error.Canceled), mapLocal(error.Canceled)); + try testing.expectEqual(@as(?ExchangeError, error.Unexpected), mapLocal(error.Unexpected)); + try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.ConnectionRefused)); + try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.TlsInitializationFailed)); +} + +test "a fake client satisfies the Client interface" { + const Fake = struct { + calls: usize = 0, + + fn exchangeFn( + ptr: *anyopaque, + io: std.Io, + query: []const u8, + response_buf: []u8, + ) ExchangeError![]u8 { + _ = io; + const self: *@This() = @ptrCast(@alignCast(ptr)); + self.calls += 1; + if (query.len > response_buf.len) return error.ResponseTooLarge; + @memcpy(response_buf[0..query.len], query); + return response_buf[0..query.len]; + } + + fn client(self: *@This()) Client { + return .{ .ptr = self, .exchangeFn = exchangeFn }; + } + }; + + var fake: Fake = .{}; + var buf: [16]u8 = undefined; + const echoed = try fake.client().exchange(undefined, "hello", &buf); + try testing.expectEqualStrings("hello", echoed); + try testing.expectEqual(@as(usize, 1), fake.calls); +} + +/// A query for example.com A: id 0x1234, RD set, one question. +const query_bytes = + "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + +/// The matching response: the question echoed plus one A record. +const response_bytes = + "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + +test "validateResponse accepts a matching pair" { + try validateResponse(query_bytes, response_bytes); +} + +test "validateResponse accepts a mixed-case question name" { + const mangled = + "\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++ + "\x07ExAmPlE\x03CoM\x00\x00\x01\x00\x01" ++ + "\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22"; + try validateResponse(query_bytes, mangled); +} + +test "validateResponse rejects a wrong id" { + var bytes: [response_bytes.len]u8 = response_bytes.*; + packet.setId(&bytes, 0x4321); + try testing.expectError(error.ResponseMismatch, validateResponse(query_bytes, &bytes)); +} + +test "validateResponse rejects a different qtype" { + const aaaa = + "\x12\x34\x81\x80\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x1c\x00\x01"; + try testing.expectError(error.ResponseMismatch, validateResponse(query_bytes, aaaa)); +} + +test "validateResponse rejects a different question name" { + const other = + "\x12\x34\x81\x80\x00\x01\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03org\x00\x00\x01\x00\x01"; + try testing.expectError(error.ResponseMismatch, validateResponse(query_bytes, other)); +} + +test "validateResponse rejects a response with QR clear" { + try testing.expectError(error.BadResponse, validateResponse(query_bytes, query_bytes)); +} + +test "validateResponse rejects a response with QDCOUNT 0" { + const no_question = "\x12\x34\x81\x80\x00\x00\x00\x00\x00\x00\x00\x00"; + try testing.expectError(error.BadResponse, validateResponse(query_bytes, no_question)); +} + +test "validateResponse rejects a response with QDCOUNT 2" { + const two = + "\x12\x34\x81\x80\x00\x02\x00\x00\x00\x00\x00\x00" ++ + "\x07example\x03com\x00\x00\x01\x00\x01" ++ + "\x07example\x03com\x00\x00\x01\x00\x01"; + try testing.expectError(error.BadResponse, validateResponse(query_bytes, two)); +} + +test "validateResponse rejects truncated garbage" { + try testing.expectError(error.BadResponse, validateResponse(query_bytes, "\x12\x34\x81")); + try testing.expectError(error.BadResponse, validateResponse(query_bytes, "")); + try testing.expectError( + error.BadResponse, + validateResponse(query_bytes, response_bytes[0 .. response_bytes.len - 3]), + ); +} + +test "validateResponse rejects a query that does not carry exactly one question" { + const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00"; + try testing.expectError(error.BadResponse, validateResponse(no_question, response_bytes)); + try testing.expectError(error.BadResponse, validateResponse("\x12\x34", response_bytes)); +}