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:
+416
-53
@@ -34,8 +34,12 @@ const std = @import("std");
|
||||
const db = @import("db.zig");
|
||||
const disk_monitor = @import("disk_monitor.zig");
|
||||
const events = @import("events.zig");
|
||||
const limits = @import("../config/limits.zig");
|
||||
const model = @import("../config/model.zig");
|
||||
const provenance = @import("provenance.zig");
|
||||
const queries_repo = @import("repositories/queries_repo.zig");
|
||||
const regex = @import("../filter/regex.zig");
|
||||
const safe_url = @import("../safe_url.zig");
|
||||
|
||||
/// Named `scope` rather than `log`: `Logger.log` is the enqueue entry point,
|
||||
/// and the two names collide inside the struct.
|
||||
@@ -58,10 +62,32 @@ pub const gate_retry_s = 1;
|
||||
pub const max_domain_len = 253;
|
||||
/// RFC 5952 text of any IPv6 address, zone identifier included.
|
||||
pub const max_client_len = 45;
|
||||
pub const max_reason_len = 32;
|
||||
pub const max_upstream_len = 64;
|
||||
|
||||
/// `matched` holds the rule that decided the query, and the widest rule the
|
||||
/// configuration accepts is a regex pattern at `regex.max_pattern_len`. It does
|
||||
/// not fit a `u8` length, which is why this one field carries a `u16`.
|
||||
pub const max_matched_len = regex.max_pattern_len;
|
||||
|
||||
/// The redacted resolver identity of the exchange that actually happened. Two
|
||||
/// bounds apply and the buffer takes the larger, so neither producer truncates:
|
||||
/// the longest well-formed `scheme://host:port` with a maximal host, and
|
||||
/// `safe_url.redact`'s own output bound (`max_len` plus the `...` it appends
|
||||
/// when it truncates).
|
||||
pub const max_upstream_len = @max(
|
||||
"https://".len + max_domain_len + ":65535".len,
|
||||
safe_url.max_len + 3,
|
||||
);
|
||||
|
||||
/// `cname_target`, `safe_search_target` and `forward_zone` each hold a domain
|
||||
/// name, so they are all the same width as `domain`.
|
||||
const max_name_len = max_domain_len;
|
||||
|
||||
/// One row on its way to `query_log`, carrying its own bytes.
|
||||
///
|
||||
/// Every field is by value: `Io.Queue` copies elements as raw bytes, so nothing
|
||||
/// here may borrow from the query that produced it. The buffer widths above are
|
||||
/// therefore the row's real storage cost, multiplied by the queue capacity —
|
||||
/// see `query_log_buffer_max`.
|
||||
pub const Entry = struct {
|
||||
timestamp: i64,
|
||||
domain_buf: [max_domain_len]u8,
|
||||
@@ -69,28 +95,68 @@ pub const Entry = struct {
|
||||
client_buf: [max_client_len]u8,
|
||||
client_len: u8,
|
||||
qtype: ?u16,
|
||||
qclass: u16,
|
||||
/// The client-visible RCODE. Twelve bits, not four: an EDNS extended code
|
||||
/// carries eight more bits in the OPT record than the header's four. The
|
||||
/// type is the enforcement — the column's `CHECK` in `querylog_schema.ddl`
|
||||
/// bounds the same value against every other writer of the file.
|
||||
rcode: u12,
|
||||
blocked: bool,
|
||||
reason_buf: [max_reason_len]u8,
|
||||
reason_len: u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream_buf: [max_upstream_len]u8,
|
||||
upstream_len: u8,
|
||||
upstream_len: u16,
|
||||
|
||||
group_id: ?i64,
|
||||
group_buf: [limits.max_group_name_len]u8,
|
||||
group_len: u8,
|
||||
policy_action: provenance.PolicyAction,
|
||||
policy_reason: provenance.PolicyReason,
|
||||
matched_buf: [max_matched_len]u8,
|
||||
matched_len: u16,
|
||||
source_id: ?i64,
|
||||
source_buf: [limits.max_source_name_len]u8,
|
||||
source_len: u8,
|
||||
cname_buf: [max_name_len]u8,
|
||||
cname_len: u8,
|
||||
safe_search_buf: [max_name_len]u8,
|
||||
safe_search_len: u8,
|
||||
route_kind: provenance.RouteKind,
|
||||
forward_zone_buf: [max_name_len]u8,
|
||||
forward_zone_len: u8,
|
||||
|
||||
/// The borrowed shape of an entry. `init` copies out of it, so a caller can
|
||||
/// build one from slices that die with the query.
|
||||
///
|
||||
/// Every text field defaults to `""`, which reaches a nullable column as
|
||||
/// NULL. The three fields with no sensible empty value — the two enums and
|
||||
/// the route — default to what a query the pipeline has not yet explained
|
||||
/// would honestly say about itself.
|
||||
pub const Fields = struct {
|
||||
timestamp: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16 = null,
|
||||
qclass: u16 = 0,
|
||||
rcode: u12 = 0,
|
||||
blocked: bool = false,
|
||||
/// Empty means "no reason", which reaches the database as NULL.
|
||||
block_reason: []const u8 = "",
|
||||
response_time_us: ?i64 = null,
|
||||
cache_hit: ?bool = null,
|
||||
/// Empty means "no upstream", which reaches the database as NULL.
|
||||
/// Empty means "no upstream was attempted", which reaches the database
|
||||
/// as NULL. Already redacted by the caller.
|
||||
upstream: []const u8 = "",
|
||||
|
||||
group_id: ?i64 = null,
|
||||
group_name: []const u8 = "",
|
||||
policy_action: provenance.PolicyAction = .not_evaluated,
|
||||
policy_reason: provenance.PolicyReason = .no_match,
|
||||
matched: []const u8 = "",
|
||||
source_id: ?i64 = null,
|
||||
source_name: []const u8 = "",
|
||||
cname_target: []const u8 = "",
|
||||
safe_search_target: []const u8 = "",
|
||||
route_kind: provenance.RouteKind = .upstream,
|
||||
forward_zone: []const u8 = "",
|
||||
};
|
||||
|
||||
/// Copies each string in, truncated to what its buffer holds. A name longer
|
||||
@@ -104,27 +170,61 @@ pub const Entry = struct {
|
||||
.client_buf = undefined,
|
||||
.client_len = 0,
|
||||
.qtype = f.qtype,
|
||||
.qclass = f.qclass,
|
||||
.rcode = f.rcode,
|
||||
.blocked = f.blocked,
|
||||
.reason_buf = undefined,
|
||||
.reason_len = 0,
|
||||
.response_time_us = f.response_time_us,
|
||||
.cache_hit = f.cache_hit,
|
||||
.upstream_buf = undefined,
|
||||
.upstream_len = 0,
|
||||
.group_id = f.group_id,
|
||||
.group_buf = undefined,
|
||||
.group_len = 0,
|
||||
.policy_action = f.policy_action,
|
||||
.policy_reason = f.policy_reason,
|
||||
.matched_buf = undefined,
|
||||
.matched_len = 0,
|
||||
.source_id = f.source_id,
|
||||
.source_buf = undefined,
|
||||
.source_len = 0,
|
||||
.cname_buf = undefined,
|
||||
.cname_len = 0,
|
||||
.safe_search_buf = undefined,
|
||||
.safe_search_len = 0,
|
||||
.route_kind = f.route_kind,
|
||||
.forward_zone_buf = undefined,
|
||||
.forward_zone_len = 0,
|
||||
};
|
||||
entry.setDomain(f.domain);
|
||||
entry.setClientIp(f.client_ip);
|
||||
entry.reason_len = copyInto(&entry.reason_buf, f.block_reason);
|
||||
entry.upstream_len = copyInto(&entry.upstream_buf, f.upstream);
|
||||
copyInto(&entry.upstream_buf, &entry.upstream_len, f.upstream);
|
||||
copyInto(&entry.group_buf, &entry.group_len, f.group_name);
|
||||
entry.setMatched(f.matched);
|
||||
copyInto(&entry.source_buf, &entry.source_len, f.source_name);
|
||||
entry.setCnameTarget(f.cname_target);
|
||||
entry.setSafeSearchTarget(f.safe_search_target);
|
||||
copyInto(&entry.forward_zone_buf, &entry.forward_zone_len, f.forward_zone);
|
||||
return entry;
|
||||
}
|
||||
|
||||
pub fn setDomain(self: *Entry, value: []const u8) void {
|
||||
self.domain_len = copyInto(&self.domain_buf, value);
|
||||
copyInto(&self.domain_buf, &self.domain_len, value);
|
||||
}
|
||||
|
||||
pub fn setClientIp(self: *Entry, value: []const u8) void {
|
||||
self.client_len = copyInto(&self.client_buf, value);
|
||||
copyInto(&self.client_buf, &self.client_len, value);
|
||||
}
|
||||
|
||||
pub fn setMatched(self: *Entry, value: []const u8) void {
|
||||
copyInto(&self.matched_buf, &self.matched_len, value);
|
||||
}
|
||||
|
||||
pub fn setCnameTarget(self: *Entry, value: []const u8) void {
|
||||
copyInto(&self.cname_buf, &self.cname_len, value);
|
||||
}
|
||||
|
||||
pub fn setSafeSearchTarget(self: *Entry, value: []const u8) void {
|
||||
copyInto(&self.safe_search_buf, &self.safe_search_len, value);
|
||||
}
|
||||
|
||||
pub fn domain(self: *const Entry) []const u8 {
|
||||
@@ -135,19 +235,56 @@ pub const Entry = struct {
|
||||
return self.client_buf[0..self.client_len];
|
||||
}
|
||||
|
||||
pub fn blockReason(self: *const Entry) []const u8 {
|
||||
return self.reason_buf[0..self.reason_len];
|
||||
}
|
||||
|
||||
pub fn upstream(self: *const Entry) []const u8 {
|
||||
return self.upstream_buf[0..self.upstream_len];
|
||||
}
|
||||
|
||||
pub fn groupName(self: *const Entry) []const u8 {
|
||||
return self.group_buf[0..self.group_len];
|
||||
}
|
||||
|
||||
pub fn matched(self: *const Entry) []const u8 {
|
||||
return self.matched_buf[0..self.matched_len];
|
||||
}
|
||||
|
||||
pub fn sourceName(self: *const Entry) []const u8 {
|
||||
return self.source_buf[0..self.source_len];
|
||||
}
|
||||
|
||||
pub fn cnameTarget(self: *const Entry) []const u8 {
|
||||
return self.cname_buf[0..self.cname_len];
|
||||
}
|
||||
|
||||
pub fn safeSearchTarget(self: *const Entry) []const u8 {
|
||||
return self.safe_search_buf[0..self.safe_search_len];
|
||||
}
|
||||
|
||||
pub fn forwardZone(self: *const Entry) []const u8 {
|
||||
return self.forward_zone_buf[0..self.forward_zone_len];
|
||||
}
|
||||
};
|
||||
|
||||
fn copyInto(buf: []u8, value: []const u8) u8 {
|
||||
/// The memory budget the queue is allowed to occupy. `Entry` travels by value,
|
||||
/// so the composition root allocates `query_log_buffer_max` of them in full at
|
||||
/// boot (`app.zig`) and the SSE hub embeds a ring of them per subscriber.
|
||||
const queue_budget_bytes = 64 * 1024 * 1024;
|
||||
|
||||
/// The ceiling `config/validate.zig` enforces on `logging.query_log_buffer_max`,
|
||||
/// derived from the width of `Entry` rather than picked.
|
||||
///
|
||||
/// The provenance columns of milestone 28 roughly tripled `Entry`, so the bound
|
||||
/// that matters is bytes, not entries: an operator who asks for a million
|
||||
/// entries is asking for well over a gigabyte of queue. This is a sanity bound,
|
||||
/// not a memory-fit guarantee — what actually fits depends on the box.
|
||||
pub const query_log_buffer_max: u32 = @intCast(queue_budget_bytes / @sizeOf(Entry));
|
||||
|
||||
/// Copies as much of `value` as `buf` holds, and stores the length through
|
||||
/// `len`. `len`'s type never bounds anything — `buf.len` does — so the same
|
||||
/// helper serves the `u8` fields and the `u16` ones.
|
||||
fn copyInto(buf: []u8, len: anytype, value: []const u8) void {
|
||||
const n = @min(buf.len, value.len);
|
||||
@memcpy(buf[0..n], value[0..n]);
|
||||
return @intCast(n);
|
||||
len.* = @intCast(n);
|
||||
}
|
||||
|
||||
/// The row borrows from `entry`, which must outlive the `writeBatch` call.
|
||||
@@ -157,11 +294,23 @@ fn toRow(entry: *const Entry) queries_repo.Row {
|
||||
.domain = entry.domain(),
|
||||
.client_ip = entry.clientIp(),
|
||||
.qtype = entry.qtype,
|
||||
.qclass = entry.qclass,
|
||||
.rcode = entry.rcode,
|
||||
.blocked = entry.blocked,
|
||||
.block_reason = emptyAsNull(entry.blockReason()),
|
||||
.response_time_us = entry.response_time_us,
|
||||
.cache_hit = entry.cache_hit,
|
||||
.upstream = emptyAsNull(entry.upstream()),
|
||||
.group_id = entry.group_id,
|
||||
.group_name = emptyAsNull(entry.groupName()),
|
||||
.policy_action = entry.policy_action,
|
||||
.policy_reason = entry.policy_reason,
|
||||
.matched = emptyAsNull(entry.matched()),
|
||||
.source_id = entry.source_id,
|
||||
.source_name = emptyAsNull(entry.sourceName()),
|
||||
.cname_target = emptyAsNull(entry.cnameTarget()),
|
||||
.safe_search_target = emptyAsNull(entry.safeSearchTarget()),
|
||||
.route_kind = entry.route_kind,
|
||||
.forward_zone = emptyAsNull(entry.forwardZone()),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -231,9 +380,23 @@ pub const Logger = struct {
|
||||
/// and hands the result to every consumer, so nothing downstream — the
|
||||
/// database or the event stream — can observe a value the operator asked
|
||||
/// to hide.
|
||||
/// `hide_domains` covers every field derived from the query name, not just
|
||||
/// `domain`: a matched wildcard, a CNAME target and a safe-search target
|
||||
/// each name the very thing the operator asked to keep out of the log.
|
||||
///
|
||||
/// `forward_zone`, `group_name` and `source_name` stay visible. They are
|
||||
/// configuration labels the operator wrote, identical on every row that
|
||||
/// hits them, and they say nothing about which name a client looked up.
|
||||
pub fn transformed(self: *const Logger, entry: Entry) Entry {
|
||||
var out = entry;
|
||||
if (self.cfg.hide_domains) out.setDomain(hidden_marker);
|
||||
if (self.cfg.hide_domains) {
|
||||
out.setDomain(hidden_marker);
|
||||
// Only where there is something to hide: an empty field means the
|
||||
// query had no such value, and writing a marker would claim it did.
|
||||
if (out.matched_len != 0) out.setMatched(hidden_marker);
|
||||
if (out.cname_len != 0) out.setCnameTarget(hidden_marker);
|
||||
if (out.safe_search_len != 0) out.setSafeSearchTarget(hidden_marker);
|
||||
}
|
||||
if (self.cfg.hide_client_ips) out.setClientIp(hidden_marker);
|
||||
return out;
|
||||
}
|
||||
@@ -574,6 +737,34 @@ fn sampleEntry(timestamp: i64, domain: []const u8) Entry {
|
||||
});
|
||||
}
|
||||
|
||||
/// Every provenance field set to a distinct recognisable value, so a test that
|
||||
/// loses one loses it visibly.
|
||||
fn fullFields(timestamp: i64) Entry.Fields {
|
||||
return .{
|
||||
.timestamp = timestamp,
|
||||
.domain = "ads.example.com",
|
||||
.client_ip = "2001:db8::1",
|
||||
.qtype = 28,
|
||||
.qclass = 1,
|
||||
.rcode = 3,
|
||||
.blocked = true,
|
||||
.response_time_us = 42,
|
||||
.cache_hit = true,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
.group_id = 7,
|
||||
.group_name = "kids",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_wildcard,
|
||||
.matched = "*.ads.example",
|
||||
.source_id = 3,
|
||||
.source_name = "steven black",
|
||||
.cname_target = "tracker.cdn.example",
|
||||
.safe_search_target = "forcesafesearch.google.com",
|
||||
.route_kind = .blocked,
|
||||
.forward_zone = "home.arpa",
|
||||
};
|
||||
}
|
||||
|
||||
fn openLog() !db.Db {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
errdefer database.close();
|
||||
@@ -583,44 +774,72 @@ fn openLog() !db.Db {
|
||||
}
|
||||
|
||||
test "an entry carries its own bytes and reads them back" {
|
||||
const entry: Entry = .init(.{
|
||||
.timestamp = 1700000000,
|
||||
.domain = "ads.example.com",
|
||||
.client_ip = "2001:db8::1",
|
||||
.qtype = 28,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist",
|
||||
.response_time_us = 42,
|
||||
.cache_hit = true,
|
||||
.upstream = "dns.example",
|
||||
});
|
||||
const entry: Entry = .init(fullFields(1700000000));
|
||||
|
||||
try testing.expectEqualStrings("ads.example.com", entry.domain());
|
||||
try testing.expectEqualStrings("2001:db8::1", entry.clientIp());
|
||||
try testing.expectEqualStrings("blocklist", entry.blockReason());
|
||||
try testing.expectEqualStrings("dns.example", entry.upstream());
|
||||
try testing.expectEqualStrings("https://dns.example/dns-query", entry.upstream());
|
||||
try testing.expectEqual(@as(?u16, 28), entry.qtype);
|
||||
try testing.expectEqual(@as(u16, 1), entry.qclass);
|
||||
try testing.expectEqual(@as(u12, 3), entry.rcode);
|
||||
try testing.expect(entry.blocked);
|
||||
try testing.expectEqual(@as(?i64, 42), entry.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, true), entry.cache_hit);
|
||||
|
||||
try testing.expectEqual(@as(?i64, 7), entry.group_id);
|
||||
try testing.expectEqualStrings("kids", entry.groupName());
|
||||
try testing.expectEqual(provenance.PolicyAction.block, entry.policy_action);
|
||||
try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, entry.policy_reason);
|
||||
try testing.expectEqualStrings("*.ads.example", entry.matched());
|
||||
try testing.expectEqual(@as(?i64, 3), entry.source_id);
|
||||
try testing.expectEqualStrings("steven black", entry.sourceName());
|
||||
try testing.expectEqualStrings("tracker.cdn.example", entry.cnameTarget());
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", entry.safeSearchTarget());
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, entry.route_kind);
|
||||
try testing.expectEqualStrings("home.arpa", entry.forwardZone());
|
||||
}
|
||||
|
||||
test "an oversize string is truncated to what its buffer holds" {
|
||||
const long_domain = "a" ** 400;
|
||||
const entry: Entry = .init(.{
|
||||
.timestamp = 1,
|
||||
.domain = long_domain,
|
||||
.client_ip = "192.0.2.1",
|
||||
.block_reason = "r" ** 64,
|
||||
.upstream = "u" ** 128,
|
||||
.domain = "a" ** 400,
|
||||
.client_ip = "c" ** 80,
|
||||
.upstream = "u" ** 600,
|
||||
.group_name = "g" ** 200,
|
||||
.matched = "m" ** 600,
|
||||
.source_name = "s" ** 200,
|
||||
.cname_target = "n" ** 400,
|
||||
.safe_search_target = "f" ** 400,
|
||||
.forward_zone = "z" ** 400,
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(usize, max_domain_len), entry.domain().len);
|
||||
try testing.expectEqual(@as(usize, max_reason_len), entry.blockReason().len);
|
||||
try testing.expectEqual(@as(usize, max_client_len), entry.clientIp().len);
|
||||
try testing.expectEqual(@as(usize, max_upstream_len), entry.upstream().len);
|
||||
try testing.expectEqual(@as(usize, limits.max_group_name_len), entry.groupName().len);
|
||||
try testing.expectEqual(@as(usize, max_matched_len), entry.matched().len);
|
||||
try testing.expectEqual(@as(usize, limits.max_source_name_len), entry.sourceName().len);
|
||||
try testing.expectEqual(@as(usize, max_name_len), entry.cnameTarget().len);
|
||||
try testing.expectEqual(@as(usize, max_name_len), entry.safeSearchTarget().len);
|
||||
try testing.expectEqual(@as(usize, max_name_len), entry.forwardZone().len);
|
||||
try testing.expectEqualStrings("a" ** max_domain_len, entry.domain());
|
||||
}
|
||||
|
||||
test "a 256-byte matched pattern is stored whole" {
|
||||
// The widest rule the configuration accepts is a regex at
|
||||
// `regex.max_pattern_len`, and it does not fit a `u8` length — which is the
|
||||
// whole reason `matched_len` is a `u16`.
|
||||
const widest = "p" ** regex.max_pattern_len;
|
||||
const entry: Entry = .init(.{
|
||||
.timestamp = 1,
|
||||
.domain = "example.com",
|
||||
.client_ip = "192.0.2.1",
|
||||
.matched = widest,
|
||||
});
|
||||
try testing.expectEqualStrings(widest, entry.matched());
|
||||
try testing.expectEqual(@as(u16, regex.max_pattern_len), entry.matched_len);
|
||||
}
|
||||
|
||||
test "toRow maps the empty strings to null and passes the rest through" {
|
||||
const bare: Entry = .init(.{
|
||||
.timestamp = 7,
|
||||
@@ -631,23 +850,85 @@ test "toRow maps the empty strings to null and passes the rest through" {
|
||||
try testing.expectEqual(@as(i64, 7), bare_row.timestamp);
|
||||
try testing.expectEqualStrings("example.com", bare_row.domain);
|
||||
try testing.expectEqualStrings("192.0.2.5", bare_row.client_ip);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.block_reason);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.upstream);
|
||||
try testing.expectEqual(@as(?u16, null), bare_row.qtype);
|
||||
try testing.expectEqual(@as(?bool, null), bare_row.cache_hit);
|
||||
// Every optional text field of an entry nothing filled in reaches its
|
||||
// column as NULL rather than as an empty string.
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.upstream);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.group_name);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.matched);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.source_name);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.cname_target);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.safe_search_target);
|
||||
try testing.expectEqual(@as(?[]const u8, null), bare_row.forward_zone);
|
||||
|
||||
const full: Entry = .init(.{
|
||||
.timestamp = 8,
|
||||
.domain = "blocked.example",
|
||||
.client_ip = "192.0.2.6",
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist",
|
||||
.upstream = "9.9.9.9",
|
||||
});
|
||||
const full: Entry = .init(fullFields(8));
|
||||
const full_row = toRow(&full);
|
||||
try testing.expect(full_row.blocked);
|
||||
try testing.expectEqualStrings("blocklist", full_row.block_reason.?);
|
||||
try testing.expectEqualStrings("9.9.9.9", full_row.upstream.?);
|
||||
try testing.expectEqual(@as(u16, 1), full_row.qclass);
|
||||
try testing.expectEqual(@as(u12, 3), full_row.rcode);
|
||||
try testing.expectEqualStrings("https://dns.example/dns-query", full_row.upstream.?);
|
||||
try testing.expectEqual(@as(?i64, 7), full_row.group_id);
|
||||
try testing.expectEqualStrings("kids", full_row.group_name.?);
|
||||
try testing.expectEqual(provenance.PolicyAction.block, full_row.policy_action);
|
||||
try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, full_row.policy_reason);
|
||||
try testing.expectEqualStrings("*.ads.example", full_row.matched.?);
|
||||
try testing.expectEqual(@as(?i64, 3), full_row.source_id);
|
||||
try testing.expectEqualStrings("steven black", full_row.source_name.?);
|
||||
try testing.expectEqualStrings("tracker.cdn.example", full_row.cname_target.?);
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", full_row.safe_search_target.?);
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, full_row.route_kind);
|
||||
try testing.expectEqualStrings("home.arpa", full_row.forward_zone.?);
|
||||
}
|
||||
|
||||
test "an entry with every provenance field set survives the queue, toRow, insert and detailById" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try queries_repo.BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{}, &buf);
|
||||
|
||||
// The widest `matched` the configuration accepts, carried the whole way:
|
||||
// 256 bytes does not fit the `u8` length every other text field uses.
|
||||
const widest_matched = "p" ** max_matched_len;
|
||||
var fields = fullFields(1234);
|
||||
fields.matched = widest_matched;
|
||||
logger.log(io, .init(fields));
|
||||
|
||||
const queued = try logger.queue.getOne(io);
|
||||
try logger.flush(io, &writer, &.{queued}, null);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const stored = (try queries_repo.detailById(&database, arena_state.allocator(), 1)).?;
|
||||
|
||||
try testing.expectEqual(@as(i64, 1234), stored.ts);
|
||||
try testing.expectEqualStrings("ads.example.com", stored.domain);
|
||||
try testing.expectEqualStrings("2001:db8::1", stored.client_ip);
|
||||
try testing.expectEqual(@as(?u16, 28), stored.qtype);
|
||||
try testing.expectEqual(@as(u16, 1), stored.qclass);
|
||||
try testing.expectEqual(@as(u12, 3), stored.rcode);
|
||||
try testing.expect(stored.blocked);
|
||||
try testing.expectEqual(@as(?i64, 42), stored.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, true), stored.cache_hit);
|
||||
try testing.expectEqualStrings("https://dns.example/dns-query", stored.upstream);
|
||||
try testing.expectEqual(@as(?i64, 7), stored.group_id);
|
||||
try testing.expectEqualStrings("kids", stored.group_name);
|
||||
try testing.expectEqual(provenance.PolicyAction.block, stored.policy_action);
|
||||
try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, stored.policy_reason);
|
||||
try testing.expectEqualStrings(widest_matched, stored.matched);
|
||||
try testing.expectEqual(@as(?i64, 3), stored.source_id);
|
||||
try testing.expectEqualStrings("steven black", stored.source_name);
|
||||
try testing.expectEqualStrings("tracker.cdn.example", stored.cname_target);
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", stored.safe_search_target);
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, stored.route_kind);
|
||||
try testing.expectEqualStrings("home.arpa", stored.forward_zone);
|
||||
}
|
||||
|
||||
test "log applies both privacy transforms before the entry reaches the queue" {
|
||||
@@ -667,6 +948,88 @@ test "log applies both privacy transforms before the entry reaches the queue" {
|
||||
try testing.expectEqual(@as(u64, 0), logger.queries_dropped.load(.monotonic));
|
||||
}
|
||||
|
||||
test "hide_domains hides every query-derived name and leaves the labels alone" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{ .hide_domains = true }, &buf);
|
||||
logger.log(io, .init(fullFields(1)));
|
||||
const hidden = try logger.queue.getOne(io);
|
||||
|
||||
// Every field derived from the name the client asked for.
|
||||
try testing.expectEqualStrings(hidden_marker, hidden.domain());
|
||||
try testing.expectEqualStrings(hidden_marker, hidden.matched());
|
||||
try testing.expectEqualStrings(hidden_marker, hidden.cnameTarget());
|
||||
try testing.expectEqualStrings(hidden_marker, hidden.safeSearchTarget());
|
||||
|
||||
// The client is governed by `hide_client_ips`, not by this flag.
|
||||
try testing.expectEqualStrings("2001:db8::1", hidden.clientIp());
|
||||
|
||||
// Configuration labels the operator wrote. They are identical on every row
|
||||
// that hits them and say nothing about which name a client looked up.
|
||||
try testing.expectEqualStrings("kids", hidden.groupName());
|
||||
try testing.expectEqualStrings("steven black", hidden.sourceName());
|
||||
try testing.expectEqualStrings("home.arpa", hidden.forwardZone());
|
||||
try testing.expectEqualStrings("https://dns.example/dns-query", hidden.upstream());
|
||||
}
|
||||
|
||||
test "hide_client_ips hides the client and nothing else" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{ .hide_client_ips = true }, &buf);
|
||||
logger.log(io, .init(fullFields(1)));
|
||||
const hidden = try logger.queue.getOne(io);
|
||||
|
||||
try testing.expectEqualStrings(hidden_marker, hidden.clientIp());
|
||||
try testing.expectEqualStrings("ads.example.com", hidden.domain());
|
||||
try testing.expectEqualStrings("*.ads.example", hidden.matched());
|
||||
try testing.expectEqualStrings("tracker.cdn.example", hidden.cnameTarget());
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", hidden.safeSearchTarget());
|
||||
try testing.expectEqualStrings("kids", hidden.groupName());
|
||||
try testing.expectEqualStrings("steven black", hidden.sourceName());
|
||||
try testing.expectEqualStrings("home.arpa", hidden.forwardZone());
|
||||
}
|
||||
|
||||
test "hide_domains writes no marker into a field the query never had" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var buf: [4]Entry = undefined;
|
||||
var logger: Logger = .init(.{ .hide_domains = true }, &buf);
|
||||
// An ordinary allowed query: no rule matched, no CNAME was uncloaked, no
|
||||
// safe-search rewrite happened. Marking those "hidden" would claim the
|
||||
// query had values it did not.
|
||||
logger.log(io, sampleEntry(1, "plain.example"));
|
||||
const hidden = try logger.queue.getOne(io);
|
||||
|
||||
try testing.expectEqualStrings(hidden_marker, hidden.domain());
|
||||
try testing.expectEqualStrings("", hidden.matched());
|
||||
try testing.expectEqualStrings("", hidden.cnameTarget());
|
||||
try testing.expectEqualStrings("", hidden.safeSearchTarget());
|
||||
}
|
||||
|
||||
test "the entry queue's worst case stays inside its byte budget" {
|
||||
// The bound `config/validate.zig` enforces is derived from this, so the
|
||||
// budget is what a maximal configuration can actually cost.
|
||||
try testing.expect(@as(usize, query_log_buffer_max) * @sizeOf(Entry) <= queue_budget_bytes);
|
||||
// One more entry than the ceiling would exceed it, so the ceiling is the
|
||||
// largest value that fits rather than a round number under it.
|
||||
try testing.expect((@as(usize, query_log_buffer_max) + 1) * @sizeOf(Entry) > queue_budget_bytes);
|
||||
|
||||
// The shipped default has to be comfortably inside the budget, or the
|
||||
// out-of-the-box configuration is the one that spends it. At the widths
|
||||
// above it costs about 17 MiB, roughly a quarter of the ceiling.
|
||||
const default_max: usize = (model.Logging{}).query_log_buffer_max;
|
||||
try testing.expect(default_max <= query_log_buffer_max);
|
||||
try testing.expect(default_max * @sizeOf(Entry) <= queue_budget_bytes / 2);
|
||||
}
|
||||
|
||||
test "log hides only the field its switch names" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -154,11 +154,23 @@ fn writeRows(database: *db.Db, timestamps: []const i64, domain: []const u8) !voi
|
||||
.domain = domain,
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = false,
|
||||
.block_reason = null,
|
||||
.response_time_us = null,
|
||||
.cache_hit = null,
|
||||
.upstream = null,
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = .allow,
|
||||
.policy_reason = .no_match,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = .upstream,
|
||||
.forward_zone = null,
|
||||
};
|
||||
}
|
||||
try writer.writeBatch(rows[0..timestamps.len]);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
//! The closed enums a `query_log` row stores to explain one query: what the
|
||||
//! policy decided, why, and where the answer came from.
|
||||
//!
|
||||
//! They live in a module of their own because everything that touches a logged
|
||||
//! row needs them — `storage/logger.zig`, `storage/repositories/queries_repo.zig`,
|
||||
//! `server/handler.zig` and the web serializers — and `logger` already imports
|
||||
//! `queries_repo`, so enums owned by either would close a loop.
|
||||
//!
|
||||
//! Each value is stored as its `@tagName` and read back through `parse`. The
|
||||
//! read path treats an unrecognised value as a data error rather than passing
|
||||
//! the text through: the column is a closed set, and a row that disagrees came
|
||||
//! from something other than this schema.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const matcher = @import("../filter/matcher.zig");
|
||||
|
||||
/// Whether the filtering policy reached a verdict on this query, and which one.
|
||||
///
|
||||
/// `not_evaluated` is the honest answer for a query the pipeline answered before
|
||||
/// filtering could apply — a non-IN question, a paused resolver, a protocol
|
||||
/// refusal — and is not the same as "allowed".
|
||||
pub const PolicyAction = enum {
|
||||
not_evaluated,
|
||||
allow,
|
||||
block,
|
||||
};
|
||||
|
||||
/// Why the policy landed where it did.
|
||||
///
|
||||
/// The first nine are `filter/matcher.zig`'s serializable reasons, one for one.
|
||||
/// The rest name the pipeline steps that decide a query without consulting the
|
||||
/// matcher at all.
|
||||
pub const PolicyReason = enum {
|
||||
rule_allow_exact,
|
||||
rule_block_exact,
|
||||
rule_allow_wildcard,
|
||||
rule_block_wildcard,
|
||||
rule_allow_regex,
|
||||
rule_block_regex,
|
||||
blocklist_exception,
|
||||
blocklist_domain,
|
||||
blocklist_wildcard,
|
||||
|
||||
/// Answered from `local_records`, before filtering.
|
||||
local_record,
|
||||
/// Answered by a configured forward zone, before filtering.
|
||||
forward_zone,
|
||||
/// The question was not class IN, so no rule could apply to it.
|
||||
non_in_class,
|
||||
/// Filtering was paused.
|
||||
paused,
|
||||
/// No filter snapshot was published yet, so the query went unfiltered.
|
||||
snapshot_unavailable,
|
||||
/// The matcher evaluated the name and nothing matched.
|
||||
no_match,
|
||||
/// A syntactically parsed request refused on protocol grounds — BADVERS,
|
||||
/// NOTIMP, a malformed EDNS OPT. It names a question, so it is logged, but
|
||||
/// no policy ever saw it.
|
||||
protocol_error,
|
||||
};
|
||||
|
||||
/// Where the answer the client received came from.
|
||||
pub const RouteKind = enum {
|
||||
blocked,
|
||||
local,
|
||||
forward_zone,
|
||||
upstream,
|
||||
cache,
|
||||
rejected,
|
||||
};
|
||||
|
||||
/// The matcher's verdict in the query log's vocabulary.
|
||||
///
|
||||
/// Exhaustive on purpose: a reason added to the matcher must be given a stored
|
||||
/// name here rather than silently reaching a row as something else. `.none` is
|
||||
/// the matcher's "nothing matched", which is exactly `no_match`.
|
||||
pub fn fromMatcherReason(reason: matcher.Reason) PolicyReason {
|
||||
return switch (reason) {
|
||||
.none => .no_match,
|
||||
.rule_allow_exact => .rule_allow_exact,
|
||||
.rule_block_exact => .rule_block_exact,
|
||||
.rule_allow_wildcard => .rule_allow_wildcard,
|
||||
.rule_block_wildcard => .rule_block_wildcard,
|
||||
.rule_allow_regex => .rule_allow_regex,
|
||||
.rule_block_regex => .rule_block_regex,
|
||||
.blocklist_exception => .blocklist_exception,
|
||||
.blocklist_domain => .blocklist_domain,
|
||||
.blocklist_wildcard => .blocklist_wildcard,
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads a stored `@tagName` back. `error.Mismatch` is the same error the
|
||||
/// repositories return for a column that does not hold what the schema says it
|
||||
/// holds, which is what an unknown value here is.
|
||||
pub fn parse(comptime Enum: type, text: []const u8) error{Mismatch}!Enum {
|
||||
return std.meta.stringToEnum(Enum, text) orelse error.Mismatch;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "every matcher reason has a stored name" {
|
||||
// The mapping is checked here rather than trusted: a reason added to the
|
||||
// matcher fails the exhaustive switch at compile time, and a reason
|
||||
// *renamed* would still compile while changing what a row says.
|
||||
inline for (@typeInfo(matcher.Reason).@"enum".fields) |field| {
|
||||
const reason: matcher.Reason = @enumFromInt(field.value);
|
||||
const mapped = fromMatcherReason(reason);
|
||||
if (reason == .none) {
|
||||
try testing.expectEqual(PolicyReason.no_match, mapped);
|
||||
} else {
|
||||
try testing.expectEqualStrings(field.name, @tagName(mapped));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "parse round-trips every value of every enum" {
|
||||
inline for ([_]type{ PolicyAction, PolicyReason, RouteKind }) |Enum| {
|
||||
inline for (@typeInfo(Enum).@"enum".fields) |field| {
|
||||
const value: Enum = @enumFromInt(field.value);
|
||||
try testing.expectEqual(value, try parse(Enum, @tagName(value)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "parse rejects a value the schema does not define" {
|
||||
try testing.expectError(error.Mismatch, parse(PolicyAction, "allowed"));
|
||||
try testing.expectError(error.Mismatch, parse(PolicyAction, ""));
|
||||
// A value that belongs to a different one of the three enums is no more
|
||||
// acceptable than a typo.
|
||||
try testing.expectError(error.Mismatch, parse(RouteKind, "allow"));
|
||||
try testing.expectError(error.Mismatch, parse(PolicyReason, "cache"));
|
||||
}
|
||||
@@ -22,8 +22,20 @@ const db = @import("db.zig");
|
||||
|
||||
const log = std.log.scoped(.querylog_schema);
|
||||
|
||||
/// Verbatim from PLAN §11.3. Multi-statement text — it goes through
|
||||
/// `db.Db.exec`, never through `prepare`.
|
||||
/// PLAN §11.3, plus the coverage watermark of milestone 28. Multi-statement
|
||||
/// text — it goes through `db.Db.exec`, never through `prepare`.
|
||||
///
|
||||
/// The trailing INSERT seeds `querylog_meta`, which is part of the schema
|
||||
/// rather than a later step: a `query_log` with no watermark beside it cannot
|
||||
/// answer whether an empty result means "no queries" or "no history", and every
|
||||
/// database this program reads from is created by executing this string.
|
||||
/// `unixepoch()` is SQLite's own UTC clock, which is the clock every
|
||||
/// `timestamp` in the file is measured against.
|
||||
///
|
||||
/// `available_since` starts one second *after* `created_at` on purpose. A row
|
||||
/// logged in the same second the file was created is not evidence that the
|
||||
/// second is completely covered, and the watermark's whole job is to be
|
||||
/// conservative. From there it only ever advances, in `queries_repo.pruneOlderThan`.
|
||||
pub const ddl: [:0]const u8 =
|
||||
\\CREATE TABLE domains (
|
||||
\\ id INTEGER PRIMARY KEY,
|
||||
@@ -37,10 +49,23 @@ pub const ddl: [:0]const u8 =
|
||||
\\ client_ip TEXT NOT NULL, -- text, not a FK: log rows are immutable facts
|
||||
\\ qtype INTEGER,
|
||||
\\ blocked INTEGER NOT NULL,
|
||||
\\ block_reason TEXT,
|
||||
\\ response_time_us INTEGER,
|
||||
\\ cache_hit INTEGER,
|
||||
\\ upstream TEXT
|
||||
\\ upstream TEXT,
|
||||
\\ qclass INTEGER NOT NULL,
|
||||
\\ rcode INTEGER NOT NULL,
|
||||
\\ group_id INTEGER, -- text/id pairs, not FKs: a renamed
|
||||
\\ group_name TEXT, -- group must not rewrite history
|
||||
\\ policy_action TEXT NOT NULL,
|
||||
\\ policy_reason TEXT NOT NULL,
|
||||
\\ matched TEXT,
|
||||
\\ source_id INTEGER,
|
||||
\\ source_name TEXT,
|
||||
\\ cname_target TEXT,
|
||||
\\ safe_search_target TEXT,
|
||||
\\ route_kind TEXT NOT NULL,
|
||||
\\ forward_zone TEXT,
|
||||
\\ CHECK (rcode BETWEEN 0 AND 4095) -- twelve bits (RFC 6891 6.1.3)
|
||||
\\);
|
||||
\\CREATE INDEX idx_query_log_ts ON query_log(timestamp);
|
||||
\\CREATE INDEX idx_query_log_client ON query_log(client_ip);
|
||||
@@ -63,6 +88,14 @@ pub const ddl: [:0]const u8 =
|
||||
\\ CHECK (failures >= 0)
|
||||
\\) WITHOUT ROWID;
|
||||
\\CREATE INDEX idx_upstream_minute_ts ON upstream_minute(minute_ts);
|
||||
\\
|
||||
\\CREATE TABLE querylog_meta (
|
||||
\\ id INTEGER PRIMARY KEY CHECK (id = 1), -- one row, enforced by the schema
|
||||
\\ created_at INTEGER NOT NULL,
|
||||
\\ available_since INTEGER NOT NULL
|
||||
\\);
|
||||
\\INSERT INTO querylog_meta (id, created_at, available_since)
|
||||
\\VALUES (1, unixepoch(), unixepoch() + 1);
|
||||
;
|
||||
|
||||
/// `PRAGMA user_version` is a signed 32-bit field. Deriving the fingerprint from
|
||||
@@ -299,7 +332,7 @@ test "ddl creates the query-log tables, the upstream-history tables and every in
|
||||
try database.exec(ddl);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(i64, 4),
|
||||
@as(i64, 5),
|
||||
try database.queryInt("SELECT count(*) FROM sqlite_schema WHERE type='table'"),
|
||||
);
|
||||
const objects = [_][]const u8{
|
||||
@@ -307,6 +340,7 @@ test "ddl creates the query-log tables, the upstream-history tables and every in
|
||||
"idx_query_log_ts", "idx_query_log_client",
|
||||
"idx_query_log_domain", "upstream_targets",
|
||||
"upstream_minute", "idx_upstream_minute_ts",
|
||||
"querylog_meta",
|
||||
};
|
||||
for (objects) |name| {
|
||||
var stmt = try database.prepare("SELECT count(*) FROM sqlite_schema WHERE name = ?1");
|
||||
@@ -317,6 +351,69 @@ test "ddl creates the query-log tables, the upstream-history tables and every in
|
||||
}
|
||||
}
|
||||
|
||||
test "the schema refuses an rcode outside twelve bits" {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
try database.exec(ddl);
|
||||
try database.exec("INSERT INTO domains (id, domain) VALUES (1, 'a.example');");
|
||||
|
||||
var stmt = try database.prepare(
|
||||
\\INSERT INTO query_log
|
||||
\\ (timestamp, domain_id, client_ip, blocked, qclass, rcode,
|
||||
\\ policy_action, policy_reason, route_kind)
|
||||
\\VALUES (1, 1, '10.0.0.1', 0, 1, ?1, 'not_evaluated', 'no_match', 'upstream')
|
||||
);
|
||||
defer stmt.deinit();
|
||||
|
||||
// The whole range an EDNS extended RCODE can express, and nothing wider:
|
||||
// the producers are `u12`, and this is what stops any other writer — a
|
||||
// hand-run UPDATE included — from putting a value in the column that the
|
||||
// read path would have to reject.
|
||||
for ([_]i64{ 0, 4095 }) |accepted| {
|
||||
try stmt.reset();
|
||||
try stmt.bindInt(1, accepted);
|
||||
try stmt.exec();
|
||||
}
|
||||
for ([_]i64{ -1, 4096, 65535 }) |refused| {
|
||||
// `sqlite3_reset` repeats the error of the statement it is resetting,
|
||||
// which for every iteration after the first is the constraint failure
|
||||
// this loop just asserted — the same reason `BatchWriter.resetAll`
|
||||
// discards it.
|
||||
stmt.reset() catch {};
|
||||
try stmt.bindInt(1, refused);
|
||||
try testing.expectError(error.Constraint, stmt.exec());
|
||||
}
|
||||
|
||||
try testing.expectEqual(@as(i64, 2), try database.queryInt("SELECT count(*) FROM query_log"));
|
||||
}
|
||||
|
||||
test "querylog_meta is seeded with one row the schema will not let a second join" {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
try database.exec(ddl);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM querylog_meta"));
|
||||
|
||||
const created = try database.queryInt("SELECT created_at FROM querylog_meta");
|
||||
const since = try database.queryInt("SELECT available_since FROM querylog_meta");
|
||||
// Conservative by exactly one second: a row logged in the creating second
|
||||
// must not let a query claim that second is completely covered.
|
||||
try testing.expectEqual(created + 1, since);
|
||||
try testing.expect(created > 1_700_000_000);
|
||||
|
||||
// `CHECK (id = 1)` is what makes "the singleton row" a schema fact rather
|
||||
// than a convention the read path has to defend against.
|
||||
try testing.expectError(error.Constraint, database.exec(
|
||||
"INSERT INTO querylog_meta (id, created_at, available_since) VALUES (2, 1, 1);",
|
||||
));
|
||||
try testing.expectError(error.Constraint, database.exec(
|
||||
"INSERT INTO querylog_meta (id, created_at, available_since) VALUES (1, 1, 1);",
|
||||
));
|
||||
try testing.expectEqual(@as(i64, 1), try database.queryInt("SELECT count(*) FROM querylog_meta"));
|
||||
}
|
||||
|
||||
test "the user_version statement stamps the fingerprint" {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
@@ -394,6 +491,57 @@ test "a recreate returns the aside name by value and a fresh create returns none
|
||||
try tmp.dir.access(io, kept, .{});
|
||||
}
|
||||
|
||||
test "a recreate resets coverage to the new file and keeps the old one aside" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var tmp = testing.tmpDir(.{ .iterate = true });
|
||||
defer tmp.cleanup();
|
||||
|
||||
var path_buf: [path_buf_len]u8 = undefined;
|
||||
const path = try std.fmt.bufPrintZ(&path_buf, ".zig-cache/tmp/{s}/querylog.db", .{tmp.sub_path});
|
||||
|
||||
var created = try open(io, std.Io.Dir.cwd(), path);
|
||||
const first_coverage = try created.database.queryInt("SELECT available_since FROM querylog_meta");
|
||||
// A row in the file the operator is about to lose.
|
||||
try created.database.exec("INSERT INTO domains (domain) VALUES ('old.example');");
|
||||
created.database.close();
|
||||
|
||||
// A healthy file this build's DDL no longer matches — the case milestone
|
||||
// 28's own schema edit produces on every upgrade.
|
||||
{
|
||||
var stamped = try db.Db.open(path, .{ .mode = .read_write_existing });
|
||||
defer stamped.close();
|
||||
var sql_buf: [64]u8 = undefined;
|
||||
try stamped.exec(try std.fmt.bufPrintZ(&sql_buf, "PRAGMA user_version = {d};", .{fingerprint +% 1}));
|
||||
}
|
||||
|
||||
var recreated = try open(io, std.Io.Dir.cwd(), path);
|
||||
defer recreated.database.close();
|
||||
|
||||
try testing.expectEqual(RecreateReason.fingerprint_mismatch, recreated.recreated.?);
|
||||
// The name says the file was healthy and this build moved, not that it rotted.
|
||||
try testing.expect(std.mem.indexOf(u8, recreated.aside(), ".schema-changed-") != null);
|
||||
try tmp.dir.access(io, std.fs.path.basename(recreated.aside()), .{});
|
||||
|
||||
// Exactly one meta row, and coverage starts at the recreate rather than
|
||||
// carrying the replaced file's promise forward.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 1),
|
||||
try recreated.database.queryInt("SELECT count(*) FROM querylog_meta"),
|
||||
);
|
||||
const new_coverage = try recreated.database.queryInt("SELECT available_since FROM querylog_meta");
|
||||
try testing.expect(new_coverage >= first_coverage);
|
||||
|
||||
// Nothing of the old file came across: the history is genuinely gone, which
|
||||
// is what the coverage start has to tell the operator.
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try recreated.database.queryInt("SELECT count(*) FROM domains"),
|
||||
);
|
||||
}
|
||||
|
||||
test "a clean reopen reports no recreate and no aside" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
|
||||
@@ -19,20 +19,42 @@ const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const db = @import("../db.zig");
|
||||
const provenance = @import("../provenance.zig");
|
||||
|
||||
/// One `query_log` row. The logger applies the privacy transforms of PLAN
|
||||
/// §11.4 before it builds this, so `domain` and `client_ip` are already
|
||||
/// §11.4 before it builds this, so every domain-bearing field is already
|
||||
/// whatever the operator agreed to store.
|
||||
///
|
||||
/// A `null` text field is a fact the query did not have — no upstream was
|
||||
/// attempted, no rule matched, no CNAME was uncloaked — and reaches the column
|
||||
/// as NULL. The three closed enums have no such state: every logged query has a
|
||||
/// policy verdict, a reason for it and a route, even when the verdict is
|
||||
/// "not evaluated".
|
||||
pub const Row = struct {
|
||||
timestamp: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
qclass: u16,
|
||||
/// Twelve bits: the EDNS extended RCODE the client saw. The column's
|
||||
/// `CHECK` bounds it to the same range, so a value this type cannot hold
|
||||
/// is one the schema would have refused anyway.
|
||||
rcode: u12,
|
||||
blocked: bool,
|
||||
block_reason: ?[]const u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: ?[]const u8,
|
||||
group_id: ?i64,
|
||||
group_name: ?[]const u8,
|
||||
policy_action: provenance.PolicyAction,
|
||||
policy_reason: provenance.PolicyReason,
|
||||
matched: ?[]const u8,
|
||||
source_id: ?i64,
|
||||
source_name: ?[]const u8,
|
||||
cname_target: ?[]const u8,
|
||||
safe_search_target: ?[]const u8,
|
||||
route_kind: provenance.RouteKind,
|
||||
forward_zone: ?[]const u8,
|
||||
};
|
||||
|
||||
const insert_domain_sql = "INSERT OR IGNORE INTO domains (domain) VALUES (?1)";
|
||||
@@ -41,9 +63,13 @@ const select_domain_sql = "SELECT id FROM domains WHERE domain = ?1";
|
||||
|
||||
const insert_row_sql =
|
||||
\\INSERT INTO query_log
|
||||
\\ (timestamp, domain_id, client_ip, qtype, blocked, block_reason,
|
||||
\\ response_time_us, cache_hit, upstream)
|
||||
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
\\ (timestamp, domain_id, client_ip, qtype, blocked,
|
||||
\\ response_time_us, cache_hit, upstream, qclass, rcode,
|
||||
\\ group_id, group_name, policy_action, policy_reason, matched,
|
||||
\\ source_id, source_name, cname_target, safe_search_target,
|
||||
\\ route_kind, forward_zone)
|
||||
\\VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10,
|
||||
\\ ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21)
|
||||
;
|
||||
|
||||
/// Owns the prepared statements of the flush loop. Init once, reuse per batch.
|
||||
@@ -123,10 +149,22 @@ pub const BatchWriter = struct {
|
||||
try stmt.bindText(3, row.client_ip);
|
||||
try bindIntOrNull(stmt, 4, if (row.qtype) |v| @as(i64, v) else null);
|
||||
try stmt.bindBool(5, row.blocked);
|
||||
try stmt.bindTextOrNull(6, row.block_reason);
|
||||
try bindIntOrNull(stmt, 7, row.response_time_us);
|
||||
try bindIntOrNull(stmt, 8, if (row.cache_hit) |v| @as(i64, @intFromBool(v)) else null);
|
||||
try stmt.bindTextOrNull(9, row.upstream);
|
||||
try bindIntOrNull(stmt, 6, row.response_time_us);
|
||||
try bindIntOrNull(stmt, 7, if (row.cache_hit) |v| @as(i64, @intFromBool(v)) else null);
|
||||
try stmt.bindTextOrNull(8, row.upstream);
|
||||
try stmt.bindInt(9, row.qclass);
|
||||
try stmt.bindInt(10, row.rcode);
|
||||
try bindIntOrNull(stmt, 11, row.group_id);
|
||||
try stmt.bindTextOrNull(12, row.group_name);
|
||||
try stmt.bindText(13, @tagName(row.policy_action));
|
||||
try stmt.bindText(14, @tagName(row.policy_reason));
|
||||
try stmt.bindTextOrNull(15, row.matched);
|
||||
try bindIntOrNull(stmt, 16, row.source_id);
|
||||
try stmt.bindTextOrNull(17, row.source_name);
|
||||
try stmt.bindTextOrNull(18, row.cname_target);
|
||||
try stmt.bindTextOrNull(19, row.safe_search_target);
|
||||
try stmt.bindText(20, @tagName(row.route_kind));
|
||||
try stmt.bindTextOrNull(21, row.forward_zone);
|
||||
try stmt.exec();
|
||||
}
|
||||
|
||||
@@ -144,17 +182,55 @@ fn bindIntOrNull(stmt: *db.Stmt, idx: c_int, value: ?i64) db.Error!void {
|
||||
return stmt.bindNull(idx);
|
||||
}
|
||||
|
||||
/// Deletes every `query_log` row strictly older than `cutoff_ts` and returns
|
||||
/// how many went.
|
||||
/// What one prune did, and where coverage now begins.
|
||||
pub const PruneResult = struct {
|
||||
deleted: i64,
|
||||
/// The watermark after the prune, which is what a later `availableSince`
|
||||
/// will return. Handed back so the caller need not re-read it.
|
||||
available_since: i64,
|
||||
};
|
||||
|
||||
/// Deletes every `query_log` row strictly older than `cutoff_ts` and advances
|
||||
/// the coverage watermark to the same cutoff, in one transaction.
|
||||
///
|
||||
/// Orphaned `domains` rows stay: it is a dimension table, re-interning a name
|
||||
/// costs one indexed insert, and §11.3 asks for no collection.
|
||||
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!i64 {
|
||||
var stmt = try database.prepare("DELETE FROM query_log WHERE timestamp < ?1");
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, cutoff_ts);
|
||||
try stmt.exec();
|
||||
return database.changes();
|
||||
/// **The two are one operation, not two.** The watermark is the promise that
|
||||
/// every query since it is still in the file; a delete that commits without the
|
||||
/// advance breaks that promise, and an advance that commits without the delete
|
||||
/// hides rows the file still holds. Either failure rolls both back, and the
|
||||
/// caller retries the whole thing on its next pass.
|
||||
///
|
||||
/// The watermark never moves backward: `max` is what makes a prune with a
|
||||
/// cutoff older than the file's own creation a no-op on it rather than a
|
||||
/// regression. Orphaned `domains` rows stay — it is a dimension table,
|
||||
/// re-interning a name costs one indexed insert, and §11.3 asks for no
|
||||
/// collection.
|
||||
pub fn pruneOlderThan(database: *db.Db, cutoff_ts: i64) db.Error!PruneResult {
|
||||
var tx = try db.Tx.begin(database);
|
||||
errdefer tx.rollback();
|
||||
|
||||
var deleting = try database.prepare("DELETE FROM query_log WHERE timestamp < ?1");
|
||||
defer deleting.deinit();
|
||||
try deleting.bindInt(1, cutoff_ts);
|
||||
try deleting.exec();
|
||||
const deleted = database.changes();
|
||||
|
||||
var advancing = try database.prepare(
|
||||
"UPDATE querylog_meta SET available_since = max(available_since, ?1) WHERE id = 1",
|
||||
);
|
||||
defer advancing.deinit();
|
||||
try advancing.bindInt(1, cutoff_ts);
|
||||
try advancing.exec();
|
||||
|
||||
const watermark = try database.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1");
|
||||
try tx.commit();
|
||||
return .{ .deleted = deleted, .available_since = watermark };
|
||||
}
|
||||
|
||||
/// The oldest timestamp this file can still answer for. A query window that
|
||||
/// starts before it is incomplete, and the API says so rather than charting the
|
||||
/// gap as zero.
|
||||
pub fn availableSince(database: *db.Db) db.Error!i64 {
|
||||
return database.queryInt("SELECT available_since FROM querylog_meta WHERE id = 1");
|
||||
}
|
||||
|
||||
/// `PRAGMA wal_checkpoint(TRUNCATE)`: moves the WAL into the database and
|
||||
@@ -189,21 +265,60 @@ pub fn countDomains(database: *db.Db) db.Error!i64 {
|
||||
|
||||
/// One row of `GET /api/queries`, joined back through the `domains` dimension.
|
||||
///
|
||||
/// `block_reason` and `upstream` are nullable columns, and a NULL reads as `""`
|
||||
/// — the same convention `Stmt.columnText` already uses. Neither column is ever
|
||||
/// written as an empty string (a reason is a word, an upstream is a URL), so the
|
||||
/// mapping loses nothing and the API layer can treat `""` as "absent".
|
||||
/// A summary projection, deliberately narrower than `QueryDetail`: the list is
|
||||
/// a table the operator scans, and the full provenance of a row is one request
|
||||
/// away at `GET /api/queries/{id}`.
|
||||
///
|
||||
/// The nullable text columns read a NULL as `""` — the same convention
|
||||
/// `Stmt.columnText` already uses. None of them is ever written as an empty
|
||||
/// string, so the mapping loses nothing and the API layer can treat `""` as
|
||||
/// "absent".
|
||||
pub const QueryRow = struct {
|
||||
id: i64,
|
||||
ts: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
qclass: u16,
|
||||
rcode: u12,
|
||||
blocked: bool,
|
||||
block_reason: []const u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: []const u8,
|
||||
policy_action: provenance.PolicyAction,
|
||||
policy_reason: provenance.PolicyReason,
|
||||
route_kind: provenance.RouteKind,
|
||||
};
|
||||
|
||||
/// Everything one `query_log` row records about one query, for
|
||||
/// `GET /api/queries/{id}`.
|
||||
///
|
||||
/// Same NULL-reads-as-`""` convention as `QueryRow`, and the same closed enums:
|
||||
/// a stored value the schema does not define is `error.Mismatch`, never passed
|
||||
/// through as text.
|
||||
pub const QueryDetail = struct {
|
||||
id: i64,
|
||||
ts: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
qclass: u16,
|
||||
rcode: u12,
|
||||
blocked: bool,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: []const u8,
|
||||
group_id: ?i64,
|
||||
group_name: []const u8,
|
||||
policy_action: provenance.PolicyAction,
|
||||
policy_reason: provenance.PolicyReason,
|
||||
matched: []const u8,
|
||||
source_id: ?i64,
|
||||
source_name: []const u8,
|
||||
cname_target: []const u8,
|
||||
safe_search_target: []const u8,
|
||||
route_kind: provenance.RouteKind,
|
||||
forward_zone: []const u8,
|
||||
};
|
||||
|
||||
/// Every field is an independent narrowing; `null` means "do not filter on it".
|
||||
@@ -230,7 +345,8 @@ pub const max_limit: u32 = 1000;
|
||||
|
||||
const select_head =
|
||||
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
|
||||
\\ q.block_reason, q.response_time_us, q.cache_hit, q.upstream
|
||||
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
|
||||
\\ q.policy_action, q.policy_reason, q.route_kind
|
||||
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
|
||||
;
|
||||
|
||||
@@ -337,15 +453,81 @@ pub fn selectQueries(database: *db.Db, arena: Allocator, filter: QueryFilter) db
|
||||
.qtype = if (stmt.isNull(4)) null else std.math.cast(u16, stmt.columnInt(4)) orelse
|
||||
return error.Mismatch,
|
||||
.blocked = stmt.columnBool(5),
|
||||
.block_reason = try stmt.columnTextAlloc(arena, 6),
|
||||
.response_time_us = if (stmt.isNull(7)) null else stmt.columnInt(7),
|
||||
.cache_hit = if (stmt.isNull(8)) null else stmt.columnBool(8),
|
||||
.upstream = try stmt.columnTextAlloc(arena, 9),
|
||||
.response_time_us = if (stmt.isNull(6)) null else stmt.columnInt(6),
|
||||
.cache_hit = if (stmt.isNull(7)) null else stmt.columnBool(7),
|
||||
.upstream = try stmt.columnTextAlloc(arena, 8),
|
||||
.qclass = try columnU16(&stmt, 9),
|
||||
.rcode = try columnU12(&stmt, 10),
|
||||
.policy_action = try provenance.parse(provenance.PolicyAction, stmt.columnText(11)),
|
||||
.policy_reason = try provenance.parse(provenance.PolicyReason, stmt.columnText(12)),
|
||||
.route_kind = try provenance.parse(provenance.RouteKind, stmt.columnText(13)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// A `NOT NULL` integer column that the schema bounds to 16 bits. A value
|
||||
/// outside that range means the row came from something other than this schema.
|
||||
fn columnU16(stmt: *db.Stmt, col: c_int) db.Error!u16 {
|
||||
return std.math.cast(u16, stmt.columnInt(col)) orelse error.Mismatch;
|
||||
}
|
||||
|
||||
/// The `rcode` column, which the schema's `CHECK` bounds to twelve bits. This
|
||||
/// build cannot write a wider value — the field is a `u12` all the way from the
|
||||
/// handler — so a row that carries one was written by something else, and is
|
||||
/// `error.Mismatch` rather than a value truncated into shape.
|
||||
fn columnU12(stmt: *db.Stmt, col: c_int) db.Error!u12 {
|
||||
return std.math.cast(u12, stmt.columnInt(col)) orelse error.Mismatch;
|
||||
}
|
||||
|
||||
const select_detail_sql =
|
||||
\\SELECT q.id, q.timestamp, d.domain, q.client_ip, q.qtype, q.blocked,
|
||||
\\ q.response_time_us, q.cache_hit, q.upstream, q.qclass, q.rcode,
|
||||
\\ q.group_id, q.group_name, q.policy_action, q.policy_reason,
|
||||
\\ q.matched, q.source_id, q.source_name, q.cname_target,
|
||||
\\ q.safe_search_target, q.route_kind, q.forward_zone
|
||||
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
|
||||
\\ WHERE q.id = ?1
|
||||
;
|
||||
|
||||
/// One row's full provenance, or `null` when no row has that id — which is what
|
||||
/// an id the operator kept from before a retention pass looks like, and is a
|
||||
/// 404 rather than an error.
|
||||
///
|
||||
/// Every string is allocated from `arena`, on the same terms as
|
||||
/// `selectQueries`.
|
||||
pub fn detailById(database: *db.Db, arena: Allocator, id: i64) db.Error!?QueryDetail {
|
||||
var stmt = try database.prepare(select_detail_sql);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindInt(1, id);
|
||||
if (!try stmt.step()) return null;
|
||||
|
||||
return .{
|
||||
.id = stmt.columnInt(0),
|
||||
.ts = stmt.columnInt(1),
|
||||
.domain = try stmt.columnTextAlloc(arena, 2),
|
||||
.client_ip = try stmt.columnTextAlloc(arena, 3),
|
||||
.qtype = if (stmt.isNull(4)) null else try columnU16(&stmt, 4),
|
||||
.blocked = stmt.columnBool(5),
|
||||
.response_time_us = if (stmt.isNull(6)) null else stmt.columnInt(6),
|
||||
.cache_hit = if (stmt.isNull(7)) null else stmt.columnBool(7),
|
||||
.upstream = try stmt.columnTextAlloc(arena, 8),
|
||||
.qclass = try columnU16(&stmt, 9),
|
||||
.rcode = try columnU12(&stmt, 10),
|
||||
.group_id = if (stmt.isNull(11)) null else stmt.columnInt(11),
|
||||
.group_name = try stmt.columnTextAlloc(arena, 12),
|
||||
.policy_action = try provenance.parse(provenance.PolicyAction, stmt.columnText(13)),
|
||||
.policy_reason = try provenance.parse(provenance.PolicyReason, stmt.columnText(14)),
|
||||
.matched = try stmt.columnTextAlloc(arena, 15),
|
||||
.source_id = if (stmt.isNull(16)) null else stmt.columnInt(16),
|
||||
.source_name = try stmt.columnTextAlloc(arena, 17),
|
||||
.cname_target = try stmt.columnTextAlloc(arena, 18),
|
||||
.safe_search_target = try stmt.columnTextAlloc(arena, 19),
|
||||
.route_kind = try provenance.parse(provenance.RouteKind, stmt.columnText(20)),
|
||||
.forward_zone = try stmt.columnTextAlloc(arena, 21),
|
||||
};
|
||||
}
|
||||
|
||||
/// Wraps `needle` in `%` and neutralises the two `LIKE` metacharacters, so a
|
||||
/// user searching for `a_b` gets domains containing `a_b` and not domains
|
||||
/// containing `axb`. The escape character escapes itself.
|
||||
@@ -493,11 +675,23 @@ fn plainRow(timestamp: i64, domain: []const u8) Row {
|
||||
.domain = domain,
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = false,
|
||||
.block_reason = null,
|
||||
.response_time_us = 1200,
|
||||
.cache_hit = false,
|
||||
.upstream = "9.9.9.9",
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = .allow,
|
||||
.policy_reason = .no_match,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = .upstream,
|
||||
.forward_zone = null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -509,6 +703,43 @@ fn domainIdOf(database: *db.Db, domain: []const u8) !i64 {
|
||||
return stmt.columnInt(0);
|
||||
}
|
||||
|
||||
test "a foreign row with an rcode wider than twelve bits is refused, not truncated" {
|
||||
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
||||
defer database.close();
|
||||
try db.applyPragmas(&database, .{});
|
||||
|
||||
// The shipped schema's `CHECK` makes this row impossible in a file this
|
||||
// build created, so the table is built without it. The read path's job is
|
||||
// to refuse a `querylog.db` that came from somewhere else rather than to
|
||||
// narrow a value it cannot represent.
|
||||
try database.exec(
|
||||
\\CREATE TABLE domains (id INTEGER PRIMARY KEY, domain TEXT NOT NULL UNIQUE);
|
||||
\\CREATE TABLE query_log (
|
||||
\\ id INTEGER PRIMARY KEY, timestamp INTEGER NOT NULL,
|
||||
\\ domain_id INTEGER NOT NULL, client_ip TEXT NOT NULL,
|
||||
\\ qtype INTEGER, blocked INTEGER NOT NULL, response_time_us INTEGER,
|
||||
\\ cache_hit INTEGER, upstream TEXT, qclass INTEGER NOT NULL,
|
||||
\\ rcode INTEGER NOT NULL, group_id INTEGER, group_name TEXT,
|
||||
\\ policy_action TEXT NOT NULL, policy_reason TEXT NOT NULL,
|
||||
\\ matched TEXT, source_id INTEGER, source_name TEXT,
|
||||
\\ cname_target TEXT, safe_search_target TEXT,
|
||||
\\ route_kind TEXT NOT NULL, forward_zone TEXT
|
||||
\\);
|
||||
\\INSERT INTO domains (id, domain) VALUES (1, 'a.example');
|
||||
\\INSERT INTO query_log
|
||||
\\ (id, timestamp, domain_id, client_ip, blocked, qclass, rcode,
|
||||
\\ policy_action, policy_reason, route_kind)
|
||||
\\VALUES (1, 10, 1, '192.0.2.10', 0, 1, 4096, 'allow', 'no_match', 'upstream');
|
||||
);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try testing.expectError(error.Mismatch, selectQueries(&database, arena, .{}));
|
||||
try testing.expectError(error.Mismatch, detailById(&database, arena, 1));
|
||||
}
|
||||
|
||||
test "writeBatch inserts every row and interns each domain once" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
@@ -553,7 +784,7 @@ test "a second batch reuses the interned domain id" {
|
||||
);
|
||||
}
|
||||
|
||||
test "nullable columns round-trip a value and a null" {
|
||||
test "every column round-trips a value and a null" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try BatchWriter.init(&database);
|
||||
@@ -565,54 +796,125 @@ test "nullable columns round-trip a value and a null" {
|
||||
.domain = "blocked.example",
|
||||
.client_ip = "2001:db8::1",
|
||||
.qtype = 28,
|
||||
.qclass = 1,
|
||||
.rcode = 3,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist",
|
||||
.response_time_us = 42,
|
||||
.cache_hit = true,
|
||||
.upstream = "dns.example",
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
.group_id = 7,
|
||||
.group_name = "kids",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_wildcard,
|
||||
.matched = "*.ads.example",
|
||||
.source_id = 3,
|
||||
.source_name = "steven black",
|
||||
.cname_target = "tracker.cdn.example",
|
||||
.safe_search_target = "forcesafesearch.google.com",
|
||||
.route_kind = .blocked,
|
||||
.forward_zone = "home.arpa",
|
||||
},
|
||||
// Every nullable column absent at once, which is the shape of a query
|
||||
// the pipeline answered before any of them applied.
|
||||
.{
|
||||
.timestamp = 11,
|
||||
.domain = "quiet.example",
|
||||
.client_ip = "hidden",
|
||||
.qtype = null,
|
||||
.qclass = 3,
|
||||
.rcode = 0,
|
||||
.blocked = false,
|
||||
.block_reason = null,
|
||||
.response_time_us = null,
|
||||
.cache_hit = null,
|
||||
.upstream = null,
|
||||
.group_id = null,
|
||||
.group_name = null,
|
||||
.policy_action = .not_evaluated,
|
||||
.policy_reason = .non_in_class,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = .upstream,
|
||||
.forward_zone = null,
|
||||
},
|
||||
});
|
||||
|
||||
var stmt = try database.prepare(
|
||||
\\SELECT d.domain, q.client_ip, q.qtype, q.blocked, q.block_reason,
|
||||
\\ q.response_time_us, q.cache_hit, q.upstream
|
||||
\\ FROM query_log q JOIN domains d ON d.id = q.domain_id
|
||||
\\ ORDER BY q.timestamp
|
||||
);
|
||||
defer stmt.deinit();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try testing.expect(try stmt.step());
|
||||
try testing.expectEqualStrings("blocked.example", stmt.columnText(0));
|
||||
try testing.expectEqualStrings("2001:db8::1", stmt.columnText(1));
|
||||
try testing.expectEqual(@as(i64, 28), stmt.columnInt(2));
|
||||
try testing.expect(stmt.columnBool(3));
|
||||
try testing.expectEqualStrings("blocklist", stmt.columnText(4));
|
||||
try testing.expectEqual(@as(i64, 42), stmt.columnInt(5));
|
||||
try testing.expect(stmt.columnBool(6));
|
||||
try testing.expectEqualStrings("dns.example", stmt.columnText(7));
|
||||
const full = (try detailById(&database, arena, 1)).?;
|
||||
try testing.expectEqualStrings("blocked.example", full.domain);
|
||||
try testing.expectEqualStrings("2001:db8::1", full.client_ip);
|
||||
try testing.expectEqual(@as(?u16, 28), full.qtype);
|
||||
try testing.expectEqual(@as(u16, 1), full.qclass);
|
||||
try testing.expectEqual(@as(u12, 3), full.rcode);
|
||||
try testing.expect(full.blocked);
|
||||
try testing.expectEqual(@as(?i64, 42), full.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, true), full.cache_hit);
|
||||
try testing.expectEqualStrings("https://dns.example/dns-query", full.upstream);
|
||||
try testing.expectEqual(@as(?i64, 7), full.group_id);
|
||||
try testing.expectEqualStrings("kids", full.group_name);
|
||||
try testing.expectEqual(provenance.PolicyAction.block, full.policy_action);
|
||||
try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, full.policy_reason);
|
||||
try testing.expectEqualStrings("*.ads.example", full.matched);
|
||||
try testing.expectEqual(@as(?i64, 3), full.source_id);
|
||||
try testing.expectEqualStrings("steven black", full.source_name);
|
||||
try testing.expectEqualStrings("tracker.cdn.example", full.cname_target);
|
||||
try testing.expectEqualStrings("forcesafesearch.google.com", full.safe_search_target);
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, full.route_kind);
|
||||
try testing.expectEqualStrings("home.arpa", full.forward_zone);
|
||||
|
||||
try testing.expect(try stmt.step());
|
||||
try testing.expectEqualStrings("quiet.example", stmt.columnText(0));
|
||||
try testing.expectEqualStrings("hidden", stmt.columnText(1));
|
||||
try testing.expect(stmt.isNull(2));
|
||||
try testing.expect(!stmt.columnBool(3));
|
||||
try testing.expect(stmt.isNull(4));
|
||||
try testing.expect(stmt.isNull(5));
|
||||
try testing.expect(stmt.isNull(6));
|
||||
try testing.expect(stmt.isNull(7));
|
||||
// A NULL text column reads as the empty string, by the documented
|
||||
// convention; a NULL integer stays null, because 0 is a real id.
|
||||
const bare = (try detailById(&database, arena, 2)).?;
|
||||
try testing.expectEqual(@as(?u16, null), bare.qtype);
|
||||
try testing.expectEqual(@as(u16, 3), bare.qclass);
|
||||
try testing.expectEqual(@as(?i64, null), bare.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, null), bare.cache_hit);
|
||||
try testing.expectEqualStrings("", bare.upstream);
|
||||
try testing.expectEqual(@as(?i64, null), bare.group_id);
|
||||
try testing.expectEqualStrings("", bare.group_name);
|
||||
try testing.expectEqual(provenance.PolicyAction.not_evaluated, bare.policy_action);
|
||||
try testing.expectEqual(provenance.PolicyReason.non_in_class, bare.policy_reason);
|
||||
try testing.expectEqualStrings("", bare.matched);
|
||||
try testing.expectEqual(@as(?i64, null), bare.source_id);
|
||||
try testing.expectEqualStrings("", bare.source_name);
|
||||
try testing.expectEqualStrings("", bare.cname_target);
|
||||
try testing.expectEqualStrings("", bare.safe_search_target);
|
||||
try testing.expectEqual(provenance.RouteKind.upstream, bare.route_kind);
|
||||
try testing.expectEqualStrings("", bare.forward_zone);
|
||||
}
|
||||
|
||||
try testing.expect(!try stmt.step());
|
||||
test "detailById returns null for an id no row has" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
||||
try seed(&database, &.{plainRow(10, "a.example")});
|
||||
|
||||
// An id from before a retention pass looks exactly like this, and is a 404
|
||||
// rather than an error.
|
||||
try testing.expectEqual(@as(?QueryDetail, null), try detailById(&database, arena_state.allocator(), 2));
|
||||
try testing.expectEqual(@as(?QueryDetail, null), try detailById(&database, arena_state.allocator(), 0));
|
||||
try testing.expect((try detailById(&database, arena_state.allocator(), 1)) != null);
|
||||
}
|
||||
|
||||
test "a stored enum value the schema does not define is a data error, not a passthrough" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
try seed(&database, &.{plainRow(10, "a.example")});
|
||||
try database.exec("UPDATE query_log SET policy_reason = 'whatever' WHERE id = 1;");
|
||||
|
||||
try testing.expectError(error.Mismatch, detailById(&database, arena, 1));
|
||||
try testing.expectError(error.Mismatch, selectQueries(&database, arena, .{}));
|
||||
}
|
||||
|
||||
test "an empty batch writes nothing and opens no transaction" {
|
||||
@@ -644,7 +946,7 @@ test "pruneOlderThan deletes strictly older rows and returns the count" {
|
||||
plainRow(300, "fresh.example"),
|
||||
});
|
||||
|
||||
try testing.expectEqual(@as(i64, 2), try pruneOlderThan(&database, 200));
|
||||
try testing.expectEqual(@as(i64, 2), (try pruneOlderThan(&database, 200)).deleted);
|
||||
try testing.expectEqual(@as(i64, 2), try countRows(&database));
|
||||
// The row exactly at the cutoff stays.
|
||||
try testing.expectEqual(
|
||||
@@ -652,7 +954,123 @@ test "pruneOlderThan deletes strictly older rows and returns the count" {
|
||||
try database.queryInt("SELECT count(*) FROM query_log WHERE timestamp = 200"),
|
||||
);
|
||||
// A second pass over the same cutoff finds nothing left to do.
|
||||
try testing.expectEqual(@as(i64, 0), try pruneOlderThan(&database, 200));
|
||||
try testing.expectEqual(@as(i64, 0), (try pruneOlderThan(&database, 200)).deleted);
|
||||
}
|
||||
|
||||
test "a prune advances the coverage watermark to its own cutoff" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
|
||||
// The seeded watermark is `created_at + 1`, which is now-ish; the cutoffs
|
||||
// below are all in the past, so they start out behind it.
|
||||
const start = try availableSince(&database);
|
||||
try writer.writeBatch(&.{ plainRow(start + 100, "a.example"), plainRow(start + 300, "b.example") });
|
||||
|
||||
const first = try pruneOlderThan(&database, start + 200);
|
||||
try testing.expectEqual(@as(i64, 1), first.deleted);
|
||||
try testing.expectEqual(start + 200, first.available_since);
|
||||
try testing.expectEqual(start + 200, try availableSince(&database));
|
||||
|
||||
// A prune that deletes nothing still advances: the window it swept is
|
||||
// covered whether or not it held rows.
|
||||
const second = try pruneOlderThan(&database, start + 250);
|
||||
try testing.expectEqual(@as(i64, 0), second.deleted);
|
||||
try testing.expectEqual(start + 250, try availableSince(&database));
|
||||
}
|
||||
|
||||
test "the watermark never moves backward" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
|
||||
const start = try availableSince(&database);
|
||||
const advanced = try pruneOlderThan(&database, start + 1000);
|
||||
try testing.expectEqual(start + 1000, advanced.available_since);
|
||||
|
||||
// A shortened `retention_days`, a clock that stepped back, a pass with a
|
||||
// stale cutoff: none of them may widen the promise the file makes.
|
||||
for ([_]i64{ start + 999, start, start - 100_000, 0 }) |older| {
|
||||
const result = try pruneOlderThan(&database, older);
|
||||
try testing.expectEqual(start + 1000, result.available_since);
|
||||
try testing.expectEqual(start + 1000, try availableSince(&database));
|
||||
}
|
||||
}
|
||||
|
||||
test "a failed delete leaves both the rows and the watermark untouched" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
|
||||
const start = try availableSince(&database);
|
||||
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
|
||||
try database.exec(
|
||||
\\CREATE TRIGGER refuse_delete BEFORE DELETE ON query_log
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try countRows(&database));
|
||||
try testing.expectEqual(start, try availableSince(&database));
|
||||
}
|
||||
|
||||
test "a failed watermark update leaves the rows it had already deleted" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
|
||||
const start = try availableSince(&database);
|
||||
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
|
||||
// The delete succeeds and the advance does not. Without one transaction
|
||||
// around the pair, this is the case that loses rows the watermark still
|
||||
// promises.
|
||||
try database.exec(
|
||||
\\CREATE TRIGGER refuse_advance BEFORE UPDATE ON querylog_meta
|
||||
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
|
||||
);
|
||||
|
||||
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), try countRows(&database));
|
||||
try testing.expectEqual(start, try availableSince(&database));
|
||||
}
|
||||
|
||||
test "a failed commit rolls back the delete and the watermark together" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
var writer = try BatchWriter.init(&database);
|
||||
defer writer.deinit();
|
||||
|
||||
const start = try availableSince(&database);
|
||||
try writer.writeBatch(&.{plainRow(start - 100, "old.example")});
|
||||
|
||||
// Both statements succeed and COMMIT is what fails: the advance inserts a
|
||||
// `query_log` row whose `domain_id` references nothing, and
|
||||
// `defer_foreign_keys` holds that violation back until the commit checks
|
||||
// it (SQLite's documented semantics for the pragma; the assertions below
|
||||
// observe the rollback, not the moment the check ran).
|
||||
try database.exec(
|
||||
\\CREATE TRIGGER break_at_commit AFTER UPDATE ON querylog_meta
|
||||
\\BEGIN INSERT INTO query_log
|
||||
\\ (timestamp, domain_id, client_ip, blocked, qclass, rcode,
|
||||
\\ policy_action, policy_reason, route_kind)
|
||||
\\VALUES (1, 999999, 'x', 0, 1, 0, 'allow', 'no_match', 'upstream'); END;
|
||||
);
|
||||
try database.exec("PRAGMA defer_foreign_keys = ON;");
|
||||
|
||||
try testing.expectError(error.Constraint, pruneOlderThan(&database, start + 1000));
|
||||
|
||||
// Nothing survived: not the delete, not the advance, not the row the
|
||||
// trigger inserted.
|
||||
try testing.expectEqual(@as(i64, 1), try countRows(&database));
|
||||
try testing.expectEqual(start, try availableSince(&database));
|
||||
try testing.expectEqual(
|
||||
@as(i64, 0),
|
||||
try database.queryInt("SELECT count(*) FROM query_log WHERE client_ip = 'x'"),
|
||||
);
|
||||
}
|
||||
|
||||
test "pruneOlderThan leaves the domains dimension table intact" {
|
||||
@@ -662,7 +1080,7 @@ test "pruneOlderThan leaves the domains dimension table intact" {
|
||||
defer writer.deinit();
|
||||
|
||||
try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(11, "b.example") });
|
||||
try testing.expectEqual(@as(i64, 2), try pruneOlderThan(&database, 1000));
|
||||
try testing.expectEqual(@as(i64, 2), (try pruneOlderThan(&database, 1000)).deleted);
|
||||
|
||||
try testing.expectEqual(@as(i64, 0), try countRows(&database));
|
||||
try testing.expectEqual(@as(i64, 2), try countDomains(&database));
|
||||
@@ -749,7 +1167,7 @@ test "checkpointTruncate and vacuum run against a WAL file database" {
|
||||
try writer.writeBatch(&.{ plainRow(10, "a.example"), plainRow(20, "b.example") });
|
||||
|
||||
try checkpointTruncate(&database);
|
||||
try testing.expectEqual(@as(i64, 1), try pruneOlderThan(&database, 20));
|
||||
try testing.expectEqual(@as(i64, 1), (try pruneOlderThan(&database, 20)).deleted);
|
||||
try checkpointTruncate(&database);
|
||||
try vacuum(&database);
|
||||
|
||||
@@ -785,22 +1203,46 @@ test "selectQueries returns the newest row first and reads every column" {
|
||||
.domain = "ads.example.net",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 28,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist",
|
||||
.response_time_us = 4200,
|
||||
.cache_hit = true,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
.group_id = 2,
|
||||
.group_name = "kids",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_domain,
|
||||
.matched = "ads.example.net",
|
||||
.source_id = 5,
|
||||
.source_name = "steven black",
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = .blocked,
|
||||
.forward_zone = null,
|
||||
},
|
||||
.{
|
||||
.timestamp = 20,
|
||||
.domain = "quiet.example",
|
||||
.client_ip = "hidden",
|
||||
.qtype = null,
|
||||
.qclass = 1,
|
||||
.rcode = 2,
|
||||
.blocked = false,
|
||||
.block_reason = null,
|
||||
.response_time_us = null,
|
||||
.cache_hit = null,
|
||||
.upstream = null,
|
||||
.group_id = null,
|
||||
.group_name = null,
|
||||
.policy_action = .not_evaluated,
|
||||
.policy_reason = .snapshot_unavailable,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = .rejected,
|
||||
.forward_zone = null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -813,12 +1255,16 @@ test "selectQueries returns the newest row first and reads every column" {
|
||||
try testing.expectEqualStrings("quiet.example", newest.domain);
|
||||
try testing.expectEqualStrings("hidden", newest.client_ip);
|
||||
try testing.expectEqual(@as(?u16, null), newest.qtype);
|
||||
try testing.expectEqual(@as(u16, 1), newest.qclass);
|
||||
try testing.expectEqual(@as(u12, 2), newest.rcode);
|
||||
try testing.expect(!newest.blocked);
|
||||
// A NULL text column reads as the empty string, by documented convention.
|
||||
try testing.expectEqualStrings("", newest.block_reason);
|
||||
try testing.expectEqual(@as(?i64, null), newest.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, null), newest.cache_hit);
|
||||
// A NULL text column reads as the empty string, by documented convention.
|
||||
try testing.expectEqualStrings("", newest.upstream);
|
||||
try testing.expectEqual(provenance.PolicyAction.not_evaluated, newest.policy_action);
|
||||
try testing.expectEqual(provenance.PolicyReason.snapshot_unavailable, newest.policy_reason);
|
||||
try testing.expectEqual(provenance.RouteKind.rejected, newest.route_kind);
|
||||
|
||||
const oldest = rows.items[1];
|
||||
try testing.expectEqual(@as(i64, 1), oldest.id);
|
||||
@@ -826,11 +1272,15 @@ test "selectQueries returns the newest row first and reads every column" {
|
||||
try testing.expectEqualStrings("ads.example.net", oldest.domain);
|
||||
try testing.expectEqualStrings("192.0.2.10", oldest.client_ip);
|
||||
try testing.expectEqual(@as(?u16, 28), oldest.qtype);
|
||||
try testing.expectEqual(@as(u16, 1), oldest.qclass);
|
||||
try testing.expectEqual(@as(u12, 0), oldest.rcode);
|
||||
try testing.expect(oldest.blocked);
|
||||
try testing.expectEqualStrings("blocklist", oldest.block_reason);
|
||||
try testing.expectEqual(@as(?i64, 4200), oldest.response_time_us);
|
||||
try testing.expectEqual(@as(?bool, true), oldest.cache_hit);
|
||||
try testing.expectEqualStrings("https://dns.example/dns-query", oldest.upstream);
|
||||
try testing.expectEqual(provenance.PolicyAction.block, oldest.policy_action);
|
||||
try testing.expectEqual(provenance.PolicyReason.blocklist_domain, oldest.policy_reason);
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, oldest.route_kind);
|
||||
}
|
||||
|
||||
test "selectQueries honours the limit and caps it at max_limit" {
|
||||
@@ -895,7 +1345,9 @@ test "each filter narrows the result on its own" {
|
||||
var blocked_row = plainRow(200, "ads.example.net");
|
||||
blocked_row.client_ip = "192.0.2.20";
|
||||
blocked_row.blocked = true;
|
||||
blocked_row.block_reason = "blocklist";
|
||||
blocked_row.policy_action = .block;
|
||||
blocked_row.policy_reason = .blocklist_domain;
|
||||
blocked_row.route_kind = .blocked;
|
||||
try seed(&database, &.{
|
||||
plainRow(100, "one.example.com"),
|
||||
blocked_row,
|
||||
@@ -1004,7 +1456,9 @@ test "statsTotals aggregates the window and averages only the timed rows" {
|
||||
timed.response_time_us = 100;
|
||||
var blocked_row = plainRow(150, "ads.example");
|
||||
blocked_row.blocked = true;
|
||||
blocked_row.block_reason = "blocklist";
|
||||
blocked_row.policy_action = .block;
|
||||
blocked_row.policy_reason = .blocklist_domain;
|
||||
blocked_row.route_kind = .blocked;
|
||||
blocked_row.response_time_us = 200;
|
||||
var cached = plainRow(199, "b.example");
|
||||
cached.client_ip = "192.0.2.99";
|
||||
@@ -1042,7 +1496,9 @@ test "timeseries writes every bucket, including the ones with no rows" {
|
||||
|
||||
var blocked_row = plainRow(1020, "ads.example");
|
||||
blocked_row.blocked = true;
|
||||
blocked_row.block_reason = "blocklist";
|
||||
blocked_row.policy_action = .block;
|
||||
blocked_row.policy_reason = .blocklist_domain;
|
||||
blocked_row.route_kind = .blocked;
|
||||
var cached = plainRow(1035, "b.example");
|
||||
cached.cache_hit = true;
|
||||
try seed(&database, &.{
|
||||
|
||||
@@ -114,8 +114,10 @@ pub const Retention = struct {
|
||||
// still prunes through `Store.init`.
|
||||
if (store) |s| s.prune(io, now);
|
||||
|
||||
if (queries_repo.pruneOlderThan(database, cutoff)) |deleted| {
|
||||
add(&self.counters.rows_pruned, @intCast(deleted));
|
||||
// One operation, not two: the delete and the coverage watermark it
|
||||
// advances commit together or not at all (`queries_repo`).
|
||||
if (queries_repo.pruneOlderThan(database, cutoff)) |pruned| {
|
||||
add(&self.counters.rows_pruned, @intCast(pruned.deleted));
|
||||
maintenance(store, io, now, "prune", null);
|
||||
} else |err| {
|
||||
log.warn("retention prune before {d} failed: {s}", .{ cutoff, @errorName(err) });
|
||||
@@ -255,11 +257,23 @@ fn writeRows(database: *db.Db, timestamps: []const i64) !void {
|
||||
.domain = "example.com",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = false,
|
||||
.block_reason = null,
|
||||
.response_time_us = null,
|
||||
.cache_hit = null,
|
||||
.upstream = null,
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = .allow,
|
||||
.policy_reason = .no_match,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = .upstream,
|
||||
.forward_zone = null,
|
||||
};
|
||||
}
|
||||
try writer.writeBatch(rows[0..timestamps.len]);
|
||||
|
||||
Reference in New Issue
Block a user