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
+279
View File
@@ -0,0 +1,279 @@
//! Blocked-response synthesis (PLAN §6.2). Pure: no allocation, no `std.Io`,
//! no clock. The caller supplies the buffer and gets back a prefix of it.
//!
//! No SOA is placed in the authority section. nxdns is not authoritative for a
//! blocked name, and a synthesized SOA would hand resolvers a negative-caching
//! TTL nxdns cannot honour: the operator can unblock the name at any moment,
//! and a client that cached the negative answer for the SOA's MINIMUM would
//! keep failing long after the block was lifted.
const std = @import("std");
const types = @import("../dns/types.zig");
const header = @import("../dns/header.zig");
const question = @import("../dns/question.zig");
const edns = @import("../dns/edns.zig");
const packet = @import("../dns/packet.zig");
const model = @import("../config/model.zig");
pub const Options = struct {
mode: model.BlockResponse,
ttl: u32,
};
pub const Error = packet.ResponseBuilder.Error;
const zero_a = [_]u8{0} ** 4;
const zero_aaaa = [_]u8{0} ** 16;
/// Writes a blocked reply for `q` into `buf` and returns a prefix of it.
///
/// `.zero`: A → 0.0.0.0, AAAA → ::, every other qtype → NOERROR with no answer
/// (NODATA). No address exists to synthesize for a qtype that carries none,
/// and answering NXDOMAIN for, say, an MX query would tell the client the
/// name does not exist while an A query for the same name says it does.
/// `.nxdomain`: RCODE = NXDOMAIN, no answer, for every qtype.
///
/// Only class `IN` is answered with addresses; any other class takes the
/// NODATA path, because `0.0.0.0` is an IN-class address and means nothing in
/// CH or HS.
///
/// `request_opt` echoes EDNS exactly as `handler.zig` does: a query that
/// carried an OPT record gets a reply carrying one with the same payload size
/// and the DO bit passed through.
pub fn writeBlocked(
buf: []u8,
request: header.Header,
q: question.Question,
request_opt: ?edns.OptRecord,
do_bit: bool,
options: Options,
) Error![]u8 {
var b = try packet.ResponseBuilder.init(buf, request, q);
switch (options.mode) {
.nxdomain => b.setRcode(.nx_domain),
.zero => if (q.qclass == .in) switch (q.qtype) {
.a => try b.addAnswer(q.name, .a, .in, options.ttl, &zero_a),
.aaaa => try b.addAnswer(q.name, .aaaa, .in, options.ttl, &zero_aaaa),
else => {},
},
}
if (request_opt) |opt| try b.addOptEcho(opt, do_bit);
return b.finish();
}
const testing = std.testing;
const name = @import("../dns/name.zig");
const record = @import("../dns/record.zig");
/// A query for example.com A with an EDNS(0) OPT record advertising 4096
/// bytes: id 0x1234, RD set, one question, one additional.
const query_bytes =
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
"\x07example\x03com\x00\x00\x01\x00\x01" ++
"\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00";
const blocked_name = "ads.example.com";
const ttl: u32 = 5;
fn requestHeader() header.Header {
return (packet.parse(query_bytes) catch unreachable).header;
}
fn requestOpt() edns.OptRecord {
const p = packet.parse(query_bytes) catch unreachable;
return edns.parseOpt(query_bytes, packet.findOptRecord(p).?) catch unreachable;
}
fn blockedQuestion(qtype: types.Type, qclass: types.Class) !question.Question {
return .{ .name = try name.fromText(blocked_name), .qtype = qtype, .qclass = qclass };
}
/// Builds a blocked reply and re-parses it, asserting the parts every case
/// shares: the echoed id, the QR and RA flags, the echoed question, an empty
/// authority section, and an additional section that holds the OPT record only
/// when the query carried one.
fn expectBlocked(
buf: []u8,
mode: model.BlockResponse,
qtype: types.Type,
qclass: types.Class,
with_opt: bool,
) !packet.Packet {
const q = try blockedQuestion(qtype, qclass);
const bytes = try writeBlocked(
buf,
requestHeader(),
q,
if (with_opt) requestOpt() else null,
false,
.{ .mode = mode, .ttl = ttl },
);
const p = try packet.parse(bytes);
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
try testing.expect(p.header.flags.qr);
try testing.expect(p.header.flags.ra);
try testing.expect(p.header.flags.rd);
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
try testing.expectEqual(@as(u16, 0), p.header.nscount);
try testing.expectEqual(@as(u16, if (with_opt) 1 else 0), p.header.arcount);
const echoed = packet.firstQuestion(p).?;
try testing.expectEqualSlices(u8, q.name.wire(), echoed.name.wire());
try testing.expectEqual(qtype, echoed.qtype);
try testing.expectEqual(qclass, echoed.qclass);
if (with_opt) {
const opt = try edns.parseOpt(bytes, packet.findOptRecord(p).?);
try testing.expectEqual(@as(u16, 4096), opt.udp_payload_size);
try testing.expectEqual(false, opt.do_bit);
} else {
try testing.expect(packet.findOptRecord(p) == null);
}
return p;
}
fn expectNodata(mode: model.BlockResponse, qtype: types.Type, qclass: types.Class) !void {
for ([_]bool{ false, true }) |with_opt| {
var buf: [512]u8 = undefined;
const p = try expectBlocked(&buf, mode, qtype, qclass, with_opt);
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
try testing.expectEqual(@as(u16, 0), p.header.ancount);
}
}
fn expectNxdomain(qtype: types.Type) !void {
for ([_]bool{ false, true }) |with_opt| {
var buf: [512]u8 = undefined;
const p = try expectBlocked(&buf, .nxdomain, qtype, .in, with_opt);
try testing.expectEqual(types.Rcode.nx_domain, p.header.flags.rcode);
try testing.expectEqual(@as(u16, 0), p.header.ancount);
}
}
test "zero mode answers A with 0.0.0.0" {
for ([_]bool{ false, true }) |with_opt| {
var buf: [512]u8 = undefined;
const p = try expectBlocked(&buf, .zero, .a, .in, with_opt);
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
try testing.expectEqual(@as(u16, 1), p.header.ancount);
var it = packet.answers(p);
const answer = (try it.next()).?;
try testing.expectEqual(types.Type.a, answer.rtype);
try testing.expectEqual(@as(u16, @intFromEnum(types.Class.in)), answer.class);
try testing.expectEqual(ttl, answer.ttl);
try testing.expectEqualSlices(
u8,
(try name.fromText(blocked_name)).wire(),
answer.name.wire(),
);
try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer));
try testing.expect((try it.next()) == null);
}
}
test "zero mode answers AAAA with ::" {
for ([_]bool{ false, true }) |with_opt| {
var buf: [512]u8 = undefined;
const p = try expectBlocked(&buf, .zero, .aaaa, .in, with_opt);
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
try testing.expectEqual(@as(u16, 1), p.header.ancount);
var it = packet.answers(p);
const answer = (try it.next()).?;
try testing.expectEqual(types.Type.aaaa, answer.rtype);
try testing.expectEqual(ttl, answer.ttl);
try testing.expectEqual(zero_aaaa, try record.rdataAaaa(p.bytes, answer));
try testing.expect((try it.next()) == null);
}
}
test "zero mode answers MX with NODATA" {
try expectNodata(.zero, .mx, .in);
}
test "zero mode answers HTTPS with NODATA" {
try expectNodata(.zero, .https, .in);
}
test "zero mode answers a non-IN class with NODATA" {
try expectNodata(.zero, .a, .ch);
try expectNodata(.zero, .aaaa, .any);
}
test "nxdomain mode answers A with NXDOMAIN" {
try expectNxdomain(.a);
}
test "nxdomain mode answers AAAA with NXDOMAIN" {
try expectNxdomain(.aaaa);
}
test "nxdomain mode answers MX with NXDOMAIN" {
try expectNxdomain(.mx);
}
test "nxdomain mode answers HTTPS with NXDOMAIN" {
try expectNxdomain(.https);
}
test "the DO bit passes through" {
for ([_]bool{ false, true }) |do_bit| {
var buf: [512]u8 = undefined;
const bytes = try writeBlocked(
&buf,
requestHeader(),
try blockedQuestion(.a, .in),
requestOpt(),
do_bit,
.{ .mode = .zero, .ttl = ttl },
);
const p = try packet.parse(bytes);
const opt = try edns.parseOpt(bytes, packet.findOptRecord(p).?);
try testing.expectEqual(do_bit, opt.do_bit);
}
}
test "a ttl of zero survives the round trip" {
var buf: [512]u8 = undefined;
const bytes = try writeBlocked(
&buf,
requestHeader(),
try blockedQuestion(.a, .in),
null,
false,
.{ .mode = .zero, .ttl = 0 },
);
const p = try packet.parse(bytes);
var it = packet.answers(p);
try testing.expectEqual(@as(u32, 0), (try it.next()).?.ttl);
}
test "a buffer too small reports a write failure instead of truncating" {
const q = try blockedQuestion(.a, .in);
const options: Options = .{ .mode = .zero, .ttl = ttl };
// Room for the header and the question, but not for the answer record.
var no_room_for_answer: [40]u8 = undefined;
try testing.expectError(
error.WriteFailed,
writeBlocked(&no_room_for_answer, requestHeader(), q, null, false, options),
);
// Room for the header and the question and the answer, but not the OPT.
var no_room_for_opt: [72]u8 = undefined;
try testing.expectError(
error.WriteFailed,
writeBlocked(&no_room_for_opt, requestHeader(), q, requestOpt(), false, options),
);
// Not even room for the header.
var tiny: [8]u8 = undefined;
try testing.expectError(
error.WriteFailed,
writeBlocked(&tiny, requestHeader(), q, null, false, options),
);
}