feat(domain-intelligence): add domain intelligence lookups with RDAP and DNS enrichment

This commit is contained in:
2026-04-13 16:51:41 +00:00
parent 7e973b842c
commit a694b0c8ae
9 changed files with 995 additions and 22 deletions
+1 -1
View File
@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartnetwork',
version: '4.5.2',
version: '4.6.0',
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
}
+2
View File
@@ -4,5 +4,7 @@ export { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
export { PublicIp } from './smartnetwork.classes.publicip.js';
export { IpIntelligence } from './smartnetwork.classes.ipintelligence.js';
export type { IIpIntelligenceResult, IIpIntelligenceOptions } from './smartnetwork.classes.ipintelligence.js';
export { DomainIntelligence } from './smartnetwork.classes.domainintelligence.js';
export type { IDomainIntelligenceResult, IDomainIntelligenceOptions } from './smartnetwork.classes.domainintelligence.js';
export { setLogger, getLogger } from './logging.js';
export { NetworkError, TimeoutError } from './errors.js';
@@ -0,0 +1,659 @@
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<typeof plugins.smartdns.dnsClientMod.Smartdns>;
/** Type alias for a single DNS record returned by Smartdns */
type TDnsRecord = Awaited<ReturnType<TSmartdnsClient['getRecordsA']>>[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<string, string> | null = null;
private bootstrapPromise: Promise<void> | 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<IDomainIntelligenceResult> {
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<IDomainIntelligenceResult | null> {
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<void> {
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<string, string>();
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<any | null> {
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[]>): 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<TDnsRecord[]>): 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<TDnsRecord[]>): 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<TDnsRecord[]>,
): { 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;
}
}
+22 -7
View File
@@ -50,6 +50,13 @@ export interface IIpIntelligenceOptions {
dbMaxAge?: number;
/** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */
timeout?: number;
/**
* Optional injected smartdns client. When provided, IpIntelligence will
* not create or destroy its own client (the owner — typically SmartNetwork —
* manages lifecycle). When omitted, a short-lived client is created per
* Team Cymru lookup and destroyed in finally.
*/
dnsClient?: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>;
}
// CDN URLs for GeoLite2 MMDB files (served via jsDelivr from npm packages)
@@ -93,9 +100,13 @@ export class IpIntelligence {
private bootstrapEntries: IBootstrapEntry[] | null = null;
private bootstrapPromise: Promise<void> | null = null;
// Optional injected smartdns client (shared by SmartNetwork)
private readonly sharedDnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null;
constructor(options?: IIpIntelligenceOptions) {
this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
this.sharedDnsClient = options?.dnsClient ?? null;
}
/**
@@ -385,16 +396,19 @@ export class IpIntelligence {
* Response: "ASN | prefix | CC | rir | date"
*/
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
let dnsClient: InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns> | null = null;
const external = this.sharedDnsClient !== null;
let dnsClient = this.sharedDnsClient;
try {
const reversed = ip.split('.').reverse().join('.');
const queryName = `${reversed}.origin.asn.cymru.com`;
dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
strategy: 'prefer-system',
allowDohFallback: true,
timeoutMs: this.timeout,
});
if (!dnsClient) {
dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
strategy: 'prefer-system',
allowDohFallback: true,
timeoutMs: this.timeout,
});
}
const records = await dnsClient.getRecordsTxt(queryName);
if (!records || records.length === 0) return null;
@@ -418,7 +432,8 @@ export class IpIntelligence {
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
return null;
} finally {
if (dnsClient) {
// Only destroy clients we created ourselves; leave injected ones alone.
if (!external && dnsClient) {
dnsClient.destroy();
}
}
+56 -7
View File
@@ -2,10 +2,14 @@ import * as plugins from './smartnetwork.plugins.js';
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
import { PublicIp } from './smartnetwork.classes.publicip.js';
import { IpIntelligence, type IIpIntelligenceResult } from './smartnetwork.classes.ipintelligence.js';
import { DomainIntelligence, type IDomainIntelligenceResult } from './smartnetwork.classes.domainintelligence.js';
import { getLogger } from './logging.js';
import { NetworkError } from './errors.js';
import { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
/** Type alias for the shared Smartdns client instance */
type TSmartdnsClient = InstanceType<typeof plugins.smartdns.dnsClientMod.Smartdns>;
/**
* Configuration options for SmartNetwork
*/
@@ -54,6 +58,8 @@ export class SmartNetwork {
private rustBridge: RustNetworkBridge;
private bridgeStarted = false;
private ipIntelligence: IpIntelligence | null = null;
private domainIntelligence: DomainIntelligence | null = null;
private dnsClient: TSmartdnsClient | null = null;
constructor(options?: SmartNetworkOptions) {
this.options = options || {};
@@ -73,13 +79,24 @@ export class SmartNetwork {
}
/**
* Stop the Rust binary bridge.
* Stop the Rust binary bridge and tear down the shared Smartdns client.
* Call this before your Node process exits if you've used any DNS or
* Rust-backed features, otherwise the smartdns Rust backend may keep
* the event loop alive.
*/
public async stop(): Promise<void> {
if (this.bridgeStarted) {
await this.rustBridge.stop();
this.bridgeStarted = false;
}
if (this.dnsClient) {
this.dnsClient.destroy();
this.dnsClient = null;
// Intelligence instances hold a stale reference to the destroyed
// client; drop them so the next call rebuilds with a fresh one.
this.ipIntelligence = null;
this.domainIntelligence = null;
}
}
/**
@@ -91,6 +108,23 @@ export class SmartNetwork {
}
}
/**
* Lazily create the shared Smartdns client. The Rust backend inside
* Smartdns is only spawned on first query that requires it (NS/MX/SOA
* with prefer-system strategy, or any query with doh/udp strategy).
* The client is destroyed by stop().
*/
private ensureDnsClient(): TSmartdnsClient {
if (!this.dnsClient) {
this.dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
strategy: 'prefer-system',
allowDohFallback: true,
timeoutMs: 5000,
});
}
return this.dnsClient;
}
/**
* Get network speed via Cloudflare speed test (pure TS, no Rust needed).
*/
@@ -310,16 +344,15 @@ export class SmartNetwork {
}
/**
* Resolve DNS records (A, AAAA, MX) — uses smartdns, no Rust needed.
* Resolve DNS records (A, AAAA, MX) via the shared smartdns client.
* The client is lifecycle-managed by start()/stop() — MX queries spawn
* the smartdns Rust bridge, which is torn down by stop().
*/
public async resolveDns(
host: string,
): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
try {
const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
strategy: 'prefer-system',
allowDohFallback: true,
});
const dnsClient = this.ensureDnsClient();
const [aRecords, aaaaRecords, mxRecords] = await Promise.all([
dnsClient.getRecordsA(host).catch((): any[] => []),
@@ -404,7 +437,7 @@ export class SmartNetwork {
*/
public async getIpIntelligence(ip: string): Promise<IIpIntelligenceResult> {
if (!this.ipIntelligence) {
this.ipIntelligence = new IpIntelligence();
this.ipIntelligence = new IpIntelligence({ dnsClient: this.ensureDnsClient() });
}
const fetcher = () => this.ipIntelligence!.getIntelligence(ip);
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
@@ -413,6 +446,22 @@ export class SmartNetwork {
return fetcher();
}
/**
* Get domain intelligence: registrar, registrant, nameservers, registration
* events, status flags, DNSSEC, and abuse contact via RDAP. Pure TS, no
* Rust needed.
*/
public async getDomainIntelligence(domain: string): Promise<IDomainIntelligenceResult> {
if (!this.domainIntelligence) {
this.domainIntelligence = new DomainIntelligence({ dnsClient: this.ensureDnsClient() });
}
const fetcher = () => this.domainIntelligence!.getIntelligence(domain);
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
return this.getCached(`domainIntelligence:${domain}`, fetcher);
}
return fetcher();
}
/**
* Internal caching helper
*/