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
+438 -2
View File
@@ -5,8 +5,10 @@
//! not appear in an export. `countClients` counts **all** rows, because S5's
//! "has this database ever been configured" predicate needs the true count.
//!
//! Only list / insert / deleteAll / count, plus the two runtime calls
//! `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
//! The import path is list / insert / deleteAll / count, plus the two runtime
//! calls `upsertSeen` and `pruneStale` that the Phase 7 client tracker owns.
//! Phase 8's REST surface is the third section: it speaks row ids and shows
//! every client, materialised ones included.
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -15,6 +17,7 @@ const db = @import("../db.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
const crud = @import("crud.zig");
const IdMap = context.IdMap;
const InsertContext = context.InsertContext;
@@ -193,6 +196,255 @@ pub fn countClientPrefixes(database: *db.Db) db.Error!i64 {
return database.queryInt("SELECT count(*) FROM client_prefixes");
}
// ---------------------------------------------------------------------------
// REST surface (milestone 8)
// ---------------------------------------------------------------------------
//
// `/api/clients` shows every row — a device the server materialised from live
// traffic is exactly what the operator wants to name — so these reads carry no
// `hand_edited` filter and report the flag instead. The write shapes take
// `group_id`, not a group name: the REST layer identifies every resource by row
// id, and a `group_id` no group holds must surface as the foreign-key violation
// it is.
pub const ClientRow = struct {
id: i64,
ip: []const u8,
/// `clients.name` is nullable; a NULL reads as `""`, as it does on the
/// import path.
name: []const u8,
group_id: i64,
group: []const u8,
hand_edited: bool,
first_seen: i64,
last_seen: i64,
};
/// What creating a client by hand needs. `first_seen` and `last_seen` are the
/// caller's clock, so this shape does not carry them.
pub const ClientInput = struct {
ip: []const u8,
name: []const u8 = "",
group_id: i64,
};
/// What editing a client may change (ruling 9). `ip` is absent on purpose: it is
/// the identity live traffic matches a row by, and rewriting it would collide
/// with the row the tracker materialises for the device that still holds it.
pub const ClientEdit = struct {
name: []const u8 = "",
group_id: i64,
};
const list_client_rows_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ ORDER BY c.ip
;
const get_client_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.id = ?1
;
/// Every client, materialised ones included. Every string is a heap copy owned
/// by `gpa`.
pub fn listClientRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientRow) {
var stmt = try database.prepare(list_client_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(ClientRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientRows(gpa, out.items);
while (try stmt.step()) {
const row = try readClientRow(&stmt, gpa);
errdefer freeClientRow(gpa, row);
try out.append(gpa, row);
}
return out;
}
pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
gpa.free(row.ip);
gpa.free(row.name);
gpa.free(row.group);
}
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
for (items) |item| freeClientRow(gpa, item);
}
pub fn getClient(database: *db.Db, gpa: Allocator, id: i64) db.Error!?ClientRow {
var stmt = try database.prepare(get_client_sql);
defer stmt.deinit();
try stmt.bindInt(1, id);
if (!try stmt.step()) return null;
return try readClientRow(&stmt, gpa);
}
fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow {
const ip = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(ip);
const name = try stmt.columnTextAlloc(gpa, 2);
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 4);
errdefer gpa.free(group);
return .{
.id = stmt.columnInt(0),
.ip = ip,
.name = name,
.group_id = stmt.columnInt(3),
.group = group,
.hand_edited = stmt.columnBool(5),
.first_seen = stmt.columnInt(6),
.last_seen = stmt.columnInt(7),
};
}
const insert_client_row_sql =
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
;
/// Creates a client the operator typed, so `hand_edited` is 1 — the difference
/// from `upsertSeen`, which materialises what the DNS path saw and never claims
/// a row is configuration.
///
/// `now_s` is unix epoch seconds, from `std.Io.Clock.real`; it seeds both
/// timestamps, exactly as `insertClient` does on the import path.
///
/// `error.Constraint`: `clients.ip` is UNIQUE, or `group_id` names no group.
pub fn insertClientRow(database: *db.Db, item: ClientInput, now_s: i64) db.Error!i64 {
var stmt = try database.prepare(insert_client_row_sql);
defer stmt.deinit();
try stmt.bindText(1, item.ip);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, item.group_id);
try stmt.bindInt(4, now_s);
try stmt.exec();
return database.lastInsertRowid();
}
/// An edit is what makes a client configuration, so this sets `hand_edited` to
/// 1 on every call (ruling 9) and `pruneStale` stops considering the row.
/// `first_seen` and `last_seen` stay the tracker's.
///
/// `error.NotFound`: no client holds `id`. `error.Constraint`: `group_id` names
/// no group.
pub fn updateClient(database: *db.Db, id: i64, item: ClientEdit) db.Error!void {
var stmt = try database.prepare(
"UPDATE clients SET name = ?2, group_id = ?3, hand_edited = 1 WHERE id = ?1",
);
defer stmt.deinit();
try stmt.bindInt(1, id);
try stmt.bindText(2, item.name);
try stmt.bindInt(3, item.group_id);
return crud.execStrict(database, &stmt);
}
/// `error.NotFound`: no client holds `id`. Nothing references `clients`, so a
/// delete cannot violate a constraint — and a device that keeps querying
/// re-materialises through `upsertSeen`.
pub fn deleteClient(database: *db.Db, id: i64) db.Error!void {
var stmt = try database.prepare("DELETE FROM clients WHERE id = ?1");
defer stmt.deinit();
try stmt.bindInt(1, id);
return crud.execStrict(database, &stmt);
}
pub const ClientPrefixRow = struct {
id: i64,
prefix: []const u8,
group_id: i64,
group: []const u8,
priority: i32,
};
pub const ClientPrefixInput = struct {
prefix: []const u8,
group_id: i64,
priority: i32 = 100,
};
const list_client_prefix_rows_sql =
\\SELECT p.id, p.prefix, p.group_id, g.name, p.priority FROM client_prefixes p
\\ JOIN groups g ON g.id = p.group_id
\\ ORDER BY p.prefix
;
/// Same order as `listClientPrefixes`; every string is a heap copy owned by
/// `gpa`.
pub fn listClientPrefixRows(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(ClientPrefixRow) {
var stmt = try database.prepare(list_client_prefix_rows_sql);
defer stmt.deinit();
var out: std.ArrayList(ClientPrefixRow) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixRows(gpa, out.items);
while (try stmt.step()) {
const prefix = try stmt.columnTextAlloc(gpa, 1);
errdefer gpa.free(prefix);
const group = try stmt.columnTextAlloc(gpa, 3);
errdefer gpa.free(group);
// The column is a 64-bit integer; the row field is `i32`. A value
// outside that range means something other than nxdns wrote the row.
const priority = std.math.cast(i32, stmt.columnInt(4)) orelse return error.Mismatch;
try out.append(gpa, .{
.id = stmt.columnInt(0),
.prefix = prefix,
.group_id = stmt.columnInt(2),
.group = group,
.priority = priority,
});
}
return out;
}
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
gpa.free(row.prefix);
gpa.free(row.group);
}
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
for (items) |item| freeClientPrefixRow(gpa, item);
}
/// Replaces the whole prefix table inside a transaction (ruling 9 makes
/// `/api/client-prefixes` one atomic list resource). Row ids do not survive the
/// call: every row is written fresh.
///
/// `error.Constraint`: `client_prefixes.prefix` is UNIQUE, so a prefix repeated
/// in `items` is rejected rather than collapsed — two rows for one prefix with
/// different groups or priorities is a contradiction, not a set. Also fires when
/// a `group_id` names no group. Either way the old table survives untouched.
pub fn replaceClientPrefixes(database: *db.Db, items: []const ClientPrefixInput) db.Error!void {
var tx = try db.Tx.begin(database);
errdefer tx.rollback();
try deleteAllClientPrefixes(database);
var stmt = try database.prepare(
"INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)",
);
defer stmt.deinit();
for (items) |item| {
// `reset` clears the bindings too, so all three are bound again on
// every pass.
try stmt.reset();
try stmt.bindText(1, item.prefix);
try stmt.bindInt(2, item.group_id);
try stmt.bindInt(3, item.priority);
try stmt.exec();
}
try tx.commit();
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
@@ -498,3 +750,187 @@ test "listClientPrefixes is leak-safe under allocation failure" {
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, listClientPrefixesUnderFailure, .{&ids});
}
// --- REST surface ----------------------------------------------------------
test "a client round-trips through insert, get, list, update and delete" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
const id = try insertClientRow(&database, .{
.ip = "192.168.1.7",
.name = "printer",
.group_id = 2,
}, 1700000000);
const fetched = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, fetched);
try testing.expectEqual(id, fetched.id);
try testing.expectEqualStrings("192.168.1.7", fetched.ip);
try testing.expectEqualStrings("printer", fetched.name);
try testing.expectEqual(@as(i64, 2), fetched.group_id);
try testing.expectEqualStrings("kids", fetched.group);
try testing.expect(fetched.hand_edited);
try testing.expectEqual(@as(i64, 1700000000), fetched.first_seen);
try testing.expectEqual(@as(i64, 1700000000), fetched.last_seen);
try updateClient(&database, id, .{ .name = "label printer", .group_id = 1 });
const updated = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, updated);
try testing.expectEqualStrings("label printer", updated.name);
try testing.expectEqualStrings("default", updated.group);
try testing.expectEqualStrings("192.168.1.7", updated.ip);
// The tracker's timestamps are not the editor's to move.
try testing.expectEqual(@as(i64, 1700000000), updated.first_seen);
try deleteClient(&database, id);
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, id));
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "listClientRows shows materialised clients with hand_edited false" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
_ = try insertClientRow(&database, .{ .ip = "192.168.1.10", .name = "desk", .group_id = 1 }, 1700000000);
try upsertSeen(&database, "192.168.1.99", 1700000500);
var rows = try listClientRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("192.168.1.10", rows.items[0].ip);
try testing.expect(rows.items[0].hand_edited);
try testing.expectEqualStrings("192.168.1.99", rows.items[1].ip);
try testing.expect(!rows.items[1].hand_edited);
// A materialised row carries no name; NULL reads as the empty string.
try testing.expectEqualStrings("", rows.items[1].name);
try testing.expectEqualStrings("default", rows.items[1].group);
}
test "an edited client stops being a candidate for pruneStale" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.99", 1700000000);
const id = (try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.99'"));
try updateClient(&database, id, .{ .name = "tv", .group_id = 1 });
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
const row = (try getClient(&database, testing.allocator, id)).?;
defer freeClientRow(testing.allocator, row);
try testing.expect(row.hand_edited);
}
test "client update and delete report NotFound for an id no row holds" {
var database = try openMigrated();
defer database.close();
try testing.expectError(error.NotFound, updateClient(&database, 404, .{ .group_id = 1 }));
try testing.expectError(error.NotFound, deleteClient(&database, 404));
try testing.expectEqual(@as(?ClientRow, null), try getClient(&database, testing.allocator, 404));
}
test "a duplicate ip and an unknown group both surface as error.Constraint" {
var database = try openMigrated();
defer database.close();
const id = try insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1);
try testing.expectError(
error.Constraint,
insertClientRow(&database, .{ .ip = "192.168.1.7", .group_id = 1 }, 1),
);
try testing.expectError(
error.Constraint,
insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 404 }, 1),
);
try testing.expectError(error.Constraint, updateClient(&database, id, .{ .group_id = 404 }));
try testing.expectEqual(@as(i64, 1), try countClients(&database));
}
test "client prefixes replace as one atomic list" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try replaceClientPrefixes(&database, &.{
.{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 10 },
.{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 },
});
var rows = try listClientPrefixRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientPrefixRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 2), rows.items.len);
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
try testing.expectEqual(@as(i64, 1), rows.items[0].group_id);
try testing.expectEqualStrings("default", rows.items[0].group);
try testing.expectEqual(@as(i32, 50), rows.items[0].priority);
try testing.expectEqualStrings("192.168.2.0/24", rows.items[1].prefix);
try testing.expectEqualStrings("kids", rows.items[1].group);
try testing.expectEqual(@as(i32, 10), rows.items[1].priority);
try testing.expect(rows.items[0].id != rows.items[1].id);
// The replacement is total, and the empty list clears the table.
try replaceClientPrefixes(&database, &.{.{ .prefix = "fd00::/48", .group_id = 2 }});
try testing.expectEqual(@as(i64, 1), try countClientPrefixes(&database));
try replaceClientPrefixes(&database, &.{});
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
}
test "replaceClientPrefixes rolls back on a duplicate prefix or an unknown group" {
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try replaceClientPrefixes(&database, &.{.{ .prefix = "192.168.1.0/24", .group_id = 1 }});
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
.{ .prefix = "10.0.0.0/8", .group_id = 2 },
.{ .prefix = "10.0.0.0/8", .group_id = 1 },
}));
try testing.expectError(error.Constraint, replaceClientPrefixes(&database, &.{
.{ .prefix = "10.0.0.0/8", .group_id = 404 },
}));
// Both failures left the previous list in place.
var rows = try listClientPrefixRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientPrefixRows(testing.allocator, rows.items);
try testing.expectEqual(@as(usize, 1), rows.items.len);
try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix);
}
fn clientRowsUnderFailure(gpa: Allocator, ids: *const IdMap) !void {
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try seedClients(&database, ids);
try seedClientPrefixes(&database, ids);
var rows = try listClientRows(&database, gpa);
defer rows.deinit(gpa);
defer freeClientRows(gpa, rows.items);
const one = (try getClient(&database, gpa, rows.items[0].id)).?;
defer freeClientRow(gpa, one);
var prefixes = try listClientPrefixRows(&database, gpa);
defer prefixes.deinit(gpa);
defer freeClientPrefixRows(gpa, prefixes.items);
}
test "the client read surface is leak-safe under allocation failure" {
var ids: IdMap = .empty;
defer ids.deinit(testing.allocator);
try ids.put(testing.allocator, "default", 1);
try ids.put(testing.allocator, "kids", 2);
try testing.checkAllAllocationFailures(testing.allocator, clientRowsUnderFailure, .{&ids});
}