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
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:
+6
-1
@@ -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
|
||||
|
||||
@@ -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 1–63
|
||||
/// 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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user