2022-03-24 23:11:53 +01:00
|
|
|
import * as plugins from './smartnetwork.plugins.js';
|
|
|
|
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
|
2025-04-28 15:30:08 +00:00
|
|
|
import { getLogger } from './logging.js';
|
2025-04-28 19:27:13 +00:00
|
|
|
import { NetworkError } from './errors.js';
|
|
|
|
import * as stats from './helpers/stats.js';
|
2019-04-16 10:21:11 +02:00
|
|
|
|
|
|
|
/**
|
|
|
|
* SmartNetwork simplifies actions within the network
|
|
|
|
*/
|
2025-04-28 19:27:13 +00:00
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
2019-04-16 10:21:11 +02:00
|
|
|
export class SmartNetwork {
|
2025-04-28 19:27:13 +00:00
|
|
|
/** Static registry for external plugins */
|
|
|
|
public static pluginsRegistry: Map<string, any> = 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<string, { value: any; expiry: number }>;
|
|
|
|
constructor(options?: SmartNetworkOptions) {
|
|
|
|
this.options = options || {};
|
|
|
|
this.cache = new Map();
|
|
|
|
}
|
2019-09-06 18:26:32 +02:00
|
|
|
/**
|
|
|
|
* get network speed
|
2025-04-28 19:27:13 +00:00
|
|
|
* @param opts optional speed test parameters
|
2019-09-06 18:26:32 +02:00
|
|
|
*/
|
2025-04-28 19:27:13 +00:00
|
|
|
public async getSpeed(
|
|
|
|
opts?: { parallelStreams?: number; duration?: number },
|
|
|
|
) {
|
|
|
|
const cloudflareSpeedInstance = new CloudflareSpeed(opts);
|
|
|
|
return cloudflareSpeedInstance.speedTest();
|
2019-04-16 10:21:11 +02:00
|
|
|
}
|
|
|
|
|
2025-04-28 19:27:13 +00:00
|
|
|
/**
|
|
|
|
* Send ICMP pings to a host. Optionally specify count for multiple pings.
|
|
|
|
*/
|
2025-04-28 12:58:01 +00:00
|
|
|
public async ping(
|
2025-04-28 19:27:13 +00:00
|
|
|
host: string,
|
|
|
|
opts?: { timeout?: number; count?: number },
|
|
|
|
): Promise<any> {
|
|
|
|
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,
|
|
|
|
};
|
2022-02-17 00:03:13 +01:00
|
|
|
}
|
|
|
|
|
2019-04-16 10:21:11 +02:00
|
|
|
/**
|
|
|
|
* returns a promise with a boolean answer
|
|
|
|
* note: false also resolves with false as argument
|
|
|
|
* @param port
|
|
|
|
*/
|
2025-04-28 19:27:13 +00:00
|
|
|
/**
|
|
|
|
* Check if a local port is unused (both IPv4 and IPv6)
|
|
|
|
*/
|
2021-04-13 10:09:39 +00:00
|
|
|
public async isLocalPortUnused(port: number): Promise<boolean> {
|
2019-04-16 10:21:11 +02:00
|
|
|
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();
|
2025-04-28 15:30:08 +00:00
|
|
|
ipv4Test.once('error', () => {
|
2019-04-16 10:21:11 +02:00
|
|
|
doneIpV4.resolve(false);
|
|
|
|
});
|
2019-09-06 18:26:32 +02:00
|
|
|
ipv4Test.once('listening', () => {
|
2019-04-16 10:21:11 +02:00
|
|
|
ipv4Test.once('close', () => {
|
|
|
|
doneIpV4.resolve(true);
|
|
|
|
});
|
|
|
|
ipv4Test.close();
|
|
|
|
});
|
|
|
|
ipv4Test.listen(port, '0.0.0.0');
|
|
|
|
|
|
|
|
await doneIpV4.promise;
|
|
|
|
|
|
|
|
// test IPv6 space
|
2019-09-06 18:26:32 +02:00
|
|
|
const ipv6Test = net.createServer();
|
2025-04-28 15:30:08 +00:00
|
|
|
ipv6Test.once('error', () => {
|
2019-04-16 10:21:11 +02:00
|
|
|
doneIpV6.resolve(false);
|
|
|
|
});
|
2019-09-06 18:26:32 +02:00
|
|
|
ipv6Test.once('listening', () => {
|
|
|
|
ipv6Test.once('close', () => {
|
2019-04-16 10:21:11 +02:00
|
|
|
doneIpV6.resolve(true);
|
|
|
|
});
|
2019-09-06 18:26:32 +02:00
|
|
|
ipv6Test.close();
|
2019-04-16 10:21:11 +02:00
|
|
|
});
|
2019-09-06 18:26:32 +02:00
|
|
|
ipv6Test.listen(port, '::');
|
2019-04-16 10:21:11 +02:00
|
|
|
|
|
|
|
// 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
|
|
|
|
*/
|
2025-04-28 19:27:13 +00:00
|
|
|
/**
|
|
|
|
* 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<boolean> {
|
|
|
|
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<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;
|
|
|
|
}
|
|
|
|
return last;
|
2019-04-16 10:21:11 +02:00
|
|
|
}
|
2019-09-06 18:26:32 +02:00
|
|
|
|
2025-04-28 19:27:13 +00:00
|
|
|
/**
|
|
|
|
* List network interfaces (gateways)
|
|
|
|
*/
|
|
|
|
public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
|
|
|
|
const fetcher = async () => plugins.os.networkInterfaces();
|
|
|
|
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
|
|
|
return this.getCached('gateways', fetcher);
|
|
|
|
}
|
|
|
|
return fetcher();
|
2019-09-06 18:26:32 +02:00
|
|
|
}
|
2019-09-08 16:15:10 +02:00
|
|
|
|
2019-11-19 23:00:37 +00:00
|
|
|
public async getDefaultGateway(): Promise<{
|
|
|
|
ipv4: plugins.os.NetworkInterfaceInfo;
|
|
|
|
ipv6: plugins.os.NetworkInterfaceInfo;
|
|
|
|
}> {
|
2019-09-08 16:15:10 +02:00
|
|
|
const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault();
|
2019-09-08 16:24:59 +02:00
|
|
|
if (!defaultGatewayName) {
|
2025-04-28 15:30:08 +00:00
|
|
|
getLogger().warn?.('Cannot determine default gateway');
|
2019-09-08 16:24:59 +02:00
|
|
|
return null;
|
|
|
|
}
|
2019-09-08 16:15:10 +02:00
|
|
|
const gateways = await this.getGateways();
|
|
|
|
const defaultGateway = gateways[defaultGatewayName];
|
|
|
|
return {
|
|
|
|
ipv4: defaultGateway[0],
|
2020-08-12 16:30:17 +00:00
|
|
|
ipv6: defaultGateway[1],
|
2019-09-08 16:15:10 +02:00
|
|
|
};
|
|
|
|
}
|
2019-11-23 16:07:04 +00:00
|
|
|
|
2025-04-28 19:27:13 +00:00
|
|
|
/**
|
|
|
|
* 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<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');
|
|
|
|
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<string, string> = {};
|
|
|
|
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<Hop[]> {
|
|
|
|
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<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
|
|
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;
|
2019-11-23 16:07:04 +00:00
|
|
|
}
|
2019-04-16 10:21:11 +02:00
|
|
|
}
|