Files
nxdns/src/local/records.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

441 lines
16 KiB
Zig

//! Local DNS records (PLAN §6.4): an immutable, sorted lookup table built once
//! from the `local_records` rows and read on the query path without allocating.
//! Pure: an allocator and plain values, no `std.Io`, no clock.
//!
//! Local records are group-independent and are matched before filtering, so a
//! name that has a record here never reaches the blocklists.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("../config/model.zig");
const header = @import("../dns/header.zig");
const name = @import("../dns/name.zig");
const packet = @import("../dns/packet.zig");
const question = @import("../dns/question.zig");
const types = @import("../dns/types.zig");
const address = @import("../platform/address.zig");
pub const Value = union(enum) { a: [4]u8, aaaa: [16]u8, cname: name.Name };
pub const Record = struct {
/// Normalized owner name: lowercase, no trailing dot.
owner: []const u8,
value: Value,
ttl: u32,
};
pub const Error = error{ OutOfMemory, BadRecordValue, BadRecordName, TooManyRecords };
pub const max_records: usize = 10_000;
pub const Records = struct {
/// Sorted by (owner, rtype) so lookup is a binary search and the answer
/// order for one name is stable across restarts.
items: []const Record,
/// One block holding every `Record.owner`; freed as a unit.
owners: []const u8,
pub const empty: Records = .{ .items = &.{}, .owners = &.{} };
/// `gpa` owns the result; `deinit` frees it. Values are parsed here, once.
/// A bad value is an error, not a skipped row — `validate.zig` already
/// rejects these, so reaching one here means the database was edited behind
/// nxdns's back and silence would make a record vanish with no signal.
pub fn build(gpa: Allocator, rows: []const model.LocalRecord) Error!Records {
if (rows.len == 0) return .empty;
if (rows.len > max_records) return error.TooManyRecords;
var owners: std.ArrayList(u8) = .empty;
defer owners.deinit(gpa);
var spans: std.ArrayList(Span) = .empty;
defer spans.deinit(gpa);
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
// A record whose owner can never be matched — a high byte, or the
// root — is a configuration error, not a record that never answers.
const owner = name.normalizeText(row.name, &buf) catch return error.BadRecordName;
const value = try parseValue(row.rtype, row.value);
try spans.append(gpa, .{
.offset = owners.items.len,
.len = owner.len,
.value = value,
.ttl = row.ttl,
});
try owners.appendSlice(gpa, owner);
}
const owner_bytes = try owners.toOwnedSlice(gpa);
errdefer gpa.free(owner_bytes);
const items = try gpa.alloc(Record, spans.items.len);
for (items, spans.items) |*item, span| item.* = .{
.owner = owner_bytes[span.offset..][0..span.len],
.value = span.value,
.ttl = span.ttl,
};
std.mem.sort(Record, items, {}, lessThan);
return .{ .items = items, .owners = owner_bytes };
}
pub fn deinit(self: *Records, gpa: Allocator) void {
gpa.free(self.items);
gpa.free(self.owners);
self.* = .empty;
}
/// All records for `domain` whose type matches `qtype`. `domain` must be
/// normalized (lowercase, no trailing dot). An empty slice means the name
/// has no local record of that type. Allocation-free.
///
/// A CNAME answers every qtype and excludes every other type at the same
/// name (RFC 1034 §3.6.2), so a name carrying one answers with the CNAME
/// alone whatever else the row set holds.
pub fn lookup(self: *const Records, domain: []const u8, qtype: types.Type) []const Record {
const at_name = self.ownerRange(domain);
if (at_name.len == 0) return at_name;
const cnames = rankRun(at_name, rank_cname);
if (cnames.len != 0) return cnames;
return switch (qtype) {
.a => rankRun(at_name, rank_a),
.aaaa => rankRun(at_name, rank_aaaa),
.any => at_name,
else => at_name[0..0],
};
}
/// True when the name has any local record of any type. The handler needs
/// this to answer NODATA instead of forwarding a name nxdns owns.
pub fn hasName(self: *const Records, domain: []const u8) bool {
return self.ownerRange(domain).len != 0;
}
fn ownerRange(self: *const Records, domain: []const u8) []const Record {
var low: usize = 0;
var high: usize = self.items.len;
while (low < high) {
const mid = low + (high - low) / 2;
if (std.mem.order(u8, self.items[mid].owner, domain) == .lt) {
low = mid + 1;
} else {
high = mid;
}
}
var end = low;
while (end < self.items.len and std.mem.eql(u8, self.items[end].owner, domain)) end += 1;
return self.items[low..end];
}
};
/// Writes `records` as answers into a builder the caller has already
/// initialized with the request header and question. Mechanism only.
pub fn writeAnswers(
b: *packet.ResponseBuilder,
owner: name.Name,
records: []const Record,
) packet.ResponseBuilder.Error!void {
for (records) |rec| {
switch (rec.value) {
.a => |bytes| try b.addAnswer(owner, .a, .in, rec.ttl, &bytes),
.aaaa => |bytes| try b.addAnswer(owner, .aaaa, .in, rec.ttl, &bytes),
.cname => |target| try b.addAnswer(owner, .cname, .in, rec.ttl, target.wire()),
}
}
}
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
/// `Record.owner` slices are cut only after the owner block stops growing, so
/// the build pass records offsets instead of pointers.
const Span = struct {
offset: usize,
len: usize,
value: Value,
ttl: u32,
};
const rank_a: u2 = 0;
const rank_aaaa: u2 = 1;
const rank_cname: u2 = 2;
fn rank(value: Value) u2 {
return switch (value) {
.a => rank_a,
.aaaa => rank_aaaa,
.cname => rank_cname,
};
}
fn lessThan(_: void, a: Record, b: Record) bool {
return switch (std.mem.order(u8, a.owner, b.owner)) {
.lt => true,
.gt => false,
.eq => rank(a.value) < rank(b.value),
};
}
/// The run of one rank inside a single name's records, which the (owner, rtype)
/// sort makes contiguous.
fn rankRun(records: []const Record, wanted: u2) []const Record {
var start: usize = 0;
while (start < records.len and rank(records[start].value) < wanted) start += 1;
var end = start;
while (end < records.len and rank(records[end].value) == wanted) end += 1;
return records[start..end];
}
fn parseValue(rtype: model.RecordType, text: []const u8) error{BadRecordValue}!Value {
switch (rtype) {
.a => {
const addr = address.NetAddress.parse(text) catch return error.BadRecordValue;
return switch (addr) {
.ip4 => |bytes| Value{ .a = bytes },
.ip6 => return error.BadRecordValue,
};
},
.aaaa => {
const addr = address.NetAddress.parse(text) catch return error.BadRecordValue;
return switch (addr) {
.ip4 => return error.BadRecordValue,
.ip6 => |bytes| Value{ .aaaa = bytes },
};
},
.cname => {
var buf: [types.max_name_len]u8 = undefined;
const target = name.normalizeText(text, &buf) catch return error.BadRecordValue;
return .{ .cname = name.fromText(target) catch return error.BadRecordValue };
},
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const sample_rows = [_]model.LocalRecord{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5", .ttl = 120 },
.{ .name = "nas.lan", .rtype = .aaaa, .value = "fd00::1", .ttl = 240 },
.{ .name = "printer.lan", .rtype = .a, .value = "192.168.1.6", .ttl = 60 },
};
test "lookup returns the records of the queried type" {
var records = try Records.build(testing.allocator, &sample_rows);
defer records.deinit(testing.allocator);
const a = records.lookup("nas.lan", .a);
try testing.expectEqual(@as(usize, 1), a.len);
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 5 }, &a[0].value.a);
try testing.expectEqual(@as(u32, 120), a[0].ttl);
const aaaa = records.lookup("nas.lan", .aaaa);
try testing.expectEqual(@as(usize, 1), aaaa.len);
try testing.expectEqual(@as(u32, 240), aaaa[0].ttl);
try testing.expectEqual(@as(usize, 0), records.lookup("nas.lan", .mx).len);
try testing.expectEqual(@as(usize, 0), records.lookup("other.lan", .a).len);
}
test "lookup returns the CNAME for every qtype" {
const rows = [_]model.LocalRecord{
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan", .ttl = 300 },
};
var records = try Records.build(testing.allocator, &rows);
defer records.deinit(testing.allocator);
for ([_]types.Type{ .a, .aaaa, .mx, .https, .any }) |qtype| {
const found = records.lookup("www.lan", qtype);
try testing.expectEqual(@as(usize, 1), found.len);
try testing.expectEqualSlices(
u8,
(try name.fromText("nas.lan")).wire(),
found[0].value.cname.wire(),
);
}
}
test "hasName covers every type at the name" {
var records = try Records.build(testing.allocator, &sample_rows);
defer records.deinit(testing.allocator);
try testing.expect(records.hasName("nas.lan"));
try testing.expect(records.hasName("printer.lan"));
try testing.expect(!records.hasName("lan"));
try testing.expect(!records.hasName("nas.lan.evil.net"));
}
test "two A records for one name come back in a stable order" {
const rows = [_]model.LocalRecord{
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" },
.{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.6" },
};
var first = try Records.build(testing.allocator, &rows);
defer first.deinit(testing.allocator);
var second = try Records.build(testing.allocator, &rows);
defer second.deinit(testing.allocator);
const a = first.lookup("nas.lan", .a);
const b = second.lookup("nas.lan", .a);
try testing.expectEqual(@as(usize, 2), a.len);
try testing.expectEqual(@as(usize, 2), b.len);
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 5 }, &a[0].value.a);
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 6 }, &a[1].value.a);
for (a, b) |lhs, rhs| try testing.expectEqualSlices(u8, &lhs.value.a, &rhs.value.a);
}
test "uppercase and trailing-dot owners normalize to one key" {
const rows = [_]model.LocalRecord{
.{ .name = "NAS.Lan.", .rtype = .a, .value = "192.168.1.5" },
};
var records = try Records.build(testing.allocator, &rows);
defer records.deinit(testing.allocator);
try testing.expectEqualStrings("nas.lan", records.items[0].owner);
try testing.expectEqual(@as(usize, 1), records.lookup("nas.lan", .a).len);
}
test "a bad A value is an error" {
const rows = [_]model.LocalRecord{
.{ .name = "nas.lan", .rtype = .a, .value = "::1" },
};
try testing.expectError(error.BadRecordValue, Records.build(testing.allocator, &rows));
}
test "a bad AAAA value is an error" {
const rows = [_]model.LocalRecord{
.{ .name = "nas.lan", .rtype = .aaaa, .value = "192.168.1.5" },
};
try testing.expectError(error.BadRecordValue, Records.build(testing.allocator, &rows));
}
test "a bad CNAME target is an error" {
const rows = [_]model.LocalRecord{
.{ .name = "www.lan", .rtype = .cname, .value = "nas..lan" },
};
try testing.expectError(error.BadRecordValue, Records.build(testing.allocator, &rows));
}
test "an unparseable owner is an error" {
const rows = [_]model.LocalRecord{
.{ .name = "nas..lan", .rtype = .a, .value = "192.168.1.5" },
};
try testing.expectError(error.BadRecordName, Records.build(testing.allocator, &rows));
const root = [_]model.LocalRecord{
.{ .name = ".", .rtype = .a, .value = "192.168.1.5" },
};
try testing.expectError(error.BadRecordName, Records.build(testing.allocator, &root));
}
test "too many rows is an error" {
const rows = try testing.allocator.alloc(model.LocalRecord, max_records + 1);
defer testing.allocator.free(rows);
for (rows) |*row| row.* = .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.5" };
try testing.expectError(error.TooManyRecords, Records.build(testing.allocator, rows));
}
test "an empty row set builds the empty table" {
var records = try Records.build(testing.allocator, &[_]model.LocalRecord{});
defer records.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), records.items.len);
try testing.expect(!records.hasName("nas.lan"));
try testing.expectEqual(@as(usize, 0), records.lookup("nas.lan", .a).len);
}
fn requestHeader() header.Header {
return .{
.id = 0x4242,
.flags = .{
.rcode = .no_error,
.z = 0,
.ra = false,
.rd = true,
.tc = false,
.aa = false,
.opcode = .query,
.qr = false,
},
.qdcount = 1,
.ancount = 0,
.nscount = 0,
.arcount = 0,
};
}
test "writeAnswers emits records the parser reads back" {
var records = try Records.build(testing.allocator, &sample_rows);
defer records.deinit(testing.allocator);
const owner = try name.fromText("nas.lan");
const q: question.Question = .{ .name = owner, .qtype = .any, .qclass = .in };
var buf: [512]u8 = undefined;
var builder = try packet.ResponseBuilder.init(&buf, requestHeader(), q);
try writeAnswers(&builder, owner, records.lookup("nas.lan", .any));
const message = builder.finish();
const parsed = try packet.parse(message);
try testing.expectEqual(@as(u16, 2), parsed.header.ancount);
var it = packet.answers(parsed);
const first = (try it.next()).?;
try testing.expectEqual(types.Type.a, first.rtype);
try testing.expectEqual(@as(u16, @intFromEnum(types.Class.in)), first.class);
try testing.expectEqual(@as(u32, 120), first.ttl);
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 5 }, first.rdata.slice(parsed.bytes));
const second = (try it.next()).?;
try testing.expectEqual(types.Type.aaaa, second.rtype);
try testing.expectEqual(@as(u32, 240), second.ttl);
try testing.expectEqual(@as(usize, 16), second.rdata.len);
try testing.expect((try it.next()) == null);
}
test "writeAnswers emits a CNAME in wire form" {
const rows = [_]model.LocalRecord{
.{ .name = "www.lan", .rtype = .cname, .value = "nas.lan", .ttl = 300 },
};
var records = try Records.build(testing.allocator, &rows);
defer records.deinit(testing.allocator);
const owner = try name.fromText("www.lan");
const q: question.Question = .{ .name = owner, .qtype = .a, .qclass = .in };
var buf: [512]u8 = undefined;
var builder = try packet.ResponseBuilder.init(&buf, requestHeader(), q);
try writeAnswers(&builder, owner, records.lookup("www.lan", .a));
const message = builder.finish();
const parsed = try packet.parse(message);
try testing.expectEqual(@as(u16, 1), parsed.header.ancount);
var it = packet.answers(parsed);
const answer = (try it.next()).?;
try testing.expectEqual(types.Type.cname, answer.rtype);
try testing.expectEqual(@as(u32, 300), answer.ttl);
try testing.expectEqualSlices(
u8,
(try name.fromText("nas.lan")).wire(),
answer.rdata.slice(parsed.bytes),
);
}
fn buildUnderFailure(gpa: Allocator) !void {
var records = try Records.build(gpa, &sample_rows);
defer records.deinit(gpa);
try testing.expectEqual(@as(usize, 3), records.items.len);
}
test "build leaks nothing under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}