milestone 5: blocklist filtering, local records and conditional forwarding
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
//! Plain UDP/TCP resolver client for conditional forward zones (PLAN §6.5).
|
||||
//!
|
||||
//! A forward zone points at a box on the LAN — a router, a NAS, an internal
|
||||
//! resolver — which speaks port 53 and nothing else. `transport.Endpoint` knows
|
||||
//! only `https://` and `tls://` by design, so the configuration for this client
|
||||
//! comes from `validate.Resolver` instead. The interface it implements is the
|
||||
//! same `transport.Client` every upstream implements, so the Phase 7 handler
|
||||
//! treats a forward zone exactly like any other exchange.
|
||||
//!
|
||||
//! No health tracking and no backoff live here. `upstream/health.zig` and
|
||||
//! `upstream/pool.zig` model the upstream *pool*, where failing over to a second
|
||||
//! endpoint is the whole point. A forward zone has exactly one designated
|
||||
//! resolver and no failover partner, so a backoff would only add latency to a
|
||||
//! failure the caller already sees. Their absence is a decision, not an
|
||||
//! oversight.
|
||||
//!
|
||||
//! One `ForwardClient` is used by one task at a time: `stats` is a plain struct
|
||||
//! and `frame_buf` is not shared.
|
||||
|
||||
const std = @import("std");
|
||||
const net = std.Io.net;
|
||||
|
||||
const transport = @import("../upstream/transport.zig");
|
||||
const validate = @import("../config/validate.zig");
|
||||
const dns_header = @import("../dns/header.zig");
|
||||
|
||||
const log = std.log.scoped(.forward_client);
|
||||
|
||||
/// RFC 1035 §4.2.2 length prefix for DNS over TCP.
|
||||
/// The TCP path splits `frame_buf` between the socket writer and the socket
|
||||
/// reader. Neither half has to hold a whole message — the reply is read
|
||||
/// straight into the caller's `response_buf` — so this is a floor that keeps
|
||||
/// each half large enough to frame a query in one write, not a capacity.
|
||||
pub const min_frame_buf: usize = 1024;
|
||||
|
||||
pub const ForwardClient = struct {
|
||||
resolver: validate.Resolver,
|
||||
/// Caller-owned scratch for the TCP length-prefixed path.
|
||||
frame_buf: []u8,
|
||||
/// On the `.awake` clock at the caller's choosing, so a suspended host does
|
||||
/// not burn the budget while it sleeps.
|
||||
read_timeout: std.Io.Clock.Duration,
|
||||
stats: Stats = .{},
|
||||
|
||||
pub const Stats = struct {
|
||||
queries: u64 = 0,
|
||||
/// TC=1 over UDP, so the exchange was retried over TCP.
|
||||
udp_truncated: u64 = 0,
|
||||
/// A datagram arrived from an address other than the resolver's. It was
|
||||
/// discarded and the receive retried within the remaining budget, which
|
||||
/// is invisible to the caller and would otherwise be an unrecorded
|
||||
/// failure mode.
|
||||
foreign_datagrams: u64 = 0,
|
||||
/// Exchanges that returned a peer fault or a local resource error.
|
||||
/// A cancellation is neither, so it is not counted.
|
||||
failures: u64 = 0,
|
||||
};
|
||||
|
||||
/// An undersized `frame_buf` is a wiring bug in this process, not a runtime
|
||||
/// condition, so it is an assertion.
|
||||
pub fn init(
|
||||
resolver: validate.Resolver,
|
||||
frame_buf: []u8,
|
||||
read_timeout: std.Io.Clock.Duration,
|
||||
) ForwardClient {
|
||||
std.debug.assert(frame_buf.len >= min_frame_buf);
|
||||
return .{
|
||||
.resolver = resolver,
|
||||
.frame_buf = frame_buf,
|
||||
.read_timeout = read_timeout,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn client(self: *ForwardClient) 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: *ForwardClient = @ptrCast(@alignCast(ptr));
|
||||
return self.exchange(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// `.udp` resolvers send one datagram and fall back to TCP when the answer
|
||||
/// comes back with TC=1. `.tcp` resolvers skip straight to the TCP path.
|
||||
pub fn exchange(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
// The TCP length prefix is 16-bit, so a longer query cannot be framed.
|
||||
if (query.len > transport.max_message_len) return error.BufferTooSmall;
|
||||
if (response_buf.len == 0) return error.BufferTooSmall;
|
||||
|
||||
self.stats.queries += 1;
|
||||
return self.route(io, query, response_buf) catch |err| {
|
||||
switch (transport.group(err)) {
|
||||
.peer_fault, .local_resource => self.stats.failures += 1,
|
||||
.cancellation => {},
|
||||
}
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn route(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
if (self.resolver.scheme == .udp) {
|
||||
if (try self.exchangeUdp(io, query, response_buf)) |reply| return reply;
|
||||
}
|
||||
return self.exchangeTcp(io, query, response_buf);
|
||||
}
|
||||
|
||||
/// `null` means the resolver set TC=1 and the caller must retry over TCP.
|
||||
///
|
||||
/// The socket is bound to the wildcard address of the resolver's family on
|
||||
/// an ephemeral port, so the kernel picks the source port for every
|
||||
/// exchange rather than this process reusing one.
|
||||
fn exchangeUdp(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError!?[]u8 {
|
||||
const dest = self.destination();
|
||||
const local = wildcardFor(dest);
|
||||
|
||||
const socket = local.bind(io, .{ .mode = .dgram }) catch |err| {
|
||||
log.debug("forward resolver: udp bind failed: {s}", .{@errorName(err)});
|
||||
return mapPhase(err, error.ConnectFailed);
|
||||
};
|
||||
defer closeSocket(io, &socket);
|
||||
|
||||
socket.send(io, &dest, query) catch |err| {
|
||||
log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)});
|
||||
return mapPhase(err, error.SendFailed);
|
||||
};
|
||||
|
||||
// A deadline, not a duration: a discarded foreign datagram restarts the
|
||||
// receive, and a duration would hand each retry the full budget again.
|
||||
const deadline = (std.Io.Timeout{ .duration = self.read_timeout }).toDeadline(io);
|
||||
|
||||
while (true) {
|
||||
const msg = socket.receiveTimeout(io, response_buf, deadline) catch |err| switch (err) {
|
||||
error.Timeout => return error.Timeout,
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
else => return mapPhase(err, error.ReceiveFailed),
|
||||
};
|
||||
|
||||
// Off-path spoofing is the reason the source address is checked at
|
||||
// all: the first datagram to arrive is not necessarily the
|
||||
// resolver's.
|
||||
if (!msg.from.eql(&dest)) {
|
||||
self.stats.foreign_datagrams += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// The kernel threw the tail away because `response_buf` was too
|
||||
// small, so the message cannot be parsed and TC=1 cannot be read
|
||||
// out of it.
|
||||
if (msg.flags.trunc) return error.ResponseTooLarge;
|
||||
|
||||
const reply = response_buf[0..msg.data.len];
|
||||
try transport.validateResponse(query, reply);
|
||||
|
||||
// Read after validation: acting on the TC bit of a message that has
|
||||
// not been matched to the query would let anything that reaches the
|
||||
// socket force a TCP connection.
|
||||
const parsed = dns_header.parse(reply) catch return error.BadResponse;
|
||||
if (parsed.flags.tc) {
|
||||
self.stats.udp_truncated += 1;
|
||||
return null;
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
}
|
||||
|
||||
/// No stream read or write in 0.16.0 takes a timeout, so the budget is a
|
||||
/// second task and the loser is canceled. `ConnectOptions.timeout` is never
|
||||
/// set: the Threaded backend panics on it (Threaded.zig:12076).
|
||||
fn exchangeTcp(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
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, tcpOnce, .{ self, io, query, response_buf }) catch |err| switch (err) {
|
||||
error.ConcurrencyUnavailable => return error.SystemResources,
|
||||
};
|
||||
race.concurrent(.expiry, expire, .{ io, self.read_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 resolver is slow.
|
||||
try result;
|
||||
return error.Timeout;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn tcpOnce(
|
||||
self: *ForwardClient,
|
||||
io: std.Io,
|
||||
query: []const u8,
|
||||
response_buf: []u8,
|
||||
) transport.ExchangeError![]u8 {
|
||||
const dest = self.destination();
|
||||
|
||||
const stream = dest.connect(io, .{ .mode = .stream }) catch |err| {
|
||||
log.debug("forward resolver: tcp connect failed: {s}", .{@errorName(err)});
|
||||
return mapPhase(err, error.ConnectFailed);
|
||||
};
|
||||
defer closeStream(io, &stream);
|
||||
|
||||
const split = self.frame_buf.len / 2;
|
||||
var stream_writer = stream.writer(io, self.frame_buf[0..split]);
|
||||
var stream_reader = stream.reader(io, self.frame_buf[split..]);
|
||||
|
||||
const w = &stream_writer.interface;
|
||||
const prefix = transport.framePrefix(@intCast(query.len));
|
||||
w.writeAll(&prefix) catch |err| return sendFailure(&stream_writer, err);
|
||||
w.writeAll(query) catch |err| return sendFailure(&stream_writer, err);
|
||||
w.flush() catch |err| return sendFailure(&stream_writer, err);
|
||||
|
||||
const r = &stream_reader.interface;
|
||||
var prefix_bytes: [transport.prefix_len]u8 = undefined;
|
||||
r.readSliceAll(&prefix_bytes) catch |err| return receiveFailure(&stream_reader, err);
|
||||
|
||||
// RFC 1035 §4.2.2 gives no meaning to a zero-length message.
|
||||
const len = transport.parsePrefix(prefix_bytes);
|
||||
if (len == 0) return error.BadResponse;
|
||||
if (len > response_buf.len) return error.ResponseTooLarge;
|
||||
r.readSliceAll(response_buf[0..len]) catch |err| return receiveFailure(&stream_reader, err);
|
||||
|
||||
try transport.validateResponse(query, response_buf[0..len]);
|
||||
return response_buf[0..len];
|
||||
}
|
||||
|
||||
fn destination(self: *const ForwardClient) net.IpAddress {
|
||||
return self.resolver.addr.toIp(self.resolver.port);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// The local address a datagram to `dest` is sent from: same family, port
|
||||
/// chosen by the kernel.
|
||||
fn wildcardFor(dest: net.IpAddress) net.IpAddress {
|
||||
return switch (dest) {
|
||||
.ip4 => .{ .ip4 = .unspecified(0) },
|
||||
.ip6 => .{ .ip6 = .unspecified(0) },
|
||||
};
|
||||
}
|
||||
|
||||
/// The TCP budget cancels the exchange task. The next cancelable `Io` call in
|
||||
/// the `defer` chain would then return `error.Canceled` and skip the close,
|
||||
/// leaking the descriptor, so both closes run with cancellation blocked.
|
||||
fn closeStream(io: std.Io, stream: *const net.Stream) void {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
stream.close(io);
|
||||
}
|
||||
|
||||
fn closeSocket(io: std.Io, socket: *const net.Socket) void {
|
||||
const prev = io.swapCancelProtection(.blocked);
|
||||
defer _ = io.swapCancelProtection(prev);
|
||||
socket.close(io);
|
||||
}
|
||||
|
||||
fn mapPhase(err: anyerror, phase: transport.PeerFault) transport.ExchangeError {
|
||||
return transport.mapLocal(err) orelse phase;
|
||||
}
|
||||
|
||||
/// `Io.Writer` collapses everything to `error.WriteFailed` and stashes the
|
||||
/// cause. Unwrapping it is what keeps `error.Canceled` and the local resource
|
||||
/// errors out of the peer fault group.
|
||||
fn sendFailure(stream_writer: *const net.Stream.Writer, err: anyerror) transport.ExchangeError {
|
||||
const cause: anyerror = if (err == error.WriteFailed and stream_writer.err != null)
|
||||
stream_writer.err.?
|
||||
else
|
||||
err;
|
||||
return mapPhase(cause, error.SendFailed);
|
||||
}
|
||||
|
||||
fn receiveFailure(stream_reader: *const net.Stream.Reader, err: anyerror) transport.ExchangeError {
|
||||
const cause: anyerror = if (err == error.ReadFailed and stream_reader.err != null)
|
||||
stream_reader.err.?
|
||||
else
|
||||
err;
|
||||
return mapPhase(cause, error.ReceiveFailed);
|
||||
}
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
fn testBuf() [min_frame_buf]u8 {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
test "ForwardClient satisfies the Client interface" {
|
||||
var buf = testBuf();
|
||||
var fc: ForwardClient = .init(
|
||||
try validate.parseResolver("udp://192.168.1.1:53"),
|
||||
&buf,
|
||||
.{ .raw = .fromMilliseconds(500), .clock = .awake },
|
||||
);
|
||||
|
||||
const iface: transport.Client = fc.client();
|
||||
try testing.expectEqual(@as(*anyopaque, @ptrCast(&fc)), iface.ptr);
|
||||
try testing.expectEqual(validate.ResolverScheme.udp, fc.resolver.scheme);
|
||||
try testing.expectEqual(@as(u16, 53), fc.resolver.port);
|
||||
}
|
||||
|
||||
test "the stats struct starts at zero" {
|
||||
const stats: ForwardClient.Stats = .{};
|
||||
try testing.expectEqual(@as(u64, 0), stats.queries);
|
||||
try testing.expectEqual(@as(u64, 0), stats.udp_truncated);
|
||||
try testing.expectEqual(@as(u64, 0), stats.foreign_datagrams);
|
||||
try testing.expectEqual(@as(u64, 0), stats.failures);
|
||||
}
|
||||
|
||||
test "init keeps a tcp resolver on the tcp path" {
|
||||
var buf = testBuf();
|
||||
const fc: ForwardClient = .init(
|
||||
try validate.parseResolver("tcp://[fd00::1]:5353"),
|
||||
&buf,
|
||||
.{ .raw = .fromSeconds(2), .clock = .awake },
|
||||
);
|
||||
try testing.expectEqual(validate.ResolverScheme.tcp, fc.resolver.scheme);
|
||||
try testing.expectEqual(@as(u16, 5353), fc.resolver.port);
|
||||
|
||||
const dest = fc.destination();
|
||||
try testing.expectEqual(net.IpAddress.Family.ip6, std.meta.activeTag(dest));
|
||||
try testing.expectEqual(@as(u16, 5353), dest.getPort());
|
||||
}
|
||||
|
||||
test "the destination carries the resolver's address and port" {
|
||||
var buf = testBuf();
|
||||
const fc: ForwardClient = .init(
|
||||
try validate.parseResolver("udp://192.168.1.1:5300"),
|
||||
&buf,
|
||||
.{ .raw = .fromSeconds(1), .clock = .awake },
|
||||
);
|
||||
const dest = fc.destination();
|
||||
try testing.expectEqualSlices(u8, &.{ 192, 168, 1, 1 }, &dest.ip4.bytes);
|
||||
try testing.expectEqual(@as(u16, 5300), dest.ip4.port);
|
||||
}
|
||||
|
||||
test "only the resolver's own address and port count as its datagram" {
|
||||
const dest: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 } };
|
||||
|
||||
const same: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 } };
|
||||
try testing.expect(same.eql(&dest));
|
||||
|
||||
// A different host, the right host on a different port, and the right
|
||||
// address in the wrong family are each a datagram this client discards.
|
||||
const other_host: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 2 }, .port = 53 } };
|
||||
try testing.expect(!other_host.eql(&dest));
|
||||
|
||||
const other_port: net.IpAddress = .{ .ip4 = .{ .bytes = .{ 192, 168, 1, 1 }, .port = 5353 } };
|
||||
try testing.expect(!other_port.eql(&dest));
|
||||
|
||||
const mapped: net.IpAddress = .{ .ip6 = .fromIp4(.{ .bytes = .{ 192, 168, 1, 1 }, .port = 53 }) };
|
||||
try testing.expect(!mapped.eql(&dest));
|
||||
}
|
||||
|
||||
test "the local socket matches the resolver's family and takes an ephemeral port" {
|
||||
const v4 = wildcardFor(.{ .ip4 = .{ .bytes = .{ 1, 1, 1, 1 }, .port = 53 } });
|
||||
try testing.expectEqual(net.IpAddress.Family.ip4, std.meta.activeTag(v4));
|
||||
try testing.expectEqual(@as(u16, 0), v4.getPort());
|
||||
try testing.expectEqualSlices(u8, &.{ 0, 0, 0, 0 }, &v4.ip4.bytes);
|
||||
|
||||
const v6 = wildcardFor(.{ .ip6 = .unspecified(53) });
|
||||
try testing.expectEqual(net.IpAddress.Family.ip6, std.meta.activeTag(v6));
|
||||
try testing.expectEqual(@as(u16, 0), v6.getPort());
|
||||
}
|
||||
|
||||
test "mapPhase keeps local resource and cancellation errors out of the peer fault group" {
|
||||
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.ReceiveFailed)),
|
||||
);
|
||||
}
|
||||
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.Canceled,
|
||||
mapPhase(error.Canceled, error.ConnectFailed),
|
||||
);
|
||||
|
||||
// A refused connection is the resolver's side, so it stays a peer fault.
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.ConnectFailed,
|
||||
mapPhase(error.ConnectionRefused, error.ConnectFailed),
|
||||
);
|
||||
}
|
||||
|
||||
test "a stashed stream error is preferred over the collapsed one" {
|
||||
var stream_writer: net.Stream.Writer = undefined;
|
||||
stream_writer.err = error.Canceled;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.Canceled,
|
||||
sendFailure(&stream_writer, error.WriteFailed),
|
||||
);
|
||||
|
||||
stream_writer.err = error.ConnectionResetByPeer;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.SendFailed,
|
||||
sendFailure(&stream_writer, error.WriteFailed),
|
||||
);
|
||||
|
||||
var stream_reader: net.Stream.Reader = undefined;
|
||||
stream_reader.err = error.SystemResources;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.SystemResources,
|
||||
receiveFailure(&stream_reader, error.ReadFailed),
|
||||
);
|
||||
|
||||
// A peer that closes mid-frame never reaches `err`, so the collapsed error
|
||||
// is what classifies it.
|
||||
stream_reader.err = null;
|
||||
try testing.expectEqual(
|
||||
transport.ExchangeError.ReceiveFailed,
|
||||
receiveFailure(&stream_reader, error.EndOfStream),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user