Files
nxdns/src/local/reverse_name.zig
T
mokhtar 3c794b645b
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
milestone 25: client names learned over reverse dns
2026-08-15 11:18:44 +02:00

205 lines
8.1 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}