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