Compare commits

..

8 Commits

Author SHA1 Message Date
jkunz d526a7d8dd v4.7.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-26 20:45:47 +00:00
jkunz 3e86e99d4f feat(ipintelligence): add canonical RDAP CIDR coverage and derive CIDRs from IPv4 start/end ranges 2026-04-26 20:45:47 +00:00
jkunz 5331a3c2ce v4.6.0
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-04-13 16:51:41 +00:00
jkunz a694b0c8ae feat(domain-intelligence): add domain intelligence lookups with RDAP and DNS enrichment 2026-04-13 16:51:41 +00:00
jkunz 7e973b842c v4.5.2
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-26 15:44:05 +00:00
jkunz 14baec56f7 fix(docs): refresh README content and align license copyright holder 2026-03-26 15:44:05 +00:00
jkunz 6eb5aca0df v4.5.1
Default (tags) / security (push) Failing after 0s
Default (tags) / test (push) Failing after 0s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2026-03-26 15:35:23 +00:00
jkunz b515d8c6a7 fix(ipintelligence): handle flat geolocation MMDB schema and clean up DNS client lifecycle 2026-03-26 15:35:22 +00:00
11 changed files with 1448 additions and 343 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog
## 2026-04-26 - 4.7.0 - feat(ipintelligence)
add canonical RDAP CIDR coverage and derive CIDRs from IPv4 start/end ranges
- Expose a new networkCidrs field alongside networkRange in IP intelligence results
- Convert RDAP IPv4 start/end ranges into canonical CIDR blocks, preserving legacy range formatting when multiple prefixes are required
- Add tests covering single-prefix and multi-prefix RDAP range parsing
## 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
- Rewrite the README to better present network diagnostics, IP intelligence capabilities, usage examples, and full API reference
- Add issue reporting guidance and clarify legal and trademark wording in project documentation
- Update the license copyright holder to Task Venture Capital GmbH
## 2026-03-26 - 4.5.1 - fix(ipintelligence)
handle flat geolocation MMDB schema and clean up DNS client lifecycle
- update IP intelligence lookups to read geolocation fields from the flat @ip-location-db schema instead of MaxMind nested city records
- destroy the Team Cymru DNS client after queries to avoid leaking resources
- adjust IP intelligence tests to validate countryCode-based geolocation and RDAP fields against current data sources
## 2026-03-26 - 4.5.0 - feat(smartnetwork)
add Rust-powered network diagnostics bridge and IP intelligence lookups
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015 Lossless GmbH
Copyright (c) 2015 Task Venture Capital GmbH
Copyright (c) 2020 Tomás Arias
Permission is hereby granted, free of charge, to any person obtaining a copy
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartnetwork",
"version": "4.5.0",
"version": "4.7.0",
"private": false,
"description": "A toolkit for network diagnostics including speed tests, port availability checks, and more.",
"exports": {
+364 -290
View File
@@ -1,40 +1,45 @@
# @push.rocks/smartnetwork 🌐
Comprehensive network diagnostics and utilities for Node.js applications
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
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## 🚀 Install
To install `@push.rocks/smartnetwork`, run the following command in your terminal:
```bash
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 network monitoring tools, implementing health checks, or just need to debug connectivity issues, this library has you covered with a clean, promise-based API.
**@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 & domain intelligence, HTTP health checks — runs in pure TypeScript.
### ✨ Key Features
- **🏎️ Speed Testing** - Measure download/upload speeds using Cloudflare's infrastructure
- **🔌 Port Management** - Check local/remote port availability, find free ports (sequential or random)
- **📡 Connectivity Testing** - Ping hosts, trace routes, check endpoints
- **🌍 DNS Operations** - Resolve A, AAAA, and MX records with smart local/remote resolution
- **🔍 Network Discovery** - Get network interfaces, gateways, public IPs
- **⚡ Performance Caching** - Built-in caching for expensive operations
- **🔧 Plugin Architecture** - Extend functionality with custom plugins
- **📝 Full TypeScript Support** - Complete type definitions included
| Category | Capabilities |
|----------|-------------|
| 🏎️ **Speed Testing** | Download/upload benchmarks via Cloudflare's global infrastructure |
| 🔌 **Port Management** | Local/remote port checks, find free ports (sequential or random), exclusion lists |
| 📡 **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 |
| 📝 **TypeScript** | Full type definitions, ESM-native |
## 💻 Usage
### Basic Setup
First, import and initialize SmartNetwork:
```typescript
import { SmartNetwork } from '@push.rocks/smartnetwork';
// Basic instance
const network = new SmartNetwork();
// Start the Rust bridge (auto-started on first use, but explicit start is recommended)
@@ -42,396 +47,465 @@ await network.start();
// ... use the network instance ...
// Clean up when done (stops the Rust bridge process)
// Clean up when done — tears down both the Rust bridge and the smartdns backend
await network.stop();
// With caching enabled (60 seconds TTL)
const cachedNetwork = new SmartNetwork({ cacheTtl: 60000 });
```
### 🏎️ Network Speed Testing
Measure your network performance using Cloudflare's global infrastructure:
Enable caching for repeated lookups:
```typescript
const speedTest = async () => {
// Quick speed test
const result = await network.getSpeed();
console.log(`Download: ${result.downloadSpeed} Mbps`);
console.log(`Upload: ${result.uploadSpeed} Mbps`);
// Advanced configuration
const advancedResult = await network.getSpeed({
parallelStreams: 3, // Number of concurrent connections
duration: 5 // Test duration in seconds
});
console.log(`Download: ${advancedResult.downloadSpeed} Mbps`);
console.log(`Upload: ${advancedResult.uploadSpeed} Mbps`);
};
const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s TTL
```
---
### 🕵️ IP Intelligence
Get ASN, organization, geolocation, and RDAP registration data for any IPv4 address. Combines three free public data sources in parallel:
- **RDAP** — direct queries to RIRs (RIPE, ARIN, APNIC, LACNIC, AFRINIC) for authoritative registration data
- **Team Cymru DNS** — fast ASN resolution via DNS TXT records
- **MaxMind GeoLite2** — in-memory MMDB databases (auto-downloaded from CDN, periodically refreshed)
```typescript
const intel = await network.getIpIntelligence('8.8.8.8');
console.log(intel);
// {
// asn: 15169,
// asnOrg: 'Google LLC',
// registrantOrg: 'Google LLC',
// registrantCountry: 'United States',
// networkRange: '8.8.8.0/24',
// networkCidrs: ['8.8.8.0/24'],
// abuseContact: null,
// country: null,
// countryCode: 'US',
// city: null,
// latitude: 37.751,
// longitude: -97.822,
// accuracyRadius: null,
// timezone: 'America/Chicago'
// }
```
Works great for your own IP too:
```typescript
const publicIps = await network.getPublicIps();
if (publicIps.v4) {
const myIntel = await network.getIpIntelligence(publicIps.v4);
console.log(`You're on AS${myIntel.asn} (${myIntel.asnOrg}) in ${myIntel.city}, ${myIntel.countryCode}`);
}
```
The `IIpIntelligenceResult` interface:
```typescript
interface IIpIntelligenceResult {
// ASN (Team Cymru primary, MaxMind fallback)
asn: number | null;
asnOrg: string | null;
// Registration (RDAP)
registrantOrg: string | null;
registrantCountry: string | null;
networkRange: string | null; // primary CIDR, or legacy start-end range when multiple CIDRs are needed
networkCidrs: string[] | null; // canonical CIDR coverage for the RDAP network when available
abuseContact: string | null; // abuse email from RDAP
// Geolocation (MaxMind GeoLite2)
country: string | null;
countryCode: string | null; // ISO 3166-1 alpha-2
city: string | null;
latitude: number | null;
longitude: number | null;
accuracyRadius: number | null; // km
timezone: string | null; // IANA timezone
}
```
> 💡 The GeoLite2 databases are fetched into memory from jsDelivr CDN on first use (~32 MB total). They auto-refresh in the background every 7 days (configurable via `IpIntelligence` options). No disk I/O, no API keys, no rate limits.
---
### 🏢 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:
```typescript
const result = await network.getSpeed();
console.log(`⬇️ Download: ${result.downloadSpeed} Mbps`);
console.log(`⬆️ Upload: ${result.uploadSpeed} Mbps`);
// Advanced: parallel streams + fixed duration
const advanced = await network.getSpeed({
parallelStreams: 3,
duration: 5,
});
```
---
### 🔌 Port Management
#### Check Local Port Availability
Verify if a port is available on your local machine (checks both IPv4 and IPv6):
Checks both IPv4 and IPv6:
```typescript
const checkLocalPort = async (port: number) => {
const isUnused = await network.isLocalPortUnused(port);
if (isUnused) {
console.log(`✅ Port ${port} is available`);
} else {
console.log(`❌ Port ${port} is in use`);
}
};
await checkLocalPort(8080);
const isUnused = await network.isLocalPortUnused(8080);
console.log(isUnused ? '✅ Port 8080 is free' : '❌ Port 8080 is in use');
```
#### Find Free Port in Range
Automatically discover available ports:
```typescript
const findFreePort = async () => {
// Find a free port between 3000 and 3100 (sequential - returns first available)
const freePort = await network.findFreePort(3000, 3100);
if (freePort) {
console.log(`🎉 Found free port: ${freePort}`);
} else {
console.log('😢 No free ports available in range');
}
// Find a random free port in range (useful to avoid port conflicts)
const randomPort = await network.findFreePort(3000, 3100, { randomize: true });
console.log(`🎲 Random free port: ${randomPort}`);
// Exclude specific ports from the search
const portWithExclusions = await network.findFreePort(3000, 3100, {
exclude: [3000, 3001, 3005] // Skip these ports even if they're free
});
console.log(`🚫 Free port (excluding specific ports): ${portWithExclusions}`);
// Combine randomize with exclude options
const randomWithExclusions = await network.findFreePort(3000, 3100, {
randomize: true,
exclude: [3000, 3001, 3005]
});
console.log(`🎲🚫 Random free port (with exclusions): ${randomWithExclusions}`);
};
```
// First available
const port = await network.findFreePort(3000, 3100);
#### Check Remote Port Availability
// Random pick (avoids clustering)
const randomPort = await network.findFreePort(3000, 3100, { randomize: true });
Test if services are accessible on remote servers:
```typescript
// Method 1: Using "host:port" syntax
const isOpen1 = await network.isRemotePortAvailable('example.com:443');
// Method 2: Using separate host and port
const isOpen2 = await network.isRemotePortAvailable('example.com', 443);
// Method 3: With advanced options
const isOpen3 = await network.isRemotePortAvailable('example.com', {
port: 443,
protocol: 'tcp', // Only TCP is supported
retries: 3, // Number of connection attempts
timeout: 5000 // Timeout per attempt in ms
// With exclusions
const port2 = await network.findFreePort(3000, 3100, {
randomize: true,
exclude: [3000, 3001, 3005],
});
// Note: UDP is not supported
try {
await network.isRemotePortAvailable('example.com', {
port: 53,
protocol: 'udp'
});
} catch (e) {
console.error('UDP not supported:', e.code); // ENOTSUP
}
```
### 📡 Network Connectivity
#### Check Remote Port
#### Ping Operations
```typescript
// Simple
const isOpen = await network.isRemotePortAvailable('example.com', 443);
Test connectivity and measure latency:
// With retries and timeout
const isOpen2 = await network.isRemotePortAvailable('example.com', {
port: 443,
retries: 3,
timeout: 5000,
});
```
---
### 📡 Ping & Traceroute
```typescript
// Simple ping
const pingResult = await network.ping('google.com');
console.log(`Host alive: ${pingResult.alive}`);
console.log(`RTT: ${pingResult.time} ms`);
const ping = await network.ping('google.com');
console.log(`Alive: ${ping.alive}, RTT: ${ping.time} ms`);
// Detailed ping statistics
const pingStats = await network.ping('google.com', {
count: 5, // Number of pings
timeout: 1000 // Timeout per ping in ms
});
// Multi-ping with statistics
const stats = await network.ping('google.com', { count: 10, timeout: 2000 });
console.log(`📊 min=${stats.min} avg=${stats.avg.toFixed(1)} max=${stats.max} loss=${stats.packetLoss}%`);
console.log(`📊 Ping Statistics:`);
console.log(` Packet loss: ${pingStats.packetLoss}%`);
console.log(` Min: ${pingStats.min} ms`);
console.log(` Max: ${pingStats.max} ms`);
console.log(` Avg: ${pingStats.avg.toFixed(2)} ms`);
console.log(` Stddev: ${pingStats.stddev.toFixed(2)} ms`);
```
#### Traceroute
Analyze network paths hop-by-hop:
```typescript
const hops = await network.traceroute('google.com', {
maxHops: 10, // Maximum number of hops
timeout: 5000 // Timeout in ms
});
console.log('🛤️ Route to destination:');
// Traceroute
const hops = await network.traceroute('google.com', { maxHops: 20 });
hops.forEach(hop => {
const rtt = hop.rtt === null ? '*' : `${hop.rtt} ms`;
console.log(` ${hop.ttl}\t${hop.ip}\t${rtt}`);
});
```
*Note: Falls back to a single-hop stub if the `traceroute` binary is unavailable.*
> ⚠️ ICMP ping requires `CAP_NET_RAW` or appropriate `ping_group_range` sysctl on Linux.
### 🌍 DNS Operations
---
Resolve various DNS record types using @push.rocks/smartdns with intelligent resolution strategy:
### 🌍 DNS Resolution
Uses `@push.rocks/smartdns` with a system-first strategy and automatic DoH (DNS-over-HTTPS) fallback:
```typescript
const dnsRecords = await network.resolveDns('example.com');
console.log('🔍 DNS Records:');
console.log(' A records:', dnsRecords.A); // IPv4 addresses
console.log(' AAAA records:', dnsRecords.AAAA); // IPv6 addresses
// MX records include priority
dnsRecords.MX.forEach(mx => {
console.log(` 📧 Mail server: ${mx.exchange} (priority: ${mx.priority})`);
const dns = await network.resolveDns('example.com');
console.log('A records:', dns.A); // ['93.184.216.34']
console.log('AAAA records:', dns.AAAA); // ['2606:2800:220:1:248:1893:25c8:1946']
dns.MX.forEach(mx => {
console.log(`📧 ${mx.exchange} (priority ${mx.priority})`);
});
// Properly handles local hostnames (localhost, etc.)
const localDns = await network.resolveDns('localhost');
console.log(' Localhost:', localDns.A); // Returns ['127.0.0.1']
```
*DNS resolution uses a `prefer-system` strategy: tries system resolver first (respects /etc/hosts and local DNS), with automatic fallback to Cloudflare DoH for external domains.*
---
### 🏥 HTTP/HTTPS Health Checks
Monitor endpoint availability and response times:
```typescript
const health = await network.checkEndpoint('https://api.example.com/health', {
timeout: 5000 // Request timeout in ms
timeout: 5000,
rejectUnauthorized: true,
});
console.log(`🩺 Endpoint Health:`);
console.log(` Status: ${health.status}`);
console.log(` RTT: ${health.rtt} ms`);
console.log(` Headers:`, health.headers);
console.log(`Status: ${health.status}, RTT: ${health.rtt.toFixed(0)} ms`);
```
### 🖥️ Network Interface Information
---
#### Get All Network Interfaces
List all network adapters on the system:
### 🖥️ Network Interfaces & Public IPs
```typescript
// All interfaces
const gateways = await network.getGateways();
Object.entries(gateways).forEach(([name, interfaces]) => {
console.log(`🔌 Interface: ${name}`);
interfaces.forEach(iface => {
console.log(` ${iface.family}: ${iface.address}`);
console.log(` Netmask: ${iface.netmask}`);
console.log(` MAC: ${iface.mac}`);
});
Object.entries(gateways).forEach(([name, ifaces]) => {
console.log(`🔌 ${name}:`);
ifaces.forEach(i => console.log(` ${i.family}: ${i.address}`));
});
```
#### Get Default Gateway
Retrieve the primary network interface:
```typescript
const defaultGateway = await network.getDefaultGateway();
if (defaultGateway) {
console.log('🌐 Default Gateway:');
console.log(' IPv4:', defaultGateway.ipv4.address);
console.log(' IPv6:', defaultGateway.ipv6.address);
// Default gateway
const gw = await network.getDefaultGateway();
if (gw) {
console.log(`🌐 Gateway IPv4: ${gw.ipv4.address}`);
}
```
### 🌎 Public IP Discovery
Discover your public-facing IP addresses:
```typescript
// Public IPs (multiple fallback services: ipify, ident.me, seeip, icanhazip)
const publicIps = await network.getPublicIps();
console.log(`🌍 Public IPs:`);
console.log(` IPv4: ${publicIps.v4 || 'Not available'}`);
console.log(` IPv6: ${publicIps.v6 || 'Not available'}`);
console.log(`🌍 IPv4: ${publicIps.v4 || 'N/A'}`);
console.log(`🌍 IPv6: ${publicIps.v6 || 'N/A'}`);
```
### ⚡ Performance Caching
---
Reduce network calls with built-in caching:
### ⚡ Caching
Caching applies to `getGateways()`, `getPublicIps()`, `getIpIntelligence()`, and `getDomainIntelligence()`:
```typescript
// Create instance with 60-second cache TTL
const cachedNetwork = new SmartNetwork({ cacheTtl: 60000 });
const network = new SmartNetwork({ cacheTtl: 60000 }); // 60s
// First call fetches from network
const gateways1 = await cachedNetwork.getGateways();
const publicIps1 = await cachedNetwork.getPublicIps();
// Subsequent calls within 60 seconds use cache
const gateways2 = await cachedNetwork.getGateways(); // From cache ⚡
const publicIps2 = await cachedNetwork.getPublicIps(); // From cache ⚡
const ips1 = await network.getPublicIps(); // fetches
const ips2 = await network.getPublicIps(); // cache hit ⚡
```
### 🔧 Plugin Architecture
Extend SmartNetwork with custom functionality:
```typescript
// Define your plugin
class CustomNetworkPlugin {
constructor(private smartNetwork: SmartNetwork) {}
async customMethod() {
// Your custom network logic here
return 'Custom result';
}
}
// Register the plugin
SmartNetwork.registerPlugin('customPlugin', CustomNetworkPlugin);
// Use the plugin
const network = new SmartNetwork();
const PluginClass = SmartNetwork.pluginsRegistry.get('customPlugin');
const plugin = new PluginClass(network);
await plugin.customMethod();
// Clean up when done
SmartNetwork.unregisterPlugin('customPlugin');
```
---
### 🚨 Error Handling
Handle network errors gracefully with custom error types:
```typescript
import { NetworkError } from '@push.rocks/smartnetwork';
import { SmartNetwork, NetworkError, TimeoutError } from '@push.rocks/smartnetwork';
try {
await network.isRemotePortAvailable('example.com', { protocol: 'udp' });
} catch (error) {
if (error instanceof NetworkError) {
console.error(`❌ Network error: ${error.message}`);
console.error(` Error code: ${error.code}`);
console.error(`${error.message} (code: ${error.code})`); // ENOTSUP
}
}
```
### 📚 TypeScript Support
Error codes: `EINVAL` (invalid argument), `ENOTSUP` (not supported), `ETIMEOUT` (timeout).
This package is written in TypeScript and provides comprehensive type definitions:
---
### 🔧 Plugin Architecture
```typescript
interface SmartNetworkOptions {
cacheTtl?: number; // Cache TTL in milliseconds
class MyPlugin {
constructor(private network: SmartNetwork) {}
async doStuff() { /* ... */ }
}
interface IFindFreePortOptions {
randomize?: boolean; // If true, returns a random free port instead of the first one
exclude?: number[]; // Array of port numbers to exclude from the search
}
SmartNetwork.registerPlugin('myPlugin', MyPlugin);
interface Hop {
ttl: number; // Time to live
ip: string; // IP address of the hop
rtt: number | null; // Round trip time in ms
}
const PluginClass = SmartNetwork.pluginsRegistry.get('myPlugin');
const plugin = new PluginClass(network);
await plugin.doStuff();
// ... and many more types for complete type safety
SmartNetwork.unregisterPlugin('myPlugin');
```
## 🛠️ Advanced Examples
---
### Building a Network Monitor
### 📝 Custom Logging
Replace the default `console` logger:
```typescript
const monitorNetwork = async () => {
import { setLogger } from '@push.rocks/smartnetwork';
setLogger({
debug: (msg) => myLogger.debug(msg),
info: (msg) => myLogger.info(msg),
warn: (msg) => myLogger.warn(msg),
error: (msg) => myLogger.error(msg),
});
```
---
## 🛠️ Advanced Example: Network Monitor
```typescript
const monitor = async () => {
const network = new SmartNetwork({ cacheTtl: 30000 });
// Check critical services
const services = [
{ name: 'Web Server', host: 'example.com', port: 443 },
{ name: 'Database', host: 'db.internal', port: 5432 },
{ name: 'Cache', host: 'redis.internal', port: 6379 }
{ name: 'Web', host: 'example.com', port: 443 },
{ name: 'DB', host: 'db.internal', port: 5432 },
];
for (const service of services) {
const isUp = await network.isRemotePortAvailable(service.host, service.port);
console.log(`${service.name}: ${isUp ? '✅ UP' : '❌ DOWN'}`);
for (const svc of services) {
const up = await network.isRemotePortAvailable(svc.host, svc.port);
console.log(`${svc.name}: ${up ? '✅ UP' : '❌ DOWN'}`);
}
// Check internet connectivity
// Internet connectivity + latency
const ping = await network.ping('8.8.8.8');
console.log(`Internet: ${ping.alive ? '✅ Connected' : '❌ Disconnected'}`);
// Measure network performance
console.log(`Internet: ${ping.alive ? '✅' : '❌'} (${ping.time} ms)`);
// Speed
const speed = await network.getSpeed();
console.log(`Speed: ⬇️ ${speed.downloadSpeed} Mbps / ⬆️ ${speed.uploadSpeed} Mbps`);
};
console.log(`Speed: ⬇️ ${speed.downloadSpeed} / ⬆️ ${speed.uploadSpeed} Mbps`);
// Run monitor every minute
setInterval(monitorNetwork, 60000);
```
### Service Discovery
```typescript
const discoverServices = async () => {
const network = new SmartNetwork();
const commonPorts = [22, 80, 443, 3000, 3306, 5432, 6379, 8080, 9200];
console.log('🔍 Scanning local services...');
for (const port of commonPorts) {
const isUsed = !(await network.isLocalPortUnused(port));
if (isUsed) {
console.log(` Port ${port}: In use (possible service running)`);
}
// Who am I?
const ips = await network.getPublicIps();
if (ips.v4) {
const intel = await network.getIpIntelligence(ips.v4);
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();
};
```
## 📋 Full API Reference
| Method | Description | Requires Rust |
|--------|-------------|:---:|
| `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 |
| `isLocalPortUnused(port)` | Check if local port is free (IPv4+IPv6) | Yes |
| `findFreePort(start, end, opts?)` | Find available port in range | Yes |
| `isRemotePortAvailable(target, opts?)` | TCP port check on remote host | Yes |
| `getGateways()` | List all network interfaces | No |
| `getDefaultGateway()` | Get default gateway info | Yes |
| `getPublicIps()` | Discover public IPv4/IPv6 (4 fallback services) | No |
| `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
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
+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();
+60 -18
View File
@@ -8,6 +8,24 @@ tap.test('should create a SmartNetwork instance', async () => {
expect(testSmartNetwork).toBeInstanceOf(smartnetwork.SmartNetwork);
});
tap.test('should get IP intelligence for 8.8.8.8 (Google)', async () => {
const result = await testSmartNetwork.getIpIntelligence('8.8.8.8');
console.log('IP Intelligence for 8.8.8.8:', JSON.stringify(result, null, 2));
// Google's ASN is 15169
expect(result.asn).toEqual(15169);
expect(result.asnOrg).toBeTruthy();
// Geolocation
expect(result.countryCode).toEqual('US');
expect(result.latitude).not.toBeNull();
expect(result.longitude).not.toBeNull();
// RDAP registration
expect(result.registrantOrg).toBeTruthy();
expect(result.networkRange).toBeTruthy();
});
tap.test('should get IP intelligence for 1.1.1.1 (Cloudflare)', async () => {
const result = await testSmartNetwork.getIpIntelligence('1.1.1.1');
console.log('IP Intelligence for 1.1.1.1:', JSON.stringify(result, null, 2));
@@ -16,24 +34,42 @@ tap.test('should get IP intelligence for 1.1.1.1 (Cloudflare)', async () => {
expect(result.asn).toEqual(13335);
expect(result.asnOrg).toBeTruthy();
// Geolocation should be present
expect(result.country).toBeTruthy();
expect(result.countryCode).toBeTruthy();
expect(result.latitude).not.toBeNull();
expect(result.longitude).not.toBeNull();
// RDAP registration data should be present
expect(result.networkRange).toBeTruthy();
expect(result.registrantCountry).toBeTruthy();
// Note: 1.1.1.1 is anycast — city-level geo may be null in GeoLite2
});
tap.test('should get IP intelligence for 8.8.8.8 (Google)', async () => {
const result = await testSmartNetwork.getIpIntelligence('8.8.8.8');
console.log('IP Intelligence for 8.8.8.8:', JSON.stringify(result, null, 2));
tap.test('should derive a single CIDR from RDAP start/end ranges', async () => {
const intelligence = new smartnetwork.IpIntelligence();
const result = (intelligence as any).parseRdapNetworkInfo({
startAddress: '203.0.113.0',
endAddress: '203.0.113.255',
});
// Google's ASN is 15169
expect(result.asn).toEqual(15169);
expect(result.country).toBeTruthy();
expect(result.countryCode).toBeTruthy();
expect(result).toEqual({
networkRange: '203.0.113.0/24',
networkCidrs: ['203.0.113.0/24'],
});
});
tap.test('should expose CIDRs for RDAP ranges that need multiple prefixes', async () => {
const intelligence = new smartnetwork.IpIntelligence();
const result = (intelligence as any).parseRdapNetworkInfo({
startAddress: '203.0.113.5',
endAddress: '203.0.113.10',
});
expect(result).toEqual({
networkRange: '203.0.113.5 - 203.0.113.10',
networkCidrs: [
'203.0.113.5/32',
'203.0.113.6/31',
'203.0.113.8/31',
'203.0.113.10/32',
],
});
});
tap.test('should get IP intelligence for own public IP', async () => {
@@ -42,7 +78,7 @@ tap.test('should get IP intelligence for own public IP', async () => {
const result = await testSmartNetwork.getIpIntelligence(ips.v4);
console.log(`IP Intelligence for own IP (${ips.v4}):`, JSON.stringify(result, null, 2));
expect(result.asn).toBeTypeofNumber();
expect(result.country).toBeTruthy();
expect(result.countryCode).toBeTruthy();
}
});
@@ -55,12 +91,18 @@ tap.test('should handle invalid IP gracefully', async () => {
tap.test('should use cache when cacheTtl is set', async () => {
const cached = new smartnetwork.SmartNetwork({ cacheTtl: 60000 });
const r1 = await cached.getIpIntelligence('1.1.1.1');
const r2 = await cached.getIpIntelligence('1.1.1.1');
const r1 = await cached.getIpIntelligence('8.8.8.8');
const r2 = await cached.getIpIntelligence('8.8.8.8');
// Second call should return the same cached result
expect(r1.asn).toEqual(r2.asn);
expect(r1.country).toEqual(r2.country);
expect(r1.city).toEqual(r2.city);
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.0',
version: '4.7.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;
}
}
+147 -25
View File
@@ -2,7 +2,21 @@ import * as plugins from './smartnetwork.plugins.js';
import { getLogger } from './logging.js';
// MaxMind types re-exported from mmdb-lib via maxmind
import type { CityResponse, AsnResponse, Reader } from 'maxmind';
import type { AsnResponse, Reader, Response } from 'maxmind';
/**
* The @ip-location-db MMDB files use a flat schema instead of the standard MaxMind nested format.
*/
interface IIpLocationDbCityRecord {
city?: string;
country_code?: string;
latitude?: number;
longitude?: number;
postcode?: string;
state1?: string;
state2?: string;
timezone?: string;
}
/**
* Unified result from all IP intelligence layers
@@ -16,6 +30,7 @@ export interface IIpIntelligenceResult {
registrantOrg: string | null;
registrantCountry: string | null;
networkRange: string | null;
networkCidrs: string[] | null;
abuseContact: string | null;
// Geolocation (MaxMind GeoLite2 City)
@@ -36,6 +51,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)
@@ -58,6 +80,11 @@ interface IBootstrapEntry {
baseUrl: string;
}
interface IRdapNetworkInfo {
networkRange: string | null;
networkCidrs: string[] | null;
}
/**
* IpIntelligence provides IP address intelligence by combining three data sources:
* - RDAP (direct to RIRs) for registration/org data
@@ -70,7 +97,7 @@ export class IpIntelligence {
private readonly timeout: number;
// MaxMind readers (lazily initialized)
private cityReader: Reader<CityResponse> | null = null;
private cityReader: Reader<IIpLocationDbCityRecord & Response> | null = null;
private asnReader: Reader<AsnResponse> | null = null;
private lastFetchTime = 0;
private refreshPromise: Promise<void> | null = null;
@@ -79,9 +106,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;
}
/**
@@ -95,6 +126,7 @@ export class IpIntelligence {
registrantOrg: null,
registrantCountry: null,
networkRange: null,
networkCidrs: null,
abuseContact: null,
country: null,
countryCode: null,
@@ -118,6 +150,7 @@ export class IpIntelligence {
result.registrantOrg = rdap.registrantOrg;
result.registrantCountry = rdap.registrantCountry;
result.networkRange = rdap.networkRange;
result.networkCidrs = rdap.networkCidrs;
result.abuseContact = rdap.abuseContact;
}
@@ -232,6 +265,7 @@ export class IpIntelligence {
registrantOrg: string | null;
registrantCountry: string | null;
networkRange: string | null;
networkCidrs: string[] | null;
abuseContact: string | null;
} | null> {
await this.ensureBootstrap();
@@ -255,14 +289,7 @@ export class IpIntelligence {
let registrantCountry: string | null = data.country || null;
let abuseContact: string | null = null;
// Parse network range
let networkRange: string | null = null;
if (data.cidr0_cidrs && data.cidr0_cidrs.length > 0) {
const cidr = data.cidr0_cidrs[0];
networkRange = `${cidr.v4prefix || cidr.v6prefix}/${cidr.length}`;
} else if (data.startAddress && data.endAddress) {
networkRange = `${data.startAddress} - ${data.endAddress}`;
}
const { networkRange, networkCidrs } = this.parseRdapNetworkInfo(data);
// Parse entities
if (data.entities && Array.isArray(data.entities)) {
@@ -295,7 +322,7 @@ export class IpIntelligence {
}
}
return { registrantOrg, registrantCountry, networkRange, abuseContact };
return { registrantOrg, registrantCountry, networkRange, networkCidrs, abuseContact };
} catch (err: any) {
this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
return null;
@@ -304,6 +331,40 @@ export class IpIntelligence {
}
}
private parseRdapNetworkInfo(data: any): IRdapNetworkInfo {
const cidrs = this.extractRdapCidrs(data);
if (cidrs.length > 0) {
return {
networkRange: cidrs[0],
networkCidrs: cidrs,
};
}
if (typeof data.startAddress === 'string' && typeof data.endAddress === 'string') {
const rangeCidrs = this.ipv4RangeToCidrs(data.startAddress, data.endAddress);
return {
networkRange: rangeCidrs.length === 1
? rangeCidrs[0]
: `${data.startAddress} - ${data.endAddress}`,
networkCidrs: rangeCidrs.length > 0 ? rangeCidrs : null,
};
}
return { networkRange: null, networkCidrs: null };
}
private extractRdapCidrs(data: any): string[] {
if (!Array.isArray(data.cidr0_cidrs)) return [];
return data.cidr0_cidrs
.map((cidr: any) => {
const prefix = cidr?.v4prefix || cidr?.v6prefix;
const length = Number(cidr?.length);
if (typeof prefix !== 'string' || !Number.isInteger(length)) return null;
return `${prefix}/${length}`;
})
.filter(Boolean) as string[];
}
/**
* Extract the 'fn' (formatted name) from an entity's vcardArray
*/
@@ -371,15 +432,19 @@ export class IpIntelligence {
* Response: "ASN | prefix | CC | rir | date"
*/
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
const external = this.sharedDnsClient !== null;
let dnsClient = this.sharedDnsClient;
try {
const reversed = ip.split('.').reverse().join('.');
const queryName = `${reversed}.origin.asn.cymru.com`;
const 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;
@@ -402,6 +467,11 @@ export class IpIntelligence {
} catch (err: any) {
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
return null;
} finally {
// Only destroy clients we created ourselves; leave injected ones alone.
if (!external && dnsClient) {
dnsClient.destroy();
}
}
}
@@ -442,7 +512,7 @@ export class IpIntelligence {
this.fetchBuffer(ASN_MMDB_URL),
]);
this.cityReader = new plugins.maxmind.Reader<CityResponse>(cityBuffer);
this.cityReader = new plugins.maxmind.Reader<IIpLocationDbCityRecord & Response>(cityBuffer);
this.asnReader = new plugins.maxmind.Reader<AsnResponse>(asnBuffer);
this.lastFetchTime = Date.now();
this.logger.info?.('MaxMind MMDB databases loaded into memory');
@@ -495,17 +565,17 @@ export class IpIntelligence {
let asn: number | null = null;
let asnOrg: string | null = null;
// City lookup
// City lookup — @ip-location-db uses flat schema: city, country_code, latitude, longitude, etc.
try {
const cityResult = this.cityReader.get(ip);
if (cityResult) {
country = cityResult.country?.names?.en || null;
countryCode = cityResult.country?.iso_code || null;
city = cityResult.city?.names?.en || null;
latitude = cityResult.location?.latitude ?? null;
longitude = cityResult.location?.longitude ?? null;
accuracyRadius = cityResult.location?.accuracy_radius ?? null;
timezone = cityResult.location?.time_zone || null;
countryCode = cityResult.country_code || null;
city = cityResult.city || null;
latitude = cityResult.latitude ?? null;
longitude = cityResult.longitude ?? null;
timezone = cityResult.timezone || null;
// @ip-location-db does not include country name or accuracy_radius
// We leave country null (countryCode is available)
}
} catch (err: any) {
this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`);
@@ -539,4 +609,56 @@ export class IpIntelligence {
parseInt(parts[3], 10)) >>> 0
);
}
private ipv4RangeToCidrs(startIp: string, endIp: string): string[] {
const start = this.ipv4ToBigInt(startIp);
const end = this.ipv4ToBigInt(endIp);
if (start === undefined || end === undefined || start > end) return [];
const cidrs: string[] = [];
let current = start;
while (current <= end) {
let maxBlockSize = current === 0n ? 1n << 32n : current & -current;
const remaining = end - current + 1n;
while (maxBlockSize > remaining) {
maxBlockSize = maxBlockSize / 2n;
}
const prefixLength = 32 - this.powerOfTwoExponent(maxBlockSize);
cidrs.push(`${this.numberToIpv4(current)}/${prefixLength}`);
current += maxBlockSize;
}
return cidrs;
}
private ipv4ToBigInt(ip: string): bigint | undefined {
const parts = ip.trim().split('.');
if (parts.length !== 4) return undefined;
let result = 0n;
for (const part of parts) {
if (!/^\d+$/.test(part)) return undefined;
const number = Number(part);
if (!Number.isInteger(number) || number < 0 || number > 255) return undefined;
result = (result * 256n) + BigInt(number);
}
return result;
}
private numberToIpv4(value: bigint): string {
return [
Number((value >> 24n) & 255n),
Number((value >> 16n) & 255n),
Number((value >> 8n) & 255n),
Number(value & 255n),
].join('.');
}
private powerOfTwoExponent(value: bigint): number {
let exponent = 0;
let remaining = value;
while (remaining > 1n) {
remaining >>= 1n;
exponent++;
}
return exponent;
}
}
+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
*/