milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -0,0 +1,459 @@
|
||||
//! 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| {
|
||||
const owner = normalizeName(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];
|
||||
}
|
||||
|
||||
const NameError = error{BadName};
|
||||
|
||||
/// Lowercases over ASCII, strips one trailing dot, and checks the result is a
|
||||
/// name `dns.name.fromText` accepts. A byte ≥ 0x80 is rejected: query names
|
||||
/// arrive ASCII-lowercased, so a high byte here could never be matched and a
|
||||
/// record that can never answer is a configuration error worth reporting. The
|
||||
/// root name is rejected for the same reason — nothing can match it.
|
||||
fn normalizeName(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
|
||||
var rest = text;
|
||||
if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
|
||||
if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;
|
||||
|
||||
for (rest, 0..) |byte, i| {
|
||||
if (byte >= 0x80) return error.BadName;
|
||||
buf[i] = std.ascii.toLower(byte);
|
||||
}
|
||||
const normalized = buf[0..rest.len];
|
||||
_ = name.fromText(normalized) catch return error.BadName;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
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 = normalizeName(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, .{});
|
||||
}
|
||||
Reference in New Issue
Block a user