492 lines
18 KiB
Zig
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);
|
|
}
|