milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
+83
-20
@@ -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 = ≶
|
||||
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 = ≶
|
||||
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 = ≶
|
||||
h.sink = &sink;
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
_ = try expectReply(udp(&h, t.io(), query_bytes, &buf));
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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 = ≶
|
||||
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 = ≶
|
||||
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 = ≶
|
||||
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 = ≶
|
||||
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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user