milestone 28: query provenance — every logged query is exactly explainable
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s
Gates / frontend (push) Successful in 1m36s
Gates / test (push) Successful in 1m56s
Gates / test-aarch64 (push) Successful in 7m37s
Gates / package (push) Successful in 9m12s
Gates / container (push) Successful in 13s
CI / gates (push) Successful in 19m4s
query rows gain qclass, rcode, group, policy action and reason, the matched rule or list entry with its source, cname and safe-search targets, route kind, forward zone, and the resolver that actually answered — the pool and local markers die. servfails are logged and name the resolver that lost; post-parse protocol refusals become rows. a detail page at /queries/:id renders the ordered explanation, and coverage watermarks distinguish an empty history from a missing one. the schema fingerprint changes: existing query history is recreated with the old file kept aside and the reset filed as a resolved diagnostic. fixes an oversized udp reply being rebuilt as noerror, which handed clients a truncated nxdomain as success.
This commit is contained in:
@@ -35,6 +35,13 @@ pub fn parsePrefix(bytes: [prefix_len]u8) u16 {
|
||||
return std.mem.readInt(u16, &bytes, .big);
|
||||
}
|
||||
|
||||
/// The longest DNS name in text form, and so the longest host an endpoint url
|
||||
/// can name. Every consumer of `Endpoint.host` sizes itself from this bound
|
||||
/// rather than re-deriving it: the query log's `upstream` column is built for
|
||||
/// the widest `scheme://host:port` this permits, so a longer host would reach
|
||||
/// storage only as a silently shortened identity.
|
||||
pub const max_host_len = 253;
|
||||
|
||||
pub const doh_default_port = 443;
|
||||
pub const dot_default_port = 853; // RFC 7858 §3.1
|
||||
pub const doh_default_path = "/dns-query"; // RFC 8484 §4.1 well-known template
|
||||
@@ -46,14 +53,15 @@ pub const Endpoint = struct {
|
||||
scheme: Scheme,
|
||||
/// The original text, for logs and the health API.
|
||||
url: []const u8,
|
||||
/// No brackets, no port. Used for SNI and certificate verification.
|
||||
/// No brackets, no port, never empty, at most `max_host_len` bytes. Used
|
||||
/// for SNI and certificate verification.
|
||||
host: []const u8,
|
||||
port: u16,
|
||||
/// DoH only; always starts with '/'; `doh_default_path` when absent. A DoT
|
||||
/// endpoint has no request path, so it carries "/" and nothing reads it.
|
||||
path: []const u8,
|
||||
|
||||
pub const ParseError = error{ UnsupportedScheme, MissingHost, BadPort, BadUrl };
|
||||
pub const ParseError = error{ UnsupportedScheme, MissingHost, HostTooLong, BadPort, BadUrl };
|
||||
|
||||
const doh_prefix = "https://";
|
||||
const dot_prefix = "tls://";
|
||||
@@ -82,6 +90,10 @@ pub const Endpoint = struct {
|
||||
|
||||
const host, const port_text = try splitAuthority(authority);
|
||||
if (host.len == 0) return error.MissingHost;
|
||||
// Rejected here rather than tolerated: every identity built from this
|
||||
// endpoint is bounded by `max_host_len`, so a longer host would parse
|
||||
// clean and then be shortened where it is stored or logged.
|
||||
if (host.len > max_host_len) return error.HostTooLong;
|
||||
|
||||
const port: u16 = if (port_text) |text| blk: {
|
||||
if (text.len == 0) return error.BadPort;
|
||||
@@ -329,17 +341,27 @@ pub const Client = struct {
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
selected: *?[]const u8,
|
||||
) ExchangeError![]u8,
|
||||
|
||||
/// Returns a prefix of `response_buf`. The returned message has already
|
||||
/// passed `validateResponse` against `query`.
|
||||
///
|
||||
/// `selected` names the resolver the exchange used. An implementation
|
||||
/// writes it *before* each attempt, never after, so a failed exchange still
|
||||
/// names the last resolver it tried — a SERVFAIL row without its resolver
|
||||
/// explains nothing. The slice must outlive the call; every implementation
|
||||
/// borrows storage it owns for at least the query's duration. Callers
|
||||
/// initialize it to null: a `null` after the call means no resolver was
|
||||
/// reached at all.
|
||||
pub fn exchange(
|
||||
self: Client,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
selected: *?[]const u8,
|
||||
) ExchangeError![]u8 {
|
||||
return self.exchangeFn(self.ptr, io, query, response_buf);
|
||||
return self.exchangeFn(self.ptr, io, query, response_buf, selected);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -449,6 +471,31 @@ test "parse rejects an empty host" {
|
||||
try testing.expectError(error.MissingHost, Endpoint.parse("tls://"));
|
||||
}
|
||||
|
||||
test "parse takes a host at the length bound and rejects one past it" {
|
||||
// Four labels, the widest a 253-byte name allows: 3 * (63 + 1) + 61.
|
||||
const at_bound = ("a" ** 63 ++ ".") ** 3 ++ "a" ** 61;
|
||||
comptime std.debug.assert(at_bound.len == max_host_len);
|
||||
|
||||
const accepted = try Endpoint.parse("https://" ++ at_bound ++ "/dns-query");
|
||||
try testing.expectEqualStrings(at_bound, accepted.host);
|
||||
|
||||
// One byte more is one byte no consumer of `host` has room for.
|
||||
try testing.expectError(
|
||||
error.HostTooLong,
|
||||
Endpoint.parse("https://" ++ at_bound ++ "a/dns-query"),
|
||||
);
|
||||
// The bound is on the host alone, so a port and a path do not spend it,
|
||||
// and the bracketed form is measured with the brackets removed.
|
||||
try testing.expectError(
|
||||
error.HostTooLong,
|
||||
Endpoint.parse("tls://" ++ at_bound ++ "a:853"),
|
||||
);
|
||||
try testing.expectError(
|
||||
error.HostTooLong,
|
||||
Endpoint.parse("https://[" ++ at_bound ++ "a]:8443/dns-query"),
|
||||
);
|
||||
}
|
||||
|
||||
test "parse rejects a bad port" {
|
||||
try testing.expectError(error.BadPort, Endpoint.parse("https://h:99999/"));
|
||||
try testing.expectError(error.BadPort, Endpoint.parse("https://h:/"));
|
||||
@@ -641,8 +688,10 @@ test "a fake client satisfies the Client interface" {
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
selected: *?[]const u8,
|
||||
) ExchangeError![]u8 {
|
||||
_ = io;
|
||||
selected.* = "fake://echo";
|
||||
const self: *@This() = @ptrCast(@alignCast(ptr));
|
||||
self.calls += 1;
|
||||
if (query.len > response_buf.len) return error.ResponseTooLarge;
|
||||
@@ -657,9 +706,11 @@ test "a fake client satisfies the Client interface" {
|
||||
|
||||
var fake: Fake = .{};
|
||||
var buf: [16]u8 = undefined;
|
||||
const echoed = try fake.client().exchange(undefined, "hello", &buf);
|
||||
var selected: ?[]const u8 = null;
|
||||
const echoed = try fake.client().exchange(undefined, "hello", &buf, &selected);
|
||||
try testing.expectEqualStrings("hello", echoed);
|
||||
try testing.expectEqual(@as(usize, 1), fake.calls);
|
||||
try testing.expectEqualStrings("fake://echo", selected.?);
|
||||
}
|
||||
|
||||
/// A query for example.com A: id 0x1234, RD set, one question.
|
||||
|
||||
Reference in New Issue
Block a user