@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/.github/workflows/npm-publish.yml +10 -2
- package/CHANGELOG.md +63 -0
- package/CLAUDE.md +22 -0
- package/README.md +17 -0
- package/lib/browserexit.js +4 -4
- package/lib/browserhealth.js +1 -1
- package/lib/fingerprint.js +320 -157
- package/lib/nettools.js +28 -2
- package/lib/proxy.js +57 -23
- package/lib/redirect.js +1 -1
- package/lib/socks-relay.js +244 -36
- package/nwss.js +74 -13
- package/package.json +1 -1
- package/scripts/test-stealth.js +281 -0
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) => {
|
|
@@ -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 ?
|
|
392
|
+
const auth = parsed.username ? '[redacted]@' : '';
|
|
351
393
|
return `${parsed.protocol}://${auth}${parsed.host}:${parsed.port}`;
|
|
352
394
|
}
|
|
353
395
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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().
|
|
65
|
+
if (!page.browser().connected) {
|
|
66
66
|
if (forceDebug) {
|
|
67
67
|
console.log(formatLogMessage('debug', 'JS redirect detector skipped - browser disconnected'));
|
|
68
68
|
}
|
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,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
|
-
|
|
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
|
-
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
|
-
*
|
|
254
|
-
*
|
|
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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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 };
|