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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user