@fanboynz/network-scanner 3.0.0 → 3.0.2

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/lib/nettools.js CHANGED
@@ -418,6 +418,12 @@ function execFileWithTimeout(cmd, args, timeout = 10000) {
418
418
 
419
419
  reject(new Error(`Command timeout after ${timeout}ms: ${cmd} ${args.join(' ')}`));
420
420
  }, timeout);
421
+ // unref the outer timeout too — a hung dig/whois firing AFTER the
422
+ // per-URL drain (3s cap) already returned would otherwise hold the
423
+ // event loop alive for up to `timeout` (5-10s) on scan exit. The exec
424
+ // callback / 'error' handler still clear it via the existing
425
+ // clearTimeout, so this only matters for the genuinely-hung case.
426
+ if (typeof timer.unref === 'function') timer.unref();
421
427
 
422
428
  // Handle child process errors
423
429
  child.on('error', (err) => {
@@ -790,7 +796,17 @@ async function whoisLookupWithRetry(domain = '', timeout = 10000, whoisServer =
790
796
  console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${actualDelay}ms progressive delay before retry ${retryCount + 1} (base: ${baseDelay}ms + extra: ${extraDelay}ms)...`));
791
797
  }
792
798
  }
793
- await new Promise(resolve => setTimeout(resolve, actualDelay));
799
+ // unref the retry-delay timer so a pending backoff (up to ~30s on
800
+ // late attempts) can't hold the event loop alive past scan exit
801
+ // when the per-URL drain has already returned. If the process is
802
+ // still otherwise busy, the timer fires normally; if it's the only
803
+ // thing left, the process exits and the now-pointless retry result
804
+ // never lands. Same pattern as the execFile/overall timers
805
+ // unref'd in 83209d4.
806
+ await new Promise(resolve => {
807
+ const t = setTimeout(resolve, actualDelay);
808
+ if (typeof t.unref === 'function') t.unref();
809
+ });
794
810
  } else if (serverIndex > 0 && retryCount === 0 && whoisDelay > 0) {
795
811
  // Add delay before trying a new server (but not the very first server)
796
812
  if (debugMode) {
@@ -800,7 +816,11 @@ async function whoisLookupWithRetry(domain = '', timeout = 10000, whoisServer =
800
816
  console.log(formatLogMessage('debug', `${messageColors.highlight('[whois-retry]')} Adding ${whoisDelay}ms delay before trying new server...`));
801
817
  }
802
818
  }
803
- await new Promise(resolve => setTimeout(resolve, whoisDelay));
819
+ // Same unref rationale as the retry-delay timer above.
820
+ await new Promise(resolve => {
821
+ const t = setTimeout(resolve, whoisDelay);
822
+ if (typeof t.unref === 'function') t.unref();
823
+ });
804
824
  } else if (debugMode && whoisDelay === 0) {
805
825
  // Log when delay is skipped due to whoisDelay being 0
806
826
  if (logFunc) {
@@ -1232,6 +1252,12 @@ function createNetToolsHandler(config) {
1232
1252
  })(),
1233
1253
  new Promise((_, reject) => {
1234
1254
  overallTimeoutId = setTimeout(() => reject(new Error('NetTools overall timeout')), 65000);
1255
+ // unref so a still-pending overall timeout (handler returned via
1256
+ // drain at 3s but the lookup is technically still in-flight) can't
1257
+ // hold the event loop alive for the full 65s on scan exit. The
1258
+ // finally on the inner promise still clearTimeouts on natural
1259
+ // completion, so this only matters for the genuinely-hung case.
1260
+ if (typeof overallTimeoutId.unref === 'function') overallTimeoutId.unref();
1235
1261
  })
1236
1262
  ]).catch(err => {
1237
1263
  if (forceDebug) {
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.1.0
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
- console.warn(formatLogMessage('proxy', `Invalid proxy URL: ${proxyUrl}`));
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>} True if auth was applied
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
- console.log(formatLogMessage('proxy', `Auth set for ${parsed.username}@${parsed.host}:${parsed.port}`));
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) => {
@@ -335,7 +370,14 @@ async function testProxy(siteConfig, timeoutMs = 5000) {
335
370
  }
336
371
 
337
372
  /**
338
- * Returns human-readable proxy info string for logging.
373
+ * Returns human-readable proxy info string for logging. The auth portion
374
+ * is REDACTED -- previously the username was emitted verbatim, which
375
+ * meant any error log line carrying this value (see nwss.js's
376
+ * ERR_SOCKS_CONNECTION_FAILED handler) leaked the proxy username to
377
+ * stderr / support tickets / screenshots. Password was already absent
378
+ * here. We keep an explicit `[redacted]@` marker when auth was configured
379
+ * so the reader still knows "yes, credentials were attached" without
380
+ * disclosing what they were.
339
381
  *
340
382
  * @param {object} siteConfig
341
383
  * @returns {string}
@@ -347,19 +389,15 @@ function getProxyInfo(siteConfig) {
347
389
  const parsed = parseProxyUrl(proxyUrl);
348
390
  if (!parsed) return 'invalid';
349
391
 
350
- const auth = parsed.username ? `${parsed.username}@` : '';
392
+ const auth = parsed.username ? '[redacted]@' : '';
351
393
  return `${parsed.protocol}://${auth}${parsed.host}:${parsed.port}`;
352
394
  }
353
395
 
354
- /**
355
- * Returns module version information
356
- */
357
- function getModuleInfo() {
358
- return { version: PROXY_MODULE_VERSION, name: 'Proxy Handler' };
359
- }
360
-
361
- // Re-export relay teardown so nwss.js cleanup paths can close listeners.
362
- 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.
363
401
 
364
402
  module.exports = {
365
403
  parseProxyUrl,
@@ -369,9 +407,5 @@ module.exports = {
369
407
  getProxyArgs,
370
408
  applyProxyAuth,
371
409
  testProxy,
372
- getProxyInfo,
373
- getModuleInfo,
374
- getConfiguredProxy,
375
- PROXY_MODULE_VERSION,
376
- SUPPORTED_PROTOCOLS
410
+ getProxyInfo
377
411
  };
package/lib/redirect.js CHANGED
@@ -62,7 +62,7 @@ async function navigateWithRedirectHandling(page, currentUrl, siteConfig, gotoOp
62
62
  }
63
63
 
64
64
  // Check if browser is still connected
65
- if (!page.browser().isConnected()) {
65
+ if (!page.browser().connected) {
66
66
  if (forceDebug) {
67
67
  console.log(formatLogMessage('debug', 'JS redirect detector skipped - browser disconnected'));
68
68
  }
@@ -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 -> { server, port, activeSockets:Set<net.Socket> }
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
- function handleClient(client, upstream, forceDebug) {
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
- // Negotiation complete disarm the handshake watchdog so a
177
- // long-running download isn't killed mid-transfer.
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,36 +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
- const activeSockets = new Set();
211
- const server = net.createServer((clientSock) => {
212
- // Disable Nagle: page scanning is full of small-packet phases (per-origin
213
- // TLS handshakes, small XHR/API calls, the SOCKS handshake itself).
214
- // Nagle + delayed-ACK adds ~40ms stalls on those; relays should not.
215
- try { clientSock.setNoDelay(true); } catch (_) {}
216
- activeSockets.add(clientSock);
217
- clientSock.on('close', () => activeSockets.delete(clientSock));
218
- handleClient(clientSock, upstream, forceDebug);
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
- await new Promise((resolve, reject) => {
222
- const onErr = (e) => reject(e);
223
- server.once('error', onErr);
224
- server.listen(0, '127.0.0.1', () => {
225
- server.removeListener('error', onErr);
226
- // Keep a listener so a late server error doesn't crash the process.
227
- server.on('error', (e) => {
228
- if (forceDebug) console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} server error: ${e.message}`));
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
- const port = server.address().port;
235
- _relays.set(key, { server, port, activeSockets });
236
- if (forceDebug) {
237
- console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} 127.0.0.1:${port} -> ${upstream.host}:${upstream.port} (auth user "${upstream.username}")`));
238
- }
239
- return port;
374
+ _pendingRelays.set(key, initPromise);
375
+ return initPromise;
240
376
  }
241
377
 
242
378
  /**
@@ -250,18 +386,90 @@ function getRelayPort(upstream) {
250
386
  }
251
387
 
252
388
  /**
253
- * Tear down every relay: destroy in-flight sockets, close listeners.
254
- * Safe to call multiple times.
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).
255
416
  */
256
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
+ }
257
427
  for (const [key, r] of _relays) {
258
- for (const s of r.activeSockets) { try { s.destroy(); } catch (_) {} }
259
- await new Promise((res) => {
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) => {
260
444
  try { r.server.close(() => res()); } catch (_) { res(); }
261
445
  });
262
- if (forceDebug) console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} closed relay for ${key}`));
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
+
465
+ if (forceDebug) {
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}`));
470
+ }
263
471
  }
264
472
  _relays.clear();
265
473
  }
266
474
 
267
- module.exports = { ensureRelay, getRelayPort, closeAllRelays };
475
+ module.exports = { ensureRelay, getRelayPort, getRelayStats, closeAllRelays };