import { domainToASCII } from 'node:url'; import * as plugins from './smartnetwork.plugins.js'; import { getLogger } from './logging.js'; /** Type alias for a Smartdns client instance */ type TSmartdnsClient = InstanceType; /** Type alias for a single DNS record returned by Smartdns */ type TDnsRecord = Awaited>[number]; /** * Unified result from a domain RDAP intelligence lookup. */ export interface IDomainIntelligenceResult { /** Normalized ASCII (punycode) form of the queried domain */ domain: string | null; /** Registry handle / identifier */ handle: string | null; /** EPP status values, e.g. ["active", "client transfer prohibited"] */ status: string[] | null; // Registration lifecycle (ISO 8601 timestamps from RDAP events) registrationDate: string | null; expirationDate: string | null; lastChangedDate: string | null; // Sponsoring registrar registrarName: string | null; registrarIanaId: number | null; // Registrant (often redacted under GDPR) registrantOrg: string | null; registrantCountry: string | null; // Abuse contact (commonly nested under the registrar entity) abuseEmail: string | null; abusePhone: string | null; // Technical nameservers: string[] | null; dnssec: boolean | null; /** Which layer populated the nameservers field */ nameserversSource: 'rdap' | 'dns' | null; // DNS enrichment (from smartdns) /** IPv4 A records */ resolvedIpv4: string[] | null; /** IPv6 AAAA records */ resolvedIpv6: string[] | null; /** Parsed MX records with priority and exchange */ mxRecords: { priority: number | null; exchange: string }[] | null; /** TXT records (SPF, DKIM, site verification, etc.) */ txtRecords: string[] | null; /** Raw serialized SOA record value */ soaRecord: string | null; } /** * Options for DomainIntelligence */ export interface IDomainIntelligenceOptions { /** Timeout (ms) for RDAP/bootstrap/DNS requests. Default: 5000 */ timeout?: number; /** * Optional injected smartdns client. When provided, DomainIntelligence * will not create or destroy its own client (the owner — typically * SmartNetwork — manages lifecycle). When omitted, a short-lived client * is created per DNS-layer query and destroyed in finally. */ dnsClient?: TSmartdnsClient; } // IANA bootstrap for domain RDAP const IANA_BOOTSTRAP_DNS_URL = 'https://data.iana.org/rdap/dns.json'; const DEFAULT_TIMEOUT = 5000; /** * Build an empty result object with all fields nulled. */ function emptyResult(domain: string | null = null): IDomainIntelligenceResult { return { domain, handle: null, status: null, registrationDate: null, expirationDate: null, lastChangedDate: null, registrarName: null, registrarIanaId: null, registrantOrg: null, registrantCountry: null, abuseEmail: null, abusePhone: null, nameservers: null, dnssec: null, nameserversSource: null, resolvedIpv4: null, resolvedIpv6: null, mxRecords: null, txtRecords: null, soaRecord: null, }; } /** * DomainIntelligence performs RDAP lookups for domain names using the * IANA DNS bootstrap to discover the correct registry RDAP endpoint per TLD. */ export class DomainIntelligence { private readonly logger = getLogger(); private readonly timeout: number; // Bootstrap cache: tld (lowercased) -> RDAP base URL (without trailing slash) private bootstrapEntries: Map | null = null; private bootstrapPromise: Promise | null = null; // Optional injected smartdns client (shared by SmartNetwork) private readonly sharedDnsClient: TSmartdnsClient | null; constructor(options?: IDomainIntelligenceOptions) { this.timeout = options?.timeout ?? DEFAULT_TIMEOUT; this.sharedDnsClient = options?.dnsClient ?? null; } /** * Get comprehensive domain intelligence. Runs RDAP and DNS lookups in * parallel, then merges the results. Returns an all-null result (rather * than throwing) for malformed input, unknown TLDs, or total failure. * * - RDAP provides: registrar, registrant, events (registration/expiration), * nameservers (registry/parent), status, DNSSEC, abuse contact * - DNS provides: A/AAAA records, MX, TXT, SOA, and a nameservers fallback * when RDAP is unavailable (closes the ccTLD gap) */ public async getIntelligence(domain: string): Promise { const normalized = this.normalizeDomain(domain); if (!normalized) return emptyResult(null); const [rdapSettled, dnsSettled] = await Promise.allSettled([ this.queryRdapLayer(normalized), this.queryDnsLayer(normalized), ]); const result = emptyResult(normalized); // Merge RDAP fields (if any) — start with the parsed RDAP result as the base if (rdapSettled.status === 'fulfilled' && rdapSettled.value) { Object.assign(result, rdapSettled.value); if (result.nameservers && result.nameservers.length > 0) { result.nameserversSource = 'rdap'; } } // Merge DNS fields (if any) if (dnsSettled.status === 'fulfilled' && dnsSettled.value) { const dns = dnsSettled.value; result.resolvedIpv4 = dns.resolvedIpv4; result.resolvedIpv6 = dns.resolvedIpv6; result.mxRecords = dns.mxRecords; result.txtRecords = dns.txtRecords; result.soaRecord = dns.soaRecord; // Nameserver fallback: only from DNS when RDAP didn't populate it if ((!result.nameservers || result.nameservers.length === 0) && dns.nameservers) { result.nameservers = dns.nameservers; result.nameserversSource = 'dns'; } } return result; } // ─── RDAP Layer (existing logic, wrapped) ─────────────────────────── /** * Run the full RDAP lookup flow for a pre-normalized domain: extract TLD, * load bootstrap, match registry, query, and parse. Returns the parsed * RDAP fields (as a full IDomainIntelligenceResult) or null if any step * fails or the TLD has no RDAP support. */ private async queryRdapLayer(domain: string): Promise { const tld = this.extractTld(domain); if (!tld) return null; await this.ensureBootstrap(); const baseUrl = this.matchTld(tld); if (!baseUrl) return null; const rdapData = await this.queryRdap(domain, baseUrl); if (!rdapData) return null; return this.parseRdapResponse(domain, rdapData); } // ─── Normalization & TLD extraction ───────────────────────────────── /** * Normalize a domain to lowercased ASCII punycode form. Returns null for * obviously invalid input. */ private normalizeDomain(input: string): string | null { if (typeof input !== 'string') return null; let trimmed = input.trim().toLowerCase(); if (!trimmed) return null; // Strip a single trailing dot (FQDN form) if (trimmed.endsWith('.')) trimmed = trimmed.slice(0, -1); if (!trimmed) return null; // Reject inputs that contain whitespace, slashes, or other URL noise if (/[\s/\\?#]/.test(trimmed)) return null; // Convert IDN to ASCII (punycode). Returns '' for invalid input. const ascii = domainToASCII(trimmed); if (!ascii) return null; // Must contain at least one dot to have a TLD if (!ascii.includes('.')) return null; return ascii; } /** * Extract the TLD as the last dot-separated label. */ private extractTld(domain: string): string | null { const idx = domain.lastIndexOf('.'); if (idx < 0 || idx === domain.length - 1) return null; return domain.slice(idx + 1); } // ─── Bootstrap Subsystem ──────────────────────────────────────────── /** * Load and cache the IANA DNS bootstrap file. */ private async ensureBootstrap(): Promise { if (this.bootstrapEntries) return; if (this.bootstrapPromise) { await this.bootstrapPromise; return; } this.bootstrapPromise = (async () => { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(IANA_BOOTSTRAP_DNS_URL, { signal: controller.signal, headers: { 'User-Agent': '@push.rocks/smartnetwork' }, }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = (await response.json()) as { services: [string[], string[]][] }; const entries = new Map(); for (const [tlds, urls] of data.services) { const baseUrl = urls[0]; // first URL is preferred if (!baseUrl) continue; const cleanBase = baseUrl.replace(/\/$/, ''); // strip trailing slash for (const tld of tlds) { entries.set(tld.toLowerCase(), cleanBase); } } this.bootstrapEntries = entries; } finally { clearTimeout(timeoutId); } } catch (err: any) { this.logger.debug?.(`Failed to load DNS RDAP bootstrap: ${err.message}`); this.bootstrapEntries = new Map(); // empty = all RDAP lookups will skip } })(); await this.bootstrapPromise; this.bootstrapPromise = null; } /** * Find the RDAP base URL for a given TLD via direct lookup. */ private matchTld(tld: string): string | null { if (!this.bootstrapEntries || this.bootstrapEntries.size === 0) return null; return this.bootstrapEntries.get(tld.toLowerCase()) ?? null; } // ─── RDAP Query ───────────────────────────────────────────────────── /** * Perform the RDAP HTTP query for a domain. */ private async queryRdap(domain: string, baseUrl: string): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), this.timeout); try { const response = await fetch(`${baseUrl}/domain/${encodeURIComponent(domain)}`, { signal: controller.signal, headers: { 'Accept': 'application/rdap+json', 'User-Agent': '@push.rocks/smartnetwork', }, }); if (!response.ok) return null; return await response.json(); } catch (err: any) { this.logger.debug?.(`RDAP query failed for ${domain}: ${err.message}`); return null; } finally { clearTimeout(timeoutId); } } // ─── RDAP Response Parsing ────────────────────────────────────────── private parseRdapResponse(domain: string, data: any): IDomainIntelligenceResult { const result = emptyResult(domain); if (typeof data.handle === 'string') result.handle = data.handle; if (Array.isArray(data.status) && data.status.length > 0) { result.status = data.status.filter((s: any): s is string => typeof s === 'string'); } // Events: registration / expiration / last changed if (Array.isArray(data.events)) { const events = this.extractEvents(data.events); result.registrationDate = events.registration; result.expirationDate = events.expiration; result.lastChangedDate = events.lastChanged; } // Registrar (sponsor) and registrant from entities if (Array.isArray(data.entities)) { const registrar = this.extractRegistrar(data.entities); result.registrarName = registrar.name; result.registrarIanaId = registrar.ianaId; result.abuseEmail = registrar.abuseEmail; result.abusePhone = registrar.abusePhone; const registrant = this.extractRegistrant(data.entities); result.registrantOrg = registrant.org; result.registrantCountry = registrant.country; } // Nameservers if (Array.isArray(data.nameservers)) { result.nameservers = this.extractNameservers(data.nameservers); } // DNSSEC: secureDNS.delegationSigned if (data.secureDNS && typeof data.secureDNS.delegationSigned === 'boolean') { result.dnssec = data.secureDNS.delegationSigned; } return result; } /** * Pull registration / expiration / last changed timestamps from an * RDAP `events` array. */ private extractEvents(events: any[]): { registration: string | null; expiration: string | null; lastChanged: string | null; } { let registration: string | null = null; let expiration: string | null = null; let lastChanged: string | null = null; for (const ev of events) { const action = typeof ev?.eventAction === 'string' ? ev.eventAction.toLowerCase() : ''; const date = typeof ev?.eventDate === 'string' ? ev.eventDate : null; if (!date) continue; if (action === 'registration') registration = date; else if (action === 'expiration') expiration = date; else if (action === 'last changed') lastChanged = date; } return { registration, expiration, lastChanged }; } /** * Extract registrar identity (name, IANA ID) and a nested abuse contact. */ private extractRegistrar(entities: any[]): { name: string | null; ianaId: number | null; abuseEmail: string | null; abusePhone: string | null; } { let name: string | null = null; let ianaId: number | null = null; let abuseEmail: string | null = null; let abusePhone: string | null = null; for (const entity of entities) { const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : []; if (!roles.includes('registrar')) continue; name = this.extractVcardFn(entity); // IANA Registrar ID lives in publicIds[] if (Array.isArray(entity.publicIds)) { for (const pid of entity.publicIds) { if (pid && typeof pid === 'object' && pid.type === 'IANA Registrar ID') { const parsed = parseInt(String(pid.identifier), 10); if (!isNaN(parsed)) ianaId = parsed; } } } // Abuse contact: nested entity with role "abuse" if (Array.isArray(entity.entities)) { for (const sub of entity.entities) { const subRoles: string[] = Array.isArray(sub?.roles) ? sub.roles : []; if (subRoles.includes('abuse')) { if (!abuseEmail) abuseEmail = this.extractVcardEmail(sub); if (!abusePhone) abusePhone = this.extractVcardTel(sub); } } } break; // first registrar wins } return { name, ianaId, abuseEmail, abusePhone }; } /** * Extract registrant org/country from entities array. */ private extractRegistrant(entities: any[]): { org: string | null; country: string | null; } { for (const entity of entities) { const roles: string[] = Array.isArray(entity?.roles) ? entity.roles : []; if (!roles.includes('registrant')) continue; const org = this.extractVcardFn(entity); const country = this.extractVcardCountry(entity); return { org, country }; } return { org: null, country: null }; } /** * Map an RDAP nameservers[] array to a list of lowercased ldhName strings. */ private extractNameservers(nameservers: any[]): string[] | null { const out: string[] = []; for (const ns of nameservers) { const ldh = ns?.ldhName; if (typeof ldh === 'string' && ldh.length > 0) { out.push(ldh.toLowerCase()); } } return out.length > 0 ? out : null; } // ─── DNS Layer ────────────────────────────────────────────────────── /** * Run DNS record lookups for the given domain using smartdns. Queries * NS/A/AAAA/MX/TXT/SOA in parallel via Promise.allSettled — failures in * one record type do not affect the others. Returns an object with each * field either populated or null. * * If a shared dnsClient was injected via constructor options, it is * reused and NOT destroyed (ownership stays with the injector). Otherwise * a short-lived client is created and destroyed in finally. */ private async queryDnsLayer(domain: string): Promise<{ nameservers: string[] | null; resolvedIpv4: string[] | null; resolvedIpv6: string[] | null; mxRecords: { priority: number | null; exchange: string }[] | null; txtRecords: string[] | null; soaRecord: string | null; } | null> { const external = this.sharedDnsClient !== null; let dnsClient: TSmartdnsClient | null = this.sharedDnsClient; try { if (!dnsClient) { dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({ strategy: 'prefer-system', allowDohFallback: true, timeoutMs: this.timeout, }); } const [nsRes, aRes, aaaaRes, mxRes, txtRes, soaRes] = await Promise.allSettled([ dnsClient.getNameServers(domain), dnsClient.getRecordsA(domain), dnsClient.getRecordsAAAA(domain), dnsClient.getRecords(domain, 'MX'), dnsClient.getRecordsTxt(domain), // 'SOA' is in smartdns's runtime dnsTypeMap but missing from the // TDnsRecordType union in @tsclass/tsclass — cast to bypass the // stale type definition. dnsClient.getRecords(domain, 'SOA' as any), ]); return { nameservers: this.parseNsResult(nsRes), resolvedIpv4: this.dnsValuesOrNull(aRes), resolvedIpv6: this.dnsValuesOrNull(aaaaRes), mxRecords: this.parseMxRecords(mxRes), txtRecords: this.dnsValuesOrNull(txtRes), soaRecord: this.dnsFirstValueOrNull(soaRes), }; } catch (err: any) { this.logger.debug?.(`DNS layer failed for ${domain}: ${err.message}`); return null; } finally { // Only destroy clients we created ourselves; leave injected ones alone. if (!external && dnsClient) { dnsClient.destroy(); } } } /** * Extract normalized nameserver hostnames from a getNameServers() result. */ private parseNsResult(res: PromiseSettledResult): string[] | null { if (res.status !== 'fulfilled' || !Array.isArray(res.value) || res.value.length === 0) { return null; } const out = res.value .map((ns) => (typeof ns === 'string' ? ns.toLowerCase().replace(/\.$/, '') : '')) .filter((ns) => ns.length > 0); return out.length > 0 ? out : null; } /** * Extract `value` strings from a settled DNS lookup result. */ private dnsValuesOrNull(res: PromiseSettledResult): string[] | null { if (res.status !== 'fulfilled' || !Array.isArray(res.value) || res.value.length === 0) { return null; } const values = res.value .map((r) => r.value) .filter((v): v is string => typeof v === 'string' && v.length > 0); return values.length > 0 ? values : null; } /** * First non-empty value from a settled DNS lookup result. */ private dnsFirstValueOrNull(res: PromiseSettledResult): string | null { const values = this.dnsValuesOrNull(res); return values?.[0] ?? null; } /** * Parse MX records into {priority, exchange} pairs. smartdns returns MX * values as serialized strings (typically "10 mail.example.com"). We * best-effort parse the priority; if parsing fails we store the whole * value as the exchange with priority=null so the result is still useful. */ private parseMxRecords( res: PromiseSettledResult, ): { priority: number | null; exchange: string }[] | null { if (res.status !== 'fulfilled' || !Array.isArray(res.value) || res.value.length === 0) { return null; } const out: { priority: number | null; exchange: string }[] = []; for (const r of res.value) { if (typeof r.value !== 'string' || !r.value.trim()) continue; const match = r.value.trim().match(/^(\d+)\s+(.+?)\.?$/); if (match) { out.push({ priority: parseInt(match[1], 10), exchange: match[2].toLowerCase() }); } else { out.push({ priority: null, exchange: r.value.toLowerCase().replace(/\.$/, '') }); } } return out.length > 0 ? out : null; } // ─── vCard helpers (duplicated from IpIntelligence) ───────────────── /** * Extract the 'fn' (formatted name) from an entity's vcardArray */ private extractVcardFn(entity: any): string | null { if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null; const properties = entity.vcardArray[1]; if (!Array.isArray(properties)) return null; for (const prop of properties) { if (Array.isArray(prop) && prop[0] === 'fn') { return prop[3] || null; } } return null; } /** * Extract email from an entity's vcardArray */ private extractVcardEmail(entity: any): string | null { if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null; const properties = entity.vcardArray[1]; if (!Array.isArray(properties)) return null; for (const prop of properties) { if (Array.isArray(prop) && prop[0] === 'email') { return prop[3] || null; } } return null; } /** * Extract telephone number from an entity's vcardArray */ private extractVcardTel(entity: any): string | null { if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null; const properties = entity.vcardArray[1]; if (!Array.isArray(properties)) return null; for (const prop of properties) { if (Array.isArray(prop) && prop[0] === 'tel') { // tel value can be a string or a uri like "tel:+1.5555555555" const value = prop[3]; if (typeof value === 'string') { return value.startsWith('tel:') ? value.slice(4) : value; } } } return null; } /** * Extract country from an entity's vcardArray address field */ private extractVcardCountry(entity: any): string | null { if (!entity?.vcardArray || !Array.isArray(entity.vcardArray)) return null; const properties = entity.vcardArray[1]; if (!Array.isArray(properties)) return null; for (const prop of properties) { if (Array.isArray(prop) && prop[0] === 'adr') { // The label parameter often contains the full address with country at the end const label = prop[1]?.label; if (typeof label === 'string') { const lines = label.split('\n'); const lastLine = lines[lines.length - 1]?.trim(); if (lastLine && lastLine.length > 1) return lastLine; } // Also check the structured value (7-element array, last element is country) const value = prop[3]; if (Array.isArray(value) && value.length >= 7 && value[6]) { return value[6]; } } } return null; } }