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
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.
1265 lines
50 KiB
Zig
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));
|
|
}
|