Files
Homelab-manager/server/src/services/domainLookup.ts
T
bobbanandClaude Sonnet 5 ca0fa817f8 Track domain registration expiry, with daily reminders
New Domains page listing when each domain registration expires, read from
the registry. Domains behind the DNS zones already synced are picked up
automatically; others can be added by hand. You're reminded daily from N
days before expiry (Settings > Notifications, default 30) until it's
renewed, and told when an expiry date hasn't been refreshable for several
days so a stale date isn't trusted silently.

RDAP alone would not have covered this homelab: .se, .nu, .io, .eu and .de
are not in IANA's RDAP bootstrap. Lookups therefore try RDAP where the TLD
publishes a server and fall back to WHOIS on port 43, found via IANA's
own referral, parsing the expiry line out of the free-text answer. Only
the expiry date and registrar are read or stored. Verified live against
the real registries: .se and .nu via WHOIS, .com/.org/.dev via RDAP.

Behaviour worth knowing:
- A DNS zone that is a subdomain (lab.example.se) resolves to the
  registration that actually expires by trying the name and then its
  parents, so no public-suffix list is needed. Zones already covered by a
  tracked domain are not looked up again.
- "Couldn't ask" is never confused with "not registered": network errors,
  rate limits and garbled answers are errors, and a transient error at any
  level stops the walk from concluding the domain doesn't exist.
- A failed refresh keeps the last known expiry and records why, rather
  than blanking a date that's still relied on.
- Zones that don't resolve to a real registration (.lan, .local, unregistered
  names) simply get no row. Zone-derived rows disappear when their zone
  does; manual rows stay. Zone-derived rows can't be deleted by hand.
- Registries that don't publish an expiry (.de, .eu) are tracked with a
  note instead of a date.
- Input like "example.com/path" is refused rather than silently reduced
  to its host.
- Runs on the daily secret-expiry schedule and reminder time, on demand
  (Check all now / per domain), and once at startup if nothing has been
  read in a day. Never blocks startup, one lookup at a time with a pause.
  The warning window lives with the other thresholds in settings.

New table domains (migration 0011); two settings fields (toggle and
warning days).

Verified with 90 checks against fake RDAP/WHOIS backends (name
normalization, date formats, WHOIS parsing including rate-limit and
no-expiry answers, bootstrap and referral caching, stale-cache fallback,
parent walking, add/sync/check/refresh, concurrency guard, alert
selection and stale detection, the daily notification and its toggle,
role rules) plus a live smoke test against real registries and a browser
check of the page against the real router. Real dev database mtime
untouched. Not checked: a screenshot of the finished page (the capture
timed out); structure, sorting, errors and the viewer view were verified.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 03:31:02 +02:00

236 lines
12 KiB
TypeScript

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<string>;
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<string, string> } | null = null;
async function rdapBase(tld: string, deps: LookupDeps): Promise<string | null> {
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<string, string>();
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<string, { at: number; server: string | null }>();
async function whoisServerFor(tld: string, deps: LookupDeps): Promise<string | null> {
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<string, number> = { 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<LookupResult> {
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<LookupResult> {
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();
}