import { canonicalJson, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core"; import { httpFetchWithRetry } from "./fetcher"; import { hostOf } from "./dns"; import { NormalizeError, type WebSensorConnector } from "./types"; /** * RDAP connector (RFC 9083 — the JSON successor of WHOIS). Sensor URL: an RDAP query such as * `https://rdap.org/domain/example.com` (bootstrap redirector) or a registry endpoint * (`https://rdap.verisign.com/com/v1/domain/example.com`). Keeps registrar, EPP status codes, * nameservers, DNSSEC flag and the registration / expiration / last-changed events; drops the * "last update of RDAP database" event, notices and links which change on every query. * Detects: registrar transfers, expiry renewals, clientHold / serverHold (domain suspended), * nameserver moves, DNSSEC enabled/disabled, and imminent expiry (`extra.daysToExpiry`). * * Config: { domain?: "example.com" }. The sensor url may be `https://rdap.org/domain/` for display: the actual * query goes to the TLD's registry server found in the IANA bootstrap file (rdap.org itself rate-limits at ~10/min). */ export interface RdapSummary { domain: string; handle: string | null; registrar: string | null; registrarIanaId: string | null; status: string[]; nameservers: string[]; dnssec: boolean | null; registration: string | null; expiration: string | null; lastChanged: string | null; transfer: string | null; } export function summarizeRdap(j: Record): RdapSummary { const events = (j.events ?? []) as { eventAction?: string; eventDate?: string }[]; const ev = (name: string): string | null => { const e = events.find((x) => (x.eventAction ?? "").toLowerCase() === name); return e?.eventDate ? new Date(e.eventDate).toISOString() : null; }; const entities = (j.entities ?? []) as Record[]; const registrarEnt = entities.find((e) => ((e.roles ?? []) as string[]).includes("registrar")); let registrar: string | null = null; let ianaId: string | null = null; if (registrarEnt) { const vcard = (registrarEnt.vcardArray as unknown[] | undefined)?.[1] as unknown[][] | undefined; const fn = vcard?.find((v) => v[0] === "fn"); registrar = fn ? String(fn[3]) : registrarEnt.handle ? String(registrarEnt.handle) : null; const ids = (registrarEnt.publicIds ?? []) as { type?: string; identifier?: string }[]; ianaId = ids.find((i) => /iana/i.test(i.type ?? ""))?.identifier ?? null; } const secure = j.secureDNS as { delegationSigned?: boolean } | undefined; return { domain: String(j.ldhName ?? j.unicodeName ?? "").toLowerCase(), handle: j.handle ? String(j.handle) : null, registrar, registrarIanaId: ianaId, status: [...((j.status ?? []) as string[])].map((s) => s.toLowerCase()).sort(), nameservers: ((j.nameservers ?? []) as { ldhName?: string }[]).map((n) => String(n.ldhName ?? "").toLowerCase().replace(/\.$/, "")).filter(Boolean).sort(), dnssec: secure ? Boolean(secure.delegationSigned) : null, registration: ev("registration"), expiration: ev("expiration"), lastChanged: ev("last changed"), transfer: ev("transfer"), }; } /** IANA RDAP bootstrap (RFC 9224): TLD → registry base URL, cached 24 h. Falls back to rdap.org. */ let bootstrap: { at: number; map: Map } | null = null; export async function rdapBaseFor(domain: string, sensorId = "rdap_bootstrap"): Promise { if (!bootstrap || Date.now() - bootstrap.at > 86_400_000) { const obs = await httpFetchWithRetry(sensorId, "https://data.iana.org/rdap/dns.json", { accept: "application/json", timeoutMs: 20_000 }); if (obs.body && obs.meta.status === 200) { const j = JSON.parse(obs.body.toString("utf8")) as { services: [string[], string[]][] }; const map = new Map(); for (const [tlds, urls] of j.services) { const https = urls.find((u) => u.startsWith("https://")) ?? urls[0]; if (https) for (const t of tlds) map.set(t.toLowerCase(), https.endsWith("/") ? https : https + "/"); } bootstrap = { at: Date.now(), map }; } else if (!bootstrap) bootstrap = { at: Date.now() - 86_000_000, map: new Map() }; } const tld = domain.split(".").at(-1) ?? ""; // TLDs without an IANA bootstrap entry but with a working registry RDAP. const OVERRIDES: Record = { io: "https://rdap.identitydigital.services/rdap/", ac: "https://rdap.identitydigital.services/rdap/", sh: "https://rdap.identitydigital.services/rdap/" }; const base = bootstrap.map.get(tld) ?? OVERRIDES[tld]; return base ? `${base}domain/${domain}` : `https://rdap.org/domain/${domain}`; } export class RdapConnector implements WebSensorConnector { mode = "json" as const; metadata(): ConnectorMetadata { return { key: "rdap", name: "RDAP (domain registration)", sensorTypes: ["JSON", "DNS"], description: "Registrar, EPP status, nameservers, DNSSEC and registration/expiry events of a domain", version: "1.0.0" }; } private async target(endpoint: SensorEndpoint): Promise { const cfg = endpoint.config as { domain?: string; direct?: boolean }; // A registry RDAP URL is used as-is; rdap.org (rate-limited redirector) and bare domains go through the IANA bootstrap. if (/\/domain\//.test(endpoint.url) && !/rdap\.org\//.test(endpoint.url)) return endpoint.url; const domain = (cfg.domain ?? endpoint.url.match(/\/domain\/([^/?#]+)/)?.[1] ?? hostOf(endpoint.url).replace(/^www\./, "")).toLowerCase(); return cfg.direct ? `https://rdap.org/domain/${domain}` : rdapBaseFor(domain, endpoint.id); } async fetch(endpoint: SensorEndpoint): Promise { const obs = await httpFetchWithRetry(endpoint.id, await this.target(endpoint), { accept: "application/rdap+json, application/json;q=0.9", timeoutMs: 30_000, maxRedirects: 6 }); return { ...obs, url: endpoint.url }; } async normalize(endpoint: SensorEndpoint, obs: Observation): Promise { if (!obs.body) throw new NormalizeError("no_body", "Observation has no body"); let j: Record; try { j = JSON.parse(obs.body.toString("utf8")) as Record; } catch { throw new NormalizeError("bad_json", "RDAP response is not JSON"); } if (j.objectClassName !== "domain" && !j.ldhName) throw new NormalizeError("bad_shape", `Not an RDAP domain object (${String(j.errorCode ?? j.title ?? "unknown")})`); const s = summarizeRdap(j); const c = canonicalJson(s); const daysToExpiry = s.expiration ? Math.round((new Date(s.expiration).getTime() - Date.now()) / 86400e3) : null; const held = s.status.some((x) => /hold|redemption|pendingdelete/i.test(x)); return { mode: "json", json: s, title: `RDAP ${s.domain}`, rawHash: sha256(obs.body), canonicalHash: sha256(c), semanticHash: simhash(c), publishedAt: s.lastChanged ? new Date(s.lastChanged) : null, extractionConfidence: 1, extra: { registrar: s.registrar, daysToExpiry, expiring: daysToExpiry !== null && daysToExpiry < 30, held, dnssec: s.dnssec, nameservers: s.nameservers.length } }; } }