@push.rocks/smartnetwork 3.0.2 → 4.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.d.ts +1 -1
- package/dist_ts/00_commitinfo_data.js +5 -5
- package/dist_ts/errors.d.ts +10 -0
- package/dist_ts/errors.js +19 -0
- package/dist_ts/helpers/stats.js +1 -1
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +3 -1
- package/dist_ts/logging.d.ts +22 -0
- package/dist_ts/logging.js +15 -0
- package/dist_ts/smartnetwork.classes.cloudflarespeed.d.ts +10 -17
- package/dist_ts/smartnetwork.classes.cloudflarespeed.js +93 -35
- package/dist_ts/smartnetwork.classes.smartnetwork.d.ts +97 -9
- package/dist_ts/smartnetwork.classes.smartnetwork.js +248 -55
- package/dist_ts/smartnetwork.plugins.d.ts +3 -3
- package/dist_ts/smartnetwork.plugins.js +4 -4
- package/npmextra.json +20 -5
- package/package.json +39 -18
- package/readme.hints.md +1 -0
- package/readme.md +167 -45
- package/ts/00_commitinfo_data.ts +4 -4
- package/ts/errors.ts +20 -0
- package/ts/index.ts +3 -0
- package/ts/logging.ts +30 -0
- package/ts/smartnetwork.classes.cloudflarespeed.ts +120 -67
- package/ts/smartnetwork.classes.smartnetwork.ts +278 -54
- package/ts/smartnetwork.plugins.ts +3 -3
|
@@ -1,25 +1,98 @@
|
|
|
1
1
|
import * as plugins from './smartnetwork.plugins.js';
|
|
2
|
-
|
|
3
2
|
import { CloudflareSpeed } from './smartnetwork.classes.cloudflarespeed.js';
|
|
3
|
+
import { getLogger } from './logging.js';
|
|
4
|
+
import { NetworkError } from './errors.js';
|
|
5
|
+
import * as stats from './helpers/stats.js';
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* SmartNetwork simplifies actions within the network
|
|
7
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Configuration options for SmartNetwork
|
|
12
|
+
*/
|
|
13
|
+
export interface SmartNetworkOptions {
|
|
14
|
+
/** Cache time-to-live in milliseconds for gateway and public IP lookups */
|
|
15
|
+
cacheTtl?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A hop in a traceroute result
|
|
20
|
+
*/
|
|
21
|
+
export interface Hop {
|
|
22
|
+
ttl: number;
|
|
23
|
+
ip: string;
|
|
24
|
+
rtt: number | null;
|
|
25
|
+
}
|
|
8
26
|
export class SmartNetwork {
|
|
27
|
+
/** Static registry for external plugins */
|
|
28
|
+
public static pluginsRegistry: Map<string, any> = new Map();
|
|
29
|
+
/** Register a plugin by name */
|
|
30
|
+
public static registerPlugin(name: string, ctor: any): void {
|
|
31
|
+
SmartNetwork.pluginsRegistry.set(name, ctor);
|
|
32
|
+
}
|
|
33
|
+
/** Unregister a plugin by name */
|
|
34
|
+
public static unregisterPlugin(name: string): void {
|
|
35
|
+
SmartNetwork.pluginsRegistry.delete(name);
|
|
36
|
+
}
|
|
37
|
+
private options: SmartNetworkOptions;
|
|
38
|
+
private cache: Map<string, { value: any; expiry: number }>;
|
|
39
|
+
constructor(options?: SmartNetworkOptions) {
|
|
40
|
+
this.options = options || {};
|
|
41
|
+
this.cache = new Map();
|
|
42
|
+
}
|
|
9
43
|
/**
|
|
10
44
|
* get network speed
|
|
11
|
-
* @param
|
|
45
|
+
* @param opts optional speed test parameters
|
|
12
46
|
*/
|
|
13
|
-
public async getSpeed() {
|
|
14
|
-
const cloudflareSpeedInstance = new CloudflareSpeed();
|
|
15
|
-
|
|
16
|
-
return test;
|
|
47
|
+
public async getSpeed(opts?: { parallelStreams?: number; duration?: number }) {
|
|
48
|
+
const cloudflareSpeedInstance = new CloudflareSpeed(opts);
|
|
49
|
+
return cloudflareSpeedInstance.speedTest();
|
|
17
50
|
}
|
|
18
51
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Send ICMP pings to a host. Optionally specify count for multiple pings.
|
|
54
|
+
*/
|
|
55
|
+
public async ping(host: string, opts?: { timeout?: number; count?: number }): Promise<any> {
|
|
56
|
+
const timeout = opts?.timeout ?? 500;
|
|
57
|
+
const count = opts?.count && opts.count > 1 ? opts.count : 1;
|
|
58
|
+
const pinger = new plugins.smartping.Smartping();
|
|
59
|
+
if (count === 1) {
|
|
60
|
+
// single ping: normalize time to number
|
|
61
|
+
const res = await pinger.ping(host, timeout);
|
|
62
|
+
return {
|
|
63
|
+
...res,
|
|
64
|
+
time: typeof res.time === 'number' ? res.time : NaN,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
const times: number[] = [];
|
|
68
|
+
let aliveCount = 0;
|
|
69
|
+
for (let i = 0; i < count; i++) {
|
|
70
|
+
try {
|
|
71
|
+
const res = await pinger.ping(host, timeout);
|
|
72
|
+
const t = typeof res.time === 'number' ? res.time : NaN;
|
|
73
|
+
if (res.alive) aliveCount++;
|
|
74
|
+
times.push(t);
|
|
75
|
+
} catch {
|
|
76
|
+
times.push(NaN);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const valid = times.filter((t) => !isNaN(t));
|
|
80
|
+
const min = valid.length ? Math.min(...valid) : NaN;
|
|
81
|
+
const max = valid.length ? Math.max(...valid) : NaN;
|
|
82
|
+
const avg = valid.length ? stats.average(valid) : NaN;
|
|
83
|
+
const stddev = valid.length ? Math.sqrt(stats.average(valid.map((v) => (v - avg) ** 2))) : NaN;
|
|
84
|
+
const packetLoss = ((count - aliveCount) / count) * 100;
|
|
85
|
+
return {
|
|
86
|
+
host,
|
|
87
|
+
count,
|
|
88
|
+
times,
|
|
89
|
+
min,
|
|
90
|
+
max,
|
|
91
|
+
avg,
|
|
92
|
+
stddev,
|
|
93
|
+
packetLoss,
|
|
94
|
+
alive: aliveCount > 0,
|
|
95
|
+
};
|
|
23
96
|
}
|
|
24
97
|
|
|
25
98
|
/**
|
|
@@ -27,6 +100,9 @@ export class SmartNetwork {
|
|
|
27
100
|
* note: false also resolves with false as argument
|
|
28
101
|
* @param port
|
|
29
102
|
*/
|
|
103
|
+
/**
|
|
104
|
+
* Check if a local port is unused (both IPv4 and IPv6)
|
|
105
|
+
*/
|
|
30
106
|
public async isLocalPortUnused(port: number): Promise<boolean> {
|
|
31
107
|
const doneIpV4 = plugins.smartpromise.defer<boolean>();
|
|
32
108
|
const doneIpV6 = plugins.smartpromise.defer<boolean>();
|
|
@@ -34,11 +110,7 @@ export class SmartNetwork {
|
|
|
34
110
|
|
|
35
111
|
// test IPv4 space
|
|
36
112
|
const ipv4Test = net.createServer();
|
|
37
|
-
ipv4Test.once('error', (
|
|
38
|
-
if (err.code !== 'EADDRINUSE') {
|
|
39
|
-
doneIpV4.resolve(false);
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
113
|
+
ipv4Test.once('error', () => {
|
|
42
114
|
doneIpV4.resolve(false);
|
|
43
115
|
});
|
|
44
116
|
ipv4Test.once('listening', () => {
|
|
@@ -53,11 +125,7 @@ export class SmartNetwork {
|
|
|
53
125
|
|
|
54
126
|
// test IPv6 space
|
|
55
127
|
const ipv6Test = net.createServer();
|
|
56
|
-
ipv6Test.once('error',
|
|
57
|
-
if (err.code !== 'EADDRINUSE') {
|
|
58
|
-
doneIpV6.resolve(false);
|
|
59
|
-
return;
|
|
60
|
-
}
|
|
128
|
+
ipv6Test.once('error', () => {
|
|
61
129
|
doneIpV6.resolve(false);
|
|
62
130
|
});
|
|
63
131
|
ipv6Test.once('listening', () => {
|
|
@@ -79,26 +147,68 @@ export class SmartNetwork {
|
|
|
79
147
|
* checks wether a remote port is available
|
|
80
148
|
* @param domainArg
|
|
81
149
|
*/
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Check if a remote port is available
|
|
152
|
+
* @param target host or "host:port"
|
|
153
|
+
* @param opts options including port, protocol (only tcp), retries and timeout
|
|
154
|
+
*/
|
|
155
|
+
/**
|
|
156
|
+
* Check if a remote port is available
|
|
157
|
+
* @param target host or "host:port"
|
|
158
|
+
* @param portOrOpts either a port number (deprecated) or options object
|
|
159
|
+
*/
|
|
160
|
+
public async isRemotePortAvailable(
|
|
161
|
+
target: string,
|
|
162
|
+
portOrOpts?:
|
|
163
|
+
| number
|
|
164
|
+
| { port?: number; protocol?: 'tcp' | 'udp'; timeout?: number; retries?: number },
|
|
165
|
+
): Promise<boolean> {
|
|
166
|
+
let hostPart: string;
|
|
167
|
+
let port: number | undefined;
|
|
168
|
+
let protocol: string = 'tcp';
|
|
169
|
+
let retries = 1;
|
|
170
|
+
let timeout: number | undefined;
|
|
171
|
+
// preserve old signature (target, port)
|
|
172
|
+
if (typeof portOrOpts === 'number') {
|
|
173
|
+
[hostPart] = target.split(':');
|
|
174
|
+
port = portOrOpts;
|
|
175
|
+
} else {
|
|
176
|
+
const opts = portOrOpts || {};
|
|
177
|
+
protocol = opts.protocol ?? 'tcp';
|
|
178
|
+
retries = opts.retries ?? 1;
|
|
179
|
+
timeout = opts.timeout;
|
|
180
|
+
[hostPart] = target.split(':');
|
|
181
|
+
const portPart = target.split(':')[1];
|
|
182
|
+
port = opts.port ?? (portPart ? parseInt(portPart, 10) : undefined);
|
|
183
|
+
}
|
|
184
|
+
if (protocol === 'udp') {
|
|
185
|
+
throw new NetworkError('UDP port check not supported', 'ENOTSUP');
|
|
186
|
+
}
|
|
187
|
+
if (!port) {
|
|
188
|
+
throw new NetworkError('Port not specified', 'EINVAL');
|
|
189
|
+
}
|
|
190
|
+
let last: boolean = false;
|
|
191
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
192
|
+
const done = plugins.smartpromise.defer<boolean>();
|
|
193
|
+
plugins.isopen(hostPart, port, (response: Record<string, { isOpen: boolean }>) => {
|
|
194
|
+
const info = response[port.toString()];
|
|
195
|
+
done.resolve(Boolean(info?.isOpen));
|
|
196
|
+
});
|
|
197
|
+
last = await done.promise;
|
|
198
|
+
if (last) return true;
|
|
199
|
+
}
|
|
200
|
+
return last;
|
|
97
201
|
}
|
|
98
202
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
203
|
+
/**
|
|
204
|
+
* List network interfaces (gateways)
|
|
205
|
+
*/
|
|
206
|
+
public async getGateways(): Promise<Record<string, plugins.os.NetworkInterfaceInfo[]>> {
|
|
207
|
+
const fetcher = async () => plugins.os.networkInterfaces();
|
|
208
|
+
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
|
209
|
+
return this.getCached('gateways', fetcher);
|
|
210
|
+
}
|
|
211
|
+
return fetcher();
|
|
102
212
|
}
|
|
103
213
|
|
|
104
214
|
public async getDefaultGateway(): Promise<{
|
|
@@ -107,7 +217,7 @@ export class SmartNetwork {
|
|
|
107
217
|
}> {
|
|
108
218
|
const defaultGatewayName = await plugins.systeminformation.networkInterfaceDefault();
|
|
109
219
|
if (!defaultGatewayName) {
|
|
110
|
-
|
|
220
|
+
getLogger().warn?.('Cannot determine default gateway');
|
|
111
221
|
return null;
|
|
112
222
|
}
|
|
113
223
|
const gateways = await this.getGateways();
|
|
@@ -118,20 +228,134 @@ export class SmartNetwork {
|
|
|
118
228
|
};
|
|
119
229
|
}
|
|
120
230
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}).catch(
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
231
|
+
/**
|
|
232
|
+
* Lookup public IPv4 and IPv6
|
|
233
|
+
*/
|
|
234
|
+
public async getPublicIps(): Promise<{ v4: string | null; v6: string | null }> {
|
|
235
|
+
const fetcher = async () => ({
|
|
236
|
+
v4: await plugins.publicIp.publicIpv4({ timeout: 1000, onlyHttps: true }).catch(() => null),
|
|
237
|
+
v6: await plugins.publicIp.publicIpv6({ timeout: 1000, onlyHttps: true }).catch(() => null),
|
|
238
|
+
});
|
|
239
|
+
if (this.options.cacheTtl && this.options.cacheTtl > 0) {
|
|
240
|
+
return this.getCached('publicIps', fetcher);
|
|
241
|
+
}
|
|
242
|
+
return fetcher();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Resolve DNS records (A, AAAA, MX)
|
|
247
|
+
*/
|
|
248
|
+
public async resolveDns(
|
|
249
|
+
host: string,
|
|
250
|
+
): Promise<{ A: string[]; AAAA: string[]; MX: { exchange: string; priority: number }[] }> {
|
|
251
|
+
try {
|
|
252
|
+
const dns = await import('dns');
|
|
253
|
+
const { resolve4, resolve6, resolveMx } = dns.promises;
|
|
254
|
+
const [A, AAAA, MX] = await Promise.all([
|
|
255
|
+
resolve4(host).catch(() => []),
|
|
256
|
+
resolve6(host).catch(() => []),
|
|
257
|
+
resolveMx(host).catch(() => []),
|
|
258
|
+
]);
|
|
259
|
+
return { A, AAAA, MX };
|
|
260
|
+
} catch (err: any) {
|
|
261
|
+
throw new NetworkError(err.message, err.code);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Perform a simple HTTP/HTTPS endpoint health check
|
|
267
|
+
*/
|
|
268
|
+
public async checkEndpoint(
|
|
269
|
+
urlString: string,
|
|
270
|
+
opts?: { timeout?: number },
|
|
271
|
+
): Promise<{ status: number; headers: Record<string, string>; rtt: number }> {
|
|
272
|
+
const start = plugins.perfHooks.performance.now();
|
|
273
|
+
try {
|
|
274
|
+
const url = new URL(urlString);
|
|
275
|
+
const lib = url.protocol === 'https:' ? plugins.https : await import('http');
|
|
276
|
+
return new Promise((resolve, reject) => {
|
|
277
|
+
const req = lib.request(
|
|
278
|
+
url,
|
|
279
|
+
{ method: 'GET', timeout: opts?.timeout, agent: false },
|
|
280
|
+
(res: any) => {
|
|
281
|
+
res.on('data', () => {});
|
|
282
|
+
res.once('end', () => {
|
|
283
|
+
const rtt = plugins.perfHooks.performance.now() - start;
|
|
284
|
+
const headers: Record<string, string> = {};
|
|
285
|
+
for (const [k, v] of Object.entries(res.headers)) {
|
|
286
|
+
headers[k] = Array.isArray(v) ? v.join(',') : String(v);
|
|
287
|
+
}
|
|
288
|
+
resolve({ status: res.statusCode, headers, rtt });
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
req.on('error', (err: any) => reject(new NetworkError(err.message, err.code)));
|
|
293
|
+
req.end();
|
|
294
|
+
});
|
|
295
|
+
} catch (err: any) {
|
|
296
|
+
throw new NetworkError(err.message, err.code);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Perform a traceroute: hop-by-hop latency using the system traceroute tool.
|
|
302
|
+
* Falls back to a single-hop stub if traceroute is unavailable or errors.
|
|
303
|
+
*/
|
|
304
|
+
public async traceroute(
|
|
305
|
+
host: string,
|
|
306
|
+
opts?: { maxHops?: number; timeout?: number },
|
|
307
|
+
): Promise<Hop[]> {
|
|
308
|
+
const maxHops = opts?.maxHops ?? 30;
|
|
309
|
+
const timeout = opts?.timeout;
|
|
310
|
+
try {
|
|
311
|
+
const { exec } = await import('child_process');
|
|
312
|
+
const cmd = `traceroute -n -m ${maxHops} ${host}`;
|
|
313
|
+
const stdout: string = await new Promise((resolve, reject) => {
|
|
314
|
+
exec(cmd, { encoding: 'utf8', timeout }, (err, stdout) => {
|
|
315
|
+
if (err) return reject(err);
|
|
316
|
+
resolve(stdout);
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
const hops: Hop[] = [];
|
|
320
|
+
for (const raw of stdout.split('\n')) {
|
|
321
|
+
const line = raw.trim();
|
|
322
|
+
if (!line || line.startsWith('traceroute')) continue;
|
|
323
|
+
const parts = line.split(/\s+/);
|
|
324
|
+
const ttl = parseInt(parts[0], 10);
|
|
325
|
+
let ip: string;
|
|
326
|
+
let rtt: number | null;
|
|
327
|
+
if (parts[1] === '*' || !parts[1]) {
|
|
328
|
+
ip = parts[1] || '';
|
|
329
|
+
rtt = null;
|
|
330
|
+
} else {
|
|
331
|
+
ip = parts[1];
|
|
332
|
+
const timePart = parts.find((p, i) => i >= 2 && /^\d+(\.\d+)?$/.test(p));
|
|
333
|
+
rtt = timePart ? parseFloat(timePart) : null;
|
|
334
|
+
}
|
|
335
|
+
hops.push({ ttl, ip, rtt });
|
|
336
|
+
}
|
|
337
|
+
if (hops.length) {
|
|
338
|
+
return hops;
|
|
339
|
+
}
|
|
340
|
+
} catch {
|
|
341
|
+
// traceroute not available or error: fall through to stub
|
|
342
|
+
}
|
|
343
|
+
// fallback stub
|
|
344
|
+
return [{ ttl: 1, ip: host, rtt: null }];
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Internal caching helper
|
|
349
|
+
*/
|
|
350
|
+
private async getCached<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
351
|
+
const now = Date.now();
|
|
352
|
+
const entry = this.cache.get(key);
|
|
353
|
+
if (entry && entry.expiry > now) {
|
|
354
|
+
return entry.value;
|
|
355
|
+
}
|
|
356
|
+
const value = await fetcher();
|
|
357
|
+
const ttl = this.options.cacheTtl || 0;
|
|
358
|
+
this.cache.set(key, { value, expiry: now + ttl });
|
|
359
|
+
return value;
|
|
136
360
|
}
|
|
137
361
|
}
|
|
@@ -6,9 +6,9 @@ import * as perfHooks from 'perf_hooks';
|
|
|
6
6
|
export { os, https, perfHooks };
|
|
7
7
|
|
|
8
8
|
// @pushrocks scope
|
|
9
|
-
import * as smartping from '@
|
|
10
|
-
import * as smartpromise from '@
|
|
11
|
-
import * as smartstring from '@
|
|
9
|
+
import * as smartping from '@push.rocks/smartping';
|
|
10
|
+
import * as smartpromise from '@push.rocks/smartpromise';
|
|
11
|
+
import * as smartstring from '@push.rocks/smartstring';
|
|
12
12
|
|
|
13
13
|
export { smartpromise, smartping, smartstring };
|
|
14
14
|
|