feat(smartnetwork): add Rust-powered network diagnostics bridge and IP intelligence lookups
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user