1001 lines
39 KiB
Zig
1001 lines
39 KiB
Zig
//! 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());
|
|
}
|