milestone 8: web server, rest api, sse, auth, metrics and static assets

This commit is contained in:
2026-08-02 00:54:13 +02:00
parent a8092bb1b9
commit 5253c47303
59 changed files with 19640 additions and 150 deletions
+83 -20
View File
@@ -26,6 +26,7 @@ const edns = @import("../dns/edns.zig");
const forward_client = @import("../local/forward_client.zig");
const forward_zones = @import("../local/forward_zones.zig");
const header = @import("../dns/header.zig");
const local_tables_mod = @import("local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const manager = @import("../filter/manager.zig");
const matcher = @import("../filter/matcher.zig");
@@ -33,6 +34,7 @@ const model = @import("../config/model.zig");
const name = @import("../dns/name.zig");
const packet = @import("../dns/packet.zig");
const pause = @import("pause.zig");
const query_sink = @import("query_sink.zig");
const question = @import("../dns/question.zig");
const rate_limiter = @import("rate_limiter.zig");
const record = @import("../dns/record.zig");
@@ -95,6 +97,12 @@ comptime {
std.debug.assert(max_synthetic_len <= udp_limit_min);
}
/// What a handler with no `local_tables` reads: no local record and no forward
/// zone. Static, so the null case costs a pointer rather than a branch in every
/// stage that consults them.
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
pub const Handler = struct {
/// In production this is `pool.client()`.
upstream: transport.Client,
@@ -102,8 +110,10 @@ pub const Handler = struct {
ecs_mode: model.EcsMode = .strip,
forward_read_timeout: std.Io.Clock.Duration,
manager: ?*manager.Manager = null,
records: *const records.Records,
zones: *const forward_zones.Zones,
/// The published local records and forward zones (milestone-8 ruling 12).
/// Null means neither table exists, which is what a handler built for one
/// upstream test wants; the API rebuilds and swaps them while queries run.
local_tables: ?*local_tables_mod.LocalTables = null,
cache: ?*dns_cache.DnsCache = null,
cache_mutex: std.Io.Mutex = .init,
/// `cfg.cache.negative_ttl_max`. `DnsCache` keeps no copy of its config and
@@ -112,7 +122,7 @@ pub const Handler = struct {
negative_ttl_max: u32 = 0,
limiter: ?*rate_limiter.RateLimiter = null,
limiter_mutex: std.Io.Mutex = .init,
logger: ?*logger_mod.Logger = null,
sink: ?*query_sink.QuerySink = null,
pause: ?*pause.Pause = null,
tracker: ?*clients.Tracker = null,
stats: Stats = .{},
@@ -255,6 +265,12 @@ pub const Handler = struct {
const snapshot: ?*const matcher.Snapshot = if (acquired) |a| a.snapshot else null;
if (snapshot == null) bump(&self.stats.unfiltered_queries);
// Ruling 12: the local tables are published the same way the snapshot
// is, so one query reads one generation of both and the API can swap
// either while queries run.
const local = if (self.local_tables) |tables| tables.acquire(io) else null;
defer if (local) |held| held.release(io);
var ctx: Context = .{
.handler = self,
.io = io,
@@ -271,6 +287,8 @@ pub const Handler = struct {
.started = started,
.now_s = started.toSeconds(),
.snapshot = snapshot,
.records = if (local) |held| held.records else &empty_records,
.zones = if (local) |held| held.zones else &empty_zones,
.group = if (snapshot) |s| s.groupForClient(from) else 0,
.domain = matcher.normalize(q.name, &scratch.normalize),
};
@@ -319,6 +337,10 @@ const Context = struct {
started: std.Io.Timestamp,
now_s: i64,
snapshot: ?*const matcher.Snapshot,
/// Borrowed from the `LocalTables` handle this query holds, so both tables
/// belong to one generation and neither can be freed mid-query.
records: *const records.Records,
zones: *const forward_zones.Zones,
group: u32,
/// The queried name, normalized into `scratch.normalize`.
domain: []const u8,
@@ -328,8 +350,8 @@ const Context = struct {
/// blocklist.
fn run(ctx: *Context) Handler.Outcome {
if (ctx.q.qclass != .in) return ctx.viaUpstream(.{ .filter = false, .cache = false });
if (ctx.handler.records.hasName(ctx.domain)) return ctx.viaLocal();
if (ctx.handler.zones.match(ctx.domain)) |zone| return ctx.viaForwardZone(zone);
if (ctx.records.hasName(ctx.domain)) return ctx.viaLocal();
if (ctx.zones.match(ctx.domain)) |zone| return ctx.viaForwardZone(zone);
const paused = if (ctx.handler.pause) |p| p.isPaused(ctx.now_s) else false;
if (paused) bump(&ctx.handler.stats.paused_queries);
@@ -342,7 +364,7 @@ const Context = struct {
/// A local CNAME is returned as it stands (ruling 12). The client re-queries
/// the target, and that query runs the whole pipeline.
fn viaLocal(ctx: *Context) Handler.Outcome {
const found = ctx.handler.records.lookup(ctx.domain, ctx.q.qtype);
const found = ctx.records.lookup(ctx.domain, ctx.q.qtype);
var b = packet.ResponseBuilder.init(ctx.response_buf, ctx.hdr, ctx.q) catch
return ctx.servFail();
@@ -489,14 +511,14 @@ const Context = struct {
}
fn log(ctx: *Context, fields: LogFields) void {
const logger = ctx.handler.logger orelse return;
const sink = ctx.handler.sink orelse return;
var ip_buf: [max_ip_text]u8 = undefined;
var w: std.Io.Writer = .fixed(&ip_buf);
ctx.from.format(&w) catch unreachable;
const now = std.Io.Clock.real.now(ctx.io);
logger.log(ctx.io, logger_mod.Entry.init(.{
sink.log(ctx.io, logger_mod.Entry.init(.{
.timestamp = ctx.now_s,
.domain = ctx.domain,
.client_ip = w.buffered(),
@@ -861,6 +883,8 @@ fn bump(counter: *std.atomic.Value(u64)) void {
// Tests
// ---------------------------------------------------------------------------
const sse = @import("../web/sse.zig");
const testing = std.testing;
/// Every test needs a real `std.Io`: the handler reads the clock on every query
@@ -881,8 +905,6 @@ const TestIo = struct {
}
};
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking: response.Options = .{ .mode = .zero, .ttl = 5 };
const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(50), .clock = .awake };
const client_ip: address.NetAddress = .{ .ip4 = .{ 192, 168, 1, 50 } };
@@ -894,8 +916,6 @@ fn bare(client: transport.Client) Handler {
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
@@ -1813,7 +1833,8 @@ test "a local record answers authoritatively without reaching the upstream" {
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
h.records = &table;
var tables: local_tables_mod.LocalTables = .{ .records = table };
h.local_tables = &tables;
var query_buf: [512]u8 = undefined;
const query = queryFor(&query_buf, 0x1234, "nas.lan", .a, .in);
@@ -1846,7 +1867,8 @@ test "a local name with no record of the queried type is authoritative NODATA" {
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
h.records = &table;
var tables: local_tables_mod.LocalTables = .{ .records = table };
h.local_tables = &tables;
var query_buf: [512]u8 = undefined;
const query = queryFor(&query_buf, 0x1234, "nas.lan", .aaaa, .in);
@@ -1883,7 +1905,8 @@ test "a forward zone answers from the cache and never reaches the pool" {
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
h.zones = &zones;
var tables: local_tables_mod.LocalTables = .{ .zones = zones };
h.local_tables = &tables;
h.cache = &cache;
h.negative_ttl_max = 3600;
h.manager = &mgr;
@@ -1938,7 +1961,8 @@ test "a forward zone bypasses the blocklist and fails on its own resolver" {
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
h.zones = &zones;
var tables: local_tables_mod.LocalTables = .{ .zones = zones };
h.local_tables = &tables;
h.manager = &mgr;
var query_buf: [512]u8 = undefined;
@@ -2406,14 +2430,16 @@ test "every answered path logs the fields ruling 20 defines" {
var queue_buf: [8]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
h.manager = &mgr;
h.records = &table;
var tables: local_tables_mod.LocalTables = .{ .records = table };
h.local_tables = &tables;
h.cache = &cache;
h.negative_ttl_max = 3600;
h.logger = &lg;
h.sink = &sink;
var buf: [udp_limit_min]u8 = undefined;
var query_buf: [512]u8 = undefined;
@@ -2465,11 +2491,12 @@ test "an uncloaked block logs the cname-prefixed reason" {
var queue_buf: [4]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var fake: FakeUpstream = .{ .reply = chain };
var h = bare(fake.client());
h.manager = &mgr;
h.logger = &lg;
h.sink = &sink;
var buf: [udp_limit_min]u8 = undefined;
_ = try expectReply(udp(&h, t.io(), query_bytes, &buf));
@@ -2483,6 +2510,41 @@ test "an uncloaked block logs the cname-prefixed reason" {
try testing.expectEqualStrings("example.com", logged[0].domain());
}
test "the sink both streams and logs the query the handler answered" {
var t: TestIo = .init();
defer t.deinit();
const io = t.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var queue_buf: [4]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, hub);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
h.sink = &sink;
const id = hub.subscribe(io).?;
defer hub.unsubscribe(io, id);
var buf: [udp_limit_min]u8 = undefined;
_ = try expectReply(udp(&h, io, query_bytes, &buf));
const streamed = hub.next(io, id).?;
try testing.expectEqualStrings("example.com", streamed.domain());
try testing.expectEqualStrings("192.168.1.50", streamed.clientIp());
try testing.expectEqualStrings("pool", streamed.upstream());
try testing.expect(hub.next(io, id) == null);
var entries: [4]logger_mod.Entry = undefined;
const logged = drainLog(&lg, io, &entries);
try testing.expectEqual(@as(usize, 1), logged.len);
try testing.expectEqualStrings("example.com", logged[0].domain());
}
test "a refused query is counted and never logged" {
var t: TestIo = .init();
defer t.deinit();
@@ -2495,11 +2557,12 @@ test "a refused query is counted and never logged" {
var queue_buf: [4]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bare(fake.client());
h.limiter = &limiter;
h.logger = &lg;
h.sink = &sink;
var buf: [udp_limit_min]u8 = undefined;
_ = try expectReply(udp(&h, t.io(), query_bytes, &buf));
+217
View File
@@ -0,0 +1,217 @@
//! The published local-answer tables: the compiled local records and the
//! compiled forward zones the query path reads (milestone-8 ruling 12).
//!
//! Both tables are immutable once built, so publishing a new one is a pointer
//! swap under an `std.Io.RwLock` — the blocklist manager's pattern at a much
//! smaller scale, and for the same reason: a shared lock held for the
//! microseconds of one lookup costs an uncontended atomic pair, and reclaiming
//! the old table without a lock would need epoch tracking this project has no
//! use for.
//!
//! The two tables live under one lock because one API call can change either
//! and the query path reads both in sequence. Two locks would double the cost
//! of every query to buy nothing.
//!
//! `acquire` brackets exactly one query. The handle borrows the live fields, so
//! it must not outlive its `release` — which is why `swap` may free the tables
//! it replaced as soon as it has the exclusive lock: no reader can still be
//! holding them.
const std = @import("std");
const Allocator = std.mem.Allocator;
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
pub const LocalTables = struct {
lock: std.Io.RwLock = .init,
records: records.Records = .empty,
zones: forward_zones.Zones = .empty,
/// No records and no zones: every name goes to the filtering path.
pub const empty: LocalTables = .{};
/// Reader side of the swap. The pointers are the live fields, so release
/// the handle before the query ends and do not retain them.
pub const Handle = struct {
records: *const records.Records,
zones: *const forward_zones.Zones,
tables: *LocalTables,
pub fn release(self: Handle, io: std.Io) void {
self.tables.lock.unlockShared(io);
}
};
/// Uncancelable, like the manager's: the critical section is a lookup with
/// no socket and no file in it, so it always completes.
pub fn acquire(self: *LocalTables, io: std.Io) Handle {
self.lock.lockSharedUncancelable(io);
return .{ .records = &self.records, .zones = &self.zones, .tables = self };
}
/// Publishes `new_records` and `new_zones` and frees the tables they
/// replace. Both are installed together, so no query can see the records of
/// one generation beside the zones of another.
///
/// The caller built both tables with `gpa` and hands ownership over here.
pub fn swap(
self: *LocalTables,
io: std.Io,
gpa: Allocator,
new_records: records.Records,
new_zones: forward_zones.Zones,
) void {
self.lock.lockUncancelable(io);
var old_records = self.records;
var old_zones = self.zones;
self.records = new_records;
self.zones = new_zones;
// Freed while the exclusive lock is held: every reader that could hold
// the old tables released its shared lock before this one was granted.
old_records.deinit(gpa);
old_zones.deinit(gpa);
self.lock.unlock(io);
}
/// Frees the published tables. The caller must have stopped every reader
/// first, exactly as it must before releasing any other borrowed collaborator.
pub fn deinit(self: *LocalTables, gpa: Allocator) void {
self.records.deinit(gpa);
self.zones.deinit(gpa);
self.* = .empty;
}
};
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const TestIo = struct {
threaded: std.Io.Threaded,
fn init() TestIo {
return .{ .threaded = .init(testing.allocator, .{}) };
}
fn io(self: *TestIo) std.Io {
return self.threaded.io();
}
fn deinit(self: *TestIo) void {
self.threaded.deinit();
}
};
fn buildRecords(name: []const u8, value: []const u8) !records.Records {
return records.Records.build(testing.allocator, &.{
.{ .name = name, .rtype = .a, .value = value, .ttl = 60 },
});
}
fn buildZones(zone: []const u8) !forward_zones.Zones {
return forward_zones.Zones.build(testing.allocator, &.{
.{ .zone = zone, .resolver = "udp://10.0.0.1:53" },
});
}
test "an empty holder answers nothing and frees nothing" {
var t: TestIo = .init();
defer t.deinit();
var tables: LocalTables = .empty;
defer tables.deinit(testing.allocator);
const handle = tables.acquire(t.io());
defer handle.release(t.io());
try testing.expect(!handle.records.hasName("nas.lan"));
try testing.expectEqual(@as(?*const forward_zones.Zone, null), handle.zones.match("nas.lan"));
}
test "a swap publishes both tables together" {
var t: TestIo = .init();
defer t.deinit();
var tables: LocalTables = .empty;
defer tables.deinit(testing.allocator);
tables.swap(t.io(), testing.allocator, try buildRecords("nas.lan", "192.168.1.10"), try buildZones("lan"));
const handle = tables.acquire(t.io());
defer handle.release(t.io());
try testing.expect(handle.records.hasName("nas.lan"));
try testing.expect(handle.zones.match("nas.lan") != null);
}
test "a second swap frees the tables it replaces" {
var t: TestIo = .init();
defer t.deinit();
var tables: LocalTables = .empty;
defer tables.deinit(testing.allocator);
tables.swap(t.io(), testing.allocator, try buildRecords("old.lan", "192.168.1.10"), try buildZones("old"));
tables.swap(t.io(), testing.allocator, try buildRecords("new.lan", "192.168.1.11"), try buildZones("new"));
const handle = tables.acquire(t.io());
defer handle.release(t.io());
try testing.expect(!handle.records.hasName("old.lan"));
try testing.expect(handle.records.hasName("new.lan"));
try testing.expect(handle.zones.match("host.old") == null);
try testing.expect(handle.zones.match("host.new") != null);
}
test "a handle keeps reading the generation it acquired" {
var t: TestIo = .init();
defer t.deinit();
var tables: LocalTables = .empty;
defer tables.deinit(testing.allocator);
tables.swap(t.io(), testing.allocator, try buildRecords("first.lan", "192.168.1.10"), .empty);
const handle = tables.acquire(t.io());
try testing.expect(handle.records.hasName("first.lan"));
// Reading twice under one handle must give one answer, which is the whole
// point of bracketing a query rather than each lookup.
try testing.expect(handle.records.hasName("first.lan"));
handle.release(t.io());
tables.swap(t.io(), testing.allocator, try buildRecords("second.lan", "192.168.1.11"), .empty);
const after = tables.acquire(t.io());
defer after.release(t.io());
try testing.expect(after.records.hasName("second.lan"));
}
test "a swap waits for a live reader and the reader sees the new tables next time" {
var t: TestIo = .init();
defer t.deinit();
const io = t.io();
var tables: LocalTables = .empty;
defer tables.deinit(testing.allocator);
const Swapper = struct {
fn run(target: *LocalTables, inner: std.Io, gpa: Allocator) void {
const built = records.Records.build(gpa, &.{
.{ .name = "swapped.lan", .rtype = .a, .value = "192.168.1.12", .ttl = 60 },
}) catch return;
target.swap(inner, gpa, built, .empty);
}
};
const handle = tables.acquire(io);
var group: std.Io.Group = .init;
try group.concurrent(io, Swapper.run, .{ &tables, io, testing.allocator });
try testing.expect(!handle.records.hasName("swapped.lan"));
handle.release(io);
try group.await(io);
const after = tables.acquire(io);
defer after.release(io);
try testing.expect(after.records.hasName("swapped.lan"));
}
+16 -13
View File
@@ -31,6 +31,7 @@ const dns_cache = @import("../cache/dns_cache.zig");
const forward_zones = @import("../local/forward_zones.zig");
const handler = @import("handler.zig");
const header = @import("../dns/header.zig");
const local_tables = @import("local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const manager = @import("../filter/manager.zig");
const matcher = @import("../filter/matcher.zig");
@@ -39,6 +40,7 @@ const model = @import("../config/model.zig");
const name = @import("../dns/name.zig");
const packet = @import("../dns/packet.zig");
const pause = @import("pause.zig");
const query_sink = @import("query_sink.zig");
const question = @import("../dns/question.zig");
const rate_limiter = @import("rate_limiter.zig");
const record = @import("../dns/record.zig");
@@ -59,9 +61,6 @@ const testing = std.testing;
/// enough that a broken server fails the run instead of hanging it.
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
/// A five-second TTL makes the blocking answer's TTL unmistakable next to the
/// upstream's 300.
const blocking: response.Options = .{ .mode = .zero, .ttl = 5 };
@@ -84,8 +83,6 @@ fn baseHandler(client: transport.Client) handler.Handler {
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
@@ -316,11 +313,12 @@ test "S7 case 1: a blocked domain is answered with the zero address and logged"
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.manager = &mgr;
h.logger = &lg;
h.sink = &sink;
var loop = try Loop.bind(gpa, io, &h);
defer loop.stop(gpa, io);
@@ -409,7 +407,8 @@ test "S7 case 3: a local record answers authoritatively without an upstream" {
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.records = &table;
var tables: local_tables.LocalTables = .{ .records = table };
h.local_tables = &tables;
var loop = try Loop.bind(gpa, io, &h);
defer loop.stop(gpa, io);
@@ -477,7 +476,8 @@ test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.manager = &mgr;
h.zones = &zones;
var tables: local_tables.LocalTables = .{ .zones = zones };
h.local_tables = &tables;
h.cache = &cache;
h.negative_ttl_max = 3600;
@@ -528,12 +528,13 @@ test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a l
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.cache = &cache;
h.negative_ttl_max = 3600;
h.logger = &lg;
h.sink = &sink;
var loop = try Loop.bind(gpa, io, &h);
defer loop.stop(gpa, io);
@@ -616,11 +617,12 @@ test "S7 case 6: a cname into a blocked target blocks the original question" {
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
var h = baseHandler(fake.client());
h.manager = &mgr;
h.logger = &lg;
h.sink = &sink;
var loop = try Loop.bind(gpa, io, &h);
defer loop.stop(gpa, io);
@@ -724,11 +726,12 @@ test "S7 case 8: the third query inside the window is refused" {
var queue_buf: [log_queue_len]logger_mod.Entry = undefined;
var lg: logger_mod.Logger = .init(.{}, &queue_buf);
var sink: query_sink.QuerySink = .init(&lg, null);
var fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.limiter = &limiter;
h.logger = &lg;
h.sink = &sink;
var loop = try Loop.bind(gpa, io, &h);
defer loop.stop(gpa, io);
@@ -961,10 +964,10 @@ test "S7 case 11: the app boots, serves a query and exits zero on shutdown" {
shutdown.reset();
defer shutdown.reset();
var future = try test_io.concurrent(app.run, .{ runner, cli.Paths{
var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{
.data_dir = root,
.config = config_path,
} });
} } });
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
const client = try client_address.bind(test_io, .{ .mode = .dgram });
+148
View File
@@ -0,0 +1,148 @@
//! Where the query path hands off a finished query (PLAN §11.4).
//!
//! Milestone 6 gave the handler a `Logger`; milestone 8 gives it a second
//! consumer, the SSE hub. `QuerySink` is that fanout, and it exists so the
//! handler still makes one call and the privacy transforms still run exactly
//! once, before either consumer sees the entry.
//!
//! Order is load-bearing: PLAN:455 puts fanout ahead of persistence, so a live
//! stream shows a query while the row is still queued for the database.
//! `Hub.publish` copies and returns, so publishing first costs the query path
//! nothing it would not have paid anyway.
const std = @import("std");
const logger = @import("../storage/logger.zig");
const sse = @import("../web/sse.zig");
pub const QuerySink = struct {
logger: *logger.Logger,
/// Null when `web.enabled` is false: nothing subscribes, so nothing needs
/// a hub, and the DNS path pays one null check.
hub: ?*sse.Hub,
pub fn init(query_logger: *logger.Logger, hub: ?*sse.Hub) QuerySink {
return .{ .logger = query_logger, .hub = hub };
}
/// Transforms once, publishes, then enqueues. Never blocks the query path
/// and never fails: both consumers drop rather than wait.
pub fn log(self: *QuerySink, io: std.Io, entry: logger.Entry) void {
const transformed = self.logger.transformed(entry);
if (self.hub) |hub| hub.publish(io, transformed);
self.logger.logTransformed(io, transformed);
}
};
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
fn sampleEntry(timestamp: i64, domain: []const u8) logger.Entry {
return .init(.{
.timestamp = timestamp,
.domain = domain,
.client_ip = "192.0.2.10",
.qtype = 1,
});
}
test "the sink publishes and logs the same entry" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var sink: QuerySink = .init(&query_logger, hub);
const id = hub.subscribe(io).?;
defer hub.unsubscribe(io, id);
sink.log(io, sampleEntry(11, "example.com"));
const streamed = hub.next(io, id).?;
try testing.expectEqualStrings("example.com", streamed.domain());
try testing.expectEqual(@as(i64, 11), streamed.timestamp);
const queued = try query_logger.queue.getOne(io);
try testing.expectEqualStrings("example.com", queued.domain());
try testing.expectEqual(@as(i64, 11), queued.timestamp);
}
test "fanout does not depend on the entry reaching the queue" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var sink: QuerySink = .init(&query_logger, hub);
const id = hub.subscribe(io).?;
defer hub.unsubscribe(io, id);
// A closed queue drops what it is handed. The stream still carries the
// query, which is only true because the publish happens first.
query_logger.shutdown(io);
sink.log(io, sampleEntry(3, "ordered.example"));
try testing.expectEqualStrings("ordered.example", hub.next(io, id).?.domain());
try testing.expectEqual(@as(u64, 1), query_logger.queries_dropped.load(.monotonic));
}
test "the privacy transforms run once, before both consumers" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
const hub = try testing.allocator.create(sse.Hub);
defer testing.allocator.destroy(hub);
hub.init();
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(
.{ .hide_domains = true, .hide_client_ips = true },
&queue_buf,
);
var sink: QuerySink = .init(&query_logger, hub);
const id = hub.subscribe(io).?;
defer hub.unsubscribe(io, id);
sink.log(io, sampleEntry(4, "tracker.example"));
const streamed = hub.next(io, id).?;
try testing.expectEqualStrings(logger.hidden_marker, streamed.domain());
try testing.expectEqualStrings(logger.hidden_marker, streamed.clientIp());
const queued = try query_logger.queue.getOne(io);
try testing.expectEqualStrings(logger.hidden_marker, queued.domain());
try testing.expectEqualStrings(logger.hidden_marker, queued.clientIp());
}
test "a sink without a hub still logs" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var queue_buf: [4]logger.Entry = undefined;
var query_logger: logger.Logger = .init(.{}, &queue_buf);
var sink: QuerySink = .init(&query_logger, null);
sink.log(io, sampleEntry(5, "nohub.example"));
const queued = try query_logger.queue.getOne(io);
try testing.expectEqualStrings("nohub.example", queued.domain());
try testing.expectEqual(@as(u64, 0), query_logger.queries_dropped.load(.monotonic));
}
-6
View File
@@ -20,8 +20,6 @@ const tcp_server = @import("tcp_server.zig");
const udp_server = @import("udp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
const packet = @import("../dns/packet.zig");
const record = @import("../dns/record.zig");
const types = @import("../dns/types.zig");
@@ -31,8 +29,6 @@ const transport = @import("../upstream/transport.zig");
const testing = std.testing;
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
.mode = blocking_defaults.response,
@@ -51,8 +47,6 @@ fn bareHandler(client: transport.Client) handler.Handler {
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
@@ -17,8 +17,6 @@ const handler = @import("handler.zig");
const tcp_server = @import("tcp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
const header = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const types = @import("../dns/types.zig");
@@ -26,8 +24,6 @@ const transport = @import("../upstream/transport.zig");
const testing = std.testing;
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
.mode = blocking_defaults.response,
@@ -46,8 +42,6 @@ fn bareHandler(client: transport.Client) handler.Handler {
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
@@ -16,8 +16,6 @@ const handler = @import("handler.zig");
const udp_server = @import("udp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
const header = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const types = @import("../dns/types.zig");
@@ -25,8 +23,6 @@ const transport = @import("../upstream/transport.zig");
const testing = std.testing;
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
.mode = blocking_defaults.response,
@@ -45,8 +41,6 @@ fn bareHandler(client: transport.Client) handler.Handler {
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}