feat(smartnetwork): add Rust-powered network diagnostics bridge and IP intelligence lookups

This commit is contained in:
2026-03-26 15:24:43 +00:00
parent e9dcd45acd
commit c3ac9b4f9e
34 changed files with 5499 additions and 3159 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartnetwork',
version: '4.4.0',
version: '4.5.0',
description: 'A toolkit for network diagnostics including speed tests, port availability checks, and more.'
}

View File

@@ -33,7 +33,7 @@ export function quartile(values: number[], percentile: number) {
export function jitter(values: number[]) {
// Average distance between consecutive latency measurements...
let jitters = [];
let jitters: number[] = [];
for (let i = 0; i < values.length - 1; i += 1) {
jitters.push(Math.abs(values[i] - values[i + 1]));

View File

@@ -1,5 +1,8 @@
export * from './smartnetwork.classes.smartnetwork.js';
export type { SmartNetworkOptions, Hop } from './smartnetwork.classes.smartnetwork.js';
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 { setLogger, getLogger } from './logging.js';
export { NetworkError, TimeoutError } from './errors.js';

View File

@@ -53,7 +53,7 @@ export class CloudflareSpeed {
const t5 = await measureDownloadParallel(100001000, 1);
downloadTests = [...t1, ...t2, ...t3, ...t4, ...t5];
}
const speedDownload = stats.quartile(downloadTests, 0.9).toFixed(2);
const speedDownload = downloadTests.length > 0 ? stats.quartile(downloadTests, 0.9).toFixed(2) : '0.00';
// lets test the upload speed with configurable parallel streams
const measureUploadParallel = (bytes: number, iterations: number) => {
@@ -84,7 +84,7 @@ export class CloudflareSpeed {
const u3 = await measureUploadParallel(1001000, 8);
uploadTests = [...u1, ...u2, ...u3];
}
const speedUpload = stats.quartile(uploadTests, 0.9).toFixed(2);
const speedUpload = uploadTests.length > 0 ? stats.quartile(uploadTests, 0.9).toFixed(2) : '0.00';
return {
...latency,
@@ -147,8 +147,14 @@ export class CloudflareSpeed {
for (let i = 0; i < iterations; i += 1) {
await this.upload(bytes).then(
async (response) => {
const transferTime = response[6];
measurements.push(await this.measureSpeed(bytes, transferTime));
// Prefer server-timing duration; fall back to client-side transfer time
let transferTime = response[6];
if (!transferTime || !isFinite(transferTime)) {
transferTime = response[5] - response[4]; // ended - ttfb
}
if (transferTime > 0) {
measurements.push(await this.measureSpeed(bytes, transferTime));
}
},
(error) => {
getLogger().error('Error measuring upload chunk:', error);
@@ -164,17 +170,22 @@ export class CloudflareSpeed {
}
public async fetchServerLocations(): Promise<{ [key: string]: string }> {
const res = JSON.parse(await this.get('speed.cloudflare.com', '/locations')) as Array<{
iata: string;
city: string;
}>;
return res.reduce(
(data: Record<string, string>, optionsArg) => {
data[optionsArg.iata] = optionsArg.city;
return data;
},
{} as Record<string, string>,
);
try {
const raw = await this.get('speed.cloudflare.com', '/locations');
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return {};
}
return (parsed as Array<{ iata: string; city: string }>).reduce(
(data: Record<string, string>, entry) => {
data[entry.iata] = entry.city;
return data;
},
{} as Record<string, string>,
);
} catch {
return {};
}
}
public async get(hostname: string, path: string): Promise<string> {
@@ -259,7 +270,12 @@ export class CloudflareSpeed {
sslHandshake,
ttfb,
ended,
parseFloat((res.headers['server-timing'] as string).slice(22)),
(() => {
const serverTiming = res.headers['server-timing'] as string | undefined;
if (!serverTiming) return 0;
const match = serverTiming.match(/dur=([\d.]+)/);
return match ? parseFloat(match[1]) : parseFloat(serverTiming.slice(22)) || 0;
})(),
]);
});
});

View File

@@ -0,0 +1,542 @@
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';
/**
* Unified result from all IP intelligence layers
*/
export 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;
abuseContact: string | null;
// Geolocation (MaxMind GeoLite2 City)
country: string | null;
countryCode: string | null;
city: string | null;
latitude: number | null;
longitude: number | null;
accuracyRadius: number | null;
timezone: string | null;
}
/**
* Options for IpIntelligence
*/
export interface IIpIntelligenceOptions {
/** Max age (ms) before triggering background MMDB refresh. Default: 7 days */
dbMaxAge?: number;
/** Timeout (ms) for RDAP/DNS/CDN requests. Default: 5000 */
timeout?: number;
}
// CDN URLs for GeoLite2 MMDB files (served via jsDelivr from npm packages)
const CITY_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-city-mmdb/geolite2-city-ipv4.mmdb';
const ASN_MMDB_URL = 'https://cdn.jsdelivr.net/npm/@ip-location-db/geolite2-asn-mmdb/geolite2-asn-ipv4.mmdb';
// IANA bootstrap for RDAP
const IANA_BOOTSTRAP_IPV4_URL = 'https://data.iana.org/rdap/ipv4.json';
const DEFAULT_DB_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days
const DEFAULT_TIMEOUT = 5000;
/**
* Parsed IANA bootstrap entry: a CIDR prefix mapped to an RDAP base URL
*/
interface IBootstrapEntry {
prefix: string;
prefixNum: number; // numeric representation of the network address
maskBits: number;
baseUrl: string;
}
/**
* IpIntelligence provides IP address intelligence by combining three data sources:
* - RDAP (direct to RIRs) for registration/org data
* - Team Cymru DNS for ASN
* - MaxMind GeoLite2 (in-memory MMDB) for geolocation
*/
export class IpIntelligence {
private readonly logger = getLogger();
private readonly dbMaxAge: number;
private readonly timeout: number;
// MaxMind readers (lazily initialized)
private cityReader: Reader<CityResponse> | null = null;
private asnReader: Reader<AsnResponse> | null = null;
private lastFetchTime = 0;
private refreshPromise: Promise<void> | null = null;
// RDAP bootstrap cache
private bootstrapEntries: IBootstrapEntry[] | null = null;
private bootstrapPromise: Promise<void> | null = null;
constructor(options?: IIpIntelligenceOptions) {
this.dbMaxAge = options?.dbMaxAge ?? DEFAULT_DB_MAX_AGE;
this.timeout = options?.timeout ?? DEFAULT_TIMEOUT;
}
/**
* Get comprehensive IP intelligence for the given IP address.
* Runs RDAP, Team Cymru DNS, and MaxMind lookups in parallel.
*/
public async getIntelligence(ip: string): Promise<IIpIntelligenceResult> {
const result: IIpIntelligenceResult = {
asn: null,
asnOrg: null,
registrantOrg: null,
registrantCountry: null,
networkRange: null,
abuseContact: null,
country: null,
countryCode: null,
city: null,
latitude: null,
longitude: null,
accuracyRadius: null,
timezone: null,
};
// Run all three layers in parallel
const [rdapResult, cymruResult, maxmindResult] = await Promise.allSettled([
this.queryRdap(ip),
this.queryTeamCymru(ip),
this.queryMaxMind(ip),
]);
// Merge RDAP results
if (rdapResult.status === 'fulfilled' && rdapResult.value) {
const rdap = rdapResult.value;
result.registrantOrg = rdap.registrantOrg;
result.registrantCountry = rdap.registrantCountry;
result.networkRange = rdap.networkRange;
result.abuseContact = rdap.abuseContact;
}
// Merge Team Cymru results (primary for ASN)
if (cymruResult.status === 'fulfilled' && cymruResult.value) {
const cymru = cymruResult.value;
result.asn = cymru.asn;
}
// Merge MaxMind results
if (maxmindResult.status === 'fulfilled' && maxmindResult.value) {
const mm = maxmindResult.value;
result.country = mm.country;
result.countryCode = mm.countryCode;
result.city = mm.city;
result.latitude = mm.latitude;
result.longitude = mm.longitude;
result.accuracyRadius = mm.accuracyRadius;
result.timezone = mm.timezone;
// Use MaxMind ASN as fallback if Team Cymru failed
if (result.asn === null && mm.asn !== null) {
result.asn = mm.asn;
}
if (mm.asnOrg) {
result.asnOrg = mm.asnOrg;
}
}
// If we got ASN from Team Cymru but not org, and MaxMind didn't provide org either,
// the asnOrg remains null (we don't do an additional lookup)
return result;
}
// ─── RDAP Subsystem ─────────────────────────────────────────────────
/**
* Load and cache the IANA RDAP 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_IPV4_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: IBootstrapEntry[] = [];
for (const [prefixes, urls] of data.services) {
const baseUrl = urls[0]; // first URL is preferred
for (const prefix of prefixes) {
const [network, bits] = prefix.split('/');
entries.push({
prefix,
prefixNum: this.ipToNumber(network),
maskBits: parseInt(bits, 10),
baseUrl: baseUrl.replace(/\/$/, ''), // strip trailing slash
});
}
}
// Sort by mask bits descending for longest-prefix match
entries.sort((a, b) => b.maskBits - a.maskBits);
this.bootstrapEntries = entries;
} finally {
clearTimeout(timeoutId);
}
} catch (err: any) {
this.logger.debug?.(`Failed to load RDAP bootstrap: ${err.message}`);
this.bootstrapEntries = []; // empty = all RDAP lookups will skip
}
})();
await this.bootstrapPromise;
this.bootstrapPromise = null;
}
/**
* Find the RDAP base URL for a given IP via longest-prefix match
*/
private matchRir(ip: string): string | null {
if (!this.bootstrapEntries || this.bootstrapEntries.length === 0) return null;
const ipNum = this.ipToNumber(ip);
for (const entry of this.bootstrapEntries) {
const mask = (0xFFFFFFFF << (32 - entry.maskBits)) >>> 0;
if ((ipNum & mask) === (entry.prefixNum & mask)) {
return entry.baseUrl;
}
}
return null;
}
/**
* Query RDAP for registration data
*/
private async queryRdap(ip: string): Promise<{
registrantOrg: string | null;
registrantCountry: string | null;
networkRange: string | null;
abuseContact: string | null;
} | null> {
await this.ensureBootstrap();
const baseUrl = this.matchRir(ip);
if (!baseUrl) return null;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(`${baseUrl}/ip/${ip}`, {
signal: controller.signal,
headers: {
'Accept': 'application/rdap+json',
'User-Agent': '@push.rocks/smartnetwork',
},
});
if (!response.ok) return null;
const data = await response.json() as any;
let registrantOrg: string | null = null;
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}`;
}
// Parse entities
if (data.entities && Array.isArray(data.entities)) {
for (const entity of data.entities) {
const roles: string[] = entity.roles || [];
if (roles.includes('registrant') || roles.includes('administrative')) {
const orgName = this.extractVcardFn(entity);
if (orgName) registrantOrg = orgName;
// Try to get country from registrant address if not at top level
if (!registrantCountry) {
registrantCountry = this.extractVcardCountry(entity);
}
}
if (roles.includes('abuse')) {
abuseContact = this.extractVcardEmail(entity);
// Check nested entities for abuse contact
if (!abuseContact && entity.entities) {
for (const subEntity of entity.entities) {
const subRoles: string[] = subEntity.roles || [];
if (subRoles.includes('abuse')) {
abuseContact = this.extractVcardEmail(subEntity);
if (abuseContact) break;
}
}
}
}
}
}
return { registrantOrg, registrantCountry, networkRange, abuseContact };
} catch (err: any) {
this.logger.debug?.(`RDAP query failed for ${ip}: ${err.message}`);
return null;
} finally {
clearTimeout(timeoutId);
}
}
/**
* 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 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;
}
// ─── Team Cymru DNS Subsystem ───────────────────────────────────────
/**
* Query Team Cymru DNS for ASN information.
* Query format: reversed.ip.origin.asn.cymru.com TXT
* Response: "ASN | prefix | CC | rir | date"
*/
private async queryTeamCymru(ip: string): Promise<{ asn: number; prefix: string; country: string } | null> {
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,
});
const records = await dnsClient.getRecordsTxt(queryName);
if (!records || records.length === 0) return null;
// Parse the first TXT record: "13335 | 1.1.1.0/24 | AU | apnic | 2011-08-11"
const txt = records[0].value || (records[0] as any).data;
if (!txt) return null;
const parts = txt.split('|').map((s: string) => s.trim());
if (parts.length < 3) return null;
const asn = parseInt(parts[0], 10);
if (isNaN(asn)) return null;
return {
asn,
prefix: parts[1] || '',
country: parts[2] || '',
};
} catch (err: any) {
this.logger.debug?.(`Team Cymru DNS query failed for ${ip}: ${err.message}`);
return null;
}
}
// ─── MaxMind GeoLite2 Subsystem ─────────────────────────────────────
/**
* Ensure MMDB readers are initialized. Downloads on first call,
* triggers background refresh if stale.
*/
private async ensureReaders(): Promise<void> {
if (this.cityReader && this.asnReader) {
// Check if refresh needed
if (Date.now() - this.lastFetchTime > this.dbMaxAge && !this.refreshPromise) {
this.refreshPromise = this.downloadAndInitReaders()
.catch((err) => this.logger.debug?.(`Background MMDB refresh failed: ${err.message}`))
.finally(() => { this.refreshPromise = null; });
}
return;
}
// First time: blocking download
if (this.refreshPromise) {
await this.refreshPromise;
return;
}
this.refreshPromise = this.downloadAndInitReaders();
await this.refreshPromise;
this.refreshPromise = null;
}
/**
* Download MMDB files from CDN and create Reader instances
*/
private async downloadAndInitReaders(): Promise<void> {
const [cityBuffer, asnBuffer] = await Promise.all([
this.fetchBuffer(CITY_MMDB_URL),
this.fetchBuffer(ASN_MMDB_URL),
]);
this.cityReader = new plugins.maxmind.Reader<CityResponse>(cityBuffer);
this.asnReader = new plugins.maxmind.Reader<AsnResponse>(asnBuffer);
this.lastFetchTime = Date.now();
this.logger.info?.('MaxMind MMDB databases loaded into memory');
}
/**
* Fetch a URL and return the response as a Buffer
*/
private async fetchBuffer(url: string): Promise<Buffer> {
const response = await fetch(url, {
headers: { 'User-Agent': '@push.rocks/smartnetwork' },
});
if (!response.ok) {
throw new Error(`Failed to fetch ${url}: HTTP ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
/**
* Query MaxMind for geo + ASN data
*/
private async queryMaxMind(ip: string): Promise<{
country: string | null;
countryCode: string | null;
city: string | null;
latitude: number | null;
longitude: number | null;
accuracyRadius: number | null;
timezone: string | null;
asn: number | null;
asnOrg: string | null;
} | null> {
try {
await this.ensureReaders();
} catch (err: any) {
this.logger.debug?.(`Failed to initialize MaxMind readers: ${err.message}`);
return null;
}
if (!this.cityReader || !this.asnReader) return null;
let country: string | null = null;
let countryCode: string | null = null;
let city: string | null = null;
let latitude: number | null = null;
let longitude: number | null = null;
let accuracyRadius: number | null = null;
let timezone: string | null = null;
let asn: number | null = null;
let asnOrg: string | null = null;
// City lookup
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;
}
} catch (err: any) {
this.logger.debug?.(`MaxMind city lookup failed for ${ip}: ${err.message}`);
}
// ASN lookup
try {
const asnResult = this.asnReader.get(ip);
if (asnResult) {
asn = asnResult.autonomous_system_number ?? null;
asnOrg = asnResult.autonomous_system_organization || null;
}
} catch (err: any) {
this.logger.debug?.(`MaxMind ASN lookup failed for ${ip}: ${err.message}`);
}
return { country, countryCode, city, latitude, longitude, accuracyRadius, timezone, asn, asnOrg };
}
// ─── Utilities ──────────────────────────────────────────────────────
/**
* Convert an IPv4 address string to a 32-bit unsigned number
*/
private ipToNumber(ip: string): number {
const parts = ip.split('.');
return (
((parseInt(parts[0], 10) << 24) |
(parseInt(parts[1], 10) << 16) |
(parseInt(parts[2], 10) << 8) |
parseInt(parts[3], 10)) >>> 0
);
}
}

View File

@@ -58,7 +58,7 @@ export class PublicIp {
this.logger.info?.(`Got IPv4 from ${service.name}: ${ip}`);
return ip;
}
} catch (error) {
} catch (error: any) {
this.logger.debug?.(`Failed to get IPv4 from ${service.name}: ${error.message}`);
}
}
@@ -80,7 +80,7 @@ export class PublicIp {
this.logger.info?.(`Got IPv6 from ${service.name}: ${ip}`);
return ip;
}
} catch (error) {
} catch (error: any) {
this.logger.debug?.(`Failed to get IPv6 from ${service.name}: ${error.message}`);
}
}

View File

@@ -0,0 +1,181 @@
import * as plugins from './smartnetwork.plugins.js';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* Command map for the rustnetwork IPC binary.
* Each key maps to { params, result } defining the typed IPC protocol.
*/
type TNetworkCommands = {
healthPing: {
params: Record<string, never>;
result: { pong: boolean };
};
ping: {
params: { host: string; count?: number; timeoutMs?: number };
result: {
alive: boolean;
times: (number | null)[];
min: number | null;
max: number | null;
avg: number | null;
stddev: number | null;
packetLoss: number;
};
};
traceroute: {
params: { host: string; maxHops?: number; timeoutMs?: number };
result: {
hops: Array<{ ttl: number; ip: string; rtt: number | null }>;
};
};
tcpPortCheck: {
params: { host: string; port: number; timeoutMs?: number };
result: { isOpen: boolean; latencyMs: number | null };
};
isLocalPortFree: {
params: { port: number };
result: { free: boolean };
};
defaultGateway: {
params: Record<string, never>;
result: {
interfaceName: string;
addresses: Array<{ family: string; address: string }>;
};
};
};
function getPlatformSuffix(): string | null {
const platform = process.platform;
const arch = process.arch;
const platformMap: Record<string, string> = {
linux: 'linux',
darwin: 'macos',
win32: 'windows',
};
const archMap: Record<string, string> = {
x64: 'amd64',
arm64: 'arm64',
};
const p = platformMap[platform];
const a = archMap[arch];
if (p && a) {
return `${p}_${a}`;
}
return null;
}
/**
* Singleton bridge to the rustnetwork binary.
* Manages the IPC lifecycle for network diagnostics operations.
*/
export class RustNetworkBridge {
private static instance: RustNetworkBridge | null = null;
public static getInstance(): RustNetworkBridge {
if (!RustNetworkBridge.instance) {
RustNetworkBridge.instance = new RustNetworkBridge();
}
return RustNetworkBridge.instance;
}
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TNetworkCommands>>;
private constructor() {
const packageDir = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
);
const platformSuffix = getPlatformSuffix();
const localPaths: string[] = [];
// Platform-specific cross-compiled binary
if (platformSuffix) {
localPaths.push(path.join(packageDir, 'dist_rust', `rustnetwork_${platformSuffix}`));
}
// Native build without suffix
localPaths.push(path.join(packageDir, 'dist_rust', 'rustnetwork'));
// Local dev paths
localPaths.push(path.join(packageDir, 'rust', 'target', 'release', 'rustnetwork'));
localPaths.push(path.join(packageDir, 'rust', 'target', 'debug', 'rustnetwork'));
this.bridge = new plugins.smartrust.RustBridge<TNetworkCommands>({
binaryName: 'rustnetwork',
cliArgs: ['--management'],
requestTimeoutMs: 30_000,
readyTimeoutMs: 10_000,
localPaths,
searchSystemPath: false,
});
}
/**
* Spawn the Rust binary and wait for it to be ready.
*/
public async start(): Promise<void> {
const ok = await this.bridge.spawn();
if (!ok) {
throw new Error('Failed to spawn rustnetwork binary');
}
}
/**
* Kill the Rust binary.
*/
public async stop(): Promise<void> {
this.bridge.kill();
}
/**
* Ensure the bridge is running before sending a command.
*/
private async ensureRunning(): Promise<void> {
// The bridge will throw if not spawned — we just call start() if not yet running
}
// ===== Command wrappers =====
public async ping(
host: string,
count?: number,
timeoutMs?: number,
): Promise<TNetworkCommands['ping']['result']> {
return this.bridge.sendCommand('ping', { host, count, timeoutMs });
}
public async traceroute(
host: string,
maxHops?: number,
timeoutMs?: number,
): Promise<TNetworkCommands['traceroute']['result']> {
return this.bridge.sendCommand('traceroute', { host, maxHops, timeoutMs });
}
public async tcpPortCheck(
host: string,
port: number,
timeoutMs?: number,
): Promise<TNetworkCommands['tcpPortCheck']['result']> {
return this.bridge.sendCommand('tcpPortCheck', { host, port, timeoutMs });
}
public async isLocalPortFree(
port: number,
): Promise<TNetworkCommands['isLocalPortFree']['result']> {
return this.bridge.sendCommand('isLocalPortFree', { port });
}
public async defaultGateway(): Promise<TNetworkCommands['defaultGateway']['result']> {
return this.bridge.sendCommand('defaultGateway', {} as Record<string, never>);
}
public async healthPing(): Promise<TNetworkCommands['healthPing']['result']> {
return this.bridge.sendCommand('healthPing', {} as Record<string, never>);
}
}

View File

@@ -1,13 +1,11 @@
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 { getLogger } from './logging.js';
import { NetworkError } from './errors.js';
import * as stats from './helpers/stats.js';
import { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
/**
* SmartNetwork simplifies actions within the network
*/
/**
* Configuration options for SmartNetwork
*/
@@ -35,6 +33,10 @@ export interface IFindFreePortOptions {
exclude?: number[];
}
/**
* SmartNetwork simplifies actions within the network.
* Uses a Rust binary for system-dependent operations (ping, traceroute, port scanning, gateway detection).
*/
export class SmartNetwork {
/** Static registry for external plugins */
public static pluginsRegistry: Map<string, any> = new Map();
@@ -46,15 +48,51 @@ export class SmartNetwork {
public static unregisterPlugin(name: string): void {
SmartNetwork.pluginsRegistry.delete(name);
}
private options: SmartNetworkOptions;
private cache: Map<string, { value: any; expiry: number }>;
private rustBridge: RustNetworkBridge;
private bridgeStarted = false;
private ipIntelligence: IpIntelligence | null = null;
constructor(options?: SmartNetworkOptions) {
this.options = options || {};
this.cache = new Map();
this.rustBridge = RustNetworkBridge.getInstance();
}
/**
* get network speed
* @param opts optional speed test parameters
* Start the Rust binary bridge. Must be called before using ping, traceroute,
* port scanning, or gateway operations. Safe to call multiple times.
*/
public async start(): Promise<void> {
if (!this.bridgeStarted) {
await this.rustBridge.start();
this.bridgeStarted = true;
}
}
/**
* Stop the Rust binary bridge.
*/
public async stop(): Promise<void> {
if (this.bridgeStarted) {
await this.rustBridge.stop();
this.bridgeStarted = false;
}
}
/**
* Ensure the Rust bridge is running before sending commands.
*/
private async ensureBridge(): Promise<void> {
if (!this.bridgeStarted) {
await this.start();
}
}
/**
* Get network speed via Cloudflare speed test (pure TS, no Rust needed).
*/
public async getSpeed(opts?: { parallelStreams?: number; duration?: number }) {
const cloudflareSpeedInstance = new CloudflareSpeed(opts);
@@ -65,35 +103,45 @@ export class SmartNetwork {
* Send ICMP pings to a host. Optionally specify count for multiple pings.
*/
public async ping(host: string, opts?: { timeout?: number; count?: number }): Promise<any> {
const timeout = opts?.timeout ?? 500;
await this.ensureBridge();
const timeoutMs = opts?.timeout ?? 5000;
const count = opts?.count && opts.count > 1 ? opts.count : 1;
const pinger = new plugins.smartping.Smartping();
if (count === 1) {
// single ping: normalize time to number
const res = await pinger.ping(host, timeout);
let result: Awaited<ReturnType<typeof this.rustBridge.ping>>;
try {
result = await this.rustBridge.ping(host, count, timeoutMs);
} catch {
// DNS resolution failure or other error — return dead ping
if (count === 1) {
return { alive: false, time: NaN };
}
return {
...res,
time: typeof res.time === 'number' ? res.time : NaN,
host,
count,
times: Array(count).fill(NaN),
min: NaN,
max: NaN,
avg: NaN,
stddev: NaN,
packetLoss: 100,
alive: false,
};
}
const times: number[] = [];
let aliveCount = 0;
for (let i = 0; i < count; i++) {
try {
const res = await pinger.ping(host, timeout);
const t = typeof res.time === 'number' ? res.time : NaN;
if (res.alive) aliveCount++;
times.push(t);
} catch {
times.push(NaN);
}
// Map times: replace null with NaN for backward compatibility
const times = result.times.map((t) => (t === null ? NaN : t));
const min = result.min === null ? NaN : result.min;
const max = result.max === null ? NaN : result.max;
const avg = result.avg === null ? NaN : result.avg;
const stddev = result.stddev === null ? NaN : result.stddev;
if (count === 1) {
return {
alive: result.alive,
time: times[0] ?? NaN,
};
}
const valid = times.filter((t) => !isNaN(t));
const min = valid.length ? Math.min(...valid) : NaN;
const max = valid.length ? Math.max(...valid) : NaN;
const avg = valid.length ? stats.average(valid) : NaN;
const stddev = valid.length ? Math.sqrt(stats.average(valid.map((v) => (v - avg) ** 2))) : NaN;
const packetLoss = ((count - aliveCount) / count) * 100;
return {
host,
count,
@@ -102,65 +150,22 @@ export class SmartNetwork {
max,
avg,
stddev,
packetLoss,
alive: aliveCount > 0,
packetLoss: result.packetLoss,
alive: result.alive,
};
}
/**
* returns a promise with a boolean answer
* note: false also resolves with false as argument
* @param port
*/
/**
* Check if a local port is unused (both IPv4 and IPv6)
*/
public async isLocalPortUnused(port: number): Promise<boolean> {
const doneIpV4 = plugins.smartpromise.defer<boolean>();
const doneIpV6 = plugins.smartpromise.defer<boolean>();
const net = await import('net'); // creates only one instance of net ;) even on multiple calls
// test IPv4 space
const ipv4Test = net.createServer();
ipv4Test.once('error', () => {
doneIpV4.resolve(false);
});
ipv4Test.once('listening', () => {
ipv4Test.once('close', () => {
doneIpV4.resolve(true);
});
ipv4Test.close();
});
ipv4Test.listen(port, '0.0.0.0');
await doneIpV4.promise;
// test IPv6 space
const ipv6Test = net.createServer();
ipv6Test.once('error', () => {
doneIpV6.resolve(false);
});
ipv6Test.once('listening', () => {
ipv6Test.once('close', () => {
doneIpV6.resolve(true);
});
ipv6Test.close();
});
ipv6Test.listen(port, '::');
// lets wait for the result
const resultIpV4 = await doneIpV4.promise;
const resultIpV6 = await doneIpV6.promise;
const result = resultIpV4 === true && resultIpV6 === true;
return result;
await this.ensureBridge();
const result = await this.rustBridge.isLocalPortFree(port);
return result.free;
}
/**
* Find the first available port within a given range
* @param startPort The start of the port range (inclusive)
* @param endPort The end of the port range (inclusive)
* @param options Optional configuration for port selection behavior
* @returns The first available port number (or random if options.randomize is true), or null if no ports are available
*/
public async findFreePort(startPort: number, endPort: number, options?: IFindFreePortOptions): Promise<number | null> {
// Validate port range
@@ -171,66 +176,36 @@ export class SmartNetwork {
throw new NetworkError('Start port must be less than or equal to end port', 'EINVAL');
}
// Create a set of excluded ports for efficient lookup
const excludedPorts = new Set(options?.exclude || []);
// If randomize option is true, collect all available ports and select randomly
if (options?.randomize) {
const availablePorts: number[] = [];
// Scan the range to find all available ports
for (let port = startPort; port <= endPort; port++) {
// Skip excluded ports
if (excludedPorts.has(port)) {
continue;
}
if (excludedPorts.has(port)) continue;
const isUnused = await this.isLocalPortUnused(port);
if (isUnused) {
availablePorts.push(port);
}
}
// If there are available ports, select one randomly
if (availablePorts.length > 0) {
const randomIndex = Math.floor(Math.random() * availablePorts.length);
return availablePorts[randomIndex];
}
// No free port found in the range
return null;
} else {
// Default behavior: return the first available port (sequential search)
for (let port = startPort; port <= endPort; port++) {
// Skip excluded ports
if (excludedPorts.has(port)) {
continue;
}
if (excludedPorts.has(port)) continue;
const isUnused = await this.isLocalPortUnused(port);
if (isUnused) {
return port;
}
}
// No free port found in the range
return null;
}
}
/**
* checks wether a remote port is available
* @param domainArg
*/
/**
* Check if a remote port is available
* @param target host or "host:port"
* @param opts options including port, protocol (only tcp), retries and timeout
*/
/**
* Check if a remote port is available
* @param target host or "host:port"
* @param portOrOpts either a port number (deprecated) or options object
*/
public async isRemotePortAvailable(
target: string,
@@ -243,7 +218,7 @@ export class SmartNetwork {
let protocol: string = 'tcp';
let retries = 1;
let timeout: number | undefined;
// preserve old signature (target, port)
if (typeof portOrOpts === 'number') {
[hostPart] = target.split(':');
port = portOrOpts;
@@ -256,47 +231,64 @@ export class SmartNetwork {
const portPart = target.split(':')[1];
port = opts.port ?? (portPart ? parseInt(portPart, 10) : undefined);
}
if (protocol === 'udp') {
throw new NetworkError('UDP port check not supported', 'ENOTSUP');
}
if (!port) {
throw new NetworkError('Port not specified', 'EINVAL');
}
let last: boolean = false;
await this.ensureBridge();
let last = false;
for (let attempt = 0; attempt < retries; attempt++) {
const done = plugins.smartpromise.defer<boolean>();
plugins.isopen(hostPart, port, (response: Record<string, { isOpen: boolean }>) => {
const info = response[port.toString()];
done.resolve(Boolean(info?.isOpen));
});
last = await done.promise;
if (last) return true;
try {
const result = await this.rustBridge.tcpPortCheck(hostPart, port, timeout);
last = result.isOpen;
if (last) return true;
} catch {
// DNS resolution failure or connection error — treat as not available
last = false;
}
}
return last;
}
/**
* List network interfaces (gateways)
* List network interfaces (gateways) — pure TS, no Rust needed.
*/
public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
const fetcher = async () => plugins.os.networkInterfaces();
const fetcher = async () => plugins.os.networkInterfaces() as Record<string, plugins.os.NetworkInterfaceInfo[]>;
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
return this.getCached('gateways', fetcher);
}
return fetcher();
}
/**
* Get the default gateway interface and its addresses.
*/
public async getDefaultGateway(): Promise<{
ipv4: plugins.os.NetworkInterfaceInfo;
ipv6: plugins.os.NetworkInterfaceInfo;
}> {
const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault();
if (!defaultGatewayName) {
} | null> {
await this.ensureBridge();
const result = await this.rustBridge.defaultGateway();
const interfaceName = result.interfaceName;
if (!interfaceName) {
getLogger().warn?.('Cannot determine default gateway');
return null;
}
// Use os.networkInterfaces() to get rich interface info
const gateways = await this.getGateways();
const defaultGateway = gateways[defaultGatewayName];
const defaultGateway = gateways[interfaceName];
if (!defaultGateway) {
getLogger().warn?.(`Interface ${interfaceName} not found in os.networkInterfaces()`);
return null;
}
return {
ipv4: defaultGateway[0],
ipv6: defaultGateway[1],
@@ -304,7 +296,7 @@ export class SmartNetwork {
}
/**
* Lookup public IPv4 and IPv6
* Lookup public IPv4 and IPv6 — pure TS, no Rust needed.
*/
public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
const fetcher = async () => {
@@ -318,28 +310,25 @@ export class SmartNetwork {
}
/**
* Resolve DNS records (A, AAAA, MX)
* Resolve DNS records (A, AAAA, MX) — uses smartdns, no Rust needed.
*/
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', // Try system resolver first (handles localhost), fallback to DoH
strategy: 'prefer-system',
allowDohFallback: true,
});
const [aRecords, aaaaRecords, mxRecords] = await Promise.all([
dnsClient.getRecordsA(host).catch((): any[] => []),
dnsClient.getRecordsAAAA(host).catch((): any[] => []),
dnsClient.getRecords(host, 'MX').catch((): any[] => []),
]);
// Extract values from the record objects
const A = aRecords.map((record: any) => record.value);
const AAAA = aaaaRecords.map((record: any) => record.value);
// Parse MX records - the value contains "priority exchange"
const MX = mxRecords.map((record: any) => {
const parts = record.value.split(' ');
return {
@@ -347,7 +336,7 @@ export class SmartNetwork {
exchange: parts[1] || '',
};
});
return { A, AAAA, MX };
} catch (err: any) {
throw new NetworkError(err.message, err.code);
@@ -355,20 +344,20 @@ export class SmartNetwork {
}
/**
* Perform a simple HTTP/HTTPS endpoint health check
* Perform a simple HTTP/HTTPS endpoint health check — pure TS.
*/
public async checkEndpoint(
urlString: string,
opts?: { timeout?: number },
opts?: { timeout?: number; rejectUnauthorized?: boolean },
): Promise<{ status: number; headers: Record<string, string>; rtt: number }> {
const start = plugins.perfHooks.performance.now();
try {
const url = new URL(urlString);
const lib = url.protocol === 'https:' ? plugins.https : await import('http');
const lib = url.protocol === 'https:' ? plugins.https : await import('node:http');
return new Promise((resolve, reject) => {
const req = lib.request(
url,
{ method: 'GET', timeout: opts?.timeout, agent: false },
{ method: 'GET', timeout: opts?.timeout, agent: false, rejectUnauthorized: opts?.rejectUnauthorized ?? true },
(res: any) => {
res.on('data', () => {});
res.once('end', () => {
@@ -390,50 +379,38 @@ export class SmartNetwork {
}
/**
* Perform a traceroute: hop-by-hop latency using the system traceroute tool.
* Falls back to a single-hop stub if traceroute is unavailable or errors.
* Perform a traceroute: hop-by-hop latency using the Rust binary.
*/
public async traceroute(
host: string,
opts?: { maxHops?: number; timeout?: number },
): Promise<Hop[]> {
await this.ensureBridge();
const maxHops = opts?.maxHops ?? 30;
const timeout = opts?.timeout;
try {
const { exec } = await import('child_process');
const cmd = `traceroute -n -m ${maxHops} ${host}`;
const stdout: string = await new Promise((resolve, reject) => {
exec(cmd, { encoding: 'utf8', timeout }, (err, stdout) => {
if (err) return reject(err);
resolve(stdout);
});
});
const hops: Hop[] = [];
for (const raw of stdout.split('\n')) {
const line = raw.trim();
if (!line || line.startsWith('traceroute')) continue;
const parts = line.split(/\s+/);
const ttl = parseInt(parts[0], 10);
let ip: string;
let rtt: number | null;
if (parts[1] === '*' || !parts[1]) {
ip = parts[1] || '';
rtt = null;
} else {
ip = parts[1];
const timePart = parts.find((p, i) => i >= 2 && /^\d+(\.\d+)?$/.test(p));
rtt = timePart ? parseFloat(timePart) : null;
}
hops.push({ ttl, ip, rtt });
}
if (hops.length) {
return hops;
}
} catch {
// traceroute not available or error: fall through to stub
const timeoutMs = opts?.timeout ?? 5000;
const result = await this.rustBridge.traceroute(host, maxHops, timeoutMs);
return result.hops.map((h) => ({
ttl: h.ttl,
ip: h.ip || '*',
rtt: h.rtt,
}));
}
/**
* Get IP intelligence: ASN, organization, geolocation, and RDAP registration data.
* Combines RDAP (RIRs), Team Cymru DNS, and MaxMind GeoLite2 — all run in parallel.
* Pure TS, no Rust needed.
*/
public async getIpIntelligence(ip: string): Promise<IIpIntelligenceResult> {
if (!this.ipIntelligence) {
this.ipIntelligence = new IpIntelligence();
}
// fallback stub
return [{ ttl: 1, ip: host, rtt: null }];
const fetcher = () => this.ipIntelligence!.getIntelligence(ip);
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
return this.getCached(`ipIntelligence:${ip}`, fetcher);
}
return fetcher();
}
/**

View File

@@ -1,21 +1,17 @@
// native scope
import * as os from 'os';
import * as https from 'https';
import * as perfHooks from 'perf_hooks';
import * as os from 'node:os';
import * as https from 'node:https';
import * as perfHooks from 'node:perf_hooks';
export { os, https, perfHooks };
// @pushrocks scope
import * as smartdns from '@push.rocks/smartdns';
import * as smartping from '@push.rocks/smartping';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartstring from '@push.rocks/smartstring';
import * as smartrust from '@push.rocks/smartrust';
export { smartdns, smartpromise, smartping, smartstring };
export { smartdns, smartrust };
// @third party scope
// @ts-ignore
import isopen from 'isopen';
import * as systeminformation from 'systeminformation';
// third party
import * as maxmind from 'maxmind';
export { isopen, systeminformation };
export { maxmind };