Gates / frontend (push) Successful in 1m26s
Gates / test (push) Successful in 1m55s
Gates / test-aarch64 (push) Failing after 3h1m8s
Gates / package (push) Successful in 3m55s
Gates / container (push) Successful in 15s
CI / gates (push) Failing after 3h21m29s
915 lines
39 KiB
Zig
915 lines
39 KiB
Zig
//! 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);
|
|
}
|