//! 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. //! //! A client keeps its connection open between exchanges (RFC 7858 §3.4). An //! upstream is free to drop an idle one at any time and says nothing first, so //! staleness is detected at use: an exchange that fails on a *reused* session, //! before any byte of its response arrived, with a connection-lifecycle cause, //! is retried exactly once on a fresh dial and counted through //! `reuse_recoveries` rather than against the upstream's health. Every other //! failure is final, and every final failure closes the session — after a //! failed send, receive or validation, the framing and TLS state are //! untrustworthy and the next exchange must start from a fresh dial. const std = @import("std"); const net = std.Io.net; const tls = std.crypto.tls; const Certificate = std.crypto.Certificate; const safe_url = @import("../safe_url.zig"); const transport = @import("transport.zig"); const tls_client = @import("../platform/tls_client.zig"); const log = std.log.scoped(.dot_client); /// One diagnostic line about one client, as a value rather than a format string /// repeated at each call site. /// /// Two reasons, in that order. The url is redacted in exactly one place, so a /// line added later cannot print it whole — the defect this type closes was four /// call sites each formatting `endpoint.url` with `{s}`, missed by three review /// rounds because each looked like the three beside it. And a `std.log` line is /// not readable from a unit test under the default test runner, which installs /// its own `std_options`; the tests below read this value instead of stderr. const Diagnostic = struct { endpoint: transport.Endpoint, detail: Detail, const Detail = union(enum) { /// `resolveAddress` refused the host. not_an_ip_literal, connect_failed: anyerror, handshake_failed: Handshake, bundle_load_failed: anyerror, /// A reused session turned out to be dead. Normal operation, so this /// one is only ever logged at debug; the counter is the real surface. stale_session: anyerror, const Handshake = struct { verify_name: []const u8, cause: anyerror, }; }; pub fn format(self: Diagnostic, w: *std.Io.Writer) std.Io.Writer.Error!void { try w.print("dot upstream {f}: ", .{safe_url.redactQuoted(self.endpoint.url)}); switch (self.detail) { // The redacted url ends in the host and the port, so naming the host // again would add nothing but an unredacted copy of it. .not_an_ip_literal => try w.writeAll("host is not an IP literal"), .connect_failed => |err| try w.print("connect failed: {s}", .{@errorName(err)}), // `verify_name` is a host name rather than a url, so it carries no // component redaction could drop. It goes through `quoteText` for // what that does to any operator-supplied string: it delimits it, // escapes it and bounds it. .handshake_failed => |hs| try w.print("TLS handshake as {f} failed: {s} ({t})", .{ safe_url.quoteText(hs.verify_name), @errorName(hs.cause), tls_client.classify(hs.cause), }), .bundle_load_failed => |err| try w.print("CA bundle load failed: {s}", .{@errorName(err)}), .stale_session => |err| try w.print( "reused session was stale ({s}), redialing once", .{@errorName(err)}, ), } } }; 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, /// 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, /// Caller-owned, guards `bundle`. bundle_lock: *std.Io.RwLock, /// Caller-owned. One `DotClient` is used by one task at a time. buffers: Buffers, /// Points at the owning pool entry's counter, which lives in the /// composition root's stable storage; null in `nxdns check` probes, which /// have no pool to report through. Incremented when a stale reused session /// is recovered by a redial. reuse_recoveries: ?*std.atomic.Value(u64), /// The connection this client keeps open between exchanges, or null when it /// holds none. Owned here: nothing else may close either half. /// /// Emplaced, never assigned from a local: `TlsStream` hands `tls.Client` /// pointers into its own reader and writer fields, so a `Session` built on /// the stack and copied in would leave the TLS client pointing at the dead /// copy. `dial` sets this to `.{ .stream = …, .tls = undefined }` first and /// runs the handshake through the stored payload. session: ?Session = null, const Session = struct { stream: net.Stream, tls: tls_client.TlsStream, }; 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. /// /// 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, reuse_recoveries: ?*std.atomic.Value(u64), 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, .verify_name = if (tls_name.len == 0) endpoint.host else tls_name, .gpa = gpa, .bundle = bundle, .bundle_lock = bundle_lock, .buffers = buffers, .reuse_recoveries = reuse_recoveries, }; } /// Releases whatever this client holds open between exchanges: TLS first, /// so the peer gets a close_notify, then the socket underneath it. /// /// Idempotent and safe with no session open, because that is how every /// caller uses it — `Upstreams.deinit` closes clients that may never have /// dialed, the `nxdns check` probe loop closes on its error paths, and /// `exchange` closes after a failure it has already classified. pub fn close(self: *DotClient, io: std.Io) void { if (self.session) |*session| { transport.closeBlocked(io, &session.tls); transport.closeBlocked(io, &session.stream); self.session = null; } } pub fn client(self: *DotClient) transport.Client { return .{ .ptr = self, .exchangeFn = exchangeFn }; } fn diagnose(self: *const DotClient, detail: Diagnostic.Detail) Diagnostic { return .{ .endpoint = self.endpoint, .detail = detail }; } fn exchangeFn( ptr: *anyopaque, io: std.Io, query: []const u8, response_buf: []u8, selected: *?[]const u8, ) transport.ExchangeError![]u8 { const self: *DotClient = @ptrCast(@alignCast(ptr)); // The endpoint outlives the client, so the borrow is safe for the whole // query. Set before the attempt: a failure names this resolver too. selected.* = self.endpoint.url; return self.exchange(io, query, response_buf); } /// One framed query and one framed reply over a session this client keeps /// open, dialing one only when it holds none. /// /// A reused session may already be dead: the upstream is entitled to close /// an idle connection and RFC 7858 keepalive is advisory, so the first sign /// is this exchange failing. That case — and only that case — is retried /// once on a fresh dial; see `retryDecision` for the three conditions. The /// caller's attempt budget covers the whole call including that redial, /// because the pool races this function as a whole. 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 reused = self.session != null; if (!reused) try self.dial(io); const failure = switch (self.transact(query, response_buf)) { .ok => |reply| return reply, .failed => |failure| failure, }; switch (retryDecision(reused, failure.received_any, failure.cause)) { .final => { self.close(io); return transport.mapPhase(failure.cause, failure.phase); }, .retry => {}, } // Debug, not warn: an upstream dropping an idle connection is routine, // and one line per idle timeout on a household resolver is log spam. // `reuse_recoveries` is what makes the churn visible. log.debug("{f}", .{self.diagnose(.{ .stale_session = failure.cause })}); self.close(io); try self.dial(io); switch (self.transact(query, response_buf)) { .ok => |reply| { if (self.reuse_recoveries) |counter| _ = counter.fetchAdd(1, .monotonic); return reply; }, // The retry's outcome is the exchange's outcome: one redial, never // two. .failed => |retried| { self.close(io); return transport.mapPhase(retried.cause, retried.phase); }, } } /// Opens a session and leaves it in `self.session`, or leaves `self.session` /// null and returns the classified failure. No partially initialized /// session ever survives this call. fn dial(self: *DotClient, io: std.Io) transport.ExchangeError!void { std.debug.assert(self.session == null); const address = resolveAddress(self.endpoint) catch |err| { log.warn("{f}", .{self.diagnose(.not_an_ip_literal)}); return err; }; try self.ensureBundle(io); const stream = address.connect(io, .{ .mode = .stream }) catch |err| { log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })}); return transport.mapPhase(err, error.ConnectFailed); }; // Emplaced before the handshake, never built beside it and copied in: // `TlsStream` is pinned by the pointers `tls.Client` holds into its own // reader and writer fields, so `init` has to run at the address the // session will live at. self.session = .{ .stream = stream, .tls = undefined }; const session = &self.session.?; // `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. session.tls.stream_reader.err = null; session.tls.stream_writer.err = null; session.tls.init(io, &session.stream, self.bundle, self.bundle_lock, self.gpa, .{ .host = self.verify_name, .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| { // Order matters: the concrete cause lives in the in-place // `TlsStream`'s two error fields, so clearing the optional first // would destroy the only record that a cancellation or a local // resource failure — not the peer — ended the handshake. const cause = concreteHandshake(&session.tls, err); transport.closeBlocked(io, &session.stream); self.session = null; log.warn("{f}", .{self.diagnose(.{ .handshake_failed = .{ .verify_name = self.verify_name, .cause = cause, } })}); return transport.mapPhase(cause, error.TlsFailed); }; } /// One query and one reply on the open session, with enough detail on /// failure for `retryDecision` to rule on it. Leaves the session open /// either way; closing a failed one is `exchange`'s job, because only it /// knows whether the failure is final. fn transact(self: *DotClient, query: []const u8, response_buf: []u8) Transact { const session = &self.session.?; const tls_stream = &session.tls; const writer = tls_stream.writer(); const prefix = transport.framePrefix(@intCast(query.len)); writer.writeAll(&prefix) catch |err| return sendFailed(tls_stream, err); writer.writeAll(query) catch |err| return sendFailed(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 sendFailed(tls_stream, err); const reader = tls_stream.reader(); // Byte at a time, not `readSliceAll`: that call is all-or-nothing, so a // failure after the first prefix byte would be indistinguishable from // one before it, and "no response byte received yet" is one of the three // conditions a retry needs. var prefix_bytes: [transport.prefix_len]u8 = undefined; for (&prefix_bytes, 0..) |*byte, received| { byte.* = reader.takeByte() catch |err| return receiveFailed(tls_stream, err, received != 0); } const len = transport.parsePrefix(prefix_bytes); if (len == 0) return .{ .failed = .{ .cause = error.BadResponse, .phase = error.BadResponse, .received_any = true, } }; if (len > response_buf.len) return .{ .failed = .{ .cause = error.ResponseTooLarge, .phase = error.ResponseTooLarge, .received_any = true, } }; reader.readSliceAll(response_buf[0..len]) catch |err| return receiveFailed(tls_stream, err, true); transport.validateResponse(query, response_buf[0..len]) catch |err| return .{ .failed = .{ .cause = err, .phase = switch (err) { error.BadResponse => error.BadResponse, error.ResponseMismatch => error.ResponseMismatch, }, .received_any = true, } }; return .{ .ok = 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 `transport.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("{f}", .{self.diagnose(.{ .bundle_load_failed = err })}); return transport.mapPhase(err, error.TlsFailed); }; } }; /// 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; } /// The outcome of one `transact`, as a value: a reply, or everything /// `retryDecision` needs to rule on the failure. const Transact = union(enum) { ok: []u8, failed: Failure, const Failure = struct { /// Already unwrapped out of `error.ReadFailed` / `error.WriteFailed`. cause: anyerror, /// The peer fault this phase means, unless `cause` belongs to this /// process. phase: transport.PeerFault, /// Whether any byte of this exchange's response had arrived. received_any: bool, }; }; fn sendFailed(stream: *tls_client.TlsStream, err: anyerror) Transact { return .{ .failed = .{ .cause = concreteWrite(stream, err), .phase = error.SendFailed, // Nothing is read before the query is out, so a send failure is always // pre-first-byte. .received_any = false, }, }; } fn receiveFailed(stream: *tls_client.TlsStream, err: anyerror, received_any: bool) Transact { return .{ .failed = .{ .cause = concreteRead(stream, err), .phase = error.ReceiveFailed, .received_any = received_any, } }; } /// Whether a failed exchange may be retried once on a fresh dial. /// /// Pure, and separate from the wire for that reason: the three conditions are /// the whole of the reuse contract, and a table test is the only way to see all /// of them at once. /// /// `reused` — a session this call dialed itself was never idle, so its failure /// is the upstream's answer, not a stale connection. `bytes_received` — once a /// reply has started, re-sending the query would be a second question, and the /// failure is the upstream's. `cause` — only the ways a connection ends /// (`error.EndOfStream` is a clean close_notify, `error.TlsConnectionTruncated` /// a close without one, `error.ConnectionResetByPeer` and `error.BrokenPipe` /// the socket-level pair). A TLS alert, a certificate fault, a local resource /// failure and a cancellation all keep their meaning and are final. fn retryDecision(reused: bool, bytes_received: bool, cause: anyerror) RetryDecision { if (!reused) return .final; if (bytes_received) return .final; return switch (cause) { error.EndOfStream, error.TlsConnectionTruncated, error.ConnectionResetByPeer, error.BrokenPipe, => .retry, else => .final, }; } const RetryDecision = enum { retry, final }; const testing = std.testing; fn expectDiagnostic(expected: []const u8, url: []const u8, detail: Diagnostic.Detail) !void { var buf: [8 * safe_url.max_len]u8 = undefined; const line = try std.fmt.bufPrint(&buf, "{f}", .{Diagnostic{ .endpoint = .{ .scheme = .dot, .url = url, .host = "host.example", .port = 853, .path = "/" }, .detail = detail, }}); try testing.expectEqualStrings(expected, line); } test "every diagnostic line redacts the url it names the upstream by" { // The endpoints are built by hand rather than parsed, on purpose. // `Endpoint.parse` refuses `@`, `?` and `#` in the authority and refuses a // `.dot` path other than "/", so no url reaching this client through it can // carry a credential in a component `redact` drops. That is a property of a // parser one file away, not of this file, and these lines used to print // whatever `endpoint.url` held. The redaction is what keeps the parser's // rules from being load-bearing here. try expectDiagnostic( "dot upstream 'tls://dns.example': host is not an IP literal", "tls://user:hunter2@dns.example/abcd12", .not_an_ip_literal, ); try expectDiagnostic( "dot upstream 'tls://dns.example:8853': connect failed: ConnectionRefused", "tls://dns.example:8853/abcd12", .{ .connect_failed = error.ConnectionRefused }, ); try expectDiagnostic( "dot upstream 'tls://dns.example': CA bundle load failed: FileNotFound", "tls://dns.example/abcd12?apikey=s3cr3t", .{ .bundle_load_failed = error.FileNotFound }, ); try expectDiagnostic( "dot upstream 'tls://dns.example:853': reused session was stale (EndOfStream), redialing once", "tls://dns.example:853/", .{ .stale_session = error.EndOfStream }, ); try expectDiagnostic( "dot upstream 'tls://dns.example': TLS handshake as 'one.one.one.one' failed: " ++ "CertificateHostMismatch (certificate)", "tls://token@dns.example/abcd12", .{ .handshake_failed = .{ .verify_name = "one.one.one.one", .cause = error.CertificateHostMismatch, } }, ); } test "a diagnostic escapes and bounds the operator-supplied text it prints" { // A control byte in either field would forge a second record on the one // output `std.log`'s own sink does not reach, and an unbounded host or // verification name would write an unbounded line. try expectDiagnostic( "dot upstream 'tls://dns.example\\n2026-01-01 ERROR forged': host is not an IP literal", "tls://dns.example\n2026-01-01 ERROR forged", .not_an_ip_literal, ); try expectDiagnostic( "dot upstream 'tls://dns.example': TLS handshake as 'a\\nb' failed: TlsAlert (handshake)", "tls://dns.example", .{ .handshake_failed = .{ .verify_name = "a\nb", .cause = error.TlsAlert } }, ); // Doubling every operator-supplied field writes the same line, and each // field is built from the three byte costs at once: a printable byte spends // one character of the budget, `\n` spends two and `\x00` spends four. That // expansion is why `safe_url.max_len` counts printed characters rather than // source bytes, so the bound holds against the widest escape rather than in // spite of it. const long = "h\n\x00" ** (2 * safe_url.max_len); const longer = long ** 2; var short_buf: [16 * safe_url.max_len]u8 = undefined; var long_buf: [16 * safe_url.max_len]u8 = undefined; try testing.expectEqualStrings( try std.fmt.bufPrint(&short_buf, "{f}", .{Diagnostic{ .endpoint = .{ .scheme = .dot, .url = "tls://" ++ long, .host = long, .port = 853, .path = "/" }, .detail = .{ .handshake_failed = .{ .verify_name = long, .cause = error.TlsAlert } }, }}), try std.fmt.bufPrint(&long_buf, "{f}", .{Diagnostic{ .endpoint = .{ .scheme = .dot, .url = "tls://" ++ longer, .host = longer, .port = 853, .path = "/" }, .detail = .{ .handshake_failed = .{ .verify_name = longer, .cause = error.TlsAlert } }, }}), ); } test "a diagnostic keeps what a parsed DoT url carries" { // The limit, pinned so it stays visible: a NextDNS DoT upstream is // `tls://abcd12.dns.nextdns.io`, whose hostname is the whole account // identifier. Redaction cannot remove it without leaving no host and an // unactionable line. See `safe_url.SafeUrl`. var buf: [8 * safe_url.max_len]u8 = undefined; const line = try std.fmt.bufPrint(&buf, "{f}", .{Diagnostic{ .endpoint = try .parse("tls://abcd12.dns.nextdns.io"), .detail = .not_an_ip_literal, }}); try testing.expectEqualStrings( "dot upstream 'tls://abcd12.dns.nextdns.io': host is not an IP literal", line, ); } 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 = transport.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 = transport.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, transport.mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed), ); var refused = stubStream(null, error.ConnectionRefused, null); try testing.expectEqual( transport.ExchangeError.TlsFailed, transport.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, transport.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, transport.mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed), ); try testing.expectEqual( transport.ExchangeError.Canceled, transport.mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed), ); } /// What `exchange` would return for a failure `transact` reported. fn mappedFailure(outcome: Transact) transport.ExchangeError { return transport.mapPhase(outcome.failed.cause, outcome.failed.phase); } test "the send and receive unwraps prefer the stored cause" { var send = stubStream(null, error.Canceled, null); try testing.expectEqual( transport.ExchangeError.Canceled, mappedFailure(sendFailed(&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, mappedFailure(receiveFailed(&receive, error.ReadFailed, false)), ); var socket = stubStream(error.SystemResources, null, null); try testing.expectEqual( transport.ExchangeError.SystemResources, mappedFailure(receiveFailed(&socket, error.ReadFailed, true)), ); } test "a send failure is always pre-first-byte, and a receive failure reports what it read" { // The retry rule reads `received_any`, so where it comes from is part of the // contract rather than an incidental field: nothing is read before the query // is on the wire, and the receive side is told by its caller. var stream = stubStream(error.ConnectionResetByPeer, error.ConnectionResetByPeer, null); try testing.expect(!sendFailed(&stream, error.WriteFailed).failed.received_any); try testing.expect(!receiveFailed(&stream, error.ReadFailed, false).failed.received_any); try testing.expect(receiveFailed(&stream, error.ReadFailed, true).failed.received_any); } test "a fresh session is never retried, whatever failed" { // The redial has nothing to fix: this call dialed the connection itself, so // the failure is the upstream's answer rather than a stale socket. for ([_]anyerror{ error.EndOfStream, error.TlsConnectionTruncated, error.ConnectionResetByPeer, error.BrokenPipe, error.TlsAlert, error.Canceled, }) |cause| { try testing.expectEqual(RetryDecision.final, retryDecision(false, false, cause)); try testing.expectEqual(RetryDecision.final, retryDecision(false, true, cause)); } } test "a reused session is retried once for the ways a connection ends" { for ([_]anyerror{ error.EndOfStream, error.TlsConnectionTruncated, error.ConnectionResetByPeer, error.BrokenPipe, }) |cause| { try testing.expectEqual(RetryDecision.retry, retryDecision(true, false, cause)); } } test "a reused session that already answered is never retried" { // Re-sending the query after a byte of the reply arrived would ask the // upstream a second question, so a mid-reply failure is final however the // connection died. for ([_]anyerror{ error.EndOfStream, error.TlsConnectionTruncated, error.ConnectionResetByPeer, error.BrokenPipe, }) |cause| { try testing.expectEqual(RetryDecision.final, retryDecision(true, true, cause)); } } test "a reused session is not retried for a fault that says something" { // A TLS alert, a certificate fault, a local resource failure, a // cancellation and a bad frame all keep their meaning: none of them is an // idle connection going away, so none is worth a second dial. for ([_]anyerror{ error.TlsAlert, error.TlsBadRecordMac, error.CertificateExpired, error.SystemResources, error.OutOfMemory, error.Canceled, error.BadResponse, error.ResponseMismatch, error.ResponseTooLarge, error.ConnectionRefused, error.Timeout, }) |cause| { try testing.expectEqual(RetryDecision.final, retryDecision(true, false, cause)); } } test "a validation failure is final and its phase survives the mapping" { // `transact` reports these with `received_any` set, so the decision is final // by two of the three conditions at once, and the peer fault the pool // records is the validation error itself rather than a receive failure. const outcomes = [_]struct { cause: anyerror, phase: transport.PeerFault }{ .{ .cause = error.BadResponse, .phase = error.BadResponse }, .{ .cause = error.ResponseMismatch, .phase = error.ResponseMismatch }, .{ .cause = error.ResponseTooLarge, .phase = error.ResponseTooLarge }, }; for (outcomes) |outcome| { try testing.expectEqual(RetryDecision.final, retryDecision(true, true, outcome.cause)); try testing.expectEqual( @as(transport.ExchangeError, outcome.phase), mappedFailure(.{ .failed = .{ .cause = outcome.cause, .phase = outcome.phase, .received_any = true, } }), ); } } 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(transport.mapPhase(err, error.TlsFailed)), ); } try testing.expectEqual( transport.ExchangeError.Canceled, transport.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, transport.mapPhase(error.FileNotFound, error.TlsFailed), ); try testing.expectEqual( transport.ExchangeError.TlsFailed, transport.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, null, .{ .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); // A client is wired with nothing open, so `init` can return by value and // the composition root can copy the result into its array: the pinned TLS // state only exists once `exchange` has dialed. try testing.expect(dot.session == null); dot.close(undefined); try testing.expect(dot.session == null); 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, null, 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, null, buffers); try testing.expectEqualStrings("1.1.1.1", plain.verify_name); }