961 lines
38 KiB
Zig
961 lines
38 KiB
Zig
//! `clients` and `client_prefixes`.
|
|
//!
|
|
//! `listClients` returns only `hand_edited = 1` rows. A client the server
|
|
//! materialised from live traffic is runtime state, not configuration, and must
|
|
//! not appear in an export. `hand_edited` is the only marker of operator intent
|
|
//! in this table, so it also decides what the reconcile engine may delete: a
|
|
//! declared row the file drops is removed, an observed row is kept whatever the
|
|
//! file says, and declaring an observed address promotes that row in place.
|
|
//! `countClients` counts **all** rows and is a test helper.
|
|
//!
|
|
//! The configuration path is list / insert / update / delete / count, plus the
|
|
//! two runtime calls `upsertSeen` and `pruneStale` that `server/clients.zig`'s
|
|
//! tracker owns.
|
|
//! The 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;
|
|
|
|
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;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// clients
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const list_clients_sql =
|
|
\\SELECT c.ip, c.name, g.name FROM clients c
|
|
\\ JOIN groups g ON g.id = c.group_id
|
|
\\ WHERE c.hand_edited = 1
|
|
\\ ORDER BY c.ip
|
|
;
|
|
|
|
/// Every string in the result is a heap copy owned by `gpa`.
|
|
pub fn listClients(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.Client) {
|
|
return crud.listRows(model.Client, database, gpa, list_clients_sql, readClient);
|
|
}
|
|
|
|
fn readClient(stmt: *db.Stmt, gpa: Allocator) db.Error!model.Client {
|
|
const ip = try stmt.columnTextAlloc(gpa, 0);
|
|
errdefer gpa.free(ip);
|
|
// `clients.name` is nullable; `columnTextAlloc` reads NULL as "", which is
|
|
// exactly the model's default.
|
|
const name = try stmt.columnTextAlloc(gpa, 1);
|
|
errdefer gpa.free(name);
|
|
const group = try stmt.columnTextAlloc(gpa, 2);
|
|
errdefer gpa.free(group);
|
|
return .{ .ip = ip, .name = name, .group = group };
|
|
}
|
|
|
|
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
|
|
crud.freeRows(model.Client, gpa, items);
|
|
}
|
|
|
|
const insert_client_sql =
|
|
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
|
\\VALUES (?1, ?2, ?3, 1, ?4, ?4)
|
|
;
|
|
|
|
/// `hand_edited` is 1: a client that reached a repository through the config
|
|
/// model came from an operator's file, by definition.
|
|
pub fn insertClient(database: *db.Db, item: model.Client, ctx: InsertContext) db.Error!void {
|
|
const group_id = try ctx.groupId(item.group);
|
|
|
|
var stmt = try database.prepare(insert_client_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, item.ip);
|
|
try stmt.bindText(2, item.name);
|
|
try stmt.bindInt(3, group_id);
|
|
try stmt.bindInt(4, ctx.now);
|
|
try stmt.exec();
|
|
}
|
|
|
|
const upsert_seen_sql =
|
|
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
|
\\VALUES (?1, NULL, (SELECT id FROM groups WHERE name = 'default'), 0, ?2, ?2)
|
|
\\ON CONFLICT(ip) DO UPDATE SET last_seen = excluded.last_seen
|
|
;
|
|
|
|
/// Materialises a client seen in live traffic (PLAN §7.2), or touches
|
|
/// `last_seen` on the row that already holds `ip`.
|
|
///
|
|
/// The conflict target is `clients.ip`, which the schema declares UNIQUE. Only
|
|
/// `last_seen` is updated: `name`, `group_id` and `hand_edited` are the
|
|
/// operator's, and a device that keeps querying must not overwrite them. A
|
|
/// hand-edited row is touched too, so the operator sees liveness for the
|
|
/// clients they named.
|
|
///
|
|
/// A new row lands in the `default` group. `groupForClient` resolves the real
|
|
/// group from the prefix rules at query time, so the column here only decides
|
|
/// what the operator sees before they assign the device themselves.
|
|
///
|
|
/// A database with no group named `default` fails the insert with
|
|
/// `error.Constraint` rather than writing a dangling row. `validate.zig`
|
|
/// rejects such a configuration long before a server serves from it.
|
|
pub fn upsertSeen(database: *db.Db, ip: []const u8, now_s: i64) db.Error!void {
|
|
var stmt = try database.prepare(upsert_seen_sql);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, ip);
|
|
try stmt.bindInt(2, now_s);
|
|
try stmt.exec();
|
|
}
|
|
|
|
/// Deletes the auto-materialised clients whose last query predates `cutoff_s`,
|
|
/// and returns how many rows went. `hand_edited = 1` rows are configuration and
|
|
/// survive any silence.
|
|
///
|
|
/// `last_seen` is the only signal, because §3.6 forbids joining `config.db`
|
|
/// against the query log.
|
|
pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
|
|
var stmt = try database.prepare("DELETE FROM clients WHERE hand_edited = 0 AND last_seen < ?1");
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, cutoff_s);
|
|
try stmt.exec();
|
|
const deleted = database.changes();
|
|
return @intCast(@min(deleted, std.math.maxInt(u32)));
|
|
}
|
|
|
|
pub fn deleteAllClients(database: *db.Db) db.Error!void {
|
|
return database.exec("DELETE FROM clients;");
|
|
}
|
|
|
|
/// Counts every row, including the materialised ones `listClients` filters out.
|
|
/// Used by tests.
|
|
pub fn countClients(database: *db.Db) db.Error!i64 {
|
|
return database.queryInt("SELECT count(*) FROM clients");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// client_prefixes
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const list_client_prefixes_sql =
|
|
\\SELECT p.prefix, g.name, p.priority FROM client_prefixes p
|
|
\\ JOIN groups g ON g.id = p.group_id
|
|
\\ ORDER BY p.prefix
|
|
;
|
|
|
|
pub fn listClientPrefixes(database: *db.Db, gpa: Allocator) db.Error!std.ArrayList(model.ClientPrefix) {
|
|
return crud.listRows(model.ClientPrefix, database, gpa, list_client_prefixes_sql, readClientPrefix);
|
|
}
|
|
|
|
fn readClientPrefix(stmt: *db.Stmt, gpa: Allocator) db.Error!model.ClientPrefix {
|
|
const prefix = try stmt.columnTextAlloc(gpa, 0);
|
|
errdefer gpa.free(prefix);
|
|
const group = try stmt.columnTextAlloc(gpa, 1);
|
|
errdefer gpa.free(group);
|
|
// The column is a 64-bit integer; the model field is `i32`. A value outside
|
|
// that range means something other than nxdns wrote the row.
|
|
const priority = std.math.cast(i32, stmt.columnInt(2)) orelse return error.Mismatch;
|
|
return .{ .prefix = prefix, .group = group, .priority = priority };
|
|
}
|
|
|
|
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
|
|
crud.freeRows(model.ClientPrefix, gpa, items);
|
|
}
|
|
|
|
pub fn insertClientPrefix(database: *db.Db, item: model.ClientPrefix, ctx: InsertContext) db.Error!void {
|
|
const group_id = try ctx.groupId(item.group);
|
|
|
|
var stmt = try database.prepare("INSERT INTO client_prefixes (prefix, group_id, priority) VALUES (?1, ?2, ?3)");
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, item.prefix);
|
|
try stmt.bindInt(2, group_id);
|
|
try stmt.bindInt(3, item.priority);
|
|
try stmt.exec();
|
|
}
|
|
|
|
pub fn deleteAllClientPrefixes(database: *db.Db) db.Error!void {
|
|
return database.exec("DELETE FROM client_prefixes;");
|
|
}
|
|
|
|
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) {
|
|
return crud.listRows(ClientRow, database, gpa, list_client_rows_sql, readClientRow);
|
|
}
|
|
|
|
pub fn freeClientRow(gpa: Allocator, row: ClientRow) void {
|
|
crud.freeRow(ClientRow, gpa, row);
|
|
}
|
|
|
|
pub fn freeClientRows(gpa: Allocator, items: []const ClientRow) void {
|
|
crud.freeRows(ClientRow, gpa, items);
|
|
}
|
|
|
|
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) {
|
|
return crud.listRows(ClientPrefixRow, database, gpa, list_client_prefix_rows_sql, readClientPrefixRow);
|
|
}
|
|
|
|
fn readClientPrefixRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientPrefixRow {
|
|
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;
|
|
return .{
|
|
.id = stmt.columnInt(0),
|
|
.prefix = prefix,
|
|
.group_id = stmt.columnInt(2),
|
|
.group = group,
|
|
.priority = priority,
|
|
};
|
|
}
|
|
|
|
pub fn freeClientPrefixRow(gpa: Allocator, row: ClientPrefixRow) void {
|
|
crud.freeRow(ClientPrefixRow, gpa, row);
|
|
}
|
|
|
|
pub fn freeClientPrefixRows(gpa: Allocator, items: []const ClientPrefixRow) void {
|
|
crud.freeRows(ClientPrefixRow, gpa, items);
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// reconcile surface (milestone 20)
|
|
// ---------------------------------------------------------------------------
|
|
//
|
|
// `replaceClientPrefixes` above is the REST list resource: one atomic swap of
|
|
// the whole table, in a transaction of its own. The reconcile engine cannot use
|
|
// it — it runs inside a transaction already, and rewriting every row would
|
|
// forfeit the row ids and the zero-writes property the engine exists for — so
|
|
// it edits and removes prefixes one at a time instead.
|
|
|
|
/// Writes the two columns a prefix row carries besides its identity.
|
|
///
|
|
/// `error.NotFound`: no prefix holds `id`. `error.Constraint`:
|
|
/// `client_prefixes.prefix` is UNIQUE, or `group_id` names no group.
|
|
pub fn updateClientPrefix(database: *db.Db, id: i64, item: ClientPrefixInput) db.Error!void {
|
|
var stmt = try database.prepare(
|
|
"UPDATE client_prefixes SET prefix = ?2, group_id = ?3, priority = ?4 WHERE id = ?1",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
try stmt.bindText(2, item.prefix);
|
|
try stmt.bindInt(3, item.group_id);
|
|
try stmt.bindInt(4, item.priority);
|
|
return crud.execStrict(database, &stmt);
|
|
}
|
|
|
|
/// `error.NotFound`: no prefix holds `id`. Nothing references
|
|
/// `client_prefixes`, so a delete cannot violate a constraint.
|
|
pub fn deleteClientPrefix(database: *db.Db, id: i64) db.Error!void {
|
|
var stmt = try database.prepare("DELETE FROM client_prefixes WHERE id = ?1");
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, id);
|
|
return crud.execStrict(database, &stmt);
|
|
}
|
|
|
|
/// Moves the observed clients of one group to another, and reports how many
|
|
/// rows moved.
|
|
///
|
|
/// `clients.group_id` references `groups(id)` with no `ON DELETE` action
|
|
/// (config_schema.zig:26), so a group that any client still sits in cannot be
|
|
/// deleted. When a configuration stops declaring a group, its *declared*
|
|
/// clients go with it, but the devices the DNS path materialised into it did
|
|
/// not come from the configuration and must not be deleted for a decision that
|
|
/// was never about them. They move to the default group, which is also the
|
|
/// semantics the operator asked for: they un-declared the group, not the
|
|
/// devices.
|
|
///
|
|
/// `hand_edited = 1` rows are untouched — those are configuration, and the
|
|
/// reconcile engine has already accounted for them.
|
|
pub fn reassignObservedClients(database: *db.Db, from_group_id: i64, to_group_id: i64) db.Error!u32 {
|
|
var stmt = try database.prepare(
|
|
"UPDATE clients SET group_id = ?2 WHERE hand_edited = 0 AND group_id = ?1",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindInt(1, from_group_id);
|
|
try stmt.bindInt(2, to_group_id);
|
|
try stmt.exec();
|
|
const moved = database.changes();
|
|
return @intCast(@min(moved, std.math.maxInt(u32)));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const testing = std.testing;
|
|
|
|
fn openMigrated() !db.Db {
|
|
var database = try db.Db.open(":memory:", .{ .mode = .memory });
|
|
errdefer database.close();
|
|
try db.applyPragmas(&database, .{});
|
|
_ = try migrations.migrate(&database);
|
|
return database;
|
|
}
|
|
|
|
/// Migration step 1 seeds `(1, 'default')`; `kids` is added here so the join
|
|
/// has two distinct groups to resolve.
|
|
fn seedGroups(database: *db.Db) !IdMap {
|
|
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
|
|
var ids: IdMap = .empty;
|
|
errdefer ids.deinit(testing.allocator);
|
|
try ids.put(testing.allocator, "default", 1);
|
|
try ids.put(testing.allocator, "kids", 2);
|
|
return ids;
|
|
}
|
|
|
|
fn seedClients(database: *db.Db, ids: *const IdMap) !void {
|
|
const ctx: InsertContext = .{ .now = 1700000000, .group_ids = ids };
|
|
try insertClient(database, .{ .ip = "192.168.1.20", .name = "laptop", .group = "kids" }, ctx);
|
|
try insertClient(database, .{ .ip = "192.168.1.10", .name = "desk" }, ctx);
|
|
try insertClient(database, .{ .ip = "fd00::1", .group = "kids" }, ctx);
|
|
}
|
|
|
|
fn seedClientPrefixes(database: *db.Db, ids: *const IdMap) !void {
|
|
const ctx: InsertContext = .{ .group_ids = ids };
|
|
try insertClientPrefix(database, .{ .prefix = "192.168.2.0/24", .group = "kids", .priority = 10 }, ctx);
|
|
try insertClientPrefix(database, .{ .prefix = "192.168.1.0/24", .priority = 50 }, ctx);
|
|
try insertClientPrefix(database, .{ .prefix = "fd00::/48", .group = "kids" }, ctx);
|
|
}
|
|
|
|
test "clients round-trip in ip order with group names resolved" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
try seedClients(&database, &ids);
|
|
|
|
var items = try listClients(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeClients(testing.allocator, items.items);
|
|
|
|
try testing.expectEqual(@as(usize, 3), items.items.len);
|
|
try testing.expectEqualStrings("192.168.1.10", items.items[0].ip);
|
|
try testing.expectEqualStrings("desk", items.items[0].name);
|
|
try testing.expectEqualStrings("default", items.items[0].group);
|
|
try testing.expectEqualStrings("192.168.1.20", items.items[1].ip);
|
|
try testing.expectEqualStrings("laptop", items.items[1].name);
|
|
try testing.expectEqualStrings("kids", items.items[1].group);
|
|
try testing.expectEqualStrings("fd00::1", items.items[2].ip);
|
|
try testing.expectEqualStrings("", items.items[2].name);
|
|
try testing.expectEqualStrings("kids", items.items[2].group);
|
|
|
|
try testing.expectEqual(
|
|
@as(i64, 1700000000),
|
|
try database.queryInt("SELECT first_seen FROM clients WHERE ip = '192.168.1.10'"),
|
|
);
|
|
try testing.expectEqual(
|
|
@as(i64, 1700000000),
|
|
try database.queryInt("SELECT last_seen FROM clients WHERE ip = '192.168.1.10'"),
|
|
);
|
|
}
|
|
|
|
test "a hand_edited = 0 client is absent from listClients but counted by countClients" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
try seedClients(&database, &ids);
|
|
try database.exec(
|
|
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
|
|
\\VALUES ('10.0.0.5', 'auto', 1, 0, 1, 1);
|
|
);
|
|
|
|
try testing.expectEqual(@as(i64, 4), try countClients(&database));
|
|
|
|
var items = try listClients(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeClients(testing.allocator, items.items);
|
|
try testing.expectEqual(@as(usize, 3), items.items.len);
|
|
for (items.items) |item| {
|
|
try testing.expect(!std.mem.eql(u8, item.ip, "10.0.0.5"));
|
|
}
|
|
}
|
|
|
|
test "deleteAllClients empties the table and countClients reflects it" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
try seedClients(&database, &ids);
|
|
|
|
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
|
try deleteAllClients(&database);
|
|
try testing.expectEqual(@as(i64, 0), try countClients(&database));
|
|
}
|
|
|
|
test "insertClient reports a group the caller's map does not hold" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
const ctx: InsertContext = .{};
|
|
try testing.expectError(
|
|
error.NotFound,
|
|
insertClient(&database, .{ .ip = "192.168.1.1" }, ctx),
|
|
);
|
|
}
|
|
|
|
fn listClientsUnderFailure(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);
|
|
|
|
var items = try listClients(&database, gpa);
|
|
defer items.deinit(gpa);
|
|
defer freeClients(gpa, items.items);
|
|
}
|
|
|
|
test "listClients 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, listClientsUnderFailure, .{&ids});
|
|
}
|
|
|
|
fn seenRow(database: *db.Db, ip: []const u8) !struct { hand_edited: i64, first_seen: i64, last_seen: i64, group_id: i64 } {
|
|
var stmt = try database.prepare(
|
|
"SELECT hand_edited, first_seen, last_seen, group_id FROM clients WHERE ip = ?1",
|
|
);
|
|
defer stmt.deinit();
|
|
try stmt.bindText(1, ip);
|
|
try testing.expect(try stmt.step());
|
|
return .{
|
|
.hand_edited = stmt.columnInt(0),
|
|
.first_seen = stmt.columnInt(1),
|
|
.last_seen = stmt.columnInt(2),
|
|
.group_id = stmt.columnInt(3),
|
|
};
|
|
}
|
|
|
|
test "upsertSeen materialises an unseen client in the default group" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
try upsertSeen(&database, "192.168.1.50", 1700000000);
|
|
|
|
try testing.expectEqual(@as(i64, 1), try countClients(&database));
|
|
const row = try seenRow(&database, "192.168.1.50");
|
|
try testing.expectEqual(@as(i64, 0), row.hand_edited);
|
|
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
|
try testing.expectEqual(@as(i64, 1700000000), row.last_seen);
|
|
try testing.expectEqual(@as(i64, 1), row.group_id);
|
|
|
|
// Materialised clients are runtime state, so an export must not see them.
|
|
var items = try listClients(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeClients(testing.allocator, items.items);
|
|
try testing.expectEqual(@as(usize, 0), items.items.len);
|
|
}
|
|
|
|
test "upsertSeen touches last_seen and leaves first_seen alone" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
|
|
try upsertSeen(&database, "192.168.1.50", 1700000000);
|
|
try upsertSeen(&database, "192.168.1.50", 1700000600);
|
|
|
|
try testing.expectEqual(@as(i64, 1), try countClients(&database));
|
|
const row = try seenRow(&database, "192.168.1.50");
|
|
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
|
try testing.expectEqual(@as(i64, 1700000600), row.last_seen);
|
|
}
|
|
|
|
test "upsertSeen keeps a hand-edited row's name, group and flag" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
try seedClients(&database, &ids);
|
|
|
|
try upsertSeen(&database, "192.168.1.20", 1700009999);
|
|
|
|
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
|
const row = try seenRow(&database, "192.168.1.20");
|
|
try testing.expectEqual(@as(i64, 1), row.hand_edited);
|
|
try testing.expectEqual(@as(i64, 2), row.group_id);
|
|
try testing.expectEqual(@as(i64, 1700000000), row.first_seen);
|
|
try testing.expectEqual(@as(i64, 1700009999), row.last_seen);
|
|
|
|
var items = try listClients(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeClients(testing.allocator, items.items);
|
|
try testing.expectEqual(@as(usize, 3), items.items.len);
|
|
try testing.expectEqualStrings("laptop", items.items[1].name);
|
|
try testing.expectEqualStrings("kids", items.items[1].group);
|
|
}
|
|
|
|
test "upsertSeen reports a database with no default group" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
try database.exec("UPDATE groups SET name = 'renamed' WHERE id = 1;");
|
|
|
|
try testing.expectError(error.Constraint, upsertSeen(&database, "192.168.1.50", 1700000000));
|
|
try testing.expectEqual(@as(i64, 0), try countClients(&database));
|
|
}
|
|
|
|
test "pruneStale removes only stale auto-materialised rows" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
// A hand-edited row far older than the cutoff.
|
|
try seedClients(&database, &ids);
|
|
|
|
try upsertSeen(&database, "10.0.0.1", 1700000000);
|
|
try upsertSeen(&database, "10.0.0.2", 1700000199);
|
|
// Exactly at the cutoff: the comparison is strict, so it stays.
|
|
try upsertSeen(&database, "10.0.0.3", 1700000200);
|
|
try upsertSeen(&database, "10.0.0.4", 1700000300);
|
|
|
|
try testing.expectEqual(@as(u32, 2), try pruneStale(&database, 1700000200));
|
|
try testing.expectEqual(@as(i64, 5), try countClients(&database));
|
|
try testing.expectEqual(
|
|
@as(i64, 0),
|
|
try database.queryInt("SELECT count(*) FROM clients WHERE ip IN ('10.0.0.1', '10.0.0.2')"),
|
|
);
|
|
try testing.expectEqual(@as(i64, 3), try database.queryInt("SELECT count(*) FROM clients WHERE hand_edited = 1"));
|
|
|
|
// A second pass over the same cutoff finds nothing left to do.
|
|
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1700000200));
|
|
}
|
|
|
|
test "pruneStale spares hand-edited rows however stale" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
try seedClients(&database, &ids);
|
|
|
|
try testing.expectEqual(@as(u32, 0), try pruneStale(&database, 1800000000));
|
|
try testing.expectEqual(@as(i64, 3), try countClients(&database));
|
|
}
|
|
|
|
test "client_prefixes round-trip in prefix order with group names resolved" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
try seedClientPrefixes(&database, &ids);
|
|
|
|
var items = try listClientPrefixes(&database, testing.allocator);
|
|
defer items.deinit(testing.allocator);
|
|
defer freeClientPrefixes(testing.allocator, items.items);
|
|
|
|
try testing.expectEqual(@as(usize, 3), items.items.len);
|
|
try testing.expectEqualStrings("192.168.1.0/24", items.items[0].prefix);
|
|
try testing.expectEqualStrings("default", items.items[0].group);
|
|
try testing.expectEqual(@as(i32, 50), items.items[0].priority);
|
|
try testing.expectEqualStrings("192.168.2.0/24", items.items[1].prefix);
|
|
try testing.expectEqualStrings("kids", items.items[1].group);
|
|
try testing.expectEqual(@as(i32, 10), items.items[1].priority);
|
|
try testing.expectEqualStrings("fd00::/48", items.items[2].prefix);
|
|
try testing.expectEqualStrings("kids", items.items[2].group);
|
|
try testing.expectEqual(@as(i32, 100), items.items[2].priority);
|
|
}
|
|
|
|
test "deleteAllClientPrefixes empties the table and countClientPrefixes reflects it" {
|
|
var database = try openMigrated();
|
|
defer database.close();
|
|
var ids = try seedGroups(&database);
|
|
defer ids.deinit(testing.allocator);
|
|
try seedClientPrefixes(&database, &ids);
|
|
|
|
try testing.expectEqual(@as(i64, 3), try countClientPrefixes(&database));
|
|
try deleteAllClientPrefixes(&database);
|
|
try testing.expectEqual(@as(i64, 0), try countClientPrefixes(&database));
|
|
}
|
|
|
|
fn listClientPrefixesUnderFailure(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 seedClientPrefixes(&database, ids);
|
|
|
|
var items = try listClientPrefixes(&database, gpa);
|
|
defer items.deinit(gpa);
|
|
defer freeClientPrefixes(gpa, items.items);
|
|
}
|
|
|
|
test "listClientPrefixes 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, 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});
|
|
}
|