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
+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();