Files
nxdns/src/web/handlers/local.zig
T
mokhtar 6f67940995
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s
milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
2026-08-07 18:20:30 +02:00

492 lines
18 KiB
Zig

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