Files
nxdns/src/server/resolver_integration_test.zig
T
mokhtar 6f67940995
CI / test (push) Successful in 1m22s
CI / test-aarch64 (push) Successful in 5m6s
CI / frontend (push) Successful in 45s
CI / cross (push) Successful in 7m53s
CI / docker (push) Failing after 1h10m57s
milestone 18: collapse duplicated infrastructure into shared listener core, crud list helper, resource shells, transport race, name and line helpers, ui modules
2026-08-07 18:20:30 +02:00

316 lines
12 KiB
Zig

//! 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 model = @import("../config/model.zig");
const response = @import("../filter/response.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;
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
/// listeners and the pool are what this test exercises, so the handler is the
/// same bare one its own tests use.
fn bareHandler(client: transport.Client) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
};
}
/// 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 these budgets only exist to stop a wedged
/// attempt from hanging the run.
const pool_timeouts: pool.Timeouts = .{
.attempt = .{ .raw = .fromSeconds(10), .clock = .awake },
.total = .{ .raw = .fromSeconds(30), .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(&transport.framePrefix(@intCast(query.len)));
try writer.interface.writeAll(query);
try writer.interface.flush();
const len = transport.parsePrefix((try reader.interface.takeArray(transport.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, pool_timeouts, 1);
var h = bareHandler(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(io);
group.cancel(io);
}