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
+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"));
}