diff --git a/server/src/nodes.test.ts b/server/src/nodes.test.ts index 1542376..67c26f1 100644 --- a/server/src/nodes.test.ts +++ b/server/src/nodes.test.ts @@ -1,6 +1,6 @@ import { test, beforeEach } from "node:test"; import assert from "node:assert/strict"; -import { clearNodeCache, mailNode } from "./nodes.js"; +import { clearNodeCache, mailNode, ptrName } from "./nodes.js"; /** * About names the mail node by the PTR of the address the server's name @@ -38,3 +38,12 @@ test("the answer is cached for a minute, then looked up again", async () => { await mailNode("https://mail.example.com", count, rev, 61_000); assert.equal(calls, 2); }); + +test("the PTR name is built for IPv4 and IPv6 alike", () => { + assert.equal(ptrName("192.0.2.52"), "52.2.0.192.in-addr.arpa"); + assert.equal( + ptrName("2001:db8::25"), + "5.2.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", + ); + assert.equal(ptrName("::1"), "1" + ".0".repeat(31) + ".ip6.arpa"); +}); diff --git a/server/src/nodes.ts b/server/src/nodes.ts index d1c2c87..95b4308 100644 --- a/server/src/nodes.ts +++ b/server/src/nodes.ts @@ -12,7 +12,8 @@ * node has one: mail/mx2/mx3). The server offers its node name only to * administrators, so asking it would leave everyone else with nothing. */ -import { lookup as dnsLookup, reverse as dnsReverse } from "node:dns/promises"; +import { lookup as dnsLookup, resolvePtr } from "node:dns/promises"; +import { isIPv4 } from "node:net"; import { hostname } from "node:os"; import { config } from "./config.js"; @@ -33,6 +34,22 @@ export interface Nodes { type Lookup = (host: string) => Promise<{ address: string }>; type Reverse = (address: string) => Promise; +/** + * The reverse-lookup name for an address: 1.2.0.192.in-addr.arpa, or the + * nibble form under ip6.arpa. Asked for directly, because `dns.reverse` came + * back empty in the image while the resolver answered the PTR (2026-09-26). + */ +export function ptrName(address: string): string { + if (isIPv4(address)) return `${address.split(".").reverse().join(".")}.in-addr.arpa`; + const [head = "", tail = ""] = address.split("::"); + const groups = (part: string) => (part ? part.split(":") : []); + const h = groups(head), t = groups(tail); + const full = [...h, ...Array(8 - h.length - t.length).fill("0"), ...t]; + return `${full.map((g) => g.padStart(4, "0")).join("").split("").reverse().join(".")}.ip6.arpa`; +} + +const dnsReverse: Reverse = (address) => resolvePtr(ptrName(address)); + const CACHE_MS = 60_000; const cache = new Map(); diff --git a/web/src/views/settings/AboutSettings.tsx b/web/src/views/settings/AboutSettings.tsx index 357df1e..1ae9500 100644 --- a/web/src/views/settings/AboutSettings.tsx +++ b/web/src/views/settings/AboutSettings.tsx @@ -16,7 +16,8 @@ interface Nodes { function mailNodeLabel(m: Nodes["mailServer"]): string { if (!m.address) return t("{host} does not resolve", { host: m.host }); - return m.name ? `${m.name} (${m.address})` : m.address; + // The node's hostname; the bare address only when it has no PTR record. + return m.name ?? m.address; } export function AboutSettings() {