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