Files
nxdns/src/storage/repositories/clients_repo.zig
T

337 lines
13 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. `countClients` counts **all** rows, because S5's
//! "has this database ever been configured" predicate needs the true count.
//!
//! Only list / insert / deleteAll / count exist.
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 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) {
var stmt = try database.prepare(list_clients_sql);
defer stmt.deinit();
var out: std.ArrayList(model.Client) = .empty;
// `errdefer`s run in reverse: `freeClients` is declared last so it runs
// before the backing array is released.
errdefer out.deinit(gpa);
errdefer freeClients(gpa, out.items);
while (try stmt.step()) {
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);
try out.append(gpa, .{ .ip = ip, .name = name, .group = group });
}
return out;
}
pub fn freeClients(gpa: Allocator, items: []const model.Client) void {
for (items) |item| {
gpa.free(item.ip);
gpa.free(item.name);
gpa.free(item.group);
}
}
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();
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
/// Counts every row, including the ones `listClients` filters out.
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) {
var stmt = try database.prepare(list_client_prefixes_sql);
defer stmt.deinit();
var out: std.ArrayList(model.ClientPrefix) = .empty;
errdefer out.deinit(gpa);
errdefer freeClientPrefixes(gpa, out.items);
while (try stmt.step()) {
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;
try out.append(gpa, .{ .prefix = prefix, .group = group, .priority = priority });
}
return out;
}
pub fn freeClientPrefixes(gpa: Allocator, items: []const model.ClientPrefix) void {
for (items) |item| {
gpa.free(item.prefix);
gpa.free(item.group);
}
}
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");
}
// ---------------------------------------------------------------------------
// 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});
}
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});
}