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
+502
View File
@@ -0,0 +1,502 @@
//! Client auto-materialisation (PLAN §7.2): every address that asks a question
//! ends up as a row in `clients`, so the operator can name it and assign it a
//! group without typing an address by hand.
//!
//! A DNS query must never wait on a database write, so `track` only records the
//! address in a fixed-capacity table in memory. A background loop drains that
//! table every `flush_interval_s` and writes one row per distinct client, and
//! prunes the rows of devices that went quiet on every
//! `prune_every_passes`-th pass.
//!
//! The table is bounded at `max_pending`. A full table drops the address and
//! counts it under `dropped_full`: a burst of spoofed source addresses must not
//! be able to grow this allocation, and a dropped address costs nothing, since
//! the next query from that client tracks it again.
//!
//! A materialised row does not reach the matcher until the next manager reload.
//! Nothing depends on it: `groupForClient` already falls back to the prefix
//! rules and then to the default group, so the row exists for the operator's
//! benefit, not for resolution.
const std = @import("std");
const address = @import("../platform/address.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
const log = std.log.scoped(.clients);
/// RFC 5952 text of any IPv6 address. `format` never writes more than this, so
/// the formatting in `flushOnce` cannot fail.
const max_ip_text = 45;
/// One address waiting for its row, with the wall-clock second of its most
/// recent query.
const Pending = struct {
addr: address.NetAddress,
last_seen: i64,
};
pub const Tracker = struct {
/// Distinct clients one flush interval can carry. A household LAN holds two
/// orders of magnitude fewer; the headroom is for the spoofing case.
pub const max_pending = 512;
pub const flush_interval_s = 60;
/// One day at `flush_interval_s` seconds per pass.
pub const prune_every_passes = 1440;
/// `tracked` counts the `track` calls that landed in the table, whether
/// they created an entry or refreshed one, so `tracked + dropped_full` is
/// the number of `track` calls.
pub const Stats = struct {
tracked: u64 = 0,
flushed: u64 = 0,
dropped_full: u64 = 0,
pruned: u64 = 0,
flush_failures: u64 = 0,
};
/// Guards `pending`, `count`, `passes` and `stats`. Every field below is
/// written under it, so a reader takes it too; see `snapshotStats`.
mutex: std.Io.Mutex,
retention_days: u16,
pending: [max_pending]Pending,
count: u32,
passes: u64,
stats: Stats,
/// `retention_days` is `logging.retention_days`, the same knob the query log
/// prunes by (milestone-7 ruling 16). A client silent for that long is as
/// uninteresting as a query that old.
pub fn init(retention_days: u16) Tracker {
return .{
.mutex = .init,
.retention_days = retention_days,
.pending = undefined,
.count = 0,
.passes = 0,
.stats = .{},
};
}
/// Records `addr` as seen now. Called from the query path, so it writes no
/// database and returns no error: a full table drops the address.
///
/// `lockUncancelable` rather than `lock`: the caller is `Handler.handle`,
/// which has no error union to carry `error.Canceled` out of. The critical
/// section is a scan of at most `max_pending` addresses and holds no I/O.
pub fn track(self: *Tracker, io: std.Io, addr: address.NetAddress) void {
self.trackAt(io, addr, std.Io.Clock.real.now(io).toSeconds());
}
/// `track` with the timestamp supplied, so a test does not depend on the
/// wall clock.
pub fn trackAt(self: *Tracker, io: std.Io, addr: address.NetAddress, now_s: i64) void {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
for (self.pending[0..self.count]) |*entry| {
if (!entry.addr.eql(addr)) continue;
entry.last_seen = now_s;
self.stats.tracked += 1;
return;
}
if (self.count == max_pending) {
self.stats.dropped_full += 1;
return;
}
self.pending[self.count] = .{ .addr = addr, .last_seen = now_s };
self.count += 1;
self.stats.tracked += 1;
}
/// Flush loop, first flush one interval in: an empty table at startup has
/// nothing to write.
///
/// `boot` rather than `awake`, so a box that suspends still sees its day
/// elapse and prunes on schedule.
///
/// `database` must be a connection dedicated to this loop: no other task may
/// use the same handle while it runs. `FULLMUTEX` (`db.zig:218`) serializes
/// one SQLite call against another, but a transaction is connection state,
/// not call state, so a flush that lands between another writer's BEGIN and
/// COMMIT would commit or roll back with that writer's batch. Milestone-7
/// ruling 21 gives this loop its own `config.db` connection; the tracker
/// opens nothing itself.
///
/// `monitor` gates the pass: while free space is critical the tracker writes
/// nothing, and the addresses it would have written stay dropped.
pub fn run(
self: *Tracker,
io: std.Io,
database: *db.Db,
monitor: ?*disk_monitor.Monitor,
) std.Io.Cancelable!void {
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(flush_interval_s),
.clock = .boot,
};
while (true) {
try interval.sleep(io);
const writes_allowed = if (monitor) |m| m.writesAllowed() else true;
self.flushOnce(io, database, writes_allowed);
}
}
/// One pass: drain the table, write a row per client, and prune on every
/// `prune_every_passes`-th pass.
///
/// A gated pass does nothing at all, not even count: the work it skipped is
/// still owed, and the pending addresses it leaves behind are re-tracked by
/// the next query from each client.
///
/// Every database failure logs one line at `warn` and counts. Nothing
/// retries within a pass: the dropped addresses come back on their own, and
/// a failed prune repeats the same work a day later against the same rows.
///
/// Only `run` may call this concurrently with itself — the drain buffer is
/// this call's stack, but the pass counter and the prune schedule assume a
/// single caller.
pub fn flushOnce(self: *Tracker, io: std.Io, database: *db.Db, writes_allowed: bool) void {
if (!writes_allowed) return;
var drained: [max_pending]Pending = undefined;
const batch = self.drain(io, &drained);
var flushed: u64 = 0;
var failures: u64 = 0;
for (batch) |entry| {
var buf: [max_ip_text]u8 = undefined;
var w: std.Io.Writer = .fixed(&buf);
entry.addr.format(&w) catch unreachable;
if (clients_repo.upsertSeen(database, w.buffered(), entry.last_seen)) {
flushed += 1;
} else |err| {
if (failures == 0) {
log.warn("materialising client {s} failed: {s}", .{ w.buffered(), @errorName(err) });
}
failures += 1;
}
}
self.mutex.lockUncancelable(io);
self.passes += 1;
self.stats.flushed += flushed;
self.stats.flush_failures += failures;
const due = self.passes % prune_every_passes == 0;
self.mutex.unlock(io);
if (!due) return;
const cutoff = std.Io.Clock.real.now(io).toSeconds() - @as(i64, self.retention_days) * 86_400;
if (clients_repo.pruneStale(database, cutoff)) |deleted| {
self.mutex.lockUncancelable(io);
self.stats.pruned += deleted;
self.mutex.unlock(io);
} else |err| {
log.warn("pruning clients before {d} failed: {s}", .{ cutoff, @errorName(err) });
self.mutex.lockUncancelable(io);
self.stats.flush_failures += 1;
self.mutex.unlock(io);
}
}
pub fn snapshotStats(self: *Tracker, io: std.Io) Stats {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.stats;
}
/// Clients waiting for their row. Reaching `max_pending` is what turns
/// further addresses into `dropped_full`.
pub fn pendingClients(self: *Tracker, io: std.Io) u32 {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
return self.count;
}
/// Empties the table into `out` and returns what it copied. The lock is
/// released before any database call, so the query path never waits on
/// SQLite.
fn drain(self: *Tracker, io: std.Io, out: *[max_pending]Pending) []const Pending {
self.mutex.lockUncancelable(io);
defer self.mutex.unlock(io);
const n = self.count;
@memcpy(out[0..n], self.pending[0..n]);
self.count = 0;
return out[0..n];
}
};
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const migrations = @import("../storage/migrations.zig");
const testing = std.testing;
fn openMigrated() !db.Db {
var database = try db.Db.open(":memory:", .{ .mode = .memory });
errdefer database.close();
try db.applyPragmas(&database, .{});
_ = try migrations.migrate(&database);
return database;
}
fn lastSeen(database: *db.Db, ip: []const u8) !i64 {
var stmt = try database.prepare("SELECT last_seen FROM clients WHERE ip = ?1");
defer stmt.deinit();
try stmt.bindText(1, ip);
try testing.expect(try stmt.step());
return stmt.columnInt(0);
}
fn parsed(text: []const u8) address.NetAddress {
return address.NetAddress.parse(text) catch unreachable;
}
test "a client tracked twice before a flush yields one row at the later time" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000030);
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(i64, 1700000030), try lastSeen(&database, "192.168.1.10"));
const stats = tracker.snapshotStats(io);
try testing.expectEqual(@as(u64, 2), stats.tracked);
try testing.expectEqual(@as(u64, 1), stats.flushed);
try testing.expectEqual(@as(u64, 0), stats.flush_failures);
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
}
test "distinct clients each get a row and ipv6 text is canonical" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.trackAt(io, parsed("192.168.1.11"), 1700000001);
tracker.trackAt(io, parsed("fd00:0:0:0:0:0:0:1"), 1700000002);
// An IPv4-mapped literal is the same client as its plain form.
tracker.trackAt(io, address.NetAddress.fromIp(try std.Io.net.IpAddress.parse("::ffff:192.168.1.10", 53)), 1700000003);
try testing.expectEqual(@as(u32, 3), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 3), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000003), try lastSeen(&database, "192.168.1.10"));
try testing.expectEqual(@as(i64, 1700000002), try lastSeen(&database, "fd00::1"));
try testing.expectEqual(@as(u64, 3), tracker.snapshotStats(io).flushed);
}
test "a full table drops further clients and counts them" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
for (0..Tracker.max_pending) |i| {
var octets: [4]u8 = undefined;
std.mem.writeInt(u32, &octets, @intCast(i), .big);
tracker.trackAt(io, .{ .ip4 = octets }, 1700000000);
}
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).dropped_full);
tracker.trackAt(io, parsed("203.0.113.7"), 1700000000);
tracker.trackAt(io, parsed("203.0.113.8"), 1700000000);
try testing.expectEqual(@as(u32, Tracker.max_pending), tracker.pendingClients(io));
const stats = tracker.snapshotStats(io);
try testing.expectEqual(@as(u64, 2), stats.dropped_full);
try testing.expectEqual(@as(u64, Tracker.max_pending), stats.tracked);
// A tracked client still refreshes while the table is full, and the flush
// makes room for the next newcomer.
tracker.trackAt(io, .{ .ip4 = .{ 0, 0, 0, 0 } }, 1700000060);
tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, Tracker.max_pending), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000060), try lastSeen(&database, "0.0.0.0"));
tracker.trackAt(io, parsed("203.0.113.7"), 1700000060);
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
}
test "a flush touches a hand-edited row without changing what the operator set" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
try database.exec("INSERT INTO groups (id, name) VALUES (2, 'kids');");
try database.exec(
\\INSERT INTO clients (ip, name, group_id, hand_edited, first_seen, last_seen)
\\VALUES ('192.168.1.10', 'laptop', 2, 1, 1690000000, 1690000000);
);
var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000000), try lastSeen(&database, "192.168.1.10"));
try testing.expectEqual(
@as(i64, 1),
try database.queryInt(
\\SELECT count(*) FROM clients
\\ WHERE ip = '192.168.1.10' AND name = 'laptop' AND group_id = 2
\\ AND hand_edited = 1 AND first_seen = 1690000000
),
);
}
test "a gated pass writes nothing and keeps the pending clients" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var monitor: disk_monitor.Monitor = .init(.{}, std.Io.Dir.cwd(), ".", null);
monitor.state_raw.store(@intFromEnum(disk_monitor.State.critical), .monotonic);
try testing.expect(!monitor.writesAllowed());
var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, monitor.writesAllowed());
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).flushed);
try testing.expectEqual(@as(u64, 0), tracker.passes);
// Free space recovers and the same pending client lands.
monitor.state_raw.store(@intFromEnum(disk_monitor.State.warn), .monotonic);
tracker.flushOnce(io, &database, monitor.writesAllowed());
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.passes);
}
test "a failing upsert counts and leaves the client to be tracked again" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
try database.exec(
\\CREATE TRIGGER refuse_insert BEFORE INSERT ON clients
\\BEGIN SELECT RAISE(ABORT, 'refused'); END;
);
var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.trackAt(io, parsed("192.168.1.11"), 1700000000);
tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
const stats = tracker.snapshotStats(io);
try testing.expectEqual(@as(u64, 0), stats.flushed);
try testing.expectEqual(@as(u64, 2), stats.flush_failures);
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
try database.exec("DROP TRIGGER refuse_insert;");
tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
test "the pass that comes due prunes the clients that went quiet" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
const day = 86_400;
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 40 * day);
try clients_repo.upsertSeen(&database, "10.0.0.2", now - 29 * day);
var tracker: Tracker = .init(30);
// Every pass before the due one leaves both rows alone.
for (0..Tracker.prune_every_passes - 1) |_| {
tracker.flushOnce(io, &database, true);
}
try testing.expectEqual(@as(i64, 2), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 0), tracker.snapshotStats(io).pruned);
tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, now - 29 * day), try lastSeen(&database, "10.0.0.2"));
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
try testing.expectEqual(@as(u64, Tracker.prune_every_passes), tracker.passes);
}
test "a shorter retention prunes what the default keeps" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
const now = std.Io.Clock.real.now(io).toSeconds();
try clients_repo.upsertSeen(&database, "10.0.0.1", now - 3 * 86_400);
var tracker: Tracker = .init(1);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
}
test "the run loop flushes on its interval and returns on cancel" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tracker: Tracker = .init(30);
tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
var future = try io.concurrent(Tracker.run, .{
&tracker,
io,
&database,
@as(?*disk_monitor.Monitor, null),
});
// The first flush is one interval away, so cancelling immediately proves the
// loop starts by sleeping rather than by writing.
try testing.expectError(error.Canceled, future.cancel(io));
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
}
+1951 -83
View File
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
//! Global pause of filtering (PLAN §13.1). One 64-bit flag the query path
//! reads and the API writes. Pure: no allocation, no `std.Io`, no clock — the
//! caller supplies the time, because the handler already has it.
//!
//! Pausing suspends FILTERING only: local records, forward zones, the cache,
//! the upstream and the query log all keep running (milestone-7 ruling 18).
//!
//! The state is in memory and is deliberately not persisted: a restart resumes
//! filtering, which is the safe default for a household.
const std = @import("std");
pub const Pause = struct {
/// 0 = filtering active. -1 = paused until someone resumes. Any other value
/// is the unix second filtering resumes at, so the expiry is a comparison
/// on the query path rather than a timer task.
until: std.atomic.Value(i64) = .init(0),
/// `.monotonic` throughout: the flag guards no other data, so nothing has
/// to be ordered against it.
pub fn isPaused(self: *const Pause, now_s: i64) bool {
const until = self.until.load(.monotonic);
if (until == 0) return false;
if (until < 0) return true;
return now_s < until;
}
/// `null` pauses until `unpause`. A pause already in force is replaced, so
/// the last call wins whether it lengthens or shortens the pause.
pub fn pauseFor(self: *Pause, now_s: i64, duration_s: ?u32) void {
const value: i64 = if (duration_s) |seconds| now_s +| @as(i64, seconds) else -1;
self.until.store(value, .monotonic);
}
pub fn unpause(self: *Pause) void {
self.until.store(0, .monotonic);
}
};
const testing = std.testing;
test "a fresh pause is not paused" {
const p: Pause = .{};
try testing.expect(!p.isPaused(0));
try testing.expect(!p.isPaused(1_700_000_000));
}
test "an indefinite pause holds at every time" {
var p: Pause = .{};
p.pauseFor(1_700_000_000, null);
try testing.expect(p.isPaused(1_700_000_000));
try testing.expect(p.isPaused(1_700_000_000 + 86_400 * 365));
try testing.expectEqual(@as(i64, -1), p.until.load(.monotonic));
}
test "a timed pause expires at its own second" {
var p: Pause = .{};
p.pauseFor(1_000, 60);
try testing.expect(p.isPaused(1_000));
try testing.expect(p.isPaused(1_059));
// The stored second is when filtering is back on, so it is not paused.
try testing.expect(!p.isPaused(1_060));
try testing.expect(!p.isPaused(1_061));
}
test "unpause resumes both kinds of pause" {
var p: Pause = .{};
p.pauseFor(1_000, null);
p.unpause();
try testing.expect(!p.isPaused(1_000));
p.pauseFor(1_000, 60);
p.unpause();
try testing.expect(!p.isPaused(1_000));
}
test "pauseFor overwrites a pause already in force" {
var p: Pause = .{};
p.pauseFor(1_000, 3_600);
p.pauseFor(1_000, 10);
try testing.expect(!p.isPaused(1_010));
// And in the other direction: indefinite replaces a timed pause.
p.pauseFor(1_000, 10);
p.pauseFor(1_000, null);
try testing.expect(p.isPaused(1_010));
}
test "a duration that would overflow saturates instead of wrapping" {
var p: Pause = .{};
p.pauseFor(std.math.maxInt(i64), std.math.maxInt(u32));
try testing.expect(p.isPaused(std.math.maxInt(i64) - 1));
}
+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 = &lg;
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 = &lg;
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 = &lg;
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 = &lg;
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());
}
+30 -1
View File
@@ -18,6 +18,10 @@ const net = std.Io.net;
const handler = @import("handler.zig");
const tcp_server = @import("tcp_server.zig");
const udp_server = @import("udp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
const packet = @import("../dns/packet.zig");
const record = @import("../dns/record.zig");
const types = @import("../dns/types.zig");
@@ -27,6 +31,31 @@ const transport = @import("../upstream/transport.zig");
const testing = std.testing;
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
.mode = blocking_defaults.response,
.ttl = blocking_defaults.ttl,
};
const forward_timeout: std.Io.Clock.Duration = .{
.raw = model.readTimeout(.{}),
.clock = .awake,
};
/// An upstream and nothing else optional: no filtering, no cache, no log. The
/// listeners and the pool are what this test exercises, so the handler is the
/// same bare one its own tests use.
fn bareHandler(client: transport.Client) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
/// 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 } };
@@ -226,7 +255,7 @@ test "the whole resolver answers over udp and tcp and fails over to a healthy up
};
var upstreams: pool.Pool = .init(&entries, test_cfg, attempt_timeout, 1);
var h: handler.Handler = .{ .upstream = upstreams.client() };
var h = bareHandler(upstreams.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var udp = try udp_server.UdpServer.bind(gpa, io, listen_address, &h, .{ .max_in_flight = 4 });
+118
View File
@@ -0,0 +1,118 @@
//! SIGINT and SIGTERM, turned into one `std.Io.Event`.
//!
//! No signalfd, no self-pipe, no epoll: the handler does exactly one thing, and
//! `std.Io.Event.set` is async-signal-safe on the Threaded Linux backend — a
//! raw `futex` wake with no allocation and no lock (`Io.zig:1855` →
//! `Threaded.futexWake`). The `.mask`/`.flags` shape is the one Threaded uses
//! for its own `SIG.IO`/`SIG.PIPE` handlers (`Threaded.zig:1653`): an empty
//! mask and no `SA_RESTART`, so a blocking syscall returns `EINTR` and the
//! backend's retry loop re-reads the cancellation state.
//!
//! Everything a shutdown actually has to do — drain the query log, cancel the
//! task group, close the databases — happens on the task blocked in `wait`.
//!
//! The previous handlers are not restored. The process is leaving, and a
//! second SIGTERM during teardown should still terminate it the default way
//! only if the operator sends it before this module is armed.
const std = @import("std");
const posix = std.posix;
var event: std.Io.Event = .unset;
/// Read by the signal handler, written by `install` before the handler exists.
/// A `std.Io` is two pointers and cannot be stored atomically, so ordering is
/// what makes the read safe: the store precedes the `sigaction` syscall that
/// arms the handler, and no signal can reach the handler before that call
/// returns.
var handler_io: ?std.Io = null;
var installed: bool = false;
/// Arms the handlers for INT and TERM. Calling it again is a no-op: the process
/// has one event and one pair of handlers, and a second boot inside one process
/// (which only a test does) must not re-arm anything.
pub fn install(io: std.Io) void {
if (installed) return;
handler_io = io;
installed = true;
const act: posix.Sigaction = .{
.handler = .{ .handler = onSignal },
.mask = posix.sigemptyset(),
.flags = 0,
};
posix.sigaction(.INT, &act, null);
posix.sigaction(.TERM, &act, null);
}
fn onSignal(_: posix.SIG) callconv(.c) void {
const io = handler_io orelse return;
event.set(io);
}
/// Blocks until a shutdown is requested. A canceled wait is the caller's cue to
/// tear down as well, which is why `app.run` treats both results the same.
pub fn wait(io: std.Io) std.Io.Cancelable!void {
return event.wait(io);
}
/// The programmatic equivalent of the signal: what a test uses to shut the app
/// down, and what a Phase 8 restart endpoint would call.
pub fn trigger(io: std.Io) void {
event.set(io);
}
pub fn isRequested() bool {
return event.isSet();
}
/// Clears the request so the next `wait` blocks again. Only a test that boots
/// the app more than once in one process needs this; a served process shuts
/// down once.
pub fn reset() void {
event.reset();
}
const testing = std.testing;
test "trigger releases a waiter and isRequested reports it" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
reset();
try testing.expect(!isRequested());
trigger(io);
try testing.expect(isRequested());
// Already set, so this returns without blocking.
try wait(io);
reset();
try testing.expect(!isRequested());
}
test "a waiting task is released by a later trigger" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
reset();
var group: std.Io.Group = .init;
try group.concurrent(io, waitThenSet, .{ io, &done });
trigger(io);
try group.await(io);
try testing.expect(done.isSet());
reset();
done.reset();
}
var done: std.Io.Event = .unset;
fn waitThenSet(io: std.Io, flag: *std.Io.Event) std.Io.Cancelable!void {
try wait(io);
flag.set(io);
}
+80 -17
View File
@@ -12,8 +12,25 @@
//! No stream read or write in 0.16.0 accepts a timeout, so every per-connection
//! operation is raced against `Options.idle_timeout` through `std.Io.Select` and
//! the loser is canceled.
//!
//! Shutdown takes one of two paths, and they end the live connections
//! differently on purpose:
//!
//! - `deinit` shuts every active stream down first, so the connections unblock
//! and finish by themselves. `serve` then drains them, and a reply that was
//! half written still goes out whole.
//! - A canceled `serve` cannot drain. `deinit` is what would shut the streams
//! down, and it cannot run until `serve` returns — the composition root
//! cancels its task group before it releases anything (app.zig). Meanwhile
//! RFC 7766 §6.2.1.1 lets a client hold a connection open indefinitely by
//! asking again inside the idle budget, so draining would let one chatty
//! client stall the whole process's shutdown. The connections are canceled
//! instead, at the cost of the one reply that was mid-write.
//!
//! Either way `serve` returns only once no task can still touch a slot.
const std = @import("std");
const address = @import("../platform/address.zig");
const handler = @import("handler.zig");
const transport = @import("../upstream/transport.zig");
@@ -53,6 +70,16 @@ const State = enum(u32) { idle, serving, closing };
/// before the close, and `deinit` only touches `.active` slots.
const ConnState = enum { free, active, closing };
/// Why the accept loop stopped, which decides what happens to the connections
/// still in flight.
const Stop = enum {
/// `deinit` published `.closing`. It has already shut every live connection
/// down, so each one is unblocked and finishing on its own.
closing,
/// This task is being canceled. Nothing has touched the connections.
canceled,
};
/// What the accept loop does with a stream it has just accepted.
const Claim = union(enum) {
/// The stream owns `conns[index]`.
@@ -77,7 +104,7 @@ pub const TcpServer = struct {
state: std.atomic.Value(State),
stopped: std.Io.Event,
/// One slot is ~131 KiB, so the default 64 connections cost ~8.4 MiB, which
/// One slot is ~137 KiB, so the default 64 connections cost ~8.8 MiB, which
/// is inside the PLAN §18 budget. The two message buffers cannot be shared
/// or shrunk: the handler holds the query while the reply is built, and
/// both ceilings are the 65535 bytes the length prefix can express.
@@ -86,7 +113,15 @@ pub const TcpServer = struct {
reply: [transport.max_message_len]u8,
read_buf: [stream_buffer_len]u8,
write_buf: [stream_buffer_len]u8,
/// The handler's per-query working memory. It belongs to the slot so
/// that answering a message allocates nothing, and a connection is
/// answered serially, so one query uses it at a time.
scratch: handler.Scratch,
stream: std.Io.net.Stream,
/// The client, read off the accepted socket once at claim time: every
/// message on this connection comes from the same peer, and the handler
/// needs it for rate limiting, groups and the query log.
peer: std.Io.net.IpAddress,
/// Guarded by `TcpServer.mutex`.
state: ConnState,
};
@@ -96,7 +131,7 @@ pub const TcpServer = struct {
pub fn listen(
gpa: std.mem.Allocator,
io: std.Io,
address: std.Io.net.IpAddress,
listen_address: std.Io.net.IpAddress,
h: *handler.Handler,
options: Options,
) ListenError!TcpServer {
@@ -106,7 +141,7 @@ pub const TcpServer = struct {
errdefer gpa.free(conns);
for (conns) |*conn| conn.state = .free;
const local = address;
const local = listen_address;
const server = try local.listen(io, .{ .reuse_address = true });
return .{
@@ -132,15 +167,28 @@ pub const TcpServer = struct {
if (self.state.cmpxchgStrong(.idle, .serving, .acq_rel, .acquire) != null) return;
var group: std.Io.Group = .init;
self.acceptLoop(io, &group);
// A reply that is half written is worse than no reply, so the live
// connections are awaited even when this task is being canceled.
const prev = io.swapCancelProtection(.blocked);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
_ = io.swapCancelProtection(prev);
switch (self.acceptLoop(io, &group)) {
// `deinit` shut every live connection down before it published
// `.closing`, so each one is already unblocked and ending on its
// own. Awaiting them means a half-written reply still goes out
// whole, and the wait is bounded by the shutdown, not the client.
.closing => {
const prev = io.swapCancelProtection(.blocked);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
_ = io.swapCancelProtection(prev);
},
// Nothing has shut these connections down: `deinit` cannot run
// until this task returns, and RFC 7766 lets a client hold a
// connection open forever by asking again inside the idle budget.
// Draining here would therefore let one client stall the whole
// process's shutdown for as long as it likes. `cancel` requests
// cancellation and joins, so the slots are still quiet — and the
// buffers still unreferenced — by the time `serve` returns; the
// price is the one reply that was mid-write.
.canceled => group.cancel(io),
}
self.stopped.set(io);
}
@@ -167,14 +215,17 @@ pub const TcpServer = struct {
self.* = undefined;
}
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) void {
fn acceptLoop(self: *TcpServer, io: std.Io, group: *std.Io.Group) Stop {
while (self.state.load(.acquire) == .serving) {
const stream = self.server.accept(io) catch |err| switch (err) {
error.Canceled, error.SocketNotListening => return,
error.Canceled => return .canceled,
// `deinit` shuts the listening socket down to unblock exactly
// this call, so it is the shutdown path arriving early.
error.SocketNotListening => return .closing,
else => {
bump(&self.stats.accept_errors);
log.debug("tcp accept failed: {t}", .{err});
retry_delay.sleep(io) catch return;
retry_delay.sleep(io) catch return .canceled;
continue;
},
};
@@ -192,7 +243,7 @@ pub const TcpServer = struct {
.shutting_down => {
bump(&self.stats.rejected_at_shutdown);
stream.close(io);
return;
return .closing;
},
};
@@ -206,6 +257,9 @@ pub const TcpServer = struct {
bump(&self.stats.accepted);
}
// The loop condition failed, which only `deinit` can cause.
return .closing;
}
fn serveConn(self: *TcpServer, io: std.Io, index: usize) void {
@@ -258,7 +312,15 @@ pub const TcpServer = struct {
},
}
const bytes = switch (self.handler.handle(io, .tcp, conn.query[0..len], &conn.reply)) {
const outcome = self.handler.handle(
io,
.tcp,
address.NetAddress.fromIp(conn.peer),
conn.query[0..len],
&conn.reply,
&conn.scratch,
);
const bytes = switch (outcome) {
// There is no framing for "no answer", so the connection ends.
.drop => return,
.reply => |b| b,
@@ -286,6 +348,7 @@ pub const TcpServer = struct {
switch (outcome) {
.slot => |index| {
self.conns[index].stream = stream;
self.conns[index].peer = stream.socket.address;
self.conns[index].state = .active;
},
.at_capacity, .shutting_down => {},
+172 -4
View File
@@ -15,6 +15,10 @@ const net = std.Io.net;
const handler = @import("handler.zig");
const tcp_server = @import("tcp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
const header = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const types = @import("../dns/types.zig");
@@ -22,6 +26,31 @@ const transport = @import("../upstream/transport.zig");
const testing = std.testing;
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
.mode = blocking_defaults.response,
.ttl = blocking_defaults.ttl,
};
const forward_timeout: std.Io.Clock.Duration = .{
.raw = model.readTimeout(.{}),
.clock = .awake,
};
/// An upstream and nothing else optional: no filtering, no cache, no log. The
/// listener is what these tests exercise, so the handler is the same bare one
/// its own tests use.
fn bareHandler(client: transport.Client) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
const budget: std.Io.Clock.Duration = .{ .raw = .fromSeconds(5), .clock = .awake };
/// Short enough to keep the idle-timeout test quick, long enough that a
@@ -150,7 +179,7 @@ test "two length-prefixed queries share one connection" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
@@ -171,6 +200,145 @@ test "two length-prefixed queries share one connection" {
};
}
test "the claimed slot records the connecting client" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
const server_address = server.boundAddress();
var group: std.Io.Group = .init;
try group.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
try bounded(io, twoQueriesOnOneConnection, .{ io, server_address });
// The only connection took slot 0, and the reply the client already read
// was written after `claim` filled the slot in, so this read races nothing.
// Without a real peer the handler would rate-limit, group and log every TCP
// client under whatever the uninitialized slot happened to hold.
const peer = server.conns[0].peer;
try testing.expectEqual(net.IpAddress.ip4, std.meta.activeTag(peer));
try testing.expectEqualSlices(u8, &[_]u8{ 127, 0, 0, 1 }, &peer.ip4.bytes);
try testing.expect(peer.ip4.port != 0);
server.deinit(gpa, io);
group.await(io) catch |err| switch (err) {
error.Canceled => unreachable,
};
}
/// `std.Io.Group` takes only `Cancelable!void`, so the result is reported out
/// of band. `answered` is set either way — a client that fails early must not
/// leave the test blocked waiting for a reply that will never come, and `set`
/// is documented to have no effect the second time.
fn holdConnection(
io: std.Io,
address: net.IpAddress,
answered: *std.Io.Event,
result: *anyerror!void,
) void {
result.* = holdConnectionOpen(io, address, answered);
answered.set(io);
}
/// Holds a connection open the way RFC 7766 lets a real client hold one: one
/// query answered, then nothing, so the server sits blocked on the read for the
/// next message. Returns once the server ends the connection, however it ends
/// it — a canceled server closes the socket, which the client sees either as
/// end of stream or as a reset.
fn holdConnectionOpen(io: std.Io, address: net.IpAddress, answered: *std.Io.Event) anyerror!void {
const remote = address;
var stream = try remote.connect(io, .{ .mode = .stream });
defer stream.close(io);
var read_buf: [1024]u8 = undefined;
var write_buf: [1024]u8 = undefined;
var reader = stream.reader(io, &read_buf);
var writer = stream.writer(io, &write_buf);
try writer.interface.writeAll(&transport.framePrefix(@intCast(query_bytes.len)));
try writer.interface.writeAll(query_bytes);
try writer.interface.flush();
const len = transport.parsePrefix((try reader.interface.takeArray(transport.prefix_len)).*);
try expectAnswersQuery(try reader.interface.take(len));
answered.set(io);
var sink: [64]u8 = undefined;
_ = reader.interface.readSliceShort(&sink) catch {};
}
test "a canceled serve does not wait for a live connection" {
if (!build_options.integration) return error.SkipZigTest;
const gpa = testing.allocator;
var threaded: std.Io.Threaded = .init(gpa, .{});
defer threaded.deinit();
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
// The idle budget is the whole time a drain would have to wait out, so it
// is set far beyond any patience this test run has: if `serve` waits for
// the connection instead of canceling it, the wait never ends.
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
.max_connections = 2,
.idle_timeout = .{ .raw = .fromSeconds(600), .clock = .awake },
});
const server_address = server.boundAddress();
var serving: std.Io.Group = .init;
try serving.concurrent(io, tcp_server.TcpServer.serve, .{ &server, io });
var answered: std.Io.Event = .unset;
var client_result: anyerror!void = {};
var client_group: std.Io.Group = .init;
try client_group.concurrent(io, holdConnection, .{ io, server_address, &answered, &client_result });
// The reply proves the connection is claimed and served, so the connection
// task is now blocked reading the message that never comes. That is the
// state a drain hangs in.
answered.waitUncancelable(io);
// This is the composition root's shutdown: cancel the task group before
// anything it borrows is released, so no `deinit` has shut the connection
// down. It must still return. A regression here does not fail the test, it
// hangs the run — there is no way to bound a join that does not finish.
serving.cancel(io);
// Cancellation must not skip the per-connection cleanup: the slot is
// released and the socket closed by `serveConn`'s defer, which runs on the
// canceled path like any other.
try testing.expectEqual(@as(?usize, 0), firstFreeSlot(&server));
client_group.cancel(io);
server.deinit(gpa, io);
// Checked last: the connection had to be answered for the test to mean
// anything, and the server is torn down before a failure is reported.
try client_result;
}
/// The first slot the server would hand out, read after `serve` has returned so
/// nothing can be writing it.
fn firstFreeSlot(server: *const tcp_server.TcpServer) ?usize {
for (server.conns, 0..) |*conn, index| {
if (conn.state == .free) return index;
}
return null;
}
test "an idle connection is closed and counted" {
if (!build_options.integration) return error.SkipZigTest;
@@ -180,7 +348,7 @@ test "an idle connection is closed and counted" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
@@ -213,7 +381,7 @@ test "a zero-length message is a connection error" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{
@@ -265,7 +433,7 @@ test "deinit ends a serve loop that is blocked on accept" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
const listen_address: net.IpAddress = try .parse("127.0.0.1", 0);
var server = try tcp_server.TcpServer.listen(gpa, io, listen_address, &h, .{ .max_connections = 2 });
+21 -8
View File
@@ -9,6 +9,7 @@
//! allocates nothing. The pool is sized once in `bind` and never grows.
const std = @import("std");
const address = @import("../platform/address.zig");
const handler = @import("handler.zig");
const transport = @import("../upstream/transport.zig");
@@ -75,10 +76,14 @@ pub const UdpServer = struct {
/// The datagram actually sent stays bounded by `udpLimit` inside the
/// handler, so nothing larger than 4096 bytes leaves this socket.
///
/// Cost: 4096 + 65535 ≈ 68 KiB per slot, so the default 64 slots hold
/// ≈ 4.3 MiB. The PLAN §18 budget is 100 MB with ~1M blocked domains,
/// so this pool takes about 4% of it.
/// Cost: 4096 + 65535 + the scratch below ≈ 74 KiB per slot, so the
/// default 64 slots hold ≈ 4.6 MiB. The PLAN §18 budget is 100 MB with
/// ~1M blocked domains, so this pool takes about 5% of it.
reply: [transport.max_message_len]u8,
/// The handler's per-query working memory. It belongs to the slot so
/// that answering a datagram still allocates nothing, and one slot
/// serves one query at a time.
scratch: handler.Scratch,
from: std.Io.net.IpAddress,
len: usize,
/// Guarded by `UdpServer.mutex`.
@@ -90,7 +95,7 @@ pub const UdpServer = struct {
pub fn bind(
gpa: std.mem.Allocator,
io: std.Io,
address: std.Io.net.IpAddress,
bind_address: std.Io.net.IpAddress,
h: *handler.Handler,
options: Options,
) BindError!UdpServer {
@@ -100,7 +105,7 @@ pub const UdpServer = struct {
errdefer gpa.free(slots);
for (slots) |*slot| slot.in_use = false;
const local = address;
const local = bind_address;
const socket = try local.bind(io, .{ .mode = .dgram });
return .{
@@ -204,7 +209,15 @@ pub const UdpServer = struct {
const slot = &self.slots[index];
defer self.release(io, index);
switch (self.handler.handle(io, .udp, slot.query[0..slot.len], &slot.reply)) {
const outcome = self.handler.handle(
io,
.udp,
address.NetAddress.fromIp(slot.from),
slot.query[0..slot.len],
&slot.reply,
&slot.scratch,
);
switch (outcome) {
.drop => bump(&self.stats.dropped_handler),
.reply => |bytes| self.socket.send(io, &slot.from, bytes) catch |err| {
bump(&self.stats.send_errors);
@@ -283,8 +296,8 @@ test "a slot's reply buffer holds a whole DNS message" {
}
test "the default slot pool stays inside the memory budget" {
// 4096 + 65535 ≈ 68 KiB per slot; 64 slots ≈ 4.3 MiB, against the 100 MB
// of PLAN §18.
// 4096 + 65535 + scratch ≈ 74 KiB per slot; 64 slots ≈ 4.6 MiB, against the
// 100 MB of PLAN §18.
const options: Options = .{};
const pool_bytes = @sizeOf(UdpServer.Slot) * @as(usize, options.max_in_flight);
try testing.expect(pool_bytes < 8 * 1024 * 1024);
+33 -4
View File
@@ -14,6 +14,10 @@ const net = std.Io.net;
const handler = @import("handler.zig");
const udp_server = @import("udp_server.zig");
const model = @import("../config/model.zig");
const response = @import("../filter/response.zig");
const forward_zones = @import("../local/forward_zones.zig");
const records = @import("../local/records.zig");
const header = @import("../dns/header.zig");
const packet = @import("../dns/packet.zig");
const types = @import("../dns/types.zig");
@@ -21,6 +25,31 @@ const transport = @import("../upstream/transport.zig");
const testing = std.testing;
const empty_records: records.Records = .empty;
const empty_zones: forward_zones.Zones = .empty;
const blocking_defaults: model.Blocking = .{};
const blocking: response.Options = .{
.mode = blocking_defaults.response,
.ttl = blocking_defaults.ttl,
};
const forward_timeout: std.Io.Clock.Duration = .{
.raw = model.readTimeout(.{}),
.clock = .awake,
};
/// An upstream and nothing else optional: no filtering, no cache, no log. The
/// listener is what these tests exercise, so the handler is the same bare one
/// its own tests use.
fn bareHandler(client: transport.Client) handler.Handler {
return .{
.upstream = client,
.blocking = blocking,
.forward_read_timeout = forward_timeout,
.records = &empty_records,
.zones = &empty_zones,
};
}
/// 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 } };
@@ -87,7 +116,7 @@ test "a udp query is answered on the loopback" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
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 });
@@ -125,7 +154,7 @@ test "a runt datagram is dropped and no reply is sent" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
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 });
@@ -160,7 +189,7 @@ test "an oversize datagram arrives truncated and is dropped" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
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 });
@@ -201,7 +230,7 @@ test "deinit ends a serve loop that is blocked on receive" {
const io = threaded.io();
var fake: FakeUpstream = .{ .reply = response_bytes };
var h: handler.Handler = .{ .upstream = fake.client() };
var h = bareHandler(fake.client());
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 });