resolver transport: udp/tcp servers, doh/dot clients, pool failover with health
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
//! RFC 8484 DNS over HTTPS upstream client.
|
||||
//!
|
||||
//! One `DohClient` wraps a caller-owned `std.http.Client`, which owns the
|
||||
//! connection pool and the CA bundle. Several `DohClient` values can share one
|
||||
//! `std.http.Client`, so a pool of DoH upstreams keeps one TLS connection per
|
||||
//! host without this file knowing anything about pooling.
|
||||
//!
|
||||
//! HTTP/1.1 only — HTTP/2 is permanently out of scope (PLAN §2.2).
|
||||
//!
|
||||
//! Every buffer is caller-owned. Nothing here allocates, so an exchange cannot
|
||||
//! fail for a reason this file invented.
|
||||
|
||||
const std = @import("std");
|
||||
const transport = @import("transport.zig");
|
||||
|
||||
pub const media_type = "application/dns-message";
|
||||
|
||||
/// The smallest `request_buf` that can hold a real query: a header, a
|
||||
/// maximum-length name, qtype/qclass and an OPT record all fit in 512 bytes.
|
||||
pub const min_request_buf = 512;
|
||||
pub const min_transfer_buf = 1024;
|
||||
|
||||
pub const DohClient = struct {
|
||||
/// Caller-owned; shared across endpoints, pools connections.
|
||||
http: *std.http.Client,
|
||||
endpoint: transport.Endpoint,
|
||||
/// Built once in `init` from `endpoint`.
|
||||
uri: std.Uri,
|
||||
/// Caller-owned. `Request.sendBodyComplete` takes `[]u8`, so the query has
|
||||
/// to be copied out of the caller's const slice before it can be sent.
|
||||
request_buf: []u8,
|
||||
/// Caller-owned HTTP body transfer buffer.
|
||||
transfer_buf: []u8,
|
||||
|
||||
pub const InitError = error{BadUrl};
|
||||
|
||||
/// `endpoint` stays the source of truth for host, port and scheme in logs;
|
||||
/// `uri` exists only because `std.http.Client.request` takes one.
|
||||
///
|
||||
/// A non-DoH endpoint is `error.BadUrl` rather than an assert: the scheme
|
||||
/// comes from configuration, so wiring a `tls://` URL to this client is a
|
||||
/// config fault that must surface as a value, not a panic.
|
||||
pub fn init(
|
||||
http: *std.http.Client,
|
||||
endpoint: transport.Endpoint,
|
||||
request_buf: []u8,
|
||||
transfer_buf: []u8,
|
||||
) InitError!DohClient {
|
||||
std.debug.assert(request_buf.len >= min_request_buf);
|
||||
std.debug.assert(transfer_buf.len >= min_transfer_buf);
|
||||
if (endpoint.scheme != .doh) return error.BadUrl;
|
||||
const uri = std.Uri.parse(endpoint.url) catch return error.BadUrl;
|
||||
return .{
|
||||
.http = http,
|
||||
.endpoint = endpoint,
|
||||
.uri = uri,
|
||||
.request_buf = request_buf,
|
||||
.transfer_buf = transfer_buf,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn client(self: *DohClient) 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: *DohClient = @ptrCast(@alignCast(ptr));
|
||||
return self.exchange(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// Returns a prefix of `response_buf` holding a message that has already
|
||||
/// passed `transport.validateResponse` against `query`.
|
||||
///
|
||||
/// The query ID is sent unchanged. RFC 8484 §4.1 suggests ID 0 so that HTTP
|
||||
/// caches can share a response; nxdns puts no HTTP cache in this path, and
|
||||
/// keeping the ID preserves the request/response binding that
|
||||
/// `validateResponse` checks.
|
||||
pub fn exchange(
|
||||
self: *DohClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
// `std.http.Client` carries the `std.Io` it was constructed with and
|
||||
// takes none per request, so the interface's `io` is unused here. It
|
||||
// stays in the signature because DoT and the pool need it.
|
||||
_ = io;
|
||||
|
||||
if (query.len > self.request_buf.len) return error.BufferTooSmall;
|
||||
@memcpy(self.request_buf[0..query.len], query);
|
||||
|
||||
var req = self.http.request(.POST, self.uri, .{
|
||||
.keep_alive = true,
|
||||
.redirect_behavior = .not_allowed,
|
||||
.headers = .{
|
||||
.content_type = .{ .override = media_type },
|
||||
// A compressed body would need the decompressing reader and
|
||||
// would stop being byte-exact, which `validateResponse` needs.
|
||||
.accept_encoding = .{ .override = "identity" },
|
||||
},
|
||||
// `Request.Headers` has no `accept` field, so this one goes in by
|
||||
// hand.
|
||||
.extra_headers = &.{.{ .name = "accept", .value = media_type }},
|
||||
}) catch |err| return mapError(err, .connect);
|
||||
defer req.deinit();
|
||||
|
||||
req.sendBodyComplete(self.request_buf[0..query.len]) catch |err|
|
||||
return mapError(err, .send);
|
||||
|
||||
// An empty redirect buffer is legal under `.not_allowed`: a redirect
|
||||
// is an error before the location is ever read.
|
||||
var resp = req.receiveHead(&.{}) catch |err| return mapError(err, .receive);
|
||||
|
||||
if (resp.head.status != .ok) return error.HttpStatus;
|
||||
// `head.content_type` points into memory that `resp.reader` invalidates,
|
||||
// so the check happens before the body stream starts.
|
||||
if (!contentTypeOk(resp.head.content_type)) return error.HttpContentType;
|
||||
if (resp.head.content_length) |declared| {
|
||||
if (declared > response_buf.len) return error.ResponseTooLarge;
|
||||
}
|
||||
|
||||
const body = resp.reader(self.transfer_buf);
|
||||
var len: usize = 0;
|
||||
var ended = false;
|
||||
while (len < response_buf.len) {
|
||||
const n = body.readSliceShort(response_buf[len..]) catch |err|
|
||||
return mapError(err, .receive);
|
||||
len += n;
|
||||
if (n == 0) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ended) {
|
||||
// `response_buf` filled exactly. One more read separates a message
|
||||
// that fits from one that was cut off.
|
||||
var probe: [1]u8 = undefined;
|
||||
const n = body.readSliceShort(&probe) catch |err| return mapError(err, .receive);
|
||||
if (n != 0) return error.ResponseTooLarge;
|
||||
}
|
||||
|
||||
try transport.validateResponse(query, response_buf[0..len]);
|
||||
return response_buf[0..len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Which call failed. The phase is what decides the peer fault, and only the
|
||||
/// call site knows it — guessing it from an error name would be wrong the first
|
||||
/// time two phases shared an error.
|
||||
const Phase = enum { connect, send, receive };
|
||||
|
||||
fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
|
||||
if (transport.mapLocal(err)) |local| return local;
|
||||
const err_name = @errorName(err);
|
||||
if (std.mem.startsWith(u8, err_name, "Tls") or
|
||||
std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed;
|
||||
return switch (phase) {
|
||||
.connect => error.ConnectFailed,
|
||||
.send => error.SendFailed,
|
||||
.receive => error.ReceiveFailed,
|
||||
};
|
||||
}
|
||||
|
||||
/// RFC 8484 §6: the response media type is `application/dns-message`. The
|
||||
/// header may carry parameters (`; charset=…`) and the type is case-insensitive
|
||||
/// per RFC 9110 §8.3.1.
|
||||
pub fn contentTypeOk(value: ?[]const u8) bool {
|
||||
const raw = value orelse return false;
|
||||
const without_params = if (std.mem.findScalar(u8, raw, ';')) |semi| raw[0..semi] else raw;
|
||||
return std.ascii.eqlIgnoreCase(std.mem.trim(u8, without_params, " \t"), media_type);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "init rejects a DoT endpoint before any io" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("tls://dns.google:853");
|
||||
try testing.expectError(
|
||||
error.BadUrl,
|
||||
DohClient.init(&http, endpoint, &request_buf, &transfer_buf),
|
||||
);
|
||||
}
|
||||
|
||||
test "init builds a uri from the endpoint url" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
||||
const doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
||||
var component_buf: [256]u8 = undefined;
|
||||
try testing.expectEqualStrings("https", doh.uri.scheme);
|
||||
try testing.expectEqualStrings("dns.example", try doh.uri.host.?.toRaw(&component_buf));
|
||||
try testing.expectEqualStrings("/dns-query", try doh.uri.path.toRaw(&component_buf));
|
||||
try testing.expectEqualStrings("dns.example", doh.endpoint.host);
|
||||
}
|
||||
|
||||
test "DohClient satisfies the transport.Client interface" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
||||
var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
||||
|
||||
// Instantiation is the check: the vtable is built from `exchangeFn`, so a
|
||||
// signature drift is a compile error here. The `std.http.Client` above is
|
||||
// never driven, and no exchange runs.
|
||||
const c: transport.Client = doh.client();
|
||||
try testing.expectEqual(@as(*anyopaque, @ptrCast(&doh)), c.ptr);
|
||||
try testing.expectEqual(
|
||||
@as(@TypeOf(c.exchangeFn), DohClient.exchangeFn),
|
||||
c.exchangeFn,
|
||||
);
|
||||
}
|
||||
|
||||
test "exchange rejects a query larger than the request buffer" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
||||
var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
||||
|
||||
const oversized: [min_request_buf + 1]u8 = @splat(0);
|
||||
var response_buf: [512]u8 = undefined;
|
||||
// The size check precedes every use of `http`, so nothing is driven.
|
||||
try testing.expectError(
|
||||
error.BufferTooSmall,
|
||||
doh.exchange(undefined, &oversized, &response_buf),
|
||||
);
|
||||
}
|
||||
|
||||
test "contentTypeOk accepts the RFC 8484 media type" {
|
||||
try testing.expect(contentTypeOk("application/dns-message"));
|
||||
try testing.expect(contentTypeOk("Application/DNS-Message"));
|
||||
try testing.expect(contentTypeOk("application/dns-message; charset=utf-8"));
|
||||
try testing.expect(contentTypeOk("application/dns-message ; charset=utf-8"));
|
||||
try testing.expect(contentTypeOk(" application/dns-message "));
|
||||
}
|
||||
|
||||
test "contentTypeOk rejects anything else" {
|
||||
try testing.expect(!contentTypeOk(null));
|
||||
try testing.expect(!contentTypeOk("text/html"));
|
||||
try testing.expect(!contentTypeOk("application/json"));
|
||||
try testing.expect(!contentTypeOk(""));
|
||||
try testing.expect(!contentTypeOk("application/dns-message-extra"));
|
||||
}
|
||||
|
||||
test "mapError maps local errors before phase errors" {
|
||||
try testing.expectEqual(error.OutOfMemory, mapError(error.OutOfMemory, .connect));
|
||||
try testing.expectEqual(error.Canceled, mapError(error.Canceled, .receive));
|
||||
try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send));
|
||||
}
|
||||
|
||||
test "mapError maps tls errors regardless of phase" {
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect));
|
||||
}
|
||||
|
||||
test "mapError maps remaining errors by phase" {
|
||||
try testing.expectEqual(error.ConnectFailed, mapError(error.ConnectionRefused, .connect));
|
||||
try testing.expectEqual(error.SendFailed, mapError(error.WriteFailed, .send));
|
||||
try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
|
||||
try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
|
||||
}
|
||||
Reference in New Issue
Block a user