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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//! End-to-end test for the whole resolver path: a client socket on the
|
||||
//! loopback, the real UDP and TCP listeners, the real handler, the real pool,
|
||||
//! and two in-process fake upstreams.
|
||||
//!
|
||||
//! This lives in its own file because it needs `@import("build_options")`, which
|
||||
//! only exists when the compilation is driven by build.zig. The body is compiled
|
||||
//! by every `zig build test` run, so it cannot rot, and skips at run time unless
|
||||
//! `-Dintegration` is passed.
|
||||
//!
|
||||
//! Hermetic: every socket is on 127.0.0.1 and the upstreams are structs, so
|
||||
//! nothing leaves the machine. No stream read in 0.16.0 takes a timeout, so the
|
||||
//! TCP client side runs as one task raced against a budget and nothing can hang.
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
const net = std.Io.net;
|
||||
|
||||
const handler = @import("handler.zig");
|
||||
const tcp_server = @import("tcp_server.zig");
|
||||
const udp_server = @import("udp_server.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const record = @import("../dns/record.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const health = @import("../upstream/health.zig");
|
||||
const pool = @import("../upstream/pool.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||
/// enough that a broken server fails the run instead of hanging it.
|
||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||
const budget_duration: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
|
||||
|
||||
/// A query for example.com A: RD set, one question, no OPT. Each step of the
|
||||
/// test rewrites the ID so a reply can only match the query it belongs to.
|
||||
const query_bytes =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
|
||||
const udp_id: u16 = 0x1234;
|
||||
const tcp_id: u16 = 0x5678;
|
||||
const backoff_id: u16 = 0x9abc;
|
||||
|
||||
/// The address every fake upstream answers with, and its TTL.
|
||||
const answer_rdata = [4]u8{ 93, 184, 216, 34 };
|
||||
const answer_ttl: u32 = 300;
|
||||
|
||||
/// One failure is enough to open a backoff window, so the third query has a
|
||||
/// single deterministic outcome. The window outlives the test many times over.
|
||||
const test_cfg: health.Config = .{
|
||||
.failure_threshold = 1,
|
||||
.base_backoff_ms = 60_000,
|
||||
.max_backoff_ms = 60_000,
|
||||
};
|
||||
|
||||
/// Nothing in this test is slow, so this budget only exists to stop a wedged
|
||||
/// attempt from hanging the run.
|
||||
const attempt_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
|
||||
|
||||
fn queryWithId(buf: *[query_bytes.len]u8, id: u16) []const u8 {
|
||||
buf.* = query_bytes.*;
|
||||
packet.setId(buf, id);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/// Echoes the question and appends one A record. This is the smallest thing a
|
||||
/// real upstream could return that the handler forwards unchanged, so the
|
||||
/// assertions below check bytes that travelled the whole path.
|
||||
fn answerQuery(query: []const u8, response_buf: []u8) transport.ExchangeError![]u8 {
|
||||
const request = packet.parse(query) catch return error.BadResponse;
|
||||
const q = packet.firstQuestion(request) orelse return error.BadResponse;
|
||||
|
||||
var b = packet.ResponseBuilder.init(response_buf, request.header, q) catch
|
||||
return error.ResponseTooLarge;
|
||||
b.addAnswer(q.name, .a, .in, answer_ttl, &answer_rdata) catch
|
||||
return error.ResponseTooLarge;
|
||||
return b.finish();
|
||||
}
|
||||
|
||||
/// The healthy upstream. `calls` is atomic because the listener tasks run on
|
||||
/// other threads than the one asserting.
|
||||
const GoodUpstream = struct {
|
||||
calls: std.atomic.Value(u64) = .init(0),
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *GoodUpstream = @ptrCast(@alignCast(ptr));
|
||||
_ = self.calls.fetchAdd(1, .monotonic);
|
||||
return answerQuery(query, response_buf);
|
||||
}
|
||||
|
||||
fn client(self: *GoodUpstream) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
/// Returns `fault` on the first `fail_first` calls and answers after that.
|
||||
/// `fail_first` is `maxInt` for an upstream that never recovers.
|
||||
const FaultyUpstream = struct {
|
||||
fault: transport.PeerFault,
|
||||
fail_first: u64,
|
||||
calls: std.atomic.Value(u64) = .init(0),
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *FaultyUpstream = @ptrCast(@alignCast(ptr));
|
||||
const seen = self.calls.fetchAdd(1, .monotonic);
|
||||
if (seen < self.fail_first) return self.fault;
|
||||
return answerQuery(query, response_buf);
|
||||
}
|
||||
|
||||
fn client(self: *FaultyUpstream) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
fn testEntry(url: []const u8, upstream_client: transport.Client, priority: i32) pool.Entry {
|
||||
return .{
|
||||
.endpoint = transport.Endpoint.parse(url) catch unreachable,
|
||||
.client = upstream_client,
|
||||
.priority = priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
};
|
||||
}
|
||||
|
||||
const Outcome = union(enum) {
|
||||
work: anyerror!void,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return duration.sleep(io);
|
||||
}
|
||||
|
||||
/// Runs the client side under a budget so a server that never answers fails the
|
||||
/// test instead of hanging the run.
|
||||
fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void {
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
defer race.cancelDiscard();
|
||||
|
||||
try race.concurrent(.work, f, args);
|
||||
try race.concurrent(.expiry, expire, .{ io, budget_duration });
|
||||
|
||||
switch (try race.await()) {
|
||||
.work => |result| return result,
|
||||
.expiry => |result| {
|
||||
try result;
|
||||
return error.TestTimedOut;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the client can check about one answer: it belongs to the query it
|
||||
/// was sent for, it succeeded, and it carries the fake upstream's A record.
|
||||
fn expectAnswer(reply: []const u8, id: u16) !void {
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(id, p.header.id);
|
||||
try testing.expectEqual(true, p.header.flags.qr);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||
|
||||
const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion;
|
||||
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);
|
||||
|
||||
var it = packet.answers(p);
|
||||
const rec = (try it.next()) orelse return error.TestMissingAnswer;
|
||||
try testing.expectEqual(types.Type.a, rec.rtype);
|
||||
try testing.expectEqual(answer_ttl, rec.ttl);
|
||||
try testing.expectEqual(answer_rdata, try record.rdataA(reply, rec));
|
||||
}
|
||||
|
||||
/// One length-prefixed query and its answer on a fresh connection
|
||||
/// (RFC 1035 §4.2.2).
|
||||
fn tcpQuery(io: std.Io, address: net.IpAddress, id: u16) anyerror!void {
|
||||
var stream = try address.connect(io, .{ .mode = .stream });
|
||||
defer stream.close(io);
|
||||
|
||||
var read_buf: [1024]u8 = undefined;
|
||||
var write_buf: [1024]u8 = undefined;
|
||||
var reader = stream.reader(io, &read_buf);
|
||||
var writer = stream.writer(io, &write_buf);
|
||||
|
||||
var query_buf: [query_bytes.len]u8 = undefined;
|
||||
const query = queryWithId(&query_buf, id);
|
||||
|
||||
try writer.interface.writeAll(&tcp_server.framePrefix(@intCast(query.len)));
|
||||
try writer.interface.writeAll(query);
|
||||
try writer.interface.flush();
|
||||
|
||||
const len = tcp_server.parsePrefix((try reader.interface.takeArray(tcp_server.prefix_len)).*);
|
||||
try expectAnswer(try reader.interface.take(len), id);
|
||||
}
|
||||
|
||||
test "the whole resolver answers over udp and tcp and fails over to a healthy upstream" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var bad: FaultyUpstream = .{
|
||||
.fault = error.ConnectFailed,
|
||||
.fail_first = std.math.maxInt(u64),
|
||||
};
|
||||
var good: GoodUpstream = .{};
|
||||
var entries = [_]pool.Entry{
|
||||
testEntry("https://bad.example/dns-query", bad.client(), 10),
|
||||
testEntry("tls://good.example", good.client(), 20),
|
||||
};
|
||||
var upstreams: pool.Pool = .init(&entries, test_cfg, attempt_timeout, 1);
|
||||
|
||||
var h: handler.Handler = .{ .upstream = upstreams.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
var tcp = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
const udp_address = udp.boundAddress();
|
||||
const tcp_address = tcp.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, udp_server.UdpServer.serve, .{ &udp, io });
|
||||
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &tcp, io });
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var client = try client_address.bind(io, .{ .mode = .dgram });
|
||||
defer client.close(io);
|
||||
|
||||
{
|
||||
var query_buf: [query_bytes.len]u8 = undefined;
|
||||
const query = queryWithId(&query_buf, udp_id);
|
||||
try client.send(io, &udp_address, query);
|
||||
|
||||
var buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const msg = try client.receiveTimeout(io, &buf, budget);
|
||||
try expectAnswer(msg.data, udp_id);
|
||||
}
|
||||
|
||||
try bounded(io, tcpQuery, .{ io, tcp_address, tcp_id });
|
||||
|
||||
try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.servfail.load(.monotonic));
|
||||
|
||||
var snapshots: [2]pool.Snapshot = undefined;
|
||||
try testing.expectEqual(@as(usize, 2), try upstreams.snapshot(io, &snapshots));
|
||||
|
||||
try testing.expectEqualStrings("https://bad.example/dns-query", snapshots[0].url);
|
||||
try testing.expect(snapshots[0].consecutive_failures >= 1);
|
||||
try testing.expect(snapshots[0].backoff_until != null);
|
||||
try testing.expect(!snapshots[0].available);
|
||||
|
||||
try testing.expectEqualStrings("tls://good.example", snapshots[1].url);
|
||||
try testing.expect(snapshots[1].total_successes >= 2);
|
||||
|
||||
// The failing entry is in backoff, so the third query must reach the
|
||||
// healthy entry without touching it.
|
||||
const bad_calls = bad.calls.load(.monotonic);
|
||||
{
|
||||
var query_buf: [query_bytes.len]u8 = undefined;
|
||||
const query = queryWithId(&query_buf, backoff_id);
|
||||
try client.send(io, &udp_address, query);
|
||||
|
||||
var buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const msg = try client.receiveTimeout(io, &buf, budget);
|
||||
try expectAnswer(msg.data, backoff_id);
|
||||
}
|
||||
try testing.expectEqual(bad_calls, bad.calls.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 3), good.calls.load(.monotonic));
|
||||
|
||||
udp.deinit(gpa, io);
|
||||
tcp.deinit(gpa, io);
|
||||
group.cancel(io);
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
//! The TCP/53 listener.
|
||||
//!
|
||||
//! RFC 1035 §4.2.2 frames every message with a 2-byte big-endian length, and
|
||||
//! RFC 7766 §6.2.1.1 lets one connection carry several queries. Both are
|
||||
//! implemented here: a connection is answered serially until the client closes
|
||||
//! it or the idle budget runs out.
|
||||
//!
|
||||
//! Connection slots are fixed and pre-allocated. Over capacity the listener
|
||||
//! closes the new stream immediately and counts it; it never queues, and it
|
||||
//! never allocates per connection.
|
||||
//!
|
||||
//! No stream read or write in 0.16.0 accepts a timeout, so every per-connection
|
||||
//! operation is raced against `Options.idle_timeout` through `std.Io.Select` and
|
||||
//! the loser is canceled.
|
||||
|
||||
const std = @import("std");
|
||||
const handler = @import("handler.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const log = std.log.scoped(.tcp_server);
|
||||
|
||||
/// RFC 1035 §4.2.2: the message length prefix is two bytes, big-endian.
|
||||
pub const prefix_len = 2;
|
||||
|
||||
/// The stream buffers only stage the framing bytes. A message longer than this
|
||||
/// is read straight into `Conn.query` and written straight from `Conn.reply`,
|
||||
/// so making them larger would buy nothing.
|
||||
const stream_buffer_len = 1024;
|
||||
|
||||
/// How long the accept loop waits after an unexpected accept failure, so a
|
||||
/// persistent one cannot turn the loop into a spin.
|
||||
const retry_delay: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
|
||||
|
||||
pub const Options = struct {
|
||||
max_connections: u16 = 64,
|
||||
/// RFC 7766 §6.2.3 recommends a few seconds of idle tolerance.
|
||||
idle_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake },
|
||||
};
|
||||
|
||||
pub const Stats = struct {
|
||||
accepted: std.atomic.Value(u64) = .init(0),
|
||||
rejected_at_capacity: std.atomic.Value(u64) = .init(0),
|
||||
rejected_at_shutdown: std.atomic.Value(u64) = .init(0),
|
||||
accept_errors: std.atomic.Value(u64) = .init(0),
|
||||
connection_errors: std.atomic.Value(u64) = .init(0),
|
||||
idle_timeouts: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
/// Lifecycle of the accept loop. `serve` claims `.serving`, `deinit` publishes
|
||||
/// `.closing`, and the two meet at `stopped` so no task touches a connection
|
||||
/// slot after it is freed.
|
||||
const State = enum(u32) { idle, serving, closing };
|
||||
|
||||
/// `.closing` exists so `deinit` never shuts down a descriptor that its own
|
||||
/// task is about to close: the transition to `.closing` happens under the mutex
|
||||
/// before the close, and `deinit` only touches `.active` slots.
|
||||
const ConnState = enum { free, active, closing };
|
||||
|
||||
/// What the accept loop does with a stream it has just accepted.
|
||||
const Claim = union(enum) {
|
||||
/// The stream owns `conns[index]`.
|
||||
slot: usize,
|
||||
/// Every slot is taken. The stream is closed and the loop continues.
|
||||
at_capacity,
|
||||
/// `deinit` has started. The stream is closed and the loop returns.
|
||||
shutting_down,
|
||||
};
|
||||
|
||||
pub const TcpServer = struct {
|
||||
server: std.Io.net.Server,
|
||||
handler: *handler.Handler,
|
||||
conns: []Conn,
|
||||
mutex: std.Io.Mutex,
|
||||
/// Guarded by `mutex`. `deinit` sets it in the same critical section that
|
||||
/// shuts the active connections down, so a stream that arrives after that
|
||||
/// scan can never claim a slot the scan will not visit again.
|
||||
shutdown_begun: bool,
|
||||
options: Options,
|
||||
stats: Stats,
|
||||
state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
/// One slot is ~131 KiB, so the default 64 connections cost ~8.4 MiB, which
|
||||
/// is inside the PLAN §18 budget. The two message buffers cannot be shared
|
||||
/// or shrunk: the handler holds the query while the reply is built, and
|
||||
/// both ceilings are the 65535 bytes the length prefix can express.
|
||||
pub const Conn = struct {
|
||||
query: [transport.max_message_len]u8,
|
||||
reply: [transport.max_message_len]u8,
|
||||
read_buf: [stream_buffer_len]u8,
|
||||
write_buf: [stream_buffer_len]u8,
|
||||
stream: std.Io.net.Stream,
|
||||
/// Guarded by `TcpServer.mutex`.
|
||||
state: ConnState,
|
||||
};
|
||||
|
||||
pub const ListenError = std.Io.net.IpAddress.ListenError || error{OutOfMemory};
|
||||
|
||||
pub fn listen(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
address: std.Io.net.IpAddress,
|
||||
h: *handler.Handler,
|
||||
options: Options,
|
||||
) ListenError!TcpServer {
|
||||
std.debug.assert(options.max_connections > 0);
|
||||
|
||||
const conns = try gpa.alloc(Conn, options.max_connections);
|
||||
errdefer gpa.free(conns);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
|
||||
const local = address;
|
||||
const server = try local.listen(io, .{ .reuse_address = true });
|
||||
|
||||
return .{
|
||||
.server = server,
|
||||
.handler = h,
|
||||
.conns = conns,
|
||||
.mutex = .init,
|
||||
.shutdown_begun = false,
|
||||
.options = options,
|
||||
.stats = .{},
|
||||
.state = .init(.idle),
|
||||
.stopped = .unset,
|
||||
};
|
||||
}
|
||||
|
||||
/// The kernel-assigned address. A port of 0 in `listen` resolves here.
|
||||
pub fn boundAddress(self: *const TcpServer) std.Io.net.IpAddress {
|
||||
return self.server.socket.address;
|
||||
}
|
||||
|
||||
/// Accept loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *TcpServer, io: std.Io) void {
|
||||
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
self.acceptLoop(io, &group);
|
||||
|
||||
// A reply that is half written is worse than no reply, so the live
|
||||
// connections are awaited even when this task is being canceled.
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
_ = io.swapCancelProtection(prev);
|
||||
|
||||
self.stopped.set(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *TcpServer, gpa: std.mem.Allocator, io: std.Io) void {
|
||||
const was_serving = self.state.swap(.closing, .acq_rel) == .serving;
|
||||
|
||||
// Shutting the listening socket down is the documented way to unblock a
|
||||
// pending `accept`: it fails with `error.SocketNotListening`.
|
||||
const listener: std.Io.net.Stream = .{ .socket = self.server.socket };
|
||||
listener.shutdown(io, .both) catch |err| {
|
||||
log.debug("tcp listener shutdown failed: {t}", .{err});
|
||||
};
|
||||
|
||||
// A live connection is blocked in a read that only the idle budget
|
||||
// would end, which is seconds away. Shutting each one down bounds this,
|
||||
// and the same critical section closes the door on new connections.
|
||||
self.beginShutdown(io);
|
||||
|
||||
if (was_serving) self.stopped.waitUncancelable(io);
|
||||
|
||||
self.server.deinit(io);
|
||||
gpa.free(self.conns);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) void {
|
||||
while (self.state.load(.acquire) == .serving) {
|
||||
const stream = self.server.accept(io) catch |err| switch (err) {
|
||||
error.Canceled, error.SocketNotListening => return,
|
||||
else => {
|
||||
bump(&self.stats.accept_errors);
|
||||
log.debug("tcp accept failed: {t}", .{err});
|
||||
retry_delay.sleep(io) catch return;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
const index = switch (self.claim(io, stream)) {
|
||||
.slot => |index| index,
|
||||
// Refusing now is honest; a queue would only hide the overload.
|
||||
.at_capacity => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
stream.close(io);
|
||||
continue;
|
||||
},
|
||||
// `deinit` will not see this stream in any slot, so serving it
|
||||
// would hold `deinit` for the whole idle budget.
|
||||
.shutting_down => {
|
||||
bump(&self.stats.rejected_at_shutdown);
|
||||
stream.close(io);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
group.concurrent(io, serveConn, .{ self, io, index }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => {
|
||||
bump(&self.stats.rejected_at_capacity);
|
||||
self.finish(io, index);
|
||||
continue;
|
||||
},
|
||||
};
|
||||
|
||||
bump(&self.stats.accepted);
|
||||
}
|
||||
}
|
||||
|
||||
fn serveConn(self: *TcpServer, io: std.Io, index: usize) void {
|
||||
defer self.finish(io, index);
|
||||
|
||||
const conn = &self.conns[index];
|
||||
var reader = conn.stream.reader(io, &conn.read_buf);
|
||||
var writer = conn.stream.writer(io, &conn.write_buf);
|
||||
const budget = self.options.idle_timeout;
|
||||
|
||||
while (true) {
|
||||
var prefix: [prefix_len]u8 = undefined;
|
||||
var got: usize = 0;
|
||||
switch (race(io, budget, readPrefix, .{ &reader.interface, &prefix, &got })) {
|
||||
.ok => {},
|
||||
.timed_out => {
|
||||
bump(&self.stats.idle_timeouts);
|
||||
return;
|
||||
},
|
||||
.canceled => return,
|
||||
.failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
|
||||
// A client that closes between messages has finished asking, which
|
||||
// is the normal end of a connection, not a failure.
|
||||
if (got == 0) return;
|
||||
if (got != prefix_len) {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
}
|
||||
|
||||
// RFC 1035 §4.2.2 gives no meaning to a zero-length message, and
|
||||
// the prefix is a u16 so it can never exceed `max_message_len`.
|
||||
const len = parsePrefix(prefix);
|
||||
if (len == 0) {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (race(io, budget, readBody, .{ &reader.interface, conn.query[0..len] })) {
|
||||
.ok => {},
|
||||
.canceled => return,
|
||||
// A half-sent message is a broken peer, not an idle one.
|
||||
.timed_out, .failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
|
||||
const bytes = switch (self.handler.handle(io, .tcp, conn.query[0..len], &conn.reply)) {
|
||||
// There is no framing for "no answer", so the connection ends.
|
||||
.drop => return,
|
||||
.reply => |b| b,
|
||||
};
|
||||
|
||||
const out = framePrefix(@intCast(bytes.len));
|
||||
switch (race(io, budget, writeReply, .{ &writer.interface, &out, bytes })) {
|
||||
.ok => {},
|
||||
.canceled => return,
|
||||
.timed_out, .failed => {
|
||||
bump(&self.stats.connection_errors);
|
||||
return;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn claim(self: *TcpServer, io: std.Io, stream: std.Io.net.Stream) Claim {
|
||||
// Uncancelable: this section takes no Io and never blocks on a peer, so
|
||||
// it cannot deadlock, and losing the lock mid-update would leak a slot.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const outcome = decideClaim(self.conns, self.shutdown_begun);
|
||||
switch (outcome) {
|
||||
.slot => |index| {
|
||||
self.conns[index].stream = stream;
|
||||
self.conns[index].state = .active;
|
||||
},
|
||||
.at_capacity, .shutting_down => {},
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
fn finish(self: *TcpServer, io: std.Io, index: usize) void {
|
||||
const conn = &self.conns[index];
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
conn.state = .closing;
|
||||
self.mutex.unlock(io);
|
||||
|
||||
// The socket is released even when this task is being torn down: the
|
||||
// next cancelable call would otherwise skip the close.
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
conn.stream.close(io);
|
||||
_ = io.swapCancelProtection(prev);
|
||||
|
||||
self.mutex.lockUncancelable(io);
|
||||
conn.state = .free;
|
||||
self.mutex.unlock(io);
|
||||
}
|
||||
|
||||
/// Closes the door on new connections and unblocks the live ones. Both
|
||||
/// happen under one hold of the mutex: a `claim` that runs before this
|
||||
/// leaves an `.active` slot the loop below shuts down, and a `claim` that
|
||||
/// runs after it reads `shutdown_begun` and takes no slot at all.
|
||||
fn beginShutdown(self: *TcpServer, io: std.Io) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
self.shutdown_begun = true;
|
||||
|
||||
for (self.conns) |*conn| {
|
||||
if (conn.state != .active) continue;
|
||||
conn.stream.shutdown(io, .both) catch |err| {
|
||||
log.debug("tcp connection shutdown failed: {t}", .{err});
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// RFC 1035 §4.2.2: the message length as a 2-byte big-endian integer.
|
||||
pub fn framePrefix(len: u16) [prefix_len]u8 {
|
||||
var out: [prefix_len]u8 = undefined;
|
||||
std.mem.writeInt(u16, &out, len, .big);
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn parsePrefix(bytes: [prefix_len]u8) u16 {
|
||||
return std.mem.readInt(u16, &bytes, .big);
|
||||
}
|
||||
|
||||
/// The capacity rule, without the mutex, so it is testable without a backend.
|
||||
fn firstFree(conns: []const TcpServer.Conn) ?usize {
|
||||
for (conns, 0..) |*conn, index| {
|
||||
if (conn.state == .free) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// The whole claim rule, without the mutex. Shutdown outranks capacity: a free
|
||||
/// slot is still refused once `deinit` has passed the connections.
|
||||
fn decideClaim(conns: []const TcpServer.Conn, shutdown_begun: bool) Claim {
|
||||
if (shutdown_begun) return .shutting_down;
|
||||
const index = firstFree(conns) orelse return .at_capacity;
|
||||
return .{ .slot = index };
|
||||
}
|
||||
|
||||
const Outcome = union(enum) {
|
||||
op: anyerror!void,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
const Result = enum { ok, timed_out, failed, canceled };
|
||||
|
||||
/// Runs one connection operation against the idle budget and cancels the loser.
|
||||
fn race(
|
||||
io: std.Io,
|
||||
budget: std.Io.Clock.Duration,
|
||||
comptime f: anytype,
|
||||
args: std.meta.ArgsTuple(@TypeOf(f)),
|
||||
) Result {
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var select: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
defer select.cancelDiscard();
|
||||
|
||||
select.concurrent(.op, f, args) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return .failed,
|
||||
};
|
||||
select.concurrent(.expiry, expire, .{ io, budget }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return .failed,
|
||||
};
|
||||
|
||||
return switch (select.await() catch return .canceled) {
|
||||
.op => |result| if (result) |_| .ok else |err| switch (err) {
|
||||
error.Canceled => .canceled,
|
||||
else => .failed,
|
||||
},
|
||||
// A canceled sleep means this task is being torn down, not that the
|
||||
// client went idle.
|
||||
.expiry => |result| if (result) |_| .timed_out else |_| .canceled,
|
||||
};
|
||||
}
|
||||
|
||||
fn expire(io: std.Io, budget: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return budget.sleep(io);
|
||||
}
|
||||
|
||||
/// `readSliceShort` rather than `readSliceAll`: a zero-length read is a client
|
||||
/// that closed cleanly between messages, and only a partial prefix is an error.
|
||||
fn readPrefix(reader: *std.Io.Reader, buf: *[prefix_len]u8, out_len: *usize) anyerror!void {
|
||||
out_len.* = try reader.readSliceShort(buf);
|
||||
}
|
||||
|
||||
fn readBody(reader: *std.Io.Reader, buf: []u8) anyerror!void {
|
||||
return reader.readSliceAll(buf);
|
||||
}
|
||||
|
||||
fn writeReply(writer: *std.Io.Writer, prefix: *const [prefix_len]u8, bytes: []const u8) anyerror!void {
|
||||
try writer.writeAll(prefix);
|
||||
try writer.writeAll(bytes);
|
||||
try writer.flush();
|
||||
}
|
||||
|
||||
fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "the length prefix is big-endian and round-trips" {
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256));
|
||||
try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535));
|
||||
|
||||
for ([_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 }) |len| {
|
||||
try testing.expectEqual(len, parsePrefix(framePrefix(len)));
|
||||
}
|
||||
}
|
||||
|
||||
test "the prefix ceiling is the message ceiling" {
|
||||
try testing.expectEqual(@as(u16, transport.max_message_len), parsePrefix(.{ 0xff, 0xff }));
|
||||
}
|
||||
|
||||
fn testConns(count: usize) ![]TcpServer.Conn {
|
||||
const conns = try testing.allocator.alloc(TcpServer.Conn, count);
|
||||
for (conns) |*conn| conn.state = .free;
|
||||
return conns;
|
||||
}
|
||||
|
||||
test "the connection pool hands out every slot once" {
|
||||
const conns = try testConns(3);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (0..conns.len) |expected| {
|
||||
const index = firstFree(conns) orelse return error.TestUnexpectedResult;
|
||||
try testing.expectEqual(expected, index);
|
||||
conns[index].state = .active;
|
||||
}
|
||||
}
|
||||
|
||||
test "a full connection pool refuses instead of growing" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
for (conns) |*conn| conn.state = .active;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
}
|
||||
|
||||
test "a closing slot is not reused until it is free" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
conns[1].state = .closing;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(conns));
|
||||
|
||||
conns[1].state = .free;
|
||||
try testing.expectEqual(@as(?usize, 1), firstFree(conns));
|
||||
}
|
||||
|
||||
test "a claim takes the first free slot before shutdown" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(@as(usize, 1), decideClaim(conns, false).slot);
|
||||
}
|
||||
|
||||
test "a claim after shutdown is refused even with a free slot" {
|
||||
const conns = try testConns(2);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
|
||||
// The refusal must not consume the slot: `deinit` frees it, nothing else.
|
||||
try testing.expectEqual(@as(?usize, 0), firstFree(conns));
|
||||
}
|
||||
|
||||
test "shutdown outranks capacity" {
|
||||
const conns = try testConns(1);
|
||||
defer testing.allocator.free(conns);
|
||||
|
||||
conns[0].state = .active;
|
||||
try testing.expectEqual(.at_capacity, std.meta.activeTag(decideClaim(conns, false)));
|
||||
try testing.expectEqual(.shutting_down, std.meta.activeTag(decideClaim(conns, true)));
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Loopback tests for `tcp_server.zig`.
|
||||
//!
|
||||
//! This lives in its own file because it needs `@import("build_options")`, which
|
||||
//! only exists when the compilation is driven by build.zig. The body is compiled
|
||||
//! by every `zig build test` run, so it cannot rot, and skips at run time unless
|
||||
//! `-Dintegration` is passed.
|
||||
//!
|
||||
//! Hermetic: one listener and one client on 127.0.0.1 and an in-process fake
|
||||
//! upstream. No stream read in 0.16.0 takes a timeout, so the whole client side
|
||||
//! of each test runs as one task raced against a budget and nothing can hang.
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
const net = std.Io.net;
|
||||
|
||||
const handler = @import("handler.zig");
|
||||
const tcp_server = @import("tcp_server.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
|
||||
|
||||
/// Short enough to keep the idle-timeout test quick, long enough that a
|
||||
/// loopback connect cannot lose to scheduling and time out on its own.
|
||||
const short_idle: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(300), .clock = .awake };
|
||||
|
||||
/// 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";
|
||||
|
||||
/// Answers from a fixture and rewrites the ID, which is all the server needs
|
||||
/// from an upstream. The real clients are exercised by their own tests.
|
||||
const FakeUpstream = struct {
|
||||
reply: []const u8,
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
|
||||
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];
|
||||
packet.setId(bytes, (header.parse(query) catch unreachable).id);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn client(self: *FakeUpstream) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
const Outcome = union(enum) {
|
||||
work: anyerror!void,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return duration.sleep(io);
|
||||
}
|
||||
|
||||
/// Runs the client side under a budget so a server that never answers fails the
|
||||
/// test instead of hanging the run.
|
||||
fn bounded(io: std.Io, comptime f: anytype, args: std.meta.ArgsTuple(@TypeOf(f))) !void {
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
defer race.cancelDiscard();
|
||||
|
||||
try race.concurrent(.work, f, args);
|
||||
try race.concurrent(.expiry, expire, .{ io, budget });
|
||||
|
||||
switch (try race.await()) {
|
||||
.work => |result| return result,
|
||||
.expiry => |result| {
|
||||
try result;
|
||||
return error.TestTimedOut;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn expectAnswersQuery(reply: []const u8) !void {
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(true, p.header.flags.qr);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.ancount);
|
||||
|
||||
const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion;
|
||||
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);
|
||||
}
|
||||
|
||||
/// RFC 7766 §6.2.1.1: two queries on one connection, answered in order.
|
||||
fn twoQueriesOnOneConnection(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
const remote = address;
|
||||
var stream = try remote.connect(io, .{ .mode = .stream });
|
||||
defer stream.close(io);
|
||||
|
||||
var read_buf: [1024]u8 = undefined;
|
||||
var write_buf: [1024]u8 = undefined;
|
||||
var reader = stream.reader(io, &read_buf);
|
||||
var writer = stream.writer(io, &write_buf);
|
||||
|
||||
for (0..2) |_| {
|
||||
try writer.interface.writeAll(&tcp_server.framePrefix(@intCast(query_bytes.len)));
|
||||
try writer.interface.writeAll(query_bytes);
|
||||
try writer.interface.flush();
|
||||
|
||||
const len = tcp_server.parsePrefix((try reader.interface.takeArray(tcp_server.prefix_len)).*);
|
||||
try expectAnswersQuery(try reader.interface.take(len));
|
||||
}
|
||||
}
|
||||
|
||||
/// The server must close an idle connection on its own, which the client sees
|
||||
/// as end of stream.
|
||||
fn waitForServerClose(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
const remote = address;
|
||||
var stream = try remote.connect(io, .{ .mode = .stream });
|
||||
defer stream.close(io);
|
||||
|
||||
var read_buf: [64]u8 = undefined;
|
||||
var reader = stream.reader(io, &read_buf);
|
||||
|
||||
var sink: [64]u8 = undefined;
|
||||
const n = try reader.interface.readSliceShort(&sink);
|
||||
if (n != 0) return error.TestUnexpectedBytes;
|
||||
}
|
||||
|
||||
test "two length-prefixed queries share one connection" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||
|
||||
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.rejected_at_capacity.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
test "an idle connection is closed and counted" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
.max_connections = 2,
|
||||
.idle_timeout = short_idle,
|
||||
});
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||
|
||||
try bounded(io, waitForServerClose, .{ io, server_address });
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.accepted.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.idle_timeouts.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.connection_errors.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
test "a zero-length message is a connection error" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
|
||||
.max_connections = 2,
|
||||
.idle_timeout = short_idle,
|
||||
});
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||
|
||||
try bounded(io, sendZeroLength, .{ io, server_address });
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.connection_errors.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.idle_timeouts.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
/// A prefix of 0 announces a message RFC 1035 §4.2.2 gives no meaning to, so
|
||||
/// the server closes rather than waiting for bytes that will never mean
|
||||
/// anything.
|
||||
fn sendZeroLength(io: std.Io, address: net.IpAddress) anyerror!void {
|
||||
const remote = address;
|
||||
var stream = try remote.connect(io, .{ .mode = .stream });
|
||||
defer stream.close(io);
|
||||
|
||||
var write_buf: [64]u8 = undefined;
|
||||
var writer = stream.writer(io, &write_buf);
|
||||
try writer.interface.writeAll(&tcp_server.framePrefix(0));
|
||||
try writer.interface.flush();
|
||||
|
||||
var read_buf: [64]u8 = undefined;
|
||||
var reader = stream.reader(io, &read_buf);
|
||||
var sink: [64]u8 = undefined;
|
||||
const n = try reader.interface.readSliceShort(&sink);
|
||||
if (n != 0) return error.TestUnexpectedBytes;
|
||||
}
|
||||
|
||||
test "deinit ends a serve loop that is blocked on accept" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
|
||||
|
||||
// No client ever connects, so `serve` is inside an accept when this runs.
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
//! The UDP/53 listener.
|
||||
//!
|
||||
//! One receive loop feeds a bounded pool of in-flight tasks. A datagram that
|
||||
//! finds no free slot is dropped and counted, never queued: an unbounded queue
|
||||
//! only moves the failure from "visible drop" to "invisible latency", which is
|
||||
//! the failure mode PLAN §1 names.
|
||||
//!
|
||||
//! Every slot carries its own query and reply buffer, so answering a datagram
|
||||
//! allocates nothing. The pool is sized once in `bind` and never grows.
|
||||
|
||||
const std = @import("std");
|
||||
const handler = @import("handler.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const log = std.log.scoped(.udp_server);
|
||||
|
||||
/// The EDNS ceiling this server advertises. A larger datagram arrives
|
||||
/// truncated and is dropped rather than guessed at.
|
||||
pub const max_datagram = 4096;
|
||||
|
||||
comptime {
|
||||
// `handler.handle` asserts this range on the buffers it is given.
|
||||
std.debug.assert(max_datagram >= handler.udp_limit_min);
|
||||
std.debug.assert(max_datagram >= handler.udp_limit_max);
|
||||
std.debug.assert(transport.max_message_len >= handler.udp_limit_min);
|
||||
}
|
||||
|
||||
/// How long a receive waits before the loop re-reads the stop flag. `deinit`
|
||||
/// therefore returns within this much time, without depending on a blocked
|
||||
/// `receive` being unblocked by a close — a guarantee POSIX does not make.
|
||||
const poll_interval: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(100), .clock = .awake };
|
||||
|
||||
pub const Options = struct {
|
||||
max_in_flight: u16 = 64,
|
||||
};
|
||||
|
||||
pub const Stats = struct {
|
||||
received: std.atomic.Value(u64) = .init(0),
|
||||
/// The datagram arrived with `flags.trunc`: the kernel discarded its tail.
|
||||
dropped_oversize: std.atomic.Value(u64) = .init(0),
|
||||
dropped_no_slot: std.atomic.Value(u64) = .init(0),
|
||||
/// The handler answered `.drop` — an upstream exchange canceled by
|
||||
/// shutdown. The handler counts its own malformed drops; this counter is
|
||||
/// what keeps the listener's "every dropped datagram is counted" rule true
|
||||
/// for the drops it cannot see.
|
||||
dropped_handler: std.atomic.Value(u64) = .init(0),
|
||||
receive_errors: std.atomic.Value(u64) = .init(0),
|
||||
send_errors: std.atomic.Value(u64) = .init(0),
|
||||
};
|
||||
|
||||
/// Lifecycle of the receive loop. `serve` claims `.serving`, `deinit` publishes
|
||||
/// `.closing`, and the two meet at `stopped` so no task touches the slots after
|
||||
/// they are freed.
|
||||
const State = enum(u32) { idle, serving, closing };
|
||||
|
||||
pub const UdpServer = struct {
|
||||
socket: std.Io.net.Socket,
|
||||
handler: *handler.Handler,
|
||||
slots: []Slot,
|
||||
mutex: std.Io.Mutex,
|
||||
stats: Stats,
|
||||
state: std.atomic.Value(State),
|
||||
stopped: std.Io.Event,
|
||||
|
||||
pub const Slot = struct {
|
||||
query: [max_datagram]u8,
|
||||
/// The buffer the handler receives the upstream answer into, so it
|
||||
/// holds a whole DNS message, not a whole datagram. A reply between
|
||||
/// `max_datagram` and `transport.max_message_len` must reach the
|
||||
/// handler intact: only the handler can turn it into the TC=1 reply
|
||||
/// that makes the client retry over TCP (S5.3). A 4096-byte buffer
|
||||
/// would instead fail the exchange with `error.ResponseTooLarge` and
|
||||
/// answer SERVFAIL, which is wrong.
|
||||
///
|
||||
/// The datagram actually sent stays bounded by `udpLimit` inside the
|
||||
/// handler, so nothing larger than 4096 bytes leaves this socket.
|
||||
///
|
||||
/// Cost: 4096 + 65535 ≈ 68 KiB per slot, so the default 64 slots hold
|
||||
/// ≈ 4.3 MiB. The PLAN §18 budget is 100 MB with ~1M blocked domains,
|
||||
/// so this pool takes about 4% of it.
|
||||
reply: [transport.max_message_len]u8,
|
||||
from: std.Io.net.IpAddress,
|
||||
len: usize,
|
||||
/// Guarded by `UdpServer.mutex`.
|
||||
in_use: bool,
|
||||
};
|
||||
|
||||
pub const BindError = std.Io.net.IpAddress.BindError || error{OutOfMemory};
|
||||
|
||||
pub fn bind(
|
||||
gpa: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
address: std.Io.net.IpAddress,
|
||||
h: *handler.Handler,
|
||||
options: Options,
|
||||
) BindError!UdpServer {
|
||||
std.debug.assert(options.max_in_flight > 0);
|
||||
|
||||
const slots = try gpa.alloc(Slot, options.max_in_flight);
|
||||
errdefer gpa.free(slots);
|
||||
for (slots) |*slot| slot.in_use = false;
|
||||
|
||||
const local = address;
|
||||
const socket = try local.bind(io, .{ .mode = .dgram });
|
||||
|
||||
return .{
|
||||
.socket = socket,
|
||||
.handler = h,
|
||||
.slots = slots,
|
||||
.mutex = .init,
|
||||
.stats = .{},
|
||||
.state = .init(.idle),
|
||||
.stopped = .unset,
|
||||
};
|
||||
}
|
||||
|
||||
/// The kernel-assigned address. A port of 0 in `bind` resolves here.
|
||||
pub fn boundAddress(self: *const UdpServer) std.Io.net.IpAddress {
|
||||
return self.socket.address;
|
||||
}
|
||||
|
||||
/// Receive loop. Returns when the task is canceled or `deinit` stops it.
|
||||
pub fn serve(self: *UdpServer, io: std.Io) void {
|
||||
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
self.receiveLoop(io, &group);
|
||||
|
||||
// A reply that is half sent is worse than no reply, so the in-flight
|
||||
// tasks are awaited even when this task is being canceled.
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
_ = io.swapCancelProtection(prev);
|
||||
|
||||
self.stopped.set(io);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *UdpServer, gpa: std.mem.Allocator, io: std.Io) void {
|
||||
// `serve` reads this between receives, so it stops on its own and the
|
||||
// socket is closed only once nothing can be reading from it.
|
||||
if (self.state.swap(.closing, .acq_rel) == .serving) self.stopped.waitUncancelable(io);
|
||||
self.socket.close(io);
|
||||
gpa.free(self.slots);
|
||||
self.* = undefined;
|
||||
}
|
||||
|
||||
fn receiveLoop(self: *UdpServer, io: std.Io, group: *std.Io.Group) void {
|
||||
// A datagram that finds no free slot still has to leave the socket
|
||||
// buffer, or the loop would spin on the same undeliverable packet.
|
||||
var overflow: [max_datagram]u8 = undefined;
|
||||
|
||||
while (self.state.load(.acquire) == .serving) {
|
||||
const claimed = self.claim(io);
|
||||
const buffer = if (claimed) |index| &self.slots[index].query else &overflow;
|
||||
|
||||
const msg = self.socket.receiveTimeout(io, buffer, .{ .duration = poll_interval }) catch |err| {
|
||||
if (claimed) |index| self.release(io, index);
|
||||
switch (err) {
|
||||
error.Canceled => return,
|
||||
// The stop flag is re-read at the top of the loop.
|
||||
error.Timeout => continue,
|
||||
else => {
|
||||
bump(&self.stats.receive_errors);
|
||||
log.debug("udp receive failed: {t}", .{err});
|
||||
// One bad datagram must not kill the listener, and a
|
||||
// persistent error must not turn it into a spin.
|
||||
poll_interval.sleep(io) catch return;
|
||||
continue;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
bump(&self.stats.received);
|
||||
|
||||
const index = claimed orelse {
|
||||
bump(&self.stats.dropped_no_slot);
|
||||
continue;
|
||||
};
|
||||
|
||||
// The kernel already threw the tail away, so the message cannot be
|
||||
// parsed and any answer would be a guess.
|
||||
if (msg.flags.trunc) {
|
||||
bump(&self.stats.dropped_oversize);
|
||||
self.release(io, index);
|
||||
continue;
|
||||
}
|
||||
|
||||
const slot = &self.slots[index];
|
||||
slot.from = msg.from;
|
||||
slot.len = msg.data.len;
|
||||
|
||||
group.concurrent(io, respondOne, .{ self, io, index }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => {
|
||||
bump(&self.stats.dropped_no_slot);
|
||||
self.release(io, index);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
fn respondOne(self: *UdpServer, io: std.Io, index: usize) void {
|
||||
const slot = &self.slots[index];
|
||||
defer self.release(io, index);
|
||||
|
||||
switch (self.handler.handle(io, .udp, slot.query[0..slot.len], &slot.reply)) {
|
||||
.drop => bump(&self.stats.dropped_handler),
|
||||
.reply => |bytes| self.socket.send(io, &slot.from, bytes) catch |err| {
|
||||
bump(&self.stats.send_errors);
|
||||
log.debug("udp send to a client failed: {t}", .{err});
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn claim(self: *UdpServer, io: std.Io) ?usize {
|
||||
// Uncancelable: this section takes no Io and never blocks on a peer, so
|
||||
// it cannot deadlock, and losing the lock mid-update would leak a slot.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const index = firstFree(self.slots) orelse return null;
|
||||
self.slots[index].in_use = true;
|
||||
return index;
|
||||
}
|
||||
|
||||
fn release(self: *UdpServer, io: std.Io, index: usize) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
self.slots[index].in_use = false;
|
||||
}
|
||||
};
|
||||
|
||||
/// The capacity rule, without the mutex, so it is testable without a backend.
|
||||
fn firstFree(slots: []const UdpServer.Slot) ?usize {
|
||||
for (slots, 0..) |*slot, index| {
|
||||
if (!slot.in_use) return index;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
fn bump(counter: *std.atomic.Value(u64)) void {
|
||||
_ = counter.fetchAdd(1, .monotonic);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn testSlots(count: usize) ![]UdpServer.Slot {
|
||||
const slots = try testing.allocator.alloc(UdpServer.Slot, count);
|
||||
for (slots) |*slot| slot.in_use = false;
|
||||
return slots;
|
||||
}
|
||||
|
||||
test "the slot pool hands out every index once" {
|
||||
const slots = try testSlots(3);
|
||||
defer testing.allocator.free(slots);
|
||||
|
||||
for (0..slots.len) |expected| {
|
||||
const index = firstFree(slots) orelse return error.TestUnexpectedResult;
|
||||
try testing.expectEqual(expected, index);
|
||||
slots[index].in_use = true;
|
||||
}
|
||||
}
|
||||
|
||||
test "a full slot pool refuses instead of growing" {
|
||||
const slots = try testSlots(2);
|
||||
defer testing.allocator.free(slots);
|
||||
|
||||
for (slots) |*slot| slot.in_use = true;
|
||||
try testing.expectEqual(@as(?usize, null), firstFree(slots));
|
||||
}
|
||||
|
||||
test "a slot's reply buffer holds a whole DNS message" {
|
||||
const slots = try testSlots(1);
|
||||
defer testing.allocator.free(slots);
|
||||
|
||||
// The handler asserts both ends of this range on the buffer it is given,
|
||||
// and an upstream reply of up to 65535 bytes has to fit so the handler can
|
||||
// truncate it instead of the exchange failing.
|
||||
try testing.expectEqual(@as(usize, transport.max_message_len), slots[0].reply.len);
|
||||
try testing.expect(slots[0].reply.len >= handler.udp_limit_min);
|
||||
try testing.expect(slots[0].reply.len > max_datagram);
|
||||
}
|
||||
|
||||
test "the default slot pool stays inside the memory budget" {
|
||||
// 4096 + 65535 ≈ 68 KiB per slot; 64 slots ≈ 4.3 MiB, against the 100 MB
|
||||
// of PLAN §18.
|
||||
const options: Options = .{};
|
||||
const pool_bytes = @sizeOf(UdpServer.Slot) * @as(usize, options.max_in_flight);
|
||||
try testing.expect(pool_bytes < 8 * 1024 * 1024);
|
||||
}
|
||||
|
||||
test "a released slot is handed out again" {
|
||||
const slots = try testSlots(2);
|
||||
defer testing.allocator.free(slots);
|
||||
|
||||
for (slots) |*slot| slot.in_use = true;
|
||||
slots[1].in_use = false;
|
||||
try testing.expectEqual(@as(?usize, 1), firstFree(slots));
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Loopback tests for `udp_server.zig`.
|
||||
//!
|
||||
//! This lives in its own file because it needs `@import("build_options")`, which
|
||||
//! only exists when the compilation is driven by build.zig. The body is compiled
|
||||
//! by every `zig build test` run, so it cannot rot, and skips at run time unless
|
||||
//! `-Dintegration` is passed.
|
||||
//!
|
||||
//! Hermetic: two sockets on 127.0.0.1 and an in-process fake upstream. Nothing
|
||||
//! leaves the machine, and every wait carries a budget.
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
const net = std.Io.net;
|
||||
|
||||
const handler = @import("handler.zig");
|
||||
const udp_server = @import("udp_server.zig");
|
||||
const header = @import("../dns/header.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const types = @import("../dns/types.zig");
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// Long enough that a loopback round trip cannot lose to scheduling, short
|
||||
/// enough that a broken server fails the run instead of hanging it.
|
||||
const budget: std.Io.Timeout = .{ .duration = .{ .raw = .fromSeconds(5), .clock = .awake } };
|
||||
|
||||
/// How long the test waits to be convinced that no reply is coming. A dropped
|
||||
/// datagram produces nothing, so this budget is spent in full on every run.
|
||||
const silence: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(300), .clock = .awake } };
|
||||
|
||||
/// 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";
|
||||
|
||||
/// Answers from a fixture and rewrites the ID, which is all the server needs
|
||||
/// from an upstream. The real clients are exercised by their own tests.
|
||||
const FakeUpstream = struct {
|
||||
reply: []const u8,
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *FakeUpstream = @ptrCast(@alignCast(ptr));
|
||||
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];
|
||||
packet.setId(bytes, (header.parse(query) catch unreachable).id);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
fn client(self: *FakeUpstream) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
fn expectAnswersQuery(reply: []const u8) !void {
|
||||
const p = try packet.parse(reply);
|
||||
try testing.expectEqual(@as(u16, 0x1234), p.header.id);
|
||||
try testing.expectEqual(true, p.header.flags.qr);
|
||||
try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode);
|
||||
try testing.expectEqual(@as(u16, 1), p.header.qdcount);
|
||||
|
||||
const echoed = packet.firstQuestion(p) orelse return error.TestMissingQuestion;
|
||||
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);
|
||||
}
|
||||
|
||||
test "a udp query is answered on the loopback" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var client = try client_address.bind(io, .{ .mode = .dgram });
|
||||
defer client.close(io);
|
||||
|
||||
try client.send(io, &server_address, query_bytes);
|
||||
|
||||
var buf: [udp_server.max_datagram]u8 = undefined;
|
||||
const msg = try client.receiveTimeout(io, &buf, budget);
|
||||
try expectAnswersQuery(msg.data);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.received.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), server.stats.send_errors.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.queries.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
test "a runt datagram is dropped and no reply is sent" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var client = try client_address.bind(io, .{ .mode = .dgram });
|
||||
defer client.close(io);
|
||||
|
||||
try client.send(io, &server_address, query_bytes[0..5]);
|
||||
|
||||
var buf: [udp_server.max_datagram]u8 = undefined;
|
||||
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.received.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 1), h.stats.dropped_malformed.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
test "an oversize datagram arrives truncated and is dropped" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
const server_address = server.boundAddress();
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
|
||||
|
||||
const client_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var client = try client_address.bind(io, .{ .mode = .dgram });
|
||||
defer client.close(io);
|
||||
|
||||
// The server receives into a `max_datagram` buffer, so the kernel discards
|
||||
// the tail of this one and reports it through `flags.trunc`.
|
||||
const oversize = try gpa.alloc(u8, udp_server.max_datagram * 2);
|
||||
defer gpa.free(oversize);
|
||||
@memcpy(oversize[0..query_bytes.len], query_bytes);
|
||||
@memset(oversize[query_bytes.len..], 0);
|
||||
try client.send(io, &server_address, oversize);
|
||||
|
||||
var buf: [udp_server.max_datagram]u8 = undefined;
|
||||
try testing.expectError(error.Timeout, client.receiveTimeout(io, &buf, silence));
|
||||
try testing.expectEqual(@as(u64, 1), server.stats.dropped_oversize.load(.monotonic));
|
||||
try testing.expectEqual(@as(u64, 0), h.stats.queries.load(.monotonic));
|
||||
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
test "deinit ends a serve loop that is blocked on receive" {
|
||||
if (!build_options.integration) return error.SkipZigTest;
|
||||
|
||||
const gpa = testing.allocator;
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: FakeUpstream = .{ .reply = response_bytes };
|
||||
var h: handler.Handler = .{ .upstream = fake.client() };
|
||||
|
||||
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
|
||||
var server = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
|
||||
|
||||
var group: std.Io.Group = .init;
|
||||
try group.concurrent(io, udp_server.UdpServer.serve, .{ &server, io });
|
||||
|
||||
// No datagram ever arrives, so `serve` is inside a receive when this runs.
|
||||
server.deinit(gpa, io);
|
||||
group.await(io) catch |err| switch (err) {
|
||||
error.Canceled => unreachable,
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,19 @@ comptime {
|
||||
_ = @import("platform/tls_client.zig");
|
||||
_ = @import("platform/tls_client_integration_test.zig");
|
||||
_ = @import("platform/tls_server.zig");
|
||||
_ = @import("upstream/transport.zig");
|
||||
_ = @import("upstream/health.zig");
|
||||
_ = @import("upstream/doh_client.zig");
|
||||
_ = @import("upstream/doh_client_live_test.zig");
|
||||
_ = @import("upstream/pool.zig");
|
||||
_ = @import("upstream/dot_client.zig");
|
||||
_ = @import("upstream/dot_client_live_test.zig");
|
||||
_ = @import("server/handler.zig");
|
||||
_ = @import("server/udp_server.zig");
|
||||
_ = @import("server/tcp_server.zig");
|
||||
_ = @import("server/udp_server_integration_test.zig");
|
||||
_ = @import("server/tcp_server_integration_test.zig");
|
||||
_ = @import("server/resolver_integration_test.zig");
|
||||
}
|
||||
|
||||
extern fn sqlite3_libversion() [*:0]const u8;
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
//! RFC 8484 DNS over HTTPS upstream client.
|
||||
//!
|
||||
//! One `DohClient` wraps a caller-owned `std.http.Client`, which owns the
|
||||
//! connection pool and the CA bundle. Several `DohClient` values can share one
|
||||
//! `std.http.Client`, so a pool of DoH upstreams keeps one TLS connection per
|
||||
//! host without this file knowing anything about pooling.
|
||||
//!
|
||||
//! HTTP/1.1 only — HTTP/2 is permanently out of scope (PLAN §2.2).
|
||||
//!
|
||||
//! Every buffer is caller-owned. Nothing here allocates, so an exchange cannot
|
||||
//! fail for a reason this file invented.
|
||||
|
||||
const std = @import("std");
|
||||
const transport = @import("transport.zig");
|
||||
|
||||
pub const media_type = "application/dns-message";
|
||||
|
||||
/// The smallest `request_buf` that can hold a real query: a header, a
|
||||
/// maximum-length name, qtype/qclass and an OPT record all fit in 512 bytes.
|
||||
pub const min_request_buf = 512;
|
||||
pub const min_transfer_buf = 1024;
|
||||
|
||||
pub const DohClient = struct {
|
||||
/// Caller-owned; shared across endpoints, pools connections.
|
||||
http: *std.http.Client,
|
||||
endpoint: transport.Endpoint,
|
||||
/// Built once in `init` from `endpoint`.
|
||||
uri: std.Uri,
|
||||
/// Caller-owned. `Request.sendBodyComplete` takes `[]u8`, so the query has
|
||||
/// to be copied out of the caller's const slice before it can be sent.
|
||||
request_buf: []u8,
|
||||
/// Caller-owned HTTP body transfer buffer.
|
||||
transfer_buf: []u8,
|
||||
|
||||
pub const InitError = error{BadUrl};
|
||||
|
||||
/// `endpoint` stays the source of truth for host, port and scheme in logs;
|
||||
/// `uri` exists only because `std.http.Client.request` takes one.
|
||||
///
|
||||
/// A non-DoH endpoint is `error.BadUrl` rather than an assert: the scheme
|
||||
/// comes from configuration, so wiring a `tls://` URL to this client is a
|
||||
/// config fault that must surface as a value, not a panic.
|
||||
pub fn init(
|
||||
http: *std.http.Client,
|
||||
endpoint: transport.Endpoint,
|
||||
request_buf: []u8,
|
||||
transfer_buf: []u8,
|
||||
) InitError!DohClient {
|
||||
std.debug.assert(request_buf.len >= min_request_buf);
|
||||
std.debug.assert(transfer_buf.len >= min_transfer_buf);
|
||||
if (endpoint.scheme != .doh) return error.BadUrl;
|
||||
const uri = std.Uri.parse(endpoint.url) catch return error.BadUrl;
|
||||
return .{
|
||||
.http = http,
|
||||
.endpoint = endpoint,
|
||||
.uri = uri,
|
||||
.request_buf = request_buf,
|
||||
.transfer_buf = transfer_buf,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn client(self: *DohClient) 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: *DohClient = @ptrCast(@alignCast(ptr));
|
||||
return self.exchange(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// Returns a prefix of `response_buf` holding a message that has already
|
||||
/// passed `transport.validateResponse` against `query`.
|
||||
///
|
||||
/// The query ID is sent unchanged. RFC 8484 §4.1 suggests ID 0 so that HTTP
|
||||
/// caches can share a response; nxdns puts no HTTP cache in this path, and
|
||||
/// keeping the ID preserves the request/response binding that
|
||||
/// `validateResponse` checks.
|
||||
pub fn exchange(
|
||||
self: *DohClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
// `std.http.Client` carries the `std.Io` it was constructed with and
|
||||
// takes none per request, so the interface's `io` is unused here. It
|
||||
// stays in the signature because DoT and the pool need it.
|
||||
_ = io;
|
||||
|
||||
if (query.len > self.request_buf.len) return error.BufferTooSmall;
|
||||
@memcpy(self.request_buf[0..query.len], query);
|
||||
|
||||
var req = self.http.request(.POST, self.uri, .{
|
||||
.keep_alive = true,
|
||||
.redirect_behavior = .not_allowed,
|
||||
.headers = .{
|
||||
.content_type = .{ .override = media_type },
|
||||
// A compressed body would need the decompressing reader and
|
||||
// would stop being byte-exact, which `validateResponse` needs.
|
||||
.accept_encoding = .{ .override = "identity" },
|
||||
},
|
||||
// `Request.Headers` has no `accept` field, so this one goes in by
|
||||
// hand.
|
||||
.extra_headers = &.{.{ .name = "accept", .value = media_type }},
|
||||
}) catch |err| return mapError(err, .connect);
|
||||
defer req.deinit();
|
||||
|
||||
req.sendBodyComplete(self.request_buf[0..query.len]) catch |err|
|
||||
return mapError(err, .send);
|
||||
|
||||
// An empty redirect buffer is legal under `.not_allowed`: a redirect
|
||||
// is an error before the location is ever read.
|
||||
var resp = req.receiveHead(&.{}) catch |err| return mapError(err, .receive);
|
||||
|
||||
if (resp.head.status != .ok) return error.HttpStatus;
|
||||
// `head.content_type` points into memory that `resp.reader` invalidates,
|
||||
// so the check happens before the body stream starts.
|
||||
if (!contentTypeOk(resp.head.content_type)) return error.HttpContentType;
|
||||
if (resp.head.content_length) |declared| {
|
||||
if (declared > response_buf.len) return error.ResponseTooLarge;
|
||||
}
|
||||
|
||||
const body = resp.reader(self.transfer_buf);
|
||||
var len: usize = 0;
|
||||
var ended = false;
|
||||
while (len < response_buf.len) {
|
||||
const n = body.readSliceShort(response_buf[len..]) catch |err|
|
||||
return mapError(err, .receive);
|
||||
len += n;
|
||||
if (n == 0) {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ended) {
|
||||
// `response_buf` filled exactly. One more read separates a message
|
||||
// that fits from one that was cut off.
|
||||
var probe: [1]u8 = undefined;
|
||||
const n = body.readSliceShort(&probe) catch |err| return mapError(err, .receive);
|
||||
if (n != 0) return error.ResponseTooLarge;
|
||||
}
|
||||
|
||||
try transport.validateResponse(query, response_buf[0..len]);
|
||||
return response_buf[0..len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Which call failed. The phase is what decides the peer fault, and only the
|
||||
/// call site knows it — guessing it from an error name would be wrong the first
|
||||
/// time two phases shared an error.
|
||||
const Phase = enum { connect, send, receive };
|
||||
|
||||
fn mapError(err: anyerror, phase: Phase) transport.ExchangeError {
|
||||
if (transport.mapLocal(err)) |local| return local;
|
||||
const err_name = @errorName(err);
|
||||
if (std.mem.startsWith(u8, err_name, "Tls") or
|
||||
std.mem.startsWith(u8, err_name, "Certificate")) return error.TlsFailed;
|
||||
return switch (phase) {
|
||||
.connect => error.ConnectFailed,
|
||||
.send => error.SendFailed,
|
||||
.receive => error.ReceiveFailed,
|
||||
};
|
||||
}
|
||||
|
||||
/// RFC 8484 §6: the response media type is `application/dns-message`. The
|
||||
/// header may carry parameters (`; charset=…`) and the type is case-insensitive
|
||||
/// per RFC 9110 §8.3.1.
|
||||
pub fn contentTypeOk(value: ?[]const u8) bool {
|
||||
const raw = value orelse return false;
|
||||
const without_params = if (std.mem.findScalar(u8, raw, ';')) |semi| raw[0..semi] else raw;
|
||||
return std.ascii.eqlIgnoreCase(std.mem.trim(u8, without_params, " \t"), media_type);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "init rejects a DoT endpoint before any io" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("tls://dns.google:853");
|
||||
try testing.expectError(
|
||||
error.BadUrl,
|
||||
DohClient.init(&http, endpoint, &request_buf, &transfer_buf),
|
||||
);
|
||||
}
|
||||
|
||||
test "init builds a uri from the endpoint url" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
||||
const doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
||||
var component_buf: [256]u8 = undefined;
|
||||
try testing.expectEqualStrings("https", doh.uri.scheme);
|
||||
try testing.expectEqualStrings("dns.example", try doh.uri.host.?.toRaw(&component_buf));
|
||||
try testing.expectEqualStrings("/dns-query", try doh.uri.path.toRaw(&component_buf));
|
||||
try testing.expectEqualStrings("dns.example", doh.endpoint.host);
|
||||
}
|
||||
|
||||
test "DohClient satisfies the transport.Client interface" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
||||
var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
||||
|
||||
// Instantiation is the check: the vtable is built from `exchangeFn`, so a
|
||||
// signature drift is a compile error here. The `std.http.Client` above is
|
||||
// never driven, and no exchange runs.
|
||||
const c: transport.Client = doh.client();
|
||||
try testing.expectEqual(@as(*anyopaque, @ptrCast(&doh)), c.ptr);
|
||||
try testing.expectEqual(
|
||||
@as(@TypeOf(c.exchangeFn), DohClient.exchangeFn),
|
||||
c.exchangeFn,
|
||||
);
|
||||
}
|
||||
|
||||
test "exchange rejects a query larger than the request buffer" {
|
||||
var http: std.http.Client = undefined;
|
||||
var request_buf: [min_request_buf]u8 = undefined;
|
||||
var transfer_buf: [min_transfer_buf]u8 = undefined;
|
||||
const endpoint = try transport.Endpoint.parse("https://dns.example/dns-query");
|
||||
var doh = try DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
||||
|
||||
const oversized: [min_request_buf + 1]u8 = @splat(0);
|
||||
var response_buf: [512]u8 = undefined;
|
||||
// The size check precedes every use of `http`, so nothing is driven.
|
||||
try testing.expectError(
|
||||
error.BufferTooSmall,
|
||||
doh.exchange(undefined, &oversized, &response_buf),
|
||||
);
|
||||
}
|
||||
|
||||
test "contentTypeOk accepts the RFC 8484 media type" {
|
||||
try testing.expect(contentTypeOk("application/dns-message"));
|
||||
try testing.expect(contentTypeOk("Application/DNS-Message"));
|
||||
try testing.expect(contentTypeOk("application/dns-message; charset=utf-8"));
|
||||
try testing.expect(contentTypeOk("application/dns-message ; charset=utf-8"));
|
||||
try testing.expect(contentTypeOk(" application/dns-message "));
|
||||
}
|
||||
|
||||
test "contentTypeOk rejects anything else" {
|
||||
try testing.expect(!contentTypeOk(null));
|
||||
try testing.expect(!contentTypeOk("text/html"));
|
||||
try testing.expect(!contentTypeOk("application/json"));
|
||||
try testing.expect(!contentTypeOk(""));
|
||||
try testing.expect(!contentTypeOk("application/dns-message-extra"));
|
||||
}
|
||||
|
||||
test "mapError maps local errors before phase errors" {
|
||||
try testing.expectEqual(error.OutOfMemory, mapError(error.OutOfMemory, .connect));
|
||||
try testing.expectEqual(error.Canceled, mapError(error.Canceled, .receive));
|
||||
try testing.expectEqual(error.Unexpected, mapError(error.Unexpected, .send));
|
||||
}
|
||||
|
||||
test "mapError maps tls errors regardless of phase" {
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsInitializationFailed, .connect));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.TlsAlert, .receive));
|
||||
try testing.expectEqual(error.TlsFailed, mapError(error.CertificateExpired, .connect));
|
||||
}
|
||||
|
||||
test "mapError maps remaining errors by phase" {
|
||||
try testing.expectEqual(error.ConnectFailed, mapError(error.ConnectionRefused, .connect));
|
||||
try testing.expectEqual(error.SendFailed, mapError(error.WriteFailed, .send));
|
||||
try testing.expectEqual(error.ReceiveFailed, mapError(error.ReadFailed, .receive));
|
||||
try testing.expectEqual(error.ReceiveFailed, mapError(error.HttpHeadersInvalid, .receive));
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Network-dependent test for `doh_client.zig`.
|
||||
//!
|
||||
//! It lives in its own file because it needs `@import("build_options")`, which
|
||||
//! only exists when the compilation is driven by build.zig. The test is
|
||||
//! compiled by every `zig build test` run, so it cannot rot, and it skips at run
|
||||
//! time unless `-Dlive` is passed. (`-Dintegration` stays hermetic: loopback
|
||||
//! only. `-Dlive` is the gate for tests that leave the machine.)
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
|
||||
const doh_client = @import("doh_client.zig");
|
||||
const transport = @import("transport.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
|
||||
/// No HTTP call in 0.16.0 takes a deadline, so the whole exchange runs as one
|
||||
/// task raced against a sleep and the loser is canceled.
|
||||
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
|
||||
|
||||
const Outcome = union(enum) {
|
||||
exchange: anyerror!usize,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
/// A query for example.com A: id 0x1234, RD set, one question.
|
||||
const query_bytes =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
|
||||
const Params = struct {
|
||||
gpa: std.mem.Allocator,
|
||||
response_buf: []u8,
|
||||
};
|
||||
|
||||
/// Returns the reply length; the bytes stay in the caller's `response_buf` so
|
||||
/// they outlive this task.
|
||||
fn runExchange(io: std.Io, params: Params) anyerror!usize {
|
||||
var http: std.http.Client = .{ .allocator = params.gpa, .io = io };
|
||||
defer http.deinit();
|
||||
|
||||
var request_buf: [1024]u8 = undefined;
|
||||
var transfer_buf: [4096]u8 = undefined;
|
||||
|
||||
const endpoint = try transport.Endpoint.parse("https://cloudflare-dns.com/dns-query");
|
||||
var doh = try doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf);
|
||||
|
||||
const reply = try doh.client().exchange(io, query_bytes, params.response_buf);
|
||||
return reply.len;
|
||||
}
|
||||
|
||||
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return duration.sleep(io);
|
||||
}
|
||||
|
||||
test "live DoH exchange against cloudflare-dns.com" {
|
||||
if (!build_options.live) return error.SkipZigTest;
|
||||
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var response_buf: [4096]u8 = undefined;
|
||||
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
defer race.cancelDiscard();
|
||||
|
||||
try race.concurrent(.exchange, runExchange, .{ io, Params{
|
||||
.gpa = gpa,
|
||||
.response_buf = &response_buf,
|
||||
} });
|
||||
try race.concurrent(.expiry, expire, .{ io, budget });
|
||||
|
||||
const len = switch (try race.await()) {
|
||||
.exchange => |result| result catch |err| {
|
||||
std.debug.print("DoH exchange failed: {s}\n", .{@errorName(err)});
|
||||
return err;
|
||||
},
|
||||
.expiry => |result| {
|
||||
try result;
|
||||
return error.DohExchangeTimedOut;
|
||||
},
|
||||
};
|
||||
|
||||
// `exchange` already ran `validateResponse`; re-run it so the assertion is
|
||||
// in the test and not only in the code under test.
|
||||
try transport.validateResponse(query_bytes, response_buf[0..len]);
|
||||
|
||||
const reply = try packet.parse(response_buf[0..len]);
|
||||
try std.testing.expect(reply.header.ancount >= 1);
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! DNS over TLS upstream client (RFC 7858).
|
||||
//!
|
||||
//! DoT is DNS over TCP with a TLS layer in between: the same 2-byte big-endian
|
||||
//! length prefix as RFC 1035 §4.2.2, carried on the plaintext side of the TLS
|
||||
//! stream. All TLS work goes through `platform/tls_client.zig`; this file never
|
||||
//! touches `std.crypto.tls.Client` directly.
|
||||
//!
|
||||
//! Every failure is classified by the phase it happened in — connect, handshake,
|
||||
//! send, receive — after `transport.mapLocal` has had a chance to claim it. A
|
||||
//! local resource error or a cancellation must never reach the pool as a peer
|
||||
//! fault, so the concrete error is unwrapped from `error.ReadFailed` /
|
||||
//! `error.WriteFailed` before it is classified.
|
||||
|
||||
const std = @import("std");
|
||||
const net = std.Io.net;
|
||||
const tls = std.crypto.tls;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
|
||||
const transport = @import("transport.zig");
|
||||
const tls_client = @import("../platform/tls_client.zig");
|
||||
|
||||
const log = std.log.scoped(.dot_client);
|
||||
|
||||
/// RFC 1035 §4.2.2 length prefix, shared by DNS over TCP and DNS over TLS.
|
||||
pub const prefix_len = 2;
|
||||
|
||||
pub fn framePrefix(len: u16) [prefix_len]u8 {
|
||||
var out: [prefix_len]u8 = undefined;
|
||||
std.mem.writeInt(u16, &out, len, .big);
|
||||
return out;
|
||||
}
|
||||
|
||||
pub fn parsePrefix(bytes: [prefix_len]u8) u16 {
|
||||
return std.mem.readInt(u16, &bytes, .big);
|
||||
}
|
||||
|
||||
pub const ResolveError = error{ConnectFailed};
|
||||
|
||||
/// DoT endpoints take IP literals. Name resolution for upstreams is out of
|
||||
/// scope for this milestone, and resolving silently would hide a config error
|
||||
/// behind a slow, confusing failure, so a non-literal host fails immediately.
|
||||
pub fn resolveAddress(endpoint: transport.Endpoint) ResolveError!net.IpAddress {
|
||||
return net.IpAddress.parse(endpoint.host, endpoint.port) catch error.ConnectFailed;
|
||||
}
|
||||
|
||||
pub const DotClient = struct {
|
||||
endpoint: transport.Endpoint,
|
||||
gpa: std.mem.Allocator,
|
||||
/// Caller-owned, shared across endpoints.
|
||||
bundle: *Certificate.Bundle,
|
||||
/// Caller-owned, guards `bundle`.
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
/// Caller-owned. One `DotClient` is used by one task at a time.
|
||||
buffers: Buffers,
|
||||
|
||||
pub const Buffers = struct {
|
||||
/// Plaintext read buffer.
|
||||
tls_read: []u8,
|
||||
/// Plaintext write buffer.
|
||||
tls_write: []u8,
|
||||
/// Ciphertext read buffer.
|
||||
stream_read: []u8,
|
||||
/// Ciphertext write buffer.
|
||||
stream_write: []u8,
|
||||
};
|
||||
|
||||
/// A `.doh` endpoint or an undersized buffer is a wiring bug in this
|
||||
/// process, not a runtime condition, so both are assertions.
|
||||
pub fn init(
|
||||
endpoint: transport.Endpoint,
|
||||
gpa: std.mem.Allocator,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
buffers: Buffers,
|
||||
) DotClient {
|
||||
std.debug.assert(endpoint.scheme == .dot);
|
||||
std.debug.assert(buffers.tls_read.len >= tls.Client.min_buffer_len);
|
||||
std.debug.assert(buffers.tls_write.len >= tls.Client.min_buffer_len);
|
||||
std.debug.assert(buffers.stream_read.len >= tls.Client.min_buffer_len);
|
||||
std.debug.assert(buffers.stream_write.len >= tls.Client.min_buffer_len);
|
||||
return .{
|
||||
.endpoint = endpoint,
|
||||
.gpa = gpa,
|
||||
.bundle = bundle,
|
||||
.bundle_lock = bundle_lock,
|
||||
.buffers = buffers,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn client(self: *DotClient) 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: *DotClient = @ptrCast(@alignCast(ptr));
|
||||
return self.exchange(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// One TCP connection and one TLS handshake per exchange, both closed
|
||||
/// before returning.
|
||||
///
|
||||
/// Connection reuse is deliberately not built. At household query rates the
|
||||
/// saved round trips are worth less than what a per-exchange connection
|
||||
/// buys: the pool's per-attempt budget stays a plain race against one task,
|
||||
/// the failover path never has to reason about a half-dead pooled socket,
|
||||
/// and every failure is attributable to exactly one exchange.
|
||||
pub fn exchange(
|
||||
self: *DotClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
// The length prefix is 16-bit, so a longer query cannot be framed. No
|
||||
// listener in this process can produce one; a caller that does gets a
|
||||
// local error rather than a silently truncated frame.
|
||||
if (query.len > transport.max_message_len) return error.BufferTooSmall;
|
||||
|
||||
const address = resolveAddress(self.endpoint) catch |err| {
|
||||
log.warn("dot upstream {s}: host \"{s}\" is not an IP literal", .{
|
||||
self.endpoint.url,
|
||||
self.endpoint.host,
|
||||
});
|
||||
return err;
|
||||
};
|
||||
|
||||
try self.ensureBundle(io);
|
||||
|
||||
var stream = address.connect(io, .{ .mode = .stream }) catch |err| {
|
||||
log.debug("dot upstream {s}: connect failed: {s}", .{
|
||||
self.endpoint.url,
|
||||
@errorName(err),
|
||||
});
|
||||
return mapPhase(err, error.ConnectFailed);
|
||||
};
|
||||
defer closeStream(io, &stream);
|
||||
|
||||
// `TlsStream` is pinned: it holds its reader and writer by value and the
|
||||
// TLS client points at them, so it must not move after `init`.
|
||||
var tls_stream: tls_client.TlsStream = undefined;
|
||||
// `concreteHandshake` reads these two fields when the handshake reports
|
||||
// `error.ReadFailed` / `error.WriteFailed`. `TlsStream.init` sets them
|
||||
// before it can produce either error, but clearing them here keeps that
|
||||
// out of this file's correctness argument.
|
||||
tls_stream.stream_reader.err = null;
|
||||
tls_stream.stream_writer.err = null;
|
||||
tls_stream.init(io, &stream, self.bundle, self.bundle_lock, self.gpa, .{
|
||||
.host = self.endpoint.host,
|
||||
.ca = .system,
|
||||
.read_buffer = self.buffers.tls_read,
|
||||
.write_buffer = self.buffers.tls_write,
|
||||
.stream_read_buffer = self.buffers.stream_read,
|
||||
.stream_write_buffer = self.buffers.stream_write,
|
||||
}) catch |err| {
|
||||
const cause = concreteHandshake(&tls_stream, err);
|
||||
log.warn("dot upstream {s}: TLS handshake failed: {s} ({t})", .{
|
||||
self.endpoint.url,
|
||||
@errorName(cause),
|
||||
tls_client.classify(cause),
|
||||
});
|
||||
return mapPhase(cause, error.TlsFailed);
|
||||
};
|
||||
defer closeTls(io, &tls_stream);
|
||||
|
||||
const writer = tls_stream.writer();
|
||||
const prefix = framePrefix(@intCast(query.len));
|
||||
writer.writeAll(&prefix) catch |err| return sendFailure(&tls_stream, err);
|
||||
writer.writeAll(query) catch |err| return sendFailure(&tls_stream, err);
|
||||
writer.flush() catch |err| return sendFailure(&tls_stream, err);
|
||||
|
||||
const reader = tls_stream.reader();
|
||||
var prefix_bytes: [prefix_len]u8 = undefined;
|
||||
reader.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&tls_stream, err);
|
||||
|
||||
const len = parsePrefix(prefix_bytes);
|
||||
if (len == 0) return error.BadResponse;
|
||||
if (len > response_buf.len) return error.ResponseTooLarge;
|
||||
reader.readSliceAll(response_buf[0..len]) catch |err|
|
||||
return receiveFailure(&tls_stream, err);
|
||||
|
||||
try transport.validateResponse(query, response_buf[0..len]);
|
||||
return response_buf[0..len];
|
||||
}
|
||||
|
||||
/// Loads the system CA bundle before the handshake, so that a failure to
|
||||
/// read it keeps its concrete cause.
|
||||
///
|
||||
/// `TlsStream.init` loads the bundle as well and returns early once
|
||||
/// `bundle` holds entries, so this runs the scan at most once per process.
|
||||
/// It exists because `TlsStream.init` folds every scan failure except
|
||||
/// cancellation into `error.CertificateBundleLoadFailure`. That name cannot
|
||||
/// tell an `error.OutOfMemory` from a corrupt PEM file, and the first is a
|
||||
/// local resource failure that must not count against the upstream's
|
||||
/// health. Scanning here keeps the concrete error for `mapPhase`.
|
||||
fn ensureBundle(self: *DotClient, io: std.Io) transport.ExchangeError!void {
|
||||
{
|
||||
try self.bundle_lock.lockShared(io);
|
||||
defer self.bundle_lock.unlockShared(io);
|
||||
if (self.bundle.map.count() != 0) return;
|
||||
}
|
||||
|
||||
try self.bundle_lock.lock(io);
|
||||
defer self.bundle_lock.unlock(io);
|
||||
if (self.bundle.map.count() != 0) return;
|
||||
|
||||
// A partial scan leaves entries in `map`, which the check above would
|
||||
// read as "already loaded". Reset so the next exchange scans again.
|
||||
self.bundle.rescan(self.gpa, io, std.Io.Clock.real.now(io)) catch |err| {
|
||||
self.bundle.deinit(self.gpa);
|
||||
self.bundle.* = .empty;
|
||||
log.warn("dot upstream {s}: CA bundle load failed: {s}", .{
|
||||
self.endpoint.url,
|
||||
@errorName(err),
|
||||
});
|
||||
return mapPhase(err, error.TlsFailed);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// The pool cancels this task when the attempt budget expires. The next
|
||||
/// cancelable `Io` call in the `defer` chain would then return `error.Canceled`
|
||||
/// and skip the close, leaking the socket, so the close runs with cancellation
|
||||
/// blocked.
|
||||
fn closeStream(io: std.Io, stream: *net.Stream) void {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
stream.close(io);
|
||||
}
|
||||
|
||||
fn closeTls(io: std.Io, stream: *tls_client.TlsStream) void {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
stream.close();
|
||||
}
|
||||
|
||||
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
|
||||
return transport.mapLocal(err) orelse phase;
|
||||
}
|
||||
|
||||
/// The handshake reads and writes through the socket reader and writer, so a
|
||||
/// cancelled or resource-starved handshake surfaces as `error.ReadFailed` /
|
||||
/// `error.WriteFailed` with the cause stashed on those two. Without this,
|
||||
/// `error.Canceled` and `error.SystemResources` would reach the pool as
|
||||
/// `error.TlsFailed` and count against the upstream's health.
|
||||
///
|
||||
/// Only the socket reader and writer are consulted: `tls.Client.init` returns
|
||||
/// its error before `TlsStream.client` is assigned, so `client.read_err` does
|
||||
/// not exist yet on this path.
|
||||
fn concreteHandshake(stream: *tls_client.TlsStream, err: anyerror) anyerror {
|
||||
return switch (err) {
|
||||
error.ReadFailed => stream.stream_reader.err orelse err,
|
||||
error.WriteFailed => stream.stream_writer.err orelse err,
|
||||
else => err,
|
||||
};
|
||||
}
|
||||
|
||||
/// `Io.Reader` collapses everything to `error.ReadFailed` and stashes the cause.
|
||||
/// Unwrapping it is what keeps `error.Canceled` and the local resource errors
|
||||
/// out of the health counters.
|
||||
fn concreteRead(stream: *tls_client.TlsStream, err: anyerror) anyerror {
|
||||
if (err != error.ReadFailed) return err;
|
||||
if (stream.client.read_err) |cause| return cause;
|
||||
if (stream.stream_reader.err) |cause| return cause;
|
||||
return err;
|
||||
}
|
||||
|
||||
fn concreteWrite(stream: *tls_client.TlsStream, err: anyerror) anyerror {
|
||||
if (err != error.WriteFailed) return err;
|
||||
if (stream.stream_writer.err) |cause| return cause;
|
||||
return err;
|
||||
}
|
||||
|
||||
fn sendFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
|
||||
return mapPhase(concreteWrite(stream, err), error.SendFailed);
|
||||
}
|
||||
|
||||
fn receiveFailure(stream: *tls_client.TlsStream, err: anyerror) transport.ExchangeError {
|
||||
return mapPhase(concreteRead(stream, err), error.ReceiveFailed);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "framePrefix writes the length big-endian" {
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x00 }, &framePrefix(0));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x00, 0x1d }, &framePrefix(29));
|
||||
try testing.expectEqualSlices(u8, &.{ 0x01, 0x00 }, &framePrefix(256));
|
||||
try testing.expectEqualSlices(u8, &.{ 0xff, 0xff }, &framePrefix(65535));
|
||||
}
|
||||
|
||||
test "parsePrefix reads the length big-endian" {
|
||||
try testing.expectEqual(@as(u16, 0), parsePrefix(.{ 0x00, 0x00 }));
|
||||
try testing.expectEqual(@as(u16, 29), parsePrefix(.{ 0x00, 0x1d }));
|
||||
try testing.expectEqual(@as(u16, 256), parsePrefix(.{ 0x01, 0x00 }));
|
||||
try testing.expectEqual(@as(u16, 65535), parsePrefix(.{ 0xff, 0xff }));
|
||||
}
|
||||
|
||||
test "framePrefix and parsePrefix round-trip" {
|
||||
const cases = [_]u16{ 0, 1, 12, 512, 4096, 65534, 65535 };
|
||||
for (cases) |len| {
|
||||
try testing.expectEqual(len, parsePrefix(framePrefix(len)));
|
||||
}
|
||||
}
|
||||
|
||||
test "resolveAddress accepts IP literals" {
|
||||
const v4 = try resolveAddress(try .parse("tls://1.1.1.1:853"));
|
||||
try testing.expectEqual(@as(u16, 853), v4.ip4.port);
|
||||
try testing.expectEqualSlices(u8, &.{ 1, 1, 1, 1 }, &v4.ip4.bytes);
|
||||
|
||||
const v6 = try resolveAddress(try .parse("tls://[2606:4700:4700::1111]"));
|
||||
try testing.expectEqual(@as(u16, transport.dot_default_port), v6.ip6.port);
|
||||
}
|
||||
|
||||
test "resolveAddress rejects a non-literal host without touching the network" {
|
||||
try testing.expectError(
|
||||
error.ConnectFailed,
|
||||
resolveAddress(try .parse("tls://dns.google:853")),
|
||||
);
|
||||
try testing.expectError(
|
||||
error.ConnectFailed,
|
||||
resolveAddress(try .parse("tls://one.one.one.one")),
|
||||
);
|
||||
}
|
||||
|
||||
/// Only the four `err` fields the unwrap helpers read are set; the rest of a
|
||||
/// `TlsStream` is a socket reader, a socket writer and a TLS client, none of
|
||||
/// which the helpers touch.
|
||||
fn stubStream(
|
||||
read_err: ?net.Stream.Reader.Error,
|
||||
write_err: ?net.Stream.Writer.Error,
|
||||
tls_read_err: ?tls.Client.ReadError,
|
||||
) tls_client.TlsStream {
|
||||
var stream: tls_client.TlsStream = undefined;
|
||||
stream.stream_reader.err = read_err;
|
||||
stream.stream_writer.err = write_err;
|
||||
stream.client.read_err = tls_read_err;
|
||||
return stream;
|
||||
}
|
||||
|
||||
test "the handshake unwrap keeps a cancelled read out of the peer fault group" {
|
||||
var stream = stubStream(error.Canceled, null, null);
|
||||
const mapped = mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed);
|
||||
try testing.expectEqual(transport.ExchangeError.Canceled, mapped);
|
||||
try testing.expectEqual(transport.Group.cancellation, transport.group(mapped));
|
||||
}
|
||||
|
||||
test "the handshake unwrap keeps a local resource write failure out of the peer fault group" {
|
||||
var stream = stubStream(null, error.SystemResources, null);
|
||||
const mapped = mapPhase(concreteHandshake(&stream, error.WriteFailed), error.TlsFailed);
|
||||
try testing.expectEqual(transport.ExchangeError.SystemResources, mapped);
|
||||
try testing.expectEqual(transport.Group.local_resource, transport.group(mapped));
|
||||
}
|
||||
|
||||
test "the handshake unwrap reports a peer side cause as a TLS fault" {
|
||||
var reset = stubStream(error.ConnectionResetByPeer, null, null);
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.TlsFailed,
|
||||
mapPhase(concreteHandshake(&reset, error.ReadFailed), error.TlsFailed),
|
||||
);
|
||||
|
||||
var refused = stubStream(null, error.ConnectionRefused, null);
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.TlsFailed,
|
||||
mapPhase(concreteHandshake(&refused, error.WriteFailed), error.TlsFailed),
|
||||
);
|
||||
}
|
||||
|
||||
test "the handshake unwrap reports a TLS fault when no cause was stored" {
|
||||
var stream = stubStream(null, null, null);
|
||||
try testing.expectEqual(error.ReadFailed, concreteHandshake(&stream, error.ReadFailed));
|
||||
try testing.expectEqual(error.WriteFailed, concreteHandshake(&stream, error.WriteFailed));
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.TlsFailed,
|
||||
mapPhase(concreteHandshake(&stream, error.ReadFailed), error.TlsFailed),
|
||||
);
|
||||
}
|
||||
|
||||
test "the handshake unwrap passes other errors through untouched" {
|
||||
var stream = stubStream(error.Canceled, error.Canceled, error.TlsAlert);
|
||||
try testing.expectEqual(
|
||||
error.CertificateExpired,
|
||||
concreteHandshake(&stream, error.CertificateExpired),
|
||||
);
|
||||
try testing.expectEqual(error.Canceled, concreteHandshake(&stream, error.Canceled));
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.TlsFailed,
|
||||
mapPhase(concreteHandshake(&stream, error.CertificateExpired), error.TlsFailed),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.Canceled,
|
||||
mapPhase(concreteHandshake(&stream, error.Canceled), error.TlsFailed),
|
||||
);
|
||||
}
|
||||
|
||||
test "the send and receive unwraps prefer the stored cause" {
|
||||
var send = stubStream(null, error.Canceled, null);
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.Canceled,
|
||||
sendFailure(&send, error.WriteFailed),
|
||||
);
|
||||
|
||||
// The TLS client's own error wins over the socket reader's.
|
||||
var receive = stubStream(error.ConnectionResetByPeer, null, error.TlsAlert);
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.ReceiveFailed,
|
||||
receiveFailure(&receive, error.ReadFailed),
|
||||
);
|
||||
|
||||
var socket = stubStream(error.SystemResources, null, null);
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.SystemResources,
|
||||
receiveFailure(&socket, error.ReadFailed),
|
||||
);
|
||||
}
|
||||
|
||||
test "a CA bundle scan failure keeps local resource errors out of the peer fault group" {
|
||||
// `Certificate.Bundle.rescan` reaches these through `Allocator.Error`,
|
||||
// `Io.File.OpenError` and `Io.UnexpectedError`.
|
||||
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.TlsFailed)),
|
||||
);
|
||||
}
|
||||
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.Canceled,
|
||||
mapPhase(error.Canceled, error.TlsFailed),
|
||||
);
|
||||
|
||||
// A missing or corrupt bundle is not this process running out of anything,
|
||||
// so it stays a TLS fault.
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.TlsFailed,
|
||||
mapPhase(error.FileNotFound, error.TlsFailed),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.TlsFailed,
|
||||
mapPhase(error.MissingEndCertificateMarker, error.TlsFailed),
|
||||
);
|
||||
}
|
||||
|
||||
test "DotClient satisfies the Client interface" {
|
||||
const gpa = testing.allocator;
|
||||
|
||||
const buffer = try gpa.alloc(u8, 4 * tls.Client.min_buffer_len);
|
||||
defer gpa.free(buffer);
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(gpa);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
// `init` asserts `endpoint.scheme == .dot`; a `.doh` endpoint trips
|
||||
// `std.debug.assert`, which a test cannot catch in-process.
|
||||
var dot: DotClient = .init(try .parse("tls://9.9.9.9:853"), gpa, &bundle, &bundle_lock, .{
|
||||
.tls_read = buffer[0..chunk],
|
||||
.tls_write = buffer[chunk .. 2 * chunk],
|
||||
.stream_read = buffer[2 * chunk .. 3 * chunk],
|
||||
.stream_write = buffer[3 * chunk ..],
|
||||
});
|
||||
|
||||
try testing.expectEqual(transport.Scheme.dot, dot.endpoint.scheme);
|
||||
try testing.expectEqualStrings("9.9.9.9", dot.endpoint.host);
|
||||
|
||||
const iface: transport.Client = dot.client();
|
||||
try testing.expectEqual(@as(*anyopaque, @ptrCast(&dot)), iface.ptr);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
//! Network-dependent test for `dot_client.zig`.
|
||||
//!
|
||||
//! Separate file because it needs `@import("build_options")`, which only exists
|
||||
//! when build.zig drives the compilation. It is compiled by every
|
||||
//! `zig build test` run, so it cannot rot, and skips at run time without
|
||||
//! `-Dlive`. (`-Dintegration` stays hermetic; `-Dlive` is the gate for tests
|
||||
//! that leave the machine.)
|
||||
|
||||
const std = @import("std");
|
||||
const build_options = @import("build_options");
|
||||
const tls = std.crypto.tls;
|
||||
const Certificate = std.crypto.Certificate;
|
||||
|
||||
const dot_client = @import("dot_client.zig");
|
||||
const transport = @import("transport.zig");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
|
||||
/// Neither `connect` nor a TLS stream read accepts a timeout in 0.16.0, so the
|
||||
/// whole exchange runs as one task raced against a sleep and the loser is
|
||||
/// canceled.
|
||||
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
|
||||
|
||||
/// An A query for example.com: id 0x1234, RD set, one question.
|
||||
const query_bytes =
|
||||
"\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
|
||||
/// This machine's IPv6 egress is dead and upstream name resolution is out of
|
||||
/// scope, so the documented anycast IPv4 literal is used. Cloudflare's
|
||||
/// certificate carries 1.1.1.1 as an IP SAN, so full verification still applies.
|
||||
const upstream_url = "tls://1.1.1.1:853";
|
||||
|
||||
const Outcome = union(enum) {
|
||||
exchange: anyerror!usize,
|
||||
expiry: std.Io.Cancelable!void,
|
||||
};
|
||||
|
||||
const Params = struct {
|
||||
gpa: std.mem.Allocator,
|
||||
bundle: *Certificate.Bundle,
|
||||
bundle_lock: *std.Io.RwLock,
|
||||
buffers: dot_client.DotClient.Buffers,
|
||||
response_buf: []u8,
|
||||
};
|
||||
|
||||
fn runExchange(io: std.Io, params: Params) anyerror!usize {
|
||||
const endpoint: transport.Endpoint = try .parse(upstream_url);
|
||||
var client: dot_client.DotClient = .init(
|
||||
endpoint,
|
||||
params.gpa,
|
||||
params.bundle,
|
||||
params.bundle_lock,
|
||||
params.buffers,
|
||||
);
|
||||
const reply = try client.client().exchange(io, query_bytes, params.response_buf);
|
||||
return reply.len;
|
||||
}
|
||||
|
||||
fn expire(io: std.Io, duration: std.Io.Clock.Duration) std.Io.Cancelable!void {
|
||||
return duration.sleep(io);
|
||||
}
|
||||
|
||||
test "live DoT exchange against 1.1.1.1" {
|
||||
if (!build_options.live) return error.SkipZigTest;
|
||||
|
||||
const gpa = std.testing.allocator;
|
||||
|
||||
var threaded: std.Io.Threaded = .init(gpa, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var bundle: Certificate.Bundle = .empty;
|
||||
defer bundle.deinit(gpa);
|
||||
var bundle_lock: std.Io.RwLock = .init;
|
||||
|
||||
const chunk = tls.Client.min_buffer_len;
|
||||
const scratch = try gpa.alloc(u8, 4 * chunk);
|
||||
defer gpa.free(scratch);
|
||||
|
||||
var response_buf: [transport.max_message_len]u8 = undefined;
|
||||
|
||||
var outcomes: [2]Outcome = undefined;
|
||||
var race: std.Io.Select(Outcome) = .init(io, &outcomes);
|
||||
defer race.cancelDiscard();
|
||||
|
||||
try race.concurrent(.exchange, runExchange, .{ io, Params{
|
||||
.gpa = gpa,
|
||||
.bundle = &bundle,
|
||||
.bundle_lock = &bundle_lock,
|
||||
.buffers = .{
|
||||
.tls_read = scratch[0..chunk],
|
||||
.tls_write = scratch[chunk .. 2 * chunk],
|
||||
.stream_read = scratch[2 * chunk .. 3 * chunk],
|
||||
.stream_write = scratch[3 * chunk ..],
|
||||
},
|
||||
.response_buf = &response_buf,
|
||||
} });
|
||||
try race.concurrent(.expiry, expire, .{ io, budget });
|
||||
|
||||
const len = switch (try race.await()) {
|
||||
.exchange => |result| result catch |err| {
|
||||
std.debug.print("DoT exchange with {s} failed: {s}\n", .{
|
||||
upstream_url,
|
||||
@errorName(err),
|
||||
});
|
||||
return err;
|
||||
},
|
||||
.expiry => |result| {
|
||||
try result;
|
||||
return error.DotExchangeTimedOut;
|
||||
},
|
||||
};
|
||||
|
||||
// `exchange` already ran `transport.validateResponse`, so the id, question
|
||||
// and QR bit are known good. What is left to check is that the upstream
|
||||
// actually answered the question.
|
||||
const reply = try packet.parse(response_buf[0..len]);
|
||||
try std.testing.expect(reply.header.ancount >= 1);
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! Per-endpoint health and exponential backoff. Pure: timestamps and jitter
|
||||
//! arrive as parameters, so the pool owns the clock and the RNG and this file
|
||||
//! is testable without a backend.
|
||||
//!
|
||||
//! Only peer faults reach this file. `transport.group` decides that; a local
|
||||
//! resource error or a cancellation must never be recorded, or a full disk
|
||||
//! would take every upstream out of service.
|
||||
//!
|
||||
//! Out-of-order completions are the normal case, not an edge case: two
|
||||
//! concurrent exchanges against the same endpoint finish in either order, so
|
||||
//! `at` moves backwards between calls routinely. Every timestamp field
|
||||
//! therefore updates through `@max` on `.nanoseconds`, and `backoff_until` is
|
||||
//! only ever extended. A late-arriving success must not shorten a backoff that
|
||||
//! a later failure already set.
|
||||
//!
|
||||
//! Changes to state that describe the endpoint *now* are therefore
|
||||
//! conditional, while the accumulated counters are not. Both record functions
|
||||
//! split the same way:
|
||||
//!
|
||||
//! * Data is data. `total_successes`, `total_failures`, the window and the
|
||||
//! `@max` of `last_success_at` / `last_error_at` always update, however old
|
||||
//! `at` is.
|
||||
//! * `consecutive_failures`, `backoff_until` and `last_error_buf` describe
|
||||
//! the present, so a newer recorded outcome overrules a stale call.
|
||||
//!
|
||||
//! `recordSuccess` clears `consecutive_failures` and `backoff_until` only when
|
||||
//! its `at` is the newest outcome the state has seen, that is `at >=
|
||||
//! max(last_success_at, last_error_at)` before the update. A stale success
|
||||
//! leaves the backoff of a newer failure alone.
|
||||
//!
|
||||
//! `recordFailure` is the mirror image. See the rule at that function.
|
||||
//!
|
||||
//! `State` carries no lock. The pool owns the mutex.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
pub const Config = struct {
|
||||
/// Consecutive peer faults before the endpoint is put in backoff.
|
||||
failure_threshold: u8 = 2,
|
||||
base_backoff_ms: u32 = 500,
|
||||
max_backoff_ms: u32 = 60_000,
|
||||
};
|
||||
|
||||
/// Rolling success-rate window, in samples. Equal to the bit width of
|
||||
/// `State.window`.
|
||||
pub const window_len = 32;
|
||||
|
||||
/// The shift is capped so `base_backoff_ms << shift` cannot run away; by then
|
||||
/// `max_backoff_ms` has clamped the result many doublings ago.
|
||||
const max_shift = 20;
|
||||
|
||||
pub const State = struct {
|
||||
consecutive_failures: u32,
|
||||
total_successes: u64,
|
||||
total_failures: u64,
|
||||
last_success_at: ?std.Io.Timestamp,
|
||||
last_error_at: ?std.Io.Timestamp,
|
||||
last_error_buf: [48]u8,
|
||||
/// Length of the `@errorName` held in `last_error_buf`, truncated to fit.
|
||||
last_error_len: u8,
|
||||
backoff_until: ?std.Io.Timestamp,
|
||||
/// Bitset, 1 = success, LSB = most recent.
|
||||
window: u32,
|
||||
window_filled: u8,
|
||||
|
||||
pub const init: State = .{
|
||||
.consecutive_failures = 0,
|
||||
.total_successes = 0,
|
||||
.total_failures = 0,
|
||||
.last_success_at = null,
|
||||
.last_error_at = null,
|
||||
.last_error_buf = @splat(0),
|
||||
.last_error_len = 0,
|
||||
.backoff_until = null,
|
||||
.window = 0,
|
||||
.window_filled = 0,
|
||||
};
|
||||
|
||||
pub fn recordSuccess(self: *State, at: std.Io.Timestamp) void {
|
||||
if (self.isNewestOutcome(at)) {
|
||||
self.consecutive_failures = 0;
|
||||
self.backoff_until = null;
|
||||
}
|
||||
self.total_successes += 1;
|
||||
self.push(1);
|
||||
self.last_success_at = later(self.last_success_at, at);
|
||||
}
|
||||
|
||||
/// True when no recorded outcome is newer than `at`. Both timestamp fields
|
||||
/// update through `@max`, so their maximum is the newest outcome the state
|
||||
/// has seen and no separate field is needed.
|
||||
fn isNewestOutcome(self: *const State, at: std.Io.Timestamp) bool {
|
||||
return !newerThan(self.last_success_at, at) and !newerThan(self.last_error_at, at);
|
||||
}
|
||||
|
||||
/// `err_name` is `@errorName` of a PeerFault member. `rand` supplies
|
||||
/// jitter; the caller owns the RNG so this stays pure and the test is
|
||||
/// deterministic.
|
||||
///
|
||||
/// A stale failure, that is one whose `at` is older than an outcome already
|
||||
/// recorded, is held to the mirror image of the stale-success rule. Three
|
||||
/// cases, decided on the state *before* this call updates it:
|
||||
///
|
||||
/// 1. A newer success exists. The endpoint answered after this failure,
|
||||
/// so this failure cannot make it "consecutively failing" now: leave
|
||||
/// `consecutive_failures` and `backoff_until` alone. A run of failures
|
||||
/// that a later success ended is over.
|
||||
/// 2. No newer success, but a newer failure exists. The run of failures
|
||||
/// is unbroken and this call is part of it, so the count and the
|
||||
/// backoff update as usual; order inside a run does not matter. The
|
||||
/// newer failure already wrote `last_error_buf`, so its text stays.
|
||||
/// 3. `at` is the newest outcome. Everything updates.
|
||||
///
|
||||
/// `backoff_until` only ever extends through `@max`, so case 2 can lengthen
|
||||
/// a backoff but never shorten one.
|
||||
pub fn recordFailure(
|
||||
self: *State,
|
||||
at: std.Io.Timestamp,
|
||||
err_name: []const u8,
|
||||
cfg: Config,
|
||||
rand: u32,
|
||||
) void {
|
||||
const newer_success = newerThan(self.last_success_at, at);
|
||||
const newer_failure = newerThan(self.last_error_at, at);
|
||||
|
||||
if (!newer_failure) {
|
||||
const copied = @min(err_name.len, self.last_error_buf.len);
|
||||
@memcpy(self.last_error_buf[0..copied], err_name[0..copied]);
|
||||
self.last_error_len = @intCast(copied);
|
||||
}
|
||||
|
||||
self.total_failures += 1;
|
||||
self.push(0);
|
||||
self.last_error_at = later(self.last_error_at, at);
|
||||
|
||||
if (newer_success) return;
|
||||
|
||||
self.consecutive_failures +|= 1;
|
||||
if (self.consecutive_failures < cfg.failure_threshold) return;
|
||||
|
||||
const delay_ms = backoffDelayMs(self.consecutive_failures, cfg);
|
||||
const half = delay_ms / 2;
|
||||
const jittered = half + rand % (half + 1);
|
||||
const deadline: std.Io.Timestamp = .{
|
||||
.nanoseconds = at.nanoseconds + @as(i96, jittered) * std.time.ns_per_ms,
|
||||
};
|
||||
self.backoff_until = later(self.backoff_until, deadline);
|
||||
}
|
||||
|
||||
pub fn available(self: *const State, now: std.Io.Timestamp) bool {
|
||||
const until = self.backoff_until orelse return true;
|
||||
return now.nanoseconds > until.nanoseconds;
|
||||
}
|
||||
|
||||
/// Over the filled part of the window; 1.0 when the window is empty, so a
|
||||
/// fresh endpoint is not reported as failing.
|
||||
pub fn successRate(self: *const State) f32 {
|
||||
if (self.window_filled == 0) return 1.0;
|
||||
const filled: u6 = @intCast(@min(self.window_filled, window_len));
|
||||
const mask: u32 = if (filled == window_len)
|
||||
std.math.maxInt(u32)
|
||||
else
|
||||
(@as(u32, 1) << @intCast(filled)) - 1;
|
||||
const successes = @popCount(self.window & mask);
|
||||
return @as(f32, @floatFromInt(successes)) / @as(f32, @floatFromInt(filled));
|
||||
}
|
||||
|
||||
pub fn lastError(self: *const State) []const u8 {
|
||||
return self.last_error_buf[0..self.last_error_len];
|
||||
}
|
||||
|
||||
fn push(self: *State, bit: u1) void {
|
||||
self.window = (self.window << 1) | bit;
|
||||
self.window_filled = @min(self.window_filled + 1, window_len);
|
||||
}
|
||||
};
|
||||
|
||||
/// True when `existing` is set and strictly newer than `at`. Equal timestamps
|
||||
/// are not stale: a call at the timestamp of the newest outcome still counts as
|
||||
/// current.
|
||||
fn newerThan(existing: ?std.Io.Timestamp, at: std.Io.Timestamp) bool {
|
||||
const previous = existing orelse return false;
|
||||
return previous.nanoseconds > at.nanoseconds;
|
||||
}
|
||||
|
||||
fn later(existing: ?std.Io.Timestamp, candidate: std.Io.Timestamp) std.Io.Timestamp {
|
||||
const previous = existing orelse return candidate;
|
||||
return .{ .nanoseconds = @max(previous.nanoseconds, candidate.nanoseconds) };
|
||||
}
|
||||
|
||||
/// `min(max_backoff_ms, base_backoff_ms << shift)` with
|
||||
/// `shift = min(consecutive_failures - failure_threshold, max_shift)`. The
|
||||
/// arithmetic runs in u64 so a large `base_backoff_ms` cannot wrap before the
|
||||
/// clamp applies.
|
||||
fn backoffDelayMs(consecutive_failures: u32, cfg: Config) u32 {
|
||||
const over = consecutive_failures - cfg.failure_threshold;
|
||||
const shift: u6 = @intCast(@min(over, max_shift));
|
||||
const shifted = @as(u64, cfg.base_backoff_ms) << shift;
|
||||
return @intCast(@min(@as(u64, cfg.max_backoff_ms), shifted));
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn ts(nanoseconds: i96) std.Io.Timestamp {
|
||||
return .{ .nanoseconds = nanoseconds };
|
||||
}
|
||||
|
||||
fn ms(count: i96) i96 {
|
||||
return count * std.time.ns_per_ms;
|
||||
}
|
||||
|
||||
test "a failure below the threshold leaves the endpoint available" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), state.consecutive_failures);
|
||||
try testing.expectEqual(@as(u64, 1), state.total_failures);
|
||||
try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until);
|
||||
try testing.expect(state.available(ts(0)));
|
||||
}
|
||||
|
||||
test "reaching the threshold puts the endpoint in backoff until the deadline" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
|
||||
// Two failures, threshold 2, shift 0: delay 500 ms, jitter 0 => 250 ms.
|
||||
const until = state.backoff_until.?;
|
||||
try testing.expectEqual(ms(250), until.nanoseconds);
|
||||
try testing.expect(!state.available(ts(0)));
|
||||
try testing.expect(!state.available(until));
|
||||
try testing.expect(state.available(ts(until.nanoseconds + 1)));
|
||||
}
|
||||
|
||||
test "consecutive failures grow the delay and saturate at max_backoff_ms" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
|
||||
var previous: i96 = -1;
|
||||
var i: usize = 0;
|
||||
while (i < 40) : (i += 1) {
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
if (state.backoff_until) |until| {
|
||||
try testing.expect(until.nanoseconds >= previous);
|
||||
previous = until.nanoseconds;
|
||||
}
|
||||
}
|
||||
|
||||
// Jitter 0 halves the delay, and the delay itself is clamped.
|
||||
try testing.expectEqual(ms(cfg.max_backoff_ms / 2), state.backoff_until.?.nanoseconds);
|
||||
try testing.expectEqual(@as(u32, 500), backoffDelayMs(2, cfg));
|
||||
try testing.expectEqual(@as(u32, 1000), backoffDelayMs(3, cfg));
|
||||
try testing.expectEqual(@as(u32, 2000), backoffDelayMs(4, cfg));
|
||||
try testing.expectEqual(cfg.max_backoff_ms, backoffDelayMs(40, cfg));
|
||||
}
|
||||
|
||||
test "a success resets the consecutive count, the window and the backoff" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
try testing.expect(state.backoff_until != null);
|
||||
|
||||
state.recordSuccess(ts(ms(1)));
|
||||
try testing.expectEqual(@as(u32, 0), state.consecutive_failures);
|
||||
try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until);
|
||||
try testing.expectEqual(@as(u64, 1), state.total_successes);
|
||||
try testing.expectEqual(@as(u32, 1), state.window & 1);
|
||||
try testing.expect(state.available(ts(0)));
|
||||
}
|
||||
|
||||
test "jitter stays inside half the delay and the whole delay" {
|
||||
const cfg: Config = .{};
|
||||
const delay = backoffDelayMs(2, cfg);
|
||||
|
||||
for ([_]u32{ 0, std.math.maxInt(u32), 1, 12345 }) |rand| {
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(0), "Timeout", cfg, rand);
|
||||
state.recordFailure(ts(0), "Timeout", cfg, rand);
|
||||
const offset = state.backoff_until.?.nanoseconds;
|
||||
try testing.expect(offset >= ms(delay / 2));
|
||||
try testing.expect(offset <= ms(delay));
|
||||
}
|
||||
}
|
||||
|
||||
test "an out-of-order success does not move last_success_at backwards" {
|
||||
var state: State = .init;
|
||||
state.recordSuccess(ts(100));
|
||||
state.recordSuccess(ts(50));
|
||||
try testing.expectEqual(@as(i96, 100), state.last_success_at.?.nanoseconds);
|
||||
}
|
||||
|
||||
test "an out-of-order failure does not shorten the backoff" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
const until = state.backoff_until.?.nanoseconds;
|
||||
|
||||
state.recordFailure(ts(ms(50)), "Timeout", cfg, 0);
|
||||
try testing.expect(state.backoff_until.?.nanoseconds >= until);
|
||||
try testing.expectEqual(@as(i96, ms(100)), state.last_error_at.?.nanoseconds);
|
||||
}
|
||||
|
||||
test "an out-of-order success does not clear the backoff of a newer failure" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
const until = state.backoff_until.?.nanoseconds;
|
||||
|
||||
state.recordSuccess(ts(ms(50)));
|
||||
try testing.expectEqual(@as(i96, until), state.backoff_until.?.nanoseconds);
|
||||
try testing.expectEqual(@as(u32, 2), state.consecutive_failures);
|
||||
try testing.expectEqual(@as(u64, 1), state.total_successes);
|
||||
try testing.expectEqual(@as(u32, 1), state.window & 1);
|
||||
try testing.expectEqual(@as(i96, ms(50)), state.last_success_at.?.nanoseconds);
|
||||
}
|
||||
|
||||
test "a stale failure behind a newer success does not raise the consecutive count" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(10)), "Timeout", cfg, 0);
|
||||
state.recordSuccess(ts(ms(100)));
|
||||
state.recordFailure(ts(ms(20)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(30)), "Timeout", cfg, 0);
|
||||
|
||||
try testing.expectEqual(@as(u32, 0), state.consecutive_failures);
|
||||
try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until);
|
||||
try testing.expect(state.available(ts(ms(31))));
|
||||
}
|
||||
|
||||
test "a stale failure behind a newer success still counts into the totals" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordSuccess(ts(ms(100)));
|
||||
state.recordFailure(ts(ms(20)), "Timeout", cfg, 0);
|
||||
|
||||
try testing.expectEqual(@as(u64, 1), state.total_failures);
|
||||
try testing.expectEqual(@as(u8, 2), state.window_filled);
|
||||
try testing.expectEqual(@as(u32, 0), state.window & 1);
|
||||
try testing.expectEqual(@as(i96, ms(20)), state.last_error_at.?.nanoseconds);
|
||||
try testing.expectEqualStrings("Timeout", state.lastError());
|
||||
}
|
||||
|
||||
test "a stale failure with no newer success still counts as consecutive" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(50)), "Timeout", cfg, 0);
|
||||
|
||||
try testing.expectEqual(@as(u32, 2), state.consecutive_failures);
|
||||
try testing.expect(state.backoff_until != null);
|
||||
try testing.expectEqual(@as(i96, ms(100)), state.last_error_at.?.nanoseconds);
|
||||
}
|
||||
|
||||
test "a stale failure does not overwrite the error of a newer failure" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(50)), "ConnectFailed", cfg, 0);
|
||||
|
||||
try testing.expectEqualStrings("Timeout", state.lastError());
|
||||
}
|
||||
|
||||
test "the newest failure records its own error and extends the backoff" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(50)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(100)), "ConnectFailed", cfg, 0);
|
||||
|
||||
try testing.expectEqualStrings("ConnectFailed", state.lastError());
|
||||
try testing.expectEqual(@as(u32, 2), state.consecutive_failures);
|
||||
try testing.expectEqual(ms(100) + ms(250), state.backoff_until.?.nanoseconds);
|
||||
}
|
||||
|
||||
test "the newest success clears the backoff of an older failure" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
try testing.expect(state.backoff_until != null);
|
||||
|
||||
state.recordSuccess(ts(ms(101)));
|
||||
try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until);
|
||||
try testing.expectEqual(@as(u32, 0), state.consecutive_failures);
|
||||
}
|
||||
|
||||
test "a success at the timestamp of the newest failure clears the backoff" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
state.recordFailure(ts(ms(100)), "Timeout", cfg, 0);
|
||||
|
||||
state.recordSuccess(ts(ms(100)));
|
||||
try testing.expectEqual(@as(?std.Io.Timestamp, null), state.backoff_until);
|
||||
try testing.expectEqual(@as(u32, 0), state.consecutive_failures);
|
||||
}
|
||||
|
||||
test "successRate over a half-success window is 0.5" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
try testing.expectEqual(@as(f32, 1.0), state.successRate());
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < 4) : (i += 1) {
|
||||
state.recordSuccess(ts(0));
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
}
|
||||
try testing.expectEqual(@as(u8, 8), state.window_filled);
|
||||
try testing.expectEqual(@as(f32, 0.5), state.successRate());
|
||||
}
|
||||
|
||||
test "successRate counts only the filled part of the window" {
|
||||
var state: State = .init;
|
||||
state.recordSuccess(ts(0));
|
||||
try testing.expectEqual(@as(f32, 1.0), state.successRate());
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < window_len * 2) : (i += 1) state.recordSuccess(ts(0));
|
||||
try testing.expectEqual(@as(u8, window_len), state.window_filled);
|
||||
try testing.expectEqual(@as(f32, 1.0), state.successRate());
|
||||
}
|
||||
|
||||
test "lastError returns the last recorded name, truncated not overflowed" {
|
||||
const cfg: Config = .{};
|
||||
var state: State = .init;
|
||||
try testing.expectEqualStrings("", state.lastError());
|
||||
|
||||
state.recordFailure(ts(0), "ConnectFailed", cfg, 0);
|
||||
try testing.expectEqualStrings("ConnectFailed", state.lastError());
|
||||
|
||||
state.recordFailure(ts(0), "Timeout", cfg, 0);
|
||||
try testing.expectEqualStrings("Timeout", state.lastError());
|
||||
|
||||
const long = "A" ** 200;
|
||||
state.recordFailure(ts(0), long, cfg, 0);
|
||||
try testing.expectEqual(@as(usize, 48), state.lastError().len);
|
||||
try testing.expectEqualStrings(long[0..48], state.lastError());
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
//! Priority-ordered sequential failover across upstream endpoints, with a
|
||||
//! per-attempt deadline, health tracking and backoff (PLAN §9).
|
||||
//!
|
||||
//! The pool is itself a `transport.Client`, so the handler above it sees one
|
||||
//! interface and knows nothing about how many upstreams exist.
|
||||
//!
|
||||
//! Two passes, not one. Pass one walks the enabled entries that health says are
|
||||
//! available. Pass two runs only when pass one attempted nothing, and skips the
|
||||
//! backoff check: every endpoint being in backoff must not turn into SERVFAIL
|
||||
//! for every client. A probe is better than a guaranteed failure, and the probe
|
||||
//! is how backoff recovers.
|
||||
//!
|
||||
//! Concurrency (PLAN §9, spec S4.2): several handler tasks share one `Pool`,
|
||||
//! and they hold two different locks at two different scopes.
|
||||
//!
|
||||
//! `Pool.mutex` guards health mutation, the jitter RNG and `snapshot`. It is
|
||||
//! never held across an exchange, so a slow upstream cannot block a health read
|
||||
//! or an attempt on some other entry.
|
||||
//!
|
||||
//! `Entry.busy` guards one entry's `client`. A task holds it for a whole
|
||||
//! attempt against that entry — the exchange and the timeout race around it —
|
||||
//! and drops it before the failover loop moves to the next candidate. This is
|
||||
//! what makes the pool safe to share: `DohClient` owns a request buffer and a
|
||||
//! transfer buffer, `DotClient` owns four TLS buffers and the stream state
|
||||
//! built on them, so one client may be inside `exchange` only once at a time.
|
||||
//!
|
||||
//! Because that wait is serializing, pass one re-reads health after it takes
|
||||
//! `busy`: the attempts a task queued behind can fail the entry into backoff
|
||||
//! while it waits, and pass one must not attempt an entry that is unavailable by
|
||||
//! the time it gets its turn. Lock order is always `busy` then `Pool.mutex`,
|
||||
//! never the reverse.
|
||||
//!
|
||||
//! The guarantee is therefore: at most one in-flight exchange per entry, any
|
||||
//! number of entries in flight at once, and bookkeeping that never waits on a
|
||||
//! peer. Two concurrent queries that resolve to the same sole upstream do
|
||||
//! serialize. At household scale that is the right trade — the alternative is a
|
||||
//! client instance per listener task, which multiplies TLS buffers and
|
||||
//! connections for a query rate that never needed them.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
const health = @import("health.zig");
|
||||
const transport = @import("transport.zig");
|
||||
|
||||
const log = std.log.scoped(.upstream);
|
||||
|
||||
pub const Entry = struct {
|
||||
endpoint: transport.Endpoint,
|
||||
client: transport.Client,
|
||||
/// Lower is tried first (PLAN §11.2 `upstreams.priority`).
|
||||
priority: i32,
|
||||
enabled: bool,
|
||||
health: health.State,
|
||||
/// Held for the whole of one attempt against this entry, so `client` is
|
||||
/// never re-entered while it is using its own buffers. Defaulted because
|
||||
/// `init` sorts `entries` by value, which may only copy unlocked mutexes,
|
||||
/// and it runs before the pool is reachable by any task.
|
||||
busy: std.Io.Mutex = .init,
|
||||
};
|
||||
|
||||
/// A copy of one entry's health, taken under the mutex. Feeds
|
||||
/// `GET /api/upstream/health` in Phase 8.
|
||||
pub const Snapshot = struct {
|
||||
url: []const u8,
|
||||
enabled: bool,
|
||||
available: bool,
|
||||
consecutive_failures: u32,
|
||||
total_successes: u64,
|
||||
total_failures: u64,
|
||||
success_rate: f32,
|
||||
last_success_at: ?std.Io.Timestamp,
|
||||
last_error_at: ?std.Io.Timestamp,
|
||||
/// Borrowed from the entry; valid until that entry's next failure.
|
||||
last_error: []const u8,
|
||||
backoff_until: ?std.Io.Timestamp,
|
||||
};
|
||||
|
||||
pub const Pool = struct {
|
||||
/// Caller-owned, sorted ascending by priority in `init`.
|
||||
entries: []Entry,
|
||||
cfg: health.Config,
|
||||
/// On the `.awake` clock, so a suspended Pi does not burn the budget.
|
||||
attempt_timeout: std.Io.Clock.Duration,
|
||||
mutex: std.Io.Mutex,
|
||||
rng: std.Random.DefaultPrng,
|
||||
|
||||
pub fn init(
|
||||
entries: []Entry,
|
||||
cfg: health.Config,
|
||||
attempt_timeout: std.Io.Clock.Duration,
|
||||
seed: u64,
|
||||
) Pool {
|
||||
std.debug.assert(entries.len > 0);
|
||||
// Stable, so entries sharing a priority keep their configured order.
|
||||
std.mem.sort(Entry, entries, {}, byPriority);
|
||||
return .{
|
||||
.entries = entries,
|
||||
.cfg = cfg,
|
||||
.attempt_timeout = attempt_timeout,
|
||||
.mutex = .init,
|
||||
.rng = .init(seed),
|
||||
};
|
||||
}
|
||||
|
||||
fn byPriority(_: void, a: Entry, b: Entry) bool {
|
||||
return a.priority < b.priority;
|
||||
}
|
||||
|
||||
pub fn client(self: *Pool) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeErased };
|
||||
}
|
||||
|
||||
fn exchangeErased(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const self: *Pool = @ptrCast(@alignCast(ptr));
|
||||
return self.exchange(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// `response_buf` is handed to each attempt in turn, so a failed attempt
|
||||
/// may have written into it. The returned slice is only meaningful on
|
||||
/// success; on error the buffer's contents are undefined.
|
||||
pub fn exchange(
|
||||
self: *Pool,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
var last_fault: ?transport.ExchangeError = null;
|
||||
var attempted = false;
|
||||
|
||||
var pass: u8 = 0;
|
||||
while (pass < 2) : (pass += 1) {
|
||||
for (self.entries) |*entry| {
|
||||
if (!entry.enabled) continue;
|
||||
if (pass == 0 and !self.entryAvailable(io, entry, now)) continue;
|
||||
|
||||
// Cancelable, unlike the health-bookkeeping locks below: a task
|
||||
// waiting its turn on a busy upstream has done nothing that a
|
||||
// cancellation could corrupt, so it gives up here rather than
|
||||
// queueing behind an exchange it will not use. The wait itself
|
||||
// is bounded by the holder's `attempt_timeout`; the waiter's own
|
||||
// budget only starts once it has the lock.
|
||||
try entry.busy.lock(io);
|
||||
defer entry.busy.unlock(io);
|
||||
|
||||
// The check above ran before the wait, and the attempts this one
|
||||
// queued behind may have failed the entry into backoff while it
|
||||
// waited. Pass one must not touch an entry that is unavailable
|
||||
// now, so re-read health against a fresh `now` and move on if it
|
||||
// is. Pass two skips this on purpose: it probes regardless of
|
||||
// backoff, which is how backoff recovers.
|
||||
if (pass == 0) {
|
||||
const recheck = std.Io.Clock.awake.now(io);
|
||||
if (!self.entryAvailable(io, entry, recheck)) continue;
|
||||
}
|
||||
attempted = true;
|
||||
|
||||
const result = self.attempt(io, entry.client, query, response_buf);
|
||||
const completed_at = std.Io.Clock.awake.now(io);
|
||||
|
||||
const response = result catch |err| switch (transport.group(err)) {
|
||||
.peer_fault => {
|
||||
log.debug("upstream {s} failed: {t}", .{ entry.endpoint.url, err });
|
||||
self.recordFailure(io, entry, completed_at, err);
|
||||
last_fault = err;
|
||||
continue;
|
||||
},
|
||||
// The next upstream would hit the same wall, and this says
|
||||
// nothing about any peer, so it is never recorded.
|
||||
.local_resource => return err,
|
||||
.cancellation => return error.Canceled,
|
||||
};
|
||||
|
||||
self.recordSuccess(io, entry, completed_at);
|
||||
return response;
|
||||
}
|
||||
if (attempted) break;
|
||||
}
|
||||
|
||||
// Guaranteed non-null whenever any entry is enabled: pass two attempts
|
||||
// every enabled entry regardless of backoff.
|
||||
if (last_fault) |err| return err;
|
||||
return error.ConnectFailed;
|
||||
}
|
||||
|
||||
/// Copies health into `out` in pool order; returns the number written.
|
||||
pub fn snapshot(self: *Pool, io: std.Io, out: []Snapshot) std.Io.Cancelable!usize {
|
||||
const now = std.Io.Clock.awake.now(io);
|
||||
try self.mutex.lock(io);
|
||||
defer self.mutex.unlock(io);
|
||||
|
||||
const count = @min(self.entries.len, out.len);
|
||||
for (self.entries[0..count], out[0..count]) |*entry, *slot| {
|
||||
slot.* = .{
|
||||
.url = entry.endpoint.url,
|
||||
.enabled = entry.enabled,
|
||||
.available = entry.enabled and entry.health.available(now),
|
||||
.consecutive_failures = entry.health.consecutive_failures,
|
||||
.total_successes = entry.health.total_successes,
|
||||
.total_failures = entry.health.total_failures,
|
||||
.success_rate = entry.health.successRate(),
|
||||
.last_success_at = entry.health.last_success_at,
|
||||
.last_error_at = entry.health.last_error_at,
|
||||
.last_error = entry.health.lastError(),
|
||||
.backoff_until = entry.health.backoff_until,
|
||||
};
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// One exchange raced against the per-attempt budget. No stream read or
|
||||
/// write in 0.16.0 takes a timeout, so the budget is a second task and the
|
||||
/// loser is canceled.
|
||||
fn attempt(
|
||||
self: *Pool,
|
||||
io: std.Io,
|
||||
entry_client: transport.Client,
|
||||
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, transport.Client.exchange, .{
|
||||
entry_client, io, query, response_buf,
|
||||
}) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
race.concurrent(.expiry, expire, .{ io, self.attempt_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 upstream is slow.
|
||||
try result;
|
||||
return error.Timeout;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn entryAvailable(
|
||||
self: *Pool,
|
||||
io: std.Io,
|
||||
entry: *const Entry,
|
||||
now: std.Io.Timestamp,
|
||||
) bool {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
return entry.health.available(now);
|
||||
}
|
||||
|
||||
fn recordSuccess(self: *Pool, io: std.Io, entry: *Entry, at: std.Io.Timestamp) void {
|
||||
// Uncancelable: this section takes no Io and never blocks on a peer.
|
||||
// Losing the bookkeeping for a completed exchange to a cancellation
|
||||
// that arrives one instruction later would corrupt health for good.
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
entry.health.recordSuccess(at);
|
||||
}
|
||||
|
||||
fn recordFailure(
|
||||
self: *Pool,
|
||||
io: std.Io,
|
||||
entry: *Entry,
|
||||
at: std.Io.Timestamp,
|
||||
err: transport.ExchangeError,
|
||||
) void {
|
||||
self.mutex.lockUncancelable(io);
|
||||
defer self.mutex.unlock(io);
|
||||
entry.health.recordFailure(at, @errorName(err), self.cfg, self.rng.random().int(u32));
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
/// A query for example.com A: id 0x1234, RD set, one question.
|
||||
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";
|
||||
|
||||
/// Stands in for a DoH or DoT client. Every behaviour the pool has to react to
|
||||
/// is one variant, and every call is counted so a test can assert that an entry
|
||||
/// in backoff was not touched.
|
||||
const Fake = struct {
|
||||
behavior: Behavior,
|
||||
calls: usize = 0,
|
||||
in_flight: std.atomic.Value(u32) = .init(0),
|
||||
/// The most tasks ever inside `exchangeFn` at once. The per-entry lock is
|
||||
/// only doing its job while this stays at 1.
|
||||
peak_in_flight: std.atomic.Value(u32) = .init(0),
|
||||
|
||||
const Behavior = union(enum) {
|
||||
/// Copy these bytes into the caller's buffer and return them.
|
||||
reply: []const u8,
|
||||
/// Fail with this error.
|
||||
fail: transport.ExchangeError,
|
||||
/// Sleep, then reply. Used to outrun the pool's attempt budget.
|
||||
slow: struct { duration: std.Io.Clock.Duration, reply: []const u8 },
|
||||
/// Sleep, then fail. Holds the entry's lock long enough for a second
|
||||
/// task to queue on it before the failure opens a backoff window.
|
||||
slow_fail: struct { duration: std.Io.Clock.Duration, err: transport.ExchangeError },
|
||||
};
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
_ = query;
|
||||
const self: *Fake = @ptrCast(@alignCast(ptr));
|
||||
const entrants = self.in_flight.fetchAdd(1, .acq_rel) + 1;
|
||||
defer _ = self.in_flight.fetchSub(1, .acq_rel);
|
||||
_ = self.peak_in_flight.fetchMax(entrants, .acq_rel);
|
||||
|
||||
self.calls += 1;
|
||||
switch (self.behavior) {
|
||||
.reply => |bytes| return copy(bytes, response_buf),
|
||||
.fail => |err| return err,
|
||||
.slow => |slow| {
|
||||
try slow.duration.sleep(io);
|
||||
return copy(slow.reply, response_buf);
|
||||
},
|
||||
.slow_fail => |slow| {
|
||||
try slow.duration.sleep(io);
|
||||
return slow.err;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn copy(bytes: []const u8, response_buf: []u8) transport.ExchangeError![]u8 {
|
||||
if (bytes.len > response_buf.len) return error.ResponseTooLarge;
|
||||
@memcpy(response_buf[0..bytes.len], bytes);
|
||||
return response_buf[0..bytes.len];
|
||||
}
|
||||
|
||||
fn client(self: *Fake) transport.Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
fn testEntry(url: []const u8, fake: *Fake, priority: i32) Entry {
|
||||
return .{
|
||||
.endpoint = Endpoint.parse(url) catch unreachable,
|
||||
.client = fake.client(),
|
||||
.priority = priority,
|
||||
.enabled = true,
|
||||
.health = .init,
|
||||
};
|
||||
}
|
||||
|
||||
const Endpoint = transport.Endpoint;
|
||||
|
||||
/// Long enough that no test can outlive a backoff it just set, and short enough
|
||||
/// that nothing waits on it.
|
||||
const test_cfg: health.Config = .{
|
||||
.failure_threshold = 2,
|
||||
.base_backoff_ms = 60_000,
|
||||
.max_backoff_ms = 60_000,
|
||||
};
|
||||
|
||||
const test_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(10), .clock = .awake };
|
||||
|
||||
test "Pool satisfies the Client interface" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var fake: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{testEntry("https://a.example/dns-query", &fake, 10)};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const reply = try pool.client().exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqualSlices(u8, response_bytes, reply);
|
||||
}
|
||||
|
||||
test "entries are tried in ascending priority order" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var low: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var high: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
// Registered out of order: priority, not position, decides.
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://high.example/dns-query", &high, 100),
|
||||
testEntry("https://low.example/dns-query", &low, 10),
|
||||
};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
try testing.expectEqual(@as(i32, 10), entries[0].priority);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqual(@as(usize, 1), low.calls);
|
||||
try testing.expectEqual(@as(usize, 0), high.calls);
|
||||
}
|
||||
|
||||
test "a peer fault fails over to the next entry and is recorded" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } };
|
||||
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://bad.example/dns-query", &bad, 10),
|
||||
testEntry("https://good.example/dns-query", &good, 20),
|
||||
};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const reply = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqualSlices(u8, response_bytes, reply);
|
||||
|
||||
try testing.expectEqual(@as(u32, 1), entries[0].health.consecutive_failures);
|
||||
try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures);
|
||||
try testing.expectEqualStrings("Timeout", entries[0].health.lastError());
|
||||
try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes);
|
||||
}
|
||||
|
||||
test "an entry in backoff is skipped while another is available" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var bad: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
|
||||
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://bad.example/dns-query", &bad, 10),
|
||||
testEntry("https://good.example/dns-query", &good, 20),
|
||||
};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
// Two failures reach `failure_threshold` and open a backoff window.
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqual(@as(usize, 2), bad.calls);
|
||||
try testing.expect(entries[0].health.backoff_until != null);
|
||||
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqual(@as(usize, 2), bad.calls);
|
||||
try testing.expectEqual(@as(usize, 3), good.calls);
|
||||
}
|
||||
|
||||
test "every entry in backoff is still probed" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var first: Fake = .{ .behavior = .{ .fail = error.ConnectFailed } };
|
||||
var second: Fake = .{ .behavior = .{ .fail = error.BadResponse } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://first.example/dns-query", &first, 10),
|
||||
testEntry("https://second.example/dns-query", &second, 20),
|
||||
};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expect(entries[0].health.backoff_until != null);
|
||||
try testing.expect(entries[1].health.backoff_until != null);
|
||||
|
||||
// Pass one now has no candidate at all. Pass two probes both anyway.
|
||||
try testing.expectError(error.BadResponse, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(usize, 3), first.calls);
|
||||
try testing.expectEqual(@as(usize, 3), second.calls);
|
||||
}
|
||||
|
||||
test "a local resource error short-circuits and records nothing" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var broke: Fake = .{ .behavior = .{ .fail = error.OutOfMemory } };
|
||||
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://broke.example/dns-query", &broke, 10),
|
||||
testEntry("https://good.example/dns-query", &good, 20),
|
||||
};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.OutOfMemory, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(usize, 0), good.calls);
|
||||
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
|
||||
try testing.expectEqual(@as(u32, 0), entries[0].health.consecutive_failures);
|
||||
}
|
||||
|
||||
test "a cancellation short-circuits and records nothing" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var canceled: Fake = .{ .behavior = .{ .fail = error.Canceled } };
|
||||
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://canceled.example/dns-query", &canceled, 10),
|
||||
testEntry("https://good.example/dns-query", &good, 20),
|
||||
};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.Canceled, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(usize, 0), good.calls);
|
||||
try testing.expectEqual(@as(u64, 0), entries[0].health.total_failures);
|
||||
}
|
||||
|
||||
test "an attempt that outruns the budget is a recorded Timeout" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var slow: Fake = .{ .behavior = .{ .slow = .{
|
||||
.duration = .{ .raw = .fromSeconds(30), .clock = .awake },
|
||||
.reply = response_bytes,
|
||||
} } };
|
||||
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://slow.example/dns-query", &slow, 10),
|
||||
testEntry("https://good.example/dns-query", &good, 20),
|
||||
};
|
||||
const budget: std.Io.Clock.Duration = .{ .raw = .fromMilliseconds(20), .clock = .awake };
|
||||
var pool: Pool = .init(&entries, test_cfg, budget, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
const reply = try pool.exchange(io, query_bytes, &buf);
|
||||
try testing.expectEqualSlices(u8, response_bytes, reply);
|
||||
|
||||
try testing.expectEqual(@as(usize, 1), slow.calls);
|
||||
try testing.expectEqual(@as(u32, 1), entries[0].health.consecutive_failures);
|
||||
try testing.expectEqualStrings("Timeout", entries[0].health.lastError());
|
||||
try testing.expectEqual(@as(u64, 1), entries[1].health.total_successes);
|
||||
}
|
||||
|
||||
test "every entry disabled yields ConnectFailed" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var one: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var two: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://one.example/dns-query", &one, 10),
|
||||
testEntry("https://two.example/dns-query", &two, 20),
|
||||
};
|
||||
for (&entries) |*entry| entry.enabled = false;
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
try testing.expectError(error.ConnectFailed, pool.exchange(io, query_bytes, &buf));
|
||||
try testing.expectEqual(@as(usize, 0), one.calls);
|
||||
try testing.expectEqual(@as(usize, 0), two.calls);
|
||||
}
|
||||
|
||||
test "snapshot reports the counters in pool order" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
var bad: Fake = .{ .behavior = .{ .fail = error.Timeout } };
|
||||
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://bad.example/dns-query", &bad, 10),
|
||||
testEntry("https://good.example/dns-query", &good, 20),
|
||||
};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf: [512]u8 = undefined;
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
_ = try pool.exchange(io, query_bytes, &buf);
|
||||
|
||||
var out: [4]Snapshot = undefined;
|
||||
const written = try pool.snapshot(io, &out);
|
||||
try testing.expectEqual(@as(usize, 2), written);
|
||||
|
||||
try testing.expectEqualStrings("https://bad.example/dns-query", out[0].url);
|
||||
try testing.expect(out[0].enabled);
|
||||
try testing.expect(!out[0].available);
|
||||
try testing.expectEqual(@as(u32, 2), out[0].consecutive_failures);
|
||||
try testing.expectEqual(@as(u64, 2), out[0].total_failures);
|
||||
try testing.expectEqual(@as(u64, 0), out[0].total_successes);
|
||||
try testing.expectEqual(@as(f32, 0.0), out[0].success_rate);
|
||||
try testing.expectEqualStrings("Timeout", out[0].last_error);
|
||||
try testing.expect(out[0].last_error_at != null);
|
||||
try testing.expect(out[0].backoff_until != null);
|
||||
|
||||
try testing.expectEqualStrings("https://good.example/dns-query", out[1].url);
|
||||
try testing.expect(out[1].available);
|
||||
try testing.expectEqual(@as(u64, 2), out[1].total_successes);
|
||||
try testing.expectEqual(@as(f32, 1.0), out[1].success_rate);
|
||||
try testing.expect(out[1].last_success_at != null);
|
||||
try testing.expectEqual(@as(?std.Io.Timestamp, null), out[1].backoff_until);
|
||||
|
||||
// A short `out` truncates rather than overflowing.
|
||||
var one: [1]Snapshot = undefined;
|
||||
try testing.expectEqual(@as(usize, 1), try pool.snapshot(io, &one));
|
||||
}
|
||||
|
||||
/// `Io.concurrent` stores the return value in the future, so the reply slice is
|
||||
/// reduced to its length here and the bytes are read back out of the caller's
|
||||
/// buffer. Returning a `usize` also lets the test discard a result with
|
||||
/// `catch 0`.
|
||||
fn exchangeLen(pool: *Pool, io: std.Io, buf: []u8) transport.ExchangeError!usize {
|
||||
const reply = try pool.exchange(io, query_bytes, buf);
|
||||
return reply.len;
|
||||
}
|
||||
|
||||
test "concurrent exchanges through one entry do not overlap" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// Slow enough that an unserialized second task would still be inside the
|
||||
// fake when the first one is, and short enough to stay far under the
|
||||
// 10-second attempt budget even when the two run back to back.
|
||||
var fake: Fake = .{ .behavior = .{ .slow = .{
|
||||
.duration = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.reply = response_bytes,
|
||||
} } };
|
||||
var entries = [_]Entry{testEntry("https://only.example/dns-query", &fake, 10)};
|
||||
var pool: Pool = .init(&entries, test_cfg, test_timeout, 1);
|
||||
|
||||
var buf_a: [512]u8 = undefined;
|
||||
var buf_b: [512]u8 = undefined;
|
||||
|
||||
var first = io.concurrent(exchangeLen, .{ &pool, io, &buf_a }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = first.await(io) catch 0;
|
||||
var second = io.concurrent(exchangeLen, .{ &pool, io, &buf_b }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = second.await(io) catch 0;
|
||||
|
||||
const len_a = try first.await(io);
|
||||
const len_b = try second.await(io);
|
||||
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_a[0..len_a]);
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_b[0..len_b]);
|
||||
try testing.expectEqual(@as(usize, 2), fake.calls);
|
||||
try testing.expectEqual(@as(u32, 1), fake.peak_in_flight.load(.acquire));
|
||||
try testing.expectEqual(@as(u64, 2), entries[0].health.total_successes);
|
||||
}
|
||||
|
||||
test "an entry that enters backoff while a task waits on it is not attempted" {
|
||||
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
|
||||
defer threaded.deinit();
|
||||
const io = threaded.io();
|
||||
|
||||
// One failure is enough to open the backoff window, so the first task's
|
||||
// failure makes the entry unavailable to the second task, which by then is
|
||||
// already past its own availability check and waiting on `busy`.
|
||||
const cfg: health.Config = .{
|
||||
.failure_threshold = 1,
|
||||
.base_backoff_ms = 60_000,
|
||||
.max_backoff_ms = 60_000,
|
||||
};
|
||||
|
||||
var slow_bad: Fake = .{ .behavior = .{ .slow_fail = .{
|
||||
.duration = .{ .raw = .fromMilliseconds(50), .clock = .awake },
|
||||
.err = error.ConnectFailed,
|
||||
} } };
|
||||
var good: Fake = .{ .behavior = .{ .reply = response_bytes } };
|
||||
var entries = [_]Entry{
|
||||
testEntry("https://slow-bad.example/dns-query", &slow_bad, 10),
|
||||
testEntry("https://good.example/dns-query", &good, 20),
|
||||
};
|
||||
var pool: Pool = .init(&entries, cfg, test_timeout, 1);
|
||||
|
||||
var buf_a: [512]u8 = undefined;
|
||||
var buf_b: [512]u8 = undefined;
|
||||
|
||||
var first = io.concurrent(exchangeLen, .{ &pool, io, &buf_a }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = first.await(io) catch 0;
|
||||
var second = io.concurrent(exchangeLen, .{ &pool, io, &buf_b }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SkipZigTest,
|
||||
};
|
||||
defer _ = second.await(io) catch 0;
|
||||
|
||||
const len_a = try first.await(io);
|
||||
const len_b = try second.await(io);
|
||||
|
||||
// Both tasks fail over to the healthy entry and get an answer.
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_a[0..len_a]);
|
||||
try testing.expectEqualSlices(u8, response_bytes, buf_b[0..len_b]);
|
||||
try testing.expectEqual(@as(usize, 2), good.calls);
|
||||
|
||||
// The point of the test: the entry was attempted once, not twice. Without
|
||||
// the re-check the second task would take the lock and attempt it anyway.
|
||||
try testing.expectEqual(@as(usize, 1), slow_bad.calls);
|
||||
try testing.expectEqual(@as(u64, 1), entries[0].health.total_failures);
|
||||
try testing.expect(entries[0].health.backoff_until != null);
|
||||
}
|
||||
@@ -0,0 +1,497 @@
|
||||
//! Shared vocabulary for every upstream client: endpoint URLs, the three
|
||||
//! disjoint failure groups, the `Client` interface, and response validation.
|
||||
//!
|
||||
//! Everything here except the `Client` vtable is pure. `validateResponse` takes
|
||||
//! two byte slices and returns; `Endpoint.parse` takes text. No socket, no
|
||||
//! clock, no allocator. The transport implementations (DoH, DoT) and the pool
|
||||
//! own all of the `std.Io` work.
|
||||
//!
|
||||
//! The failure classification is the reason this file exists. Health and
|
||||
//! backoff must count only what the peer did wrong: a local `OutOfMemory` says
|
||||
//! nothing about the upstream, and `error.Canceled` says nothing at all. The
|
||||
//! three error sets below are disjoint by construction and `group` switches
|
||||
//! over them exhaustively, so a new failure mode cannot silently land in the
|
||||
//! wrong bucket.
|
||||
|
||||
const std = @import("std");
|
||||
const packet = @import("../dns/packet.zig");
|
||||
const name = @import("../dns/name.zig");
|
||||
|
||||
/// RFC 1035 §4.2.2: the TCP length prefix is 16-bit, so no DNS message can be
|
||||
/// larger than this on any transport nxdns speaks.
|
||||
pub const max_message_len = 65535;
|
||||
pub const doh_default_port = 443;
|
||||
pub const dot_default_port = 853; // RFC 7858 §3.1
|
||||
pub const doh_default_path = "/dns-query"; // RFC 8484 §4.1 well-known template
|
||||
|
||||
pub const Scheme = enum { doh, dot };
|
||||
|
||||
/// Borrowed view over the configured URL text; the caller owns the string.
|
||||
pub const Endpoint = struct {
|
||||
scheme: Scheme,
|
||||
/// The original text, for logs and the health API.
|
||||
url: []const u8,
|
||||
/// No brackets, no port. Used for SNI and certificate verification.
|
||||
host: []const u8,
|
||||
port: u16,
|
||||
/// DoH only; always starts with '/'; `doh_default_path` when absent. A DoT
|
||||
/// endpoint has no request path, so it carries "/" and nothing reads it.
|
||||
path: []const u8,
|
||||
|
||||
pub const ParseError = error{ UnsupportedScheme, MissingHost, BadPort, BadUrl };
|
||||
|
||||
const doh_prefix = "https://";
|
||||
const dot_prefix = "tls://";
|
||||
|
||||
/// `https://…` => .doh, `tls://…` => .dot (PLAN §9). Accepts `[v6]:port`.
|
||||
///
|
||||
/// Hand-written rather than a `std.Uri` round trip: `std.Uri` hands back
|
||||
/// percent-encoded components that would need re-decoding into a caller
|
||||
/// buffer, which is a lot of machinery for one household-scale config
|
||||
/// value that is an IP literal or a hostname.
|
||||
pub fn parse(url: []const u8) ParseError!Endpoint {
|
||||
const scheme: Scheme, const rest = if (std.mem.startsWith(u8, url, doh_prefix))
|
||||
.{ .doh, url[doh_prefix.len..] }
|
||||
else if (std.mem.startsWith(u8, url, dot_prefix))
|
||||
.{ .dot, url[dot_prefix.len..] }
|
||||
else
|
||||
return error.UnsupportedScheme;
|
||||
|
||||
const authority, const path = split: {
|
||||
const slash = std.mem.findScalar(u8, rest, '/') orelse break :split .{ rest, "" };
|
||||
break :split .{ rest[0..slash], rest[slash..] };
|
||||
};
|
||||
|
||||
try rejectDelimiters(authority, "@?#");
|
||||
try rejectDelimiters(path, "?#");
|
||||
|
||||
const host, const port_text = try splitAuthority(authority);
|
||||
if (host.len == 0) return error.MissingHost;
|
||||
|
||||
const port: u16 = if (port_text) |text| blk: {
|
||||
if (text.len == 0) return error.BadPort;
|
||||
break :blk std.fmt.parseInt(u16, text, 10) catch return error.BadPort;
|
||||
} else switch (scheme) {
|
||||
.doh => doh_default_port,
|
||||
.dot => dot_default_port,
|
||||
};
|
||||
|
||||
return switch (scheme) {
|
||||
.doh => .{
|
||||
.scheme = .doh,
|
||||
.url = url,
|
||||
.host = host,
|
||||
.port = port,
|
||||
.path = if (path.len == 0) doh_default_path else path,
|
||||
},
|
||||
.dot => blk: {
|
||||
// RFC 7858 frames DNS directly on the TLS stream; a path would
|
||||
// be config the transport cannot honour, so it is rejected
|
||||
// rather than ignored.
|
||||
if (path.len != 0 and !std.mem.eql(u8, path, "/")) return error.BadUrl;
|
||||
break :blk .{
|
||||
.scheme = .dot,
|
||||
.url = url,
|
||||
.host = host,
|
||||
.port = port,
|
||||
.path = "/",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// `@`, `?` and `#` open a userinfo, query or fragment component. This
|
||||
/// parser implements none of them, so keeping one as literal host or path
|
||||
/// text would let `host` disagree with the authority an RFC 3986 parser
|
||||
/// reads out of the same URL — the name verified against the certificate
|
||||
/// would not be the name dialed. A household config has no use for them,
|
||||
/// so they are a config error rather than something to strip.
|
||||
fn rejectDelimiters(text: []const u8, comptime delimiters: []const u8) ParseError!void {
|
||||
inline for (delimiters) |delimiter| {
|
||||
if (std.mem.findScalar(u8, text, delimiter) != null) return error.BadUrl;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the host without brackets and the port text when one is present.
|
||||
fn splitAuthority(authority: []const u8) ParseError!struct { []const u8, ?[]const u8 } {
|
||||
if (authority.len != 0 and authority[0] == '[') {
|
||||
const close = std.mem.findScalar(u8, authority, ']') orelse return error.BadUrl;
|
||||
const host = authority[1..close];
|
||||
const tail = authority[close + 1 ..];
|
||||
if (tail.len == 0) return .{ host, null };
|
||||
if (tail[0] != ':') return error.BadUrl;
|
||||
return .{ host, tail[1..] };
|
||||
}
|
||||
const colon = std.mem.findScalar(u8, authority, ':') orelse return .{ authority, null };
|
||||
return .{ authority[0..colon], authority[colon + 1 ..] };
|
||||
}
|
||||
};
|
||||
|
||||
/// The upstream misbehaved, timed out, or was unreachable. Only these count
|
||||
/// against health.
|
||||
pub const PeerFault = error{
|
||||
ConnectFailed,
|
||||
TlsFailed,
|
||||
SendFailed,
|
||||
ReceiveFailed,
|
||||
Timeout,
|
||||
/// Unparseable, not a response, or QDCOUNT != 1.
|
||||
BadResponse,
|
||||
/// ID or question does not match the query.
|
||||
ResponseMismatch,
|
||||
/// Does not fit the caller's buffer.
|
||||
ResponseTooLarge,
|
||||
/// DoH: status other than 200.
|
||||
HttpStatus,
|
||||
/// DoH: content-type other than application/dns-message.
|
||||
HttpContentType,
|
||||
};
|
||||
|
||||
/// This process ran out of something. Never the upstream's fault, so never
|
||||
/// recorded against an endpoint's health.
|
||||
pub const LocalResource = error{
|
||||
OutOfMemory,
|
||||
SystemResources,
|
||||
ProcessFdQuotaExceeded,
|
||||
SystemFdQuotaExceeded,
|
||||
/// A caller-supplied buffer cannot hold even a query.
|
||||
BufferTooSmall,
|
||||
Unexpected,
|
||||
};
|
||||
|
||||
pub const Cancellation = error{Canceled};
|
||||
|
||||
pub const ExchangeError = PeerFault || LocalResource || Cancellation;
|
||||
|
||||
pub const Group = enum { peer_fault, local_resource, cancellation };
|
||||
|
||||
/// Exhaustive switch over `ExchangeError` — no `else` arm. A new error member
|
||||
/// must break the build here, so no failure can silently land in the wrong
|
||||
/// group.
|
||||
pub fn group(err: ExchangeError) Group {
|
||||
return switch (err) {
|
||||
error.ConnectFailed,
|
||||
error.TlsFailed,
|
||||
error.SendFailed,
|
||||
error.ReceiveFailed,
|
||||
error.Timeout,
|
||||
error.BadResponse,
|
||||
error.ResponseMismatch,
|
||||
error.ResponseTooLarge,
|
||||
error.HttpStatus,
|
||||
error.HttpContentType,
|
||||
=> .peer_fault,
|
||||
|
||||
error.OutOfMemory,
|
||||
error.SystemResources,
|
||||
error.ProcessFdQuotaExceeded,
|
||||
error.SystemFdQuotaExceeded,
|
||||
error.BufferTooSmall,
|
||||
error.Unexpected,
|
||||
=> .local_resource,
|
||||
|
||||
error.Canceled => .cancellation,
|
||||
};
|
||||
}
|
||||
|
||||
/// Folds a foreign stdlib error into `ExchangeError` when its name matches a
|
||||
/// `LocalResource` or `Cancellation` member; `null` means the caller should
|
||||
/// classify the error as a peer fault from the phase it occurred in.
|
||||
///
|
||||
/// This is the only place a foreign error set is folded in. Everywhere else
|
||||
/// the call site names the peer fault it means, because the call site is what
|
||||
/// knows whether it was connecting, sending or receiving.
|
||||
pub fn mapLocal(err: anyerror) ?ExchangeError {
|
||||
return switch (err) {
|
||||
error.OutOfMemory => error.OutOfMemory,
|
||||
error.SystemResources => error.SystemResources,
|
||||
error.ProcessFdQuotaExceeded => error.ProcessFdQuotaExceeded,
|
||||
error.SystemFdQuotaExceeded => error.SystemFdQuotaExceeded,
|
||||
error.BufferTooSmall => error.BufferTooSmall,
|
||||
error.Unexpected => error.Unexpected,
|
||||
error.Canceled => error.Canceled,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// A thing that sends one DNS message and returns one validated DNS message.
|
||||
/// Implemented by DohClient, DotClient, Pool, and test fakes.
|
||||
pub const Client = struct {
|
||||
ptr: *anyopaque,
|
||||
exchangeFn: *const fn (
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) ExchangeError![]u8,
|
||||
|
||||
/// Returns a prefix of `response_buf`. The returned message has already
|
||||
/// passed `validateResponse` against `query`.
|
||||
pub fn exchange(
|
||||
self: Client,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) ExchangeError![]u8 {
|
||||
return self.exchangeFn(self.ptr, io, query, response_buf);
|
||||
}
|
||||
};
|
||||
|
||||
pub const ValidateError = error{ BadResponse, ResponseMismatch };
|
||||
|
||||
/// RFC 9619: exactly one question on both sides. RFC 4343: names compare
|
||||
/// case-insensitively, so a case-mangling (0x20) upstream still matches. Does
|
||||
/// not inspect the answer section — content policy is not this layer's
|
||||
/// business.
|
||||
pub fn validateResponse(query: []const u8, response: []const u8) ValidateError!void {
|
||||
const resp = packet.parse(response) catch return error.BadResponse;
|
||||
if (!resp.header.flags.qr) return error.BadResponse;
|
||||
if (resp.header.qdcount != 1) return error.BadResponse;
|
||||
|
||||
// A malformed query here is a bug in this process, not in the upstream.
|
||||
// It still may not crash, so it reports the same structural error.
|
||||
const req = packet.parse(query) catch return error.BadResponse;
|
||||
if (req.header.qdcount != 1) return error.BadResponse;
|
||||
|
||||
if (resp.header.id != req.header.id) return error.ResponseMismatch;
|
||||
|
||||
const rq = packet.firstQuestion(resp) orelse return error.BadResponse;
|
||||
const qq = packet.firstQuestion(req) orelse return error.BadResponse;
|
||||
if (rq.qtype != qq.qtype) return error.ResponseMismatch;
|
||||
if (rq.qclass != qq.qclass) return error.ResponseMismatch;
|
||||
if (!name.eqlIgnoreCase(rq.name, qq.name)) return error.ResponseMismatch;
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "parse a DoH url with an explicit path" {
|
||||
const e = try Endpoint.parse("https://cloudflare-dns.com/dns-query");
|
||||
try testing.expectEqual(Scheme.doh, e.scheme);
|
||||
try testing.expectEqualStrings("cloudflare-dns.com", e.host);
|
||||
try testing.expectEqual(@as(u16, 443), e.port);
|
||||
try testing.expectEqualStrings("/dns-query", e.path);
|
||||
try testing.expectEqualStrings("https://cloudflare-dns.com/dns-query", e.url);
|
||||
}
|
||||
|
||||
test "parse preserves a non-default DoH path" {
|
||||
const e = try Endpoint.parse("https://dns.example/x");
|
||||
try testing.expectEqualStrings("dns.example", e.host);
|
||||
try testing.expectEqualStrings("/x", e.path);
|
||||
}
|
||||
|
||||
test "parse defaults the DoH path" {
|
||||
const e = try Endpoint.parse("https://dns.example");
|
||||
try testing.expectEqualStrings("dns.example", e.host);
|
||||
try testing.expectEqual(@as(u16, 443), e.port);
|
||||
try testing.expectEqualStrings(doh_default_path, e.path);
|
||||
}
|
||||
|
||||
test "parse a DoT url with an explicit port" {
|
||||
const e = try Endpoint.parse("tls://dns.google:853");
|
||||
try testing.expectEqual(Scheme.dot, e.scheme);
|
||||
try testing.expectEqualStrings("dns.google", e.host);
|
||||
try testing.expectEqual(@as(u16, 853), e.port);
|
||||
}
|
||||
|
||||
test "parse defaults the DoT port" {
|
||||
const e = try Endpoint.parse("tls://dns.google");
|
||||
try testing.expectEqual(Scheme.dot, e.scheme);
|
||||
try testing.expectEqualStrings("dns.google", e.host);
|
||||
try testing.expectEqual(@as(u16, dot_default_port), e.port);
|
||||
}
|
||||
|
||||
test "parse strips IPv6 brackets and keeps the port" {
|
||||
const e = try Endpoint.parse("https://[2606:4700:4700::1111]:8443/dns-query");
|
||||
try testing.expectEqualStrings("2606:4700:4700::1111", e.host);
|
||||
try testing.expectEqual(@as(u16, 8443), e.port);
|
||||
try testing.expectEqualStrings("/dns-query", e.path);
|
||||
}
|
||||
|
||||
test "parse rejects an unsupported scheme" {
|
||||
try testing.expectError(error.UnsupportedScheme, Endpoint.parse("udp://1.1.1.1:53"));
|
||||
try testing.expectError(error.UnsupportedScheme, Endpoint.parse("http://dns.example/"));
|
||||
try testing.expectError(error.UnsupportedScheme, Endpoint.parse("1.1.1.1"));
|
||||
}
|
||||
|
||||
test "parse rejects an empty host" {
|
||||
try testing.expectError(error.MissingHost, Endpoint.parse("https://"));
|
||||
try testing.expectError(error.MissingHost, Endpoint.parse("https://:443/dns-query"));
|
||||
try testing.expectError(error.MissingHost, Endpoint.parse("tls://"));
|
||||
}
|
||||
|
||||
test "parse rejects a bad port" {
|
||||
try testing.expectError(error.BadPort, Endpoint.parse("https://h:99999/"));
|
||||
try testing.expectError(error.BadPort, Endpoint.parse("https://h:/"));
|
||||
try testing.expectError(error.BadPort, Endpoint.parse("https://h:abc/"));
|
||||
try testing.expectError(error.BadPort, Endpoint.parse("tls://[::1]:70000"));
|
||||
}
|
||||
|
||||
test "parse rejects a malformed url" {
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://[::1"));
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://[::1]x"));
|
||||
// A DoT endpoint has no request path.
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google/dns-query"));
|
||||
// A bare trailing slash is not a path, so it is accepted.
|
||||
try testing.expectEqual(Scheme.dot, (try Endpoint.parse("tls://dns.google/")).scheme);
|
||||
}
|
||||
|
||||
test "parse rejects userinfo" {
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://user@host/"));
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://user:pass@host/dns-query"));
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("tls://user@dns.google:853"));
|
||||
}
|
||||
|
||||
test "parse rejects a query string" {
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://host?x"));
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://host/dns-query?x=1"));
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google/?x"));
|
||||
}
|
||||
|
||||
test "parse rejects a fragment" {
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://host#f"));
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("https://host/dns-query#f"));
|
||||
try testing.expectError(error.BadUrl, Endpoint.parse("tls://dns.google#f"));
|
||||
}
|
||||
|
||||
test "the three error groups are disjoint" {
|
||||
const sets = .{ PeerFault, LocalResource, Cancellation };
|
||||
inline for (sets, 0..) |a, i| {
|
||||
inline for (sets, 0..) |b, j| {
|
||||
if (i >= j) continue;
|
||||
inline for (@typeInfo(a).error_set.?) |member_a| {
|
||||
inline for (@typeInfo(b).error_set.?) |member_b| {
|
||||
try testing.expect(!std.mem.eql(u8, member_a.name, member_b.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every member of the union belongs to exactly one group, which `group`
|
||||
// proves by being an exhaustive switch. Assert the counts line up so a
|
||||
// member added to two sets at once cannot pass unnoticed.
|
||||
const total = @typeInfo(PeerFault).error_set.?.len +
|
||||
@typeInfo(LocalResource).error_set.?.len +
|
||||
@typeInfo(Cancellation).error_set.?.len;
|
||||
try testing.expectEqual(total, @typeInfo(ExchangeError).error_set.?.len);
|
||||
}
|
||||
|
||||
test "group classifies each member" {
|
||||
try testing.expectEqual(Group.peer_fault, group(error.Timeout));
|
||||
try testing.expectEqual(Group.peer_fault, group(error.HttpContentType));
|
||||
try testing.expectEqual(Group.local_resource, group(error.OutOfMemory));
|
||||
try testing.expectEqual(Group.local_resource, group(error.BufferTooSmall));
|
||||
try testing.expectEqual(Group.cancellation, group(error.Canceled));
|
||||
}
|
||||
|
||||
test "mapLocal folds only local and cancellation errors" {
|
||||
try testing.expectEqual(@as(?ExchangeError, error.OutOfMemory), mapLocal(error.OutOfMemory));
|
||||
try testing.expectEqual(@as(?ExchangeError, error.Canceled), mapLocal(error.Canceled));
|
||||
try testing.expectEqual(@as(?ExchangeError, error.Unexpected), mapLocal(error.Unexpected));
|
||||
try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.ConnectionRefused));
|
||||
try testing.expectEqual(@as(?ExchangeError, null), mapLocal(error.TlsInitializationFailed));
|
||||
}
|
||||
|
||||
test "a fake client satisfies the Client interface" {
|
||||
const Fake = struct {
|
||||
calls: usize = 0,
|
||||
|
||||
fn exchangeFn(
|
||||
ptr: *anyopaque,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) ExchangeError![]u8 {
|
||||
_ = io;
|
||||
const self: *@This() = @ptrCast(@alignCast(ptr));
|
||||
self.calls += 1;
|
||||
if (query.len > response_buf.len) return error.ResponseTooLarge;
|
||||
@memcpy(response_buf[0..query.len], query);
|
||||
return response_buf[0..query.len];
|
||||
}
|
||||
|
||||
fn client(self: *@This()) Client {
|
||||
return .{ .ptr = self, .exchangeFn = exchangeFn };
|
||||
}
|
||||
};
|
||||
|
||||
var fake: Fake = .{};
|
||||
var buf: [16]u8 = undefined;
|
||||
const echoed = try fake.client().exchange(undefined, "hello", &buf);
|
||||
try testing.expectEqualStrings("hello", echoed);
|
||||
try testing.expectEqual(@as(usize, 1), fake.calls);
|
||||
}
|
||||
|
||||
/// A query for example.com A: id 0x1234, RD set, one question.
|
||||
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";
|
||||
|
||||
test "validateResponse accepts a matching pair" {
|
||||
try validateResponse(query_bytes, response_bytes);
|
||||
}
|
||||
|
||||
test "validateResponse accepts a mixed-case question name" {
|
||||
const mangled =
|
||||
"\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";
|
||||
try validateResponse(query_bytes, mangled);
|
||||
}
|
||||
|
||||
test "validateResponse rejects a wrong id" {
|
||||
var bytes: [response_bytes.len]u8 = response_bytes.*;
|
||||
packet.setId(&bytes, 0x4321);
|
||||
try testing.expectError(error.ResponseMismatch, validateResponse(query_bytes, &bytes));
|
||||
}
|
||||
|
||||
test "validateResponse rejects a different qtype" {
|
||||
const aaaa =
|
||||
"\x12\x34\x81\x80\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x1c\x00\x01";
|
||||
try testing.expectError(error.ResponseMismatch, validateResponse(query_bytes, aaaa));
|
||||
}
|
||||
|
||||
test "validateResponse rejects a different question name" {
|
||||
const other =
|
||||
"\x12\x34\x81\x80\x00\x01\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03org\x00\x00\x01\x00\x01";
|
||||
try testing.expectError(error.ResponseMismatch, validateResponse(query_bytes, other));
|
||||
}
|
||||
|
||||
test "validateResponse rejects a response with QR clear" {
|
||||
try testing.expectError(error.BadResponse, validateResponse(query_bytes, query_bytes));
|
||||
}
|
||||
|
||||
test "validateResponse rejects a response with QDCOUNT 0" {
|
||||
const no_question = "\x12\x34\x81\x80\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
try testing.expectError(error.BadResponse, validateResponse(query_bytes, no_question));
|
||||
}
|
||||
|
||||
test "validateResponse rejects a response with QDCOUNT 2" {
|
||||
const two =
|
||||
"\x12\x34\x81\x80\x00\x02\x00\x00\x00\x00\x00\x00" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01" ++
|
||||
"\x07example\x03com\x00\x00\x01\x00\x01";
|
||||
try testing.expectError(error.BadResponse, validateResponse(query_bytes, two));
|
||||
}
|
||||
|
||||
test "validateResponse rejects truncated garbage" {
|
||||
try testing.expectError(error.BadResponse, validateResponse(query_bytes, "\x12\x34\x81"));
|
||||
try testing.expectError(error.BadResponse, validateResponse(query_bytes, ""));
|
||||
try testing.expectError(
|
||||
error.BadResponse,
|
||||
validateResponse(query_bytes, response_bytes[0 .. response_bytes.len - 3]),
|
||||
);
|
||||
}
|
||||
|
||||
test "validateResponse rejects a query that does not carry exactly one question" {
|
||||
const no_question = "\x12\x34\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00";
|
||||
try testing.expectError(error.BadResponse, validateResponse(no_question, response_bytes));
|
||||
try testing.expectError(error.BadResponse, validateResponse("\x12\x34", response_bytes));
|
||||
}
|
||||
Reference in New Issue
Block a user