//! The DoH listener (RFC 8484 over HTTP/1.1 + TLS, milestone-10 ruling 2). //! //! The shape is web/server.zig's: one `std.http.Server` per connection over the //! shared `listener.Core` accept loop, fixed pre-allocated connection slots, and //! a keep-alive loop per connection that ends on //! `error.HttpConnectionClosing`. The difference is the transport: after the TCP //! accept, a certificate generation is pinned (`CertStore.acquire`) and //! `ServerStream.accept` runs the TLS handshake through //! `listener.handshakeStage`, and `std.http.Server` sits on the stream's //! plaintext reader/writer (http/Server.zig:25 takes arbitrary interfaces). //! //! The handshake runs under the same race budget tcp_server applies to its //! reads (ruling 3's rationale): a client that connects and never handshakes //! must not pin one of the 64 slots forever. //! //! Over capacity the raw TCP stream is closed and counted, with no response. //! The web listener's 503 is not possible here: HTTP exists only on the far //! side of a TLS handshake, and a handshake costs a slot's buffers and a //! certificate acquisition — exactly what an over-capacity listener does not //! have to spend. Closing is the cheapest honest refusal. //! //! Only `/dns-query` exists. This is not the admin API, so errors are plain //! text, not JSON, and there are no cache-control headers (LAN, no //! intermediaries). const std = @import("std"); const http = std.http; const net = std.Io.net; const Allocator = std.mem.Allocator; const address = @import("../platform/address.zig"); const cert_store = @import("cert_store.zig"); const doh_client = @import("../upstream/doh_client.zig"); const handler = @import("handler.zig"); const listener = @import("listener.zig"); const model = @import("../config/model.zig"); const tls_server = @import("../platform/tls_server.zig"); const transport = @import("../upstream/transport.zig"); const upstream_owner = @import("../upstream/owner.zig"); pub const dns_query_path = "/dns-query"; /// Ruling 5. Mbed TLS records the pointer, so the list must outlive every /// `ServerContext` built with it; a module-scope comptime constant has static /// lifetime. The composition root passes this to the DoH endpoint's /// `CertStore.init`. pub const alpn_protocols: [*:null]const ?[*:0]const u8 = &.{"http/1.1"}; /// The `ServerStream` plaintext receive buffer, which `std.http.Server` also /// uses as the request head, so this is the maximum head size /// (http/Server.zig:32 sets `max_head_len` from it). const recv_buffer_len = 8 * 1024; const send_buffer_len = 4 * 1024; pub const default_max_connections: u16 = 64; const allow_header: http.Header = .{ .name = "allow", .value = "GET, POST" }; pub const Options = struct { max_connections: u16 = default_max_connections, /// The budget for the TLS handshake and for the wait on the next request /// head of a keep-alive connection (milestone-16 ruling 10). A request /// already being served has no timeout, like the web listener: the port is /// LAN-facing and the cancel path is what bounds shutdown. What is raced is /// every wait on a peer that owes nxdns bytes and has sent none. idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake }, }; /// What DoH counts on top of `listener.CoreStats`. pub const Stats = struct { tls_handshake_failures: std.atomic.Value(u64) = .init(0), /// Every 4xx answered on `/dns-query` and every miss beside it: the /// visibility counter for clients that speak, but speak wrongly. bad_requests: std.atomic.Value(u64) = .init(0), }; pub const Snapshot = struct { connections: u64, rejected_at_capacity: u64, rejected_at_shutdown: u64, accept_errors: u64, tls_handshake_failures: u64, /// Keep-alive connections reclaimed after `idle_timeout` elapsed with no /// request head on the wire. A stalled handshake counts as a handshake /// failure instead (milestone-16 ruling 9), so this name means only what /// it says. idle_timeouts: u64, connection_errors: u64, bad_requests: u64, }; pub const DohServer = struct { core: listener.Core(Config), handler: *handler.Handler, certs: *cert_store.CertStore, options: Options, stats: Stats, /// One slot is ~150 KiB, so the default 64 connections cost ~9.4 MiB. The /// two message buffers cannot shrink: a POST body and the reply both go up /// to the 65535 bytes a DNS message can be. The `ServerStream` plaintext /// buffers belong to the core; its `read_buf` doubles as the HTTP head cap /// (see `recv_buffer_len`). pub const Payload = struct { /// The decoded query: a POST body or a GET `dns` parameter. query: [transport.max_message_len]u8, reply: [transport.max_message_len]u8, /// The handler's per-query working memory. A connection is answered /// serially, so one query uses it at a time. scratch: handler.Scratch, /// Valid between a successful `ServerStream.accept` and the /// `close(gpa)` in `serveOne`'s defer. tls: tls_server.ServerStream, }; const Config = struct { pub const Owner = DohServer; pub const ConnPayload = Payload; pub const serveConn = serveOne; pub const read_buffer_len = recv_buffer_len; pub const write_buffer_len = send_buffer_len; pub const log = std.log.scoped(.doh_server); pub const name = "doh"; }; pub const Conn = listener.Core(Config).Conn; pub const ListenError = listener.Core(Config).ListenError; pub fn listen( gpa: Allocator, io: std.Io, listen_address: net.IpAddress, h: *handler.Handler, certs: *cert_store.CertStore, options: Options, ) ListenError!DohServer { return .{ .core = try listener.Core(Config).listen(gpa, io, listen_address, options.max_connections), .handler = h, .certs = certs, .options = options, .stats = .{}, }; } /// The kernel-assigned address. A port of 0 in `listen` resolves here. pub fn boundAddress(self: *const DohServer) net.IpAddress { return self.core.boundAddress(); } pub fn snapshotStats(self: *const DohServer) Snapshot { const core = &self.core.stats; return .{ .connections = core.connections.load(.monotonic), .rejected_at_capacity = core.rejected_at_capacity.load(.monotonic), .rejected_at_shutdown = core.rejected_at_shutdown.load(.monotonic), .accept_errors = core.accept_errors.load(.monotonic), .tls_handshake_failures = self.stats.tls_handshake_failures.load(.monotonic), .idle_timeouts = core.idle_timeouts.load(.monotonic), .connection_errors = core.connection_errors.load(.monotonic), .bad_requests = self.stats.bad_requests.load(.monotonic), }; } /// Accept loop. Returns when the task is canceled or `deinit` stops it. pub fn serve(self: *DohServer, io: std.Io) void { self.core.serve(io); } pub fn deinit(self: *DohServer, io: std.Io) void { self.core.deinit(io); self.* = undefined; } /// One connection: pin, handshake, keep-alive loop, close_notify, release — /// the ordering `listener.handshakeStage` documents. The core closes the /// TCP stream after this returns. fn serveOne(self: *DohServer, io: std.Io, index: usize) void { const conn = &self.core.conns[index]; const stats = &self.core.stats; const gpa = self.core.gpa; // Pinned for the whole connection (ruling 6): a reload never frees the // generation this stream handshook against. const entry = self.certs.acquire(io); defer self.certs.release(io, entry); const stage: Handshake = .{ .conn = conn, .gpa = gpa, .ctx = &entry.ctx, .io = io }; switch (listener.handshakeStage(io, self.options.idle_timeout, stage)) { .ok => {}, .canceled => return, // Milestone-16 ruling 9: a stalled handshake is refused like a broken // one, the DoT arrangement. `idle_timeouts` belongs to the keep-alive // wait below, so the two listeners export the same names for the // same events. .timed_out, .failed => { listener.bump(&self.stats.tls_handshake_failures); return; }, } // Flushes, sends close_notify and frees the TLS context on every exit // path below; the core closes the TCP stream afterwards. defer conn.payload.tls.close(gpa); var connection: http.Server = .init(conn.payload.tls.reader(), conn.payload.tls.writer()); while (connection.reader.state == .ready) { // Milestone-16 ruling 10: the wait for the next request head is the // one place a vanished keep-alive peer could pin a slot forever, so // it runs under the same budget as the handshake. The body read and // `handleRequest` below stay untimed. var head: ReceiveHeadResult = error.ReadFailed; switch (listener.race(io, self.options.idle_timeout, receiveHeadInto, .{ &connection, &head })) { .ok => {}, .timed_out => { listener.bump(&stats.idle_timeouts); return; }, // Cancellation is shutdown; `.failed` here is only the wrapper // failing to start, which costs nothing and counts as nothing. .canceled, .failed => return, } var request = head catch |err| switch (err) { // The normal end of a keep-alive connection. error.HttpConnectionClosing => return, // A vanished client lands here; not worth a counter. error.ReadFailed => return, error.HttpHeadersOversize, error.HttpRequestTruncated, error.HttpHeadersInvalid, => { listener.bump(&stats.connection_errors); return; }, }; // RFC 9110 §8.6: a request with neither content-length nor // transfer-encoding has an empty body, but std leaves the head // saying "unknown" and `discardBody` asserts on it inside every // `respond` (http/Server.zig:631). A zero length is what the head // means, and `bodyReader` goes straight to `.ready` on it. if (request.head.method.requestHasBody() and request.head.transfer_encoding == .none and request.head.content_length == null) { request.head.content_length = 0; } const next = self.handleRequest(io, conn, &request) catch |err| switch (err) { // The peer went away mid-response. Normal. error.WriteFailed => return, error.HttpExpectationFailed, error.ReadFailed => { listener.bump(&stats.connection_errors); return; }, }; // The loop condition alone cannot end these connections: after a // fully-read POST body the stdlib reader is `.ready` again even // when the response said `connection: close`, so a client that // ignores the header could keep the slot. The server hangs up. if (next == .close) return; } } /// The `listener.handshakeStage` stage: everything one mbedTLS handshake /// needs, plus the close that undoes it. const Handshake = struct { conn: *Conn, gpa: Allocator, ctx: *tls_server.ServerContext, io: std.Io, pub fn accept(self: Handshake) anyerror!void { const conn = self.conn; try conn.payload.tls.accept( self.gpa, self.ctx, self.io, &conn.stream, &conn.read_buf, &conn.write_buf, ); } pub fn close(self: Handshake) void { self.conn.payload.tls.close(self.gpa); } }; const HandleError = error{ WriteFailed, HttpExpectationFailed, ReadFailed }; /// What `serveConn`'s keep-alive loop does after the response went out. const Next = enum { keep_open, close }; fn handleRequest( self: *DohServer, io: std.Io, conn: *Conn, request: *http.Server.Request, ) HandleError!Next { // Request smuggling guard: for methods where `requestHasBody` is // false, `respond` (http/Server.zig:618) never drains body bytes, so // content-length or chunked framing on e.g. a GET would leave those // bytes in the stream to be parsed as the next request head. Routing // still decides the status (a framed DELETE is a 405, a framed GET // beside `/dns-query` a 404); the guard only forces the connection // closed, which discards the framed bytes without reading them. const frames_unreadable = framesUnreadableBody( request.head.method, request.head.transfer_encoding, request.head.content_length, ); // Any refusal while framed body bytes are still unread must also // close, whatever the method: with keep_alive=true `respond` drains // the ENTIRE body before sending a byte (http/Server.zig:618 keeps the // connection only after `discardRemaining`), so a chunked body that // never terminates would pin the slot with no response out. // keep_alive=false skips the drain; the routed status still goes out // first, then the connection ends. const keep = !framesBody(request.head.transfer_encoding, request.head.content_length); const target = request.head.target; const split = std.mem.findScalar(u8, target, '?') orelse target.len; if (!std.mem.eql(u8, target[0..split], dns_query_path)) { return self.refuse(request, .not_found, "not found\n", &.{}, keep); } switch (request.head.method) { .GET => { // The routed method itself must not frame a body; refused // before the query string is even looked at. if (frames_unreadable) { return self.refuse(request, .bad_request, "bad request\n", &.{}, false); } const raw_query = if (split == target.len) "" else target[split + 1 ..]; const value = switch (dnsParam(raw_query)) { .value => |v| v, .missing, .duplicate => { return self.refuse(request, .bad_request, "bad request\n", &.{}, true); }, }; const query = decodeDnsValue(value, &conn.payload.query) catch { return self.refuse(request, .bad_request, "bad request\n", &.{}, true); }; return self.answer(io, conn, request, query); }, .POST => { if (!doh_client.contentTypeOk(request.head.content_type)) { return self.refuse(request, .unsupported_media_type, "unsupported media type\n", &.{}, keep); } // Refused before any body byte is read; keep_alive=false so the // oversize body is never drained, the connection just ends. if (request.head.content_length) |len| if (len > transport.max_message_len) { return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); }; const reader = try request.readerExpectContinue(&.{}); const got = reader.readSliceShort(&conn.payload.query) catch return error.ReadFailed; // A full buffer is either a message of exactly the DNS maximum // or a chunked body that keeps going; one probe byte decides. if (got == conn.payload.query.len) { var probe: [1]u8 = undefined; const extra = reader.readSliceShort(&probe) catch return error.ReadFailed; if (extra != 0) { return self.refuse(request, .payload_too_large, "payload too large\n", &.{}, false); } } return self.answer(io, conn, request, conn.payload.query[0..got]); }, else => return self.refuse(request, .method_not_allowed, "method not allowed\n", &.{allow_header}, keep), } } /// Ruling 2: `.drop` means "no answer on purpose" — malformed or /// refused-silent, which includes the empty body — and maps to 400 with /// the connection closed, GET and POST alike. fn answer( self: *DohServer, io: std.Io, conn: *Conn, request: *http.Server.Request, query: []const u8, ) error{ WriteFailed, HttpExpectationFailed }!Next { const outcome = self.handler.handle( io, .tcp, address.NetAddress.fromIp(conn.peer), query, &conn.payload.reply, &conn.payload.scratch, ); switch (outcome) { .drop => return self.refuse(request, .bad_request, "bad request\n", &.{}, false), .reply => |bytes| { try request.respond(bytes, .{ .extra_headers = &.{.{ .name = "content-type", .value = doh_client.media_type }}, }); // `respond` honors the request head's `connection: close` // (http/Server.zig:628), so the loop must agree with the wire. return if (request.head.keep_alive) .keep_open else .close; }, } } fn refuse( self: *DohServer, request: *http.Server.Request, status: http.Status, body: []const u8, extra_headers: []const http.Header, keep_alive: bool, ) error{ WriteFailed, HttpExpectationFailed }!Next { listener.bump(&self.stats.bad_requests); try request.respond(body, .{ .status = status, .keep_alive = keep_alive, .extra_headers = extra_headers, }); // `respond` ANDs `keep_alive` with the request head's own // (http/Server.zig:628); a client that asked to close gets // `connection: close` either way, and the loop must agree. return if (keep_alive and request.head.keep_alive) .keep_open else .close; } }; const ReceiveHeadResult = http.Server.ReceiveHeadError!http.Server.Request; /// The DoT out-param precedent (`readPrefix`'s `out_len`): `race` needs an /// `anyerror!void` operation, so the request — or the error that replaced it — /// travels through a pointer instead of a return value. fn receiveHeadInto(connection: *http.Server, out: *ReceiveHeadResult) anyerror!void { out.* = connection.receiveHead(); } /// True when the head frames body bytes on the wire. A bare /// `content-length: 0` frames nothing. fn framesBody(transfer_encoding: http.TransferEncoding, content_length: ?u64) bool { return transfer_encoding != .none or (content_length orelse 0) != 0; } /// True when the request frames a body the server would never read: the /// stdlib drains bodies only for methods where `requestHasBody` is true, so /// content-length > 0 or any transfer-encoding on the other methods would /// smuggle those bytes into the next request head. fn framesUnreadableBody( method: http.Method, transfer_encoding: http.TransferEncoding, content_length: ?u64, ) bool { if (method.requestHasBody()) return false; return framesBody(transfer_encoding, content_length); } const DnsParam = union(enum) { missing, /// Two `dns` parameters answer differently depending on which one is read; /// a validating decoder refuses to pick. duplicate, value: []const u8, }; /// Finds the `dns` parameter in a raw query string. No percent-decoding /// happens anywhere in this parser: RFC 8484 §4.1 defines the value as /// base64url, whose alphabet contains nothing that needs escaping, so a `%` is /// simply an invalid character for the decoder to reject. fn dnsParam(raw_query: []const u8) DnsParam { var found: ?[]const u8 = null; var it = std.mem.splitScalar(u8, raw_query, '&'); while (it.next()) |pair| { if (!std.mem.startsWith(u8, pair, "dns=")) continue; if (found != null) return .duplicate; found = pair["dns=".len..]; } return if (found) |value| .{ .value = value } else .missing; } /// RFC 8484 §6: base64url without padding. Padding, whitespace, `%` and every /// other character outside the alphabet are rejected, not skipped — a lenient /// decode would make distinct request strings alias one query. fn decodeDnsValue(value: []const u8, dest: []u8) error{Invalid}![]u8 { if (value.len == 0) return error.Invalid; const decoder = std.base64.url_safe_no_pad.Decoder; const len = decoder.calcSizeForSlice(value) catch return error.Invalid; if (len > dest.len) return error.Invalid; decoder.decode(dest[0..len], value) catch return error.Invalid; return dest[0..len]; } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const build_options = @import("build_options"); const fixtures = @import("test_fixtures"); const testing = std.testing; const header_mod = @import("../dns/header.zig"); const packet = @import("../dns/packet.zig"); const records = @import("../local/records.zig"); const local_tables_mod = @import("local_tables.zig"); const response = @import("../filter/response.zig"); const types = @import("../dns/types.zig"); test "framesBody sees framing in either header and none in content-length: 0" { try testing.expect(framesBody(.chunked, null)); try testing.expect(framesBody(.none, 4)); try testing.expect(!framesBody(.none, null)); try testing.expect(!framesBody(.none, 0)); } test "framesUnreadableBody flags body framing only on bodyless methods" { try testing.expect(framesUnreadableBody(.GET, .none, 4)); try testing.expect(framesUnreadableBody(.GET, .chunked, null)); try testing.expect(framesUnreadableBody(.HEAD, .none, 1)); try testing.expect(!framesUnreadableBody(.GET, .none, null)); try testing.expect(!framesUnreadableBody(.GET, .none, 0)); try testing.expect(!framesUnreadableBody(.POST, .none, 4)); try testing.expect(!framesUnreadableBody(.POST, .chunked, null)); } test "dnsParam finds the value among other parameters" { try testing.expectEqualStrings("AAAB", dnsParam("dns=AAAB").value); try testing.expectEqualStrings("AAAB", dnsParam("ct=x&dns=AAAB&other=1").value); // An empty value is found here and rejected by the decoder. try testing.expectEqualStrings("", dnsParam("dns=").value); } test "dnsParam refuses a missing and a duplicated parameter" { try testing.expectEqual(.missing, std.meta.activeTag(dnsParam(""))); try testing.expectEqual(.missing, std.meta.activeTag(dnsParam("ct=x"))); // "dns" as a prefix of another key is not the dns parameter. try testing.expectEqual(.missing, std.meta.activeTag(dnsParam("dnsx=AAAB"))); try testing.expectEqual(.duplicate, std.meta.activeTag(dnsParam("dns=AAAB&dns=AAAB"))); } test "decodeDnsValue round-trips base64url without padding" { const query = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00"; var encoded: [64]u8 = undefined; const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query); var decoded: [64]u8 = undefined; try testing.expectEqualSlices(u8, query, try decodeDnsValue(value, &decoded)); } test "decodeDnsValue rejects padding, whitespace, percent and the empty value" { var buf: [64]u8 = undefined; try testing.expectError(error.Invalid, decodeDnsValue("", &buf)); try testing.expectError(error.Invalid, decodeDnsValue("YWJj=", &buf)); try testing.expectError(error.Invalid, decodeDnsValue("YW Jj", &buf)); try testing.expectError(error.Invalid, decodeDnsValue("YWJj\t", &buf)); try testing.expectError(error.Invalid, decodeDnsValue("YW%3D", &buf)); try testing.expectError(error.Invalid, decodeDnsValue("Y!Jj", &buf)); } test "decodeDnsValue refuses a value larger than its buffer" { var big: [16]u8 = undefined; @memset(&big, 'A'); var small: [4]u8 = undefined; try testing.expectError(error.Invalid, decodeDnsValue(&big, &small)); } // --------------------------------------------------------------------------- // integration tests (-Dintegration): loopback DoH over a real TLS handshake // --------------------------------------------------------------------------- const blocking_defaults: model.Blocking = .{}; const test_blocking: response.Options = .{ .mode = blocking_defaults.response, .ttl = blocking_defaults.ttl, }; const test_forward_timeout: std.Io.Clock.Duration = .{ .raw = model.readTimeout(.{}), .clock = .awake, }; const test_budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake }; const local_rows = [_]model.LocalRecord{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 120 }, }; /// A query for nas.lan A: id 0xBEEF, RD set, one question, no OPT. The harness /// handler answers it from `local_rows`, never from an upstream. const query_bytes = "\xbe\xef\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++ "\x03nas\x03lan\x00\x00\x01\x00\x01"; /// Proves every 200 came from the local-records table: any upstream exchange /// fails the query into SERVFAIL, which the assertions below would catch. const FailingUpstream = struct { fn exchangeFn( ptr: *anyopaque, io: std.Io, query: []const u8, response_buf: []u8, selected: *?[]const u8, ) transport.ExchangeError![]u8 { _ = ptr; _ = io; _ = query; _ = response_buf; selected.* = "fake://failing-upstream"; return error.ConnectFailed; } fn client(self: *FailingUpstream) transport.Client { return .{ .ptr = self, .exchangeFn = exchangeFn }; } }; /// One whole server: fixture certs written to a tmp dir for the `CertStore`, /// a local-records handler, and a serving listener on 127.0.0.1. Must not move /// after `start` — the store borrows the path buffers and the handler borrows /// the tables. const Harness = struct { threaded: std.Io.Threaded, tmp: testing.TmpDir, cert_path_buf: [128]u8, key_path_buf: [128]u8, store: cert_store.CertStore, tables: local_tables_mod.LocalTables, upstream: FailingUpstream, upstream_owner: upstream_owner.Borrowed, h: handler.Handler, server: DohServer, group: std.Io.Group, fn start(hx: *Harness) !void { return hx.startWith(.{ .max_connections = 4 }); } fn startWith(hx: *Harness, options: Options) !void { hx.threaded = .init(testing.allocator, .{}); errdefer hx.threaded.deinit(); const hio = hx.threaded.io(); hx.tmp = testing.tmpDir(.{}); errdefer hx.tmp.cleanup(); try hx.tmp.dir.writeFile(hio, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem }); try hx.tmp.dir.writeFile(hio, .{ .sub_path = "key.pem", .data = fixtures.key_pem }); const cert_path = try std.fmt.bufPrint(&hx.cert_path_buf, ".zig-cache/tmp/{s}/cert.pem", .{hx.tmp.sub_path}); const key_path = try std.fmt.bufPrint(&hx.key_path_buf, ".zig-cache/tmp/{s}/key.pem", .{hx.tmp.sub_path}); hx.store = try cert_store.CertStore.init(testing.allocator, hio, cert_path, key_path, alpn_protocols); errdefer hx.store.deinit(hio); hx.tables = .{ .records = try records.Records.build(testing.allocator, &local_rows) }; errdefer hx.tables.deinit(testing.allocator); hx.upstream = .{}; hx.upstream_owner = .{}; hx.h = .{ .upstream = hx.upstream_owner.client(hx.upstream.client()), .policy = .{ .blocking = test_blocking, .forward_read_timeout = test_forward_timeout }, .local_tables = &hx.tables, }; const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); hx.server = try DohServer.listen(testing.allocator, hio, listen_address, &hx.h, &hx.store, options); errdefer hx.server.deinit(hio); hx.group = .init; try hx.group.concurrent(hio, DohServer.serve, .{ &hx.server, hio }); } fn stop(hx: *Harness) void { const hio = hx.threaded.io(); hx.server.deinit(hio); hx.group.await(hio) catch |err| switch (err) { error.Canceled => unreachable, }; hx.store.deinit(hio); hx.tables.deinit(testing.allocator); hx.tmp.cleanup(); hx.threaded.deinit(); } fn io(hx: *Harness) std.Io { return hx.threaded.io(); } fn addr(hx: *const Harness) net.IpAddress { return hx.server.boundAddress(); } }; const TestOutcome = union(enum) { work: anyerror!void, expiry: std.Io.Cancelable!void, }; /// 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]TestOutcome = undefined; var select: std.Io.Select(TestOutcome) = .init(io, &outcomes); defer select.cancelDiscard(); try select.concurrent(.work, f, args); try select.concurrent(.expiry, listener.expire, .{ io, test_budget }); switch (try select.await()) { .work => |result| return result, .expiry => |result| { try result; return error.TestTimedOut; }, } } /// One TLS client connection speaking raw HTTP/1.1 text, the ruling 12 shape: /// no std.http.Client and no CA ceremony, just `std.crypto.tls.Client` with /// verification off against the self-signed fixture. const ClientConn = struct { stream: net.Stream, transport_read_buf: [std.crypto.tls.Client.min_buffer_len]u8, transport_write_buf: [std.crypto.tls.Client.min_buffer_len]u8, plaintext_read_buf: [4096]u8, plaintext_write_buf: [4096]u8, net_reader: net.Stream.Reader, net_writer: net.Stream.Writer, client: std.crypto.tls.Client, fn connect(self: *ClientConn, io: std.Io, remote: net.IpAddress) !void { self.stream = try remote.connect(io, .{ .mode = .stream }); errdefer self.stream.close(io); self.net_reader = self.stream.reader(io, &self.transport_read_buf); self.net_writer = self.stream.writer(io, &self.transport_write_buf); var entropy: [std.crypto.tls.Client.Options.entropy_len]u8 = undefined; io.random(&entropy); self.client = try std.crypto.tls.Client.init( &self.net_reader.interface, &self.net_writer.interface, .{ .host = .no_verification, .ca = .no_verification, .read_buffer = &self.plaintext_read_buf, .write_buffer = &self.plaintext_write_buf, .entropy = &entropy, .realtime_now = std.Io.Timestamp.now(io, .real), }, ); try self.net_writer.interface.flush(); } fn send(self: *ClientConn, bytes: []const u8) !void { try self.client.writer.writeAll(bytes); try self.client.writer.flush(); try self.net_writer.interface.flush(); } fn end(self: *ClientConn) !void { try self.client.end(); try self.net_writer.interface.flush(); } fn close(self: *ClientConn, io: std.Io) void { self.stream.close(io); } }; const ClientResponse = struct { status: u16, content_length: usize, content_type_buf: [64]u8, content_type_len: usize, allow_buf: [32]u8, allow_len: usize, body_buf: [512]u8, fn contentType(self: *const ClientResponse) []const u8 { return self.content_type_buf[0..self.content_type_len]; } fn allow(self: *const ClientResponse) []const u8 { return self.allow_buf[0..self.allow_len]; } fn body(self: *const ClientResponse) []const u8 { return self.body_buf[0..self.content_length]; } }; /// Reads one HTTP/1.1 response. Header values are copied out because each /// `takeDelimiter` may invalidate the previous line. fn readResponse(reader: *std.Io.Reader) !ClientResponse { var resp: ClientResponse = .{ .status = 0, .content_length = 0, .content_type_buf = undefined, .content_type_len = 0, .allow_buf = undefined, .allow_len = 0, .body_buf = undefined, }; const status_line = (try reader.takeDelimiter('\n')) orelse return error.TestBadStatusLine; if (status_line.len < 12 or !std.mem.startsWith(u8, status_line, "HTTP/1.1 ")) { return error.TestBadStatusLine; } resp.status = try std.fmt.parseInt(u16, status_line[9..12], 10); while (true) { const raw = (try reader.takeDelimiter('\n')) orelse return error.TestTruncatedHead; const line = std.mem.trimEnd(u8, raw, "\r"); if (line.len == 0) break; const colon = std.mem.findScalar(u8, line, ':') orelse continue; const name = line[0..colon]; const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); if (std.ascii.eqlIgnoreCase(name, "content-length")) { resp.content_length = try std.fmt.parseInt(usize, value, 10); } else if (std.ascii.eqlIgnoreCase(name, "content-type")) { if (value.len > resp.content_type_buf.len) return error.TestHeaderTooLong; @memcpy(resp.content_type_buf[0..value.len], value); resp.content_type_len = value.len; } else if (std.ascii.eqlIgnoreCase(name, "allow")) { if (value.len > resp.allow_buf.len) return error.TestHeaderTooLong; @memcpy(resp.allow_buf[0..value.len], value); resp.allow_len = value.len; } } if (resp.content_length > resp.body_buf.len) return error.TestBodyTooLong; try reader.readSliceAll(resp.body_buf[0..resp.content_length]); return resp; } fn sendPost(conn: *ClientConn, content_type: []const u8, request_body: []const u8) !void { var head_buf: [256]u8 = undefined; const head = try std.fmt.bufPrint( &head_buf, "POST {s} HTTP/1.1\r\nhost: doh.test\r\ncontent-type: {s}\r\ncontent-length: {d}\r\n\r\n", .{ dns_query_path, content_type, request_body.len }, ); try conn.client.writer.writeAll(head); try conn.send(request_body); } /// The local answer is deterministic, so the reply must carry the query's id, /// exactly one answer, and the fixture address as the final rdata bytes. fn expectLocalReply(reply: []const u8) !void { const p = try packet.parse(reply); try testing.expectEqual(@as(u16, 0xbeef), 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); try testing.expect(std.mem.endsWith(u8, reply, &.{ 192, 168, 1, 5 })); } fn postRoundTrip(io: std.Io, remote: net.IpAddress) anyerror!void { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try sendPost(&conn, doh_client.media_type, query_bytes); const resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 200), resp.status); try testing.expect(doh_client.contentTypeOk(resp.contentType())); try expectLocalReply(resp.body()); try conn.end(); } test "a POST round trip answers from local records" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), postRoundTrip, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 1), stats.connections); try testing.expectEqual(@as(u64, 0), stats.bad_requests); try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures); } fn getMatchesPost(io: std.Io, remote: net.IpAddress) anyerror!void { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try sendPost(&conn, doh_client.media_type, query_bytes); const post_resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 200), post_resp.status); var encoded: [128]u8 = undefined; const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query_bytes); var head_buf: [256]u8 = undefined; const head = try std.fmt.bufPrint( &head_buf, "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\n\r\n", .{ dns_query_path, value }, ); try conn.send(head); const get_resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 200), get_resp.status); try testing.expect(doh_client.contentTypeOk(get_resp.contentType())); try expectLocalReply(get_resp.body()); try testing.expectEqualSlices(u8, post_resp.body(), get_resp.body()); try conn.end(); } test "a GET round trip returns the same reply bytes as the POST" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), getMatchesPost, .{ hx.io(), hx.addr() }); try testing.expectEqual(@as(u64, 1), hx.server.snapshotStats().connections); } fn errorMatrix(io: std.Io, remote: net.IpAddress) anyerror!void { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); // 415: the right path, the wrong media type. Zero-length on purpose — // only a refusal with no unread body may keep the connection; a 415 with // body bytes in flight closes instead (the drain-free test below). try sendPost(&conn, "text/plain", ""); try testing.expectEqual(@as(u16, 415), (try readResponse(&conn.client.reader)).status); // 405 with the Allow header naming what would have worked. try conn.send("PUT /dns-query HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 0\r\n\r\n"); const put_resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 405), put_resp.status); try testing.expectEqualStrings("GET, POST", put_resp.allow()); // 404: only /dns-query exists. try conn.send("GET /api/health HTTP/1.1\r\nhost: doh.test\r\n\r\n"); try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status); // 400: padded base64 is invalid per RFC 8484 §6. try conn.send("GET /dns-query?dns=YWJj= HTTP/1.1\r\nhost: doh.test\r\n\r\n"); try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); try conn.end(); } test "the error matrix: 415, 405, 404 and 400 on one keep-alive connection" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), errorMatrix, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 1), stats.connections); try testing.expectEqual(@as(u64, 4), stats.bad_requests); try testing.expectEqual(@as(u64, 0), stats.connection_errors); } /// A closed connection ends with close_notify (a short read) when the server /// beat the client to the socket, or a reset when the close already landed; /// both are the same fact for these tests: no further response can arrive. fn expectEof(reader: *std.Io.Reader) !void { var byte: [1]u8 = undefined; const got = reader.readSliceShort(&byte) catch 0; try testing.expectEqual(@as(usize, 0), got); } fn bodiedGetIsRejectedAndClosed(io: std.Io, remote: net.IpAddress) anyerror!void { var encoded: [128]u8 = undefined; const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, query_bytes); // content-length framing on a GET: the four body bytes would otherwise be // parsed as the next request head. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); var head_buf: [256]u8 = undefined; const head = try std.fmt.bufPrint( &head_buf, "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 4\r\n\r\nHTTP", .{ dns_query_path, value }, ); try conn.send(head); try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); try expectEof(&conn.client.reader); } // chunked framing on a GET is the same refusal. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); var head_buf: [256]u8 = undefined; const head = try std.fmt.bufPrint( &head_buf, "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n", .{ dns_query_path, value }, ); try conn.send(head); try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); try expectEof(&conn.client.reader); } } test "a GET that frames a body is refused and the connection closes" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), bodiedGetIsRejectedAndClosed, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 2), stats.connections); try testing.expectEqual(@as(u64, 2), stats.bad_requests); try testing.expectEqual(@as(u64, 0), stats.connection_errors); } fn framedRequestsKeepTheirRoutedStatus(io: std.Io, remote: net.IpAddress) anyerror!void { // A framed DELETE of the endpoint is still a method miss: 405 with Allow, // and the connection closes so the framed bytes are never read. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try conn.send("DELETE /dns-query HTTP/1.1\r\nhost: doh.test\r\ncontent-length: 4\r\n\r\nHTTP"); const resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 405), resp.status); try testing.expectEqualStrings("GET, POST", resp.allow()); try expectEof(&conn.client.reader); } // A framed GET beside the endpoint is still a path miss: 404, closed. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try conn.send("GET /api/health HTTP/1.1\r\nhost: doh.test\r\ntransfer-encoding: chunked\r\n\r\n0\r\n\r\n"); try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status); try expectEof(&conn.client.reader); } } test "a framed request keeps its routed status and the connection closes" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), framedRequestsKeepTheirRoutedStatus, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 2), stats.connections); try testing.expectEqual(@as(u64, 2), stats.bad_requests); try testing.expectEqual(@as(u64, 0), stats.connection_errors); } fn refusalsDoNotDrainUnfinishedBodies(io: std.Io, remote: net.IpAddress) anyerror!void { // A chunked POST beside the endpoint, first chunk sent, terminal chunk // never sent. A server that drained the body before refusing would wait // here forever (the `bounded` budget fails the test); the fix answers 404 // immediately and hangs up without reading a body byte. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try conn.send("POST /wrong-path HTTP/1.1\r\nhost: doh.test\r\n" ++ "content-type: application/dns-message\r\n" ++ "transfer-encoding: chunked\r\n\r\n4\r\nHTTP\r\n"); try testing.expectEqual(@as(u16, 404), (try readResponse(&conn.client.reader)).status); try expectEof(&conn.client.reader); } // A wrong-media-type POST declaring 4096 body bytes it never sends: the // 415 must arrive without the server waiting for the missing bytes. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try conn.send("POST /dns-query HTTP/1.1\r\nhost: doh.test\r\n" ++ "content-type: text/plain\r\ncontent-length: 4096\r\n\r\n"); try testing.expectEqual(@as(u16, 415), (try readResponse(&conn.client.reader)).status); try expectEof(&conn.client.reader); } } test "a refusal with an unread body answers at once and closes, never draining" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), refusalsDoNotDrainUnfinishedBodies, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 2), stats.connections); try testing.expectEqual(@as(u64, 2), stats.bad_requests); try testing.expectEqual(@as(u64, 0), stats.connection_errors); } fn postWithConnectionClose(io: std.Io, remote: net.IpAddress) anyerror!void { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); var head_buf: [256]u8 = undefined; const head = try std.fmt.bufPrint( &head_buf, "POST {s} HTTP/1.1\r\nhost: doh.test\r\nconnection: close\r\ncontent-type: {s}\r\ncontent-length: {d}\r\n\r\n", .{ dns_query_path, doh_client.media_type, query_bytes.len }, ); try conn.client.writer.writeAll(head); try conn.send(query_bytes); const resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 200), resp.status); try expectLocalReply(resp.body()); try expectEof(&conn.client.reader); } test "a successful POST with connection: close is answered, then the server hangs up" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), postWithConnectionClose, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 1), stats.connections); try testing.expectEqual(@as(u64, 0), stats.bad_requests); try testing.expectEqual(@as(u64, 0), stats.connection_errors); } fn dropClosesConnection(io: std.Io, remote: net.IpAddress) anyerror!void { // POST: a well-formed request whose body is not a DNS message drops in // the handler; ruling 2 says 400 and the connection ends. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try sendPost(&conn, doh_client.media_type, "xx"); try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); try expectEof(&conn.client.reader); } // GET: the same garbage, validly base64url-encoded, drops the same way. { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); var encoded: [8]u8 = undefined; const value = std.base64.url_safe_no_pad.Encoder.encode(&encoded, "xx"); var head_buf: [128]u8 = undefined; const head = try std.fmt.bufPrint( &head_buf, "GET {s}?dns={s} HTTP/1.1\r\nhost: doh.test\r\n\r\n", .{ dns_query_path, value }, ); try conn.send(head); try testing.expectEqual(@as(u16, 400), (try readResponse(&conn.client.reader)).status); try expectEof(&conn.client.reader); } } test "a handler drop answers 400 and the connection closes, POST and GET" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), dropClosesConnection, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 2), stats.connections); try testing.expectEqual(@as(u64, 2), stats.bad_requests); try testing.expectEqual(@as(u64, 0), stats.connection_errors); } fn twoPostsOneConnection(io: std.Io, remote: net.IpAddress) anyerror!void { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); for (0..2) |_| { try sendPost(&conn, doh_client.media_type, query_bytes); const resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 200), resp.status); try expectLocalReply(resp.body()); } try conn.end(); } /// Long enough for a loopback round trip, short enough that the `bounded` /// budget still fails a server that never reclaims the connection. const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake }; /// One request, then silence on a connection the client keeps open. The server /// owes no answer, so the only thing that can end the read is the idle budget. fn idleAfterOneRequest(io: std.Io, remote: net.IpAddress) anyerror!void { var conn: ClientConn = undefined; try conn.connect(io, remote); defer conn.close(io); try sendPost(&conn, doh_client.media_type, query_bytes); const resp = try readResponse(&conn.client.reader); try testing.expectEqual(@as(u16, 200), resp.status); try expectLocalReply(resp.body()); try expectEof(&conn.client.reader); } test "doh: an idle keep-alive connection is reclaimed and counted" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.startWith(.{ .max_connections = 4, .idle_timeout = short_idle }); defer hx.stop(); try bounded(hx.io(), idleAfterOneRequest, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 1), stats.connections); try testing.expectEqual(@as(u64, 1), stats.idle_timeouts); try testing.expectEqual(@as(u64, 0), stats.tls_handshake_failures); try testing.expectEqual(@as(u64, 0), stats.connection_errors); try testing.expectEqual(@as(u64, 0), stats.bad_requests); } test "keep-alive: two requests are answered on one connection" { if (!build_options.integration) return error.SkipZigTest; var hx: Harness = undefined; try hx.start(); defer hx.stop(); try bounded(hx.io(), twoPostsOneConnection, .{ hx.io(), hx.addr() }); const stats = hx.server.snapshotStats(); try testing.expectEqual(@as(u64, 1), stats.connections); try testing.expectEqual(@as(u64, 0), stats.bad_requests); try testing.expectEqual(@as(u64, 2), hx.h.stats.local_answers.load(.monotonic)); }