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
205 lines
8.1 KiB
Zig
205 lines
8.1 KiB
Zig
//! 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"));
|
||
}
|