milestone 5: blocklist filtering, local records and conditional forwarding

This commit is contained in:
2026-08-01 16:43:55 +02:00
parent 3baf5d6581
commit 59d94df722
29 changed files with 10257 additions and 81 deletions
+455
View File
@@ -0,0 +1,455 @@
//! Plain UDP/TCP resolver client for conditional forward zones (PLAN §6.5).
//!
//! A forward zone points at a box on the LAN — a router, a NAS, an internal
//! resolver — which speaks port 53 and nothing else. `transport.Endpoint` knows
//! only `https://` and `tls://` by design, so the configuration for this client
//! comes from `validate.Resolver` instead. The interface it implements is the
//! same `transport.Client` every upstream implements, so the Phase 7 handler
//! treats a forward zone exactly like any other exchange.
//!
//! No health tracking and no backoff live here. `upstream/health.zig` and
//! `upstream/pool.zig` model the upstream *pool*, where failing over to a second
//! endpoint is the whole point. A forward zone has exactly one designated
//! resolver and no failover partner, so a backoff would only add latency to a
//! failure the caller already sees. Their absence is a decision, not an
//! oversight.
//!
//! One `ForwardClient` is used by one task at a time: `stats` is a plain struct
//! and `frame_buf` is not shared.
const std = @import("std");
const net = std.Io.net;
const transport = @import("../upstream/transport.zig");
const validate = @import("../config/validate.zig");
const dns_header = @import("../dns/header.zig");
const log = std.log.scoped(.forward_client);
/// RFC 1035 §4.2.2 length prefix for DNS over TCP.
/// The TCP path splits `frame_buf` between the socket writer and the socket
/// reader. Neither half has to hold a whole message — the reply is read
/// straight into the caller's `response_buf` — so this is a floor that keeps
/// each half large enough to frame a query in one write, not a capacity.
pub const min_frame_buf: usize = 1024;
pub const ForwardClient = struct {
resolver: validate.Resolver,
/// Caller-owned scratch for the TCP length-prefixed path.
frame_buf: []u8,
/// On the `.awake` clock at the caller's choosing, so a suspended host does
/// not burn the budget while it sleeps.
read_timeout: std.Io.Clock.Duration,
stats: Stats = .{},
pub const Stats = struct {
queries: u64 = 0,
/// TC=1 over UDP, so the exchange was retried over TCP.
udp_truncated: u64 = 0,
/// A datagram arrived from an address other than the resolver's. It was
/// discarded and the receive retried within the remaining budget, which
/// is invisible to the caller and would otherwise be an unrecorded
/// failure mode.
foreign_datagrams: u64 = 0,
/// Exchanges that returned a peer fault or a local resource error.
/// A cancellation is neither, so it is not counted.
failures: u64 = 0,
};
/// An undersized `frame_buf` is a wiring bug in this process, not a runtime
/// condition, so it is an assertion.
pub fn init(
resolver: validate.Resolver,
frame_buf: []u8,
read_timeout: std.Io.Clock.Duration,
) ForwardClient {
std.debug.assert(frame_buf.len >= min_frame_buf);
return .{
.resolver = resolver,
.frame_buf = frame_buf,
.read_timeout = read_timeout,
};
}
pub fn client(self: *ForwardClient) transport.Client {
return .{ .ptr = self, .exchangeFn = exchangeFn };
}
fn exchangeFn(
ptr: *anyopaque,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
const self: *ForwardClient = @ptrCast(@alignCast(ptr));
return self.exchange(io, query, response_buf);
}
/// `.udp` resolvers send one datagram and fall back to TCP when the answer
/// comes back with TC=1. `.tcp` resolvers skip straight to the TCP path.
pub fn exchange(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
// The TCP length prefix is 16-bit, so a longer query cannot be framed.
if (query.len > transport.max_message_len) return error.BufferTooSmall;
if (response_buf.len == 0) return error.BufferTooSmall;
self.stats.queries += 1;
return self.route(io, query, response_buf) catch |err| {
switch (transport.group(err)) {
.peer_fault, .local_resource => self.stats.failures += 1,
.cancellation => {},
}
return err;
};
}
fn route(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
if (self.resolver.scheme == .udp) {
if (try self.exchangeUdp(io, query, response_buf)) |reply| return reply;
}
return self.exchangeTcp(io, query, response_buf);
}
/// `null` means the resolver set TC=1 and the caller must retry over TCP.
///
/// The socket is bound to the wildcard address of the resolver's family on
/// an ephemeral port, so the kernel picks the source port for every
/// exchange rather than this process reusing one.
fn exchangeUdp(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError!?[]u8 {
const dest = self.destination();
const local = wildcardFor(dest);
const socket = local.bind(io, .{ .mode = .dgram }) catch |err| {
log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed);
};
defer closeSocket(io, &socket);
socket.send(io, &dest, query) catch |err| {
log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)});
return mapPhase(err, error.SendFailed);
};
// A deadline, not a duration: a discarded foreign datagram restarts the
// receive, and a duration would hand each retry the full budget again.
const deadline = (std.Io.Timeout{ .duration = self.read_timeout }).toDeadline(io);
while (true) {
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
error.Timeout => return error.Timeout,
error.ConcurrencyUnavailable => return error.SystemResources,
else => return mapPhase(err, error.ReceiveFailed),
};
// Off-path spoofing is the reason the source address is checked at
// all: the first datagram to arrive is not necessarily the
// resolver's.
if (!msg.from.eql(&dest)) {
self.stats.foreign_datagrams += 1;
continue;
}
// The kernel threw the tail away because `response_buf` was too
// small, so the message cannot be parsed and TC=1 cannot be read
// out of it.
if (msg.flags.trunc) return error.ResponseTooLarge;
const reply = response_buf[0..msg.data.len];
try transport.validateResponse(query, reply);
// Read after validation: acting on the TC bit of a message that has
// not been matched to the query would let anything that reaches the
// socket force a TCP connection.
const parsed = dns_header.parse(reply) catch return error.BadResponse;
if (parsed.flags.tc) {
self.stats.udp_truncated += 1;
return null;
}
return reply;
}
}
/// No stream read or write in 0.16.0 takes a timeout, so the budget is a
/// second task and the loser is canceled. `ConnectOptions.timeout` is never
/// set: the Threaded backend panics on it (Threaded.zig:12076).
fn exchangeTcp(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
var outcomes: [2]Outcome = undefined;
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
defer race.cancelDiscard();
race.concurrent(.exchange, tcpOnce, .{ self, io, query, response_buf }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
race.concurrent(.expiry, expire, .{ io, self.read_timeout }) catch |err| switch (err) {
error.ConcurrencyUnavailable => return error.SystemResources,
};
switch (try race.await()) {
.exchange => |result| return result,
.expiry => |result| {
// A canceled sleep means this whole task is being torn down,
// not that the resolver is slow.
try result;
return error.Timeout;
},
}
}
fn tcpOnce(
self: *ForwardClient,
io: std.Io,
query: []const u8,
response_buf: []u8,
) transport.ExchangeError![]u8 {
const dest = self.destination();
const stream = dest.connect(io, .{ .mode = .stream }) catch |err| {
log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)});
return mapPhase(err, error.ConnectFailed);
};
defer closeStream(io, &stream);
const split = self.frame_buf.len / 2;
var stream_writer = stream.writer(io, self.frame_buf[0..split]);
var stream_reader = stream.reader(io, self.frame_buf[split..]);
const w = &stream_writer.interface;
const prefix = transport.framePrefix(@intCast(query.len));
w.writeAll(&prefix) catch |err| return sendFailure(&stream_writer, err);
w.writeAll(query) catch |err| return sendFailure(&stream_writer, err);
w.flush() catch |err| return sendFailure(&stream_writer, err);
const r = &stream_reader.interface;
var prefix_bytes: [transport.prefix_len]u8 = undefined;
r.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&stream_reader, err);
// RFC 1035 §4.2.2 gives no meaning to a zero-length message.
const len = transport.parsePrefix(prefix_bytes);
if (len == 0) return error.BadResponse;
if (len > response_buf.len) return error.ResponseTooLarge;
r.readSliceAll(response_buf[0..len]) catch |err| return receiveFailure(&stream_reader, err);
try transport.validateResponse(query, response_buf[0..len]);
return response_buf[0..len];
}
fn destination(self: *const ForwardClient) net.IpAddress {
return self.resolver.addr.toIp(self.resolver.port);
}
};
const Outcome = union(enum) {
exchange: transport.ExchangeError![]u8,
expiry: std.Io.Cancelable!void,
};
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
return duration.sleep(io);
}
/// The local address a datagram to `dest` is sent from: same family, port
/// chosen by the kernel.
fn wildcardFor(dest: net.IpAddress) net.IpAddress {
return switch (dest) {
.ip4 => .{ .ip4 = .unspecified(0) },
.ip6 => .{ .ip6 = .unspecified(0) },
};
}
/// The TCP budget cancels the exchange task. The next cancelable `Io` call in
/// the `defer` chain would then return `error.Canceled` and skip the close,
/// leaking the descriptor, so both closes run with cancellation blocked.
fn closeStream(io: std.Io, stream: *const net.Stream) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
stream.close(io);
}
fn closeSocket(io: std.Io, socket: *const net.Socket) void {
const prev = io.swapCancelProtection(.blocked);
defer _ = io.swapCancelProtection(prev);
socket.close(io);
}
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
return transport.mapLocal(err) orelse phase;
}
/// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the
/// cause. Unwrapping it is what keeps `error.Canceled` and the local resource
/// errors out of the peer fault group.
fn sendFailure(stream_writer: *const net.Stream.Writer, err: anyerror) transport.ExchangeError {
const cause: anyerror = if (err == error.WriteFailed and stream_writer.err != null)
stream_writer.err.?
else
err;
return mapPhase(cause, error.SendFailed);
}
fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError {
const cause: anyerror = if (err == error.ReadFailed and stream_reader.err != null)
stream_reader.err.?
else
err;
return mapPhase(cause, error.ReceiveFailed);
}
const testing = std.testing;
fn testBuf() [min_frame_buf]u8 {
return undefined;
}
test "ForwardClient satisfies the Client interface" {
var buf = testBuf();
var fc: ForwardClient = .init(
try validate.parseResolver("udp://192.168.1.1:53"),
&buf,
.{ .raw = .fromMilliseconds(500), .clock = .awake },
);
const iface: transport.Client = fc.client();
try testing.expectEqual(@as(*anyopaque, @ptrCast(&fc)), iface.ptr);
try testing.expectEqual(validate.ResolverScheme.udp, fc.resolver.scheme);
try testing.expectEqual(@as(u16, 53), fc.resolver.port);
}
test "the stats struct starts at zero" {
const stats: ForwardClient.Stats = .{};
try testing.expectEqual(@as(u64, 0), stats.queries);
try testing.expectEqual(@as(u64, 0), stats.udp_truncated);
try testing.expectEqual(@as(u64, 0), stats.foreign_datagrams);
try testing.expectEqual(@as(u64, 0), stats.failures);
}
test "init keeps a tcp resolver on the tcp path" {
var buf = testBuf();
const fc: ForwardClient = .init(
try validate.parseResolver("tcp://[fd00::1]:5353"),
&buf,
.{ .raw = .fromSeconds(2), .clock = .awake },
);
try testing.expectEqual(validate.ResolverScheme.tcp, fc.resolver.scheme);
try testing.expectEqual(@as(u16, 5353), fc.resolver.port);
const dest = fc.destination();
try testing.expectEqual(net.IpAddress.Family.ip6, std.meta.activeTag(dest));
try testing.expectEqual(@as(u16, 5353), dest.getPort());
}
test "the destination carries the resolver's address and port" {
var buf = testBuf();
const fc: ForwardClient = .init(
try validate.parseResolver("udp://192.168.1.1:5300"),
&buf,
.{ .raw = .fromSeconds(1), .clock = .awake },
);
const dest = fc.destination();
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 1 }, &dest.ip4.bytes);
try testing.expectEqual(@as(u16, 5300), dest.ip4.port);
}
test "only the resolver's own address and port count as its datagram" {
const dest: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 } };
const same: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 } };
try testing.expect(same.eql(&dest));
// A different host, the right host on a different port, and the right
// address in the wrong family are each a datagram this client discards.
const other_host: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 2 }, .port = 53 } };
try testing.expect(!other_host.eql(&dest));
const other_port: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 5353 } };
try testing.expect(!other_port.eql(&dest));
const mapped: net.IpAddress = .{ .ip6 = .fromIp4(.{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 }) };
try testing.expect(!mapped.eql(&dest));
}
test "the local socket matches the resolver's family and takes an ephemeral port" {
const v4 = wildcardFor(.{ .ip4 = .{ .bytes = .{ 1, 1, 1, 1 }, .port = 53 } });
try testing.expectEqual(net.IpAddress.Family.ip4, std.meta.activeTag(v4));
try testing.expectEqual(@as(u16, 0), v4.getPort());
try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, &v4.ip4.bytes);
const v6 = wildcardFor(.{ .ip6 = .unspecified(53) });
try testing.expectEqual(net.IpAddress.Family.ip6, std.meta.activeTag(v6));
try testing.expectEqual(@as(u16, 0), v6.getPort());
}
test "mapPhase keeps local resource and cancellation errors out of the peer fault group" {
const local = [_]anyerror{
error.OutOfMemory,
error.SystemResources,
error.ProcessFdQuotaExceeded,
error.SystemFdQuotaExceeded,
error.Unexpected,
};
for (local) |err| {
try testing.expectEqual(
transport.Group.local_resource,
transport.group(mapPhase(err, error.ReceiveFailed)),
);
}
try testing.expectEqual(
transport.ExchangeError.Canceled,
mapPhase(error.Canceled, error.ConnectFailed),
);
// A refused connection is the resolver's side, so it stays a peer fault.
try testing.expectEqual(
transport.ExchangeError.ConnectFailed,
mapPhase(error.ConnectionRefused, error.ConnectFailed),
);
}
test "a stashed stream error is preferred over the collapsed one" {
var stream_writer: net.Stream.Writer = undefined;
stream_writer.err = error.Canceled;
try testing.expectEqual(
transport.ExchangeError.Canceled,
sendFailure(&stream_writer, error.WriteFailed),
);
stream_writer.err = error.ConnectionResetByPeer;
try testing.expectEqual(
transport.ExchangeError.SendFailed,
sendFailure(&stream_writer, error.WriteFailed),
);
var stream_reader: net.Stream.Reader = undefined;
stream_reader.err = error.SystemResources;
try testing.expectEqual(
transport.ExchangeError.SystemResources,
receiveFailure(&stream_reader, error.ReadFailed),
);
// A peer that closes mid-frame never reaches `err`, so the collapsed error
// is what classifies it.
stream_reader.err = null;
try testing.expectEqual(
transport.ExchangeError.ReceiveFailed,
receiveFailure(&stream_reader, error.EndOfStream),
);
}
+265
View File
@@ -0,0 +1,265 @@
//! Conditional forward zones (PLAN §6.5): an immutable, longest-suffix-first
//! table built once from the `forward_zones` rows and read on the query path
//! without allocating. Pure: an allocator and plain values, no `std.Io`, no
//! clock.
//!
//! Resolver URLs are parsed by `config/validate.zig`'s `parseResolver`, whose
//! doc comment names this file as its importer. There is no second resolver
//! parser.
const std = @import("std");
const Allocator = std.mem.Allocator;
const model = @import("../config/model.zig");
const validate = @import("../config/validate.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");
pub const Zone = struct {
/// Normalized: lowercase, no trailing dot.
zone: []const u8,
resolver: validate.Resolver,
};
pub const Error = error{ OutOfMemory, BadZone, BadResolver, TooManyZones };
pub const max_zones: usize = 1_000;
pub const Zones = struct {
/// Sorted by descending label count then by name, so the first match found
/// by a forward scan is the longest one.
items: []const Zone,
/// One block holding every `Zone.zone`; freed as a unit.
names: []const u8,
pub const empty: Zones = .{ .items = &.{}, .names = &.{} };
/// `gpa` owns the result; `deinit` frees it. A row that fails to parse 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 send a zone's queries to the wrong resolver.
pub fn build(gpa: Allocator, rows: []const model.ForwardZone) Error!Zones {
if (rows.len == 0) return .empty;
if (rows.len > max_zones) return error.TooManyZones;
var names: std.ArrayList(u8) = .empty;
defer names.deinit(gpa);
var spans: std.ArrayList(Span) = .empty;
defer spans.deinit(gpa);
var buf: [types.max_name_len]u8 = undefined;
for (rows) |row| {
const zone = normalizeName(row.zone, &buf) catch return error.BadZone;
const resolver = validate.parseResolver(row.resolver) catch return error.BadResolver;
try spans.append(gpa, .{
.offset = names.items.len,
.len = zone.len,
.resolver = resolver,
});
try names.appendSlice(gpa, zone);
}
const name_bytes = try names.toOwnedSlice(gpa);
errdefer gpa.free(name_bytes);
const items = try gpa.alloc(Zone, spans.items.len);
for (items, spans.items) |*item, span| item.* = .{
.zone = name_bytes[span.offset..][0..span.len],
.resolver = span.resolver,
};
std.mem.sort(Zone, items, {}, lessThan);
return .{ .items = items, .names = name_bytes };
}
pub fn deinit(self: *Zones, gpa: Allocator) void {
gpa.free(self.items);
gpa.free(self.names);
self.* = .empty;
}
/// Longest-suffix match on label boundaries: `lan.home` matches
/// `nas.lan.home` and `lan.home`, and does not match `notlan.home`.
/// `domain` must be normalized (lowercase, no trailing dot).
/// Allocation-free.
pub fn match(self: *const Zones, domain: []const u8) ?*const Zone {
for (self.items) |*zone| {
if (suffixMatches(zone.zone, domain)) return zone;
}
return null;
}
};
// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------
/// `Zone.zone` slices are cut only after the name block stops growing, so the
/// build pass records offsets instead of pointers.
const Span = struct {
offset: usize,
len: usize,
resolver: validate.Resolver,
};
fn labelCount(zone: []const u8) usize {
return std.mem.count(u8, zone, ".") + 1;
}
fn lessThan(_: void, a: Zone, b: Zone) bool {
const a_labels = labelCount(a.zone);
const b_labels = labelCount(b.zone);
if (a_labels != b_labels) return a_labels > b_labels;
return std.mem.order(u8, a.zone, b.zone) == .lt;
}
fn suffixMatches(zone: []const u8, domain: []const u8) bool {
if (domain.len == zone.len) return std.mem.eql(u8, domain, zone);
if (domain.len < zone.len + 1) return false;
const start = domain.len - zone.len;
return domain[start - 1] == '.' and std.mem.eql(u8, domain[start..], zone);
}
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 because query
/// names arrive ASCII-lowercased, so a high byte could never match. The root
/// zone is rejected too: a zone that forwards everything would bypass the
/// upstream pool entirely, which is not what conditional forwarding means.
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;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const testing = std.testing;
const lan_resolver = "udp://192.168.1.1:53";
const home_resolver = "tcp://[fd00::1]:5353";
test "a zone matches itself and its subdomains on label boundaries" {
const rows = [_]model.ForwardZone{
.{ .zone = "lan.home", .resolver = lan_resolver },
};
var zones = try Zones.build(testing.allocator, &rows);
defer zones.deinit(testing.allocator);
for ([_][]const u8{ "lan.home", "nas.lan.home", "a.b.lan.home" }) |domain| {
const found = zones.match(domain) orelse return error.TestExpectedMatch;
try testing.expectEqualStrings("lan.home", found.zone);
}
for ([_][]const u8{ "notlan.home", "home", "lan.home.evil.net", "" }) |domain| {
try testing.expect(zones.match(domain) == null);
}
}
test "the longest configured zone wins" {
const rows = [_]model.ForwardZone{
.{ .zone = "home", .resolver = home_resolver },
.{ .zone = "lan.home", .resolver = lan_resolver },
};
var zones = try Zones.build(testing.allocator, &rows);
defer zones.deinit(testing.allocator);
const nas = zones.match("nas.lan.home") orelse return error.TestExpectedMatch;
try testing.expectEqualStrings("lan.home", nas.zone);
try testing.expectEqual(validate.ResolverScheme.udp, nas.resolver.scheme);
const printer = zones.match("printer.home") orelse return error.TestExpectedMatch;
try testing.expectEqualStrings("home", printer.zone);
try testing.expectEqual(validate.ResolverScheme.tcp, printer.resolver.scheme);
try testing.expectEqual(@as(u16, 5353), printer.resolver.port);
}
test "a reverse zone matches every name under it" {
const rows = [_]model.ForwardZone{
.{ .zone = "10.in-addr.arpa", .resolver = lan_resolver },
};
var zones = try Zones.build(testing.allocator, &rows);
defer zones.deinit(testing.allocator);
const found = zones.match("5.4.3.10.in-addr.arpa") orelse return error.TestExpectedMatch;
try testing.expectEqualStrings("10.in-addr.arpa", found.zone);
try testing.expect(zones.match("5.4.3.11.in-addr.arpa") == null);
}
test "uppercase and trailing-dot zones normalize to one key" {
const rows = [_]model.ForwardZone{
.{ .zone = "LAN.Home.", .resolver = lan_resolver },
};
var zones = try Zones.build(testing.allocator, &rows);
defer zones.deinit(testing.allocator);
try testing.expectEqualStrings("lan.home", zones.items[0].zone);
try testing.expect(zones.match("nas.lan.home") != null);
}
test "the resolver comes from parseResolver" {
const rows = [_]model.ForwardZone{
.{ .zone = "lan.home", .resolver = lan_resolver },
};
var zones = try Zones.build(testing.allocator, &rows);
defer zones.deinit(testing.allocator);
const expected = try validate.parseResolver(lan_resolver);
const found = zones.match("lan.home") orelse return error.TestExpectedMatch;
try testing.expectEqual(expected.scheme, found.resolver.scheme);
try testing.expectEqual(expected.port, found.resolver.port);
try testing.expect(expected.addr.eql(found.resolver.addr));
}
test "a bad resolver URL is an error" {
for ([_][]const u8{ "https://dns.example/dns-query", "udp://192.168.1.1", "udp://nas.lan:53" }) |url| {
const rows = [_]model.ForwardZone{.{ .zone = "lan.home", .resolver = url }};
try testing.expectError(error.BadResolver, Zones.build(testing.allocator, &rows));
}
}
test "a bad zone is an error" {
for ([_][]const u8{ "lan..home", ".", "" }) |zone| {
const rows = [_]model.ForwardZone{.{ .zone = zone, .resolver = lan_resolver }};
try testing.expectError(error.BadZone, Zones.build(testing.allocator, &rows));
}
}
test "too many rows is an error" {
const rows = try testing.allocator.alloc(model.ForwardZone, max_zones + 1);
defer testing.allocator.free(rows);
for (rows) |*row| row.* = .{ .zone = "lan.home", .resolver = lan_resolver };
try testing.expectError(error.TooManyZones, Zones.build(testing.allocator, rows));
}
test "an empty row set builds the empty table" {
var zones = try Zones.build(testing.allocator, &[_]model.ForwardZone{});
defer zones.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), zones.items.len);
try testing.expect(zones.match("lan.home") == null);
}
fn buildUnderFailure(gpa: Allocator) !void {
const rows = [_]model.ForwardZone{
.{ .zone = "home", .resolver = home_resolver },
.{ .zone = "lan.home", .resolver = lan_resolver },
};
var zones = try Zones.build(gpa, &rows);
defer zones.deinit(gpa);
try testing.expectEqualStrings("lan.home", zones.items[0].zone);
}
test "build leaks nothing under allocation failure" {
try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}
+459
View File
@@ -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, .{});
}