milestone 8: web server, rest api, sse, auth, metrics and static assets
This commit is contained in:
@@ -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).?,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user