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

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:
2026-08-22 09:16:40 +02:00
parent 7e6cb507d2
commit 0fd6bbd312
65 changed files with 7036 additions and 685 deletions
+62 -1
View File
@@ -33,8 +33,17 @@ const log = std.log.scoped(.forward_client);
/// each half large enough to frame a query in one write, not a capacity.
pub const min_frame_buf: usize = 1024;
/// `tcp://[` + the longest IPv6 text form + `]:65535`, the widest spelling
/// `identityText` can produce.
pub const max_identity_len: usize = "tcp://[".len + 45 + "]:65535".len;
pub const ForwardClient = struct {
resolver: validate.Resolver,
/// The resolver as text, owned here so the `transport.Client` out-parameter
/// has something stable to borrow: `validate.Resolver` is a parsed address,
/// and a caller logging the exchange needs its spelling.
identity_buf: [max_identity_len]u8 = undefined,
identity_len: usize = 0,
/// Caller-owned scratch for the TCP length-prefixed path.
frame_buf: []u8,
/// On the `.awake` clock at the caller's choosing, so a suspended host does
@@ -64,11 +73,20 @@ pub const ForwardClient = struct {
read_timeout: std.Io.Clock.Duration,
) ForwardClient {
std.debug.assert(frame_buf.len >= min_frame_buf);
return .{
var self: ForwardClient = .{
.resolver = resolver,
.frame_buf = frame_buf,
.read_timeout = read_timeout,
};
self.identity_len = identityText(resolver, &self.identity_buf).len;
return self;
}
/// `udp://192.168.1.1:53`, `tcp://[fd00::1]:53` — the same spelling
/// `validate.parseResolver` accepts, so a log row names the configured
/// value. Valid for as long as this client is.
pub fn identity(self: *const ForwardClient) []const u8 {
return self.identity_buf[0..self.identity_len];
}
pub fn client(self: *ForwardClient) transport.Client {
@@ -80,8 +98,12 @@ pub const ForwardClient = struct {
io: std.Io,
query: []const u8,
response_buf: []u8,
selected: *?[]const u8,
) transport.ExchangeError![]u8 {
const self: *ForwardClient = @ptrCast(@alignCast(ptr));
// Set before the attempt: a failed forward-zone exchange still names
// the resolver it was sent to.
selected.* = self.identity();
return self.exchange(io, query, response_buf);
}
@@ -238,6 +260,24 @@ pub const ForwardClient = struct {
}
};
fn identityText(resolver: validate.Resolver, buf: *[max_identity_len]u8) []const u8 {
var w: std.Io.Writer = .fixed(buf);
w.writeAll(switch (resolver.scheme) {
.udp => "udp://",
.tcp => "tcp://",
}) catch unreachable;
const bracketed = switch (resolver.addr) {
.ip4 => false,
.ip6 => true,
};
if (bracketed) w.writeByte('[') catch unreachable;
resolver.addr.format(&w) catch unreachable;
if (bracketed) w.writeByte(']') catch unreachable;
w.print(":{d}", .{resolver.port}) catch unreachable;
return w.buffered();
}
/// The local address a datagram to `dest` is sent from: same family, port
/// chosen by the kernel.
fn wildcardFor(dest: net.IpAddress) net.IpAddress {
@@ -286,6 +326,27 @@ test "ForwardClient satisfies the Client interface" {
try testing.expectEqual(@as(u16, 53), fc.resolver.port);
}
test "the client owns its resolver identity in both address families" {
var buf = testBuf();
const v4: ForwardClient = .init(
try validate.parseResolver("udp://192.168.1.1:5300"),
&buf,
.{ .raw = .fromSeconds(1), .clock = .awake },
);
try testing.expectEqualStrings("udp://192.168.1.1:5300", v4.identity());
var buf6 = testBuf();
const v6: ForwardClient = .init(
try validate.parseResolver("tcp://[fd00::1]:5353"),
&buf6,
.{ .raw = .fromSeconds(1), .clock = .awake },
);
try testing.expectEqualStrings("tcp://[fd00::1]:5353", v6.identity());
// The borrow points into the client, not into `init`'s frame.
try testing.expect(@intFromPtr(v6.identity().ptr) >= @intFromPtr(&v6));
}
test "the stats struct starts at zero" {
const stats: ForwardClient.Stats = .{};
try testing.expectEqual(@as(u64, 0), stats.queries);