feat(domain-intelligence): add domain intelligence lookups with RDAP and DNS enrichment
This commit is contained in:
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-03-26 - 4.5.2 - fix(docs)
|
||||||
refresh README content and align license copyright holder
|
refresh README content and align license copyright holder
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# @push.rocks/smartnetwork 🌐
|
# @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
|
## Issue Reporting and Security
|
||||||
|
|
||||||
@@ -14,9 +14,9 @@ pnpm install @push.rocks/smartnetwork --save
|
|||||||
|
|
||||||
## 🎯 Overview
|
## 🎯 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
|
### ✨ 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 |
|
| 📡 **Connectivity** | ICMP ping with stats, hop-by-hop traceroute, HTTP/HTTPS health checks |
|
||||||
| 🌍 **DNS** | A, AAAA, MX resolution with system-first + DoH fallback strategy |
|
| 🌍 **DNS** | A, AAAA, MX resolution with system-first + DoH fallback strategy |
|
||||||
| 🔍 **IP Intelligence** | ASN, organization, geolocation, RDAP registration — all from free public sources |
|
| 🔍 **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 |
|
| 🖥️ **Network Discovery** | Interfaces, default gateway, public IPv4/IPv6 |
|
||||||
| ⚡ **Caching** | Built-in TTL cache for expensive lookups |
|
| ⚡ **Caching** | Built-in TTL cache for expensive lookups |
|
||||||
| 🔧 **Extensible** | Plugin architecture for custom functionality |
|
| 🔧 **Extensible** | Plugin architecture for custom functionality |
|
||||||
@@ -46,7 +47,7 @@ await network.start();
|
|||||||
|
|
||||||
// ... use the network instance ...
|
// ... 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();
|
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
|
### 🏎️ Speed Testing
|
||||||
|
|
||||||
Measure network performance via Cloudflare's global infrastructure:
|
Measure network performance via Cloudflare's global infrastructure:
|
||||||
@@ -263,7 +362,7 @@ console.log(`🌍 IPv6: ${publicIps.v6 || 'N/A'}`);
|
|||||||
|
|
||||||
### ⚡ Caching
|
### ⚡ Caching
|
||||||
|
|
||||||
Caching applies to `getGateways()`, `getPublicIps()`, and `getIpIntelligence()`:
|
Caching applies to `getGateways()`, `getPublicIps()`, `getIpIntelligence()`, and `getDomainIntelligence()`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s
|
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}`);
|
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();
|
await network.stop();
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -368,7 +472,7 @@ const monitor = async () => {
|
|||||||
|
|
||||||
| Method | Description | Requires Rust |
|
| 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 |
|
| `getSpeed(opts?)` | Cloudflare speed test (download + upload) | No |
|
||||||
| `ping(host, opts?)` | ICMP ping with optional multi-ping stats | Yes |
|
| `ping(host, opts?)` | ICMP ping with optional multi-ping stats | Yes |
|
||||||
| `traceroute(host, opts?)` | Hop-by-hop network path analysis | 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 |
|
| `resolveDns(host)` | Resolve A, AAAA, MX records | No |
|
||||||
| `checkEndpoint(url, opts?)` | HTTP/HTTPS health check with RTT | No |
|
| `checkEndpoint(url, opts?)` | HTTP/HTTPS health check with RTT | No |
|
||||||
| `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration | No |
|
| `getIpIntelligence(ip)` | ASN + org + geo + RDAP registration | No |
|
||||||
|
| `getDomainIntelligence(domain)` | Registrar, nameservers, events, DNS records, DNSSEC | No |
|
||||||
|
|
||||||
## License and Legal Information
|
## License and Legal Information
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -65,6 +65,13 @@ tap.test('should use cache when cacheTtl is set', async () => {
|
|||||||
// Second call should return the same cached result
|
// Second call should return the same cached result
|
||||||
expect(r1.asn).toEqual(r2.asn);
|
expect(r1.asn).toEqual(r2.asn);
|
||||||
expect(r1.countryCode).toEqual(r2.countryCode);
|
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();
|
export default tap.start();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartnetwork',
|
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.'
|
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,7 @@ export { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
|
|||||||
export { PublicIp } from './smartnetwork.classes.publicip.js';
|
export { PublicIp } from './smartnetwork.classes.publicip.js';
|
||||||
export { IpIntelligence } from './smartnetwork.classes.ipintelligence.js';
|
export { IpIntelligence } from './smartnetwork.classes.ipintelligence.js';
|
||||||
export type { IIpIntelligenceResult, IIpIntelligenceOptions } 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 { setLogger, getLogger } from './logging.js';
|
||||||
export { NetworkError, TimeoutError } from './errors.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,13 @@ export interface IIpIntelligenceOptions {
|
|||||||
dbMaxAge?: number;
|
dbMaxAge?: number;
|
||||||
/** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */
|
/** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */
|
||||||
timeout?: number;
|
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)
|
// 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 bootstrapEntries: IBootstrapEntry[] | null = null;
|
||||||
private bootstrapPromise: Promise<void> | 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) {
|
constructor(options?: IIpIntelligenceOptions) {
|
||||||
this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
|
this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
|
||||||
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
|
||||||
|
this.sharedDnsClient = options?.dnsClient ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -385,16 +396,19 @@ export class IpIntelligence {
|
|||||||
* Response: "ASN | prefix | CC | rir | date"
|
* Response: "ASN | prefix | CC | rir | date"
|
||||||
*/
|
*/
|
||||||
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
|
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 {
|
try {
|
||||||
const reversed = ip.split('.').reverse().join('.');
|
const reversed = ip.split('.').reverse().join('.');
|
||||||
const queryName = `${reversed}.origin.asn.cymru.com`;
|
const queryName = `${reversed}.origin.asn.cymru.com`;
|
||||||
|
|
||||||
|
if (!dnsClient) {
|
||||||
dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
||||||
strategy: 'prefer-system',
|
strategy: 'prefer-system',
|
||||||
allowDohFallback: true,
|
allowDohFallback: true,
|
||||||
timeoutMs: this.timeout,
|
timeoutMs: this.timeout,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const records = await dnsClient.getRecordsTxt(queryName);
|
const records = await dnsClient.getRecordsTxt(queryName);
|
||||||
if (!records || records.length === 0) return null;
|
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}`);
|
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
|
||||||
return null;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
if (dnsClient) {
|
// Only destroy clients we created ourselves; leave injected ones alone.
|
||||||
|
if (!external && dnsClient) {
|
||||||
dnsClient.destroy();
|
dnsClient.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import * as plugins from './smartnetwork.plugins.js';
|
|||||||
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
|
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
|
||||||
import { PublicIp } from './smartnetwork.classes.publicip.js';
|
import { PublicIp } from './smartnetwork.classes.publicip.js';
|
||||||
import { IpIntelligence, type IIpIntelligenceResult } from './smartnetwork.classes.ipintelligence.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 { getLogger } from './logging.js';
|
||||||
import { NetworkError } from './errors.js';
|
import { NetworkError } from './errors.js';
|
||||||
import { RustNetworkBridge } from './smartnetwork.classes.rustbridge.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
|
* Configuration options for SmartNetwork
|
||||||
*/
|
*/
|
||||||
@@ -54,6 +58,8 @@ export class SmartNetwork {
|
|||||||
private rustBridge: RustNetworkBridge;
|
private rustBridge: RustNetworkBridge;
|
||||||
private bridgeStarted = false;
|
private bridgeStarted = false;
|
||||||
private ipIntelligence: IpIntelligence | null = null;
|
private ipIntelligence: IpIntelligence | null = null;
|
||||||
|
private domainIntelligence: DomainIntelligence | null = null;
|
||||||
|
private dnsClient: TSmartdnsClient | null = null;
|
||||||
|
|
||||||
constructor(options?: SmartNetworkOptions) {
|
constructor(options?: SmartNetworkOptions) {
|
||||||
this.options = options || {};
|
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> {
|
public async stop(): Promise<void> {
|
||||||
if (this.bridgeStarted) {
|
if (this.bridgeStarted) {
|
||||||
await this.rustBridge.stop();
|
await this.rustBridge.stop();
|
||||||
this.bridgeStarted = false;
|
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).
|
* 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(
|
public async resolveDns(
|
||||||
host: string,
|
host: string,
|
||||||
): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
|
): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
|
||||||
try {
|
try {
|
||||||
const dnsClient = new plugins.smartdns.dnsClientMod.Smartdns({
|
const dnsClient = this.ensureDnsClient();
|
||||||
strategy: 'prefer-system',
|
|
||||||
allowDohFallback: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [aRecords, aaaaRecords, mxRecords] = await Promise.all([
|
const [aRecords, aaaaRecords, mxRecords] = await Promise.all([
|
||||||
dnsClient.getRecordsA(host).catch((): any[] => []),
|
dnsClient.getRecordsA(host).catch((): any[] => []),
|
||||||
@@ -404,7 +437,7 @@ export class SmartNetwork {
|
|||||||
*/
|
*/
|
||||||
public async getIpIntelligence(ip: string): Promise<IIpIntelligenceResult> {
|
public async getIpIntelligence(ip: string): Promise<IIpIntelligenceResult> {
|
||||||
if (!this.ipIntelligence) {
|
if (!this.ipIntelligence) {
|
||||||
this.ipIntelligence = new IpIntelligence();
|
this.ipIntelligence = new IpIntelligence({ dnsClient: this.ensureDnsClient() });
|
||||||
}
|
}
|
||||||
const fetcher = () => this.ipIntelligence!.getIntelligence(ip);
|
const fetcher = () => this.ipIntelligence!.getIntelligence(ip);
|
||||||
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
||||||
@@ -413,6 +446,22 @@ export class SmartNetwork {
|
|||||||
return fetcher();
|
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
|
* Internal caching helper
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user