337 lines
13 KiB
Zig
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});
|
|
}
|