diff --git a/changelog.md b/changelog.md index dfa08e9..8cf4e40 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,14 @@ # Changelog +## 2026-04-13 - 4.6.0 - feat(domain-intelligence) +add domain intelligence lookups with RDAP and DNS enrichment + +- introduces DomainIntelligence with normalized domain handling, RDAP bootstrap discovery, and merged DNS enrichment data +- adds SmartNetwork.getDomainIntelligence() with cache support and public exports for domain intelligence types +- reuses a shared smartdns client across DNS and intelligence features and tears it down cleanly in stop() to prevent hanging processes +- adds integration tests for gTLDs, RDAP-less ccTLDs, IDN normalization, malformed input, caching, and shutdown behavior +- updates the README to document domain intelligence, caching coverage, and shared smartdns lifecycle behavior + ## 2026-03-26 - 4.5.2 - fix(docs) refresh README content and align license copyright holder diff --git a/readme.md b/readme.md index 9837bce..c855504 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # @push.rocks/smartnetwork 🌐 -Comprehensive network diagnostics and IP intelligence for Node.js β€” speed tests, port scanning, ICMP ping, traceroute, DNS, RDAP, ASN lookups, and geolocation in a single, promise-based toolkit. +Comprehensive network diagnostics and intelligence for Node.js β€” speed tests, port scanning, ICMP ping, traceroute, DNS, IP & domain RDAP, ASN lookups, geolocation, and DNS record enrichment in a single, promise-based toolkit. ## Issue Reporting and Security @@ -14,9 +14,9 @@ pnpm install @push.rocks/smartnetwork --save ## 🎯 Overview -**@push.rocks/smartnetwork** is your Swiss Army knife for network diagnostics in Node.js. Whether you're building monitoring dashboards, investigating IP ownership, or debugging connectivity β€” this library has you covered with a clean, async API and zero-config setup. +**@push.rocks/smartnetwork** is your Swiss Army knife for network diagnostics in Node.js. Whether you're building monitoring dashboards, investigating IP/domain ownership, or debugging connectivity β€” this library has you covered with a clean, async API and zero-config setup. -Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port checks, gateway detection) are powered by a bundled **Rust binary** for maximum performance and cross-platform reliability. Everything else β€” speed tests, DNS, public IP discovery, IP intelligence, HTTP health checks β€” runs in pure TypeScript. +Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port checks, gateway detection) are powered by a bundled **Rust binary** for maximum performance and cross-platform reliability. Everything else β€” speed tests, DNS, public IP discovery, IP & domain intelligence, HTTP health checks β€” runs in pure TypeScript. ### ✨ Key Features @@ -27,6 +27,7 @@ Under the hood, system-level operations (ICMP ping, traceroute, raw-socket port | πŸ“‘ **Connectivity** | ICMP ping with stats, hop-by-hop traceroute, HTTP/HTTPS health checks | | 🌍 **DNS** | A, AAAA, MX resolution with system-first + DoH fallback strategy | | πŸ” **IP Intelligence** | ASN, organization, geolocation, RDAP registration β€” all from free public sources | +| 🏒 **Domain Intelligence** | RDAP registrar/registrant, nameservers, events, DNSSEC, plus DNS enrichment (A/AAAA/MX/TXT/SOA) | | πŸ–₯️ **Network Discovery** | Interfaces, default gateway, public IPv4/IPv6 | | ⚑ **Caching** | Built-in TTL cache for expensive lookups | | πŸ”§ **Extensible** | Plugin architecture for custom functionality | @@ -46,7 +47,7 @@ await network.start(); // ... use the network instance ... -// Clean up when done +// Clean up when done β€” tears down both the Rust bridge and the smartdns backend await network.stop(); ``` @@ -126,6 +127,104 @@ interface IIpIntelligenceResult { --- +### 🏒 Domain Intelligence + +Get comprehensive domain registration and DNS data. Runs an **RDAP layer** and a **DNS layer** in parallel, then merges results: + +- **RDAP** β€” queries the correct registry RDAP server (discovered via the [IANA DNS bootstrap](https://data.iana.org/rdap/dns.json)) for registrar, registrant, events, status flags, DNSSEC, and abuse contacts +- **DNS** β€” queries A, AAAA, NS, MX, TXT, and SOA records via `@push.rocks/smartdns` (system resolver first, DoH fallback) + +For gTLDs (`.com`, `.org`, `.net`, etc.) you get the full picture from both layers. For ccTLDs without RDAP support (`.de`, `.fr`, `.nl`, etc.), the DNS layer fills in nameservers, resolved IPs, MX, TXT, and SOA β€” so you still get useful intelligence even when RDAP isn't available. + +```typescript +const info = await network.getDomainIntelligence('google.com'); + +console.log(info); +// { +// domain: 'google.com', +// handle: '2138514_DOMAIN_COM-VRSN', +// status: ['client delete prohibited', 'server transfer prohibited', ...], +// registrationDate: '1997-09-15T04:00:00Z', +// expirationDate: '2028-09-14T04:00:00Z', +// lastChangedDate: '2019-09-09T15:39:04Z', +// registrarName: 'MarkMonitor Inc.', +// registrarIanaId: 292, +// registrantOrg: null, // often redacted under GDPR +// registrantCountry: null, +// abuseEmail: 'abusecomplaints@markmonitor.com', +// abusePhone: '+1.2086851750', +// nameservers: ['ns1.google.com', 'ns2.google.com', 'ns3.google.com', 'ns4.google.com'], +// dnssec: false, +// nameserversSource: 'rdap', +// resolvedIpv4: ['142.251.20.102', '142.251.20.113', ...], +// resolvedIpv6: ['2a00:1450:4001:c15::8b', ...], +// mxRecords: [{ priority: 10, exchange: 'smtp.google.com' }], +// txtRecords: ['v=spf1 include:_spf.google.com ~all', ...], +// soaRecord: 'ns1.google.com dns-admin.google.com 895796075 900 900 1800 60' +// } +``` + +Works with ccTLDs that don't have RDAP (like `.de`): + +```typescript +const deInfo = await network.getDomainIntelligence('bund.de'); +console.log(deInfo.registrarName); // null (no .de RDAP) +console.log(deInfo.nameservers); // ['bamberg.bund.de', 'argon.bund.de', ...] +console.log(deInfo.nameserversSource); // 'dns' β€” nameservers came from DNS, not RDAP +console.log(deInfo.resolvedIpv4); // ['80.245.156.34'] +console.log(deInfo.mxRecords); // [{priority: 10, exchange: 'mx1.bund.de'}, ...] +``` + +Handles IDN (internationalized domain names) automatically: + +```typescript +const idn = await network.getDomainIntelligence('mΓΌnchen.de'); +console.log(idn.domain); // 'xn--mnchen-3ya.de' β€” normalized to punycode +``` + +The `IDomainIntelligenceResult` interface: + +```typescript +interface IDomainIntelligenceResult { + domain: string | null; // normalized ASCII form + handle: string | null; // registry handle + status: string[] | null; // EPP status codes + + // Registration lifecycle (ISO 8601) + registrationDate: string | null; + expirationDate: string | null; + lastChangedDate: string | null; + + // Registrar + registrarName: string | null; + registrarIanaId: number | null; + + // Registrant (often redacted under GDPR) + registrantOrg: string | null; + registrantCountry: string | null; + + // Abuse contact + abuseEmail: string | null; + abusePhone: string | null; + + // Technical + nameservers: string[] | null; + dnssec: boolean | null; // secureDNS.delegationSigned from RDAP + nameserversSource: 'rdap' | 'dns' | null; + + // DNS enrichment + resolvedIpv4: string[] | null; // A records + resolvedIpv6: string[] | null; // AAAA records + mxRecords: { priority: number | null; exchange: string }[] | null; + txtRecords: string[] | null; // SPF, DKIM, site verification, etc. + soaRecord: string | null; // raw SOA value +} +``` + +> πŸ’‘ **RDAP vs DNS nameservers**: When RDAP is available (most gTLDs), `nameservers` comes from the registry's authoritative parent-zone delegation (`nameserversSource: 'rdap'`). When RDAP is unavailable (many ccTLDs), the DNS layer fills in nameservers via NS record resolution (`nameserversSource: 'dns'`). The `nameserversSource` field tells you which source won. + +--- + ### 🏎️ Speed Testing Measure network performance via Cloudflare's global infrastructure: @@ -263,7 +362,7 @@ console.log(`🌍 IPv6: ${publicIps.v6 || 'N/A'}`); ### ⚑ Caching -Caching applies to `getGateways()`, `getPublicIps()`, and `getIpIntelligence()`: +Caching applies to `getGateways()`, `getPublicIps()`, `getIpIntelligence()`, and `getDomainIntelligence()`: ```typescript const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s @@ -360,6 +459,11 @@ const monitor = async () => { console.log(`AS${intel.asn} (${intel.asnOrg}) β€” ${intel.city || intel.countryCode}`); } + // Domain recon + const domain = await network.getDomainIntelligence('example.com'); + console.log(`Registrar: ${domain.registrarName}, expires: ${domain.expirationDate}`); + console.log(`Nameservers (${domain.nameserversSource}):`, domain.nameservers); + await network.stop(); }; ``` @@ -368,7 +472,7 @@ const monitor = async () => { | Method | Description | Requires Rust | |--------|-------------|:---:| -| `start()` / `stop()` | Start/stop the Rust binary bridge | β€” | +| `start()` / `stop()` | Start/stop the Rust bridge and smartdns backend | β€” | | `getSpeed(opts?)` | Cloudflare speed test (download + upload) | No | | `ping(host, opts?)` | ICMP ping with optional multi-ping stats | Yes | | `traceroute(host, opts?)` | Hop-by-hop network path analysis | Yes | @@ -381,6 +485,7 @@ const monitor = async () => { | `resolveDns(host)` | Resolve A, AAAA, MX records | No | | `checkEndpoint(url, opts?)` | HTTP/HTTPS health check with RTT | No | | `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration | No | +| `getDomainIntelligence(domain)` | Registrar, nameservers, events, DNS records, DNSSEC | No | ## License and Legal Information @@ -396,7 +501,7 @@ Use of these trademarks must comply with Task Venture Capital GmbH's Trademark G ### Company Information -Task Venture Capital GmbH +Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany For any legal inquiries or further information, please contact us via email at hello@task.vc. diff --git a/test/test.domainintelligence.ts b/test/test.domainintelligence.ts new file mode 100644 index 0000000..ca535d4 --- /dev/null +++ b/test/test.domainintelligence.ts @@ -0,0 +1,127 @@ +import { expect, tap } from '@git.zone/tstest/tapbundle'; +import * as smartnetwork from '../ts/index.js'; + +let testSmartNetwork: smartnetwork.SmartNetwork; + +tap.test('should create a SmartNetwork instance', async () => { + testSmartNetwork = new smartnetwork.SmartNetwork(); + expect(testSmartNetwork).toBeInstanceOf(smartnetwork.SmartNetwork); +}); + +tap.test('should get domain intelligence for google.com', async () => { + const result = await testSmartNetwork.getDomainIntelligence('google.com'); + console.log('Domain Intelligence for google.com:', JSON.stringify(result, null, 2)); + + // RDAP fields + expect(result.domain).toEqual('google.com'); + expect(result.registrarName).toBeTruthy(); + expect(result.nameservers).toBeTruthy(); + expect(Array.isArray(result.nameservers)).toBeTrue(); + expect(result.nameservers!.length).toBeGreaterThan(0); + expect(result.registrationDate).toBeTruthy(); + expect(result.expirationDate).toBeTruthy(); + expect(Array.isArray(result.status)).toBeTrue(); + expect(result.nameserversSource).toEqual('rdap'); + + // DNS enrichment fields + expect(result.resolvedIpv4).toBeTruthy(); + expect(result.resolvedIpv4!.length).toBeGreaterThan(0); + expect(result.mxRecords).toBeTruthy(); + expect(result.mxRecords!.length).toBeGreaterThan(0); + expect(result.txtRecords).toBeTruthy(); + expect(result.txtRecords!.length).toBeGreaterThan(0); +}); + +tap.test('should get domain intelligence for cloudflare.com', async () => { + const result = await testSmartNetwork.getDomainIntelligence('cloudflare.com'); + console.log('Domain Intelligence for cloudflare.com:', JSON.stringify(result, null, 2)); + + expect(result.domain).toEqual('cloudflare.com'); + expect(result.registrarName).toBeTruthy(); + expect(result.nameservers).toBeTruthy(); + expect(result.nameservers!.length).toBeGreaterThan(0); + expect(result.registrationDate).toBeTruthy(); +}); + +tap.test('should get domain intelligence for wikipedia.org (.org TLD)', async () => { + const result = await testSmartNetwork.getDomainIntelligence('wikipedia.org'); + console.log('Domain Intelligence for wikipedia.org:', JSON.stringify(result, null, 2)); + + expect(result.domain).toEqual('wikipedia.org'); + expect(result.registrarName).toBeTruthy(); + expect(result.nameservers).toBeTruthy(); + expect(result.nameservers!.length).toBeGreaterThan(0); +}); + +tap.test('should normalize an FQDN with trailing dot', async () => { + const result = await testSmartNetwork.getDomainIntelligence('Google.Com.'); + expect(result.domain).toEqual('google.com'); +}); + +tap.test('should normalize an IDN to ASCII (punycode)', async () => { + // The IDN "mΓΌnchen.de" β†’ "xn--mnchen-3ya.de" + const result = await testSmartNetwork.getDomainIntelligence('mΓΌnchen.de'); + console.log('IDN normalized to:', result.domain); + // Even if the lookup fails (e.g. .de RDAP behavior), the normalization should still produce the ASCII form + expect(result.domain).toEqual('xn--mnchen-3ya.de'); +}); + +tap.test('should handle unknown TLD gracefully', async () => { + const result = await testSmartNetwork.getDomainIntelligence('foo.invalidtld12345'); + console.log('Unknown TLD result:', JSON.stringify(result, null, 2)); + expect(result).toBeTruthy(); + expect(result.registrarName).toBeNull(); + expect(result.nameservers).toBeNull(); +}); + +tap.test('should handle malformed input gracefully', async () => { + const r1 = await testSmartNetwork.getDomainIntelligence(''); + expect(r1).toBeTruthy(); + expect(r1.domain).toBeNull(); + + const r2 = await testSmartNetwork.getDomainIntelligence('not a domain at all'); + expect(r2).toBeTruthy(); + expect(r2.domain).toBeNull(); + + const r3 = await testSmartNetwork.getDomainIntelligence('nodot'); + expect(r3).toBeTruthy(); + expect(r3.domain).toBeNull(); +}); + +tap.test('should parse MX records with priority and exchange', async () => { + const result = await testSmartNetwork.getDomainIntelligence('google.com'); + expect(result.mxRecords).toBeTruthy(); + for (const mx of result.mxRecords!) { + expect(typeof mx.exchange).toEqual('string'); + expect(mx.exchange.length).toBeGreaterThan(0); + } +}); + +tap.test('should populate nameservers via DNS for RDAP-less .de TLD', async () => { + const result = await testSmartNetwork.getDomainIntelligence('bund.de'); + console.log('Domain Intelligence for bund.de:', JSON.stringify(result, null, 2)); + // .de has no RDAP in the IANA bootstrap, so RDAP fields are null + expect(result.registrarName).toBeNull(); + // DNS layer should fill in nameservers + resolved IPs + expect(result.nameservers).toBeTruthy(); + expect(result.nameservers!.length).toBeGreaterThan(0); + expect(result.nameserversSource).toEqual('dns'); + expect(result.resolvedIpv4).toBeTruthy(); +}); + +tap.test('should use cache when cacheTtl is set', async () => { + const cached = new smartnetwork.SmartNetwork({ cacheTtl: 60000 }); + const r1 = await cached.getDomainIntelligence('google.com'); + const r2 = await cached.getDomainIntelligence('google.com'); + expect(r1.registrarName).toEqual(r2.registrarName); + expect(r1.registrationDate).toEqual(r2.registrationDate); + await cached.stop(); +}); + +tap.test('should stop cleanly (tears down shared smartdns client)', async () => { + // If the Rust-backed smartdns bridge wasn't destroyed, this test process + // would hang at exit instead of completing. + await testSmartNetwork.stop(); +}); + +export default tap.start(); diff --git a/test/test.ipintelligence.ts b/test/test.ipintelligence.ts index 053ed93..31d6852 100644 --- a/test/test.ipintelligence.ts +++ b/test/test.ipintelligence.ts @@ -65,6 +65,13 @@ tap.test('should use cache when cacheTtl is set', async () => { // Second call should return the same cached result expect(r1.asn).toEqual(r2.asn); expect(r1.countryCode).toEqual(r2.countryCode); + await cached.stop(); +}); + +tap.test('should stop cleanly (tears down shared smartdns client)', async () => { + // If the Rust-backed smartdns bridge wasn't destroyed, this test process + // would hang at exit instead of completing. + await testSmartNetwork.stop(); }); export default tap.start(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 187a3c5..e675185 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/index.ts b/ts/index.ts index 71ccf02..19badf7 100644 --- a/ts/index.ts +++ b/ts/index.ts @@ -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'; diff --git a/ts/smartnetwork.classes.domainintelligence.ts b/ts/smartnetwork.classes.domainintelligence.ts new file mode 100644 index 0000000..115378f --- /dev/null +++ b/ts/smartnetwork.classes.domainintelligence.ts @@ -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; +/** 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; + } +} diff --git a/ts/smartnetwork.classes.ipintelligence.ts b/ts/smartnetwork.classes.ipintelligence.ts index 60da9ae..b8f3d51 100644 --- a/ts/smartnetwork.classes.ipintelligence.ts +++ b/ts/smartnetwork.classes.ipintelligence.ts @@ -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; } // 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 | null = null; + // Optional injected smartdns client (shared by SmartNetwork) + private readonly sharedDnsClient: InstanceType | 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 | 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(); } } diff --git a/ts/smartnetwork.classes.smartnetwork.ts b/ts/smartnetwork.classes.smartnetwork.ts index 7b9e594..e0ea951 100644 --- a/ts/smartnetwork.classes.smartnetwork.ts +++ b/ts/smartnetwork.classes.smartnetwork.ts @@ -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; + /** * 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 { 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 { 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 { + 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 */