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));