//! 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 `server/handler.zig` //! 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; /// `tcp://[` + the longest IPv6 text form + `]:65535`, the widest spelling /// `identityText` can produce. pub const max_identity_len: usize = "tcp://[".len + 45 + "]:65535".len; pub const ForwardClient = struct { resolver: validate.Resolver, /// The resolver as text, owned here so the `transport.Client` out-parameter /// has something stable to borrow: `validate.Resolver` is a parsed address, /// and a caller logging the exchange needs its spelling. identity_buf: [max_identity_len]u8 = undefined, identity_len: usize = 0, /// 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); var self: ForwardClient = .{ .resolver = resolver, .frame_buf = frame_buf, .read_timeout = read_timeout, }; self.identity_len = identityText(resolver, &self.identity_buf).len; return self; } /// `udp://192.168.1.1:53`, `tcp://[fd00::1]:53` — the same spelling /// `validate.parseResolver` accepts, so a log row names the configured /// value. Valid for as long as this client is. pub fn identity(self: *const ForwardClient) []const u8 { return self.identity_buf[0..self.identity_len]; } 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, selected: *?[]const u8, ) transport.ExchangeError![]u8 { const self: *ForwardClient = @ptrCast(@alignCast(ptr)); // Set before the attempt: a failed forward-zone exchange still names // the resolver it was sent to. selected.* = self.identity(); 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. /// /// `read_timeout` bounds the WHOLE exchange, truncation fallback included: /// the instant is computed once here and every blocking step inside runs /// against it, so a truncated UDP answer followed by a stalled TCP retry /// costs one budget rather than two. 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; const expiry_at: std.Io.Clock.Timestamp = .fromNow(io, self.read_timeout); // The outcome is deliberately discarded. A forward zone has exactly one // configured resolver, so an expiry here is still that resolver failing // to answer in time: `error.Timeout` is peer evidence, and the // budget/peer distinction is upstream-pool policy. var outcome: transport.RaceOutcome = .completed; return transport.raceUntilTagged(io, expiry_at, &outcome, route, .{ self, io, query, response_buf, expiry_at, }) catch |err| { switch (transport.group(err)) { .peer_fault, .local_resource => self.stats.failures += 1, .cancellation, .budget_exhausted => {}, } return err; }; } fn route( self: *ForwardClient, io: std.Io, query: []const u8, response_buf: []u8, expiry_at: std.Io.Clock.Timestamp, ) transport.ExchangeError![]u8 { if (self.resolver.scheme == .udp) { if (try self.exchangeUdp(io, query, response_buf, expiry_at)) |reply| return reply; } return self.tcpOnce(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, expiry_at: std.Io.Clock.Timestamp, ) 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 transport.mapPhase(err, error.ConnectFailed); }; defer transport.closeBlocked(io, &socket); socket.send(io, &dest, query) catch |err| { log.debug("forward resolver: udp send failed: {s}", .{@errorName(err)}); return transport.mapPhase(err, error.SendFailed); }; // The exchange-wide instant, not a fresh duration: a discarded foreign // datagram restarts the receive, and a duration would hand each retry // the full budget again. const deadline: std.Io.Timeout = .{ .deadline = expiry_at }; 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 transport.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; } } /// Unbounded on its own: `exchange` runs it inside the exchange-wide race, /// which is what cancels a stalled connect or read. It takes no budget of /// its own, so a truncation fallback does not start a second one. /// `ConnectOptions.timeout` is never set: the Threaded backend panics on it /// (Threaded.zig:12076). 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 transport.mapPhase(err, error.ConnectFailed); }; defer transport.closeBlocked(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); } }; fn identityText(resolver: validate.Resolver, buf: *[max_identity_len]u8) []const u8 { var w: std.Io.Writer = .fixed(buf); w.writeAll(switch (resolver.scheme) { .udp => "udp://", .tcp => "tcp://", }) catch unreachable; const bracketed = switch (resolver.addr) { .ip4 => false, .ip6 => true, }; if (bracketed) w.writeByte('[') catch unreachable; resolver.addr.format(&w) catch unreachable; if (bracketed) w.writeByte(']') catch unreachable; w.print(":{d}", .{resolver.port}) catch unreachable; return w.buffered(); } /// 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) }, }; } /// `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 transport.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 transport.mapPhase(cause, error.ReceiveFailed); } const testing = std.testing; const build_options = @import("build_options"); const name_mod = @import("../dns/name.zig"); const packet = @import("../dns/packet.zig"); const question = @import("../dns/question.zig"); const types = @import("../dns/types.zig"); 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 client owns its resolver identity in both address families" { var buf = testBuf(); const v4: ForwardClient = .init( try validate.parseResolver("udp://192.168.1.1:5300"), &buf, .{ .raw = .fromSeconds(1), .clock = .awake }, ); try testing.expectEqualStrings("udp://192.168.1.1:5300", v4.identity()); var buf6 = testBuf(); const v6: ForwardClient = .init( try validate.parseResolver("tcp://[fd00::1]:5353"), &buf6, .{ .raw = .fromSeconds(1), .clock = .awake }, ); try testing.expectEqualStrings("tcp://[fd00::1]:5353", v6.identity()); // The borrow points into the client, not into `init`'s frame. try testing.expect(@intFromPtr(v6.identity().ptr) >= @intFromPtr(&v6)); } 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(transport.mapPhase(err, error.ReceiveFailed)), ); } try testing.expectEqual( transport.ExchangeError.Canceled, transport.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, transport.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), ); } const one_budget_ms = 400; /// Late enough in the budget that a second, fresh budget for the TCP leg would /// be unmistakable in the elapsed time. const truncate_after_ms = 300; /// Answers the first datagram late in the budget with TC=1, which sends the /// client to TCP — where a listener that never accepts leaves it stalled. fn truncatingThenStallingResolver(io: std.Io, socket: *const net.Socket) void { var buf: [2048]u8 = undefined; const msg = socket.receive(io, &buf) catch return; const request = packet.parse(msg.data) catch return; (std.Io.Clock.Duration{ .raw = .fromMilliseconds(truncate_after_ms), .clock = .awake, }).sleep(io) catch return; // The question has to be echoed: `transport.validateResponse` runs before // the client reads the TC bit, so a bare header would come back as // `BadResponse` and never reach the TCP fallback this case is about. const q = packet.firstQuestion(request) orelse return; var reply_buf: [512]u8 = undefined; var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch return; const reply = b.finish(); var parsed = dns_header.parse(reply) catch return; parsed.flags.tc = true; dns_header.encode(parsed, reply[0..types.header_len]); socket.send(io, &msg.from, reply) catch return; } fn testQuery(io: std.Io, buf: []u8) ![]const u8 { var id_bytes: [2]u8 = undefined; io.random(&id_bytes); dns_header.encode(.{ .id = std.mem.readInt(u16, &id_bytes, .big), .flags = .{ .rcode = .no_error, .z = 0, .ra = false, .rd = true, .tc = false, .aa = false, .opcode = .query, .qr = false, }, .qdcount = 1, .ancount = 0, .nscount = 0, .arcount = 0, }, buf[0..types.header_len]); var w: std.Io.Writer = .fixed(buf[types.header_len..]); try question.encode(.{ .name = try name_mod.fromText("nas.lan"), .qtype = .a, .qclass = .in, }, &w); return buf[0 .. types.header_len + w.buffered().len]; } test "one budget covers the udp leg, the TC=1 fallback and the tcp leg" { if (!build_options.integration) return error.SkipZigTest; var threaded: std.Io.Threaded = .init(testing.allocator, .{}); defer threaded.deinit(); const io = threaded.io(); const bind_address: net.IpAddress = try .parse("127.0.0.1", 0); const socket = try bind_address.bind(io, .{ .mode = .dgram }); defer socket.close(io); const port = socket.address.ip4.port; // Bound but never accepted: the connect completes out of the kernel's // backlog and the read then waits forever, which is the stall a second // budget would be spent on. const tcp_address: net.IpAddress = try .parse("127.0.0.1", port); var tcp_listener = try tcp_address.listen(io, .{ .reuse_address = true }); defer tcp_listener.deinit(io); var group: std.Io.Group = .init; defer group.cancel(io); try group.concurrent(io, truncatingThenStallingResolver, .{ io, &socket }); var url_buf: [64]u8 = undefined; const url = try std.fmt.bufPrint(&url_buf, "udp://127.0.0.1:{d}", .{port}); var frame_buf: [min_frame_buf]u8 = undefined; var fc: ForwardClient = .init( try validate.parseResolver(url), &frame_buf, .{ .raw = .fromMilliseconds(one_budget_ms), .clock = .awake }, ); var query_buf: [types.header_len + types.max_name_len + 4]u8 = undefined; const query = try testQuery(io, &query_buf); var response_buf: [2048]u8 = undefined; const started = std.Io.Clock.awake.now(io); try testing.expectError(error.Timeout, fc.exchange(io, query, &response_buf)); const elapsed = started.durationTo(std.Io.Clock.awake.now(io)).nanoseconds; // Two budgets would spend 300 ms on the UDP leg and then a fresh 400 ms on // the stalled TCP leg. The bound sits between one budget and that sum, so // the double-budget shape cannot pass. try testing.expect(elapsed < @as(i96, one_budget_ms + truncate_after_ms / 2) * std.time.ns_per_ms); try testing.expectEqual(@as(u64, 1), fc.stats.udp_truncated); try testing.expectEqual(@as(u64, 1), fc.stats.failures); }