milestone 25: client names learned over reverse dns
Gates / test (push) Successful in 2m58s
Gates / frontend (push) Successful in 3m57s
Gates / test-aarch64 (push) Successful in 8m20s
Gates / package (push) Successful in 7m27s
Gates / container (push) Successful in 17s
CI / gates (push) Successful in 19m9s

This commit is contained in:
2026-08-15 11:18:44 +02:00
parent c428bc2398
commit 3c794b645b
20 changed files with 2638 additions and 36 deletions
+6 -1
View File
@@ -34,6 +34,7 @@ const api_limiter = @import("web/api_limiter.zig");
const auth = @import("web/auth.zig");
const cert_store = @import("server/cert_store.zig");
const cli = @import("cli.zig");
const client_names = @import("server/client_names.zig");
const clients = @import("server/clients.zig");
const config_export = @import("config/export.zig");
const db = @import("storage/db.zig");
@@ -462,6 +463,9 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
var paused: pause.Pause = .{};
var tracker: clients.Tracker = .init(cfg.logging.retention_days);
// Naming rides the tracker's pass, on the tracker's task and connection
// (milestone-25 ruling 1), and reads the live forward zones.
var client_names_resolver: client_names.Resolver = .init(&tables);
// The queue holds waiting tasks in intrusive lists, so neither the buffer
// nor the `Logger` may move once a task has touched either.
@@ -635,6 +639,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
.handler = &h,
.pause = &paused,
.tracker = &tracker,
.client_names = &client_names_resolver,
.manager = &manager,
.pool = &pool,
.monitor = &monitor,
@@ -751,7 +756,7 @@ fn serve(r: cli.Runner, args: cli.RunArgs) !u8 {
try group.concurrent(io, retention_mod.Retention.run, .{ &retention, io, &querylog_retention_db, gate });
try group.concurrent(io, disk_monitor.Monitor.run, .{ &monitor, io });
try group.concurrent(io, manager_mod.Manager.runScheduler, .{ &manager, io });
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate });
try group.concurrent(io, clients.Tracker.run, .{ &tracker, io, &tracker_db, gate, &client_names_resolver });
try group.concurrent(io, runMaintenance, .{ &h, if (web_limiter) |*l| l else null, io });
// Started last (ruling 26), canceled by the same `group.cancel`; its inner
+204
View File
@@ -0,0 +1,204 @@
//! Reverse DNS names and the hostname gate for learned client names
//! (milestone 25). Pure: bytes in, bytes out — no `std.Io`, no clock, no
//! sockets. Everything that queries a resolver or writes a row lives in
//! `src/server/`.
const std = @import("std");
const address = @import("../platform/address.zig");
const v4_suffix = "in-addr.arpa";
const v6_suffix = "ip6.arpa";
/// The v6 form is the longest: 32 nibbles, each followed by a dot, then
/// `ip6.arpa`.
pub const max_reverse_len: usize = 32 * 2 + v6_suffix.len;
/// A PTR owner name is one label per byte (v4) or per nibble (v6), least
/// significant first, under `in-addr.arpa` / `ip6.arpa`. Lowercase hex for v6,
/// no trailing dot — the form `forward_zones.Zones.match` expects.
///
/// The tracker canonicalises an IPv4-mapped v6 address to `.ip4` before a
/// `NetAddress` reaches here, so this function never sees one.
pub fn reverseName(addr: address.NetAddress, buf: *[max_reverse_len]u8) []const u8 {
var w = std.Io.Writer.fixed(buf);
switch (addr) {
.ip4 => |b| {
w.print("{d}.{d}.{d}.{d}.{s}", .{ b[3], b[2], b[1], b[0], v4_suffix }) catch unreachable;
},
.ip6 => |b| {
std.debug.assert(!isIp4Mapped(b));
var i: usize = b.len;
while (i > 0) {
i -= 1;
const byte = b[i];
w.writeByte(hex_digits[byte & 0x0f]) catch unreachable;
w.writeByte('.') catch unreachable;
w.writeByte(hex_digits[byte >> 4]) catch unreachable;
w.writeByte('.') catch unreachable;
}
w.writeAll(v6_suffix) catch unreachable;
},
}
return w.buffered();
}
const hex_digits = "0123456789abcdef";
fn isIp4Mapped(b: [16]u8) bool {
return std.mem.eql(u8, b[0..10], &[_]u8{0} ** 10) and b[10] == 0xff and b[11] == 0xff;
}
/// The gate every PTR target passes before it is stored, logged or displayed.
/// The bytes come from whatever box the operator pointed a forward zone at, so
/// nothing weaker is enough.
///
/// Accepts only `[a-z0-9._-]` after ASCII-lowercasing `A-Z`; labels are 163
/// bytes and the whole name is at most 253; no label starts or ends with `-`.
/// An empty label is rejected, which also rejects a trailing dot — `formatText`
/// emits none, so one appearing means the reply was malformed.
///
/// Underscore is accepted because real DHCP hostnames carry it. Nothing else
/// outside the set is.
pub fn acceptHostname(text: []const u8) bool {
if (text.len == 0 or text.len > 253) return false;
var label_len: usize = 0;
var prev: u8 = 0;
for (text) |raw| {
const ch = std.ascii.toLower(raw);
if (ch == '.') {
if (label_len == 0) return false;
if (prev == '-') return false;
label_len = 0;
prev = ch;
continue;
}
if (label_len == 0 and ch == '-') return false;
if (!isHostByte(ch)) return false;
label_len += 1;
if (label_len > 63) return false;
prev = ch;
}
if (label_len == 0) return false;
if (prev == '-') return false;
return true;
}
fn isHostByte(ch: u8) bool {
return (ch >= 'a' and ch <= 'z') or (ch >= '0' and ch <= '9') or ch == '_' or ch == '-';
}
// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------
const testing = std.testing;
test "reverseName reverses the octets of a v4 address" {
var buf: [max_reverse_len]u8 = undefined;
try testing.expectEqualStrings(
"10.1.168.192.in-addr.arpa",
reverseName(try address.NetAddress.parse("192.168.1.10"), &buf),
);
try testing.expectEqualStrings(
"0.0.0.0.in-addr.arpa",
reverseName(try address.NetAddress.parse("0.0.0.0"), &buf),
);
try testing.expectEqualStrings(
"255.255.255.255.in-addr.arpa",
reverseName(try address.NetAddress.parse("255.255.255.255"), &buf),
);
}
test "reverseName writes the 32-nibble lowercase ip6.arpa form" {
var buf: [max_reverse_len]u8 = undefined;
try testing.expectEqualStrings(
"1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.f.ip6.arpa",
reverseName(try address.NetAddress.parse("fd00::1"), &buf),
);
// Every nibble distinct, so a swapped high/low half would show.
try testing.expectEqualStrings(
"b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.b.a.9.8.7.6.5.4.3.2.1.0.f.e.d.c.ip6.arpa",
reverseName(try address.NetAddress.parse("cdef:0123:4567:89ab:cdef:0123:4567:89ab"), &buf),
);
}
test "the v6 form is exactly max_reverse_len bytes and the v4 form is shorter" {
var buf: [max_reverse_len]u8 = undefined;
const v6 = reverseName(try address.NetAddress.parse("cdef:0123:4567:89ab:cdef:0123:4567:89ab"), &buf);
try testing.expectEqual(max_reverse_len, v6.len);
const v4 = reverseName(try address.NetAddress.parse("255.255.255.255"), &buf);
try testing.expect(v4.len < max_reverse_len);
}
test "an IPv4-mapped v6 literal is already canonical, so reverseName sees v4" {
var buf: [max_reverse_len]u8 = undefined;
try testing.expectEqualStrings(
"10.1.168.192.in-addr.arpa",
reverseName(try address.NetAddress.parse("::ffff:192.168.1.10"), &buf),
);
}
test "acceptHostname accepts and rejects ruling 4's table" {
// 254 bytes, every label within 63: only the total length rejects it.
const long_name = "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 62;
try testing.expectEqual(@as(usize, 254), long_name.len);
const long_label = "a" ** 64;
const cases = [_]struct { text: []const u8, want: bool }{
.{ .text = "", .want = false },
.{ .text = long_name, .want = false },
.{ .text = long_label, .want = false },
.{ .text = "a b", .want = false },
.{ .text = "a\x00b", .want = false },
.{ .text = "héllo", .want = false },
.{ .text = "-x", .want = false },
.{ .text = "x-.y", .want = false },
.{ .text = "a..b", .want = false },
.{ .text = ".a", .want = false },
.{ .text = "a.", .want = false },
.{ .text = "nas-1.lan", .want = true },
.{ .text = "my_printer.home", .want = true },
.{ .text = "x", .want = true },
};
for (cases) |case| {
testing.expectEqual(case.want, acceptHostname(case.text)) catch |err| {
std.debug.print("acceptHostname(\"{s}\")\n", .{case.text});
return err;
};
}
}
test "a reverse name matches the forward zone declared over its reverse space" {
const forward_zones = @import("forward_zones.zig");
var zones = try forward_zones.Zones.build(testing.allocator, &.{
.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" },
.{ .zone = "0.0.d.f.ip6.arpa", .resolver = "udp://[fd00::1]:53" },
});
defer zones.deinit(testing.allocator);
var buf: [max_reverse_len]u8 = undefined;
const v4 = reverseName(try address.NetAddress.parse("192.168.1.10"), &buf);
try testing.expectEqualStrings("168.192.in-addr.arpa", zones.match(v4).?.zone);
var buf6: [max_reverse_len]u8 = undefined;
const v6 = reverseName(try address.NetAddress.parse("fd00::1"), &buf6);
try testing.expectEqualStrings("0.0.d.f.ip6.arpa", zones.match(v6).?.zone);
// An address outside both declared reverse zones matches nothing, which is
// the `no_zone` outcome: no query is sent to anyone.
const outside = reverseName(try address.NetAddress.parse("10.0.0.1"), &buf);
try testing.expectEqual(@as(?*const forward_zones.Zone, null), zones.match(outside));
}
test "acceptHostname takes the boundary lengths and mixed case" {
try testing.expect(acceptHostname("a" ** 63));
// 253 bytes, the longest name accepted.
try testing.expect(acceptHostname("a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 63 ++ "." ++ "a" ** 61));
try testing.expect(acceptHostname("NAS-1.LAN"));
try testing.expect(!acceptHostname("x-"));
try testing.expect(!acceptHostname("a.-b"));
try testing.expect(!acceptHostname("a.b-.c"));
}
File diff suppressed because it is too large Load Diff
+167 -26
View File
@@ -21,6 +21,7 @@
const std = @import("std");
const address = @import("../platform/address.zig");
const client_names = @import("client_names.zig");
const clients_repo = @import("../storage/repositories/clients_repo.zig");
const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
@@ -135,6 +136,7 @@ pub const Tracker = struct {
io: std.Io,
database: *db.Db,
monitor: ?*disk_monitor.Monitor,
names: ?*client_names.Resolver,
) std.Io.Cancelable!void {
const interval: std.Io.Clock.Duration = .{
.raw = .fromSeconds(flush_interval_s),
@@ -143,12 +145,18 @@ pub const Tracker = struct {
while (true) {
try interval.sleep(io);
const writes_allowed = if (monitor) |m| m.writesAllowed() else true;
self.flushOnce(io, database, writes_allowed);
self.flushOnce(io, database, writes_allowed, names);
}
}
/// One pass: drain the table, write a row per client, and prune on every
/// `prune_every_passes`-th pass.
/// One pass: drain the table, write a row per client, prune on every
/// `prune_every_passes`-th pass, and then learn names for the rows that
/// have none (milestone-25 ruling 1).
///
/// The order of the three steps is fixed and one `now_s` serves all three.
/// Resolving before pruning would spend PTR queries on rows the same pass
/// deletes; resolving before the drain would make a slow resolver delay the
/// writes the pass exists for.
///
/// 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
@@ -161,9 +169,17 @@ pub const Tracker = struct {
/// 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 {
pub fn flushOnce(
self: *Tracker,
io: std.Io,
database: *db.Db,
writes_allowed: bool,
names: ?*client_names.Resolver,
) void {
// A gated pass skips naming too: naming writes.
if (!writes_allowed) return;
const now_s = std.Io.Clock.real.now(io).toSeconds();
var drained: [max_pending]Pending = undefined;
const batch = self.drain(io, &drained);
@@ -193,18 +209,21 @@ pub const Tracker = struct {
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);
if (due) {
const cutoff = now_s - @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);
}
}
if (names) |resolver| resolver.runPass(io, database, now_s);
}
pub fn snapshotStats(self: *Tracker, io: std.Io) Stats {
@@ -277,7 +296,7 @@ test "a client tracked twice before a flush yields one row at the later time" {
try testing.expectEqual(@as(u64, 0), tracker.trackAt(io, parsed("192.168.1.10"), 1700000030));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000030), try lastSeen(&database, "192.168.1.10"));
@@ -305,7 +324,7 @@ test "distinct clients each get a row and ipv6 text is canonical" {
_ = 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);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 3), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000003), try lastSeen(&database, "192.168.1.10"));
@@ -343,7 +362,7 @@ test "a full table drops further clients and counts them" {
// 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);
tracker.flushOnce(io, &database, true, null);
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"));
@@ -366,7 +385,7 @@ test "a flush touches a hand-edited row without changing what the operator set"
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(i64, 1700000000), try lastSeen(&database, "192.168.1.10"));
@@ -394,7 +413,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
var tracker: Tracker = .init(30);
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000000);
tracker.flushOnce(io, &database, monitor.writesAllowed());
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
@@ -403,7 +422,7 @@ test "a gated pass writes nothing and keeps the pending clients" {
// 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());
tracker.flushOnce(io, &database, monitor.writesAllowed(), null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.passes);
}
@@ -423,7 +442,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
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);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
const stats = tracker.snapshotStats(io);
@@ -433,7 +452,7 @@ test "a failing upsert counts and leaves the client to be tracked again" {
try database.exec("DROP TRIGGER refuse_insert;");
_ = tracker.trackAt(io, parsed("192.168.1.10"), 1700000060);
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
}
@@ -453,12 +472,12 @@ test "the pass that comes due prunes the clients that went quiet" {
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);
tracker.flushOnce(io, &database, true, null);
}
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);
tracker.flushOnce(io, &database, true, null);
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"));
@@ -479,7 +498,7 @@ test "a shorter retention prunes what the default keeps" {
var tracker: Tracker = .init(1);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u64, 1), tracker.snapshotStats(io).pruned);
@@ -501,6 +520,7 @@ test "the run loop flushes on its interval and returns on cancel" {
io,
&database,
@as(?*disk_monitor.Monitor, null),
@as(?*client_names.Resolver, null),
});
// The first flush is one interval away, so cancelling immediately proves the
// loop starts by sleeping rather than by writing.
@@ -508,3 +528,124 @@ test "the run loop flushes on its interval and returns on cancel" {
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 1), tracker.pendingClients(io));
}
// --- the naming step's place in the pass (milestone-25 ruling 1) ------------
const forward_zones = @import("../local/forward_zones.zig");
const local_tables = @import("local_tables.zig");
const validate = @import("../config/validate.zig");
/// Counts exchanges and times out on every one, and records how many client
/// rows existed when the first exchange was attempted.
const CountingExchange = struct {
var calls: usize = 0;
var database: ?*db.Db = null;
var rows_at_first_call: i64 = -1;
fn reset(target: *db.Db) void {
calls = 0;
database = target;
rows_at_first_call = -1;
}
fn exchange(
_: std.Io,
_: validate.Resolver,
_: []const u8,
_: []u8,
) @import("../upstream/transport.zig").ExchangeError![]u8 {
if (calls == 0) {
rows_at_first_call = clients_repo.countClients(database.?) catch -1;
}
calls += 1;
return error.Timeout;
}
};
fn namingTables(io: std.Io, tables: *local_tables.LocalTables) !void {
tables.swap(io, testing.allocator, .empty, try forward_zones.Zones.build(
testing.allocator,
&.{.{ .zone = "168.192.in-addr.arpa", .resolver = "udp://192.168.1.1:53" }},
));
}
test "the drain lands before any exchange, and attempts stop at the cap" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
var tracker: Tracker = .init(30);
const pending = clients_repo.max_per_pass + 4;
for (0..pending) |i| {
_ = tracker.trackAt(io, .{ .ip4 = .{ 192, 168, 2, @intCast(i) } }, 1700000000);
}
tracker.flushOnce(io, &database, true, &names);
// Every pending row was written before the first exchange went out, which
// is what keeps a slow resolver off the drain.
try testing.expectEqual(@as(i64, @intCast(pending)), CountingExchange.rows_at_first_call);
try testing.expectEqual(clients_repo.max_per_pass, CountingExchange.calls);
try testing.expectEqual(@as(u64, @intCast(pending)), tracker.snapshotStats(io).flushed);
try testing.expectEqual(@as(u64, clients_repo.max_per_pass), names.snapshotStats(io).failed);
}
test "a row the due pass prunes is never asked about" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
const now = std.Io.Clock.real.now(io).toSeconds();
try clients_repo.upsertSeen(&database, "192.168.1.10", now - 40 * 86_400);
var tracker: Tracker = .init(30);
tracker.passes = Tracker.prune_every_passes - 1;
tracker.flushOnce(io, &database, true, &names);
try testing.expectEqual(@as(i64, 0), try clients_repo.countClients(&database));
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}
test "a gated pass attempts no naming either" {
var threaded: std.Io.Threaded = .init(testing.allocator, .{});
defer threaded.deinit();
const io = threaded.io();
var database = try openMigrated();
defer database.close();
var tables: local_tables.LocalTables = .empty;
defer tables.deinit(testing.allocator);
try namingTables(io, &tables);
var names: client_names.Resolver = .init(&tables);
names.exchange_fn = CountingExchange.exchange;
CountingExchange.reset(&database);
try clients_repo.upsertSeen(&database, "192.168.1.10", 1700000000);
var tracker: Tracker = .init(30);
tracker.flushOnce(io, &database, false, &names);
try testing.expectEqual(@as(usize, 0), CountingExchange.calls);
try testing.expectEqual(@as(u64, 0), names.snapshotStats(io).attempted);
}
+1 -1
View File
@@ -844,7 +844,7 @@ test "S7 case 10: the querying client is materialised as a row" {
// 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);
tracker.flushOnce(io, &database, true, null);
try testing.expectEqual(@as(i64, 1), try clients_repo.countClients(&database));
try testing.expectEqual(@as(u32, 0), tracker.pendingClients(io));
+2
View File
@@ -29,6 +29,8 @@ pub const ddl_v1: [:0]const u8 =
\\ id INTEGER PRIMARY KEY,
\\ ip TEXT NOT NULL UNIQUE, -- canonical text form (v4 dotted / v6 RFC 5952)
\\ name TEXT,
\\ learned_name TEXT,
\\ name_attempt_after INTEGER NOT NULL DEFAULT 0,
\\ group_id INTEGER NOT NULL REFERENCES groups(id),
\\ hand_edited INTEGER NOT NULL DEFAULT 0,
\\ first_seen INTEGER NOT NULL,
+2
View File
@@ -269,6 +269,8 @@ test "a fresh database reaches the baseline with every v1 column and rule kind"
try testing.expectEqual(@as(u32, 1), try migrate(&database));
try testing.expectEqual(@as(u32, 1), target_version);
try testing.expect(try columnExists(&database, "clients", "learned_name"));
try testing.expect(try columnExists(&database, "clients", "name_attempt_after"));
try testing.expect(try columnExists(&database, "upstreams", "tls_name"));
try testing.expect(try columnExists(&database, "blocklist_sources", "exception_count"));
try testing.expect(try columnExists(&database, "blocklist_sources", "skipped_unsupported_count"));
+327 -2
View File
@@ -18,6 +18,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const db = @import("../db.zig");
const logger = @import("../logger.zig");
const migrations = @import("../migrations.zig");
const model = @import("../../config/model.zig");
const context = @import("context.zig");
@@ -122,6 +123,123 @@ pub fn pruneStale(database: *db.Db, cutoff_s: i64) db.Error!u32 {
return @intCast(@min(deleted, std.math.maxInt(u32)));
}
// --- learned names (milestone 25) ------------------------------------------
//
// `learned_name` and `name_attempt_after` are runtime state beside `last_seen`,
// never configuration: `listClients` does not select them, so an export cannot
// carry them, and the reconcile engine never writes them. The operator's `name`
// is never touched here, and the operator never writes `learned_name` — so
// precedence is a display rule, not a write conflict.
/// How many rows one naming pass may take. The candidate buffer is sized by
/// this, and the SQL's LIMIT repeats it as a literal.
pub const max_per_pass: usize = 16;
/// One selected address. Fixed-size storage because the naming path allocates
/// nothing: `logger.max_client_len` is the bound every address text in this
/// program is sized by.
pub const Candidate = struct {
buf: [logger.max_client_len]u8 = undefined,
len: usize = 0,
pub fn ip(self: *const Candidate) []const u8 {
return self.buf[0..self.len];
}
};
pub const CandidateBuf = [max_per_pass]Candidate;
const resolve_candidates_sql =
\\SELECT ip FROM clients
\\ WHERE (name IS NULL OR name = '') AND name_attempt_after <= ?1
\\ ORDER BY name_attempt_after, ip LIMIT 16
;
/// Fills `out` with the rows whose displayed name would come from learning and
/// whose next attempt is due, and returns how many slots it filled.
///
/// `hand_edited` is deliberately absent from the predicate: display precedence
/// never consults it, so candidacy must not either. A row with a name is
/// skipped whatever its flag, because its learned name would never be shown.
///
/// `name_attempt_after` holds the epoch second before which the row is not
/// attempted again, so the predicate is a plain comparison: 0 (the column
/// default) means eligible now, and no arithmetic can overflow on a corrupt
/// value.
///
/// `error.Mismatch`: a stored `ip` longer than `logger.max_client_len`, which
/// means something other than nxdns wrote the row.
pub fn resolveCandidates(database: *db.Db, out: *CandidateBuf, now_s: i64) db.Error!usize {
var stmt = try database.prepare(resolve_candidates_sql);
defer stmt.deinit();
try stmt.bindInt(1, now_s);
var count: usize = 0;
while (try stmt.step()) : (count += 1) {
const ip = stmt.columnText(0);
if (ip.len > logger.max_client_len) return error.Mismatch;
@memcpy(out[count].buf[0..ip.len], ip);
out[count].len = ip.len;
}
return count;
}
/// What one naming attempt decided about a row's learned name.
pub const LearnedName = union(enum) {
/// The resolver answered a target that passed `acceptHostname`.
store: []const u8,
/// The resolver said the address has no name (NXDOMAIN or NODATA).
clear,
/// Anything else — an outage must not strip names from the dashboard.
keep,
};
pub const NameOutcome = struct {
/// The epoch second before which this row is not attempted again.
attempt_after: i64,
learned: LearnedName,
};
const store_learned_sql =
"UPDATE clients SET learned_name = ?2, name_attempt_after = ?3 WHERE ip = ?1";
const clear_learned_sql =
"UPDATE clients SET learned_name = NULL, name_attempt_after = ?2 WHERE ip = ?1";
const keep_learned_sql =
"UPDATE clients SET name_attempt_after = ?2 WHERE ip = ?1";
/// Records one attempt: always the next attempt time, and the learned name only
/// when the outcome decided one.
///
/// A row deleted between selection and this call makes the UPDATE touch zero
/// rows. That is a no-op by design — the device left, and nothing was learned
/// about nothing — so it is neither an error nor a counted failure.
pub fn noteNameOutcome(database: *db.Db, ip: []const u8, outcome: NameOutcome) db.Error!void {
switch (outcome.learned) {
.store => |text| {
var stmt = try database.prepare(store_learned_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindText(2, text);
try stmt.bindInt(3, outcome.attempt_after);
try stmt.exec();
},
.clear => {
var stmt = try database.prepare(clear_learned_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindInt(2, outcome.attempt_after);
try stmt.exec();
},
.keep => {
var stmt = try database.prepare(keep_learned_sql);
defer stmt.deinit();
try stmt.bindText(1, ip);
try stmt.bindInt(2, outcome.attempt_after);
try stmt.exec();
},
}
}
pub fn deleteAllClients(database: *db.Db) db.Error!void {
return database.exec("DELETE FROM clients;");
}
@@ -197,6 +315,11 @@ pub const ClientRow = struct {
/// `clients.name` is nullable; a NULL reads as `""`, as it does on the
/// import path.
name: []const u8,
/// The name learned over reverse DNS, or `""` when nothing was learned.
/// Display-only runtime state: `name` wins whenever it is non-empty, and
/// this never reaches an export. `name_attempt_after` is deliberately not
/// here — it is scheduling state with no operator meaning.
learned_name: []const u8,
group_id: i64,
group: []const u8,
hand_edited: bool,
@@ -221,14 +344,16 @@ pub const ClientEdit = struct {
};
const list_client_rows_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen,
\\ c.learned_name
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ ORDER BY c.ip
;
const get_client_sql =
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen
\\SELECT c.id, c.ip, c.name, c.group_id, g.name, c.hand_edited, c.first_seen, c.last_seen,
\\ c.learned_name
\\ FROM clients c
\\ JOIN groups g ON g.id = c.group_id
\\ WHERE c.id = ?1
@@ -263,10 +388,14 @@ fn readClientRow(stmt: *db.Stmt, gpa: Allocator) db.Error!ClientRow {
errdefer gpa.free(name);
const group = try stmt.columnTextAlloc(gpa, 4);
errdefer gpa.free(group);
// `clients.learned_name` is nullable; NULL reads as "", like `name`.
const learned_name = try stmt.columnTextAlloc(gpa, 8);
errdefer gpa.free(learned_name);
return .{
.id = stmt.columnInt(0),
.ip = ip,
.name = name,
.learned_name = learned_name,
.group_id = stmt.columnInt(3),
.group = group,
.hand_edited = stmt.columnBool(5),
@@ -721,6 +850,202 @@ test "pruneStale spares hand-edited rows however stale" {
try testing.expectEqual(@as(i64, 3), try countClients(&database));
}
// --- learned names ---------------------------------------------------------
/// `columnText` is borrowed until the statement dies, so the value is copied
/// into the caller's buffer.
fn learnedName(database: *db.Db, ip: []const u8, buf: []u8) !?[]const u8 {
var stmt = try database.prepare("SELECT learned_name FROM clients WHERE ip = ?1");
defer stmt.deinit();
try stmt.bindText(1, ip);
try testing.expect(try stmt.step());
if (stmt.isNull(0)) return null;
const text = stmt.columnText(0);
@memcpy(buf[0..text.len], text);
return buf[0..text.len];
}
fn attemptAfter(database: *db.Db, ip: []const u8) !i64 {
var stmt = try database.prepare("SELECT name_attempt_after FROM clients WHERE ip = ?1");
defer stmt.deinit();
try stmt.bindText(1, ip);
try testing.expect(try stmt.step());
return stmt.columnInt(0);
}
test "resolveCandidates selects unnamed rows in attempt-then-ip order" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.30", 1700000000);
try upsertSeen(&database, "192.168.1.10", 1700000000);
try upsertSeen(&database, "192.168.1.20", 1700000000);
// Attempted already, and due later than the other two.
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 500, .learned = .keep });
var buf: CandidateBuf = undefined;
const count = try resolveCandidates(&database, &buf, 1000);
try testing.expectEqual(@as(usize, 3), count);
try testing.expectEqualStrings("192.168.1.20", buf[0].ip());
try testing.expectEqualStrings("192.168.1.30", buf[1].ip());
try testing.expectEqualStrings("192.168.1.10", buf[2].ip());
}
test "resolveCandidates skips named rows whatever their hand_edited flag" {
var database = try openMigrated();
defer database.close();
_ = try insertClientRow(&database, .{ .ip = "192.168.1.7", .name = "printer", .group_id = 1 }, 1);
// Hand-edited but unnamed: grouped, not named, so it still benefits.
_ = try insertClientRow(&database, .{ .ip = "192.168.1.8", .group_id = 1 }, 1);
try upsertSeen(&database, "192.168.1.9", 1);
// An empty name is as unnamed as NULL.
try database.exec("UPDATE clients SET name = '' WHERE ip = '192.168.1.9';");
var buf: CandidateBuf = undefined;
const count = try resolveCandidates(&database, &buf, 1000);
try testing.expectEqual(@as(usize, 2), count);
try testing.expectEqualStrings("192.168.1.8", buf[0].ip());
try testing.expectEqualStrings("192.168.1.9", buf[1].ip());
}
test "a never-attempted row is due at any now_s, and the cutoff is inclusive" {
var database = try openMigrated();
defer database.close();
var buf: CandidateBuf = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
// `now_s` smaller than any cadence constant still selects the DEFAULT 0 row.
try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, 0));
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 1700086400, .learned = .keep });
try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, 1700086400));
try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, 1700086399));
}
test "an extreme name_attempt_after never selects and never traps" {
var database = try openMigrated();
defer database.close();
var buf: CandidateBuf = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{
.attempt_after = std.math.maxInt(i64),
.learned = .keep,
});
try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, std.math.maxInt(i64) - 1));
try testing.expectEqual(@as(usize, 1), try resolveCandidates(&database, &buf, std.math.maxInt(i64)));
}
test "resolveCandidates stops at max_per_pass" {
var database = try openMigrated();
defer database.close();
for (0..max_per_pass + 4) |i| {
var ip_buf: [logger.max_client_len]u8 = undefined;
const ip = try std.fmt.bufPrint(&ip_buf, "192.168.2.{d}", .{i});
try upsertSeen(&database, ip, 1700000000);
}
var buf: CandidateBuf = undefined;
try testing.expectEqual(max_per_pass, try resolveCandidates(&database, &buf, 1700000000));
}
test "noteNameOutcome stores, overwrites, clears and keeps" {
var database = try openMigrated();
defer database.close();
var name_buf: [logger.max_client_len]u8 = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 10, .learned = .{ .store = "nas.lan" } });
try testing.expectEqualStrings("nas.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
try testing.expectEqual(@as(i64, 10), try attemptAfter(&database, "192.168.1.10"));
// The router is the authority on its own zone: a changed answer wins.
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 20, .learned = .{ .store = "tv.lan" } });
try testing.expectEqualStrings("tv.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
// Keep leaves the stored name and only moves the schedule.
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 30, .learned = .keep });
try testing.expectEqualStrings("tv.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
try testing.expectEqual(@as(i64, 30), try attemptAfter(&database, "192.168.1.10"));
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 40, .learned = .clear });
try testing.expectEqual(@as(?[]const u8, null), try learnedName(&database, "192.168.1.10", &name_buf));
try testing.expectEqual(@as(i64, 40), try attemptAfter(&database, "192.168.1.10"));
}
test "noteNameOutcome on a row that no longer exists is a no-op" {
var database = try openMigrated();
defer database.close();
try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .{ .store = "gone.lan" } });
try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .clear });
try noteNameOutcome(&database, "192.168.1.99", .{ .attempt_after = 10, .learned = .keep });
try testing.expectEqual(@as(i64, 0), try countClients(&database));
}
test "a learned name reads back on ClientRow and a NULL reads as the empty string" {
var database = try openMigrated();
defer database.close();
try upsertSeen(&database, "192.168.1.10", 1700000000);
try upsertSeen(&database, "192.168.1.11", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 10, .learned = .{ .store = "nas.lan" } });
var rows = try listClientRows(&database, testing.allocator);
defer rows.deinit(testing.allocator);
defer freeClientRows(testing.allocator, rows.items);
try testing.expectEqualStrings("nas.lan", rows.items[0].learned_name);
try testing.expectEqualStrings("", rows.items[1].learned_name);
const fetched = (try getClient(&database, testing.allocator, rows.items[0].id)).?;
defer freeClientRow(testing.allocator, fetched);
try testing.expectEqualStrings("nas.lan", fetched.learned_name);
}
test "updateClient leaves learned_name alone and removes the row from candidacy" {
var database = try openMigrated();
defer database.close();
var name_buf: [logger.max_client_len]u8 = undefined;
try upsertSeen(&database, "192.168.1.10", 1700000000);
try noteNameOutcome(&database, "192.168.1.10", .{ .attempt_after = 0, .learned = .{ .store = "nas.lan" } });
const id = try database.queryInt("SELECT id FROM clients WHERE ip = '192.168.1.10'");
try updateClient(&database, id, .{ .name = "the nas", .group_id = 1 });
try testing.expectEqualStrings("nas.lan", (try learnedName(&database, "192.168.1.10", &name_buf)).?);
var buf: CandidateBuf = undefined;
try testing.expectEqual(@as(usize, 0), try resolveCandidates(&database, &buf, 1700000000));
}
test "an export is byte-identical across a learned name landing" {
const export_mod = @import("../../config/export.zig");
var database = try openMigrated();
defer database.close();
var ids = try seedGroups(&database);
defer ids.deinit(testing.allocator);
try seedClients(&database, &ids);
try upsertSeen(&database, "192.168.1.99", 1700000000);
var before: std.Io.Writer.Allocating = .init(testing.allocator);
defer before.deinit();
try export_mod.writeToWriter(testing.allocator, &database, &before.writer);
try noteNameOutcome(&database, "192.168.1.99", .{
.attempt_after = 1700086400,
.learned = .{ .store = "phone.lan" },
});
var after: std.Io.Writer.Allocating = .init(testing.allocator);
defer after.deinit();
try export_mod.writeToWriter(testing.allocator, &database, &after.writer);
try testing.expectEqualStrings(before.written(), after.written());
}
test "client_prefixes round-trip in prefix order with group names resolved" {
var database = try openMigrated();
defer database.close();
+2
View File
@@ -71,6 +71,7 @@ comptime {
_ = @import("local/records.zig");
_ = @import("local/forward_zones.zig");
_ = @import("local/forward_client.zig");
_ = @import("local/reverse_name.zig");
_ = @import("cache/dns_cache.zig");
_ = @import("server/rate_limiter.zig");
_ = @import("storage/repositories/queries_repo.zig");
@@ -81,6 +82,7 @@ comptime {
_ = @import("storage/retention.zig");
_ = @import("storage/phase6_integration_test.zig");
_ = @import("server/pause.zig");
_ = @import("server/client_names.zig");
_ = @import("server/clients.zig");
_ = @import("server/shutdown.zig");
_ = @import("server/phase7_integration_test.zig");
+19
View File
@@ -24,6 +24,7 @@ const std = @import("std");
const Allocator = std.mem.Allocator;
const cert_store = @import("../server/cert_store.zig");
const client_names = @import("../server/client_names.zig");
const clients = @import("../server/clients.zig");
const dns_cache = @import("../cache/dns_cache.zig");
const dns_handler = @import("../server/handler.zig");
@@ -122,6 +123,7 @@ pub const Sample = struct {
cache: ?CacheSample = null,
limiter: ?LimiterSample = null,
tracker: ?TrackerSample = null,
client_names: ?client_names.Resolver.Stats = null,
retention: ?retention_mod.Stats = null,
blocklist: ?BlocklistSample = null,
disk: ?DiskSample = null,
@@ -193,6 +195,8 @@ pub fn collect(state: *server.WebState, io: std.Io, arena: Allocator) Allocator.
.pending_clients = tracker.pendingClients(io),
};
if (state.client_names) |names| sample.client_names = names.snapshotStats(io);
if (state.retention) |retention| sample.retention = retention.snapshotStats();
if (state.manager) |manager| {
@@ -340,6 +344,10 @@ pub fn render(w: *std.Io.Writer, sample: Sample) std.Io.Writer.Error!void {
);
}
if (sample.client_names) |names| {
try counterGroup(w, "nxdns_client_names_", "Learned client name counter", names);
}
if (sample.retention) |retention| {
try counterGroup(w, "nxdns_retention_", "Query log retention counter", retention);
}
@@ -594,6 +602,7 @@ fn writeLabelValue(w: *std.Io.Writer, value: []const u8) std.Io.Writer.Error!voi
// tests
// ---------------------------------------------------------------------------
const local_tables = @import("../server/local_tables.zig");
const logger_mod = @import("../storage/logger.zig");
const testing = std.testing;
@@ -645,6 +654,7 @@ test "a full sample renders the whole exposition, byte for byte" {
.stats = .{ .tracked = 4, .flushed = 3, .dropped_full = 0, .pruned = 1, .flush_failures = 0 },
.pending_clients = 2,
},
.client_names = .{ .attempted = 6, .answered = 3, .nxdomain = 1, .no_zone = 2, .invalid = 0, .failed = 0, .read_failures = 0, .write_failures = 1 },
.retention = .{ .passes = 7, .rows_pruned = 100, .checkpoints = 7, .vacuums = 1 },
.blocklist = .{ .refreshes_gated = 2, .generation = 4 },
.disk = .{
@@ -681,6 +691,9 @@ test "a full sample renders the whole exposition, byte for byte" {
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_dns_rate_limit_tracked_clients 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_dropped_full_total 0\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_clients_pending 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_no_zone_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_answered_total 3\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_client_names_write_failures_total 1\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_retention_rows_pruned_total 100\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_refreshes_gated_total 2\n"));
try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns_blocklist_generation 4\n"));
@@ -1242,12 +1255,17 @@ test "collect reads the live counters of the components it is given" {
var tracker: clients.Tracker = .init(30);
var retention: retention_mod.Retention = .init(.{});
var tables: local_tables.LocalTables = .empty;
var names: client_names.Resolver = .init(&tables);
names.stats.no_zone = 4;
names.stats.attempted = 4;
var state: server.WebState = .{
.gpa = testing.allocator,
.handler = &handler,
.logger = &query_logger,
.tracker = &tracker,
.client_names = &names,
.retention = &retention,
};
@@ -1263,6 +1281,7 @@ test "collect reads the live counters of the components it is given" {
try testing.expectEqual(@as(u64, 3), sample.limiter.?.stats.refused);
try testing.expectEqual(@as(u64, 90), sample.logger.rows_written);
try testing.expectEqual(@as(u64, 0), sample.tracker.?.pending_clients);
try testing.expectEqual(@as(u64, 4), sample.client_names.?.no_zone);
try testing.expectEqual(@as(u64, 0), sample.retention.?.passes);
try testing.expectEqual(@as(?BlocklistSample, null), sample.blocklist);
try testing.expectEqual(@as(usize, 0), sample.upstreams.len);
+8 -1
View File
@@ -2024,13 +2024,20 @@ components:
Client:
type: object
required: [id, ip, name, group_id, group, hand_edited, first_seen, last_seen]
required: [id, ip, name, learned_name, group_id, group, hand_edited, first_seen, last_seen]
properties:
id: { type: integer }
ip: { type: string }
name:
type: string
description: Empty when the client was never named.
learned_name:
type: string
description: |
The name learned over reverse DNS, or empty when nothing was
learned. Display-only runtime state: `name` wins whenever it is
non-empty, and a learned name never appears in an export. The
server writes it; a client cannot.
group_id: { type: integer }
group: { type: string }
hand_edited: { type: boolean }
+3
View File
@@ -25,6 +25,7 @@ const address = @import("../platform/address.zig");
const api_limiter = @import("api_limiter.zig");
const auth = @import("auth.zig");
const cert_store = @import("../server/cert_store.zig");
const client_names = @import("../server/client_names.zig");
const clients = @import("../server/clients.zig");
const db = @import("../storage/db.zig");
const disk_monitor = @import("../storage/disk_monitor.zig");
@@ -130,6 +131,8 @@ pub const WebState = struct {
handler: ?*dns_handler.Handler = null,
pause: ?*pause_mod.Pause = null,
tracker: ?*clients.Tracker = null,
/// The learned-name resolver, for `metrics.collect` (milestone-25 ruling 9).
client_names: ?*client_names.Resolver = null,
manager: ?*manager_mod.Manager = null,
pool: ?*pool_mod.Pool = null,
monitor: ?*disk_monitor.Monitor = null,