resolver transport: udp/tcp servers, doh/dot clients, pool failover with health
This commit is contained in:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user