Files
nxdns/src/server/doh_server.zig
T
mokhtar ce143d1d87
Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
db-mode config changes apply live in-process
settings and upstream writes now follow a prepare, commit, publish, retire
contract: candidates are built and validated before the database transaction,
published as infallible pointer swaps, and old generations retire after their
readers drain. per-query policy values snapshot once per query; upstream pool,
cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and
the query-log queue each gained one named live operation. restart_required
shrinks from every scalar key to the bind keys and web.enabled; the admin ui
drops its restart notices for everything else. file mode is unchanged.
2026-08-24 00:04:28 +02:00

1265 lines
50 KiB
Zig

//! 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));
}