milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -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});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user