resolver transport: udp/tcp servers, doh/dot clients, pool failover with health

This commit is contained in:
2026-08-01 12:36:50 +02:00
parent 346f2dc502
commit 17d0401f8a
15 changed files with 6062 additions and 0 deletions
+478
View File
@@ -0,0 +1,478 @@
//! DNS over TLS upstream client (RFC 7858).
//!
//! DoT is DNS over TCP with a TLS layer in between: the same 2-byte big-endian
//! length prefix as RFC 1035 §4.2.2, carried on the plaintext side of the TLS
//! stream. All TLS work goes through `platform/tls_client.zig`; this file never
//! touches `std.crypto.tls.Client` directly.
//!
//! Every failure is classified by the phase it happened in — connect, handshake,
//! send, receive — after `transport.mapLocal` has had a chance to claim it. A
//! local resource error or a cancellation must never reach the pool as a peer
//! fault, so the concrete error is unwrapped from `error.ReadFailed` /
//! `error.WriteFailed` before it is classified.
const std = @import("std");
const net = std.Io.net;
const tls = std.crypto.tls;
const Certificate = std.crypto.Certificate;
const transport = @import("transport.zig");
const tls_client = @import("../platform/tls_client.zig");
const log = std.log.scoped(.dot_client);
/// RFC 1035 §4.2.2 length prefix, shared by DNS over TCP and DNS over TLS.
pub const prefix_len = 2;
pub fn framePrefix(len: u16) [prefix_len]u8 {
var out: [prefix_len]u8 = undefined;
std.mem.writeInt(u16, &out, len, .big);
return out;
}
pub fn parsePrefix(bytes: [prefix_len]u8) u16 {
return std.mem.readInt(u16, &bytes, .big);
}
pub const ResolveError = error{ConnectFailed};
/// DoT endpoints take IP literals. Name resolution for upstreams is out of
/// scope for this milestone, and resolving silently would hide a config error
/// behind a slow, confusing failure, so a non-literal host fails immediately.
pub fn resolveAddress(endpoint: transport.Endpoint) ResolveError!net.IpAddress {
return net.IpAddress.parse(endpoint.host, endpoint.port) catch error.ConnectFailed;
}
pub const DotClient = struct {
endpoint: transport.Endpoint,
gpa: std.mem.Allocator,
/// Caller-owned, shared across endpoints.
bundle: *Certificate.Bundle,
/// Caller-owned, guards `bundle`.
bundle_lock: *std.Io.RwLock,
/// Caller-owned. One `DotClient` is used by one task at a time.
buffers: Buffers,
pub const Buffers = struct {
/// Plaintext read buffer.
tls_read: []u8,
/// Plaintext write buffer.
tls_write: []u8,
/// Ciphertext read buffer.
stream_read: []u8,
/// Ciphertext write buffer.
stream_write: []u8,
};
/// A `.doh` endpoint or an undersized buffer is a wiring bug in this
/// process, not a runtime condition, so both are assertions.
pub fn init(
endpoint: transport.Endpoint,
gpa: std.mem.Allocator,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
buffers: Buffers,
) DotClient {
std.debug.assert(endpoint.scheme == .dot);
std.debug.assert(buffers.tls_read.len >= tls.Client.min_buffer_len);
std.debug.assert(buffers.tls_write.len >= tls.Client.min_buffer_len);
std.debug.assert(buffers.stream_read.len >= tls.Client.min_buffer_len);
std.debug.assert(buffers.stream_write.len >= tls.Client.min_buffer_len);
return .{
.endpoint = endpoint,
.gpa = gpa,
.bundle = bundle,
.bundle_lock = bundle_lock,
.buffers = buffers,
};
}
pub fn client(self: *DotClient) transport.Client {
return .{ .ptr = self, .exchangeFn = exchangeFn };
}
fn exchangeFn(
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
const self: *DotClient = @ptrCast(@alignCast(ptr));
return self.exchange(io, query, response_buf);
}
/// One TCP connection and one TLS handshake per exchange, both closed
/// before returning.
///
/// Connection reuse is deliberately not built. At household query rates the
/// saved round trips are worth less than what a per-exchange connection
/// buys: the pool's per-attempt budget stays a plain race against one task,
/// the failover path never has to reason about a half-dead pooled socket,
/// and every failure is attributable to exactly one exchange.
pub fn exchange(
self: *DotClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
// The length prefix is 16-bit, so a longer query cannot be framed. No
// listener in this process can produce one; a caller that does gets a
// local error rather than a silently truncated frame.
if (query.len > transport.max_message_len) return error.BufferTooSmall;
const address = resolveAddress(self.endpoint) catch |err| {
log.warn("dot upstream {s}: host \"{s}\" is not an IP literal", .{
self.endpoint.url,
self.endpoint.host,
});
return err;
};
try self.ensureBundle(io);
var stream = address.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("dot upstream {s}: connect failed: {s}", .{
self.endpoint.url,
@errorName(err),
});
return mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
// `TlsStream` is pinned: it holds its reader and writer by value and the
// TLS client points at them, so it must not move after `init`.
var tls_stream: tls_client.TlsStream = undefined;
// `concreteHandshake` reads these two fields when the handshake reports
// `error.ReadFailed` / `error.WriteFailed`. `TlsStream.init` sets them
// before it can produce either error, but clearing them here keeps that
// out of this file's correctness argument.
tls_stream.stream_reader.err = null;
tls_stream.stream_writer.err = null;
tls_stream.init(io, &stream, self.bundle, self.bundle_lock, self.gpa, .{
.host = self.endpoint.host,
.ca = .system,
.read_buffer = self.buffers.tls_read,
.write_buffer = self.buffers.tls_write,
.stream_read_buffer = self.buffers.stream_read,
.stream_write_buffer = self.buffers.stream_write,
}) catch |err| {
const cause = concreteHandshake(&tls_stream, err);
log.warn("dot upstream {s}: TLS handshake failed: {s} ({t})", .{
self.endpoint.url,
@errorName(cause),
tls_client.classify(cause),
});
return mapPhase(cause, error.TlsFailed);
};
defer closeTls(io, &tls_stream);
const writer = tls_stream.writer();
const prefix = framePrefix(@intCast(query.len));
writer.writeAll(&prefix) catch |err| return sendFailure(&tls_stream, err);
writer.writeAll(query) catch |err| return sendFailure(&tls_stream, err);
writer.flush() catch |err| return sendFailure(&tls_stream, err);
const reader = tls_stream.reader();
var prefix_bytes: [prefix_len]u8 = undefined;
reader.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&tls_stream, err);
const len = parsePrefix(prefix_bytes);
if (len == 0) return error.BadResponse;
if (len > response_buf.len) return error.ResponseTooLarge;
reader.readSliceAll(response_buf[0..len]) catch |err|
return receiveFailure(&tls_stream, err);
try transport.validateResponse(query, response_buf[0..len]);
return response_buf[0..len];
}
/// Loads the system CA bundle before the handshake, so that a failure to
/// read it keeps its concrete cause.
///
/// `TlsStream.init` loads the bundle as well and returns early once
/// `bundle` holds entries, so this runs the scan at most once per process.
/// It exists because `TlsStream.init` folds every scan failure except
/// cancellation into `error.CertificateBundleLoadFailure`. That name cannot
/// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a
/// local resource failure that must not count against the upstream's
/// health. Scanning here keeps the concrete error for `mapPhase`.
fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void {
{
try self.bundle_lock.lockShared(io);
defer self.bundle_lock.unlockShared(io);
if (self.bundle.map.count() != 0) return;
}
try self.bundle_lock.lock(io);
defer self.bundle_lock.unlock(io);
if (self.bundle.map.count() != 0) return;
// A partial scan leaves entries in `map`, which the check above would
// read as "already loaded". Reset so the next exchange scans again.
self.bundle.rescan(self.gpa, io, std.Io.Clock.real.now(io)) catch |err| {
self.bundle.deinit(self.gpa);
self.bundle.* = .empty;
log.warn("dot upstream {s}: CA bundle load failed: {s}", .{
self.endpoint.url,
@errorName(err),
});
return mapPhase(err, error.TlsFailed);
};
}
};
/// The pool cancels this task when the attempt budget expires. The next
/// cancelable `Io` call in the `defer` chain would then return `error.Canceled`
/// and skip the close, leaking the socket, so the close runs with cancellation
/// blocked.
fn closeStream(io: std.Io, stream: *net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeTls(io: std.Io, stream: *tls_client.TlsStream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close();
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// The handshake reads and writes through the socket reader and writer, so a
/// cancelled or resource-starved handshake surfaces as `error.ReadFailed` /
/// `error.WriteFailed` with the cause stashed on those two. Without this,
/// `error.Canceled` and `error.SystemResources` would reach the pool as
/// `error.TlsFailed` and count against the upstream's health.
///
/// Only the socket reader and writer are consulted: `tls.Client.init` returns
/// its error before `TlsStream.client` is assigned, so `client.read_err` does
/// not exist yet on this path.
fn concreteHandshake(stream: *tls_client.TlsStream, err: anyerror) anyerror {
return switch (err) {
error.ReadFailed => stream.stream_reader.err orelse err,
error.WriteFailed => stream.stream_writer.err orelse err,
else => err,
};
}
/// `Io.Reader` collapses everything to `error.ReadFailed` and stashes the cause.
/// Unwrapping it is what keeps `error.Canceled` and the local resource errors
/// out of the health counters.
fn concreteRead(stream: *tls_client.TlsStream, err: anyerror) anyerror {
if (err != error.ReadFailed) return err;
if (stream.client.read_err) |cause| return cause;
if (stream.stream_reader.err) |cause| return cause;
return err;
}
fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror {
if (err != error.WriteFailed) return err;
if (stream.stream_writer.err) |cause| return cause;
return err;
}
fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteWrite(stream, err), error.SendFailed);
}
fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
return mapPhase(concreteRead(stream, err), error.ReceiveFailed);
}
const testing = std.testing;
test "framePrefix writes the length big-endian" {
try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0));
try testing.expectEqualSlices(u8, &.{ 0x00, 0x1d }, &framePrefix(29));
try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256));
try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535));
}
test "parsePrefix reads the length big-endian" {
try testing.expectEqual(@as(u16, 0), parsePrefix(.{ 0x00, 0x00 }));
try testing.expectEqual(@as(u16, 29), parsePrefix(.{ 0x00, 0x1d }));
try testing.expectEqual(@as(u16, 256), parsePrefix(.{ 0x01, 0x00 }));
try testing.expectEqual(@as(u16, 65535), parsePrefix(.{ 0xff, 0xff }));
}
test "framePrefix and parsePrefix round-trip" {
const cases = [_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 };
for (cases) |len| {
try testing.expectEqual(len, parsePrefix(framePrefix(len)));
}
}
test "resolveAddress accepts IP literals" {
const v4 = try resolveAddress(try .parse("tls://1.1.1.1:853"));
try testing.expectEqual(@as(u16, 853), v4.ip4.port);
try testing.expectEqualSlices(u8, &.{ 1, 1, 1, 1 }, &v4.ip4.bytes);
const v6 = try resolveAddress(try .parse("tls://[2606:4700:4700::1111]"));
try testing.expectEqual(@as(u16, transport.dot_default_port), v6.ip6.port);
}
test "resolveAddress rejects a non-literal host without touching the network" {
try testing.expectError(
error.ConnectFailed,
resolveAddress(try .parse("tls://dns.google:853")),
);
try testing.expectError(
error.ConnectFailed,
resolveAddress(try .parse("tls://one.one.one.one")),
);
}
/// Only the four `err` fields the unwrap helpers read are set; the rest of a
/// `TlsStream` is a socket reader, a socket writer and a TLS client, none of
/// which the helpers touch.
fn stubStream(
read_err: ?net.Stream.Reader.Error,
write_err: ?net.Stream.Writer.Error,
tls_read_err: ?tls.Client.ReadError,
) tls_client.TlsStream {
var stream: tls_client.TlsStream = undefined;
stream.stream_reader.err = read_err;
stream.stream_writer.err = write_err;
stream.client.read_err = tls_read_err;
return stream;
}
test "the handshake unwrap keeps a cancelled read out of the peer fault group" {
var stream = stubStream(error.Canceled, null, null);
const mapped = mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
}
test "the handshake unwrap keeps a local resource write failure out of the peer fault group" {
var stream = stubStream(null, error.SystemResources, null);
const mapped = mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
}
test "the handshake unwrap reports a peer side cause as a TLS fault" {
var reset = stubStream(error.ConnectionResetByPeer, null, null);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
);
var refused = stubStream(null, error.ConnectionRefused, null);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
);
}
test "the handshake unwrap reports a TLS fault when no cause was stored" {
var stream = stubStream(null, null, null);
try testing.expectEqual(error.ReadFailed, concreteHandshake(&stream, error.ReadFailed));
try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
);
}
test "the handshake unwrap passes other errors through untouched" {
var stream = stubStream(error.Canceled, error.Canceled, error.TlsAlert);
try testing.expectEqual(
error.CertificateExpired,
concreteHandshake(&stream, error.CertificateExpired),
);
try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled));
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
);
}
test "the send and receive unwraps prefer the stored cause" {
var send = stubStream(null, error.Canceled, null);
try testing.expectEqual(
transport.ExchangeError.Canceled,
sendFailure(&send, error.WriteFailed),
);
// The TLS client's own error wins over the socket reader's.
var receive = stubStream(error.ConnectionResetByPeer, null, error.TlsAlert);
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
receiveFailure(&receive, error.ReadFailed),
);
var socket = stubStream(error.SystemResources, null, null);
try testing.expectEqual(
transport.ExchangeError.SystemResources,
receiveFailure(&socket, error.ReadFailed),
);
}
test "a CA bundle scan failure keeps local resource errors out of the peer fault group" {
// `Certificate.Bundle.rescan` reaches these through `Allocator.Error`,
// `Io.File.OpenError` and `Io.UnexpectedError`.
const local = [_]anyerror{
error.OutOfMemory,
error.SystemResources,
error.ProcessFdQuotaExceeded,
error.SystemFdQuotaExceeded,
error.Unexpected,
};
for (local) |err| {
try testing.expectEqual(
transport.Group.local_resource,
transport.group(mapPhase(err, error.TlsFailed)),
);
}
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.TlsFailed),
);
// A missing or corrupt bundle is not this process running out of anything,
// so it stays a TLS fault.
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(error.FileNotFound, error.TlsFailed),
);
try testing.expectEqual(
transport.ExchangeError.TlsFailed,
mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
);
}
test "DotClient satisfies the Client interface" {
const gpa = testing.allocator;
const buffer = try gpa.alloc(u8, 4 * tls.Client.min_buffer_len);
defer gpa.free(buffer);
const chunk = tls.Client.min_buffer_len;
var bundle: Certificate.Bundle = .empty;
defer bundle.deinit(gpa);
var bundle_lock: std.Io.RwLock = .init;
// `init` asserts `endpoint.scheme == .dot`; a `.doh` endpoint trips
// `std.debug.assert`, which a test cannot catch in-process.
var dot: DotClient = .init(try .parse("tls://9.9.9.9:853"), gpa, &bundle, &bundle_lock, .{
.tls_read = buffer[0..chunk],
.tls_write = buffer[chunk .. 2 * chunk],
.stream_read = buffer[2 * chunk .. 3 * chunk],
.stream_write = buffer[3 * chunk ..],
});
try testing.expectEqual(transport.Scheme.dot, dot.endpoint.scheme);
try testing.expectEqualStrings("9.9.9.9", dot.endpoint.host);
const iface: transport.Client = dot.client();
try testing.expectEqual(@as(*anyopaque, @ptrCast(&dot)), iface.ptr);
}