//! `/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)).?, ); }