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));
|
||||
|
||||
Reference in New Issue
Block a user