-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathspeedtest.js
More file actions
706 lines (645 loc) · 26.2 KB
/
Copy pathspeedtest.js
File metadata and controls
706 lines (645 loc) · 26.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
/**
* Speed test using Cloudflare's public speed test CDN.
* No token or auth required. Accurate and reliable.
*
* WireGuard: direct (all traffic routes through tunnel)
* V2Ray: via SOCKS5 proxy on localhost:PORT
*
* ─── Methodology ───────────────────────────────────────────────────
*
* Multi-request approach:
* Downloads N sequential chunks, each over a FRESH TCP+TLS connection.
* VPN overhead (extra RTT per connection) compounds across chunks,
* creating a genuine, measurable speed gap vs direct connection.
* This is both FAIR (same test for both) and REALISTIC (real browsing
* makes many small requests, not one big stream).
*
* Adaptive phases:
* Phase 1 (probe): 1 × 1MB download (~1s at 10 Mbps)
* Phase 2 (full): 5 × 1MB sequential downloads (if probe > 3 Mbps)
* If probe < 3 Mbps, report probe result directly (saves time on slow nodes)
*
* Calculation:
* mbps = (totalBytes × 8) / totalSeconds / 1,000,000
* Result rounded to 2 decimal places via bytesToMbps(bytes, seconds, 2)
*
* Parameters (hardcoded):
* CHUNK_BYTES = 1,048,576 (1 MB per request)
* CHUNK_COUNT = 5 (sequential downloads in phase 2)
* PROBE_CUTOFF = 3 Mbps (below this, skip phase 2)
* CF_HOST = speed.cloudflare.com (Cloudflare CDN, no auth)
*
* Fallback chain (if Cloudflare fails):
* 1. Cloudflare CDN via pre-resolved IP (avoids DNS issues behind VPN)
* 2. Fallback targets: Ubuntu releases, Hetzner mirror, Debian CD
* 3. google.com single-page download (last resort, low accuracy)
*
* DNS handling:
* CF hostname is pre-resolved once via resolveCfIp() and cached.
* This avoids DNS failures when testing through WireGuard tunnels
* (which may not have DNS configured yet).
*
* SOCKS5 notes:
* V2Ray tests MUST use axios with SocksProxyAgent — native fetch
* silently ignores SOCKS proxy agents. Each chunk gets a fresh agent
* to avoid connection reuse (which would hide per-request overhead).
*/
import axios from 'axios';
import https from 'https';
import dns from 'dns';
import { SocksProxyAgent } from 'socks-proxy-agent';
// axios adapter set in defaults.js — prevents undici "fetch failed" on Node.js v18+.
import { sleep, bytesToMbps } from './defaults.js';
import { TunnelError, ErrorCodes } from './errors.js';
// Cloudflare speed test CDN — always up, no auth, geographically distributed
const CF_HOST = 'speed.cloudflare.com';
const CF_DOWN = `https://${CF_HOST}/__down`;
// Multi-request test parameters
const CHUNK_BYTES = 1 * 1024 * 1024; // 1MB per chunk
const CHUNK_COUNT = 5; // 5 sequential requests = 5MB total
const PROBE_BYTES = 1 * 1024 * 1024; // 1MB probe
/** Exported speed test configuration constants. Read-only reference for consumers. */
export const SPEEDTEST_DEFAULTS = Object.freeze({
chunkBytes: CHUNK_BYTES,
chunkCount: CHUNK_COUNT,
probeBytes: PROBE_BYTES,
probeThresholdMbps: 3,
primaryHost: CF_HOST,
dnsCacheTtl: 5 * 60_000,
fallbackHosts: [
{ host: 'proof.ovh.net', path: '/files/1Mb.dat', size: 1_000_000 },
{ host: 'speedtest.tele2.net', path: '/1MB.zip', size: 1_000_000 },
],
});
// Fallback download targets when Cloudflare is unreachable through a tunnel
const FALLBACK_URLS = SPEEDTEST_DEFAULTS.fallbackHosts;
// Cache resolved IP with TTL (survives WireGuard DNS breakage, refreshes on stale)
let cachedCfIp = null;
let cachedCfTime = 0;
let cachedFallbackIps = {};
const DNS_CACHE_TTL = SPEEDTEST_DEFAULTS.dnsCacheTtl;
/** Flush cached DNS resolutions. Call when switching VPN connections. */
export function flushSpeedTestDnsCache() {
cachedCfIp = null;
cachedCfTime = 0;
cachedFallbackIps = {};
}
async function resolveCfHost() {
if (cachedCfIp && Date.now() - cachedCfTime < DNS_CACHE_TTL) return cachedCfIp;
// Method 1: Explicit resolver to 1.1.1.1 (most reliable — bypasses broken system DNS)
try {
const resolver = new dns.Resolver();
resolver.setServers(['1.1.1.1', '8.8.8.8']);
const addrs = await new Promise((resolve, reject) => {
resolver.resolve4(CF_HOST, (err, addresses) => err ? reject(err) : resolve(addresses));
});
if (addrs.length > 0) { cachedCfIp = addrs[0]; cachedCfTime = Date.now(); return cachedCfIp; }
} catch { }
// Method 2: Default resolve4 (uses c-ares — fails on Windows 11 DoH setups)
try {
const addrs = await dns.promises.resolve4(CF_HOST);
if (addrs.length > 0) { cachedCfIp = addrs[0]; cachedCfTime = Date.now(); return cachedCfIp; }
} catch { }
// Method 3: OS resolver (getaddrinfo — always works but may return CDN-specific IP)
try {
const { address } = await dns.promises.lookup(CF_HOST);
cachedCfIp = address;
cachedCfTime = Date.now();
return cachedCfIp;
} catch { }
return null;
}
/** Pre-resolve fallback hosts so they work behind WireGuard tunnels too. */
async function resolveFallbackHosts() {
for (const fb of FALLBACK_URLS) {
if (cachedFallbackIps[fb.host]) continue;
try {
const { address } = await dns.promises.lookup(fb.host);
cachedFallbackIps[fb.host] = address;
} catch {} // DNS resolution may fail — fallback will use hostname directly
}
}
/**
* Download limitBytes from url with a FRESH TCP+TLS connection.
* Uses https.get (NOT fetch) because native fetch silently ignores the agent option.
* agent: false ensures no keep-alive — every call does a full TCP+TLS handshake.
* timeoutMs defaults to 30s but can be increased for tunnel retries.
*/
function freshDownload(url, limitBytes, agentOpts, timeoutMs = 30000) {
return new Promise((resolve, reject) => {
let downloaded = 0;
const start = Date.now();
const parsed = new URL(url);
let finished = false;
function done(err) {
if (finished) return;
finished = true;
const elapsed = (Date.now() - start) / 1000;
if (err && downloaded === 0) { reject(err); return; }
if (elapsed <= 0 || downloaded === 0) { reject(new Error('No data received')); return; }
resolve({ bytes: downloaded, seconds: elapsed });
}
const options = {
hostname: parsed.hostname,
path: parsed.pathname + parsed.search,
headers: {},
rejectUnauthorized: false,
agent: false, // CRITICAL: fresh TCP+TLS connection every time (no keep-alive)
};
// IP-based URL: set Host header and TLS SNI so server accepts it
if (/^\d+\.\d+\.\d+\.\d+$/.test(parsed.hostname)) {
const hostName = agentOpts?.fallbackHost || CF_HOST;
options.headers['Host'] = hostName;
options.servername = hostName;
}
// Allow custom agent (overrides agent: false) for specific cases
if (agentOpts?.httpsAgent) {
options.agent = agentOpts.httpsAgent;
}
const req = https.get(options, (res) => {
if (res.statusCode !== 200) {
req.destroy();
done(new Error(`HTTP ${res.statusCode}`));
return;
}
res.on('data', (chunk) => {
downloaded += chunk.length;
if (downloaded >= limitBytes) {
res.destroy();
done();
}
});
res.on('end', () => done());
res.on('error', (err) => done(err));
});
req.on('error', (err) => done(err));
req.setTimeout(timeoutMs, () => {
req.destroy();
done(new Error('timeout'));
});
});
}
/**
* Last-resort single-stream download with long timeout (60s).
* Used when the multi-request test fails through a tunnel.
* Downloads a smaller amount (2MB) with a keep-alive agent for reliability.
* Returns low but valid speed rather than failing the node entirely.
*/
async function rescueDownload() {
const RESCUE_BYTES = 2 * 1024 * 1024;
const rescueAgent = new https.Agent({ rejectUnauthorized: false, servername: CF_HOST, keepAlive: true });
// Try: IP with agent, hostname with agent, fallback hosts
const urls = [];
if (cachedCfIp) urls.push(`https://${cachedCfIp}/__down?bytes=${RESCUE_BYTES}`);
urls.push(`${CF_DOWN}?bytes=${RESCUE_BYTES}`);
for (const url of urls) {
try {
const r = await freshDownload(url, RESCUE_BYTES, { httpsAgent: rescueAgent }, 60000);
const mbps = bytesToMbps(r.bytes, r.seconds);
rescueAgent.destroy();
return { mbps: parseFloat(mbps.toFixed(2)), chunks: 1, adaptive: 'rescue' };
} catch {}
}
// Try fallback URLs with long timeout
for (const fb of FALLBACK_URLS) {
const ip = cachedFallbackIps[fb.host];
const targets = [];
if (ip) targets.push({ url: `https://${ip}${fb.path}`, opts: { httpsAgent: rescueAgent, fallbackHost: fb.host } });
targets.push({ url: `https://${fb.host}${fb.path}`, opts: { httpsAgent: rescueAgent } });
for (const t of targets) {
try {
const r = await freshDownload(t.url, fb.size, t.opts, 60000);
const mbps = bytesToMbps(r.bytes, r.seconds);
rescueAgent.destroy();
return { mbps: parseFloat(mbps.toFixed(2)), chunks: 1, adaptive: 'rescue-fallback', fallbackHost: fb.host };
} catch {}
}
}
rescueAgent.destroy();
return null;
}
/**
* Multi-request speed test: download N chunks sequentially, each with fresh TCP+TLS.
* Total elapsed time includes all connection overhead (handshakes compound).
* VPN latency shows up as genuinely lower effective throughput.
*/
async function multiRequestMeasure(baseUrl, chunkBytes, chunkCount, agentOpts) {
let totalBytes = 0;
let successCount = 0;
const overallStart = Date.now();
for (let i = 0; i < chunkCount; i++) {
try {
const r = await freshDownload(baseUrl, chunkBytes, agentOpts);
totalBytes += r.bytes;
successCount++;
} catch {
// Allow partial success — report based on successful chunks
if (successCount === 0 && i === chunkCount - 1) {
throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'All speed test chunks failed');
}
}
}
if (successCount === 0) throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'All speed test chunks failed');
const totalElapsed = (Date.now() - overallStart) / 1000;
const mbps = bytesToMbps(totalBytes, totalElapsed, 2);
return { mbps, chunks: successCount, totalBytes, seconds: totalElapsed };
}
/**
* Fallback speed measurement — download a known-size file via HTTPS.
* Used when Cloudflare is unreachable through a WireGuard tunnel.
*/
async function fallbackMeasure(agentOpts) {
for (const fb of FALLBACK_URLS) {
const ip = cachedFallbackIps[fb.host];
if (!ip) continue;
try {
const result = await freshDownload(
`https://${ip}${fb.path}`,
fb.size,
{ ...agentOpts, fallbackHost: fb.host }
);
return { mbps: bytesToMbps(result.bytes, result.seconds, 2), chunks: 1, adaptive: 'fallback', fallbackHost: fb.host };
} catch {}
// Also try hostname directly
try {
const result = await freshDownload(
`https://${fb.host}${fb.path}`,
fb.size,
agentOpts
);
return { mbps: bytesToMbps(result.bytes, result.seconds, 2), chunks: 1, adaptive: 'fallback', fallbackHost: fb.host };
} catch {}
}
return null;
}
/**
* Direct speedtest — used for baseline and WireGuard tunnel testing.
* All traffic goes through the active network interface (WireGuard tunnel when up).
* Pre-resolves CF hostname to avoid DNS failures behind WireGuard tunnels.
*
* WireGuard note: When a WireGuard tunnel is active in full-tunnel mode, ALL
* traffic is routed through it — including this speed test. There is no separate
* "WireGuard speed test" function because `speedtestDirect()` IS the WireGuard
* speed test when called while a WireGuard tunnel is up. For V2Ray (SOCKS5 proxy),
* use `speedtestViaSocks5()` instead which explicitly routes through the proxy.
*
* Multi-request approach: 5 × 1MB sequential downloads, each with fresh TCP+TLS.
* VPN overhead (extra handshake latency per request) creates genuine speed gap.
*/
export async function speedtestDirect() {
await resolveCfHost();
await resolveFallbackHosts();
// Build URL — try IP first (avoids DNS failures behind WireGuard tunnels)
function cfUrl(bytes) {
return cachedCfIp
? `https://${cachedCfIp}/__down?bytes=${bytes}`
: `${CF_DOWN}?bytes=${bytes}`;
}
function cfUrlHostname(bytes) {
return `${CF_DOWN}?bytes=${bytes}`;
}
// Phase 1: Quick 1MB single probe
let probe;
try {
probe = await freshDownload(cfUrl(PROBE_BYTES), PROBE_BYTES, {});
} catch {
// IP failed, try hostname
try {
probe = await freshDownload(cfUrlHostname(PROBE_BYTES), PROBE_BYTES, {});
} catch {
// Cloudflare unreachable with fresh connections — try fallback targets
const fb = await fallbackMeasure({});
if (fb) return fb;
// Last resort: rescue download with keep-alive agent + 60s timeout
const rescue = await rescueDownload();
if (rescue) return rescue;
throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'Speed test failed (CF and all fallbacks unreachable)');
}
}
const probeMbps = bytesToMbps(probe.bytes, probe.seconds, 2);
// If probe speed is low (< 3 Mbps), don't waste time on full test
if (probeMbps < 3) {
return { mbps: probeMbps, chunks: 1, adaptive: 'probe-only' };
}
// Phase 2: Multi-request test — 5 × 1MB sequential downloads
const url = cfUrl(CHUNK_BYTES);
try {
const full = await multiRequestMeasure(url, CHUNK_BYTES, CHUNK_COUNT, {});
return { mbps: full.mbps, chunks: full.chunks, adaptive: 'multi-request' };
} catch {
// Try hostname fallback
try {
const full = await multiRequestMeasure(cfUrlHostname(CHUNK_BYTES), CHUNK_BYTES, CHUNK_COUNT, {});
return { mbps: full.mbps, chunks: full.chunks, adaptive: 'multi-request' };
} catch {
// Full test failed but probe worked — return probe result
return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
}
}
}
/**
* SOCKS5 speedtest — used for V2Ray tunnel testing.
* Routes through the SOCKS5 proxy at localhost:proxyPort.
* Uses axios (not native fetch) because undici ignores the agent option for SOCKS5.
*
* IMPORTANT: Creates a fresh SocksProxyAgent per request to avoid connection
* reuse issues with V2Ray's SOCKS5 handler. Uses arraybuffer mode (not stream)
* because stream mode causes TLS handshake failures with some SOCKS5 proxies.
*
* Multi-request: 5 × 1MB sequential downloads, each with fresh SOCKS5+TCP+TLS.
*/
export async function speedtestViaSocks5(testMb = 5, proxyPort = 1080, socksAuth = null) {
// Fresh agent per request — V2Ray SOCKS5 can fail with connection reuse
function makeAgent() {
const authStr = socksAuth ? `${socksAuth.user}:${socksAuth.pass}@` : '';
return new SocksProxyAgent(`socks5://${authStr}127.0.0.1:${proxyPort}`);
}
async function measure(url, bytes, timeoutMs = 30_000) {
const agent = makeAgent();
try {
const start = Date.now();
const res = await axios.get(url, {
responseType: 'arraybuffer',
timeout: timeoutMs,
httpAgent: agent,
httpsAgent: agent,
});
const downloaded = res.data.byteLength;
const elapsed = (Date.now() - start) / 1000;
if (elapsed <= 0 || downloaded === 0) throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'Speed test: no data received');
return { bytes: downloaded, seconds: elapsed };
} finally {
agent.destroy();
}
}
// Phase 0: Quick connectivity check — verify the SOCKS5 tunnel can reach the internet at all.
// Without this, nodes with working tunnels get marked as failures just because speedtest
// targets (CF, OVH, Tele2) are blocked by the node's ISP/firewall.
//
// Retry once: V2Ray SOCKS5 binding is async and variable. Even after waiting for the port
// to accept TCP connections, the proxy pipeline may not be fully ready. A single retry
// after a 3s pause catches slow-starting nodes that would otherwise be false failures.
const CONNECTIVITY_TARGETS = [
'https://www.google.com',
'https://www.cloudflare.com',
'https://one.one.one.one',
];
let tunnelConnected = false;
for (let attempt = 0; attempt < 2 && !tunnelConnected; attempt++) {
if (attempt > 0) await new Promise(r => setTimeout(r, 3000));
for (const target of CONNECTIVITY_TARGETS) {
const agent = makeAgent();
try {
await axios.get(target, { timeout: 10_000, httpAgent: agent, httpsAgent: agent, maxRedirects: 2, validateStatus: () => true });
tunnelConnected = true;
break;
} catch {} finally { agent.destroy(); }
}
}
if (!tunnelConnected) {
throw new TunnelError(ErrorCodes.WG_NO_CONNECTIVITY, 'SOCKS5 tunnel has no internet connectivity (google/cloudflare/1.1.1.1 all unreachable after 2 attempts)');
}
// Phase 1: 1MB single probe — try CF first, then fallback targets, then rescue with 60s timeout
let probe;
let probeSource = 'cloudflare';
try {
probe = await measure(`${CF_DOWN}?bytes=${PROBE_BYTES}`, PROBE_BYTES);
} catch {
// CF download failed via SOCKS5 — try fallback download targets
let fallbackOk = false;
for (const fb of FALLBACK_URLS) {
try {
probe = await measure(`https://${fb.host}${fb.path}`, fb.size);
probeSource = fb.host;
fallbackOk = true;
break;
} catch {}
}
if (!fallbackOk) {
// Last resort: retry CF with 60s timeout (slow tunnels need more time)
try {
probe = await measure(`${CF_DOWN}?bytes=${PROBE_BYTES}`, PROBE_BYTES, 60_000);
} catch {
// Tunnel IS connected (phase 0 passed) but all download targets are blocked.
// Use a timed GET of a known page as rough speed estimate instead of giving up.
const agent = makeAgent();
try {
const start = Date.now();
const res = await axios.get('https://www.google.com', {
responseType: 'arraybuffer', timeout: 15_000,
httpAgent: agent, httpsAgent: agent,
});
const bytes = res.data.byteLength;
const elapsed = (Date.now() - start) / 1000;
if (bytes > 0 && elapsed > 0) {
return { mbps: Math.max(bytesToMbps(bytes, elapsed, 2), 0.1), chunks: 1, adaptive: 'google-fallback' };
}
} catch {} finally { agent.destroy(); }
throw new TunnelError(ErrorCodes.TUNNEL_SETUP_FAILED, 'SOCKS5 speed test failed (CF and all fallbacks unreachable)');
}
}
}
const probeMbps = bytesToMbps(probe.bytes, probe.seconds, 2);
if (probeMbps < 3) {
return { mbps: probeMbps, chunks: 1, adaptive: 'probe-only' };
}
// Phase 2: Multi-request — 5 × 1MB sequential downloads, each with fresh SOCKS5 agent
let totalBytes = 0;
let successCount = 0;
const overallStart = Date.now();
for (let i = 0; i < CHUNK_COUNT; i++) {
try {
const r = await measure(`${CF_DOWN}?bytes=${CHUNK_BYTES}`, CHUNK_BYTES);
totalBytes += r.bytes;
successCount++;
} catch {
if (successCount === 0 && i === CHUNK_COUNT - 1) {
// All failed — return probe
return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
}
}
}
if (successCount === 0) {
return { mbps: probeMbps, chunks: 1, adaptive: 'probe-fallback' };
}
const totalElapsed = (Date.now() - overallStart) / 1000;
return { mbps: bytesToMbps(totalBytes, totalElapsed, 2), chunks: successCount, adaptive: 'multi-request' };
}
/** Pre-resolve CF hostname so WireGuard DNS issues don't affect speedtests. Call once at startup. */
export { resolveCfHost };
/**
* Resolve all speedtest target IPs (Cloudflare + fallbacks).
* Used for WireGuard split tunneling — only these IPs get routed through the tunnel.
* MUST be called BEFORE installing the tunnel (DNS won't work through a dead tunnel).
*/
export async function resolveSpeedtestIPs() {
await resolveCfHost();
await resolveFallbackHosts();
const ips = [];
if (cachedCfIp) ips.push(cachedCfIp);
for (const ip of Object.values(cachedFallbackIps)) {
if (ip) ips.push(ip);
}
return ips;
}
// ─── Speed Test Comparison (v25) ─────────────────────────────────────────────
/**
* Compare two speed test results. Returns delta and whether speed improved/degraded.
* Useful for before/after VPN comparison or detecting degradation.
*
* @param {object} before - SpeedResult from earlier test
* @param {object} after - SpeedResult from later test
* @returns {{ improved: boolean, degraded: boolean, delta: { downloadMbps: number, uploadMbps: number, latencyMs: number }, percentChange: { download: number, upload: number } }}
*/
export function compareSpeedTests(before, after) {
const dlDelta = (after.downloadMbps || 0) - (before.downloadMbps || 0);
const ulDelta = (after.uploadMbps || 0) - (before.uploadMbps || 0);
const latDelta = (after.latencyMs || 0) - (before.latencyMs || 0);
const dlPct = before.downloadMbps > 0 ? (dlDelta / before.downloadMbps) * 100 : 0;
const ulPct = before.uploadMbps > 0 ? (ulDelta / before.uploadMbps) * 100 : 0;
return {
improved: dlDelta > 0,
degraded: dlDelta < -1, // >1 Mbps drop = degraded
delta: {
downloadMbps: parseFloat(dlDelta.toFixed(2)),
uploadMbps: parseFloat(ulDelta.toFixed(2)),
latencyMs: Math.round(latDelta),
},
percentChange: {
download: parseFloat(dlPct.toFixed(1)),
upload: parseFloat(ulPct.toFixed(1)),
},
};
}
// ─── Post-Tunnel Google Accessibility Checks ────────────────────────────────
//
// Some nodes route Cloudflare fine but block Google (region-specific egress
// filtering). Speedtest passing != general internet works. These helpers do
// a cheap, latency-only HTTPS hit against `google.com` after the tunnel is
// up — useful as a fast pre-flight before a full speedtest, or as a separate
// "general internet works" signal in audit results.
//
// Two flavors:
// - checkGoogleDirect: for tunnels where all traffic is tunneled (WireGuard)
// - checkGoogleViaSocks5: for tunnels where traffic goes via local SOCKS5 (V2Ray)
//
// Direct check uses an external resolver (8.8.8.8 / 1.1.1.1) to resolve
// `www.google.com` BEFORE the tunnel may have working DNS, then issues HTTPS
// to the IP with `Host: www.google.com` + SNI. Works on tunnels that don't
// route DNS or that point to dead resolvers.
const GOOGLE_HOST = 'www.google.com';
const GOOGLE_DNS_CACHE_TTL = 5 * 60_000;
let cachedGoogleIp = null;
let cachedGoogleTime = 0;
/**
* Resolve `www.google.com` to an A record using public resolvers, with
* fallback to the system resolver and finally `dns.lookup`. Result is cached
* for {@link GOOGLE_DNS_CACHE_TTL} ms across calls in the same process.
*
* @returns {Promise<string|null>} IPv4 address or null if every resolver failed
*/
export async function resolveGoogleIp() {
if (cachedGoogleIp && Date.now() - cachedGoogleTime < GOOGLE_DNS_CACHE_TTL) return cachedGoogleIp;
try {
const resolver = new dns.Resolver();
resolver.setServers(['8.8.8.8', '1.1.1.1']);
const addrs = await new Promise((resolve, reject) => {
resolver.resolve4(GOOGLE_HOST, (err, addresses) => err ? reject(err) : resolve(addresses));
});
if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
} catch { }
try {
const addrs = await dns.promises.resolve4(GOOGLE_HOST);
if (addrs.length > 0) { cachedGoogleIp = addrs[0]; cachedGoogleTime = Date.now(); return cachedGoogleIp; }
} catch { }
try {
const { address } = await dns.promises.lookup(GOOGLE_HOST);
cachedGoogleIp = address;
cachedGoogleTime = Date.now();
return cachedGoogleIp;
} catch { }
return null;
}
/**
* Check if `google.com` is reachable through the ambient network path
* (typically a WireGuard tunnel where all traffic is tunneled). Tries the
* resolved IP first (with `Host` header + SNI), then falls back to the
* hostname directly. Any successful TLS handshake counts as reachable.
*
* @param {number} [timeoutMs=10000]
* @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
*/
export async function checkGoogleDirect(timeoutMs = 10_000) {
const start = Date.now();
const targetIp = await resolveGoogleIp();
const targets = [];
if (targetIp) targets.push(`https://${targetIp}/`);
targets.push(`https://${GOOGLE_HOST}/`);
for (const url of targets) {
try {
await new Promise((resolve, reject) => {
const parsed = new URL(url);
const options = {
hostname: parsed.hostname,
path: '/',
method: 'GET',
rejectUnauthorized: false,
agent: false,
headers: { Host: GOOGLE_HOST },
servername: GOOGLE_HOST,
};
const req = https.get(options, (res) => {
res.destroy();
resolve();
});
req.on('error', reject);
req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error('timeout')); });
});
return {
googleAccessible: true,
googleLatencyMs: Date.now() - start,
googleError: null,
};
} catch { }
}
return {
googleAccessible: false,
googleLatencyMs: null,
googleError: 'Google unreachable through tunnel',
};
}
/**
* Check if `google.com` is reachable through a V2Ray SOCKS5 proxy on
* localhost. Required because native Node `fetch` silently ignores SOCKS
* proxy agents — must use axios + SocksProxyAgent.
*
* @param {number} proxyPort - Local V2Ray SOCKS5 port (e.g. 1080)
* @param {number} [timeoutMs=10000]
* @returns {Promise<{ googleAccessible: boolean, googleLatencyMs: number|null, googleError: string|null }>}
*/
export async function checkGoogleViaSocks5(proxyPort, timeoutMs = 10_000) {
const start = Date.now();
const agent = new SocksProxyAgent(`socks5://127.0.0.1:${proxyPort}`);
try {
await axios.get(`https://${GOOGLE_HOST}/`, {
timeout: timeoutMs,
httpAgent: agent,
httpsAgent: agent,
maxRedirects: 2,
validateStatus: () => true,
});
return {
googleAccessible: true,
googleLatencyMs: Date.now() - start,
googleError: null,
};
} catch (err) {
return {
googleAccessible: false,
googleLatencyMs: null,
googleError: err.message || 'Google unreachable through SOCKS5',
};
} finally {
agent.destroy();
}
}