milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -0,0 +1,457 @@
|
||||
//! `/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 = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
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 = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
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 = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return failure,
|
||||
};
|
||||
|
||||
// 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn list(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing clients"),
|
||||
};
|
||||
|
||||
const rows = clients_repo.listClientRows(database, request.arena) catch |err|
|
||||
return mutations.respondFailure(request, .{ .internal = err }, "listing clients");
|
||||
|
||||
return http_util.respondJson(request, .ok, .{ .clients = rows.items }, &.{});
|
||||
}
|
||||
|
||||
pub fn get(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "reading a client"),
|
||||
};
|
||||
|
||||
const row = clients_repo.getClient(database, request.arena, request.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 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 remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
if (applyDelete(state, io, request.id.?)) |failure| {
|
||||
return mutations.respondFailure(request, failure, "deleting a client");
|
||||
}
|
||||
return http_util.respondEmpty(request, .no_content);
|
||||
}
|
||||
|
||||
pub fn listPrefixes(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
|
||||
_ = io;
|
||||
const database = switch (mutations.configDb(state)) {
|
||||
.database => |value| value,
|
||||
.fail => |failure| return mutations.respondFailure(request, failure, "listing client prefixes"),
|
||||
};
|
||||
|
||||
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 }, &.{});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user