milestone 7: serving pipeline, client tracking, pause and lifecycle

This commit is contained in:
2026-08-01 21:43:52 +02:00
parent 8c50b6617f
commit a8092bb1b9
17 changed files with 5916 additions and 131 deletions
+996
View File
@@ -0,0 +1,996 @@
//! 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 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 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 } };
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
/// 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,
.records = &empty_records,
.zones = &empty_zones,
};
}
/// 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,
.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 fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.manager = &mgr;
h.logger = ≶
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());
h.records = &table;
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;
h.zones = &zones;
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 fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.cache = &cache;
h.negative_ttl_max = 3600;
h.logger = ≶
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 fake: FakeUpstream = .{ .reply = .{ .cname = "tracker.example.org" } };
var h = baseHandler(fake.client());
h.manager = &mgr;
h.logger = ≶
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 fake: FakeUpstream = .{ .reply = .a };
var h = baseHandler(fake.client());
h.limiter = &limiter;
h.logger = ≶
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.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());
}