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>
236 lines
12 KiB
TypeScript
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();
|
|
}
|