//! 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 model = @import("../config/model.zig"); const response = @import("../filter/response.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 upstream_owner = @import("../upstream/owner.zig"); const testing = std.testing; const blocking_defaults: model.Blocking = .{}; const blocking: response.Options = .{ .mode = blocking_defaults.response, .ttl = blocking_defaults.ttl, }; const forward_timeout: std.Io.Clock.Duration = .{ .raw = model.readTimeout(.{}), .clock = .awake, }; /// An upstream and nothing else optional: no filtering, no cache, no log. The /// listener is what these tests exercise, so the handler is the same bare one /// its own tests use. fn bareHandler(up: *upstream_owner.Owner) handler.Handler { return .{ .upstream = up, .policy = .{ .blocking = blocking, .forward_read_timeout = forward_timeout }, }; } 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, selected: *?[]const u8, ) transport.ExchangeError![]u8 { _ = io; selected.* = "fake://tcp-server-upstream"; 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(&transport.framePrefix(@intCast(query_bytes.len))); try writer.interface.writeAll(query_bytes); try writer.interface.flush(); const len = transport.parsePrefix((try reader.interface.takeArray(transport.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_owner: upstream_owner.Borrowed = .{}; var h = bareHandler(h_owner.client(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.core.stats.connections.load(.monotonic)); try testing.expectEqual(@as(u64, 0), server.core.stats.rejected_at_capacity.load(.monotonic)); try testing.expectEqual(@as(u64, 2), h.stats.queries.load(.monotonic)); server.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; } test "the claimed slot records the connecting client" { 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_owner: upstream_owner.Borrowed = .{}; var h = bareHandler(h_owner.client(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 }); // The only connection took slot 0, and the reply the client already read // was written after `claim` filled the slot in, so this read races nothing. // Without a real peer the handler would rate-limit, group and log every TCP // client under whatever the uninitialized slot happened to hold. const peer = server.core.conns[0].peer; try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer)); try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes); try testing.expect(peer.ip4.port != 0); server.deinit(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; } /// `std.Io.Group` takes only `Cancelable!void`, so the result is reported out /// of band. `answered` is set either way — a client that fails early must not /// leave the test blocked waiting for a reply that will never come, and `set` /// is documented to have no effect the second time. fn holdConnection( io: std.Io, address: net.IpAddress, answered: *std.Io.Event, result: *anyerror!void, ) void { result.* = holdConnectionOpen(io, address, answered); answered.set(io); } /// Holds a connection open the way RFC 7766 lets a real client hold one: one /// query answered, then nothing, so the server sits blocked on the read for the /// next message. Returns once the server ends the connection, however it ends /// it — a canceled server closes the socket, which the client sees either as /// end of stream or as a reset. fn holdConnectionOpen(io: std.Io, address: net.IpAddress, answered: *std.Io.Event) 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); try writer.interface.writeAll(&transport.framePrefix(@intCast(query_bytes.len))); try writer.interface.writeAll(query_bytes); try writer.interface.flush(); const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*); try expectAnswersQuery(try reader.interface.take(len)); answered.set(io); var sink: [64]u8 = undefined; _ = reader.interface.readSliceShort(&sink) catch {}; } test "a canceled serve does not wait for a live 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_owner: upstream_owner.Borrowed = .{}; var h = bareHandler(h_owner.client(fake.client())); const listen_address: net.IpAddress = try .parse("127.0.0.1", 0); // The idle budget is the whole time a drain would have to wait out, so it // is set far beyond any patience this test run has: if `serve` waits for // the connection instead of canceling it, the wait never ends. var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2, .idle_timeout = .{ .raw = .fromSeconds(600), .clock = .awake }, }); const server_address = server.boundAddress(); var serving: std.Io.Group = .init; try serving.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io }); var answered: std.Io.Event = .unset; var client_result: anyerror!void = {}; var client_group: std.Io.Group = .init; try client_group.concurrent(io, holdConnection, .{ io, server_address, &answered, &client_result }); // The reply proves the connection is claimed and served, so the connection // task is now blocked reading the message that never comes. That is the // state a drain hangs in. answered.waitUncancelable(io); // This is the composition root's shutdown: cancel the task group before // anything it borrows is released, so no `deinit` has shut the connection // down. It must still return. A regression here does not fail the test, it // hangs the run — there is no way to bound a join that does not finish. serving.cancel(io); // Cancellation must not skip the per-connection cleanup: the slot is // released and the socket closed by `serveConn`'s defer, which runs on the // canceled path like any other. try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server)); client_group.cancel(io); server.deinit(io); // Checked last: the connection had to be answered for the test to mean // anything, and the server is torn down before a failure is reported. try client_result; } /// The first slot the server would hand out, read after `serve` has returned so /// nothing can be writing it. fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize { for (server.core.conns, 0..) |*conn, index| { if (conn.state == .free) return index; } return null; } 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_owner: upstream_owner.Borrowed = .{}; var h = bareHandler(h_owner.client(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.core.stats.connections.load(.monotonic)); try testing.expectEqual(@as(u64, 1), server.core.stats.idle_timeouts.load(.monotonic)); try testing.expectEqual(@as(u64, 0), server.core.stats.connection_errors.load(.monotonic)); server.deinit(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_owner: upstream_owner.Borrowed = .{}; var h = bareHandler(h_owner.client(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.core.stats.connection_errors.load(.monotonic)); try testing.expectEqual(@as(u64, 0), server.core.stats.idle_timeouts.load(.monotonic)); server.deinit(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(&transport.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_owner: upstream_owner.Borrowed = .{}; var h = bareHandler(h_owner.client(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(io); group.await(io) catch |err| switch (err) { error.Canceled => unreachable, }; }