//! `/api/local-records` and `/api/forward-zones` — the names nxdns answers //! itself and the zones it hands to another resolver. //! //! Both take effect live (ruling 12), and not through the blocklist snapshot: //! the two tables are rebuilt from the database and published into //! `state.local_tables`, so the next query reads the new generation. The reload //! seam is called as well, so the composition root learns about every //! configuration change through one path. //! //! A record's type travels as the word the schema stores — `A`, `AAAA`, //! `CNAME` — which is also the word the config file uses. const std = @import("std"); const Allocator = std.mem.Allocator; const http_util = @import("../http_util.zig"); const local_repo = @import("../../storage/repositories/local_repo.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 record_conflict = "that name, type and value are already stored"; const zone_conflict = "that zone already has a resolver"; const RecordBody = struct { name: []const u8, rtype: []const u8, value: []const u8, ttl: u32 = 300, }; const ZoneBody = struct { zone: []const u8, resolver: []const u8, }; const Created = union(enum) { id: i64, fail: Failure }; fn toRecord(body: RecordBody) union(enum) { record: model.LocalRecord, fail: Failure } { const rtype = model.RecordType.fromDb(body.rtype) orelse return .{ .fail = .{ .invalid = "rtype must be 'A', 'AAAA' or 'CNAME'" } }; return .{ .record = .{ .name = body.name, .rtype = rtype, .value = body.value, .ttl = body.ttl, } }; } /// The wire shape of a local record: the row with its type spelled the way the /// schema spells it. const RecordView = struct { id: i64, name: []const u8, rtype: []const u8, value: []const u8, ttl: u32, fn from(row: local_repo.LocalRecordRow) RecordView { return .{ .id = row.id, .name = row.name, .rtype = row.rtype.toDb(), .value = row.value, .ttl = row.ttl, }; } }; // --------------------------------------------------------------------------- // local records: decisions // --------------------------------------------------------------------------- /// Publishes the rebuilt tables and then announces the change. The swap comes /// first because it is what makes the answer live; the seam only tells the rest /// of the server that something moved. /// /// Callers hold `state.config_lock` across the database write and this call: /// the rebuild reads the generation the write produced, and the swap publishes /// in write order — a concurrent mutation cannot overwrite a newer generation /// with an older one. fn publish(state: *server.WebState, io: std.Io, arena: Allocator, database: *@import("../../storage/db.zig").Db) ?Failure { if (mutations.swapLocalTables(state, io, arena, database)) |failure| return failure; return mutations.reload(state, io); } pub fn applyCreateRecord( state: *server.WebState, io: std.Io, arena: Allocator, item: model.LocalRecord, ) error{OutOfMemory}!Created { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); const id = local_repo.insertLocalRecordRow(database, item) catch |err| return .{ .fail = mutations.dbFailure(err, record_conflict) }; if (publish(state, io, arena, database)) |failure| return .{ .fail = failure }; return .{ .id = id }; } pub fn applyUpdateRecord( state: *server.WebState, io: std.Io, arena: Allocator, id: i64, item: model.LocalRecord, ) error{OutOfMemory}!?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkLocalRecord(arena, item)) |problem| return .{ .invalid = problem }; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); local_repo.updateLocalRecord(database, id, item) catch |err| return mutations.dbFailure(err, record_conflict); return publish(state, io, arena, database); } pub fn applyDeleteRecord(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); local_repo.deleteLocalRecord(database, id) catch |err| return mutations.dbFailure(err, record_conflict); return publish(state, io, arena, database); } // --------------------------------------------------------------------------- // forward zones: decisions // --------------------------------------------------------------------------- pub fn applyCreateZone( state: *server.WebState, io: std.Io, arena: Allocator, item: model.ForwardZone, ) error{OutOfMemory}!Created { const database = mutations.requireConfigDb(state) catch return .{ .fail = mutations.no_config_db }; if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .fail = .{ .invalid = problem } }; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); const id = local_repo.insertForwardZoneRow(database, item) catch |err| return .{ .fail = mutations.dbFailure(err, zone_conflict) }; if (publish(state, io, arena, database)) |failure| return .{ .fail = failure }; return .{ .id = id }; } pub fn applyUpdateZone( state: *server.WebState, io: std.Io, arena: Allocator, id: i64, item: model.ForwardZone, ) error{OutOfMemory}!?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; if (try mutations.checkForwardZone(arena, item)) |problem| return .{ .invalid = problem }; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); local_repo.updateForwardZone(database, id, item) catch |err| return mutations.dbFailure(err, zone_conflict); return publish(state, io, arena, database); } pub fn applyDeleteZone(state: *server.WebState, io: std.Io, arena: Allocator, id: i64) ?Failure { const database = mutations.requireConfigDb(state) catch return mutations.no_config_db; state.config_lock.lockUncancelable(io); defer state.config_lock.unlock(io); local_repo.deleteForwardZone(database, id) catch |err| return mutations.dbFailure(err, zone_conflict); return publish(state, io, arena, database); } // --------------------------------------------------------------------------- // local records: routes // --------------------------------------------------------------------------- const records_resource = mutations.Resource(.{ .Row = local_repo.LocalRecordRow, .list = local_repo.listLocalRecordRows, .get = local_repo.getLocalRecord, .remove = applyDeleteRecord, .label = "a local record", .plural = "local records", .envelope = "local_records", .view = RecordView.from, }); pub const listRecords = records_resource.list; pub const getRecord = records_resource.get; pub const removeRecord = records_resource.remove; pub fn createRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(RecordBody, request) catch |err| return mutations.respondBadBody(request, err); const item = switch (toRecord(parsed.value)) { .fail => |failure| return mutations.respondFailure(request, failure, "creating a local record"), .record => |value| value, }; return switch (try applyCreateRecord(state, io, request.arena, item)) { .fail => |failure| mutations.respondFailure(request, failure, "creating a local record"), .id => |id| http_util.respondJson(request, .created, .{ .id = id, .name = item.name, .rtype = item.rtype.toDb(), .value = item.value, .ttl = item.ttl, }, &.{}), }; } pub fn updateRecord(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(RecordBody, request) catch |err| return mutations.respondBadBody(request, err); const item = switch (toRecord(parsed.value)) { .fail => |failure| return mutations.respondFailure(request, failure, "updating a local record"), .record => |value| value, }; const id = request.id.?; if (try applyUpdateRecord(state, io, request.arena, id, item)) |failure| { return mutations.respondFailure(request, failure, "updating a local record"); } return http_util.respondJson(request, .ok, .{ .id = id, .name = item.name, .rtype = item.rtype.toDb(), .value = item.value, .ttl = item.ttl, }, &.{}); } // --------------------------------------------------------------------------- // forward zones: routes // --------------------------------------------------------------------------- const zones_resource = mutations.Resource(.{ .Row = local_repo.ForwardZoneRow, .list = local_repo.listForwardZoneRows, .get = local_repo.getForwardZone, .remove = applyDeleteZone, .label = "a forward zone", .plural = "forward zones", .envelope = "forward_zones", }); pub const listZones = zones_resource.list; pub const getZone = zones_resource.get; pub const removeZone = zones_resource.remove; pub fn createZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(ZoneBody, request) catch |err| return mutations.respondBadBody(request, err); const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver }; return switch (try applyCreateZone(state, io, request.arena, item)) { .fail => |failure| mutations.respondFailure(request, failure, "creating a forward zone"), .id => |id| http_util.respondJson(request, .created, .{ .id = id, .zone = item.zone, .resolver = item.resolver, }, &.{}), }; } pub fn updateZone(state: *server.WebState, io: std.Io, request: *Request) HandlerError!void { const parsed = http_util.parseBody(ZoneBody, request) catch |err| return mutations.respondBadBody(request, err); const item: model.ForwardZone = .{ .zone = parsed.value.zone, .resolver = parsed.value.resolver }; const id = request.id.?; if (try applyUpdateZone(state, io, request.arena, id, item)) |failure| { return mutations.respondFailure(request, failure, "updating a forward zone"); } return http_util.respondJson(request, .ok, .{ .id = id, .zone = item.zone, .resolver = item.resolver, }, &.{}); } // --------------------------------------------------------------------------- // tests // --------------------------------------------------------------------------- const testing = std.testing; const nas: model.LocalRecord = .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }; const lan: model.ForwardZone = .{ .zone = "lan", .resolver = "udp://10.0.0.1:53" }; test "a created local record is answered by the published table" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); try testing.expect(created == .id); try testing.expectEqual(@as(usize, 1), bench.reloads); const handle = bench.tables.acquire(bench.io()); defer handle.release(bench.io()); try testing.expect(handle.records.hasName("nas.lan")); try testing.expectEqual(@as(usize, 1), handle.records.lookup("nas.lan", .a).len); } test "an edited local record replaces what the table answers" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); const failure = try applyUpdateRecord(&bench.state, bench.io(), bench.arena(), created.id, .{ .name = "printer.lan", .rtype = .a, .value = "192.168.1.11", .ttl = 120, }); try testing.expectEqual(@as(?Failure, null), failure); const handle = bench.tables.acquire(bench.io()); defer handle.release(bench.io()); try testing.expect(!handle.records.hasName("nas.lan")); try testing.expect(handle.records.hasName("printer.lan")); } test "a deleted local record leaves the published table empty" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); try testing.expectEqual( @as(?Failure, null), applyDeleteRecord(&bench.state, bench.io(), bench.arena(), created.id), ); const handle = bench.tables.acquire(bench.io()); defer handle.release(bench.io()); try testing.expect(!handle.records.hasName("nas.lan")); try testing.expectEqual(@as(usize, 2), bench.reloads); } test "a record value the validator refuses never reaches the database" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const bad_value = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{ .name = "nas.lan", .rtype = .a, .value = "2001:db8::1", .ttl = 60, }); try testing.expect(bad_value.fail == .invalid); const bad_ttl = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 0, }); try testing.expect(bad_ttl.fail == .invalid); try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM local_records")); try testing.expectEqual(@as(usize, 0), bench.reloads); } test "the same record twice is a conflict" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); _ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); const again = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); try testing.expectEqualStrings(record_conflict, again.fail.conflict); } test "an id no record 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 applyUpdateRecord(&bench.state, bench.io(), bench.arena(), 999, nas)).?, ); try testing.expectEqual( Failure.not_found, applyDeleteRecord(&bench.state, bench.io(), bench.arena(), 999).?, ); } test "a created forward zone is matched by the published table" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan); try testing.expect(created == .id); const handle = bench.tables.acquire(bench.io()); defer handle.release(bench.io()); try testing.expect(handle.zones.match("nas.lan") != null); try testing.expect(handle.zones.match("example.test") == null); } test "a resolver the validator refuses never reaches the database" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{ .zone = "lan", .resolver = "https://10.0.0.1", }); try testing.expect(created.fail == .invalid); try testing.expectEqual(@as(i64, 0), try bench.queryInt("SELECT count(*) FROM forward_zones")); } test "one zone cannot have two resolvers" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); _ = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan); const again = try applyCreateZone(&bench.state, bench.io(), bench.arena(), .{ .zone = "lan", .resolver = "tcp://10.0.0.2:53", }); try testing.expectEqualStrings(zone_conflict, again.fail.conflict); } test "a deleted zone stops matching" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const created = try applyCreateZone(&bench.state, bench.io(), bench.arena(), lan); try testing.expectEqual( @as(?Failure, null), applyDeleteZone(&bench.state, bench.io(), bench.arena(), created.id), ); const handle = bench.tables.acquire(bench.io()); defer handle.release(bench.io()); try testing.expect(handle.zones.match("nas.lan") == null); } test "a record change is visible to a reader that acquires afterwards" { var bench: mutations.Bench = undefined; try bench.init(testing.allocator); defer bench.deinit(testing.allocator); const before = bench.tables.acquire(bench.io()); try testing.expect(!before.records.hasName("nas.lan")); before.release(bench.io()); _ = try applyCreateRecord(&bench.state, bench.io(), bench.arena(), nas); const after = bench.tables.acquire(bench.io()); defer after.release(bench.io()); try testing.expect(after.records.hasName("nas.lan")); } test "an unknown record type is a 400 before anything is written" { try testing.expect(toRecord(.{ .name = "nas.lan", .rtype = "MX", .value = "mail.lan", }).fail == .invalid); const good = toRecord(.{ .name = "nas.lan", .rtype = "CNAME", .value = "other.lan" }); try testing.expectEqual(model.RecordType.cname, good.record.rtype); try testing.expectEqual(@as(u32, 300), good.record.ttl); }