@fanboynz/network-scanner 3.0.1 → 3.0.3
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/.github/workflows/npm-publish.yml +4 -1
- package/CHANGELOG.md +68 -0
- package/lib/fingerprint.js +318 -44
- package/lib/nettools.js +28 -2
- package/lib/proxy.js +48 -21
- package/lib/socks-relay.js +242 -47
- package/nwss.js +71 -10
- package/package.json +1 -1
- package/scripts/test-stealth.js +39 -13
package/lib/proxy.js
CHANGED
|
@@ -61,13 +61,19 @@
|
|
|
61
61
|
* // After page creation, before page.goto()
|
|
62
62
|
* await applyProxyAuth(page, siteConfig, forceDebug);
|
|
63
63
|
*
|
|
64
|
-
* @version 1.
|
|
64
|
+
* @version 1.2.0
|
|
65
65
|
*/
|
|
66
66
|
|
|
67
|
+
const net = require('net');
|
|
67
68
|
const { formatLogMessage } = require('./colorize');
|
|
68
|
-
const { ensureRelay, getRelayPort } = require('./socks-relay');
|
|
69
|
+
const { ensureRelay, getRelayPort, closeAllRelays: closeAllSocksRelays } = require('./socks-relay');
|
|
70
|
+
|
|
71
|
+
// Note: no separate subsystem TAG here — formatLogMessage('proxy', ...)
|
|
72
|
+
// already emits the `[proxy]` prefix from the severity. socks-relay.js's
|
|
73
|
+
// pattern (`[proxy] [socks-relay] ...`) is correct THERE because its
|
|
74
|
+
// module name differs from the severity. For this file the module IS the
|
|
75
|
+
// severity, so a second '[proxy]' would be redundant double-prefix.
|
|
69
76
|
|
|
70
|
-
const PROXY_MODULE_VERSION = '1.2.0';
|
|
71
77
|
const SUPPORTED_PROTOCOLS = ['socks5', 'socks4', 'http', 'https'];
|
|
72
78
|
|
|
73
79
|
const DEFAULT_PORTS = {
|
|
@@ -115,6 +121,10 @@ function parseProxyUrl(proxyUrl) {
|
|
|
115
121
|
if (!host) return null;
|
|
116
122
|
|
|
117
123
|
const port = parseInt(url.port, 10) || DEFAULT_PORTS[protocol] || 1080;
|
|
124
|
+
// Reject obvious typos at parse time rather than passing a >65535 port
|
|
125
|
+
// through to Chromium and getting an opaque downstream error. Port 0
|
|
126
|
+
// is technically OS-assigned but never a valid proxy target.
|
|
127
|
+
if (port < 1 || port > 65535) return null;
|
|
118
128
|
// decodeURIComponent throws URIError on a literal '%' that isn't a valid
|
|
119
129
|
// escape (e.g. a password containing '%'). Fall back to the raw value so
|
|
120
130
|
// an otherwise-valid proxy isn't rejected as "Invalid proxy URL".
|
|
@@ -186,7 +196,19 @@ function getProxyArgs(siteConfig, forceDebug = false) {
|
|
|
186
196
|
|
|
187
197
|
const parsed = parseProxyUrl(proxyUrl);
|
|
188
198
|
if (!parsed) {
|
|
189
|
-
|
|
199
|
+
// Strip user:pass before echoing the URL — same redaction policy as
|
|
200
|
+
// getProxyInfo() / applyProxyAuth / socks-relay logs. Without this, a
|
|
201
|
+
// proxy URL with embedded creds (`socks5://user:pass@host:port`) that
|
|
202
|
+
// fails parse (typo in protocol, port out of range, etc.) leaks the
|
|
203
|
+
// raw creds to stderr. Regex handles both scheme-prefixed
|
|
204
|
+
// (`socks5://user:pass@`) and bare (`user:pass@`) forms — the latter
|
|
205
|
+
// because parseProxyUrl normalises bare host:port internally so the
|
|
206
|
+
// user-supplied string still reaches here unchanged.
|
|
207
|
+
const safeUrl = String(proxyUrl).replace(
|
|
208
|
+
/^([a-z0-9+]+:\/\/)?[^@\s]+@/i,
|
|
209
|
+
(_m, scheme) => `${scheme || ''}[redacted]@`
|
|
210
|
+
);
|
|
211
|
+
console.warn(formatLogMessage('proxy', `Invalid proxy URL: ${safeUrl}`));
|
|
190
212
|
return [];
|
|
191
213
|
}
|
|
192
214
|
|
|
@@ -249,10 +271,20 @@ function getProxyArgs(siteConfig, forceDebug = false) {
|
|
|
249
271
|
* Applies proxy authentication to a page via Puppeteer's authenticate API.
|
|
250
272
|
* Must be called BEFORE page.goto().
|
|
251
273
|
*
|
|
274
|
+
* Returns `true` only on a successful HTTP/HTTPS page.authenticate() call.
|
|
275
|
+
* Returns `false` in five distinct scenarios — callers cannot use the
|
|
276
|
+
* boolean to distinguish them; treat `false` as "no further action needed
|
|
277
|
+
* from this module" rather than "auth failed":
|
|
278
|
+
* - no proxy configured
|
|
279
|
+
* - proxy has no username (anonymous)
|
|
280
|
+
* - SOCKS5 with creds -> the local relay handles upstream auth out-of-band
|
|
281
|
+
* - SOCKS4 with creds -> genuinely unsupported (warned)
|
|
282
|
+
* - page.authenticate() threw (warned)
|
|
283
|
+
*
|
|
252
284
|
* @param {object} page - Puppeteer page instance
|
|
253
285
|
* @param {object} siteConfig
|
|
254
286
|
* @param {boolean} forceDebug
|
|
255
|
-
* @returns {Promise<boolean>}
|
|
287
|
+
* @returns {Promise<boolean>}
|
|
256
288
|
*/
|
|
257
289
|
async function applyProxyAuth(page, siteConfig, forceDebug = false) {
|
|
258
290
|
const proxyUrl = getConfiguredProxy(siteConfig);
|
|
@@ -283,7 +315,11 @@ async function applyProxyAuth(page, siteConfig, forceDebug = false) {
|
|
|
283
315
|
|
|
284
316
|
const debug = forceDebug || siteConfig.proxy_debug || siteConfig.socks5_debug;
|
|
285
317
|
if (debug) {
|
|
286
|
-
|
|
318
|
+
// Redact the username — same policy as getProxyInfo() and the
|
|
319
|
+
// socks-relay logs. debug output gets pasted into support tickets /
|
|
320
|
+
// screenshots / gists; '[redacted]' keeps the "yes, creds were
|
|
321
|
+
// attached" signal without disclosing what they were.
|
|
322
|
+
console.log(formatLogMessage('proxy', `Auth set for [redacted]@${parsed.host}:${parsed.port}`));
|
|
287
323
|
}
|
|
288
324
|
|
|
289
325
|
return true;
|
|
@@ -311,7 +347,6 @@ async function testProxy(siteConfig, timeoutMs = 5000) {
|
|
|
311
347
|
return { reachable: false, latencyMs: 0, error: 'Invalid proxy URL' };
|
|
312
348
|
}
|
|
313
349
|
|
|
314
|
-
const net = require('net');
|
|
315
350
|
const start = Date.now();
|
|
316
351
|
|
|
317
352
|
return new Promise((resolve) => {
|
|
@@ -358,15 +393,11 @@ function getProxyInfo(siteConfig) {
|
|
|
358
393
|
return `${parsed.protocol}://${auth}${parsed.host}:${parsed.port}`;
|
|
359
394
|
}
|
|
360
395
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Re-export relay teardown so nwss.js cleanup paths can close listeners.
|
|
369
|
-
const { closeAllRelays: closeAllSocksRelays } = require('./socks-relay');
|
|
396
|
+
// getModuleInfo() / PROXY_MODULE_VERSION / SUPPORTED_PROTOCOLS / and now
|
|
397
|
+
// getConfiguredProxy removed from exports -- zero external callers (mirrors
|
|
398
|
+
// the same trim done in lib/cloudflare.js). SUPPORTED_PROTOCOLS and
|
|
399
|
+
// getConfiguredProxy stay as module-local since parseProxyUrl /
|
|
400
|
+
// needsProxy / prepareSocksRelays / getProxyArgs use them.
|
|
370
401
|
|
|
371
402
|
module.exports = {
|
|
372
403
|
parseProxyUrl,
|
|
@@ -376,9 +407,5 @@ module.exports = {
|
|
|
376
407
|
getProxyArgs,
|
|
377
408
|
applyProxyAuth,
|
|
378
409
|
testProxy,
|
|
379
|
-
getProxyInfo
|
|
380
|
-
getModuleInfo,
|
|
381
|
-
getConfiguredProxy,
|
|
382
|
-
PROXY_MODULE_VERSION,
|
|
383
|
-
SUPPORTED_PROTOCOLS
|
|
410
|
+
getProxyInfo
|
|
384
411
|
};
|
package/lib/socks-relay.js
CHANGED
|
@@ -22,9 +22,24 @@ const { SocksClient } = require('socks');
|
|
|
22
22
|
const { formatLogMessage, messageColors } = require('./colorize');
|
|
23
23
|
const SOCKS_RELAY_TAG = messageColors.processing('[socks-relay]');
|
|
24
24
|
|
|
25
|
-
// upstreamKey -> {
|
|
25
|
+
// upstreamKey -> {
|
|
26
|
+
// server: net.Server, // listening on 127.0.0.1:port
|
|
27
|
+
// port: number, // OS-assigned local port
|
|
28
|
+
// activeSockets: Set<net.Socket>, // live client sockets (Chromium side)
|
|
29
|
+
// errors: number // cumulative upstream-connect failures
|
|
30
|
+
// }
|
|
26
31
|
const _relays = new Map();
|
|
27
32
|
|
|
33
|
+
// upstreamKey -> Promise<port> currently initialising. Singleflight guard for
|
|
34
|
+
// ensureRelay so two concurrent callers for the same upstream share one
|
|
35
|
+
// in-flight init instead of both creating servers and racing to _relays.set,
|
|
36
|
+
// where the loser's server would be orphaned (listening forever, never
|
|
37
|
+
// closed by closeAllRelays). Not triggered by current usage (proxy.js's
|
|
38
|
+
// prepareSocksRelays uses a sequential await loop) but cheap defence
|
|
39
|
+
// against future callers that don't know to serialise. Mirrors the
|
|
40
|
+
// pendingDigLookups / pendingWhoisLookups pattern in lib/nettools.js.
|
|
41
|
+
const _pendingRelays = new Map();
|
|
42
|
+
|
|
28
43
|
function upstreamKey(u) {
|
|
29
44
|
return `${u.host}:${u.port}:${u.username || ''}`;
|
|
30
45
|
}
|
|
@@ -39,7 +54,28 @@ function upstreamKey(u) {
|
|
|
39
54
|
// notices (default ~2 hours on Linux).
|
|
40
55
|
const HANDSHAKE_TIMEOUT_MS = 10000;
|
|
41
56
|
|
|
42
|
-
|
|
57
|
+
// Cap pre-piping buffer growth. A real SOCKS5 greeting+request is well
|
|
58
|
+
// under 300 bytes; absorbing more before the watchdog fires lets a hostile
|
|
59
|
+
// or buggy local process drip-feed garbage to pin memory for up to 10s.
|
|
60
|
+
// 4096 is the next clean ceiling above any realistic handshake and matches
|
|
61
|
+
// typical TCP receive-buffer batches.
|
|
62
|
+
const MAX_HANDSHAKE_BYTES = 4096;
|
|
63
|
+
|
|
64
|
+
// Cap simultaneous local connections per relay. If Chromium opens more than
|
|
65
|
+
// this (prefetch-heavy site, fetch-retry loop), excess gets refused at the
|
|
66
|
+
// TCP-accept layer, which Chromium's HTTP retry logic handles cleanly. The
|
|
67
|
+
// alternative (no cap) is excess tunnels opening to the upstream and the
|
|
68
|
+
// provider silently dropping them past its concurrent-tunnel quota — looks
|
|
69
|
+
// to the scan like random missed requests.
|
|
70
|
+
const MAX_LOCAL_CONNECTIONS = 256;
|
|
71
|
+
|
|
72
|
+
// On closeAllRelays, give in-flight tunnels this long to drain their
|
|
73
|
+
// response data into Chromium before force-destroying. Without it, SIGINT
|
|
74
|
+
// mid-scan loses any upstream bytes that hadn't yet hit Puppeteer's
|
|
75
|
+
// response listener, leaving incomplete entries in results.json.
|
|
76
|
+
const DRAIN_TIMEOUT_MS = 2000;
|
|
77
|
+
|
|
78
|
+
function handleClient(client, upstream, forceDebug, relay) {
|
|
43
79
|
let phase = 'greeting';
|
|
44
80
|
let buf = Buffer.alloc(0);
|
|
45
81
|
let upstreamSock = null;
|
|
@@ -73,6 +109,21 @@ function handleClient(client, upstream, forceDebug) {
|
|
|
73
109
|
|
|
74
110
|
const onData = async (chunk) => {
|
|
75
111
|
buf = Buffer.concat([buf, chunk]);
|
|
112
|
+
// Reject oversize pre-piping buffers before the 10s watchdog. Sends
|
|
113
|
+
// a protocol-appropriate failure reply per phase so a misbehaving but
|
|
114
|
+
// RFC-aware client gets a clean signal rather than a raw connection
|
|
115
|
+
// drop. Skipped once piping starts (buf is nulled then anyway).
|
|
116
|
+
if (phase !== 'piping' && buf.length > MAX_HANDSHAKE_BYTES) {
|
|
117
|
+
if (forceDebug) {
|
|
118
|
+
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} handshake oversize (${buf.length} bytes, phase=${phase}) — closing`));
|
|
119
|
+
}
|
|
120
|
+
if (phase === 'greeting') {
|
|
121
|
+
try { client.write(Buffer.from([0x05, 0xFF])); } catch (_) {} // no acceptable methods
|
|
122
|
+
} else if (phase === 'request') {
|
|
123
|
+
failReply(client, 0x01); // general SOCKS server failure
|
|
124
|
+
}
|
|
125
|
+
return cleanup();
|
|
126
|
+
}
|
|
76
127
|
try {
|
|
77
128
|
if (phase === 'greeting') {
|
|
78
129
|
// [0x05, NMETHODS, METHODS...]
|
|
@@ -134,6 +185,17 @@ function handleClient(client, upstream, forceDebug) {
|
|
|
134
185
|
phase = 'connecting';
|
|
135
186
|
client.pause();
|
|
136
187
|
client.off('data', onData);
|
|
188
|
+
// Disarm the handshake watchdog now (not later at 'piping'). The
|
|
189
|
+
// client has completed its SOCKS5 negotiation; the remaining wait
|
|
190
|
+
// is on SocksClient.createConnection (which has its own 20s
|
|
191
|
+
// timeout below). Without this, if the upstream connect takes
|
|
192
|
+
// longer than HANDSHAKE_TIMEOUT_MS (10s) but less than 20s, the
|
|
193
|
+
// watchdog fires cleanup mid-await — destroying the client and
|
|
194
|
+
// setting settled=true — then the upstream connect resolves into
|
|
195
|
+
// a fresh socket that cleanup() can no longer destroy (settled
|
|
196
|
+
// guard short-circuits), orphaning an open TCP connection until
|
|
197
|
+
// OS-level timeout or remote close.
|
|
198
|
+
if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
|
|
137
199
|
const early = buf.subarray(hdrLen); // any bytes after the request header
|
|
138
200
|
buf = null;
|
|
139
201
|
|
|
@@ -152,6 +214,10 @@ function handleClient(client, upstream, forceDebug) {
|
|
|
152
214
|
timeout: 20000,
|
|
153
215
|
});
|
|
154
216
|
} catch (e) {
|
|
217
|
+
// Bump the per-relay error counter (exposed via getRelayStats)
|
|
218
|
+
// so post-scan diagnostics can see "X of N upstream connects
|
|
219
|
+
// failed" without re-running with forceDebug.
|
|
220
|
+
relay.errors++;
|
|
155
221
|
if (forceDebug) {
|
|
156
222
|
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} upstream connect failed (${host}:${port}): ${e.message}`));
|
|
157
223
|
}
|
|
@@ -160,7 +226,28 @@ function handleClient(client, upstream, forceDebug) {
|
|
|
160
226
|
}
|
|
161
227
|
|
|
162
228
|
upstreamSock = info.socket;
|
|
229
|
+
// Safety net: if cleanup() ran while we were awaiting the upstream
|
|
230
|
+
// connect (some path other than the handshake watchdog — e.g. a
|
|
231
|
+
// 'close' event on the client during pause), settled is true and
|
|
232
|
+
// cleanup's settled guard would short-circuit a future call,
|
|
233
|
+
// orphaning this freshly-connected upstream socket. Destroy it
|
|
234
|
+
// here directly. With Fix #1a moving the watchdog clearTimeout to
|
|
235
|
+
// the 'connecting' transition this is currently unreachable, but
|
|
236
|
+
// cheap to keep as defense-in-depth against future code paths.
|
|
237
|
+
if (settled) {
|
|
238
|
+
try { upstreamSock.destroy(); } catch (_) {}
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
163
241
|
try { upstreamSock.setNoDelay(true); } catch (_) {}
|
|
242
|
+
// Catch silently-dead upstreams (NAT timeout, mobile-tower drop,
|
|
243
|
+
// proxy crash without FIN/RST) faster than the default ~2-hour
|
|
244
|
+
// Linux idle. setKeepAlive(true, 60000) sets TCP_KEEPIDLE only —
|
|
245
|
+
// the kernel still uses tcp_keepalive_intvl/tcp_keepalive_probes
|
|
246
|
+
// for the probe phase, so total detection is ~60s idle + N probes
|
|
247
|
+
// (default 9 × 75s on Linux) ≈ 12 minutes. Big improvement over
|
|
248
|
+
// 2h, not the 60s the bare argument suggests. Client-side keep-
|
|
249
|
+
// alive is omitted — same kernel, OS surfaces death immediately.
|
|
250
|
+
try { upstreamSock.setKeepAlive(true, 60000); } catch (_) {}
|
|
164
251
|
upstreamSock.on('error', cleanup);
|
|
165
252
|
upstreamSock.on('close', cleanup);
|
|
166
253
|
client.on('error', cleanup);
|
|
@@ -173,9 +260,8 @@ function handleClient(client, upstream, forceDebug) {
|
|
|
173
260
|
upstreamSock.pipe(client);
|
|
174
261
|
client.resume();
|
|
175
262
|
phase = 'piping';
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
|
|
263
|
+
// (handshakeTimer was already disarmed at the 'connecting'
|
|
264
|
+
// transition above; no second clearTimeout needed.)
|
|
179
265
|
}
|
|
180
266
|
} catch (e) {
|
|
181
267
|
if (forceDebug) {
|
|
@@ -207,41 +293,86 @@ async function ensureRelay(upstream, forceDebug = false) {
|
|
|
207
293
|
const existing = _relays.get(key);
|
|
208
294
|
if (existing) return existing.port;
|
|
209
295
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
296
|
+
// Singleflight: if another caller is already initialising this upstream,
|
|
297
|
+
// ride its promise instead of starting a parallel init. Prevents the
|
|
298
|
+
// race where two concurrent callers both pass the _relays.get(key) check
|
|
299
|
+
// above, both create servers, and the second _relays.set(key, ...) below
|
|
300
|
+
// orphans the first server (listening forever, never closed).
|
|
301
|
+
if (_pendingRelays.has(key)) return _pendingRelays.get(key);
|
|
302
|
+
|
|
303
|
+
// Most authenticated SOCKS5 servers reject empty-password auth at the
|
|
304
|
+
// RFC 1929 handshake; without this warn, the misconfig surfaces only
|
|
305
|
+
// per-request inside forceDebug-gated logs (silent in production).
|
|
306
|
+
// Fire once per unique upstream (after the existing-relay short-circuit
|
|
307
|
+
// above) so repeated calls don't spam.
|
|
308
|
+
if (upstream.username && !upstream.password) {
|
|
309
|
+
console.warn(formatLogMessage('warn', `${SOCKS_RELAY_TAG} upstream ${upstream.host}:${upstream.port} has username but no password — RFC 1929 auth will likely fail`));
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// .finally() (not try/finally inside the IIFE) so the cleanup is
|
|
313
|
+
// scheduled in a microtask, guaranteed to run AFTER the _pendingRelays.set
|
|
314
|
+
// below. If the cleanup were a try/finally inside an async IIFE and the
|
|
315
|
+
// body threw SYNCHRONOUSLY (before its first await), the finally would
|
|
316
|
+
// run SYNC before the implicit rejected promise was returned, _pendingRelays
|
|
317
|
+
// wouldn't be set yet, the delete would no-op, and then the outer .set
|
|
318
|
+
// would register a permanent rejected entry that future callers would
|
|
319
|
+
// await forever. The current body has no realistic sync-throw paths
|
|
320
|
+
// (net.createServer / Set / object literal don't throw), but defensive.
|
|
321
|
+
const initPromise = (async () => {
|
|
322
|
+
const activeSockets = new Set();
|
|
323
|
+
// Single mutable state object referenced by both the connection handler
|
|
324
|
+
// (writes .errors) and _relays / getRelayStats (read both). Server +
|
|
325
|
+
// port assigned after listen() completes; declared up-front so the
|
|
326
|
+
// closure below can close over `relayEntry` and pass it to handleClient.
|
|
327
|
+
const relayEntry = { server: null, port: null, activeSockets, errors: 0 };
|
|
328
|
+
|
|
329
|
+
const server = net.createServer((clientSock) => {
|
|
330
|
+
// Disable Nagle: page scanning is full of small-packet phases (per-origin
|
|
331
|
+
// TLS handshakes, small XHR/API calls, the SOCKS handshake itself).
|
|
332
|
+
// Nagle + delayed-ACK adds ~40ms stalls on those; relays should not.
|
|
333
|
+
try { clientSock.setNoDelay(true); } catch (_) {}
|
|
334
|
+
activeSockets.add(clientSock);
|
|
335
|
+
clientSock.on('close', () => activeSockets.delete(clientSock));
|
|
336
|
+
handleClient(clientSock, upstream, forceDebug, relayEntry);
|
|
337
|
+
});
|
|
220
338
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
server.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
339
|
+
// Shed excess connections at the TCP-accept layer instead of letting them
|
|
340
|
+
// all proceed to open authenticated tunnels (which the upstream provider
|
|
341
|
+
// may silently drop past its quota).
|
|
342
|
+
server.maxConnections = MAX_LOCAL_CONNECTIONS;
|
|
343
|
+
|
|
344
|
+
await new Promise((resolve, reject) => {
|
|
345
|
+
const onErr = (e) => reject(e);
|
|
346
|
+
server.once('error', onErr);
|
|
347
|
+
server.listen(0, '127.0.0.1', () => {
|
|
348
|
+
server.removeListener('error', onErr);
|
|
349
|
+
// Keep a listener so a late server error doesn't crash the process.
|
|
350
|
+
server.on('error', (e) => {
|
|
351
|
+
if (forceDebug) console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} server error: ${e.message}`));
|
|
352
|
+
});
|
|
353
|
+
resolve();
|
|
229
354
|
});
|
|
230
|
-
resolve();
|
|
231
355
|
});
|
|
356
|
+
|
|
357
|
+
relayEntry.server = server;
|
|
358
|
+
relayEntry.port = server.address().port;
|
|
359
|
+
_relays.set(key, relayEntry);
|
|
360
|
+
const port = relayEntry.port;
|
|
361
|
+
if (forceDebug) {
|
|
362
|
+
// auth status is kept as a presence flag only -- previously printed
|
|
363
|
+
// the raw username, which leaked into shared debug output (support
|
|
364
|
+
// tickets, screenshots, gists). Same redaction policy as the
|
|
365
|
+
// proxy.js getProxyInfo() change.
|
|
366
|
+
const authTag = upstream.username ? ' (auth: [redacted])' : ' (no auth)';
|
|
367
|
+
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} 127.0.0.1:${port} -> ${upstream.host}:${upstream.port}${authTag}`));
|
|
368
|
+
}
|
|
369
|
+
return port;
|
|
370
|
+
})().finally(() => {
|
|
371
|
+
_pendingRelays.delete(key);
|
|
232
372
|
});
|
|
233
373
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (forceDebug) {
|
|
237
|
-
// auth status is kept as a presence flag only -- previously printed
|
|
238
|
-
// the raw username, which leaked into shared debug output (support
|
|
239
|
-
// tickets, screenshots, gists). Same redaction policy as the
|
|
240
|
-
// proxy.js getProxyInfo() change.
|
|
241
|
-
const authTag = upstream.username ? ' (auth: [redacted])' : ' (no auth)';
|
|
242
|
-
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} 127.0.0.1:${port} -> ${upstream.host}:${upstream.port}${authTag}`));
|
|
243
|
-
}
|
|
244
|
-
return port;
|
|
374
|
+
_pendingRelays.set(key, initPromise);
|
|
375
|
+
return initPromise;
|
|
245
376
|
}
|
|
246
377
|
|
|
247
378
|
/**
|
|
@@ -255,26 +386,90 @@ function getRelayPort(upstream) {
|
|
|
255
386
|
}
|
|
256
387
|
|
|
257
388
|
/**
|
|
258
|
-
*
|
|
259
|
-
*
|
|
389
|
+
* Snapshot of active relays for diagnostics. Returns an array of
|
|
390
|
+
* { key, port, activeConnections, errors } — the upstream `key` has its
|
|
391
|
+
* trailing `:username` segment stripped using the same regex as
|
|
392
|
+
* closeAllRelays' display path (IPv6-safe). `errors` is the cumulative
|
|
393
|
+
* count of failed upstream-tunnel opens for the relay's lifetime.
|
|
394
|
+
* Useful for answering "is my proxy slow because the upstream is
|
|
395
|
+
* saturated, or because the scan is opening too many parallel tunnels?"
|
|
396
|
+
* without enabling forceDebug.
|
|
397
|
+
*/
|
|
398
|
+
function getRelayStats() {
|
|
399
|
+
const stats = [];
|
|
400
|
+
for (const [key, r] of _relays) {
|
|
401
|
+
stats.push({
|
|
402
|
+
key: key.replace(/:[^:]*$/, ''),
|
|
403
|
+
port: r.port,
|
|
404
|
+
activeConnections: r.activeSockets.size,
|
|
405
|
+
errors: r.errors
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
return stats;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Tear down every relay. Stops accepting new connections, gives in-flight
|
|
413
|
+
* tunnels up to DRAIN_TIMEOUT_MS (2s) to flush remaining response bytes
|
|
414
|
+
* into Chromium / Puppeteer, then force-destroys any stragglers. Safe to
|
|
415
|
+
* call multiple times (subsequent calls iterate an empty _relays Map).
|
|
260
416
|
*/
|
|
261
417
|
async function closeAllRelays(forceDebug = false) {
|
|
418
|
+
// Wait for any in-flight ensureRelay inits to finish before snapshotting
|
|
419
|
+
// _relays. Without this, a relay whose listen() completes AFTER our
|
|
420
|
+
// iteration starts would land in _relays unowned by closeAllRelays —
|
|
421
|
+
// leaked until next call or process exit. allSettled (not all) because
|
|
422
|
+
// a rejected init has already cleaned up its _pendingRelays entry via
|
|
423
|
+
// .finally; we just need to not throw here.
|
|
424
|
+
if (_pendingRelays.size > 0) {
|
|
425
|
+
await Promise.allSettled(Array.from(_pendingRelays.values()));
|
|
426
|
+
}
|
|
262
427
|
for (const [key, r] of _relays) {
|
|
263
|
-
|
|
264
|
-
|
|
428
|
+
// upstreamKey embeds the username (host:port:username), so the raw
|
|
429
|
+
// key would leak it in debug output. Strip just the trailing
|
|
430
|
+
// `:username` segment for display; using a regex (not split-on-':')
|
|
431
|
+
// so IPv6 hosts with embedded colons (e.g. 2001:db8::1:1080:user)
|
|
432
|
+
// aren't mangled. The relay identity stays unambiguous from host+port.
|
|
433
|
+
const displayKey = key.replace(/:[^:]*$/, '');
|
|
434
|
+
const startedWith = r.activeSockets.size;
|
|
435
|
+
|
|
436
|
+
// server.close() stops accepting new connections and resolves only
|
|
437
|
+
// when all existing sockets have closed naturally. Race that against
|
|
438
|
+
// DRAIN_TIMEOUT_MS: if active tunnels finish flushing in time,
|
|
439
|
+
// Chromium / Puppeteer gets the response bytes it was waiting for;
|
|
440
|
+
// beyond that, force-destroy stragglers (the close callback then
|
|
441
|
+
// fires immediately). Trade-off chosen so SIGINT mid-scan doesn't
|
|
442
|
+
// amputate in-flight responses but a hung tunnel can't block exit.
|
|
443
|
+
const closePromise = new Promise((res) => {
|
|
265
444
|
try { r.server.close(() => res()); } catch (_) { res(); }
|
|
266
445
|
});
|
|
446
|
+
|
|
447
|
+
let timer;
|
|
448
|
+
const drained = await Promise.race([
|
|
449
|
+
closePromise.then(() => { if (timer) clearTimeout(timer); return true; }),
|
|
450
|
+
new Promise((res) => {
|
|
451
|
+
timer = setTimeout(() => res(false), DRAIN_TIMEOUT_MS);
|
|
452
|
+
// Don't hold the event loop open on a still-pending drain timer
|
|
453
|
+
// when the close-promise won the race.
|
|
454
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
455
|
+
})
|
|
456
|
+
]);
|
|
457
|
+
|
|
458
|
+
let forcedCount = 0;
|
|
459
|
+
if (!drained) {
|
|
460
|
+
forcedCount = r.activeSockets.size;
|
|
461
|
+
for (const s of r.activeSockets) { try { s.destroy(); } catch (_) {} }
|
|
462
|
+
await closePromise; // resolves now that the last socket has closed
|
|
463
|
+
}
|
|
464
|
+
|
|
267
465
|
if (forceDebug) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
// aren't mangled. The relay identity stays unambiguous from host+port.
|
|
273
|
-
const displayKey = key.replace(/:[^:]*$/, '');
|
|
274
|
-
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} closed relay for ${displayKey}`));
|
|
466
|
+
const note = forcedCount > 0
|
|
467
|
+
? ` (drain timeout — force-closed ${forcedCount}/${startedWith} active socket(s))`
|
|
468
|
+
: (startedWith > 0 ? ` (drained ${startedWith} active socket(s))` : '');
|
|
469
|
+
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} closed relay for ${displayKey}${note}`));
|
|
275
470
|
}
|
|
276
471
|
}
|
|
277
472
|
_relays.clear();
|
|
278
473
|
}
|
|
279
474
|
|
|
280
|
-
module.exports = { ensureRelay, getRelayPort, closeAllRelays };
|
|
475
|
+
module.exports = { ensureRelay, getRelayPort, getRelayStats, closeAllRelays };
|