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