dot upstreams: per-upstream tls_name for sni and cert verification by dns name

This commit is contained in:
2026-08-01 14:38:45 +02:00
parent 70bff22d75
commit 3baf5d6581
13 changed files with 405 additions and 17 deletions
+50 -4
View File
@@ -45,6 +45,12 @@ pub fn resolveAddress(endpoint: transport.Endpoint) ResolveError!net.IpAddress {
pub const DotClient = struct {
endpoint: transport.Endpoint,
/// SNI, and the name matched against the leaf certificate. The dial target
/// stays `endpoint.host`, so this is what lets an upstream configured as an
/// IP literal verify: `std.crypto.Certificate.Parsed.verifyHostName` matches
/// dNSName SANs only and never an IP SAN, so `tls://1.1.1.1:853` alone is
/// always `error.CertificateHostMismatch`. Borrowed, like `endpoint`.
verify_name: []const u8,
gpa: std.mem.Allocator,
/// Caller-owned, shared across endpoints.
bundle: *Certificate.Bundle,
@@ -66,8 +72,12 @@ pub const DotClient = struct {
/// A `.doh` endpoint or an undersized buffer is a wiring bug in this
/// process, not a runtime condition, so both are assertions.
///
/// An empty `tls_name` keeps the endpoint's own host as the verification
/// name, which is correct whenever the url already carries a DNS name.
pub fn init(
endpoint: transport.Endpoint,
tls_name: []const u8,
gpa: std.mem.Allocator,
bundle: *Certificate.Bundle,
bundle_lock: *std.Io.RwLock,
@@ -80,6 +90,7 @@ pub const DotClient = struct {
std.debug.assert(buffers.stream_write.len >= tls.Client.min_buffer_len);
return .{
.endpoint = endpoint,
.verify_name = if (tls_name.len == 0) endpoint.host else tls_name,
.gpa = gpa,
.bundle = bundle,
.bundle_lock = bundle_lock,
@@ -149,7 +160,7 @@ pub const DotClient = struct {
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,
.host = self.verify_name,
.ca = .system,
.read_buffer = self.buffers.tls_read,
.write_buffer = self.buffers.tls_write,
@@ -157,8 +168,9 @@ pub const DotClient = struct {
.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})", .{
log.warn("dot upstream {s}: TLS handshake as \"{s}\" failed: {s} ({t})", .{
self.endpoint.url,
self.verify_name,
@errorName(cause),
tls_client.classify(cause),
});
@@ -170,7 +182,10 @@ pub const DotClient = struct {
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);
// `TlsStream.flush`, not `writer.flush`: the latter leaves the encrypted
// record in the socket writer's buffer and the query never leaves this
// process.
tls_stream.flush() catch |err| return sendFailure(&tls_stream, err);
const reader = tls_stream.reader();
var prefix_bytes: [prefix_len]u8 = undefined;
@@ -463,7 +478,7 @@ test "DotClient satisfies the Client interface" {
// `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, .{
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],
@@ -476,3 +491,34 @@ test "DotClient satisfies the Client interface" {
const iface: transport.Client = dot.client();
try testing.expectEqual(@as(*anyopaque, @ptrCast(&dot)), iface.ptr);
}
test "a tls_name replaces the verification name and leaves the dial target alone" {
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;
const buffers: DotClient.Buffers = .{
.tls_read = buffer[0..chunk],
.tls_write = buffer[chunk .. 2 * chunk],
.stream_read = buffer[2 * chunk .. 3 * chunk],
.stream_write = buffer[3 * chunk ..],
};
const endpoint: transport.Endpoint = try .parse("tls://1.1.1.1:853");
const named: DotClient = .init(endpoint, "one.one.one.one", gpa, &bundle, &bundle_lock, buffers);
try testing.expectEqualStrings("one.one.one.one", named.verify_name);
try testing.expectEqualStrings("1.1.1.1", named.endpoint.host);
const address = try resolveAddress(named.endpoint);
try testing.expectEqualSlices(u8, &.{ 1, 1, 1, 1 }, &address.ip4.bytes);
try testing.expectEqual(@as(u16, 853), address.ip4.port);
const plain: DotClient = .init(endpoint, "", gpa, &bundle, &bundle_lock, buffers);
try testing.expectEqualStrings("1.1.1.1", plain.verify_name);
}