SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
7.1 KB · 119 lines typescript
Raw Blame History
1import { canonicalJson, sha256, simhash, type ConnectorMetadata, type NormalizedContent, type Observation, type SensorEndpoint } from "@websensor/core";2import { httpFetchWithRetry } from "./fetcher";3import { hostOf } from "./dns";4import { NormalizeError, type WebSensorConnector } from "./types";56/**7 * RDAP connector (RFC 9083 — the JSON successor of WHOIS). Sensor URL: an RDAP query such as8 * `https://rdap.org/domain/example.com` (bootstrap redirector) or a registry endpoint9 * (`https://rdap.verisign.com/com/v1/domain/example.com`). Keeps registrar, EPP status codes,10 * nameservers, DNSSEC flag and the registration / expiration / last-changed events; drops the11 * "last update of RDAP database" event, notices and links which change on every query.12 * Detects: registrar transfers, expiry renewals, clientHold / serverHold (domain suspended),13 * nameserver moves, DNSSEC enabled/disabled, and imminent expiry (`extra.daysToExpiry`).14 *15 * Config: { domain?: "example.com" }. The sensor url may be `https://rdap.org/domain/<domain>` for display: the actual16 * query goes to the TLD's registry server found in the IANA bootstrap file (rdap.org itself rate-limits at ~10/min).17 */18export interface RdapSummary {19  domain: string;20  handle: string | null;21  registrar: string | null;22  registrarIanaId: string | null;23  status: string[];24  nameservers: string[];25  dnssec: boolean | null;26  registration: string | null;27  expiration: string | null;28  lastChanged: string | null;29  transfer: string | null;30}3132export function summarizeRdap(j: Record<string, unknown>): RdapSummary {33  const events = (j.events ?? []) as { eventAction?: string; eventDate?: string }[];34  const ev = (name: string): string | null => {35    const e = events.find((x) => (x.eventAction ?? "").toLowerCase() === name);36    return e?.eventDate ? new Date(e.eventDate).toISOString() : null;37  };38  const entities = (j.entities ?? []) as Record<string, unknown>[];39  const registrarEnt = entities.find((e) => ((e.roles ?? []) as string[]).includes("registrar"));40  let registrar: string | null = null;41  let ianaId: string | null = null;42  if (registrarEnt) {43    const vcard = (registrarEnt.vcardArray as unknown[] | undefined)?.[1] as unknown[][] | undefined;44    const fn = vcard?.find((v) => v[0] === "fn");45    registrar = fn ? String(fn[3]) : registrarEnt.handle ? String(registrarEnt.handle) : null;46    const ids = (registrarEnt.publicIds ?? []) as { type?: string; identifier?: string }[];47    ianaId = ids.find((i) => /iana/i.test(i.type ?? ""))?.identifier ?? null;48  }49  const secure = j.secureDNS as { delegationSigned?: boolean } | undefined;50  return {51    domain: String(j.ldhName ?? j.unicodeName ?? "").toLowerCase(),52    handle: j.handle ? String(j.handle) : null,53    registrar,54    registrarIanaId: ianaId,55    status: [...((j.status ?? []) as string[])].map((s) => s.toLowerCase()).sort(),56    nameservers: ((j.nameservers ?? []) as { ldhName?: string }[]).map((n) => String(n.ldhName ?? "").toLowerCase().replace(/\.$/, "")).filter(Boolean).sort(),57    dnssec: secure ? Boolean(secure.delegationSigned) : null,58    registration: ev("registration"),59    expiration: ev("expiration"),60    lastChanged: ev("last changed"),61    transfer: ev("transfer"),62  };63}6465/** IANA RDAP bootstrap (RFC 9224): TLD → registry base URL, cached 24 h. Falls back to rdap.org. */66let bootstrap: { at: number; map: Map<string, string> } | null = null;67export async function rdapBaseFor(domain: string, sensorId = "rdap_bootstrap"): Promise<string> {68  if (!bootstrap || Date.now() - bootstrap.at > 86_400_000) {69    const obs = await httpFetchWithRetry(sensorId, "https://data.iana.org/rdap/dns.json", { accept: "application/json", timeoutMs: 20_000 });70    if (obs.body && obs.meta.status === 200) {71      const j = JSON.parse(obs.body.toString("utf8")) as { services: [string[], string[]][] };72      const map = new Map<string, string>();73      for (const [tlds, urls] of j.services) {74        const https = urls.find((u) => u.startsWith("https://")) ?? urls[0];75        if (https) for (const t of tlds) map.set(t.toLowerCase(), https.endsWith("/") ? https : https + "/");76      }77      bootstrap = { at: Date.now(), map };78    } else if (!bootstrap) bootstrap = { at: Date.now() - 86_000_000, map: new Map() };79  }80  const tld = domain.split(".").at(-1) ?? "";81  // TLDs without an IANA bootstrap entry but with a working registry RDAP.82  const OVERRIDES: Record<string, string> = { io: "https://rdap.identitydigital.services/rdap/", ac: "https://rdap.identitydigital.services/rdap/", sh: "https://rdap.identitydigital.services/rdap/" };83  const base = bootstrap.map.get(tld) ?? OVERRIDES[tld];84  return base ? `${base}domain/${domain}` : `https://rdap.org/domain/${domain}`;85}8687export class RdapConnector implements WebSensorConnector {88  mode = "json" as const;89  metadata(): ConnectorMetadata {90    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" };91  }92  private async target(endpoint: SensorEndpoint): Promise<string> {93    const cfg = endpoint.config as { domain?: string; direct?: boolean };94    // A registry RDAP URL is used as-is; rdap.org (rate-limited redirector) and bare domains go through the IANA bootstrap.95    if (/\/domain\//.test(endpoint.url) && !/rdap\.org\//.test(endpoint.url)) return endpoint.url;96    const domain = (cfg.domain ?? endpoint.url.match(/\/domain\/([^/?#]+)/)?.[1] ?? hostOf(endpoint.url).replace(/^www\./, "")).toLowerCase();97    return cfg.direct ? `https://rdap.org/domain/${domain}` : rdapBaseFor(domain, endpoint.id);98  }99  async fetch(endpoint: SensorEndpoint): Promise<Observation> {100    const obs = await httpFetchWithRetry(endpoint.id, await this.target(endpoint), { accept: "application/rdap+json, application/json;q=0.9", timeoutMs: 30_000, maxRedirects: 6 });101    return { ...obs, url: endpoint.url };102  }103  async normalize(endpoint: SensorEndpoint, obs: Observation): Promise<NormalizedContent> {104    if (!obs.body) throw new NormalizeError("no_body", "Observation has no body");105    let j: Record<string, unknown>;106    try {107      j = JSON.parse(obs.body.toString("utf8")) as Record<string, unknown>;108    } catch {109      throw new NormalizeError("bad_json", "RDAP response is not JSON");110    }111    if (j.objectClassName !== "domain" && !j.ldhName) throw new NormalizeError("bad_shape", `Not an RDAP domain object (${String(j.errorCode ?? j.title ?? "unknown")})`);112    const s = summarizeRdap(j);113    const c = canonicalJson(s);114    const daysToExpiry = s.expiration ? Math.round((new Date(s.expiration).getTime() - Date.now()) / 86400e3) : null;115    const held = s.status.some((x) => /hold|redemption|pendingdelete/i.test(x));116    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 } };117  }118}119