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
+551
View File
@@ -0,0 +1,551 @@
//! `/api/local-records` and `/api/forward-zones` — the names nxdns answers
//! itself and the zones it hands to another resolver.
//!
//! Both take effect live (ruling 12), and not through the blocklist snapshot:
//! the two tables are rebuilt from the database and published into
//! `state.local_tables`, so the next query reads the new generation. The reload
//! seam is called as well, so the composition root learns about every
//! configuration change through one path.
//!
//! A record's type travels as the word the schema stores — `A`, `AAAA`,
//! `CNAME` — which is also the word the config file uses.
const std = @import("std");
const Allocator = std.mem.Allocator;
const http_util = @import("../http_util.zig");
const local_repo = @import("../../storage/repositories/local_repo.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const server = @import("../server.zig");
const Failure = mutations.Failure;
const Request = http_util.Request;
const HandlerError = http_util.HandlerError;
const record_conflict = "that name, type and value are already stored";
const zone_conflict = "that zone already has a resolver";
const RecordBody = struct {
name: []const u8,
rtype: []const u8,
value: []const u8,
ttl: u32 = 300,
};
const ZoneBody = struct {
zone: []const u8,
resolver: []const u8,
};
const Created = union(enum) { id: i64, fail: Failure };
fn toRecord(body: RecordBody) union(enum) { record: model.LocalRecord, fail: Failure } {
const rtype = model.RecordType.fromDb(body.rtype) orelse
return .{ .fail = .{ .invalid = "rtype must be 'A', 'AAAA' or 'CNAME'" } };
return .{ .record = .{
.name = body.name,
.rtype = rtype,
.value = body.value,
.ttl = body.ttl,
} };
}
/// The wire shape of a local record: the row with its type spelled the way the
/// schema spells it.
const RecordView = struct {
id: i64,
name: []const u8,
rtype: []const u8,
value: []const u8,
ttl: u32,
fn from(row: local_repo.LocalRecordRow) RecordView {
return .{
.id = row.id,
.name = row.name,
.rtype = row.rtype.toDb(),
.value = row.value,
.ttl = row.ttl,
};
}
};
// ---------------------------------------------------------------------------
// local records: decisions
// ---------------------------------------------------------------------------
/// Publishes the rebuilt tables and then announces the change. The swap comes
/// first because it is what makes the answer live; the seam only tells the rest
/// of the server that something moved.
///
/// Callers hold `state.config_lock` across the database write and this call:
/// the rebuild reads the generation the write produced, and the swap publishes
/// in write order — a concurrent mutation cannot overwrite a newer generation
/// with an older one.
fn publish(state: *server.WebState, io: std.Io, arena: Allocator, database: *@import("../../storage/db.zig").Db) ?Failure {
if (mutations.swapLocalTables(state, io, arena, database)) |failure| return failure;
return mutations.reload(state, io);
}
pub fn applyCreateRecord(
state: *server.WebState,
io: std.Io,
arena: Allocator,
item: model.LocalRecord,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
const id = local_repo.insertLocalRecordRow(database, item) catch |err|
return .{ .fail = mutations.dbFailure(err, record_conflict) };
if (publish(state, io, arena, database)) |failure| return .{ .fail = failure };
return .{ .id = id };
}
pub fn applyUpdateRecord(
state: *server.WebState,
io: std.Io,
arena: Allocator,
id: i64,
item: model.LocalRecord,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
local_repo.updateLocalRecord(database, id, item) catch |err|
return mutations.dbFailure(err, record_conflict);
return publish(state, io, arena, database);
}
pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
local_repo.deleteLocalRecord(database, id) catch |err|
return mutations.dbFailure(err, record_conflict);
return publish(state, io, arena, database);
}
// ---------------------------------------------------------------------------
// forward zones: decisions
// ---------------------------------------------------------------------------
pub fn applyCreateZone(
state: *server.WebState,
io: std.Io,
arena: Allocator,
item: model.ForwardZone,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
const id = local_repo.insertForwardZoneRow(database, item) catch |err|
return .{ .fail = mutations.dbFailure(err, zone_conflict) };
if (publish(state, io, arena, database)) |failure| return .{ .fail = failure };
return .{ .id = id };
}
pub fn applyUpdateZone(
state: *server.WebState,
io: std.Io,
arena: Allocator,
id: i64,
item: model.ForwardZone,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
local_repo.updateForwardZone(database, id, item) catch |err|
return mutations.dbFailure(err, zone_conflict);
return publish(state, io, arena, database);
}
pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
local_repo.deleteForwardZone(database, id) catch |err|
return mutations.dbFailure(err, zone_conflict);
return publish(state, io, arena, database);
}
// ---------------------------------------------------------------------------
// local records: routes
// ---------------------------------------------------------------------------
pub fn listRecords(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing local records"),
};
const rows = local_repo.listLocalRecordRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing local records");
const views = try request.arena.alloc(RecordView, rows.items.len);
for (views, rows.items) |*view, row| view.* = .from(row);
return http_util.respondJson(request, .ok, .{ .local_records = views }, &.{});
}
pub fn getRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a local record"),
};
const row = local_repo.getLocalRecord(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a local record");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, RecordView.from(found), &.{});
}
pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(RecordBody, request) catch |err|
return mutations.respondBadBody(request, err);
const item = switch (toRecord(parsed.value)) {
.fail => |failure| return mutations.respondFailure(request, failure, "creating a local record"),
.record => |value| value,
};
return switch (try applyCreateRecord(state, io, request.arena, item)) {
.fail => |failure| mutations.respondFailure(request, failure, "creating a local record"),
.id => |id| http_util.respondJson(request, .created, .{
.id = id,
.name = item.name,
.rtype = item.rtype.toDb(),
.value = item.value,
.ttl = item.ttl,
}, &.{}),
};
}
pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(RecordBody, request) catch |err|
return mutations.respondBadBody(request, err);
const item = switch (toRecord(parsed.value)) {
.fail => |failure| return mutations.respondFailure(request, failure, "updating a local record"),
.record => |value| value,
};
const id = request.id.?;
if (try applyUpdateRecord(state, io, request.arena, id, item)) |failure| {
return mutations.respondFailure(request, failure, "updating a local record");
}
return http_util.respondJson(request, .ok, .{
.id = id,
.name = item.name,
.rtype = item.rtype.toDb(),
.value = item.value,
.ttl = item.ttl,
}, &.{});
}
pub fn removeRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDeleteRecord(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a local record");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// forward zones: routes
// ---------------------------------------------------------------------------
pub fn listZones(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "listing forward zones"),
};
const rows = local_repo.listForwardZoneRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing forward zones");
return http_util.respondJson(request, .ok, .{ .forward_zones = rows.items }, &.{});
}
pub fn getZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
_ = io;
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return mutations.respondFailure(request, failure, "reading a forward zone"),
};
const row = local_repo.getForwardZone(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading a forward zone");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ZoneBody, request) catch |err|
return mutations.respondBadBody(request, err);
const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver };
return switch (try applyCreateZone(state, io, request.arena, item)) {
.fail => |failure| mutations.respondFailure(request, failure, "creating a forward zone"),
.id => |id| http_util.respondJson(request, .created, .{
.id = id,
.zone = item.zone,
.resolver = item.resolver,
}, &.{}),
};
}
pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(ZoneBody, request) catch |err|
return mutations.respondBadBody(request, err);
const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver };
const id = request.id.?;
if (try applyUpdateZone(state, io, request.arena, id, item)) |failure| {
return mutations.respondFailure(request, failure, "updating a forward zone");
}
return http_util.respondJson(request, .ok, .{
.id = id,
.zone = item.zone,
.resolver = item.resolver,
}, &.{});
}
pub fn removeZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDeleteZone(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting a forward zone");
}
return http_util.respondEmpty(request, .no_content);
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const nas: model.LocalRecord = .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 };
const lan: model.ForwardZone = .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" };
test "a created local record is answered by the published table" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
try testing.expect(created == .id);
try testing.expectEqual(@as(usize, 1), bench.reloads);
const handle = bench.tables.acquire(bench.io());
defer handle.release(bench.io());
try testing.expect(handle.records.hasName("nas.lan"));
try testing.expectEqual(@as(usize, 1), handle.records.lookup("nas.lan", .a).len);
}
test "an edited local record replaces what the table answers" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
const failure = try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), created.id, .{
.name = "printer.lan",
.rtype = .a,
.value = "192.168.1.11",
.ttl = 120,
});
try testing.expectEqual(@as(?Failure, null), failure);
const handle = bench.tables.acquire(bench.io());
defer handle.release(bench.io());
try testing.expect(!handle.records.hasName("nas.lan"));
try testing.expect(handle.records.hasName("printer.lan"));
}
test "a deleted local record leaves the published table empty" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
try testing.expectEqual(
@as(?Failure, null),
applyDeleteRecord(&bench.state, bench.io(), bench.arena(), created.id),
);
const handle = bench.tables.acquire(bench.io());
defer handle.release(bench.io());
try testing.expect(!handle.records.hasName("nas.lan"));
try testing.expectEqual(@as(usize, 2), bench.reloads);
}
test "a record value the validator refuses never reaches the database" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const bad_value = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{
.name = "nas.lan",
.rtype = .a,
.value = "2001:db8::1",
.ttl = 60,
});
try testing.expect(bad_value.fail == .invalid);
const bad_ttl = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{
.name = "nas.lan",
.rtype = .a,
.value = "192.168.1.10",
.ttl = 0,
});
try testing.expect(bad_ttl.fail == .invalid);
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM local_records"));
try testing.expectEqual(@as(usize, 0), bench.reloads);
}
test "the same record twice is a conflict" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
_ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
const again = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
try testing.expectEqualStrings(record_conflict, again.fail.conflict);
}
test "an id no record holds is a 404 on both update and delete" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
try testing.expectEqual(
Failure.not_found,
(try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), 999, nas)).?,
);
try testing.expectEqual(
Failure.not_found,
applyDeleteRecord(&bench.state, bench.io(), bench.arena(), 999).?,
);
}
test "a created forward zone is matched by the published table" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
try testing.expect(created == .id);
const handle = bench.tables.acquire(bench.io());
defer handle.release(bench.io());
try testing.expect(handle.zones.match("nas.lan") != null);
try testing.expect(handle.zones.match("example.test") == null);
}
test "a resolver the validator refuses never reaches the database" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{
.zone = "lan",
.resolver = "https://10.0.0.1",
});
try testing.expect(created.fail == .invalid);
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM forward_zones"));
}
test "one zone cannot have two resolvers" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
_ = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
const again = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{
.zone = "lan",
.resolver = "tcp://10.0.0.2:53",
});
try testing.expectEqualStrings(zone_conflict, again.fail.conflict);
}
test "a deleted zone stops matching" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan);
try testing.expectEqual(
@as(?Failure, null),
applyDeleteZone(&bench.state, bench.io(), bench.arena(), created.id),
);
const handle = bench.tables.acquire(bench.io());
defer handle.release(bench.io());
try testing.expect(handle.zones.match("nas.lan") == null);
}
test "a record change is visible to a reader that acquires afterwards" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const before = bench.tables.acquire(bench.io());
try testing.expect(!before.records.hasName("nas.lan"));
before.release(bench.io());
_ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas);
const after = bench.tables.acquire(bench.io());
defer after.release(bench.io());
try testing.expect(after.records.hasName("nas.lan"));
}
test "an unknown record type is a 400 before anything is written" {
try testing.expect(toRecord(.{
.name = "nas.lan",
.rtype = "MX",
.value = "mail.lan",
}).fail == .invalid);
const good = toRecord(.{ .name = "nas.lan", .rtype = "CNAME", .value = "other.lan" });
try testing.expectEqual(model.RecordType.cname, good.record.rtype);
try testing.expectEqual(@as(u32, 300), good.record.ttl);
}