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