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
+9
View File
@@ -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
+112 -7
View File
@@ -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.
+127
View File
@@ -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();
+7
View File
@@ -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();
+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
*/