//! `/api/clients` and `/api/client-prefixes` — which device belongs to which //! group. //! //! Clients have no POST (ruling 9): a row appears because the DNS path saw the //! address or because an import wrote it. What the API adds is an edit — a name //! and a group — and an edit is what turns a materialised row into //! configuration, so every PUT sets `hand_edited` and the stale-client prune //! stops considering the row (W2's `ClientEdit`). //! //! `ip` is not editable. It is the identity `upsertSeen` matches a live device //! by; rewriting it would collide with the row the tracker re-materialises for //! the device that still holds the address. A DELETE is how an operator forgets //! a device, and a device that keeps querying comes back materialised. //! //! Client prefixes are one small list resource, replaced whole and atomically //! (ruling 9): the table is a handful of rows and a partial update of an //! ordered, priority-carrying set is more ways to be wrong than to be right. //! Each prefix is stored in canonical text (dotted decimal, RFC 5952, host //! bits zeroed), so two spellings of one network collide in the API instead //! of surviving as an ambiguous pair the next restart's validation rejects. const std = @import("std"); const Allocator = std.mem.Allocator; const address = @import("../../platform/address.zig"); const clients_repo = @import("../../storage/repositories/clients_repo.zig"); const http_util = @import("../http_util.zig"); const model = @import("../../config/model.zig"); const mutations = @import("mutations.zig"); const server = @import("../server.zig"); const Failure = mutations.Failure; const Request = http_util.Request; const HandlerError = http_util.HandlerError; const group_conflict = "that group does not exist"; const prefix_conflict = "that prefix is listed twice, or names a group that does not exist"; const ClientBody = struct { name: []const u8 = "", group_id: i64, }; const PrefixItem = struct { prefix: []const u8, group_id: i64, priority: i32 = 100, }; const PrefixesBody = struct { client_prefixes: []const PrefixItem, }; // --------------------------------------------------------------------------- // decisions // --------------------------------------------------------------------------- pub fn applyUpdate( state: *server.WebState, io: std.Io, id: i64, edit: clients_repo.ClientEdit, ) ?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const written = clients_repo.updateClient(database, id, edit); state.config_lock.unlock(io); written catch |err| return mutations.dbFailure(err, group_conflict); return mutations.reload(state, io); } pub fn applyDelete(state: *server.WebState, io: std.Io, id: i64) ?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); const written = clients_repo.deleteClient(database, id); state.config_lock.unlock(io); written catch |err| return mutations.dbFailure(err, group_conflict); return mutations.reload(state, io); } pub fn applyReplacePrefixes( state: *server.WebState, io: std.Io, arena: Allocator, items: []const clients_repo.ClientPrefixInput, ) error{OutOfMemory}!?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; // Canonical duplicates are the same UNIQUE collision the database would // report for identical text, so they answer 409 (ruling 9) before the // validator can call the second spelling a 400. Unparseable text stays // out of the set; the validator names it below. const stored = try arena.alloc(clients_repo.ClientPrefixInput, items.len); var seen: std.StringHashMapUnmanaged(void) = .empty; for (stored, items) |*out, item| { out.* = item; const parsed = address.Prefix.parse(item.prefix) catch continue; out.prefix = try canonicalText(arena, parsed); const entry = try seen.getOrPut(arena, out.prefix); if (entry.found_existing) return .{ .conflict = prefix_conflict }; } if (try checkPrefixSet(arena, stored)) |problem| return .{ .invalid = problem }; state.config_lock.lockUncancelable(io); const written = clients_repo.replaceClientPrefixes(database, stored); state.config_lock.unlock(io); written catch |err| return mutations.dbFailure(err, prefix_conflict); return mutations.reload(state, io); } fn canonicalText(arena: Allocator, prefix: address.Prefix) error{OutOfMemory}![]u8 { // The longest form this writes is an IPv6 prefix, 45 + 4 bytes. var buf: [64]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); prefix.format(&w) catch unreachable; return arena.dupe(u8, w.buffered()); } /// The whole candidate list through the real validator, inside the same /// skeleton `mutations.checkClientPrefix` uses — group ids cannot be mapped /// to names here, so every row wears the skeleton group and the foreign key /// still answers for ids that name no group. fn checkPrefixSet( arena: Allocator, items: []const clients_repo.ClientPrefixInput, ) error{OutOfMemory}!?[]const u8 { const rows = try arena.alloc(model.ClientPrefix, items.len); for (rows, items) |*row, item| row.* = .{ .prefix = item.prefix, .priority = item.priority }; return mutations.firstProblem(arena, .{ .upstreams = &.{.{ .url = "https://dns.example/dns-query" }}, .groups = &.{.{ .name = "default" }}, .client_prefixes = rows, }); } // --------------------------------------------------------------------------- // routes // --------------------------------------------------------------------------- const resource = mutations.Resource(.{ .Row = clients_repo.ClientRow, .list = clients_repo.listClientRows, .get = clients_repo.getClient, .remove = applyDelete, .label = "a client", .plural = "clients", .envelope = "clients", }); pub const list = resource.list; pub const get = resource.get; pub const remove = resource.remove; /// The prefixes are one list resource with no `/{id}` route: the whole set is /// read and replaced (ruling 9), so there is nothing to get or delete by id. const prefixes_resource = mutations.Resource(.{ .Row = clients_repo.ClientPrefixRow, .list = clients_repo.listClientPrefixRows, .get = null, .remove = null, .label = "a client prefix", .plural = "client prefixes", .envelope = "client_prefixes", }); pub const listPrefixes = prefixes_resource.list; pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(ClientBody, request) catch |err| return mutations.respondBadBody(request, err); const id = request.id.?; if (applyUpdate(state, io, id, .{ .name = parsed.value.name, .group_id = parsed.value.group_id, })) |failure| { return mutations.respondFailure(request, failure, "updating a client"); } const database = state.config_db.?; const row = clients_repo.getClient(database, request.arena, id) catch |err| return mutations.respondFailure(request, .{ .internal = err }, "reading a client"); const found = row orelse return mutations.respondFailure(request, .not_found, ""); return http_util.respondJson(request, .ok, found, &.{}); } pub fn putPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(PrefixesBody, request) catch |err| return mutations.respondBadBody(request, err); const items = try request.arena.alloc(clients_repo.ClientPrefixInput, parsed.value.client_prefixes.len); for (items, parsed.value.client_prefixes) |*item, body| item.* = .{ .prefix = body.prefix, .group_id = body.group_id, .priority = body.priority, }; if (try applyReplacePrefixes(state, io, request.arena, items)) |failure| { return mutations.respondFailure(request, failure, "replacing the client prefixes"); } const database = state.config_db.?; const rows = clients_repo.listClientPrefixRows(database, request.arena) catch |err| return mutations.respondFailure(request, .{ .internal = err }, "listing client prefixes"); return http_util.respondJson(request, .ok, .{ .client_prefixes = rows.items }, &.{}); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; fn seedClient(bench: *mutations.Bench) !void { try bench.exec( \\INSERT INTO groups (id, name) VALUES (2, 'kids'); \\INSERT INTO clients (id, ip, group_id, hand_edited, first_seen, last_seen) \\VALUES (1, '192.168.1.10', 1, 0, 100, 200); ); } test "editing a client names it, moves it and marks it hand edited" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seedClient(&bench); const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 2 }); try testing.expectEqual(@as(?Failure, null), failure); try testing.expectEqual(@as(usize, 1), bench.reloads); const row = (try clients_repo.getClient(&bench.database, bench.arena(), 1)).?; try testing.expectEqualStrings("laptop", row.name); try testing.expectEqualStrings("kids", row.group); try testing.expect(row.hand_edited); // The tracker's timestamps and the address are not the API's to move. try testing.expectEqualStrings("192.168.1.10", row.ip); try testing.expectEqual(@as(i64, 100), row.first_seen); } test "editing a client into a group that does not exist is a conflict" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seedClient(&bench); const failure = applyUpdate(&bench.state, bench.io(), 1, .{ .name = "laptop", .group_id = 404 }); try testing.expectEqualStrings(group_conflict, failure.?.conflict); try testing.expectEqual(@as(usize, 0), bench.reloads); } test "an id no client holds is a 404 on both update and delete" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try testing.expectEqual( Failure.not_found, applyUpdate(&bench.state, bench.io(), 999, .{ .group_id = 1 }).?, ); try testing.expectEqual(Failure.not_found, applyDelete(&bench.state, bench.io(), 999).?); } test "deleting a client removes the row and announces the change" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try seedClient(&bench); try testing.expectEqual(@as(?Failure, null), applyDelete(&bench.state, bench.io(), 1)); try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM clients")); try testing.expectEqual(@as(usize, 1), bench.reloads); } test "the prefix list is replaced whole" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try bench.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');"); try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( &bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 10 }, .{ .prefix = "192.168.2.0/24", .group_id = 2, .priority = 20 }, }, )); try testing.expectEqual(@as(i64, 2), try bench.queryInt("SELECT count(*) FROM client_prefixes")); try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( &bench.state, bench.io(), bench.arena(), &.{.{ .prefix = "10.0.0.0/8", .group_id = 1 }}, )); const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix); try testing.expectEqual(@as(i32, 100), rows.items[0].priority); try testing.expectEqual(@as(usize, 2), bench.reloads); } test "an empty prefix list clears the table" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "192.168.1.0/24", .group_id = 1 }, }); try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( &bench.state, bench.io(), bench.arena(), &.{}, )); try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM client_prefixes")); } test "a malformed prefix is refused and the stored list survives" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "192.168.1.0/24", .group_id = 1 }, }); const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "192.168.2.0/24", .group_id = 1 }, .{ .prefix = "not-a-prefix", .group_id = 1 }, }); try testing.expect(failure.? == .invalid); const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix); try testing.expectEqual(@as(usize, 1), bench.reloads); } test "one prefix twice is a conflict and the old list survives" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "10.0.0.0/8", .group_id = 1 }, }); const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "192.168.1.0/24", .group_id = 1 }, .{ .prefix = "192.168.1.0/24", .group_id = 1, .priority = 50 }, }); try testing.expectEqualStrings(prefix_conflict, failure.?.conflict); const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix); } test "two spellings of one prefix in one PUT are a conflict" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); _ = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "10.0.0.0/8", .group_id = 1 }, }); const v6_case = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "fd00:abcd::/48", .group_id = 1 }, .{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1, .priority = 50 }, }); try testing.expectEqualStrings(prefix_conflict, v6_case.?.conflict); const host_bits = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "192.168.1.0/24", .group_id = 1 }, .{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 }, }); try testing.expectEqualStrings(prefix_conflict, host_bits.?.conflict); const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); try testing.expectEqual(@as(usize, 1), rows.items.len); try testing.expectEqualStrings("10.0.0.0/8", rows.items[0].prefix); try testing.expectEqual(@as(usize, 1), bench.reloads); } test "a prefix is stored and listed in canonical form" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); try testing.expectEqual(@as(?Failure, null), try applyReplacePrefixes( &bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "FD00:ABCD:0:0:0:0:0:0/48", .group_id = 1 }, .{ .prefix = "192.168.1.55/24", .group_id = 1, .priority = 50 }, }, )); // `listPrefixes` serves these rows, so the GET body carries the same text. const rows = try clients_repo.listClientPrefixRows(&bench.database, bench.arena()); try testing.expectEqual(@as(usize, 2), rows.items.len); try testing.expectEqualStrings("192.168.1.0/24", rows.items[0].prefix); try testing.expectEqualStrings("fd00:abcd::/48", rows.items[1].prefix); } test "a prefix naming a group that does not exist is a conflict" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const failure = try applyReplacePrefixes(&bench.state, bench.io(), bench.arena(), &.{ .{ .prefix = "192.168.1.0/24", .group_id = 404 }, }); try testing.expectEqualStrings(prefix_conflict, failure.?.conflict); }