milestone 8: web server, rest api, sse, auth, metrics and static assets

This commit is contained in:
2026-08-02 00:54:13 +02:00
parent a8092bb1b9
commit 5253c47303
59 changed files with 19640 additions and 150 deletions
+365
View File
@@ -0,0 +1,365 @@
//! `/api/upstreams` — the resolvers nxdns forwards to.
//!
//! Ruling 9 makes this a resource like any other; ruling 12 makes it the one
//! mutable resource that is NOT live. The pool builds its clients, its health
//! state and its TLS material at startup, so an upstream added, edited or
//! removed here takes effect at the next restart. The response says so through
//! `restart_required`, which is the same word `/api/settings` uses, so the UI
//! has one banner and one meaning for it.
//!
//! `tls_name` is the DoT-only SNI and certificate name (migration v2). It is
//! empty for every other scheme, and the validator refuses it there.
const std = @import("std");
const Allocator = std.mem.Allocator;
const http_util = @import("../http_util.zig");
const model = @import("../../config/model.zig");
const mutations = @import("mutations.zig");
const server = @import("../server.zig");
const upstreams_repo = @import("../../storage/repositories/upstreams_repo.zig");
const Failure = mutations.Failure;
const Request = http_util.Request;
const HandlerError = http_util.HandlerError;
const url_conflict = "an upstream with that url already exists";
const Body = struct {
url: []const u8,
priority: i32 = 100,
enabled: bool = true,
tls_name: []const u8 = "",
};
const Created = union(enum) { id: i64, fail: Failure };
// ---------------------------------------------------------------------------
// decisions
// ---------------------------------------------------------------------------
pub fn applyCreate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
item: model.UpstreamServer,
) error{OutOfMemory}!Created {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return .{ .fail = failure },
};
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
state.config_lock.lockUncancelable(io);
const inserted = upstreams_repo.insertUpstreamRow(database, item);
state.config_lock.unlock(io);
const id = inserted catch |err| return .{ .fail = mutations.dbFailure(err, url_conflict) };
return .{ .id = id };
}
pub fn applyUpdate(
state: *server.WebState,
io: std.Io,
arena: Allocator,
id: i64,
item: model.UpstreamServer,
) error{OutOfMemory}!?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .invalid = problem };
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
// The same rule `applyDelete` enforces: a set with no enabled upstream
// would refuse to boot, so the write that would create one is a conflict.
if (!item.enabled) {
const remaining = countEnabledExcept(database, arena, id) catch |err|
return mutations.dbFailure(err, url_conflict);
switch (remaining) {
.missing => return .not_found,
.count => |left| if (left == 0) return .{
.conflict = "the last enabled upstream cannot be disabled",
},
}
}
upstreams_repo.updateUpstream(database, id, item) catch |err|
return mutations.dbFailure(err, url_conflict);
return null;
}
/// The last enabled upstream cannot go: a resolver with nowhere to forward to
/// answers nothing, and `validate.validate` refuses that configuration at
/// startup — so allowing it here would only produce a box that will not boot.
pub fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure {
const database = switch (mutations.configDb(state)) {
.database => |value| value,
.fail => |failure| return failure,
};
state.config_lock.lockUncancelable(io);
defer state.config_lock.unlock(io);
const remaining = countEnabledExcept(database, arena, id) catch |err|
return mutations.dbFailure(err, url_conflict);
switch (remaining) {
.missing => return .not_found,
.count => |left| if (left == 0) return .{
.conflict = "the last enabled upstream cannot be removed",
},
}
upstreams_repo.deleteUpstream(database, id) catch |err|
return mutations.dbFailure(err, url_conflict);
return null;
}
const Remaining = union(enum) { missing, count: usize };
fn countEnabledExcept(
database: *@import("../../storage/db.zig").Db,
arena: Allocator,
id: i64,
) @import("../../storage/db.zig").Error!Remaining {
const rows = try upstreams_repo.listUpstreamRows(database, arena);
var found = false;
var left: usize = 0;
for (rows.items) |row| {
if (row.id == id) {
found = true;
continue;
}
if (row.enabled) left += 1;
}
return if (found) .{ .count = left } else .missing;
}
// ---------------------------------------------------------------------------
// 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 upstreams"),
};
const rows = upstreams_repo.listUpstreamRows(database, request.arena) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "listing upstreams");
return http_util.respondJson(request, .ok, .{ .upstreams = 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 an upstream"),
};
const row = upstreams_repo.getUpstream(database, request.arena, request.id.?) catch |err|
return mutations.respondFailure(request, .{ .internal = err }, "reading an upstream");
const found = row orelse return mutations.respondFailure(request, .not_found, "");
return http_util.respondJson(request, .ok, found, &.{});
}
pub fn create(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
return mutations.respondBadBody(request, err);
const item = toModel(parsed.value);
return switch (try applyCreate(state, io, request.arena, item)) {
.fail => |failure| mutations.respondFailure(request, failure, "creating an upstream"),
.id => |id| http_util.respondJson(request, .created, .{
.id = id,
.url = item.url,
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
}, &.{}),
};
}
pub fn update(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
const parsed = http_util.parseBody(Body, request) catch |err|
return mutations.respondBadBody(request, err);
const item = toModel(parsed.value);
const id = request.id.?;
if (try applyUpdate(state, io, request.arena, id, item)) |failure| {
return mutations.respondFailure(request, failure, "updating an upstream");
}
return http_util.respondJson(request, .ok, .{
.id = id,
.url = item.url,
.priority = item.priority,
.enabled = item.enabled,
.tls_name = item.tls_name,
.restart_required = true,
}, &.{});
}
pub fn remove(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void {
if (applyDelete(state, io, request.arena, request.id.?)) |failure| {
return mutations.respondFailure(request, failure, "deleting an upstream");
}
return http_util.respondEmpty(request, .no_content);
}
fn toModel(body: Body) model.UpstreamServer {
return .{
.url = body.url,
.priority = body.priority,
.enabled = body.enabled,
.tls_name = body.tls_name,
};
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const doh: model.UpstreamServer = .{ .url = "https://dns.example/dns-query" };
test "a created upstream is stored" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const row = (try upstreams_repo.getUpstream(&bench.database, bench.arena(), created.id)).?;
try testing.expectEqualStrings(doh.url, row.url);
try testing.expect(row.enabled);
try testing.expectEqualStrings("", row.tls_name);
}
test "an upstream change never announces a reload" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
_ = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, .{
.url = doh.url,
.priority = 50,
.enabled = true,
});
// Ruling 12: the pool is built at startup, so nothing is live to reload.
try testing.expectEqual(@as(usize, 0), bench.reloads);
}
test "a url the validator refuses never reaches the database" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const scheme = try applyCreate(&bench.state, bench.io(), bench.arena(), .{ .url = "udp://1.1.1.1:53" });
try testing.expect(scheme.fail == .invalid);
const misplaced_name = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.url = "https://dns.example/dns-query",
.tls_name = "dns.example",
});
try testing.expect(misplaced_name.fail == .invalid);
try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM upstreams"));
}
test "a duplicate url is a conflict" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const again = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
try testing.expectEqualStrings(url_conflict, again.fail.conflict);
}
test "the last enabled upstream cannot be deleted" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const failure = applyDelete(&bench.state, bench.io(), bench.arena(), created.id);
try testing.expectEqualStrings("the last enabled upstream cannot be removed", failure.?.conflict);
const second = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.url = "tls://1.1.1.1:853",
.tls_name = "one.one.one.one",
});
try testing.expectEqual(
@as(?Failure, null),
applyDelete(&bench.state, bench.io(), bench.arena(), created.id),
);
try testing.expectEqual(
@as(i64, 1),
try bench.queryInt("SELECT count(*) FROM upstreams"),
);
try testing.expect(second == .id);
}
test "a disabled upstream can be created while an enabled one exists" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const spare = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.url = "tls://1.1.1.1:853",
.tls_name = "one.one.one.one",
.enabled = false,
});
try testing.expect(spare == .id);
try testing.expectEqual(
@as(i64, 0),
try bench.queryInt("SELECT enabled FROM upstreams WHERE url = 'tls://1.1.1.1:853'"),
);
}
test "the last enabled upstream cannot be disabled" {
var bench: mutations.Bench = undefined;
try bench.init(testing.allocator);
defer bench.deinit(testing.allocator);
const created = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
const off: model.UpstreamServer = .{ .url = doh.url, .enabled = false };
const refused = try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off);
try testing.expectEqualStrings("the last enabled upstream cannot be disabled", refused.?.conflict);
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1"));
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), .{
.url = "tls://1.1.1.1:853",
.tls_name = "one.one.one.one",
});
try testing.expectEqual(
@as(?Failure, null),
try applyUpdate(&bench.state, bench.io(), bench.arena(), created.id, off),
);
try testing.expectEqual(@as(i64, 1), try bench.queryInt("SELECT count(*) FROM upstreams WHERE enabled = 1"));
}
test "an id no upstream 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,
(try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).?,
);
try testing.expectEqual(
Failure.not_found,
applyDelete(&bench.state, bench.io(), bench.arena(), 999).?,
);
}