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
+104 -5
View File
@@ -6,9 +6,13 @@
//! instead of a line number, so every `UNIQUE` and every foreign key in
//! PLAN §11.2 has a check here.
//!
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite, no clock. The
//! only `std.Io` type used is `std.Io.Writer`, for rendering diagnostics. The
//! allocator exists for diagnostic text and scratch bookkeeping alone.
//! Pure: no `std.Io` value is a parameter anywhere, no SQLite call, no clock.
//! The only `std.Io` type used is `std.Io.Writer`, for rendering diagnostics.
//! The allocator exists for diagnostic text and scratch bookkeeping alone. The
//! `storage/logger.zig` import is a comptime one — `query_log_buffer_max` is
//! derived from `@sizeOf(logger.Entry)`, because the bound this file enforces
//! on the queue is a bound on bytes and only the logger knows how wide a queued
//! entry is. Nothing in this file calls into storage.
//!
//! Parsers are reused, never reimplemented: `transport.Endpoint.parse` for
//! upstream URLs, `NetAddress.parse` / `Prefix.parse` for addresses, and
@@ -44,6 +48,8 @@ const Writer = std.Io.Writer;
const model = @import("model.zig");
const address = @import("../platform/address.zig");
const dns_name = @import("../dns/name.zig");
const limits = @import("limits.zig");
const logger = @import("../storage/logger.zig");
const regex = @import("../filter/regex.zig");
const safe_url = @import("../safe_url.zig");
const transport = @import("../upstream/transport.zig");
@@ -72,6 +78,7 @@ pub const ValidateError = error{
DuplicateGroupName,
UnknownGroup,
EmptyGroupName,
GroupNameTooLong,
BadClientIp,
DuplicateClientIp,
BadClientPrefix,
@@ -79,6 +86,7 @@ pub const ValidateError = error{
BadSourceUrl,
DuplicateSourceUrl,
EmptySourceName,
SourceNameTooLong,
UnknownSource,
DuplicateGroupSource,
BadRulePattern,
@@ -461,13 +469,16 @@ fn checkScalars(cfg: Config, diags: *Diagnostics) error{OutOfMemory}!void {
if (cfg.logging.query_log_buffer_max < 1) {
try diags.add(error.BadRetention, "logging.query_log_buffer_max", .{}, "must be at least 1", .{});
}
if (cfg.logging.query_log_buffer_max > max_boot_entries) {
// Its own ceiling, not `max_boot_entries`: a queued `logger.Entry` carries
// every provenance field by value, so the queue's cost is bytes rather than
// entries and the bound follows the width of the entry.
if (cfg.logging.query_log_buffer_max > logger.query_log_buffer_max) {
try diags.add(
error.BadRetention,
"logging.query_log_buffer_max",
.{},
"must be at most {d}, got {d}",
.{ max_boot_entries, cfg.logging.query_log_buffer_max },
.{ logger.query_log_buffer_max, cfg.logging.query_log_buffer_max },
);
}
// No floor: 0 is the documented "do not wait" setting, not a mistake.
@@ -714,6 +725,29 @@ fn checkDotHost(
};
}
/// The shared shape of the two label caps.
///
/// Both names are copied by value into every `query_log` row that mentions
/// them, so the cap is what keeps a pasted paragraph out of the fixed buffers
/// of `storage/logger.zig`. Bytes, not codepoints: the buffer counts bytes.
fn checkNameLength(
diags: *Diagnostics,
comptime fault: ValidateError,
comptime path: []const u8,
path_args: anytype,
value: []const u8,
cap: usize,
) error{OutOfMemory}!void {
if (value.len <= cap) return;
try diags.add(
fault,
path,
path_args,
"must be at most {d} bytes, got {d}; the name is copied into every logged query",
.{ cap, value.len },
);
}
fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{OutOfMemory}!void {
var group_names: IndexSet = .empty;
var has_default = false;
@@ -729,6 +763,16 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
.{safe_url.quoteText(group.name)},
);
}
// Independent of the chain above: an over-long name is still a name,
// and a duplicate of one is still a duplicate.
try checkNameLength(
diags,
error.GroupNameTooLong,
"groups[{d}].name",
.{i},
group.name,
limits.max_group_name_len,
);
if (std.mem.eql(u8, group.name, "default")) has_default = true;
}
if (!has_default) {
@@ -859,6 +903,14 @@ fn checkCollections(cfg: Config, diags: *Diagnostics, scratch: Allocator) error{
.{},
);
}
try checkNameLength(
diags,
error.SourceNameTooLong,
"blocklist_sources[{d}].name",
.{i},
source.name,
limits.max_source_name_len,
);
}
var group_source_pairs: IndexSet = .empty;
@@ -1308,6 +1360,24 @@ test "an https:// upstream may name a host" {
try expectClean(cfg);
}
/// The longest host `transport.Endpoint.parse` accepts: four labels, 253 bytes.
const host_at_bound = ("a" ** 63 ++ ".") ** 3 ++ "a" ** 61;
test "an upstream host at the length bound validates cleanly" {
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "https://" ++ host_at_bound ++ "/dns-query" }};
try expectClean(cfg);
}
test "error.BadUpstreamUrl on an upstream host one byte past the length bound" {
// The bound is the query log's `upstream` width and every other identity
// built from the endpoint, so an over-long host has to fail here rather
// than be shortened downstream.
var cfg = baseConfig();
cfg.upstreams = &.{.{ .url = "https://" ++ host_at_bound ++ "a/dns-query" }};
try expectProblem(cfg, error.BadUpstreamUrl, "upstreams[0].url");
}
test "an IPv6 literal tls:// upstream validates cleanly" {
// `Endpoint.parse` strips the brackets, so the host reaching the check is
// exactly what the client hands to the address parser.
@@ -1371,6 +1441,35 @@ test "error.EmptyGroupName" {
try expectProblem(cfg, error.EmptyGroupName, "groups[1].name");
}
test "error.GroupNameTooLong" {
const cap = limits.max_group_name_len;
// Exactly at the cap is accepted; one byte past it is not. The cap is what
// `storage/logger.zig` sizes its `Entry` buffer from, so a name that passes
// here is a name a logged row stores whole.
var at_cap = baseConfig();
at_cap.groups = &.{ .{ .name = "default" }, .{ .name = "g" ** cap } };
try expectClean(at_cap);
var over = baseConfig();
over.groups = &.{ .{ .name = "default" }, .{ .name = "g" ** (cap + 1) } };
try expectProblem(over, error.GroupNameTooLong, "groups[1].name");
}
test "error.SourceNameTooLong" {
const cap = limits.max_source_name_len;
const url = "https://lists.example/hosts.txt";
var at_cap = baseConfig();
at_cap.blocklist_sources = &.{.{ .url = url, .name = "s" ** cap }};
at_cap.group_sources = &.{.{ .group = "default", .source_url = url }};
try expectClean(at_cap);
var over = baseConfig();
over.blocklist_sources = &.{.{ .url = url, .name = "s" ** (cap + 1) }};
over.group_sources = &.{.{ .group = "default", .source_url = url }};
try expectProblem(over, error.SourceNameTooLong, "blocklist_sources[0].name");
}
test "error.BadClientIp" {
var cfg = baseConfig();
cfg.clients = &.{.{ .ip = "nonsense" }};