milestone 13 discrepancies: redact credentials from urls in logs, metrics and cli output

This commit is contained in:
2026-08-07 00:45:17 +02:00
parent 1ff727feb8
commit 8c3328562e
39 changed files with 5734 additions and 510 deletions
+155 -18
View File
@@ -16,11 +16,59 @@ 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,
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)}),
}
}
};
pub const ResolveError = error{ConnectFailed};
/// DoT endpoints take IP literals. Name resolution for upstreams is out of
@@ -89,6 +137,10 @@ pub const DotClient = struct {
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,
@@ -119,20 +171,14 @@ pub const DotClient = struct {
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,
});
log.warn("{f}", .{self.diagnose(.not_an_ip_literal)});
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),
});
log.debug("{f}", .{self.diagnose(.{ .connect_failed = err })});
return mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
@@ -155,12 +201,10 @@ 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 as \"{s}\" failed: {s} ({t})", .{
self.endpoint.url,
self.verify_name,
@errorName(cause),
tls_client.classify(cause),
});
log.warn("{f}", .{self.diagnose(.{ .handshake_failed = .{
.verify_name = self.verify_name,
.cause = cause,
} })});
return mapPhase(cause, error.TlsFailed);
};
defer closeTls(io, &tls_stream);
@@ -214,10 +258,7 @@ pub const DotClient = struct {
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),
});
log.warn("{f}", .{self.diagnose(.{ .bundle_load_failed = err })});
return mapPhase(err, error.TlsFailed);
};
}
@@ -286,6 +327,102 @@ fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.Exchan
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': 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);
+60 -1
View File
@@ -40,10 +40,32 @@
const std = @import("std");
const health = @import("health.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("transport.zig");
const log = std.log.scoped(.upstream);
/// The one line `exchange` writes about a failed attempt, as a value.
///
/// It is a value rather than a format string at the call site for the same
/// reason `dot_client.Diagnostic` is: a `std.log` line is not readable from a
/// unit test under the default test runner, so the test below reads this
/// instead of stderr. An upstream url is operator-supplied and a DoH one carries
/// its credential in the path — `https://dns.nextdns.io/abcd12` is a whole
/// NextDNS account identifier — so it reaches the line through
/// `safe_url.redactQuoted`. Quoted rather than bare because the error name
/// follows it: a redacted authority may still hold a space and a `:`, so
/// unquoted, a url ending `ok failed: Timeout` would report a failure that did
/// not happen.
const AttemptFailure = struct {
endpoint: transport.Endpoint,
err: transport.ExchangeError,
pub fn format(self: AttemptFailure, w: *std.Io.Writer) std.Io.Writer.Error!void {
try w.print("upstream {f} failed: {t}", .{ safe_url.redactQuoted(self.endpoint.url), self.err });
}
};
pub const Entry = struct {
endpoint: transport.Endpoint,
client: transport.Client,
@@ -61,6 +83,11 @@ pub const Entry = struct {
/// A copy of one entry's health, taken under the mutex. Feeds
/// `GET /api/upstream/health` in Phase 8.
pub const Snapshot = struct {
/// Whole, not redacted. `GET /api/upstream/health` returns this to a session
/// that `GET /api/upstreams` already serves the same url to in full, so
/// redacting here would hide nothing from that reader and would make two
/// responses of one API disagree. A consumer reachable without a session has
/// to redact it itself.
url: []const u8,
enabled: bool,
available: bool,
@@ -165,7 +192,7 @@ pub const Pool = struct {
const response = result catch |err| switch (transport.group(err)) {
.peer_fault => {
log.debug("upstream {s} failed: {t}", .{ entry.endpoint.url, err });
log.debug("{f}", .{AttemptFailure{ .endpoint = entry.endpoint, .err = err }});
self.recordFailure(io, entry, completed_at, err);
last_fault = err;
continue;
@@ -385,6 +412,38 @@ const test_cfg: health.Config = .{
const test_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
fn expectFailureLine(expected: []const u8, url: []const u8, err: transport.ExchangeError) !void {
var buf: [8 * safe_url.max_len]u8 = undefined;
const line = try std.fmt.bufPrint(&buf, "{f}", .{AttemptFailure{
.endpoint = try .parse(url),
.err = err,
}});
try testing.expectEqualStrings(expected, line);
}
test "the failed-attempt line names an upstream by a url carrying no credential" {
// A NextDNS DoH upstream puts the whole account identifier in the path, and
// this line ran at `debug` on every peer fault, so a debug-level operator
// persisted it to the journal once per failure.
try expectFailureLine(
"upstream 'https://dns.nextdns.io' failed: Timeout",
"https://dns.nextdns.io/abcd12",
error.Timeout,
);
try expectFailureLine(
"upstream 'https://cdn.example:8443' failed: TlsFailed",
"https://cdn.example:8443/d/hunter2/dns-query",
error.TlsFailed,
);
// What it still says, because an operator reading a failover has to know
// which upstream failed: the scheme, the host and the port.
try expectFailureLine(
"upstream 'tls://9.9.9.9:853' failed: ConnectFailed",
"tls://9.9.9.9:853",
error.ConnectFailed,
);
}
test "Pool satisfies the Client interface" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();