431 lines
13 KiB
TypeScript
431 lines
13 KiB
TypeScript
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 { RustNetworkBridge } from './smartnetwork.classes.rustbridge.js';
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* Options for the findFreePort method
|
|
*/
|
|
export interface IFindFreePortOptions {
|
|
/** If true, selects a random available port within the range instead of the first one */
|
|
randomize?: boolean;
|
|
/** Array of port numbers to exclude from the search */
|
|
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();
|
|
/** 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 }>;
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
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<any> {
|
|
await this.ensureBridge();
|
|
const timeoutMs = opts?.timeout ?? 5000;
|
|
const count = opts?.count && opts.count > 1 ? opts.count : 1;
|
|
|
|
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 {
|
|
host,
|
|
count,
|
|
times: Array(count).fill(NaN),
|
|
min: NaN,
|
|
max: NaN,
|
|
avg: NaN,
|
|
stddev: NaN,
|
|
packetLoss: 100,
|
|
alive: false,
|
|
};
|
|
}
|
|
|
|
// 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,
|
|
};
|
|
}
|
|
|
|
return {
|
|
host,
|
|
count,
|
|
times,
|
|
min,
|
|
max,
|
|
avg,
|
|
stddev,
|
|
packetLoss: result.packetLoss,
|
|
alive: result.alive,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a local port is unused (both IPv4 and IPv6)
|
|
*/
|
|
public async isLocalPortUnused(port: number): Promise<boolean> {
|
|
await this.ensureBridge();
|
|
const result = await this.rustBridge.isLocalPortFree(port);
|
|
return result.free;
|
|
}
|
|
|
|
/**
|
|
* Find the first available port within a given range
|
|
*/
|
|
public async findFreePort(startPort: number, endPort: number, options?: IFindFreePortOptions): Promise<number | null> {
|
|
// Validate port range
|
|
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
|
|
throw new NetworkError('Port numbers must be between 1 and 65535', 'EINVAL');
|
|
}
|
|
if (startPort > endPort) {
|
|
throw new NetworkError('Start port must be less than or equal to end port', 'EINVAL');
|
|
}
|
|
|
|
const excludedPorts = new Set(options?.exclude || []);
|
|
|
|
if (options?.randomize) {
|
|
const availablePorts: number[] = [];
|
|
for (let port = startPort; port <= endPort; port++) {
|
|
if (excludedPorts.has(port)) continue;
|
|
const isUnused = await this.isLocalPortUnused(port);
|
|
if (isUnused) {
|
|
availablePorts.push(port);
|
|
}
|
|
}
|
|
if (availablePorts.length > 0) {
|
|
const randomIndex = Math.floor(Math.random() * availablePorts.length);
|
|
return availablePorts[randomIndex];
|
|
}
|
|
return null;
|
|
} else {
|
|
for (let port = startPort; port <= endPort; port++) {
|
|
if (excludedPorts.has(port)) continue;
|
|
const isUnused = await this.isLocalPortUnused(port);
|
|
if (isUnused) {
|
|
return port;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if a remote port is available
|
|
*/
|
|
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;
|
|
|
|
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');
|
|
}
|
|
|
|
await this.ensureBridge();
|
|
let last = false;
|
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
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) — pure TS, no Rust needed.
|
|
*/
|
|
public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
|
|
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;
|
|
} | 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[interfaceName];
|
|
if (!defaultGateway) {
|
|
getLogger().warn?.(`Interface ${interfaceName} not found in os.networkInterfaces()`);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
ipv4: defaultGateway[0],
|
|
ipv6: defaultGateway[1],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lookup public IPv4 and IPv6 — pure TS, no Rust needed.
|
|
*/
|
|
public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
|
|
const fetcher = async () => {
|
|
const publicIp = new PublicIp({ timeout: 1000 });
|
|
return publicIp.getPublicIps();
|
|
};
|
|
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
|
return this.getCached('publicIps', fetcher);
|
|
}
|
|
return fetcher();
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
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[] => []),
|
|
]);
|
|
|
|
const A = aRecords.map((record: any) => record.value);
|
|
const AAAA = aaaaRecords.map((record: any) => record.value);
|
|
const MX = mxRecords.map((record: any) => {
|
|
const parts = record.value.split(' ');
|
|
return {
|
|
priority: parseInt(parts[0], 10),
|
|
exchange: parts[1] || '',
|
|
};
|
|
});
|
|
|
|
return { A, AAAA, MX };
|
|
} catch (err: any) {
|
|
throw new NetworkError(err.message, err.code);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Perform a simple HTTP/HTTPS endpoint health check — pure TS.
|
|
*/
|
|
public async checkEndpoint(
|
|
urlString: string,
|
|
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('node:http');
|
|
return new Promise((resolve, reject) => {
|
|
const req = lib.request(
|
|
url,
|
|
{ method: 'GET', timeout: opts?.timeout, agent: false, rejectUnauthorized: opts?.rejectUnauthorized ?? true },
|
|
(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 Rust binary.
|
|
*/
|
|
public async traceroute(
|
|
host: string,
|
|
opts?: { maxHops?: number; timeout?: number },
|
|
): Promise<Hop[]> {
|
|
await this.ensureBridge();
|
|
const maxHops = opts?.maxHops ?? 30;
|
|
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();
|
|
}
|
|
const fetcher = () => this.ipIntelligence!.getIntelligence(ip);
|
|
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
|
return this.getCached(`ipIntelligence:${ip}`, fetcher);
|
|
}
|
|
return fetcher();
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|