milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -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),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user