import * as plugins from './smartnetwork.plugins.js'; import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js'; import { getLogger } from './logging.js'; import { NetworkError } from './errors.js'; import * as stats from './helpers/stats.js'; /** * SmartNetwork simplifies actions within the network */ /** * Configuration options for SmartNetwork */ export interface SmartNetworkOptions { /** Cache time-to-live in milliseconds for gateway and public IP lookups */ cacheTtl?: number; } /** * A hop in a traceroute result */ export interface Hop { ttl: number; ip: string; rtt: number | null; } export class SmartNetwork { /** Static registry for external plugins */ public static pluginsRegistry: Map = new Map(); /** Register a plugin by name */ public static registerPlugin(name: string, ctor: any): void { SmartNetwork.pluginsRegistry.set(name, ctor); } /** Unregister a plugin by name */ public static unregisterPlugin(name: string): void { SmartNetwork.pluginsRegistry.delete(name); } private options: SmartNetworkOptions; private cache: Map; constructor(options?: SmartNetworkOptions) { this.options = options || {}; this.cache = new Map(); } /** * get network speed * @param opts optional speed test parameters */ public async getSpeed( opts?: { parallelStreams?: number; duration?: number }, ) { const cloudflareSpeedInstance = new CloudflareSpeed(opts); return cloudflareSpeedInstance.speedTest(); } /** * Send ICMP pings to a host. Optionally specify count for multiple pings. */ public async ping( host: string, opts?: { timeout?: number; count?: number }, ): Promise { const timeout = opts?.timeout ?? 500; 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); return { ...res, time: typeof res.time === 'number' ? res.time : NaN, }; } 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); } } 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, times, min, max, avg, stddev, packetLoss, alive: aliveCount > 0, }; } /** * 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 { const doneIpV4 = plugins.smartpromise.defer(); const doneIpV6 = plugins.smartpromise.defer(); 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; } /** * 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, portOrOpts?: number | { port?: number; protocol?: 'tcp' | 'udp'; timeout?: number; retries?: number }, ): Promise { let hostPart: string; let port: number | undefined; 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; } else { const opts = portOrOpts || {}; protocol = opts.protocol ?? 'tcp'; retries = opts.retries ?? 1; timeout = opts.timeout; [hostPart] = target.split(':'); 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; for (let attempt = 0; attempt < retries; attempt++) { const done = plugins.smartpromise.defer(); plugins.isopen(hostPart, port, (response: Record) => { const info = response[port.toString()]; done.resolve(Boolean(info?.isOpen)); }); last = await done.promise; if (last) return true; } return last; } /** * List network interfaces (gateways) */ public async getGateways(): Promise> { const fetcher = async () => plugins.os.networkInterfaces(); if (this.options.cacheTtl && this.options.cacheTtl > 0) { return this.getCached('gateways', fetcher); } return fetcher(); } public async getDefaultGateway(): Promise<{ ipv4: plugins.os.NetworkInterfaceInfo; ipv6: plugins.os.NetworkInterfaceInfo; }> { const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault(); if (!defaultGatewayName) { getLogger().warn?.('Cannot determine default gateway'); return null; } const gateways = await this.getGateways(); const defaultGateway = gateways[defaultGatewayName]; return { ipv4: defaultGateway[0], ipv6: defaultGateway[1], }; } /** * Lookup public IPv4 and IPv6 */ public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> { const fetcher = async () => ({ v4: await plugins.publicIp.publicIpv4({ timeout: 1000, onlyHttps: true }).catch(() => null), v6: await plugins.publicIp.publicIpv6({ timeout: 1000, onlyHttps: true }).catch(() => null), }); if (this.options.cacheTtl && this.options.cacheTtl > 0) { return this.getCached('publicIps', fetcher); } return fetcher(); } /** * Resolve DNS records (A, AAAA, MX) */ public async resolveDns(host: string): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> { try { const dns = await import('dns'); const { resolve4, resolve6, resolveMx } = dns.promises; const [A, AAAA, MX] = await Promise.all([ resolve4(host).catch(() => []), resolve6(host).catch(() => []), resolveMx(host).catch(() => []), ]); return { A, AAAA, MX }; } catch (err: any) { throw new NetworkError(err.message, err.code); } } /** * Perform a simple HTTP/HTTPS endpoint health check */ public async checkEndpoint( urlString: string, opts?: { timeout?: number }, ): Promise<{ status: number; headers: Record; rtt: number }> { const start = plugins.perfHooks.performance.now(); try { const url = new URL(urlString); const lib = url.protocol === 'https:' ? plugins.https : await import('http'); return new Promise((resolve, reject) => { const req = lib.request( url, { method: 'GET', timeout: opts?.timeout, agent: false }, (res: any) => { res.on('data', () => {}); res.once('end', () => { const rtt = plugins.perfHooks.performance.now() - start; const headers: Record = {}; for (const [k, v] of Object.entries(res.headers)) { headers[k] = Array.isArray(v) ? v.join(',') : String(v); } resolve({ status: res.statusCode, headers, rtt }); }); }, ); req.on('error', (err: any) => reject(new NetworkError(err.message, err.code))); req.end(); }); } catch (err: any) { throw new NetworkError(err.message, err.code); } } /** * 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. */ public async traceroute( host: string, opts?: { maxHops?: number; timeout?: number }, ): Promise { 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 } // fallback stub return [{ ttl: 1, ip: host, rtt: null }]; } /** * Internal caching helper */ private async getCached(key: string, fetcher: () => Promise): Promise { const now = Date.now(); const entry = this.cache.get(key); if (entry && entry.expiry > now) { return entry.value; } const value = await fetcher(); const ttl = this.options.cacheTtl || 0; this.cache.set(key, { value, expiry: now + ttl }); return value; } }