resolver transport: udp/tcp servers, doh/dot clients, pool failover with health
This commit is contained in:
@@ -0,0 +1,825 @@
|
||||
//! The serving handler: raw bytes from a listener in, bytes to send back out.
|
||||
//! It owns no socket, no clock and no allocator — the listener supplies the
|
||||
//! buffers and the upstream supplies the answer.
|
||||
//!
|
||||
//! `handle` returns no error union. Every failure is either a DNS response the
|
||||
//! client can act on or a counted drop, because a listener has nothing useful
|
||||
//! to do with an error value: it cannot retry and it must not die. That is the
|
||||
//! "every failure mode is visible" rule applied to the hot path — the counters
|
||||
//! are the failure surface.
|
||||
//!
|
||||
//! Phase 7 extends this file with filtering, cache, local records and query
|
||||
//! logging. None of that is here: this handler forwards every query.
|
||||
|
||||
const std = @import("std");
|
||||
const edns = @import("../dns/edns.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const question = @import("../dns/question.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
/// Which listener a query arrived on. Only the UDP size limit depends on it.
|
||||
pub const Transport = enum { udp, tcp };
|
||||
|
||||
/// RFC 1035 §4.2.1: 512 bytes is what a client accepts over UDP without EDNS,
|
||||
/// and RFC 6891 §6.2.3 says a smaller advertised size must not be honoured
|
||||
/// below that floor.
|
||||
pub const udp_limit_min: u16 = types.max_udp_payload;
|
||||
|
||||
/// The ceiling nxdns advertises and accepts. Above this, fragmentation and
|
||||
/// reflection amplification cost more than a TCP retry.
|
||||
pub const udp_limit_max: u16 = 4096;
|
||||
|
||||
/// A synthesized reply is a header, at most one question and at most one OPT
|
||||
/// record: 12 + (255 + 4) + 11 = 282 bytes worst case. Every buffer this file
|
||||
/// builds into is at least `udp_limit_min`, so the builder cannot run out of
|
||||
/// room and its `Writer.Error` is unreachable.
|
||||
const max_synthetic_len = types.header_len + types.max_name_len + 4 + 11;
|
||||
|
||||
comptime {
|
||||
std.debug.assert(max_synthetic_len <= udp_limit_min);
|
||||
}
|
||||
|
||||
pub const Handler = struct {
|
||||
/// In production this is `pool.client()`.
|
||||
upstream: transport.Client,
|
||||
stats: Stats = .{},
|
||||
|
||||
pub const Stats = struct {
|
||||
queries: std.atomic.Value(u64) = .init(0),
|
||||
dropped_malformed: std.atomic.Value(u64) = .init(0),
|
||||
formerr: std.atomic.Value(u64) = .init(0),
|
||||
notimp: std.atomic.Value(u64) = .init(0),
|
||||
servfail: std.atomic.Value(u64) = .init(0),
|
||||
truncated: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
pub const Outcome = union(enum) {
|
||||
/// A prefix of `response_buf`.
|
||||
reply: []u8,
|
||||
/// No response is possible or appropriate.
|
||||
drop,
|
||||
};
|
||||
|
||||
/// `response_buf.len` must be >= 512 and <= 65535.
|
||||
pub fn handle(
|
||||
self: *Handler,
|
||||
io: std.Io,
|
||||
which: Transport,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) Outcome {
|
||||
std.debug.assert(response_buf.len >= udp_limit_min);
|
||||
std.debug.assert(response_buf.len <= transport.max_message_len);
|
||||
|
||||
// Fewer than 12 bytes: there is no ID and no question, so no reply can
|
||||
// be addressed to this query (PLAN §6.1).
|
||||
const hdr = header.parse(query) catch {
|
||||
bump(&self.stats.dropped_malformed);
|
||||
return .drop;
|
||||
};
|
||||
|
||||
// A response on a listener port is either a misdirected reply or a
|
||||
// reflection attempt. Answering it would make this server the amplifier.
|
||||
if (hdr.flags.qr) {
|
||||
bump(&self.stats.dropped_malformed);
|
||||
return .drop;
|
||||
}
|
||||
|
||||
const p = packet.parse(query) catch |err| switch (err) {
|
||||
error.Truncated => {
|
||||
bump(&self.stats.dropped_malformed);
|
||||
return .drop;
|
||||
},
|
||||
// The header is intact but a section is not. The question is what
|
||||
// failed to parse, so nothing is echoed and no OPT is trusted.
|
||||
else => return synthesize(hdr, null, null, .form_err, &self.stats.formerr, response_buf),
|
||||
};
|
||||
|
||||
// RFC 6891 §6.1.1 puts the OPT record in the additional section, and §7
|
||||
// answers a violation with FORMERR. `packet.parse` already rejects a
|
||||
// second OPT wherever it sits; the case it accepts and this walk does
|
||||
// not is a lone OPT in the answer or authority section, which
|
||||
// `findOptRecord` cannot see.
|
||||
if (containsOpt(packet.answers(p)) or containsOpt(packet.authorities(p))) {
|
||||
const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null;
|
||||
return synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf);
|
||||
}
|
||||
|
||||
// RFC 6891 §6.1.1 also gives the OPT record a root owner name. An OPT
|
||||
// that is present but unusable must not be forwarded as if the query
|
||||
// carried none: the reply's OPT echo would then disappear with nothing
|
||||
// to say it ever existed.
|
||||
const opt: ?edns.OptRecord = if (packet.findOptRecord(p)) |rec|
|
||||
edns.parseOpt(query, rec) catch {
|
||||
const echo = if (hdr.qdcount == 1) packet.firstQuestion(p) else null;
|
||||
return synthesize(hdr, echo, null, .form_err, &self.stats.formerr, response_buf);
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
if (hdr.flags.opcode != .query) {
|
||||
return synthesize(hdr, null, opt, .not_imp, &self.stats.notimp, response_buf);
|
||||
}
|
||||
|
||||
// RFC 9619: exactly one question, in both directions. Any other count
|
||||
// has no valid interpretation, so there is no question to echo either.
|
||||
if (hdr.qdcount != 1) {
|
||||
return synthesize(hdr, null, opt, .form_err, &self.stats.formerr, response_buf);
|
||||
}
|
||||
|
||||
// `parse` already walked the question section, so a packet with
|
||||
// qdcount 1 always has a first question. The fallback keeps a
|
||||
// hand-assembled `Packet` from turning into a crash.
|
||||
const q = packet.firstQuestion(p) orelse
|
||||
return synthesize(hdr, null, opt, .form_err, &self.stats.formerr, response_buf);
|
||||
|
||||
const reply = self.upstream.exchange(io, query, response_buf) catch |err| switch (transport.group(err)) {
|
||||
// The process is shutting down. There is nothing to say, and the
|
||||
// client is about to lose the socket anyway; the listener's own
|
||||
// counters record the abandoned datagram.
|
||||
.cancellation => return .drop,
|
||||
.peer_fault, .local_resource => return synthesize(
|
||||
hdr,
|
||||
q,
|
||||
opt,
|
||||
.serv_fail,
|
||||
&self.stats.servfail,
|
||||
response_buf,
|
||||
),
|
||||
};
|
||||
|
||||
bump(&self.stats.queries);
|
||||
|
||||
if (which == .udp and reply.len > udpLimit(p)) {
|
||||
// The upstream answer occupies `response_buf`, so the replacement
|
||||
// is built beside it and copied over.
|
||||
bump(&self.stats.truncated);
|
||||
var scratch: [udp_limit_min]u8 = undefined;
|
||||
const message = build(hdr, q, opt, .no_error, true, &scratch);
|
||||
@memcpy(response_buf[0..message.len], message);
|
||||
return .{ .reply = response_buf[0..message.len] };
|
||||
}
|
||||
|
||||
return .{ .reply = reply };
|
||||
}
|
||||
};
|
||||
|
||||
/// Counts one synthesized reply and encodes it. The counter is passed in
|
||||
/// because the counter *is* the record that this failure mode happened.
|
||||
fn synthesize(
|
||||
hdr: header.Header,
|
||||
q: ?question.Question,
|
||||
opt: ?edns.OptRecord,
|
||||
rcode: types.Rcode,
|
||||
counter: *std.atomic.Value(u64),
|
||||
response_buf: []u8,
|
||||
) Handler.Outcome {
|
||||
bump(counter);
|
||||
return .{ .reply = build(hdr, q, opt, rcode, false, response_buf) };
|
||||
}
|
||||
|
||||
/// Whether the section holds a record of TYPE=OPT. A section that will not walk
|
||||
/// counts as holding one: the caller's only answer to either condition is
|
||||
/// FORMERR, and a `Packet` from `packet.parse` walks in full, so the fallback is
|
||||
/// reachable only for a `Packet` assembled by hand around unvalidated bytes.
|
||||
fn containsOpt(section: packet.RecordIterator) bool {
|
||||
var it = section;
|
||||
while (it.next() catch return true) |rec| {
|
||||
if (rec.rtype == .opt) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The query's advertised UDP payload size, clamped to `[512, 4096]`, or 512
|
||||
/// when the query carries no usable OPT record.
|
||||
pub fn udpLimit(query_packet: packet.Packet) u16 {
|
||||
const rec = packet.findOptRecord(query_packet) orelse return udp_limit_min;
|
||||
const opt = edns.parseOpt(query_packet.bytes, rec) catch return udp_limit_min;
|
||||
return std.math.clamp(opt.udp_payload_size, udp_limit_min, udp_limit_max);
|
||||
}
|
||||
|
||||
/// Encodes one synthesized reply. `ResponseBuilder.init` copies the request's
|
||||
/// ID, opcode and RD bit and sets QR and RA, so every reply built here binds to
|
||||
/// its request. `buf` is at least `udp_limit_min`, which `max_synthetic_len`
|
||||
/// proves is enough, so the writer cannot fail.
|
||||
fn build(
|
||||
hdr: header.Header,
|
||||
q: ?question.Question,
|
||||
opt: ?edns.OptRecord,
|
||||
rcode: types.Rcode,
|
||||
tc: bool,
|
||||
buf: []u8,
|
||||
) []u8 {
|
||||
var b = packet.ResponseBuilder.init(buf, hdr, q) catch unreachable;
|
||||
b.setRcode(rcode);
|
||||
b.header.flags.tc = tc;
|
||||
// An EDNS query gets an EDNS reply, and the DO bit passes through
|
||||
// untouched — nxdns validates no signatures, so it must not claim the
|
||||
// client asked for none (PLAN §6.1).
|
||||
if (opt) |o| b.addOptEcho(o, o.do_bit) catch unreachable;
|
||||
return b.finish();
|
||||
}
|
||||
|
||||
fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// A query for example.com A: id 0x1234, RD set, one question, no OPT.
|
||||
const query_bytes =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
|
||||
/// The matching response: the question echoed plus one A record.
|
||||
const response_bytes =
|
||||
"\x12\x34\x81\x80\x00\x01\x00\x01\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\xc0\x0c\x00\x01\x00\x01\x00\x00\x01\x2c\x00\x04\x5d\xb8\xd8\x22";
|
||||
|
||||
const opt_len = 11;
|
||||
const query_with_opt_len = query_bytes.len + opt_len;
|
||||
|
||||
/// `query_bytes` plus an OPT record advertising `payload_size`.
|
||||
fn queryWithOpt(buf: *[query_with_opt_len]u8, payload_size: u16, do_bit: bool) []const u8 {
|
||||
@memcpy(buf[0..query_bytes.len], query_bytes);
|
||||
std.mem.writeInt(u16, buf[10..12], 1, .big); // arcount
|
||||
|
||||
const opt = buf[query_bytes.len..][0..opt_len];
|
||||
@memset(opt, 0);
|
||||
opt[2] = @intFromEnum(types.Type.opt);
|
||||
std.mem.writeInt(u16, opt[3..5], payload_size, .big);
|
||||
if (do_bit) opt[7] = 0x80; // the DO bit is bit 15 of the TTL word
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// `query_bytes` plus an OPT record whose owner name is `com.` instead of the
|
||||
/// root, which RFC 6891 §6.1.1 forbids.
|
||||
const query_with_named_opt =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x01" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\x03com\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00";
|
||||
|
||||
/// `query_bytes` plus an OPT record whose RDATA ends in a three-byte option
|
||||
/// header. The record itself fits the packet; only the option list is broken.
|
||||
const query_with_bad_option =
|
||||
"\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\x03\x00\x08\x00";
|
||||
|
||||
/// A bare OPT record: root owner, TYPE 41, 4096-byte payload, empty RDATA.
|
||||
const opt_record = "\x00\x00\x29\x10\x00\x00\x00\x00\x00\x00\x00";
|
||||
|
||||
/// `query_bytes` with the OPT record counted into the answer section, which
|
||||
/// RFC 6891 §6.1.1 forbids.
|
||||
const query_with_opt_in_answer =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x01\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
opt_record;
|
||||
|
||||
/// The same OPT record, counted into the authority section instead.
|
||||
const query_with_opt_in_authority =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x01\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
opt_record;
|
||||
|
||||
/// Two OPT records in the additional section. RFC 6891 §6.1.1 allows one.
|
||||
const query_with_two_opts =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x02" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
opt_record ++ opt_record;
|
||||
|
||||
const FakeUpstream = struct {
|
||||
reply: []const u8 = &.{},
|
||||
err: ?transport.ExchangeError = null,
|
||||
calls: usize = 0,
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
|
||||
self.calls += 1;
|
||||
if (self.err) |e| return e;
|
||||
if (self.reply.len > response_buf.len) return error.ResponseTooLarge;
|
||||
@memcpy(response_buf[0..self.reply.len], self.reply);
|
||||
const bytes = response_buf[0..self.reply.len];
|
||||
if (bytes.len >= types.header_len) {
|
||||
packet.setId(bytes, (header.parse(query) catch unreachable).id);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn client(self: *FakeUpstream) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
fn expectReply(outcome: Handler.Outcome) ![]u8 {
|
||||
return switch (outcome) {
|
||||
.reply => |bytes| bytes,
|
||||
.drop => error.TestUnexpectedDrop,
|
||||
};
|
||||
}
|
||||
|
||||
test "a udp query is forwarded and the upstream reply is returned unchanged" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf));
|
||||
|
||||
try testing.expectEqualSlices(u8, response_bytes, reply);
|
||||
try testing.expectEqual(@as(usize, 1), fake.calls);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic));
|
||||
// A query that carries no OPT at all is not an EDNS violation.
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.formerr.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a response arriving on the listener port is dropped" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
try testing.expectEqual(Handler.Outcome.drop, h.handle(undefined, .udp, response_bytes, &buf));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "a query shorter than a header is dropped" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const short = query_bytes[0..8];
|
||||
try testing.expectEqual(Handler.Outcome.drop, h.handle(undefined, .udp, short, &buf));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "a query whose question runs off the end gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
// The header promises a question; the packet ends after 12 bytes plus a
|
||||
// partial name, which `packet.parse` reports as a section overrun.
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const overrun = query_bytes[0 .. query_bytes.len - 3];
|
||||
const outcome = h.handle(undefined, .udp, overrun, &buf);
|
||||
const reply = try expectReply(outcome);
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a query with QDCOUNT 0 gets FORMERR with no question" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, no_question, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.qdcount);
|
||||
try testing.expectEqual(true, p.header.flags.qr);
|
||||
try testing.expectEqual(true, p.header.flags.ra);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "a query with QDCOUNT 2 gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const two =
|
||||
"\x12\x34\x01\x00\x00\x02\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\x07example\x03com\x00\x00\x1c\x00\x01";
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, two, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "a structurally broken question gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
// A compression pointer to itself: the name never terminates.
|
||||
const loop = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\xc0\x0c\x00\x01\x00\x01";
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, loop, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "an unimplemented opcode gets NOTIMP" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var update: [query_bytes.len]u8 = query_bytes.*;
|
||||
std.mem.writeInt(u16, update[2..4], 0x2900, .big); // opcode update, RD set
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, &update, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.not_imp, p.header.flags.rcode);
|
||||
try testing.expectEqual(types.Opcode.update, p.header.flags.opcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.notimp.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "a peer fault becomes SERVFAIL with the question echoed" {
|
||||
var fake: FakeUpstream = .{ .err = error.Timeout };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(true, p.header.flags.rd);
|
||||
try testing.expectEqual(true, p.header.flags.qr);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.ancount);
|
||||
|
||||
const echoed = packet.firstQuestion(p).?;
|
||||
const asked = packet.firstQuestion(try packet.parse(query_bytes)).?;
|
||||
try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire());
|
||||
try testing.expectEqual(types.Type.a, echoed.qtype);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.servfail.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a local resource failure becomes SERVFAIL, not a drop" {
|
||||
var fake: FakeUpstream = .{ .err = error.OutOfMemory };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.servfail.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a canceled exchange is dropped" {
|
||||
var fake: FakeUpstream = .{ .err = error.Canceled };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
try testing.expectEqual(Handler.Outcome.drop, h.handle(undefined, .udp, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.servfail.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.dropped_malformed.load(.monotonic));
|
||||
}
|
||||
|
||||
test "udpLimit clamps the advertised payload size" {
|
||||
try testing.expectEqual(@as(u16, 512), udpLimit(try packet.parse(query_bytes)));
|
||||
|
||||
const cases = [_]struct { advertised: u16, expected: u16 }{
|
||||
.{ .advertised = 1232, .expected = 1232 },
|
||||
.{ .advertised = 200, .expected = 512 },
|
||||
.{ .advertised = 9000, .expected = 4096 },
|
||||
.{ .advertised = 512, .expected = 512 },
|
||||
.{ .advertised = 4096, .expected = 4096 },
|
||||
};
|
||||
for (cases) |c| {
|
||||
var buf: [query_with_opt_len]u8 = undefined;
|
||||
const q = queryWithOpt(&buf, c.advertised, false);
|
||||
try testing.expectEqual(c.expected, udpLimit(try packet.parse(q)));
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid response for `query_bytes` padded past 512 bytes with A records.
|
||||
fn oversizeResponse(buf: []u8) []u8 {
|
||||
const request = packet.parse(query_bytes) catch unreachable;
|
||||
const q = packet.firstQuestion(request).?;
|
||||
var b = packet.ResponseBuilder.init(buf, request.header, q) catch unreachable;
|
||||
var i: usize = 0;
|
||||
while (i < 32) : (i += 1) {
|
||||
b.addAnswer(q.name, .a, .in, 300, "\x5d\xb8\xd8\x22") catch unreachable;
|
||||
}
|
||||
return b.finish();
|
||||
}
|
||||
|
||||
test "an oversize udp reply is replaced by a truncated one" {
|
||||
var reply_buf: [2048]u8 = undefined;
|
||||
const oversize = oversizeResponse(&reply_buf);
|
||||
try testing.expect(oversize.len > udp_limit_min);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = oversize };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_bytes, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(true, p.header.flags.tc);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.ancount);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(true, p.header.flags.rd);
|
||||
|
||||
const echoed = packet.firstQuestion(p).?;
|
||||
const asked = packet.firstQuestion(try packet.parse(query_bytes)).?;
|
||||
try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire());
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
|
||||
}
|
||||
|
||||
test "the same oversize reply passes through untouched over tcp" {
|
||||
var reply_buf: [2048]u8 = undefined;
|
||||
const oversize = oversizeResponse(&reply_buf);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = oversize };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .tcp, query_bytes, &buf));
|
||||
|
||||
try testing.expectEqualSlices(u8, oversize, reply);
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
|
||||
}
|
||||
|
||||
test "a reply within the advertised EDNS limit is not truncated" {
|
||||
var reply_buf: [2048]u8 = undefined;
|
||||
const oversize = oversizeResponse(&reply_buf);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = oversize };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var query_buf: [query_with_opt_len]u8 = undefined;
|
||||
const query = queryWithOpt(&query_buf, 4096, false);
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query, &buf));
|
||||
try testing.expectEqual(oversize.len, reply.len);
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.truncated.load(.monotonic));
|
||||
}
|
||||
|
||||
test "the DO bit passes through into a synthesized reply" {
|
||||
for ([_]bool{ false, true }) |do_bit| {
|
||||
var fake: FakeUpstream = .{ .err = error.Timeout };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var query_buf: [query_with_opt_len]u8 = undefined;
|
||||
const query = queryWithOpt(&query_buf, 1232, do_bit);
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.serv_fail, p.header.flags.rcode);
|
||||
const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?);
|
||||
try testing.expectEqual(do_bit, opt.do_bit);
|
||||
try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size);
|
||||
}
|
||||
}
|
||||
|
||||
test "the truncated reply echoes the OPT record" {
|
||||
var reply_buf: [2048]u8 = undefined;
|
||||
const oversize = oversizeResponse(&reply_buf);
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = oversize };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
// 512 is the advertised size, so the oversize answer still does not fit.
|
||||
var query_buf: [query_with_opt_len]u8 = undefined;
|
||||
const query = queryWithOpt(&query_buf, 512, true);
|
||||
|
||||
var buf: [4096]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(true, p.header.flags.tc);
|
||||
const opt = try edns.parseOpt(reply, packet.findOptRecord(p).?);
|
||||
try testing.expectEqual(true, opt.do_bit);
|
||||
try testing.expectEqual(@as(u16, 512), opt.udp_payload_size);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.truncated.load(.monotonic));
|
||||
}
|
||||
|
||||
test "an OPT record with a non-root owner name gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_with_named_opt, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(true, p.header.flags.qr);
|
||||
try testing.expectEqual(true, p.header.flags.ra);
|
||||
|
||||
// The question parsed, so it is echoed; the OPT did not, so none is.
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
const echoed = packet.firstQuestion(p).?;
|
||||
const asked = packet.firstQuestion(try packet.parse(query_bytes)).?;
|
||||
try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire());
|
||||
try testing.expect(packet.findOptRecord(p) == null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "an OPT record with a malformed option list gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
// The packet as a whole is well-formed: only `parseOpt` rejects it.
|
||||
const p_query = try packet.parse(query_with_bad_option);
|
||||
try testing.expectError(
|
||||
error.BadOption,
|
||||
edns.parseOpt(query_with_bad_option, packet.findOptRecord(p_query).?),
|
||||
);
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_with_bad_option, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expect(packet.findOptRecord(p) == null);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "an OPT record whose rdata runs off the end gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
// RDLENGTH claims three bytes and one follows, which `packet.parse` reports
|
||||
// as a section overrun before the OPT is ever read.
|
||||
const truncated = query_with_bad_option[0 .. query_with_bad_option.len - 2];
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, truncated, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "an OPT record in the answer section gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
// The message walks: only its placement of the OPT is illegal, so the
|
||||
// handler's own section walk is what has to reject it.
|
||||
const p_query = try packet.parse(query_with_opt_in_answer);
|
||||
try testing.expect(packet.findOptRecord(p_query) == null);
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_with_opt_in_answer, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
|
||||
// The question parsed, so it is echoed; the OPT is not echoed anywhere.
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
const echoed = packet.firstQuestion(p).?;
|
||||
const asked = packet.firstQuestion(try packet.parse(query_bytes)).?;
|
||||
try testing.expectEqualSlices(u8, asked.name.wire(), echoed.name.wire());
|
||||
try testing.expect(packet.findOptRecord(p) == null);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.arcount);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "an OPT record in the authority section gets FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const p_query = try packet.parse(query_with_opt_in_authority);
|
||||
try testing.expect(packet.findOptRecord(p_query) == null);
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_with_opt_in_authority, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
try testing.expect(packet.findOptRecord(p) == null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "two OPT records in the additional section get FORMERR" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
// Here the rejection comes from `packet.parse`, one section walk earlier.
|
||||
try testing.expectError(error.MultipleOptRecords, packet.parse(query_with_two_opts));
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query_with_two_opts, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(types.Rcode.form_err, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expect(packet.findOptRecord(p) == null);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.formerr.load(.monotonic));
|
||||
try testing.expectEqual(@as(usize, 0), fake.calls);
|
||||
}
|
||||
|
||||
test "a query with a valid OPT record is still forwarded" {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var query_buf: [query_with_opt_len]u8 = undefined;
|
||||
const query = queryWithOpt(&query_buf, 1232, true);
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, query, &buf));
|
||||
|
||||
try testing.expectEqualSlices(u8, response_bytes, reply);
|
||||
try testing.expectEqual(@as(usize, 1), fake.calls);
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.formerr.load(.monotonic));
|
||||
|
||||
// The same query, answered from the SERVFAIL path, still echoes the OPT.
|
||||
var fail: FakeUpstream = .{ .err = error.Timeout };
|
||||
var h2: Handler = .{ .upstream = fail.client() };
|
||||
const synthesized = try expectReply(h2.handle(undefined, .udp, query, &buf));
|
||||
const p = try packet.parse(synthesized);
|
||||
const opt = try edns.parseOpt(synthesized, packet.findOptRecord(p).?);
|
||||
try testing.expectEqual(true, opt.do_bit);
|
||||
try testing.expectEqual(@as(u16, 1232), opt.udp_payload_size);
|
||||
try testing.expectEqual(@as(u64, 0), h2.stats.formerr.load(.monotonic));
|
||||
}
|
||||
|
||||
test "every synthesized reply re-parses and binds to its request" {
|
||||
const Case = struct {
|
||||
query: []const u8,
|
||||
err: ?transport.ExchangeError,
|
||||
rcode: types.Rcode,
|
||||
};
|
||||
const broken_question = "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\xc0\x0c\x00\x01\x00\x01";
|
||||
const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
const update = "\x12\x34\x29\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
|
||||
const cases = [_]Case{
|
||||
.{ .query = broken_question, .err = null, .rcode = .form_err },
|
||||
.{ .query = no_question, .err = null, .rcode = .form_err },
|
||||
.{ .query = query_with_named_opt, .err = null, .rcode = .form_err },
|
||||
.{ .query = query_with_bad_option, .err = null, .rcode = .form_err },
|
||||
.{ .query = query_with_opt_in_answer, .err = null, .rcode = .form_err },
|
||||
.{ .query = query_with_opt_in_authority, .err = null, .rcode = .form_err },
|
||||
.{ .query = query_with_two_opts, .err = null, .rcode = .form_err },
|
||||
.{ .query = update, .err = null, .rcode = .not_imp },
|
||||
.{ .query = query_bytes, .err = error.BadResponse, .rcode = .serv_fail },
|
||||
.{ .query = query_bytes, .err = error.Unexpected, .rcode = .serv_fail },
|
||||
};
|
||||
|
||||
for (cases) |c| {
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes, .err = c.err };
|
||||
var h: Handler = .{ .upstream = fake.client() };
|
||||
|
||||
var buf: [udp_limit_min]u8 = undefined;
|
||||
const reply = try expectReply(h.handle(undefined, .udp, c.query, &buf));
|
||||
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(c.rcode, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(true, p.header.flags.qr);
|
||||
try testing.expectEqual(true, p.header.flags.ra);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.ancount);
|
||||
try testing.expectEqual(@as(u16, 0), p.header.nscount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user