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

@@ -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();
}
/**