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:
@@ -0,0 +1,56 @@
|
||||
//! How much of the window a client asked about the query log can still answer
|
||||
//! for.
|
||||
//!
|
||||
//! Retention deletes old rows and advances a watermark in the same transaction
|
||||
//! (`queries_repo.pruneOlderThan`), so the file knows the oldest instant it is
|
||||
//! complete for. Without that fact on the wire a chart draws a pruned week as a
|
||||
//! week of silence, which is the one reading that is certainly wrong.
|
||||
//!
|
||||
//! Three endpoints carry it — `/api/queries`, `/api/stats` and
|
||||
//! `/api/stats/timeseries` — and they judge it against their own effective
|
||||
//! lower bound: the client's `since` for the query log, the period's aligned
|
||||
//! window start for the two stats endpoints.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const db = @import("../storage/db.zig");
|
||||
const queries_repo = @import("../storage/repositories/queries_repo.zig");
|
||||
|
||||
pub const Coverage = struct {
|
||||
/// True only when the whole requested window is inside what the file still
|
||||
/// holds. A request with no lower bound at all asks about all of history,
|
||||
/// which no file that has ever pruned can promise.
|
||||
complete: bool,
|
||||
/// The oldest instant the file is complete for, unix seconds.
|
||||
available_since: i64,
|
||||
};
|
||||
|
||||
pub fn of(available_since: i64, since: ?i64) Coverage {
|
||||
return .{
|
||||
.complete = if (since) |lower_bound| lower_bound >= available_since else false,
|
||||
.available_since = available_since,
|
||||
};
|
||||
}
|
||||
|
||||
/// Reads the watermark for a request that is about to answer.
|
||||
pub fn read(database: *db.Db, since: ?i64) db.Error!Coverage {
|
||||
return of(try queries_repo.availableSince(database), since);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a window that starts at or after the watermark is complete" {
|
||||
try testing.expect(of(1000, 1000).complete);
|
||||
try testing.expect(of(1000, 1001).complete);
|
||||
try testing.expect(!of(1000, 999).complete);
|
||||
}
|
||||
|
||||
test "an unbounded window is never complete" {
|
||||
const unbounded = of(1000, null);
|
||||
try testing.expect(!unbounded.complete);
|
||||
try testing.expectEqual(@as(i64, 1000), unbounded.available_since);
|
||||
}
|
||||
+33
-37
@@ -23,7 +23,7 @@ const std = @import("std");
|
||||
|
||||
const address = @import("../../platform/address.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const provenance_view = @import("../provenance_view.zig");
|
||||
const server = @import("../server.zig");
|
||||
const sse = @import("../sse.zig");
|
||||
|
||||
@@ -36,32 +36,13 @@ pub const heartbeat_interval: std.Io.Clock.Duration = .{
|
||||
.clock = .awake,
|
||||
};
|
||||
|
||||
/// One event's `data:` payload — the `/api/queries` row fields (ruling 20),
|
||||
/// minus `id`: a live entry precedes persistence, so no row id exists yet.
|
||||
pub const EventView = struct {
|
||||
ts: i64,
|
||||
domain: []const u8,
|
||||
client_ip: []const u8,
|
||||
qtype: ?u16,
|
||||
blocked: bool,
|
||||
block_reason: []const u8,
|
||||
response_time_us: ?i64,
|
||||
cache_hit: ?bool,
|
||||
upstream: []const u8,
|
||||
};
|
||||
/// One event's `data:` payload: the shared full-provenance DTO, exactly. A live
|
||||
/// event says everything `GET /api/queries/{id}` would say about the same query
|
||||
/// except its id, which does not exist yet — the entry precedes its own insert.
|
||||
pub const EventView = provenance_view.Provenance;
|
||||
|
||||
pub fn view(entry: *const sse.Entry) EventView {
|
||||
return .{
|
||||
.ts = entry.timestamp,
|
||||
.domain = entry.domain(),
|
||||
.client_ip = entry.clientIp(),
|
||||
.qtype = entry.qtype,
|
||||
.blocked = entry.blocked,
|
||||
.block_reason = entry.blockReason(),
|
||||
.response_time_us = entry.response_time_us,
|
||||
.cache_hit = entry.cache_hit,
|
||||
.upstream = entry.upstream(),
|
||||
};
|
||||
return provenance_view.fromEntry(entry);
|
||||
}
|
||||
|
||||
/// One `event: query` frame. JSON never contains a raw newline, so the whole
|
||||
@@ -139,14 +120,18 @@ pub fn stream(
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "the event payload carries the /api/queries row fields, minus id" {
|
||||
const row_fields = @typeInfo(queries_repo.QueryRow).@"struct".fields;
|
||||
test "the event payload is the detail body minus its id, name and type for name" {
|
||||
const detail_fields = @typeInfo(provenance_view.QueryDetail).@"struct".fields;
|
||||
const view_fields = @typeInfo(EventView).@"struct".fields;
|
||||
comptime {
|
||||
std.debug.assert(view_fields.len == row_fields.len - 1);
|
||||
std.debug.assert(std.mem.eql(u8, row_fields[0].name, "id"));
|
||||
for (row_fields[1..], view_fields) |row_field, view_field| {
|
||||
std.debug.assert(std.mem.eql(u8, row_field.name, view_field.name));
|
||||
std.debug.assert(view_fields.len == detail_fields.len - 1);
|
||||
std.debug.assert(std.mem.eql(u8, detail_fields[0].name, "id"));
|
||||
for (detail_fields[1..], view_fields) |detail_field, view_field| {
|
||||
std.debug.assert(std.mem.eql(u8, detail_field.name, view_field.name));
|
||||
// Names alone would let a group keep its key while changing what it
|
||||
// holds, which is the drift a live viewer would see and a detail
|
||||
// page would not.
|
||||
std.debug.assert(detail_field.type == view_field.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,11 +142,19 @@ test "a frame is one event line and one data line of JSON" {
|
||||
.domain = "ads.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist_domain",
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_domain,
|
||||
.matched = "ads.example",
|
||||
.source_id = 3,
|
||||
.source_name = "StevenBlack",
|
||||
.route_kind = .blocked,
|
||||
.response_time_us = 42,
|
||||
.cache_hit = false,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
});
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
@@ -172,10 +165,12 @@ test "a frame is one event line and one data line of JSON" {
|
||||
try testing.expect(std.mem.startsWith(u8, frame, "event: query\ndata: {"));
|
||||
try testing.expect(std.mem.endsWith(u8, frame, "}\n\n"));
|
||||
try testing.expectEqual(@as(usize, 3), std.mem.count(u8, frame, "\n"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"ts\":1700000000"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"time\":1700000000"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"domain\":\"ads.example\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"blocked\":true"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"block_reason\":\"blocklist_domain\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"group\":{\"id\":1,\"name\":\"default\"}"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"reason\":\"blocklist_domain\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"source_name\":\"StevenBlack\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"kind\":\"blocked\""));
|
||||
}
|
||||
|
||||
test "an unlogged field stays null and an empty string stays a string" {
|
||||
@@ -191,6 +186,7 @@ test "an unlogged field stays null and an empty string stays a string" {
|
||||
const frame = writer.buffered();
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"qtype\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"cache_hit\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"duration_us\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"upstream\":\"\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, frame, 1, "\"id\":null"));
|
||||
}
|
||||
|
||||
+148
-10
@@ -1,4 +1,5 @@
|
||||
//! `GET /api/queries` — the query log, newest first (ruling 11).
|
||||
//! `GET /api/queries` — the query log, newest first (ruling 11) — and
|
||||
//! `GET /api/queries/{id}`, one row of it fully explained.
|
||||
//!
|
||||
//! Keyset pagination rather than an offset: the table is append-only and the
|
||||
//! UI reads the head of it, so `id < before` is one index seek no matter how
|
||||
@@ -13,9 +14,11 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const coverage = @import("../coverage.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const logger = @import("../../storage/logger.zig");
|
||||
const provenance_view = @import("../provenance_view.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
const server = @import("../server.zig");
|
||||
|
||||
@@ -41,6 +44,10 @@ pub const Page = struct {
|
||||
queries: []const queries_repo.QueryRow,
|
||||
/// The cursor for the next page, or null when this page is the last one.
|
||||
next_before: ?i64,
|
||||
/// Whether the log still covers the window the filter asked for. A client
|
||||
/// that reads rows without reading this cannot tell an empty window from a
|
||||
/// pruned one.
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub const FilterError = error{
|
||||
@@ -110,6 +117,7 @@ pub fn page(
|
||||
return .{
|
||||
.queries = rows.items,
|
||||
.next_before = if (full and rows.items.len != 0) rows.items[rows.items.len - 1].id else null,
|
||||
.coverage = try coverage.read(database, filter.since),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -138,10 +146,37 @@ pub fn list(
|
||||
return http_util.respondJson(request, .ok, result, &.{});
|
||||
}
|
||||
|
||||
/// `GET /api/queries/{id}` — one query, fully explained.
|
||||
///
|
||||
/// The row is the whole answer: every field is a fact recorded when the query
|
||||
/// was answered, so nothing here is joined against current configuration. A
|
||||
/// group or blocklist renamed since keeps the name it had.
|
||||
pub fn detail(
|
||||
state: *server.WebState,
|
||||
io: std.Io,
|
||||
request: *http_util.Request,
|
||||
) http_util.HandlerError!void {
|
||||
_ = io;
|
||||
|
||||
const database = state.querylog_db orelse
|
||||
return http_util.respondError(request, .service_unavailable, "query log unavailable");
|
||||
|
||||
const row = queries_repo.detailById(database, request.arena, request.id.?) catch |err| {
|
||||
log.warn("query log read failed: {s}", .{@errorName(err)});
|
||||
return http_util.respondError(request, .internal_server_error, "internal error");
|
||||
};
|
||||
|
||||
// An id retention has pruned and one that never existed are the same
|
||||
// answer, and the API does not pretend to tell them apart.
|
||||
const found = row orelse return http_util.respondError(request, .not_found, "not found");
|
||||
return http_util.respondJson(request, .ok, provenance_view.fromDetail(found), &.{});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const provenance = @import("../../storage/provenance.zig");
|
||||
const querylog_schema = @import("../../storage/querylog_schema.zig");
|
||||
const testing = std.testing;
|
||||
|
||||
@@ -210,21 +245,40 @@ fn openLog() !db.Db {
|
||||
return database;
|
||||
}
|
||||
|
||||
/// Rows the resolver could actually have written. Ruling 20 ties the three
|
||||
/// route facts together: a block never consulted the cache, so its `cache_hit`
|
||||
/// is NULL rather than false; a cache hit has no upstream to name; and only an
|
||||
/// upstream answer carries one. A fixture that broke those ties would let a
|
||||
/// serializer regression pass here and fail on real rows.
|
||||
fn seed(database: *db.Db, count: usize) !void {
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
defer writer.deinit();
|
||||
var rows: [16]queries_repo.Row = undefined;
|
||||
for (rows[0..count], 0..) |*row, i| {
|
||||
const blocked = i % 2 == 0;
|
||||
const from_cache = i % 4 == 1;
|
||||
row.* = .{
|
||||
.timestamp = 1_700_000_000 + @as(i64, @intCast(i)),
|
||||
.domain = if (i % 2 == 0) "ads.example" else "safe.example",
|
||||
.domain = if (blocked) "ads.example" else "safe.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.blocked = i % 2 == 0,
|
||||
.block_reason = if (i % 2 == 0) "blocklist_domain" else null,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = blocked,
|
||||
.response_time_us = 500,
|
||||
.cache_hit = false,
|
||||
.upstream = null,
|
||||
.cache_hit = if (blocked) null else from_cache,
|
||||
.upstream = if (blocked or from_cache) null else "9.9.9.9",
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = if (blocked) .block else .allow,
|
||||
.policy_reason = if (blocked) .blocklist_domain else .no_match,
|
||||
.matched = if (blocked) "ads.example" else null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = if (blocked) .blocked else if (from_cache) .cache else .upstream,
|
||||
.forward_zone = null,
|
||||
};
|
||||
}
|
||||
try writer.writeBatch(rows[0..count]);
|
||||
@@ -297,6 +351,37 @@ test "the parsed filters narrow the rows the page returns" {
|
||||
try testing.expectEqual(@as(usize, 0), nobody.queries.len);
|
||||
}
|
||||
|
||||
test "the seeded rows carry only the route shapes ruling 20 allows" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try seed(&database, 4);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
var seen: std.EnumSet(provenance.RouteKind) = .initEmpty();
|
||||
for ((try page(&database, arena.allocator(), .{})).queries) |row| {
|
||||
seen.insert(row.route_kind);
|
||||
switch (row.route_kind) {
|
||||
.blocked => {
|
||||
try testing.expectEqual(@as(?bool, null), row.cache_hit);
|
||||
try testing.expectEqualStrings("", row.upstream);
|
||||
},
|
||||
.cache => {
|
||||
try testing.expectEqual(@as(?bool, true), row.cache_hit);
|
||||
try testing.expectEqualStrings("", row.upstream);
|
||||
},
|
||||
.upstream => {
|
||||
try testing.expectEqual(@as(?bool, false), row.cache_hit);
|
||||
try testing.expectEqualStrings("9.9.9.9", row.upstream);
|
||||
},
|
||||
.local, .forward_zone, .rejected => return error.UnseededRouteKind,
|
||||
}
|
||||
}
|
||||
// All three, so the serializer tests below read every shape the fixture claims.
|
||||
try testing.expectEqual(@as(usize, 3), seen.count());
|
||||
}
|
||||
|
||||
test "the page serializes as the envelope ruling 11 defines" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
@@ -312,14 +397,67 @@ test "the page serializes as the envelope ruling 11 defines" {
|
||||
const text = allocating.written();
|
||||
|
||||
try testing.expect(std.mem.startsWith(u8, text, "{\"queries\":["));
|
||||
try testing.expect(std.mem.endsWith(u8, text, "\"next_before\":null}"));
|
||||
for ([_][]const u8{
|
||||
"\"id\":", "\"ts\":", "\"domain\":", "\"client_ip\":",
|
||||
"\"qtype\":", "\"blocked\":", "\"cache_hit\":", "\"upstream\":",
|
||||
"\"upstream\":", "\"response_time_us\":", "\"block_reason\":",
|
||||
"\"id\":", "\"ts\":", "\"domain\":", "\"client_ip\":",
|
||||
"\"qtype\":", "\"qclass\":", "\"rcode\":", "\"blocked\":",
|
||||
"\"cache_hit\":", "\"upstream\":", "\"response_time_us\":", "\"policy_action\":",
|
||||
"\"policy_reason\":", "\"route_kind\":",
|
||||
}) |field| {
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, field));
|
||||
}
|
||||
// W1's ruling: a NULL column reads as "", and "" stays "" on the wire.
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\""));
|
||||
// The column the provenance columns replaced.
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "block_reason"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"coverage\":{\"complete\":"));
|
||||
}
|
||||
|
||||
test "the coverage of a page answers the window the filter asked for" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try seed(&database, 1);
|
||||
|
||||
var arena: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena.deinit();
|
||||
|
||||
const watermark = try queries_repo.availableSince(&database);
|
||||
|
||||
const unbounded = try page(&database, arena.allocator(), .{});
|
||||
try testing.expectEqual(watermark, unbounded.coverage.available_since);
|
||||
try testing.expect(!unbounded.coverage.complete);
|
||||
|
||||
const covered = try page(&database, arena.allocator(), .{ .since = watermark });
|
||||
try testing.expect(covered.coverage.complete);
|
||||
|
||||
const older = try page(&database, arena.allocator(), .{ .since = watermark - 1 });
|
||||
try testing.expect(!older.coverage.complete);
|
||||
}
|
||||
|
||||
test "a detail row carries every provenance field the row stored" {
|
||||
var database = try openLog();
|
||||
defer database.close();
|
||||
try seed(&database, 1);
|
||||
|
||||
var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
const row = (try queries_repo.detailById(&database, arena, 1)).?;
|
||||
const view = provenance_view.fromDetail(row);
|
||||
|
||||
try testing.expectEqual(@as(i64, 1), view.id);
|
||||
try testing.expectEqualStrings("ads.example", view.request.domain);
|
||||
try testing.expectEqualStrings("192.0.2.10", view.request.client);
|
||||
try testing.expectEqual(@as(u16, 1), view.request.qclass);
|
||||
try testing.expectEqualStrings("default", view.group.name);
|
||||
try testing.expectEqual(provenance.PolicyAction.block, view.policy.action);
|
||||
try testing.expectEqualStrings("ads.example", view.policy.matched);
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, view.route.kind);
|
||||
try testing.expectEqualStrings("", view.route.upstream);
|
||||
try testing.expectEqual(@as(?i64, 500), view.response.duration_us);
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(?queries_repo.QueryDetail, null),
|
||||
try queries_repo.detailById(&database, arena, 99),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const coverage = @import("../coverage.zig");
|
||||
const db = @import("../../storage/db.zig");
|
||||
const http_util = @import("../http_util.zig");
|
||||
const queries_repo = @import("../../storage/repositories/queries_repo.zig");
|
||||
@@ -99,6 +100,10 @@ pub const TotalsBody = struct {
|
||||
cached: u64,
|
||||
clients: u64,
|
||||
avg_response_time_us: ?i64,
|
||||
/// Judged against `since`, which is the window this body reports on — so a
|
||||
/// dashboard can say "history starts here" instead of charting a pruned
|
||||
/// stretch as a quiet one.
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub const TimeseriesBody = struct {
|
||||
@@ -107,6 +112,7 @@ pub const TimeseriesBody = struct {
|
||||
until: i64,
|
||||
bucket_seconds: u32,
|
||||
buckets: []const queries_repo.Bucket,
|
||||
coverage: coverage.Coverage,
|
||||
};
|
||||
|
||||
pub fn totals(
|
||||
@@ -121,6 +127,9 @@ pub fn totals(
|
||||
const result = queries_repo.statsTotals(database, span.since, span.until) catch |err| {
|
||||
return internal(request, "stats totals", err);
|
||||
};
|
||||
const covered = coverage.read(database, span.since) catch |err| {
|
||||
return internal(request, "stats coverage", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TotalsBody{
|
||||
.period = period.label(),
|
||||
@@ -131,6 +140,7 @@ pub fn totals(
|
||||
.cached = result.cached,
|
||||
.clients = result.distinct_clients,
|
||||
.avg_response_time_us = result.avg_response_time_us,
|
||||
.coverage = covered,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -148,6 +158,9 @@ pub fn timeseries(
|
||||
const written = queries_repo.timeseries(database, span.since, span.bucket_seconds, out) catch |err| {
|
||||
return internal(request, "stats timeseries", err);
|
||||
};
|
||||
const covered = coverage.read(database, span.since) catch |err| {
|
||||
return internal(request, "stats coverage", err);
|
||||
};
|
||||
|
||||
return http_util.respondJson(request, .ok, TimeseriesBody{
|
||||
.period = period.label(),
|
||||
@@ -155,6 +168,7 @@ pub fn timeseries(
|
||||
.until = span.until,
|
||||
.bucket_seconds = span.bucket_seconds,
|
||||
.buckets = out[0..written],
|
||||
.coverage = covered,
|
||||
}, &.{});
|
||||
}
|
||||
|
||||
@@ -268,11 +282,23 @@ fn writeRow(writer: *queries_repo.BatchWriter, timestamp: i64, blocked: bool, ca
|
||||
.domain = "example.com",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = blocked,
|
||||
.block_reason = if (blocked) "blocklist_domain" else null,
|
||||
.response_time_us = 1000,
|
||||
.cache_hit = cached,
|
||||
.upstream = null,
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = if (blocked) .block else .allow,
|
||||
.policy_reason = if (blocked) .blocklist_domain else .no_match,
|
||||
.matched = null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = if (blocked) .blocked else .upstream,
|
||||
.forward_zone = null,
|
||||
}};
|
||||
try writer.writeBatch(&rows);
|
||||
}
|
||||
|
||||
+218
-10
@@ -226,14 +226,47 @@ paths:
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/queries/{id}:
|
||||
get:
|
||||
summary: One query, fully explained
|
||||
description: |
|
||||
The full provenance of one logged query: what was asked, which group's
|
||||
policy applied, what that policy decided and matched on, what was
|
||||
rewritten, where the answer came from, and what the client received.
|
||||
Every field is a fact recorded when the query was answered, so a group
|
||||
or blocklist renamed since keeps the name it had.
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: integer, minimum: 1 }
|
||||
responses:
|
||||
"200":
|
||||
description: The query.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QueryDetail"
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFound"
|
||||
"429":
|
||||
$ref: "#/components/responses/RateLimited"
|
||||
"500":
|
||||
$ref: "#/components/responses/Internal"
|
||||
"503":
|
||||
$ref: "#/components/responses/Unavailable"
|
||||
|
||||
/api/queries/live:
|
||||
get:
|
||||
summary: Live query stream (server-sent events)
|
||||
description: |
|
||||
`text/event-stream`. The stream opens with `retry: 3000`, then sends
|
||||
one `event: query` frame per resolved query whose `data:` line is a
|
||||
JSON object with the `/api/queries` row fields minus `id` (the entry
|
||||
precedes persistence). A `: ping` comment goes out every 15 seconds.
|
||||
`Provenance` object — the body of `/api/queries/{id}` without its `id`,
|
||||
which does not exist yet because the entry precedes its own insert.
|
||||
A `: ping` comment goes out every 15 seconds.
|
||||
A client that falls more than 64 events behind is disconnected and
|
||||
should re-sync via `/api/queries` after reconnecting. Connections
|
||||
per address are capped by `web.sse_max_connections_per_ip`; the
|
||||
@@ -1855,9 +1888,33 @@ components:
|
||||
type: boolean
|
||||
description: False when no password is configured; no cookie is set.
|
||||
|
||||
PolicyAction:
|
||||
type: string
|
||||
description: |
|
||||
Whether the filtering policy reached a verdict. `not_evaluated` is the
|
||||
honest answer for a query answered before filtering could apply, and is
|
||||
not the same as `allow`.
|
||||
enum: [not_evaluated, allow, block]
|
||||
|
||||
PolicyReason:
|
||||
type: string
|
||||
description: |
|
||||
Why the policy landed where it did. The first nine are the matcher's own
|
||||
verdicts; the rest name a pipeline step that decided without consulting
|
||||
the matcher.
|
||||
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, local_record, forward_zone, non_in_class, paused, snapshot_unavailable, no_match, protocol_error]
|
||||
|
||||
RouteKind:
|
||||
type: string
|
||||
description: Where the answer the client received came from.
|
||||
enum: [blocked, local, forward_zone, upstream, cache, rejected]
|
||||
|
||||
QueryRow:
|
||||
type: object
|
||||
required: [id, ts, domain, client_ip, qtype, blocked, block_reason, response_time_us, cache_hit, upstream]
|
||||
description: |
|
||||
The summary projection the query-log table scans. The full provenance of
|
||||
a row is one request away at `/api/queries/{id}`.
|
||||
required: [id, ts, domain, client_ip, qtype, qclass, rcode, blocked, response_time_us, cache_hit, upstream, policy_action, policy_reason, route_kind]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
ts:
|
||||
@@ -1868,10 +1925,11 @@ components:
|
||||
qtype:
|
||||
type: integer
|
||||
nullable: true
|
||||
qclass: { type: integer }
|
||||
rcode:
|
||||
type: integer
|
||||
description: The twelve-bit EDNS extended code, not the four header bits alone.
|
||||
blocked: { type: boolean }
|
||||
block_reason:
|
||||
type: string
|
||||
description: Empty when the query was not blocked.
|
||||
response_time_us:
|
||||
type: integer
|
||||
nullable: true
|
||||
@@ -1880,11 +1938,155 @@ components:
|
||||
nullable: true
|
||||
upstream:
|
||||
type: string
|
||||
description: Empty for cache hits and local answers.
|
||||
description: Empty for cache hits, local answers and blocked queries.
|
||||
policy_action:
|
||||
$ref: "#/components/schemas/PolicyAction"
|
||||
policy_reason:
|
||||
$ref: "#/components/schemas/PolicyReason"
|
||||
route_kind:
|
||||
$ref: "#/components/schemas/RouteKind"
|
||||
|
||||
ProvenanceRequest:
|
||||
type: object
|
||||
required: [time, domain, client, qtype, qclass]
|
||||
properties:
|
||||
time:
|
||||
type: integer
|
||||
description: Unix seconds.
|
||||
domain: { type: string }
|
||||
client: { type: string }
|
||||
qtype:
|
||||
type: integer
|
||||
nullable: true
|
||||
qclass: { type: integer }
|
||||
|
||||
ProvenanceGroup:
|
||||
type: object
|
||||
description: |
|
||||
The client's filtering group at the time of the query, as a historical
|
||||
fact: the id may name a group since renamed or deleted.
|
||||
required: [id, name]
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
nullable: true
|
||||
name: { type: string }
|
||||
|
||||
ProvenancePolicy:
|
||||
type: object
|
||||
required: [action, reason, matched, source_id, source_name]
|
||||
properties:
|
||||
action:
|
||||
$ref: "#/components/schemas/PolicyAction"
|
||||
reason:
|
||||
$ref: "#/components/schemas/PolicyReason"
|
||||
matched:
|
||||
type: string
|
||||
description: The rule pattern or list entry that decided; empty when nothing matched.
|
||||
source_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
source_name:
|
||||
type: string
|
||||
description: The blocklist the match came from; empty for a rule.
|
||||
|
||||
ProvenanceRewrites:
|
||||
type: object
|
||||
required: [cname_target, safe_search_target]
|
||||
properties:
|
||||
cname_target:
|
||||
type: string
|
||||
description: Set when the decision was made about a CNAME target rather than the queried name.
|
||||
safe_search_target: { type: string }
|
||||
|
||||
ProvenanceRoute:
|
||||
type: object
|
||||
required: [kind, forward_zone, upstream]
|
||||
properties:
|
||||
kind:
|
||||
$ref: "#/components/schemas/RouteKind"
|
||||
forward_zone: { type: string }
|
||||
upstream:
|
||||
type: string
|
||||
description: |
|
||||
Non-empty only for an attempted upstream or forward-zone exchange,
|
||||
including one that failed. Already redacted: the userinfo, path,
|
||||
query and fragment of a resolver url never reach here.
|
||||
|
||||
ProvenanceResponse:
|
||||
type: object
|
||||
required: [rcode, duration_us]
|
||||
properties:
|
||||
rcode:
|
||||
type: integer
|
||||
description: The twelve-bit EDNS extended code, not the four header bits alone.
|
||||
duration_us:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
Provenance:
|
||||
type: object
|
||||
description: |
|
||||
One query, fully explained, in the order a query meets the pipeline. The
|
||||
`data:` payload of a live-stream `event: query` frame is exactly this.
|
||||
required: [request, group, policy, rewrites, route, response]
|
||||
properties:
|
||||
request:
|
||||
$ref: "#/components/schemas/ProvenanceRequest"
|
||||
group:
|
||||
$ref: "#/components/schemas/ProvenanceGroup"
|
||||
policy:
|
||||
$ref: "#/components/schemas/ProvenancePolicy"
|
||||
rewrites:
|
||||
$ref: "#/components/schemas/ProvenanceRewrites"
|
||||
route:
|
||||
$ref: "#/components/schemas/ProvenanceRoute"
|
||||
response:
|
||||
$ref: "#/components/schemas/ProvenanceResponse"
|
||||
|
||||
QueryDetail:
|
||||
type: object
|
||||
description: |
|
||||
`Provenance` plus the row id. Written out rather than composed with
|
||||
`allOf` so the drift guard reads one property list per schema.
|
||||
required: [id, request, group, policy, rewrites, route, response]
|
||||
properties:
|
||||
id: { type: integer }
|
||||
request:
|
||||
$ref: "#/components/schemas/ProvenanceRequest"
|
||||
group:
|
||||
$ref: "#/components/schemas/ProvenanceGroup"
|
||||
policy:
|
||||
$ref: "#/components/schemas/ProvenancePolicy"
|
||||
rewrites:
|
||||
$ref: "#/components/schemas/ProvenanceRewrites"
|
||||
route:
|
||||
$ref: "#/components/schemas/ProvenanceRoute"
|
||||
response:
|
||||
$ref: "#/components/schemas/ProvenanceResponse"
|
||||
|
||||
Coverage:
|
||||
type: object
|
||||
description: |
|
||||
How much of the requested window the query log can still answer for.
|
||||
Retention deletes rows and advances the watermark in one transaction, so
|
||||
a client can tell an empty window from a pruned one instead of charting
|
||||
the gap as zero.
|
||||
required: [complete, available_since]
|
||||
properties:
|
||||
complete:
|
||||
type: boolean
|
||||
description: |
|
||||
True only when the window's lower bound is at or after
|
||||
`available_since`. A request with no lower bound asks about all of
|
||||
history, which no file that has ever pruned can promise.
|
||||
available_since:
|
||||
type: integer
|
||||
description: The oldest instant the file is complete for, unix seconds.
|
||||
|
||||
QueriesPage:
|
||||
type: object
|
||||
required: [queries, next_before]
|
||||
required: [queries, next_before, coverage]
|
||||
properties:
|
||||
queries:
|
||||
type: array
|
||||
@@ -1894,6 +2096,8 @@ components:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Cursor for the next page; null on the last page.
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
DiagnosticEvent:
|
||||
type: object
|
||||
@@ -1983,7 +2187,7 @@ components:
|
||||
|
||||
StatsTotals:
|
||||
type: object
|
||||
required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us]
|
||||
required: [period, since, until, queries, blocked, cached, clients, avg_response_time_us, coverage]
|
||||
properties:
|
||||
period:
|
||||
type: string
|
||||
@@ -2004,6 +2208,8 @@ components:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Null when no query in the window recorded a time.
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
Bucket:
|
||||
type: object
|
||||
@@ -2018,7 +2224,7 @@ components:
|
||||
|
||||
StatsTimeseries:
|
||||
type: object
|
||||
required: [period, since, until, bucket_seconds, buckets]
|
||||
required: [period, since, until, bucket_seconds, buckets, coverage]
|
||||
properties:
|
||||
period:
|
||||
type: string
|
||||
@@ -2030,6 +2236,8 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/Bucket"
|
||||
coverage:
|
||||
$ref: "#/components/schemas/Coverage"
|
||||
|
||||
Lookup:
|
||||
type: object
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
//! The wire shape of one query's provenance, defined once.
|
||||
//!
|
||||
//! Two surfaces answer with it: `GET /api/queries/{id}`, which reads a stored
|
||||
//! row, and the `event: query` frames of `GET /api/queries/live`, which read a
|
||||
//! queued entry that has not been written yet. They must describe a query the
|
||||
//! same way — an operator watching the stream and an operator opening the row
|
||||
//! afterwards are looking at the same facts — so the live event *is* this DTO
|
||||
//! and the detail body is this DTO plus the row id.
|
||||
//!
|
||||
//! It is nested rather than flat because the six groups answer six different
|
||||
//! questions, in the order a query meets them: what was asked, which group's
|
||||
//! policy applied, what that policy decided, what was rewritten on the way,
|
||||
//! where the answer came from, and what the client got back.
|
||||
//!
|
||||
//! The list row (`queries_repo.QueryRow`) stays a separate, flatter summary.
|
||||
//! A table the operator scans wants columns, not a tree, and the full story is
|
||||
//! one request away.
|
||||
//!
|
||||
//! Every text field follows the repository's convention: a NULL column reads as
|
||||
//! `""`, and `""` on the wire means "absent". No field is ever written as an
|
||||
//! empty string that means something else.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const logger = @import("../storage/logger.zig");
|
||||
const provenance = @import("../storage/provenance.zig");
|
||||
const queries_repo = @import("../storage/repositories/queries_repo.zig");
|
||||
|
||||
/// What the client asked. `time` is unix seconds; `qtype` is null for a
|
||||
/// question whose type the log never recorded.
|
||||
pub const Request = struct {
|
||||
time: i64,
|
||||
domain: []const u8,
|
||||
client: []const u8,
|
||||
qtype: ?u16,
|
||||
qclass: u16,
|
||||
};
|
||||
|
||||
/// The client's filtering group at the time of the query, as a historical fact:
|
||||
/// the id may name a group that has since been renamed or deleted, which is why
|
||||
/// the name is stored beside it rather than joined at read time.
|
||||
pub const Group = struct {
|
||||
id: ?i64,
|
||||
name: []const u8,
|
||||
};
|
||||
|
||||
/// What the policy decided and what it matched on. `matched` is the rule
|
||||
/// pattern or list entry that decided; `source_id`/`source_name` name the
|
||||
/// blocklist it came from, and are absent for a rule.
|
||||
pub const Policy = struct {
|
||||
action: provenance.PolicyAction,
|
||||
reason: provenance.PolicyReason,
|
||||
matched: []const u8,
|
||||
source_id: ?i64,
|
||||
source_name: []const u8,
|
||||
};
|
||||
|
||||
/// The two rewrites that can happen between the question and the answer.
|
||||
/// `cname_target` is set when the decision was made about a CNAME target rather
|
||||
/// than the queried name.
|
||||
pub const Rewrites = struct {
|
||||
cname_target: []const u8,
|
||||
safe_search_target: []const u8,
|
||||
};
|
||||
|
||||
/// Where the answer came from. `upstream` is non-empty only for an attempted
|
||||
/// upstream or forward-zone exchange, including one that failed, and is already
|
||||
/// redacted — the path, query and userinfo of a resolver url never reach here.
|
||||
pub const Route = struct {
|
||||
kind: provenance.RouteKind,
|
||||
forward_zone: []const u8,
|
||||
upstream: []const u8,
|
||||
};
|
||||
|
||||
/// What the client saw. `rcode` is the twelve-bit EDNS extended code, not the
|
||||
/// four header bits alone.
|
||||
pub const Response = struct {
|
||||
rcode: u16,
|
||||
duration_us: ?i64,
|
||||
};
|
||||
|
||||
/// One query, fully explained. The live stream's `data:` payload is exactly
|
||||
/// this.
|
||||
pub const Provenance = struct {
|
||||
request: Request,
|
||||
group: Group,
|
||||
policy: Policy,
|
||||
rewrites: Rewrites,
|
||||
route: Route,
|
||||
response: Response,
|
||||
};
|
||||
|
||||
/// `GET /api/queries/{id}`: the same six groups, plus the id the caller asked
|
||||
/// for. Spelled out rather than composed, because a JSON object is flat at its
|
||||
/// top level and Zig has no field-splicing; the `comptime` block below is what
|
||||
/// keeps the two from drifting.
|
||||
pub const QueryDetail = struct {
|
||||
id: i64,
|
||||
request: Request,
|
||||
group: Group,
|
||||
policy: Policy,
|
||||
rewrites: Rewrites,
|
||||
route: Route,
|
||||
response: Response,
|
||||
};
|
||||
|
||||
comptime {
|
||||
const detail = @typeInfo(QueryDetail).@"struct".fields;
|
||||
const shared = @typeInfo(Provenance).@"struct".fields;
|
||||
std.debug.assert(detail.len == shared.len + 1);
|
||||
std.debug.assert(std.mem.eql(u8, detail[0].name, "id"));
|
||||
for (detail[1..], shared) |a, b| {
|
||||
std.debug.assert(std.mem.eql(u8, a.name, b.name));
|
||||
std.debug.assert(a.type == b.type);
|
||||
}
|
||||
}
|
||||
|
||||
/// A stored row, as the detail endpoint answers it. Borrows `row`'s strings,
|
||||
/// which the caller's arena owns.
|
||||
pub fn fromDetail(row: queries_repo.QueryDetail) QueryDetail {
|
||||
return .{
|
||||
.id = row.id,
|
||||
.request = .{
|
||||
.time = row.ts,
|
||||
.domain = row.domain,
|
||||
.client = row.client_ip,
|
||||
.qtype = row.qtype,
|
||||
.qclass = row.qclass,
|
||||
},
|
||||
.group = .{ .id = row.group_id, .name = row.group_name },
|
||||
.policy = .{
|
||||
.action = row.policy_action,
|
||||
.reason = row.policy_reason,
|
||||
.matched = row.matched,
|
||||
.source_id = row.source_id,
|
||||
.source_name = row.source_name,
|
||||
},
|
||||
.rewrites = .{
|
||||
.cname_target = row.cname_target,
|
||||
.safe_search_target = row.safe_search_target,
|
||||
},
|
||||
.route = .{
|
||||
.kind = row.route_kind,
|
||||
.forward_zone = row.forward_zone,
|
||||
.upstream = row.upstream,
|
||||
},
|
||||
.response = .{ .rcode = row.rcode, .duration_us = row.response_time_us },
|
||||
};
|
||||
}
|
||||
|
||||
/// A queued entry, as the live stream sends it. Borrows the entry's buffers, so
|
||||
/// the result must not outlive the entry it was taken from — in the stream both
|
||||
/// live in one loop iteration.
|
||||
///
|
||||
/// There is no id: the entry precedes its own insert, so no row id exists yet.
|
||||
pub fn fromEntry(entry: *const logger.Entry) Provenance {
|
||||
return .{
|
||||
.request = .{
|
||||
.time = entry.timestamp,
|
||||
.domain = entry.domain(),
|
||||
.client = entry.clientIp(),
|
||||
.qtype = entry.qtype,
|
||||
.qclass = entry.qclass,
|
||||
},
|
||||
.group = .{ .id = entry.group_id, .name = entry.groupName() },
|
||||
.policy = .{
|
||||
.action = entry.policy_action,
|
||||
.reason = entry.policy_reason,
|
||||
.matched = entry.matched(),
|
||||
.source_id = entry.source_id,
|
||||
.source_name = entry.sourceName(),
|
||||
},
|
||||
.rewrites = .{
|
||||
.cname_target = entry.cnameTarget(),
|
||||
.safe_search_target = entry.safeSearchTarget(),
|
||||
},
|
||||
.route = .{
|
||||
.kind = entry.route_kind,
|
||||
.forward_zone = entry.forwardZone(),
|
||||
.upstream = entry.upstream(),
|
||||
},
|
||||
.response = .{ .rcode = entry.rcode, .duration_us = entry.response_time_us },
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a stored row and a queued entry describe the same query identically" {
|
||||
const row: queries_repo.QueryDetail = .{
|
||||
.id = 7,
|
||||
.ts = 1_700_000_000,
|
||||
.domain = "ads.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 3,
|
||||
.blocked = true,
|
||||
.response_time_us = 1234,
|
||||
.cache_hit = false,
|
||||
.upstream = "https://dns.example",
|
||||
.group_id = 2,
|
||||
.group_name = "kids",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_domain,
|
||||
.matched = "tracker.example",
|
||||
.source_id = 5,
|
||||
.source_name = "StevenBlack",
|
||||
.cname_target = "tracker.example",
|
||||
.safe_search_target = "forcesafesearch.example",
|
||||
.route_kind = .blocked,
|
||||
.forward_zone = "lan",
|
||||
};
|
||||
|
||||
const entry: logger.Entry = .init(.{
|
||||
.timestamp = row.ts,
|
||||
.domain = row.domain,
|
||||
.client_ip = row.client_ip,
|
||||
.qtype = row.qtype,
|
||||
.qclass = row.qclass,
|
||||
.rcode = row.rcode,
|
||||
.blocked = row.blocked,
|
||||
.response_time_us = row.response_time_us,
|
||||
.cache_hit = row.cache_hit,
|
||||
.upstream = row.upstream,
|
||||
.group_id = row.group_id,
|
||||
.group_name = row.group_name,
|
||||
.policy_action = row.policy_action,
|
||||
.policy_reason = row.policy_reason,
|
||||
.matched = row.matched,
|
||||
.source_id = row.source_id,
|
||||
.source_name = row.source_name,
|
||||
.cname_target = row.cname_target,
|
||||
.safe_search_target = row.safe_search_target,
|
||||
.route_kind = row.route_kind,
|
||||
.forward_zone = row.forward_zone,
|
||||
});
|
||||
|
||||
const from_row = fromDetail(row);
|
||||
const from_entry = fromEntry(&entry);
|
||||
|
||||
try testing.expectEqual(@as(i64, 7), from_row.id);
|
||||
inline for (@typeInfo(Provenance).@"struct".fields) |field| {
|
||||
const a = @field(from_row, field.name);
|
||||
const b = @field(from_entry, field.name);
|
||||
inline for (@typeInfo(field.type).@"struct".fields) |inner| {
|
||||
const left = @field(a, inner.name);
|
||||
const right = @field(b, inner.name);
|
||||
if (@TypeOf(left) == []const u8) {
|
||||
try testing.expectEqualStrings(left, right);
|
||||
} else {
|
||||
try testing.expectEqual(left, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "an unexplained query serializes as nulls and empty strings, not as absent keys" {
|
||||
const entry: logger.Entry = .init(.{
|
||||
.timestamp = 1,
|
||||
.domain = "safe.example",
|
||||
.client_ip = "192.0.2.11",
|
||||
});
|
||||
|
||||
var allocating: std.Io.Writer.Allocating = .init(testing.allocator);
|
||||
defer allocating.deinit();
|
||||
try std.json.Stringify.value(fromEntry(&entry), .{}, &allocating.writer);
|
||||
const text = allocating.written();
|
||||
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"qtype\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"duration_us\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"id\":null"));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"upstream\":\"\""));
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "\"action\":\"not_evaluated\""));
|
||||
}
|
||||
+5
-1
@@ -67,6 +67,10 @@ pub const table: []const router.RouteInfo = &.{
|
||||
// Query log, stats, live stream, lookup.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .handler = queries.list },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .handler = live.stream, .rate_limit = .exempt },
|
||||
// Listed after the literal `live`, which a linear first-match scan reaches
|
||||
// first — though `{id}` would refuse it anyway, since it captures a
|
||||
// positive integer and nothing else.
|
||||
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .handler = queries.detail },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .handler = stats.totals },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .handler = stats.timeseries },
|
||||
.{ .method = .GET, .pattern = "/api/lookup", .auth = .session, .policy = .read, .handler = lookup.handle },
|
||||
@@ -152,7 +156,7 @@ const std = @import("std");
|
||||
const testing = std.testing;
|
||||
|
||||
test "the table carries every endpoint of the milestone" {
|
||||
try testing.expectEqual(@as(usize, 60), table.len);
|
||||
try testing.expectEqual(@as(usize, 61), table.len);
|
||||
}
|
||||
|
||||
test "no two entries claim the same method and pattern" {
|
||||
|
||||
@@ -25,6 +25,14 @@ pub const max_subscribers = 32;
|
||||
|
||||
/// Entries one subscriber may fall behind by. At household query rates this is
|
||||
/// several seconds of slack on a stalled TCP connection.
|
||||
///
|
||||
/// The ring is embedded in the slot, so this multiplies `@sizeOf(logger.Entry)`
|
||||
/// — about 1.8 KiB once milestone 28 widened it for provenance. One subscriber
|
||||
/// therefore costs roughly 115 KiB of ring and the whole hub roughly 3.6 MiB,
|
||||
/// allocated once for the life of the process. That is the reason to keep both
|
||||
/// this and `max_subscribers` small: they are paid whether or not anyone is
|
||||
/// watching. `logger.query_log_buffer_max` bounds the other consumer of the
|
||||
/// same width, the writer queue.
|
||||
pub const ring_capacity = 64;
|
||||
|
||||
pub const SubscriberId = enum(u8) { _ };
|
||||
|
||||
@@ -38,15 +38,20 @@ const header = @import("../dns/header.zig");
|
||||
const http_util = @import("http_util.zig");
|
||||
const local_repo = @import("../storage/repositories/local_repo.zig");
|
||||
const local_tables_mod = @import("../server/local_tables.zig");
|
||||
const logger_mod = @import("../storage/logger.zig");
|
||||
const manager_mod = @import("../filter/manager.zig");
|
||||
const migrations = @import("../storage/migrations.zig");
|
||||
const name = @import("../dns/name.zig");
|
||||
const openapi = @import("openapi.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const pause_mod = @import("../server/pause.zig");
|
||||
const coverage_mod = @import("coverage.zig");
|
||||
const pool_mod = @import("../upstream/pool.zig");
|
||||
const provenance = @import("../storage/provenance.zig");
|
||||
const provenance_view = @import("provenance_view.zig");
|
||||
const queries_repo = @import("../storage/repositories/queries_repo.zig");
|
||||
const querylog_schema = @import("../storage/querylog_schema.zig");
|
||||
const query_sink = @import("../server/query_sink.zig");
|
||||
const question = @import("../dns/question.zig");
|
||||
const router = @import("router.zig");
|
||||
const server = @import("server.zig");
|
||||
@@ -257,7 +262,10 @@ fn contentLength(head: []const u8) ?usize {
|
||||
// the environment: the real web stack over in-memory databases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const seeded_query_rows = 25;
|
||||
/// The rows the uniform loop writes, before the two provenance-rich ones the
|
||||
/// detail endpoint and the credential sweep read.
|
||||
const seeded_plain_query_rows = 25;
|
||||
const seeded_query_rows = seeded_plain_query_rows + 2;
|
||||
|
||||
const EnvOptions = struct {
|
||||
password_hash: []const u8 = "",
|
||||
@@ -270,6 +278,10 @@ const EnvOptions = struct {
|
||||
/// wants; the file-authority tests below name a path.
|
||||
authority: server.Authority = .database,
|
||||
reconciled_at: ?i64 = null,
|
||||
/// False detaches the query log from the web state, which is the box a
|
||||
/// `logging.query_log = false` operator runs. Every query-log route then
|
||||
/// answers 503 rather than an empty page, which would be a lie.
|
||||
querylog: bool = true,
|
||||
};
|
||||
|
||||
/// Heap-allocated because `state` and the listener hold pointers into it.
|
||||
@@ -400,7 +412,7 @@ const Env = struct {
|
||||
.limiter = &self.limiter,
|
||||
.hub = self.hub,
|
||||
.config_db = &self.config_db,
|
||||
.querylog_db = &self.querylog_db,
|
||||
.querylog_db = if (options.querylog) &self.querylog_db else null,
|
||||
.events = &self.events_store,
|
||||
.version = "w10-test",
|
||||
.started_unix = std.Io.Clock.real.now(ioh).toSeconds(),
|
||||
@@ -472,27 +484,108 @@ fn seedConfig(database: *db.Db) !void {
|
||||
);
|
||||
}
|
||||
|
||||
/// The oldest instant the seeded log is complete for. Pinned rather than taken
|
||||
/// from `unixepoch()`, which the schema's own seed uses: the contract samples
|
||||
/// are byte-compared, so a clock in `coverage.available_since` would make the
|
||||
/// golden a property of the machine that generated it.
|
||||
///
|
||||
/// It equals the oldest seeded row's timestamp, so a request bounded at exactly
|
||||
/// this instant is complete and one bounded a second earlier is not.
|
||||
const seeded_available_since: i64 = 1_700_000_000;
|
||||
|
||||
fn seedQueryLog(database: *db.Db) !void {
|
||||
try database.exec(
|
||||
\\UPDATE querylog_meta SET created_at = 1700000000, available_since = 1700000000 WHERE id = 1
|
||||
);
|
||||
|
||||
var writer = try queries_repo.BatchWriter.init(database);
|
||||
defer writer.deinit();
|
||||
|
||||
var domain_buf: [32]u8 = undefined;
|
||||
var index: usize = 0;
|
||||
while (index < seeded_query_rows) : (index += 1) {
|
||||
while (index < seeded_plain_query_rows) : (index += 1) {
|
||||
const domain = std.fmt.bufPrint(&domain_buf, "d{d}.example", .{index}) catch unreachable;
|
||||
const blocked = index % 5 == 0;
|
||||
// The three states the handler can actually produce (`Context.cacheHit`
|
||||
// and `route_kind` are set together): a blocked answer consulted no
|
||||
// cache and named no resolver, a cache hit named no resolver, and only
|
||||
// an upstream exchange did both.
|
||||
const from_cache = !blocked and index % 2 == 0;
|
||||
try writer.writeBatch(&.{.{
|
||||
.timestamp = 1_700_000_000 + @as(i64, @intCast(index)),
|
||||
.domain = domain,
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = blocked,
|
||||
.block_reason = if (blocked) "blocklist_domain" else null,
|
||||
.response_time_us = 250,
|
||||
.cache_hit = if (blocked) null else (index % 2 == 0),
|
||||
.upstream = if (blocked) null else "https://dns.example/dns-query",
|
||||
.cache_hit = if (blocked) null else from_cache,
|
||||
.upstream = if (blocked or from_cache) null else "https://dns.example/dns-query",
|
||||
.group_id = 1,
|
||||
.group_name = "default",
|
||||
.policy_action = if (blocked) .block else .allow,
|
||||
.policy_reason = if (blocked) .blocklist_domain else .no_match,
|
||||
.matched = if (blocked) domain else null,
|
||||
.source_id = null,
|
||||
.source_name = null,
|
||||
.cname_target = null,
|
||||
.safe_search_target = null,
|
||||
.route_kind = if (blocked) .blocked else if (from_cache) .cache else .upstream,
|
||||
.forward_zone = null,
|
||||
}});
|
||||
}
|
||||
|
||||
// Two rows with provenance the loop above never produces, so the detail
|
||||
// endpoint and its contract sample have a real row to read. They are the
|
||||
// newest rows, so a first page shows them.
|
||||
try writer.writeBatch(&.{.{
|
||||
.timestamp = 1_700_000_000 + seeded_plain_query_rows,
|
||||
.domain = "news.example",
|
||||
.client_ip = "192.0.2.10",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 0,
|
||||
.blocked = false,
|
||||
.response_time_us = 18_400,
|
||||
.cache_hit = false,
|
||||
.upstream = "https://dns.example/dns-query",
|
||||
.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(&.{.{
|
||||
.timestamp = 1_700_000_000 + seeded_plain_query_rows + 1,
|
||||
.domain = "shop.example",
|
||||
.client_ip = "192.0.2.11",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.rcode = 3,
|
||||
.blocked = true,
|
||||
.response_time_us = 900,
|
||||
.cache_hit = null,
|
||||
.upstream = null,
|
||||
.group_id = 2,
|
||||
.group_name = "kids",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_wildcard,
|
||||
.matched = "||tracker.example^",
|
||||
.source_id = 4,
|
||||
.source_name = "StevenBlack",
|
||||
.cname_target = "cdn.tracker.example",
|
||||
.safe_search_target = null,
|
||||
.route_kind = .blocked,
|
||||
.forward_zone = null,
|
||||
}});
|
||||
}
|
||||
|
||||
/// A fixed instant, like every other seeded timestamp here: the contract
|
||||
@@ -653,6 +746,7 @@ const contract = [_]Contract{
|
||||
|
||||
// Query log, stats, live stream, upstream health.
|
||||
.{ .method = .GET, .pattern = "/api/queries", .auth = .session, .policy = .read, .target = "/api/queries?limit=10", .status = 200, .check = jsonShape(handlers_queries.Page) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/{id}", .auth = .session, .policy = .read, .target = "/api/queries/27", .status = 200, .check = jsonShape(provenance_view.QueryDetail) },
|
||||
.{ .method = .GET, .pattern = "/api/queries/live", .auth = .session, .policy = .read, .rate_limit = .exempt, .target = "/api/queries/live", .status = 200, .kind = .sse },
|
||||
.{ .method = .GET, .pattern = "/api/stats", .auth = .session, .policy = .read, .target = "/api/stats?period=1h", .status = 200, .check = jsonShape(handlers_stats.TotalsBody) },
|
||||
.{ .method = .GET, .pattern = "/api/stats/timeseries", .auth = .session, .policy = .read, .target = "/api/stats/timeseries?period=1h", .status = 200, .check = jsonShape(handlers_stats.TimeseriesBody) },
|
||||
@@ -1653,12 +1747,15 @@ fn sseStream(io: std.Io, env: *Env) anyerror!void {
|
||||
.domain = "live.example",
|
||||
.client_ip = "192.0.2.99",
|
||||
.qtype = 1,
|
||||
.qclass = 1,
|
||||
.blocked = true,
|
||||
.block_reason = "blocklist_domain",
|
||||
.policy_action = .block,
|
||||
.policy_reason = .blocklist_domain,
|
||||
.route_kind = .blocked,
|
||||
}));
|
||||
try conn.readChunkedUntil(&seen, env.gpa, "event: query");
|
||||
try conn.readChunkedUntil(&seen, env.gpa, "\"domain\":\"live.example\"");
|
||||
try conn.readChunkedUntil(&seen, env.gpa, "\"blocked\":true");
|
||||
try conn.readChunkedUntil(&seen, env.gpa, "\"reason\":\"blocklist_domain\"");
|
||||
|
||||
// The cap is per address and the environment allows one stream: a second
|
||||
// subscriber from the same address is refused while the first is open.
|
||||
@@ -1784,7 +1881,7 @@ fn paginationWalk(io: std.Io, env: *Env) anyerror!void {
|
||||
try testing.expect(pages < 10);
|
||||
}
|
||||
|
||||
// 25 seeded rows walk as 10, 10 and 5, with the cursor ending exactly
|
||||
// 27 seeded rows walk as 10, 10 and 7, with the cursor ending exactly
|
||||
// after the third page.
|
||||
try testing.expectEqual(@as(usize, seeded_query_rows), total);
|
||||
try testing.expectEqual(@as(usize, 3), pages);
|
||||
@@ -1800,6 +1897,333 @@ test "W10 keyset pagination walks the seeded log exactly once, newest first" {
|
||||
try bounded(env.io(), default_budget, paginationWalk, .{ env.io(), env });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// query provenance: the detail endpoint, coverage, and the credential sweep
|
||||
// (milestone 28)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// The id of the seeded CNAME-uncloaked block, which is the last row written.
|
||||
const seeded_detail_id = seeded_query_rows;
|
||||
|
||||
fn detailWalk(io: std.Io, env: *Env) anyerror!void {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
|
||||
defer arena_state.deinit();
|
||||
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [64 * 1024]u8 = undefined;
|
||||
var target_buf: [64]u8 = undefined;
|
||||
|
||||
const target = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{seeded_detail_id});
|
||||
try conn.request("GET", target, null, null);
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), response.status);
|
||||
|
||||
const detail = try std.json.parseFromSliceLeaky(
|
||||
provenance_view.QueryDetail,
|
||||
arena_state.allocator(),
|
||||
response.body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
|
||||
try testing.expectEqual(@as(i64, seeded_detail_id), detail.id);
|
||||
try testing.expectEqualStrings("shop.example", detail.request.domain);
|
||||
try testing.expectEqualStrings("192.0.2.11", detail.request.client);
|
||||
try testing.expectEqual(@as(u16, 1), detail.request.qclass);
|
||||
try testing.expectEqual(@as(?i64, 2), detail.group.id);
|
||||
try testing.expectEqualStrings("kids", detail.group.name);
|
||||
try testing.expectEqual(provenance.PolicyAction.block, detail.policy.action);
|
||||
try testing.expectEqual(provenance.PolicyReason.blocklist_wildcard, detail.policy.reason);
|
||||
try testing.expectEqualStrings("||tracker.example^", detail.policy.matched);
|
||||
try testing.expectEqual(@as(?i64, 4), detail.policy.source_id);
|
||||
try testing.expectEqualStrings("StevenBlack", detail.policy.source_name);
|
||||
try testing.expectEqualStrings("cdn.tracker.example", detail.rewrites.cname_target);
|
||||
try testing.expectEqualStrings("", detail.rewrites.safe_search_target);
|
||||
try testing.expectEqual(provenance.RouteKind.blocked, detail.route.kind);
|
||||
// A blocked query attempted no exchange, so it names no resolver.
|
||||
try testing.expectEqualStrings("", detail.route.upstream);
|
||||
try testing.expectEqual(@as(u16, 3), detail.response.rcode);
|
||||
try testing.expectEqual(@as(?i64, 900), detail.response.duration_us);
|
||||
|
||||
// An id past the end of the log and an id retention would have pruned are
|
||||
// the same answer.
|
||||
const missing = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{seeded_query_rows + 1000});
|
||||
try conn.request("GET", missing, null, null);
|
||||
const not_found = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 404), not_found.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, not_found.body, 1, "\"error\""));
|
||||
|
||||
// A non-positive id never reaches SQL: the pattern captures a positive
|
||||
// integer or does not match, so this is a routing 404.
|
||||
try conn.request("GET", "/api/queries/0", null, null);
|
||||
try testing.expectEqual(@as(u16, 404), (try conn.receive(&body_buf)).status);
|
||||
}
|
||||
|
||||
test "W10 milestone 28: the detail endpoint answers one row and 404s the rest" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, detailWalk, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn detailUnavailable(io: std.Io, env: *Env) anyerror!void {
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [8 * 1024]u8 = undefined;
|
||||
for ([_][]const u8{ "/api/queries/1", "/api/queries?limit=1", "/api/stats", "/api/stats/timeseries" }) |target| {
|
||||
try conn.request("GET", target, null, null);
|
||||
const response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 503), response.status);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, response.body, 1, "query log unavailable"));
|
||||
}
|
||||
}
|
||||
|
||||
test "W10 milestone 28: a box with no query log answers 503, not an empty page" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{ .querylog = false });
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, detailUnavailable, .{ env.io(), env });
|
||||
}
|
||||
|
||||
fn coverageWalk(io: std.Io, env: *Env) anyerror!void {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [64 * 1024]u8 = undefined;
|
||||
var target_buf: [64]u8 = undefined;
|
||||
|
||||
// No lower bound: the request asks about all of history, which a file that
|
||||
// may have pruned cannot promise.
|
||||
try conn.request("GET", "/api/queries?limit=1", null, null);
|
||||
const unbounded = try std.json.parseFromSliceLeaky(
|
||||
handlers_queries.Page,
|
||||
arena,
|
||||
(try conn.receive(&body_buf)).body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expectEqual(seeded_available_since, unbounded.coverage.available_since);
|
||||
try testing.expect(!unbounded.coverage.complete);
|
||||
|
||||
// Bounded exactly at the watermark.
|
||||
const at = try std.fmt.bufPrint(&target_buf, "/api/queries?limit=1&since={d}", .{seeded_available_since});
|
||||
try conn.request("GET", at, null, null);
|
||||
const covered = try std.json.parseFromSliceLeaky(
|
||||
handlers_queries.Page,
|
||||
arena,
|
||||
(try conn.receive(&body_buf)).body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expect(covered.coverage.complete);
|
||||
|
||||
// One second earlier, and the window reaches past what the file holds.
|
||||
const before = try std.fmt.bufPrint(&target_buf, "/api/queries?limit=1&since={d}", .{seeded_available_since - 1});
|
||||
try conn.request("GET", before, null, null);
|
||||
const partial = try std.json.parseFromSliceLeaky(
|
||||
handlers_queries.Page,
|
||||
arena,
|
||||
(try conn.receive(&body_buf)).body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expect(!partial.coverage.complete);
|
||||
|
||||
// The stats endpoints judge the same watermark against their own aligned
|
||||
// window, which for any live period starts well after the seeded rows.
|
||||
try conn.request("GET", "/api/stats?period=1h", null, null);
|
||||
const totals = try std.json.parseFromSliceLeaky(
|
||||
handlers_stats.TotalsBody,
|
||||
arena,
|
||||
(try conn.receive(&body_buf)).body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expectEqual(seeded_available_since, totals.coverage.available_since);
|
||||
try testing.expectEqual(totals.since >= seeded_available_since, totals.coverage.complete);
|
||||
|
||||
try conn.request("GET", "/api/stats/timeseries?period=1h", null, null);
|
||||
const series = try std.json.parseFromSliceLeaky(
|
||||
handlers_stats.TimeseriesBody,
|
||||
arena,
|
||||
(try conn.receive(&body_buf)).body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expectEqual(totals.since, series.since);
|
||||
try testing.expectEqual(totals.coverage.complete, series.coverage.complete);
|
||||
}
|
||||
|
||||
test "W10 milestone 28: every window-bounded endpoint reports its own coverage" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
|
||||
try bounded(env.io(), default_budget, coverageWalk, .{ env.io(), env });
|
||||
}
|
||||
|
||||
/// NextDNS's shape: the account id rides in the path, which is exactly where a
|
||||
/// credential lives in a url an operator may legitimately configure.
|
||||
/// `Endpoint.parse` refuses userinfo, so the path is the shape a real
|
||||
/// configuration can carry a secret in — and the path is what
|
||||
/// `safe_url.redact` drops.
|
||||
const sweep_token = "b1c2d3";
|
||||
const sweep_upstream_url = "https://dns.nextdns.io/" ++ sweep_token;
|
||||
/// What every surface must show instead. The origin survives redaction — an
|
||||
/// operator reading a failure has to know where the query went — so each
|
||||
/// surface is checked for it too: one that showed nothing at all would pass a
|
||||
/// secret check by saying nothing.
|
||||
const sweep_redacted_upstream = "https://dns.nextdns.io";
|
||||
/// The name the swept query asks for, so each surface can be pinned to the row
|
||||
/// this test produced rather than to a seeded one.
|
||||
const sweep_domain = "creds.example";
|
||||
|
||||
/// Names the resolver and never its token.
|
||||
fn expectRedacted(text: []const u8) !void {
|
||||
try testing.expect(std.mem.containsAtLeast(u8, text, 1, sweep_redacted_upstream));
|
||||
try testing.expect(!std.mem.containsAtLeast(u8, text, 1, sweep_token));
|
||||
}
|
||||
|
||||
/// The cross-surface credential sweep, driven end to end: a real `Handler`
|
||||
/// answers a real query through a resolver whose url carries a token, and the
|
||||
/// entry travels the production path — `QuerySink`, then the hub and the
|
||||
/// logger, then the query log the API reads. Nothing here redacts anything, so
|
||||
/// a handler that stopped redacting fails this test.
|
||||
///
|
||||
/// Four surfaces read the same query back: the stored row, straight out of
|
||||
/// SQLite, and the three the operator's browser sees — the live frame, the list
|
||||
/// page and the detail body. A leak on any one of them is a secret in a browser
|
||||
/// history, and the four are separate code paths to the same text.
|
||||
fn credentialSweep(
|
||||
io: std.Io,
|
||||
env: *Env,
|
||||
query_logger: *logger_mod.Logger,
|
||||
handler: *dns_handler.Handler,
|
||||
) anyerror!void {
|
||||
var arena_state: std.heap.ArenaAllocator = .init(env.gpa);
|
||||
defer arena_state.deinit();
|
||||
const arena = arena_state.allocator();
|
||||
|
||||
// Subscribed before the query runs: the hub publishes to whoever is
|
||||
// listening at that moment and keeps nothing for a later reader.
|
||||
var seen: std.ArrayList(u8) = .empty;
|
||||
defer seen.deinit(env.gpa);
|
||||
var stream: Conn = undefined;
|
||||
try openLiveStream(io, env, &stream, &seen);
|
||||
defer stream.close(io);
|
||||
|
||||
var query_buf: [512]u8 = undefined;
|
||||
var response_buf: [512]u8 = undefined;
|
||||
var scratch: dns_handler.Scratch = undefined;
|
||||
const from = address.NetAddress.fromIp(.{ .ip4 = .loopback(53100) });
|
||||
const query = queryFor(&query_buf, 0x4444, sweep_domain, .a);
|
||||
try testing.expect(handler.handle(io, .udp, from, query, &response_buf, &scratch) == .reply);
|
||||
|
||||
// The live frame. Waiting on the redacted origin rather than on the whole
|
||||
// frame is safe in both directions: a leaked url starts with it.
|
||||
try stream.readChunkedUntil(&seen, env.gpa, sweep_redacted_upstream);
|
||||
try testing.expect(std.mem.containsAtLeast(u8, seen.items, 1, sweep_domain));
|
||||
try expectRedacted(seen.items);
|
||||
|
||||
// The stored row. The producer has already run, so closing the queue and
|
||||
// running the writer inline drains it in one call: `runWriter` returns when
|
||||
// a closed queue is empty, and a zero flush interval makes it commit the
|
||||
// batch it holds rather than wait for company.
|
||||
query_logger.shutdown(io);
|
||||
try query_logger.runWriter(io, &env.querylog_db, null);
|
||||
try testing.expectEqual(@as(u64, 1), query_logger.rows_written.load(.monotonic));
|
||||
|
||||
var stmt = try env.querylog_db.prepare(
|
||||
\\SELECT query_log.id, query_log.upstream
|
||||
\\FROM query_log JOIN domains ON domains.id = query_log.domain_id
|
||||
\\WHERE domains.domain = ?
|
||||
);
|
||||
defer stmt.deinit();
|
||||
try stmt.bindText(1, sweep_domain);
|
||||
try testing.expect(try stmt.step());
|
||||
const row_id = stmt.columnInt(0);
|
||||
try testing.expectEqualStrings(sweep_redacted_upstream, stmt.columnText(1));
|
||||
try testing.expect(!try stmt.step());
|
||||
|
||||
var conn: Conn = undefined;
|
||||
try conn.connect(io, env.addr);
|
||||
defer conn.close(io);
|
||||
|
||||
var body_buf: [64 * 1024]u8 = undefined;
|
||||
var target_buf: [64]u8 = undefined;
|
||||
|
||||
// The list row. The swept query is the newest in the log, so a page of one
|
||||
// is it.
|
||||
try conn.request("GET", "/api/queries?limit=1", null, null);
|
||||
const rows = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), rows.status);
|
||||
const page = try std.json.parseFromSliceLeaky(
|
||||
handlers_queries.Page,
|
||||
arena,
|
||||
rows.body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expectEqual(@as(usize, 1), page.queries.len);
|
||||
try testing.expectEqualStrings(sweep_domain, page.queries[0].domain);
|
||||
try testing.expectEqualStrings(sweep_redacted_upstream, page.queries[0].upstream);
|
||||
try expectRedacted(rows.body);
|
||||
|
||||
// The detail body, read by the row id the database just handed over.
|
||||
const one = try std.fmt.bufPrint(&target_buf, "/api/queries/{d}", .{row_id});
|
||||
try conn.request("GET", one, null, null);
|
||||
const detail_response = try conn.receive(&body_buf);
|
||||
try testing.expectEqual(@as(u16, 200), detail_response.status);
|
||||
const detail = try std.json.parseFromSliceLeaky(
|
||||
provenance_view.QueryDetail,
|
||||
arena,
|
||||
detail_response.body,
|
||||
.{ .ignore_unknown_fields = false },
|
||||
);
|
||||
try testing.expectEqualStrings(sweep_domain, detail.request.domain);
|
||||
try testing.expectEqualStrings(sweep_redacted_upstream, detail.route.upstream);
|
||||
try expectRedacted(detail_response.body);
|
||||
}
|
||||
|
||||
test "W10 milestone 28: no query surface echoes a resolver credential" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var env = try Env.create(gpa, .{});
|
||||
defer env.destroy();
|
||||
const io = env.io();
|
||||
|
||||
var queue_buf: [4]logger_mod.Entry = undefined;
|
||||
// Zero flush interval: the drain below is synchronous, and nothing else
|
||||
// will ever put an entry on this queue for the writer to wait for.
|
||||
var query_logger: logger_mod.Logger = .init(.{ .query_log_flush_interval_s = 0 }, &queue_buf);
|
||||
var sink: query_sink.QuerySink = .init(&query_logger, env.hub);
|
||||
|
||||
var fake: FakeUpstream = .{ .identity = sweep_upstream_url };
|
||||
var handler: dns_handler.Handler = .{
|
||||
.upstream = fake.client(),
|
||||
.blocking = .{ .mode = .zero, .ttl = 5 },
|
||||
.forward_read_timeout = .{ .raw = .fromSeconds(2), .clock = .awake },
|
||||
.manager = &env.mgr,
|
||||
.pause = &env.pauser,
|
||||
.sink = &sink,
|
||||
};
|
||||
|
||||
try bounded(io, default_budget, credentialSweep, .{ io, env, &query_logger, &handler });
|
||||
try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mutation → reload observed (ruling 12)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1980,15 +2404,21 @@ fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u
|
||||
/// can tell whether the filter let the query through.
|
||||
const FakeUpstream = struct {
|
||||
calls: std.atomic.Value(u64) = .init(0),
|
||||
/// The resolver the handler reports as having answered. Operator-supplied
|
||||
/// text in production, so the credential sweep points it at a url with a
|
||||
/// token in its path.
|
||||
identity: []const u8 = "fake://web-upstream",
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
selected: *?[]const u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
|
||||
selected.* = self.identity;
|
||||
_ = self.calls.fetchAdd(1, .monotonic);
|
||||
|
||||
const request = packet.parse(query) catch return error.BadResponse;
|
||||
@@ -2348,6 +2778,272 @@ test "drift guard a bites: methods swapped between two documented paths fail the
|
||||
try testing.expectEqual(@as(usize, 2), swapped_routes);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// focused schema drift guards (milestone 28)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Guard a proves every served route is documented and guard b counts the
|
||||
// operations, and neither looks inside a schema. A field renamed, retyped, made
|
||||
// nullable or dropped from `required` passes both while breaking every client
|
||||
// that reads the document — and the query-log provenance shapes are exactly
|
||||
// where a rename is easy and a wrong `nullable` is silent.
|
||||
//
|
||||
// So these read the schema back and hold it to the Zig struct that produces it:
|
||||
// the same property names, the same types, the same nullability, the same
|
||||
// requiredness, and no extra property on either side. A `$ref` recurses, so
|
||||
// checking `QueryDetail` checks all six of its nested objects.
|
||||
//
|
||||
// The YAML reader below understands only the shape this document is written in
|
||||
// — two-space indentation, schemas at four, properties at eight, inline `{ ... }`
|
||||
// or an indented block, and single-line flow sequences. It is not a YAML parser
|
||||
// and must not become one; a document it cannot read is a document that stopped
|
||||
// matching the house style.
|
||||
|
||||
/// One schema's body: everything from its key line to the next schema key.
|
||||
fn yamlSchema(schema_name: []const u8) ?[]const u8 {
|
||||
var key_buf: [64]u8 = undefined;
|
||||
const key = std.fmt.bufPrint(&key_buf, "\n {s}:\n", .{schema_name}) catch return null;
|
||||
const at = std.mem.indexOf(u8, openapi.yaml, key) orelse return null;
|
||||
const body = openapi.yaml[at + key.len ..];
|
||||
|
||||
var end: usize = 0;
|
||||
var lines = std.mem.splitScalar(u8, body, '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break;
|
||||
end += line.len + 1;
|
||||
}
|
||||
return body[0..@min(end, body.len)];
|
||||
}
|
||||
|
||||
/// One property's definition: the rest of its line for the inline form, or the
|
||||
/// indented block that follows it.
|
||||
fn yamlProperty(schema: []const u8, property_name: []const u8) ?[]const u8 {
|
||||
const properties_at = std.mem.indexOf(u8, schema, "\n properties:\n") orelse return null;
|
||||
const properties = schema[properties_at..];
|
||||
|
||||
var key_buf: [64]u8 = undefined;
|
||||
const key = std.fmt.bufPrint(&key_buf, "\n {s}:", .{property_name}) catch return null;
|
||||
const at = std.mem.indexOf(u8, properties, key) orelse return null;
|
||||
const rest = properties[at + key.len ..];
|
||||
|
||||
const line_end = std.mem.indexOfScalar(u8, rest, '\n') orelse rest.len;
|
||||
if (std.mem.trim(u8, rest[0..line_end], " ").len != 0) return rest[0..line_end];
|
||||
|
||||
var end: usize = line_end + 1;
|
||||
var lines = std.mem.splitScalar(u8, rest[line_end + 1 ..], '\n');
|
||||
while (lines.next()) |line| {
|
||||
if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break;
|
||||
end += line.len + 1;
|
||||
}
|
||||
return rest[0..@min(end, rest.len)];
|
||||
}
|
||||
|
||||
/// The comma-separated items of a single-line flow sequence, `key: [a, b, c]`.
|
||||
fn yamlFlowSeq(schema: []const u8, key: []const u8, out: *std.ArrayList([]const u8), gpa: Allocator) !void {
|
||||
var key_buf: [32]u8 = undefined;
|
||||
const needle = try std.fmt.bufPrint(&key_buf, "\n {s}: [", .{key});
|
||||
const at = std.mem.indexOf(u8, schema, needle) orelse return error.TestUnexpectedResult;
|
||||
const rest = schema[at + needle.len ..];
|
||||
const close = std.mem.indexOfScalar(u8, rest, ']') orelse return error.TestUnexpectedResult;
|
||||
|
||||
var items = std.mem.splitScalar(u8, rest[0..close], ',');
|
||||
while (items.next()) |item| try out.append(gpa, std.mem.trim(u8, item, " "));
|
||||
}
|
||||
|
||||
/// The property names the schema declares, in document order.
|
||||
fn yamlPropertyNames(schema: []const u8, out: *std.ArrayList([]const u8), gpa: Allocator) !void {
|
||||
const properties_at = std.mem.indexOf(u8, schema, "\n properties:\n") orelse
|
||||
return error.TestUnexpectedResult;
|
||||
var lines = std.mem.splitScalar(u8, schema[properties_at + 1 ..], '\n');
|
||||
_ = lines.next();
|
||||
while (lines.next()) |line| {
|
||||
if (line.len != 0 and !std.mem.startsWith(u8, line, " ")) break;
|
||||
if (!std.mem.startsWith(u8, line, " ") or std.mem.startsWith(u8, line, " ")) continue;
|
||||
const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
|
||||
try out.append(gpa, line[8..colon]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The OpenAPI `type` a Zig field must be documented as, or `null` when the
|
||||
/// field is a nested object and must be a `$ref` instead.
|
||||
fn documentedType(comptime T: type) ?[]const u8 {
|
||||
const Payload = switch (@typeInfo(T)) {
|
||||
.optional => |o| o.child,
|
||||
else => T,
|
||||
};
|
||||
return switch (@typeInfo(Payload)) {
|
||||
.int => "integer",
|
||||
.bool => "boolean",
|
||||
// A closed enum is a string on the wire, documented as its own schema.
|
||||
.@"enum" => null,
|
||||
.pointer => "string",
|
||||
.@"struct" => null,
|
||||
else => @compileError("no documented type for " ++ @typeName(Payload)),
|
||||
};
|
||||
}
|
||||
|
||||
fn isOptional(comptime T: type) bool {
|
||||
return @typeInfo(T) == .optional;
|
||||
}
|
||||
|
||||
/// The schema name a `$ref` property points at.
|
||||
fn refTarget(property: []const u8) ?[]const u8 {
|
||||
const marker = "$ref: \"#/components/schemas/";
|
||||
const at = std.mem.indexOf(u8, property, marker) orelse return null;
|
||||
const rest = property[at + marker.len ..];
|
||||
const close = std.mem.indexOfScalar(u8, rest, '"') orelse return null;
|
||||
return rest[0..close];
|
||||
}
|
||||
|
||||
/// Holds `schema_name` to `T`: same properties, same types, same nullability,
|
||||
/// same requiredness, nothing extra on either side. Recurses through `$ref`.
|
||||
fn expectSchemaMatches(gpa: Allocator, comptime T: type, schema_name: []const u8) !void {
|
||||
const schema = yamlSchema(schema_name) orelse {
|
||||
std.debug.print("openapi.yaml has no schema {s}\n", .{schema_name});
|
||||
return error.TestUnexpectedResult;
|
||||
};
|
||||
|
||||
var required: std.ArrayList([]const u8) = .empty;
|
||||
defer required.deinit(gpa);
|
||||
try yamlFlowSeq(schema, "required", &required, gpa);
|
||||
|
||||
const fields = @typeInfo(T).@"struct".fields;
|
||||
inline for (fields) |field| {
|
||||
const property = yamlProperty(schema, field.name) orelse {
|
||||
std.debug.print("{s}: no property {s}\n", .{ schema_name, field.name });
|
||||
return error.TestUnexpectedResult;
|
||||
};
|
||||
|
||||
// Ahead of both branches: a `$ref` property is as free to go null as a
|
||||
// scalar one, and a nested object or enum the server may omit is
|
||||
// exactly the drift a client reading the document cannot see coming.
|
||||
const documented_nullable = std.mem.containsAtLeast(u8, property, 1, "nullable: true");
|
||||
if (documented_nullable != isOptional(field.type)) {
|
||||
std.debug.print(
|
||||
"{s}.{s}: nullable is {} in the document and {} in Zig\n",
|
||||
.{ schema_name, field.name, documented_nullable, isOptional(field.type) },
|
||||
);
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
|
||||
if (comptime documentedType(field.type)) |wanted| {
|
||||
var type_buf: [32]u8 = undefined;
|
||||
const needle = try std.fmt.bufPrint(&type_buf, "type: {s}", .{wanted});
|
||||
if (!std.mem.containsAtLeast(u8, property, 1, needle)) {
|
||||
std.debug.print("{s}.{s}: not documented as {s}\n", .{ schema_name, field.name, wanted });
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
} else {
|
||||
const target = refTarget(property) orelse {
|
||||
std.debug.print("{s}.{s}: not a $ref\n", .{ schema_name, field.name });
|
||||
return error.TestUnexpectedResult;
|
||||
};
|
||||
const Payload = switch (@typeInfo(field.type)) {
|
||||
.optional => |o| o.child,
|
||||
else => field.type,
|
||||
};
|
||||
switch (@typeInfo(Payload)) {
|
||||
.@"enum" => try expectEnumMatches(gpa, Payload, target),
|
||||
else => try expectSchemaMatches(gpa, Payload, target),
|
||||
}
|
||||
}
|
||||
|
||||
var listed = false;
|
||||
for (required.items) |listed_name| listed = listed or std.mem.eql(u8, listed_name, field.name);
|
||||
if (!listed) {
|
||||
std.debug.print("{s}.{s}: not in required\n", .{ schema_name, field.name });
|
||||
return error.TestUnexpectedResult;
|
||||
}
|
||||
}
|
||||
|
||||
var documented: std.ArrayList([]const u8) = .empty;
|
||||
defer documented.deinit(gpa);
|
||||
try yamlPropertyNames(schema, &documented, gpa);
|
||||
try testing.expectEqual(fields.len, documented.items.len);
|
||||
try testing.expectEqual(fields.len, required.items.len);
|
||||
}
|
||||
|
||||
/// Holds an enum schema to its Zig enum: the same values, in the same order.
|
||||
fn expectEnumMatches(gpa: Allocator, comptime T: type, schema_name: []const u8) !void {
|
||||
const schema = yamlSchema(schema_name) orelse {
|
||||
std.debug.print("openapi.yaml has no schema {s}\n", .{schema_name});
|
||||
return error.TestUnexpectedResult;
|
||||
};
|
||||
|
||||
var values: std.ArrayList([]const u8) = .empty;
|
||||
defer values.deinit(gpa);
|
||||
try yamlFlowSeq(schema, "enum", &values, gpa);
|
||||
|
||||
const tags = @typeInfo(T).@"enum".fields;
|
||||
try testing.expectEqual(tags.len, values.items.len);
|
||||
inline for (tags, 0..) |tag, index| {
|
||||
try testing.expectEqualStrings(tag.name, values.items[index]);
|
||||
}
|
||||
}
|
||||
|
||||
test "drift guard c: the query-log schemas match the structs that serialize them" {
|
||||
const gpa = testing.allocator;
|
||||
try expectSchemaMatches(gpa, queries_repo.QueryRow, "QueryRow");
|
||||
try expectSchemaMatches(gpa, provenance_view.QueryDetail, "QueryDetail");
|
||||
try expectSchemaMatches(gpa, provenance_view.Provenance, "Provenance");
|
||||
try expectSchemaMatches(gpa, coverage_mod.Coverage, "Coverage");
|
||||
}
|
||||
|
||||
test "drift guard c: the three closed enums are documented value for value" {
|
||||
const gpa = testing.allocator;
|
||||
try expectEnumMatches(gpa, provenance.PolicyAction, "PolicyAction");
|
||||
try expectEnumMatches(gpa, provenance.PolicyReason, "PolicyReason");
|
||||
try expectEnumMatches(gpa, provenance.RouteKind, "RouteKind");
|
||||
}
|
||||
|
||||
test "drift guard c bites: a renamed, retyped or newly optional field fails it" {
|
||||
const gpa = testing.allocator;
|
||||
|
||||
// A field the document does not name at all.
|
||||
const Renamed = struct { complete: bool, available_from: i64 };
|
||||
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Renamed, "Coverage"));
|
||||
|
||||
// A field the document names, with the wrong type.
|
||||
const Retyped = struct { complete: bool, available_since: []const u8 };
|
||||
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Retyped, "Coverage"));
|
||||
|
||||
// A field the document names and types correctly, but which Zig may now
|
||||
// send as null while `nullable` is absent from the document.
|
||||
const Nullable = struct { complete: bool, available_since: ?i64 };
|
||||
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, Nullable, "Coverage"));
|
||||
|
||||
// The same drift behind a `$ref`, where the property carries no `type:` of
|
||||
// its own: a nested object the server may now omit.
|
||||
const NullableObject = struct {
|
||||
request: provenance_view.Request,
|
||||
group: ?provenance_view.Group,
|
||||
policy: provenance_view.Policy,
|
||||
rewrites: provenance_view.Rewrites,
|
||||
route: provenance_view.Route,
|
||||
response: provenance_view.Response,
|
||||
};
|
||||
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableObject, "Provenance"));
|
||||
|
||||
// And behind a `$ref` to an enum, whose values would still line up.
|
||||
const NullableEnum = struct {
|
||||
action: ?provenance.PolicyAction,
|
||||
reason: provenance.PolicyReason,
|
||||
matched: []const u8,
|
||||
source_id: ?i64,
|
||||
source_name: []const u8,
|
||||
};
|
||||
try testing.expectError(error.TestUnexpectedResult, expectSchemaMatches(gpa, NullableEnum, "ProvenancePolicy"));
|
||||
|
||||
// A struct short one documented property, which excess-property checking on
|
||||
// the client side would never catch.
|
||||
const Narrowed = struct { complete: bool };
|
||||
try testing.expectError(error.TestExpectedEqual, expectSchemaMatches(gpa, Narrowed, "Coverage"));
|
||||
|
||||
// An enum missing one of the document's values.
|
||||
const Short = enum { not_evaluated, allow };
|
||||
try testing.expectError(error.TestExpectedEqual, expectEnumMatches(gpa, Short, "PolicyAction"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// contract samples: the frontend's consumed shapes against real responses
|
||||
// (milestone-17 ruling 5)
|
||||
@@ -2443,6 +3139,10 @@ const contract_sample_walk = [_]ContractSample{
|
||||
// Query log and stats. `limit=5` reaches seeded row 21, the blocked one, so
|
||||
// the page carries both the null-bearing and the populated row shape.
|
||||
.{ .name = "get_queries", .ts_type = "QueriesPage", .method = "GET", .target = "/api/queries?limit=5", .status = 200 },
|
||||
// The newest seeded row: a CNAME-uncloaked block with a group, a source and
|
||||
// a matched pattern, so the golden exercises every nested object rather
|
||||
// than a row of nulls.
|
||||
.{ .name = "get_query_detail", .ts_type = "QueryDetail", .method = "GET", .target = "/api/queries/27", .status = 200 },
|
||||
.{ .name = "get_stats", .ts_type = "StatsTotals", .method = "GET", .target = "/api/stats?period=1h", .status = 200 },
|
||||
.{ .name = "get_stats_timeseries", .ts_type = "StatsTimeseries", .method = "GET", .target = "/api/stats/timeseries?period=1h", .status = 200 },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user