//! Milestone-7 integration tests (spec S7): the serving pipeline end to end, //! over real sockets. //! //! 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 compiles //! on every `zig build test` run, so it cannot rot, and every case skips at run //! time unless `-Dintegration` is passed. //! //! What separates these cases from `handler.zig`'s own tests is the socket. The //! handler tests call `handle` directly; here every query travels through a real //! `UdpServer` on 127.0.0.1, through the real handler with its real cache, //! limiter, tracker and query log, and the reply is read back off the wire. The //! upstream is a `transport.Client` fixture, except in the forward-zone case, //! where the zone resolver has to be a real UDP socket because `ForwardClient` //! speaks wire DNS to an address. //! //! Hermetic: every socket is bound to 127.0.0.1, every database is in memory or //! inside a `std.testing.tmpDir`, and every wait carries a budget. const std = @import("std"); const build_options = @import("build_options"); const net = std.Io.net; const Allocator = std.mem.Allocator; const app = @import("../app.zig"); const cli = @import("../cli.zig"); const clients = @import("clients.zig"); const clients_repo = @import("../storage/repositories/clients_repo.zig"); const db = @import("../storage/db.zig"); const dns_cache = @import("../cache/dns_cache.zig"); const forward_zones = @import("../local/forward_zones.zig"); const handler = @import("handler.zig"); const header = @import("../dns/header.zig"); const local_tables = @import("local_tables.zig"); const logger_mod = @import("../storage/logger.zig"); const manager = @import("../filter/manager.zig"); const matcher = @import("../filter/matcher.zig"); const migrations = @import("../storage/migrations.zig"); const model = @import("../config/model.zig"); const name = @import("../dns/name.zig"); const packet = @import("../dns/packet.zig"); const pause = @import("pause.zig"); const query_sink = @import("query_sink.zig"); const question = @import("../dns/question.zig"); const rate_limiter = @import("rate_limiter.zig"); const record = @import("../dns/record.zig"); const records = @import("../local/records.zig"); const response = @import("../filter/response.zig"); const shutdown = @import("shutdown.zig"); const transport = @import("../upstream/transport.zig"); const types = @import("../dns/types.zig"); const udp_server = @import("udp_server.zig"); const testing = std.testing; // --------------------------------------------------------------------------- // shared fixtures // --------------------------------------------------------------------------- /// 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 } }; /// A five-second TTL makes the blocking answer's TTL unmistakable next to the /// upstream's 300. const blocking: response.Options = .{ .mode = .zero, .ttl = 5 }; const forward_timeout: std.Io.Clock.Duration = .{ .raw = .fromSeconds(2), .clock = .awake }; /// What every fake upstream answers with, and the TTL it carries. const upstream_rdata = [4]u8{ 93, 184, 216, 34 }; const upstream_ttl: u32 = 300; /// The zone resolver's answer, distinct from the pool's so a case can tell /// which of the two replied. const zone_rdata = [4]u8{ 10, 0, 0, 7 }; const zone_ttl: u32 = 120; /// The handler every case starts from: an upstream, the blocking options and /// the empty local tables. Each case wires in the collaborators it exercises. fn baseHandler(client: transport.Client) handler.Handler { return .{ .upstream = client, .blocking = blocking, .forward_read_timeout = forward_timeout, }; } /// A real listener, a real client socket and the task that serves them. /// /// Two phases: `bind` produces the value, `start` spawns the serve task against /// its final address. Nothing may copy a `Loop` after `start`, because the task /// holds a pointer into it. const Loop = struct { server: udp_server.UdpServer, group: std.Io.Group, client: net.Socket, server_address: net.IpAddress, fn bind(gpa: Allocator, io: std.Io, h: *handler.Handler) !Loop { 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 }); errdefer server.deinit(gpa, io); const client_address: net.IpAddress = try .parse("127.0.0.1", 0); const client = try client_address.bind(io, .{ .mode = .dgram }); return .{ .server = server, .group = .init, .client = client, .server_address = server.boundAddress(), }; } fn start(self: *Loop, io: std.Io) !void { try self.group.concurrent(io, udp_server.UdpServer.serve, .{ &self.server, io }); } /// One query, one reply. The reply is a prefix of `buf`. fn ask(self: *Loop, io: std.Io, query: []const u8, buf: []u8) ![]u8 { try self.client.send(io, &self.server_address, query); const msg = try self.client.receiveTimeout(io, buf, budget); return msg.data; } fn stop(self: *Loop, gpa: Allocator, io: std.Io) void { self.server.deinit(gpa, io); self.group.cancel(io); self.client.close(io); } }; /// A query for `domain`, RD set, one question, no OPT. fn queryFor(buf: []u8, id: u16, domain: []const u8, qtype: types.Type) []const u8 { var w: std.Io.Writer = .fixed(buf); var encoded: [types.header_len]u8 = undefined; header.encode(.{ .id = id, .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, }, &encoded); w.writeAll(&encoded) catch unreachable; question.encode(.{ .name = name.fromText(domain) catch unreachable, .qtype = qtype, .qclass = .in, }, &w) catch unreachable; return w.buffered(); } /// The pool stand-in. It answers the question it is given rather than a fixed /// byte string, because the safe-search and uncloaking cases both change the /// question on the way out. /// /// `calls` is atomic: the listener task runs on another thread than the one /// asserting. const FakeUpstream = struct { reply: Reply, calls: std.atomic.Value(u64) = .init(0), const Reply = union(enum) { /// One A record for the queried name. a, /// One CNAME record for the queried name, pointing at this target. cname: []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)); _ = self.calls.fetchAdd(1, .monotonic); 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; switch (self.reply) { .a => b.addAnswer(q.name, .a, .in, upstream_ttl, &upstream_rdata) catch return error.ResponseTooLarge, .cname => |target| { const t = name.fromText(target) catch return error.BadResponse; b.addAnswer(q.name, .cname, .in, upstream_ttl, t.wire()) catch return error.ResponseTooLarge; }, } return b.finish(); } fn client(self: *FakeUpstream) transport.Client { return .{ .ptr = self, .exchangeFn = exchangeFn }; } }; /// The forward zone's resolver: a real UDP socket, because `ForwardClient` /// speaks wire DNS to an address and nothing smaller would prove it did. /// /// The loop ends when the receive is canceled, which is what `group.cancel` /// does at the end of the case. fn zoneResolver(io: std.Io, socket: *const net.Socket, calls: *std.atomic.Value(u64)) void { var buf: [udp_server.max_datagram]u8 = undefined; while (true) { const msg = socket.receive(io, &buf) catch return; _ = calls.fetchAdd(1, .monotonic); const request = packet.parse(msg.data) catch continue; const q = packet.firstQuestion(request) orelse continue; var reply_buf: [512]u8 = undefined; var b = packet.ResponseBuilder.init(&reply_buf, request.header, q) catch continue; b.addAnswer(q.name, .a, .in, zone_ttl, &zone_rdata) catch continue; socket.send(io, &msg.from, b.finish()) catch return; } } const SnapshotFixture = struct { groups: []const model.Group = &.{.{ .name = "default" }}, rules: []const model.Rule = &.{}, }; fn buildSnapshot(gpa: Allocator, fixture: SnapshotFixture) !matcher.Snapshot { return matcher.Snapshot.build(gpa, .{ .groups = fixture.groups, .group_ids = &.{1}, .group_sources = &.{}, .sources = &.{}, .source_ids = &.{}, .rules = fixture.rules, .clients = &.{}, .prefixes = &.{}, .compiled = &.{}, .seed = 0x5eed, .generation = 1, }); } /// `Manager.acquire` reads the manager's lock and its current snapshot and /// nothing else, so a manager that publishes one hand-built snapshot needs /// none of the database, fetcher or blocklist directory the real one owns. fn fixtureManager(m: *manager.Manager, snapshot: *matcher.Snapshot) void { m.* = .{ .gpa = testing.allocator, .database = undefined, .paths = undefined, .fetcher = undefined, .update = .{}, .total_budget = forward_timeout, .lock = .init, .writer_lock = .init, .refresh_lock = .init, .current = snapshot, .generation = 1, .statuses = &.{}, .status_arena = .init(testing.allocator), }; } fn blockRule(pattern: []const u8) model.Rule { return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .block }; } fn allowRule(pattern: []const u8) model.Rule { return .{ .group = "default", .pattern = pattern, .kind = .exact, .action = .allow }; } fn firstAnswer(p: packet.Packet) !record.Record { var it = packet.answers(p); return (try it.next()) orelse error.TestExpectedAnswer; } fn drainLog(lg: *logger_mod.Logger, io: std.Io, out: []logger_mod.Entry) []logger_mod.Entry { const n = lg.queue.getUncancelable(io, out, 0) catch 0; return out[0..n]; } /// Every case that asserts on the query log wants the same shape: a queue big /// enough to hold the whole case, drained once at the end. const log_queue_len = 8; // --------------------------------------------------------------------------- // case 1: blocked domain // --------------------------------------------------------------------------- test "S7 case 1: a blocked domain is answered with the zero address and logged" { 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 snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.manager = &mgr; h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var query_buf: [512]u8 = undefined; var reply_buf: [udp_server.max_datagram]u8 = undefined; const reply = try loop.ask(io, queryFor(&query_buf, 0x1111, "ads.example.com", .a), &reply_buf); const p = try packet.parse(reply); try testing.expectEqual(@as(u16, 0x1111), p.header.id); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(@as(u16, 1), p.header.ancount); const answer = try firstAnswer(p); try testing.expectEqual(@as(u32, blocking.ttl), answer.ttl); try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer)); try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic)); var entries: [log_queue_len]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try testing.expectEqual(true, logged[0].blocked); try testing.expectEqualStrings("ads.example.com", logged[0].domain()); try testing.expectEqualStrings("rule_block_exact", logged[0].blockReason()); try testing.expectEqualStrings("127.0.0.1", logged[0].clientIp()); } // --------------------------------------------------------------------------- // case 2: allow over block // --------------------------------------------------------------------------- test "S7 case 2: an allow rule beats the blocklist and the upstream answers" { 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 snapshot = try buildSnapshot(gpa, .{ .rules = &.{ blockRule("com"), allowRule("example.com") }, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.manager = &mgr; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var query_buf: [512]u8 = undefined; var reply_buf: [udp_server.max_datagram]u8 = undefined; const reply = try loop.ask(io, queryFor(&query_buf, 0x2222, "example.com", .a), &reply_buf); const p = try packet.parse(reply); const answer = try firstAnswer(p); try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, answer)); try testing.expectEqual(upstream_ttl, answer.ttl); try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); } // --------------------------------------------------------------------------- // case 3: local records // --------------------------------------------------------------------------- test "S7 case 3: a local record answers authoritatively without an 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 table = try records.Records.build(gpa, &.{ .{ .name = "nas.lan", .rtype = .a, .value = "192.168.1.10", .ttl = 60 }, }); defer table.deinit(gpa); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); var tables: local_tables.LocalTables = .{ .records = table }; h.local_tables = &tables; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var query_buf: [512]u8 = undefined; var reply_buf: [udp_server.max_datagram]u8 = undefined; const reply = try loop.ask(io, queryFor(&query_buf, 0x3333, "nas.lan", .a), &reply_buf); const p = try packet.parse(reply); try testing.expectEqual(true, p.header.flags.aa); try testing.expectEqual(@as(u16, 1), p.header.ancount); const answer = try firstAnswer(p); try testing.expectEqual(@as(u32, 60), answer.ttl); try testing.expectEqual([4]u8{ 192, 168, 1, 10 }, try record.rdataA(p.bytes, answer)); try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.local_answers.load(.monotonic)); } // --------------------------------------------------------------------------- // case 4: forward zones // --------------------------------------------------------------------------- test "S7 case 4: a forward zone reaches its resolver, bypasses the blocklist and caches" { 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(); // The zone resolver is a socket of its own, so the case can tell a query // that reached it from one the pool answered. const resolver_bind: net.IpAddress = try .parse("127.0.0.1", 0); const resolver_socket = try resolver_bind.bind(io, .{ .mode = .dgram }); defer resolver_socket.close(io); var resolver_calls: std.atomic.Value(u64) = .init(0); var resolver_group: std.Io.Group = .init; defer resolver_group.cancel(io); try resolver_group.concurrent(io, zoneResolver, .{ io, &resolver_socket, &resolver_calls }); var resolver_text: [64]u8 = undefined; const resolver_url = try std.fmt.bufPrint(&resolver_text, "udp://127.0.0.1:{d}", .{ resolver_socket.address.ip4.port, }); var zones = try forward_zones.Zones.build(gpa, &.{ .{ .zone = "lan.home", .resolver = resolver_url }, }); defer zones.deinit(gpa); // The name is blocklisted, so an answer from the zone resolver is proof the // bypass (ruling 7) holds over the wire. var snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("nas.lan.home")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.manager = &mgr; var tables: local_tables.LocalTables = .{ .zones = zones }; h.local_tables = &tables; h.cache = &cache; h.negative_ttl_max = 3600; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var query_buf: [512]u8 = undefined; var reply_buf: [udp_server.max_datagram]u8 = undefined; const query = queryFor(&query_buf, 0x4444, "nas.lan.home", .a); const first = try loop.ask(io, query, &reply_buf); const p = try packet.parse(first); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(zone_rdata, try record.rdataA(p.bytes, try firstAnswer(p))); try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic)); try testing.expectEqual(@as(u64, 0), fake.calls.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); try testing.expectEqual(@as(u32, 1), cache.len()); // The second query is answered from the cache: the resolver socket sees // nothing more (PLAN ยง6.5). var second_buf: [512]u8 = undefined; const second_query = queryFor(&second_buf, 0x4455, "nas.lan.home", .a); const second = try loop.ask(io, second_query, &reply_buf); const second_p = try packet.parse(second); try testing.expectEqual(@as(u16, 0x4455), second_p.header.id); try testing.expectEqual(zone_rdata, try record.rdataA(second_p.bytes, try firstAnswer(second_p))); try testing.expectEqual(@as(u64, 1), resolver_calls.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic)); } // --------------------------------------------------------------------------- // case 5: cache // --------------------------------------------------------------------------- test "S7 case 5: a cached answer comes back with a fresh id, an aged ttl and a logged hit" { 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 cache: dns_cache.DnsCache = try .init(gpa, .{ .size = 8, .negative_ttl_max = 3600 }); defer cache.deinit(); var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.cache = &cache; h.negative_ttl_max = 3600; h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var reply_buf: [udp_server.max_datagram]u8 = undefined; // Miss, then hit under a different transaction ID. var miss_buf: [512]u8 = undefined; _ = try loop.ask(io, queryFor(&miss_buf, 0x5501, "example.com", .a), &reply_buf); try testing.expectEqual(@as(u32, 1), cache.len()); try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic)); var hit_buf: [512]u8 = undefined; const hit = try loop.ask(io, queryFor(&hit_buf, 0x5502, "example.com", .a), &reply_buf); const p = try packet.parse(hit); try testing.expectEqual(@as(u16, 0x5502), p.header.id); try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p))); try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic)); try testing.expectEqual(@as(u64, 1), h.stats.cache_hits.load(.monotonic)); // Ageing needs elapsed time, and a test cannot wait 10 seconds for it. The // entry is therefore planted with a stored-at stamp 10 seconds in the past, // under exactly the key the handler builds for this query. var aged_query_buf: [512]u8 = undefined; const aged_query = queryFor(&aged_query_buf, 0x5503, "aged.example.com", .a); var stored_buf: [512]u8 = undefined; const aged_p = try packet.parse(aged_query); var b = try packet.ResponseBuilder.init(&stored_buf, aged_p.header, packet.firstQuestion(aged_p).?); try b.addAnswer(try name.fromText("aged.example.com"), .a, .in, upstream_ttl, &upstream_rdata); var key_buf: [dns_cache.max_key_len]u8 = undefined; const key = dns_cache.buildKey( &key_buf, "aged.example.com", @intFromEnum(types.Type.a), @intFromEnum(types.Class.in), false, null, ); const aged_by = 10; try cache.put( std.Io.Clock.real.now(io).toSeconds() - aged_by, key, b.finish(), .{ .ttl_seconds = upstream_ttl, .negative = false }, ); const aged = try loop.ask(io, aged_query, &reply_buf); const aged_reply = try packet.parse(aged); try testing.expectEqual(upstream_ttl - aged_by, (try firstAnswer(aged_reply)).ttl); try testing.expectEqual(@as(u64, 1), fake.calls.load(.monotonic)); var entries: [log_queue_len]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 3), logged.len); try testing.expectEqual(@as(?bool, false), logged[0].cache_hit); try testing.expectEqualStrings("pool", logged[0].upstream()); try testing.expectEqual(@as(?bool, true), logged[1].cache_hit); try testing.expectEqualStrings("", logged[1].upstream()); try testing.expectEqual(@as(?bool, true), logged[2].cache_hit); } // --------------------------------------------------------------------------- // case 6: CNAME uncloaking // --------------------------------------------------------------------------- test "S7 case 6: a cname into a blocked target blocks the original question" { 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 snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("tracker.example.org")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } }; var h = baseHandler(fake.client()); h.manager = &mgr; h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var query_buf: [512]u8 = undefined; var reply_buf: [udp_server.max_datagram]u8 = undefined; const reply = try loop.ask(io, queryFor(&query_buf, 0x6666, "cdn.example.com", .a), &reply_buf); const p = try packet.parse(reply); try testing.expectEqual(@as(u16, 1), p.header.ancount); // The answer is about the name the client asked for, not the target. const answer = try firstAnswer(p); try testing.expectEqual(types.Type.a, answer.rtype); try testing.expectEqualSlices( u8, (try name.fromText("cdn.example.com")).wire(), answer.name.wire(), ); try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(p.bytes, answer)); try testing.expectEqual(@as(u64, 1), h.stats.uncloak_blocked.load(.monotonic)); var entries: [log_queue_len]logger_mod.Entry = undefined; const logged = drainLog(&lg, io, &entries); try testing.expectEqual(@as(usize, 1), logged.len); try testing.expectEqual(true, logged[0].blocked); try testing.expectEqualStrings("cname:rule_block_exact", logged[0].blockReason()); try testing.expectEqualStrings("cdn.example.com", logged[0].domain()); } // --------------------------------------------------------------------------- // case 7: safe search // --------------------------------------------------------------------------- test "S7 case 7: safe search answers the original question with a cname to the target" { 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 snapshot = try buildSnapshot(gpa, .{ .groups = &.{.{ .name = "default", .safe_search = true }}, }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.manager = &mgr; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var query_buf: [512]u8 = undefined; var reply_buf: [udp_server.max_datagram]u8 = undefined; const reply = try loop.ask(io, queryFor(&query_buf, 0x7777, "www.google.com", .a), &reply_buf); const p = try packet.parse(reply); const target = try name.fromText("forcesafesearch.google.com"); // The reply keeps the question the client asked. try testing.expectEqualSlices( u8, (try name.fromText("www.google.com")).wire(), packet.firstQuestion(p).?.name.wire(), ); try testing.expectEqual(@as(u16, 2), p.header.ancount); var it = packet.answers(p); const cname = (try it.next()).?; try testing.expectEqual(types.Type.cname, cname.rtype); try testing.expectEqualSlices(u8, target.wire(), (try record.rdataCname(p.bytes, cname)).wire()); const a = (try it.next()).?; try testing.expectEqual(types.Type.a, a.rtype); try testing.expectEqualSlices(u8, target.wire(), a.name.wire()); try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, a)); try testing.expectEqual(@as(u64, 1), h.stats.safesearch_rewrites.load(.monotonic)); } // --------------------------------------------------------------------------- // case 8: rate limit // --------------------------------------------------------------------------- test "S7 case 8: the third query inside the window is refused" { 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 limiter: rate_limiter.RateLimiter = try .init(gpa, .{ .limit = 2, .window_seconds = 60 }); defer limiter.deinit(); var queue_buf: [log_queue_len]logger_mod.Entry = undefined; var lg: logger_mod.Logger = .init(.{}, &queue_buf); var sink: query_sink.QuerySink = .init(&lg, null); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.limiter = &limiter; h.sink = &sink; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var reply_buf: [udp_server.max_datagram]u8 = undefined; for ([_]u16{ 0x8801, 0x8802 }) |id| { var query_buf: [512]u8 = undefined; const reply = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf); try testing.expectEqual(types.Rcode.no_error, (try packet.parse(reply)).header.flags.rcode); } var third_buf: [512]u8 = undefined; const refused = try loop.ask(io, queryFor(&third_buf, 0x8803, "example.com", .a), &reply_buf); const p = try packet.parse(refused); try testing.expectEqual(types.Rcode.refused, p.header.flags.rcode); try testing.expectEqual(@as(u16, 0x8803), p.header.id); try testing.expectEqual(@as(u64, 1), h.stats.refused.load(.monotonic)); try testing.expectEqual(@as(u64, 2), fake.calls.load(.monotonic)); // Ruling 8: a refused query is never query-logged. var entries: [log_queue_len]logger_mod.Entry = undefined; try testing.expectEqual(@as(usize, 2), drainLog(&lg, io, &entries).len); } // --------------------------------------------------------------------------- // case 9: pause // --------------------------------------------------------------------------- test "S7 case 9: pause lifts filtering and unpause restores it" { 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 snapshot = try buildSnapshot(gpa, .{ .rules = &.{blockRule("ads.example.com")} }); defer snapshot.deinit(); var mgr: manager.Manager = undefined; fixtureManager(&mgr, &snapshot); var paused: pause.Pause = .{}; paused.pauseFor(std.Io.Clock.real.now(io).toSeconds(), null); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.manager = &mgr; h.pause = &paused; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var reply_buf: [udp_server.max_datagram]u8 = undefined; var paused_buf: [512]u8 = undefined; const while_paused = try loop.ask(io, queryFor(&paused_buf, 0x9901, "ads.example.com", .a), &reply_buf); const p = try packet.parse(while_paused); try testing.expectEqual(upstream_rdata, try record.rdataA(p.bytes, try firstAnswer(p))); try testing.expectEqual(@as(u64, 1), h.stats.paused_queries.load(.monotonic)); try testing.expectEqual(@as(u64, 0), h.stats.blocked.load(.monotonic)); // Resuming puts the block back without a restart (ruling 18). paused.unpause(); var resumed_buf: [512]u8 = undefined; const after = try loop.ask(io, queryFor(&resumed_buf, 0x9902, "ads.example.com", .a), &reply_buf); const after_p = try packet.parse(after); try testing.expectEqual([4]u8{ 0, 0, 0, 0 }, try record.rdataA(after_p.bytes, try firstAnswer(after_p))); try testing.expectEqual(@as(u64, 1), h.stats.blocked.load(.monotonic)); } // --------------------------------------------------------------------------- // case 10: client tracking // --------------------------------------------------------------------------- test "S7 case 10: the querying client is materialised as a row" { 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(); // The tracker's own connection (ruling 21), in memory here: the flush is // what this case asserts on, not where the file lives. var database = try db.Db.open(":memory:", .{ .mode = .memory }); defer database.close(); try db.applyPragmas(&database, .{}); _ = try migrations.migrate(&database); var tracker: clients.Tracker = .init(30); var fake: FakeUpstream = .{ .reply = .a }; var h = baseHandler(fake.client()); h.tracker = &tracker; var loop = try Loop.bind(gpa, io, &h); defer loop.stop(gpa, io); try loop.start(io); var reply_buf: [udp_server.max_datagram]u8 = undefined; for ([_]u16{ 0xa001, 0xa002 }) |id| { var query_buf: [512]u8 = undefined; _ = try loop.ask(io, queryFor(&query_buf, id, "example.com", .a), &reply_buf); } // Two queries from one client are one pending entry, and the forced pass // stands in for the 60-second flush interval (S4 As-built seam). try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io)); tracker.flushOnce(io, &database, true); try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database)); try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io)); try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).flushed); var stmt = try database.prepare("SELECT ip, hand_edited FROM clients"); defer stmt.deinit(); try testing.expect(try stmt.step()); try testing.expectEqualStrings("127.0.0.1", stmt.columnText(0)); try testing.expectEqual(@as(i64, 0), stmt.columnInt(1)); } // --------------------------------------------------------------------------- // case 11: the whole application // --------------------------------------------------------------------------- /// `std.testing.tmpDir` creates its directory against `std.testing.io`, so the /// application under test runs on the same `Io` instance the fixture used. The /// other cases build an `Io.Threaded` of their own, the way the listener tests /// do; this one cannot, because the temporary directory is already bound to /// this instance. const test_io = testing.io; /// Where `std.testing.tmpDir` puts its directories (`lib/std/testing.zig:634`). const tmp_prefix = ".zig-cache/tmp/"; const sub_path_len = @typeInfo(@FieldType(testing.TmpDir, "sub_path")).array.len; /// High enough to need no privilege, and not the 15353/15354 pair the milestone /// smoke test used, so a stray smoke process cannot make this case pass. const app_port = 15455; /// The unreachable upstream the seed configuration names. Nothing in this case /// needs it: the query it resolves is a local record, and a dead upstream is /// what proves the fail-open design still serves. const dead_upstream = "https://127.0.0.1:9/dns-query"; const app_config = \\.{ \\ .dns = .{ \\ .bind_ipv4 = "127.0.0.1", \\ .bind_ipv6 = "::1", \\ .port = 15455, \\ .rate_limit = 1000, \\ .rate_window_seconds = 60, \\ }, \\ .logging = .{ .level = .info, .output = .stderr }, \\ .web = .{ .enabled = false }, \\ .groups = .{ .{ .name = "default" } }, \\ .upstreams = .{ .{ .url = "https://127.0.0.1:9/dns-query" } }, \\ .local_records = .{ \\ .{ .name = "boot.test", .rtype = .a, .value = "10.9.8.7", .ttl = 60 }, \\ }, \\} \\ ; comptime { // The port and the upstream appear in the configuration text as literals, // because a `.zon` file is data and not a format string. std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, std.fmt.comptimePrint("{d}", .{app_port}))); std.debug.assert(std.mem.containsAtLeast(u8, app_config, 1, dead_upstream)); } /// How long one attempt at reaching the booting server waits, and how many /// attempts it gets. The product is the time the application has to bind. const boot_attempt: std.Io.Timeout = .{ .duration = .{ .raw = .fromMilliseconds(200), .clock = .awake } }; const boot_attempts = 100; /// Queries the booting server until it answers. A server that has not bound yet /// either swallows the datagram or answers it with an ICMP rejection, and both /// arrive here as an error worth retrying. fn askUntilAnswered( socket: *const net.Socket, dest: net.IpAddress, query: []const u8, buf: []u8, ) ![]u8 { var attempt: usize = 0; while (attempt < boot_attempts) : (attempt += 1) { socket.send(test_io, &dest, query) catch continue; const msg = socket.receiveTimeout(test_io, buf, boot_attempt) catch continue; return msg.data; } return error.TestAppNeverAnswered; } test "S7 case 11: the app boots, serves a query and exits zero on shutdown" { if (!build_options.integration) return error.SkipZigTest; const gpa = testing.allocator; var tmp = testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); try tmp.dir.writeFile(test_io, .{ .sub_path = "config.zon", .data = app_config }); var root_buf: [tmp_prefix.len + sub_path_len]u8 = undefined; @memcpy(root_buf[0..tmp_prefix.len], tmp_prefix); @memcpy(root_buf[tmp_prefix.len..], &tmp.sub_path); const root: []const u8 = &root_buf; var config_buf: [root_buf.len + "/config.zon".len]u8 = undefined; const config_path = try std.fmt.bufPrint(&config_buf, "{s}/config.zon", .{root}); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); var err: std.Io.Writer.Allocating = .init(gpa); defer err.deinit(); const runner: cli.Runner = .{ .io = test_io, .gpa = gpa, .out = &out.writer, .err = &err.writer, }; // The shutdown event is process-global, and another case in this binary may // have left it set. shutdown.reset(); defer shutdown.reset(); var future = try test_io.concurrent(app.run, .{ runner, cli.RunArgs{ .paths = .{ .data_dir = root, .config = config_path, } } }); const client_address: net.IpAddress = try .parse("127.0.0.1", 0); const client = try client_address.bind(test_io, .{ .mode = .dgram }); defer client.close(test_io); const server_address: net.IpAddress = try .parse("127.0.0.1", app_port); var query_buf: [512]u8 = undefined; const query = queryFor(&query_buf, 0xb001, "boot.test", .a); var reply_buf: [udp_server.max_datagram]u8 = undefined; const reply = askUntilAnswered(&client, server_address, query, &reply_buf) catch |e| { shutdown.trigger(test_io); _ = future.await(test_io); return e; }; const p = try packet.parse(reply); try testing.expectEqual(@as(u16, 0xb001), p.header.id); try testing.expectEqual(types.Rcode.no_error, p.header.flags.rcode); try testing.expectEqual(true, p.header.flags.aa); try testing.expectEqual([4]u8{ 10, 9, 8, 7 }, try record.rdataA(p.bytes, try firstAnswer(p))); shutdown.trigger(test_io); try testing.expectEqual(cli.exit_ok, future.await(test_io)); // The lifecycle proof is the exit code, and a clean exit prints nothing. try testing.expectEqualStrings("", err.written()); }