import * as net from "node:net"; import { domainToASCII } from "node:url"; /** * Looks up when a domain registration expires. RDAP (the JSON successor to WHOIS) is used wherever the TLD * publishes an RDAP server in IANA's bootstrap file. Plenty of TLDs don't — notably .se, .nu, .de, .io and .eu — * so those fall back to classic WHOIS on port 43, found through IANA's own referral, and the expiry line is * parsed out of the free-text answer. Only the expiry date and registrar are kept; nothing else about the * registrant is read or stored. */ export type LookupResult = | { ok: true; domain: string; expiresAt: string | null; registrar: string | null; source: "rdap" | "whois" } /** unsupported: no RDAP or WHOIS server for the TLD. not_found: the registry has no such domain. error: couldn't ask (network, rate limit, garbled answer) — says nothing about whether it exists. */ | { ok: false; reason: "unsupported" | "not_found" | "error"; message: string }; export interface LookupDeps { fetchJson: (url: string) => Promise<{ status: number; json: any }>; whoisQuery: (server: string, query: string) => Promise; now: () => number; } const BOOTSTRAP_URL = "https://data.iana.org/rdap/dns.json"; const BOOTSTRAP_TTL_MS = 24 * 60 * 60 * 1000; const USER_AGENT = "homelab-manager (domain expiry check)"; export const defaultDeps: LookupDeps = { async fetchJson(url) { const res = await fetch(url, { headers: { Accept: "application/rdap+json, application/json", "User-Agent": USER_AGENT }, redirect: "follow", signal: AbortSignal.timeout(15_000), }); const text = await res.text(); let json: any = null; try { json = text ? JSON.parse(text) : null; } catch { // not JSON (an HTML error page, say) } return { status: res.status, json }; }, whoisQuery(server, query) { return new Promise((resolve, reject) => { const socket = net.connect({ host: server, port: 43 }); let out = ""; socket.setTimeout(12_000, () => { socket.destroy(); reject(new Error(`Timed out asking ${server}`)); }); socket.on("connect", () => socket.write(`${query}\r\n`)); socket.on("data", (chunk) => { out += chunk; if (out.length > 200_000) socket.destroy(); // a WHOIS answer is a few KB; anything larger isn't one }); socket.on("end", () => resolve(out)); socket.on("close", () => resolve(out)); socket.on("error", reject); }); }, now: () => Date.now(), }; // ─── Names ────────────────────────────────────────────────────────────────── /** Lowercased ASCII (punycode) form of a domain, or null if it isn't a plausible registrable name. */ export function normalizeDomain(input: string): string | null { const trimmed = input.trim().toLowerCase().replace(/\.$/, ""); if (!trimmed) return null; // domainToASCII quietly drops anything after a "/" or "?" — a pasted URL would be accepted as its host. Refuse instead. if (/[\s/\:@?#]/.test(trimmed)) return null; const ascii = domainToASCII(trimmed); if (!ascii || ascii.length > 253) return null; const labels = ascii.split("."); if (labels.length < 2) return null; if (!labels.every((l) => /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(l))) return null; return ascii; } /** The name itself, then each parent with one fewer label, stopping at two labels: "a.b.example.se" → a.b.example.se, b.example.se, example.se. */ export function candidateNames(domain: string): string[] { const labels = domain.split("."); const out: string[] = []; for (let i = 0; i <= labels.length - 2; i++) out.push(labels.slice(i).join(".")); return out; } function tldOf(domain: string): string { return domain.slice(domain.lastIndexOf(".") + 1); } // ─── RDAP ─────────────────────────────────────────────────────────────────── let bootstrapCache: { at: number; byTld: Map } | null = null; async function rdapBase(tld: string, deps: LookupDeps): Promise { if (!bootstrapCache || deps.now() - bootstrapCache.at > BOOTSTRAP_TTL_MS) { try { const { status, json } = await deps.fetchJson(BOOTSTRAP_URL); if (status !== 200 || !Array.isArray(json?.services)) throw new Error(`HTTP ${status}`); const byTld = new Map(); for (const [tlds, urls] of json.services as [string[], string[]][]) { const url = urls.find((u) => u.startsWith("https://")) ?? urls[0]; if (url) for (const t of tlds) byTld.set(t.toLowerCase(), url.endsWith("/") ? url : `${url}/`); } bootstrapCache = { at: deps.now(), byTld }; } catch (err) { // A stale list is far better than none — it changes rarely. if (!bootstrapCache) throw new Error(`Couldn't load IANA's RDAP server list: ${err instanceof Error ? err.message : err}`); } } return bootstrapCache!.byTld.get(tld) ?? null; } function rdapFromJson(json: any, domain: string): LookupResult { const events: { eventAction?: string; eventDate?: string }[] = Array.isArray(json?.events) ? json.events : []; const expiry = events.find((e) => /^expiration$|^expiry$/i.test(e.eventAction ?? ""))?.eventDate; const time = expiry ? Date.parse(expiry) : NaN; const registrarEntity = (Array.isArray(json?.entities) ? json.entities : []).find((e: any) => (e.roles ?? []).includes("registrar")); const fn = registrarEntity?.vcardArray?.[1]?.find((v: unknown[]) => v[0] === "fn")?.[3]; return { ok: true, domain, expiresAt: Number.isFinite(time) ? new Date(time).toISOString().slice(0, 10) : null, registrar: typeof fn === "string" && fn ? fn : null, source: "rdap", }; } // ─── WHOIS ────────────────────────────────────────────────────────────────── const whoisServerCache = new Map(); async function whoisServerFor(tld: string, deps: LookupDeps): Promise { const cached = whoisServerCache.get(tld); if (cached && deps.now() - cached.at < BOOTSTRAP_TTL_MS) return cached.server; const answer = await deps.whoisQuery("whois.iana.org", tld); const server = /^whois:\s*(\S+)/im.exec(answer)?.[1] ?? null; whoisServerCache.set(tld, { at: deps.now(), server }); return server; } const MONTHS: Record = { jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11 }; /** The date formats registries actually use, as a YYYY-MM-DD string (or null). */ export function parseWhoisDate(raw: string): string | null { const s = raw.trim(); let m = /^(\d{4})[-./](\d{1,2})[-./](\d{1,2})/.exec(s); // 2031-03-09, 2031.03.09, 2027-08-13T04:00:00Z if (m) return ymd(Number(m[1]), Number(m[2]) - 1, Number(m[3])); m = /^(\d{1,2})[-\s]([A-Za-z]{3})[a-z]*[-\s](\d{4})/.exec(s); // 13-Aug-2027, 13 August 2027 if (m && MONTHS[m[2].toLowerCase()] !== undefined) return ymd(Number(m[3]), MONTHS[m[2].toLowerCase()], Number(m[1])); m = /^(\d{1,2})\.(\d{1,2})\.(\d{4})/.exec(s); // 13.08.2027 if (m) return ymd(Number(m[3]), Number(m[2]) - 1, Number(m[1])); return null; } function ymd(y: number, mo: number, d: number): string | null { const date = new Date(Date.UTC(y, mo, d)); if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo || date.getUTCDate() !== d) return null; return date.toISOString().slice(0, 10); } const EXPIRY_LINE = /^\s*(?:registry\s+expiry\s+date|registrar\s+registration\s+expiration\s+date|expiration\s+date|expiry\s+date|expire\s+date|expires(?:\s+on)?|expire|paid-till|renewal\s+date)\s*:\s*(.+?)\s*$/i; const NOT_FOUND = /no match|not found|no entries found|no data found|domain not found|status:\s*(?:free|available)|is free|no object found/i; export function parseWhois(text: string, domain: string): LookupResult { let expiresAt: string | null = null; for (const line of text.split(/\r?\n/)) { const m = EXPIRY_LINE.exec(line); if (m) { expiresAt = parseWhoisDate(m[1]); if (expiresAt) break; } } const registrar = /^\s*registrar(?:\s+name)?\s*:\s*(.+?)\s*$/im.exec(text)?.[1] ?? null; if (expiresAt) return { ok: true, domain, expiresAt, registrar, source: "whois" }; if (NOT_FOUND.test(text)) return { ok: false, reason: "not_found", message: `${domain} isn't registered.` }; // A server pushing back on us isn't the same as a registry that doesn't publish expiry dates. if (/quota|rate.?limit|too many|exceeded|access denied|blocked|try again/i.test(text)) { return { ok: false, reason: "error", message: `The WHOIS server for ${domain} refused the query (rate limited?). It will be retried.` }; } // Answered, but with no expiry we can read (e.g. .de and .eu don't publish one). return { ok: true, domain, expiresAt: null, registrar, source: "whois" }; } // ─── Lookup ───────────────────────────────────────────────────────────────── /** Looks up exactly this name (no parent walking). */ export async function lookupRegistration(domain: string, deps: LookupDeps = defaultDeps): Promise { const tld = tldOf(domain); try { const base = await rdapBase(tld, deps); if (base) { const { status, json } = await deps.fetchJson(`${base}domain/${encodeURIComponent(domain)}`); if (status === 200 && json) return rdapFromJson(json, domain); if (status === 404) return { ok: false, reason: "not_found", message: `${domain} isn't registered.` }; return { ok: false, reason: "error", message: `The RDAP server for .${tld} answered HTTP ${status}.` }; } const server = await whoisServerFor(tld, deps); if (!server) return { ok: false, reason: "unsupported", message: `.${tld} has no public RDAP or WHOIS server to ask.` }; return parseWhois(await deps.whoisQuery(server, domain), domain); } catch (err) { return { ok: false, reason: "error", message: err instanceof Error ? err.message : String(err) }; } } /** * Finds the registration a name belongs to by trying it and then its parents ("lab.example.se" → "example.se"), * so a DNS zone that's a subdomain still resolves to the domain that actually expires — without needing a * public-suffix list. A transient error anywhere means we can't conclude "not registered". */ export async function resolveRegistration(name: string, deps: LookupDeps = defaultDeps): Promise { let sawError: LookupResult | null = null; let sawNotFound: LookupResult | null = null; let firstUnsupported: LookupResult | null = null; for (const candidate of candidateNames(name)) { const result = await lookupRegistration(candidate, deps); if (result.ok) return result; if (result.reason === "unsupported") { // Every candidate shares the TLD, so nothing further up can be answered either. firstUnsupported = result; break; } if (result.reason === "error") sawError = sawError ?? result; else sawNotFound = sawNotFound ?? result; } return sawError ?? firstUnsupported ?? sawNotFound ?? { ok: false, reason: "not_found", message: `${name} isn't registered.` }; } /** For tests: forget cached RDAP/WHOIS server lists. */ export function resetLookupCaches(): void { bootstrapCache = null; whoisServerCache.clear(); }