Gates / frontend (push) Successful in 1m43s
Gates / test (push) Successful in 2m14s
Gates / test-aarch64 (push) Successful in 8m3s
Gates / package (push) Successful in 5m42s
Gates / container (push) Successful in 54s
CI / gates (push) Successful in 50m24s
settings and upstream writes now follow a prepare, commit, publish, retire contract: candidates are built and validated before the database transaction, published as infallible pointer swaps, and old generations retire after their readers drain. per-query policy values snapshot once per query; upstream pool, cache, rate limiter, sessions, api limiter, log sink, blocklist scheduler and the query-log queue each gained one named live operation. restart_required shrinks from every scalar key to the bind keys and web.enabled; the admin ui drops its restart notices for everything else. file mode is unchanged.
424 lines
15 KiB
Zig
424 lines
15 KiB
Zig
//! `/api/upstreams` — the resolvers nxdns forwards to.
|
|
//!
|
|
//! Ruling 9 makes this a resource like any other, and milestone 34 makes it
|
|
//! live like the rest of them: an upstream added, edited or removed here is
|
|
//! applied in-process. The row set the write will leave behind is built into a
|
|
//! candidate generation BEFORE the write, so a set that cannot produce clients
|
|
//! is refused with nothing changed; the candidate is published after the commit
|
|
//! and the displaced generation retires once the exchanges holding it finish.
|
|
//! `restart_required` in the response is therefore `false`, and stays in the
|
|
//! shape so the UI has one field with one meaning across every mutation.
|
|
//!
|
|
//! `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 apply = @import("apply.zig");
|
|
const db = @import("../../storage/db.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 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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Prepares the generation the row set after `mutation` would produce, on a
|
|
/// caller that already holds `config_lock`. Returns the plan for the caller to
|
|
/// publish or abandon.
|
|
fn planFor(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
arena: Allocator,
|
|
database: *db.Db,
|
|
mutation: apply.RowMutation,
|
|
) error{OutOfMemory}!union(enum) { plan: apply.Plan, fail: Failure } {
|
|
const cfg = mutations.loadConfig(arena, database) catch |err| switch (err) {
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
else => return .{ .fail = .{ .internal = err } },
|
|
};
|
|
const rows = upstreams_repo.listUpstreamRows(database, arena) catch |err| switch (err) {
|
|
error.OutOfMemory => return error.OutOfMemory,
|
|
else => return .{ .fail = .{ .internal = err } },
|
|
};
|
|
const servers = try apply.hypotheticalRows(arena, rows.items, mutation);
|
|
|
|
var plan: apply.Plan = .init(state, arena, cfg, .initEmpty());
|
|
if (try plan.prepareUpstreams(io, servers)) |failure| {
|
|
plan.abandon(io);
|
|
return .{ .fail = failure };
|
|
}
|
|
return .{ .plan = plan };
|
|
}
|
|
|
|
fn applyCreate(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
arena: Allocator,
|
|
item: model.UpstreamServer,
|
|
) error{OutOfMemory}!Created {
|
|
const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db };
|
|
if (try mutations.checkUpstream(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } };
|
|
|
|
state.config_lock.lockUncancelable(io);
|
|
defer state.config_lock.unlock(io);
|
|
|
|
var plan = switch (try planFor(state, io, arena, database, .{ .add = item })) {
|
|
.fail => |failure| return .{ .fail = failure },
|
|
.plan => |p| p,
|
|
};
|
|
|
|
const id = upstreams_repo.insertUpstreamRow(database, item) catch |err| {
|
|
plan.abandon(io);
|
|
return .{ .fail = mutations.dbFailure(err, url_conflict) };
|
|
};
|
|
|
|
plan.publish(io);
|
|
plan.retire(io);
|
|
return .{ .id = id };
|
|
}
|
|
|
|
fn applyUpdate(
|
|
state: *server.WebState,
|
|
io: std.Io,
|
|
arena: Allocator,
|
|
id: i64,
|
|
item: model.UpstreamServer,
|
|
) error{OutOfMemory}!?Failure {
|
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
|
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",
|
|
},
|
|
}
|
|
}
|
|
|
|
var plan = switch (try planFor(state, io, arena, database, .{
|
|
.replace = .{ .id = id, .item = item },
|
|
})) {
|
|
.fail => |failure| return failure,
|
|
.plan => |p| p,
|
|
};
|
|
|
|
upstreams_repo.updateUpstream(database, id, item) catch |err| {
|
|
plan.abandon(io);
|
|
return mutations.dbFailure(err, url_conflict);
|
|
};
|
|
|
|
plan.publish(io);
|
|
plan.retire(io);
|
|
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.
|
|
fn applyDelete(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) error{OutOfMemory}!?Failure {
|
|
const database = mutations.requireConfigDb(state) catch return mutations.no_config_db;
|
|
|
|
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",
|
|
},
|
|
}
|
|
|
|
var plan = switch (try planFor(state, io, arena, database, .{ .remove = id })) {
|
|
.fail => |failure| return failure,
|
|
.plan => |p| p,
|
|
};
|
|
|
|
upstreams_repo.deleteUpstream(database, id) catch |err| {
|
|
plan.abandon(io);
|
|
return mutations.dbFailure(err, url_conflict);
|
|
};
|
|
|
|
plan.publish(io);
|
|
plan.retire(io);
|
|
return null;
|
|
}
|
|
|
|
const Remaining = union(enum) { missing, count: usize };
|
|
|
|
fn countEnabledExcept(
|
|
database: *db.Db,
|
|
arena: Allocator,
|
|
id: i64,
|
|
) db.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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const resource = mutations.Resource(.{
|
|
.Row = upstreams_repo.UpstreamRow,
|
|
.list = upstreams_repo.listUpstreamRows,
|
|
.get = upstreams_repo.getUpstream,
|
|
.remove = applyDelete,
|
|
.label = "an upstream",
|
|
.plural = "upstreams",
|
|
.envelope = "upstreams",
|
|
});
|
|
|
|
pub const list = resource.list;
|
|
pub const get = resource.get;
|
|
pub const remove = resource.remove;
|
|
|
|
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 = false,
|
|
}, &.{}),
|
|
};
|
|
}
|
|
|
|
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 = false,
|
|
}, &.{});
|
|
}
|
|
|
|
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 or a restart" {
|
|
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 "no upstream write owes a restart, refused or accepted" {
|
|
var bench: mutations.Bench = undefined;
|
|
try bench.init(testing.allocator);
|
|
defer bench.deinit(testing.allocator);
|
|
|
|
try testing.expect((try applyCreate(&bench.state, bench.io(), bench.arena(), .{
|
|
.url = "udp://1.1.1.1:53",
|
|
})).fail == .invalid);
|
|
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
|
|
|
try testing.expect((try applyUpdate(&bench.state, bench.io(), bench.arena(), 999, doh)).? == .not_found);
|
|
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
|
|
|
try testing.expect((try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).? == .not_found);
|
|
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
|
|
|
// Milestone 34: an accepted write is applied in-process, so it owes no
|
|
// restart either. `restart_pending` now has exactly two sources, and
|
|
// neither of them is here.
|
|
_ = try applyCreate(&bench.state, bench.io(), bench.arena(), doh);
|
|
try testing.expect(!bench.state.restart_pending.load(.monotonic));
|
|
}
|
|
|
|
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 = try 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),
|
|
try 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,
|
|
(try applyDelete(&bench.state, bench.io(), bench.arena(), 999)).?,
|
|
);
|
|
}
|