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
+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"));
}