@fanboynz/network-scanner 2.0.65 → 2.0.66
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/proxy.js +105 -7
- package/lib/socks-relay.js +266 -0
- package/nwss.js +140 -1
- package/package.json +4 -3
package/lib/proxy.js
CHANGED
|
@@ -18,6 +18,15 @@
|
|
|
18
18
|
*
|
|
19
19
|
* SOCKS5 with auth:
|
|
20
20
|
* "proxy": "socks5://user:pass@127.0.0.1:1080"
|
|
21
|
+
* Chromium itself cannot authenticate SOCKS5 (crbug.com/256785), so
|
|
22
|
+
* this module auto-starts an in-process no-auth SOCKS5 relay
|
|
23
|
+
* (lib/socks-relay.js) that does the upstream RFC 1929 auth. Chromium
|
|
24
|
+
* connects to the local relay (no auth — which it CAN do) and the
|
|
25
|
+
* relay tunnels to the authenticated upstream. Transparent: keep the
|
|
26
|
+
* socks5://user:pass@host form in config. Requires prepareSocksRelays()
|
|
27
|
+
* to be awaited once before the scan loop (nwss.js does this).
|
|
28
|
+
* NOTE: socks4 with auth is still unsupported (userId-only,
|
|
29
|
+
* near-extinct) — use socks5 or an authenticated HTTP proxy.
|
|
21
30
|
*
|
|
22
31
|
* HTTP proxy (corporate):
|
|
23
32
|
* "proxy": "http://proxy.corp.com:3128"
|
|
@@ -56,8 +65,9 @@
|
|
|
56
65
|
*/
|
|
57
66
|
|
|
58
67
|
const { formatLogMessage } = require('./colorize');
|
|
68
|
+
const { ensureRelay, getRelayPort } = require('./socks-relay');
|
|
59
69
|
|
|
60
|
-
const PROXY_MODULE_VERSION = '1.
|
|
70
|
+
const PROXY_MODULE_VERSION = '1.2.0';
|
|
61
71
|
const SUPPORTED_PROTOCOLS = ['socks5', 'socks4', 'http', 'https'];
|
|
62
72
|
|
|
63
73
|
const DEFAULT_PORTS = {
|
|
@@ -105,8 +115,12 @@ function parseProxyUrl(proxyUrl) {
|
|
|
105
115
|
if (!host) return null;
|
|
106
116
|
|
|
107
117
|
const port = parseInt(url.port, 10) || DEFAULT_PORTS[protocol] || 1080;
|
|
108
|
-
|
|
109
|
-
|
|
118
|
+
// decodeURIComponent throws URIError on a literal '%' that isn't a valid
|
|
119
|
+
// escape (e.g. a password containing '%'). Fall back to the raw value so
|
|
120
|
+
// an otherwise-valid proxy isn't rejected as "Invalid proxy URL".
|
|
121
|
+
const safeDecode = (v) => { try { return decodeURIComponent(v); } catch (_) { return v; } };
|
|
122
|
+
const username = url.username ? safeDecode(url.username) : null;
|
|
123
|
+
const password = url.password ? safeDecode(url.password) : null;
|
|
110
124
|
|
|
111
125
|
return { protocol, host, port, username, password };
|
|
112
126
|
} catch (_) {
|
|
@@ -124,6 +138,41 @@ function needsProxy(siteConfig) {
|
|
|
124
138
|
return !!getConfiguredProxy(siteConfig);
|
|
125
139
|
}
|
|
126
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Pre-start local no-auth SOCKS5 relays for every distinct authenticated
|
|
143
|
+
* SOCKS5 upstream across the given site configs. Must be awaited ONCE
|
|
144
|
+
* before the scan loop — getProxyArgs() then does a pure sync lookup of
|
|
145
|
+
* the relay port, so the fragile per-batch browser-launch path stays
|
|
146
|
+
* synchronous.
|
|
147
|
+
*
|
|
148
|
+
* @param {object[]} siteConfigs
|
|
149
|
+
* @param {boolean} forceDebug
|
|
150
|
+
* @returns {Promise<number>} count of relays started
|
|
151
|
+
*/
|
|
152
|
+
async function prepareSocksRelays(siteConfigs, forceDebug = false) {
|
|
153
|
+
let started = 0;
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const cfg of (siteConfigs || [])) {
|
|
156
|
+
const url = getConfiguredProxy(cfg);
|
|
157
|
+
if (!url) continue;
|
|
158
|
+
const parsed = parseProxyUrl(url);
|
|
159
|
+
// Only socks5 with credentials needs a relay. socks4-auth stays
|
|
160
|
+
// unsupported (near-extinct, userId-only); http/https auth works
|
|
161
|
+
// natively via page.authenticate().
|
|
162
|
+
if (!parsed || parsed.protocol !== 'socks5' || !parsed.username) continue;
|
|
163
|
+
const key = `${parsed.host}:${parsed.port}:${parsed.username}`;
|
|
164
|
+
if (seen.has(key)) continue;
|
|
165
|
+
seen.add(key);
|
|
166
|
+
try {
|
|
167
|
+
await ensureRelay(parsed, forceDebug);
|
|
168
|
+
started++;
|
|
169
|
+
} catch (e) {
|
|
170
|
+
console.warn(formatLogMessage('proxy', `Failed to start SOCKS5 auth relay for ${parsed.host}:${parsed.port}: ${e.message}`));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return started;
|
|
174
|
+
}
|
|
175
|
+
|
|
127
176
|
/**
|
|
128
177
|
* Returns Chromium launch arguments for the configured proxy.
|
|
129
178
|
*
|
|
@@ -141,15 +190,45 @@ function getProxyArgs(siteConfig, forceDebug = false) {
|
|
|
141
190
|
return [];
|
|
142
191
|
}
|
|
143
192
|
|
|
193
|
+
// Authenticated SOCKS5: Chromium can't auth SOCKS, so point it at the
|
|
194
|
+
// local no-auth relay (started upfront by prepareSocksRelays) which does
|
|
195
|
+
// the upstream auth. Credentials never reach Chromium. The relay speaks
|
|
196
|
+
// SOCKS5 and forwards domain addresses, so the remote-DNS rule below
|
|
197
|
+
// still applies correctly to the localhost hop.
|
|
198
|
+
let effectiveHost = parsed.host;
|
|
199
|
+
let effectivePort = parsed.port;
|
|
200
|
+
let effectiveProto = parsed.protocol;
|
|
201
|
+
if (parsed.protocol === 'socks5' && parsed.username) {
|
|
202
|
+
const relayPort = getRelayPort(parsed);
|
|
203
|
+
if (relayPort) {
|
|
204
|
+
effectiveHost = '127.0.0.1';
|
|
205
|
+
effectivePort = relayPort;
|
|
206
|
+
const debug = forceDebug || siteConfig.proxy_debug || siteConfig.socks5_debug;
|
|
207
|
+
if (debug) {
|
|
208
|
+
console.log(formatLogMessage('proxy', `SOCKS5 auth via local relay 127.0.0.1:${relayPort} -> ${parsed.host}:${parsed.port}`));
|
|
209
|
+
}
|
|
210
|
+
} else {
|
|
211
|
+
// prepareSocksRelays should have started this; defensive only.
|
|
212
|
+
console.warn(formatLogMessage('proxy', `No SOCKS5 auth relay for ${parsed.host}:${parsed.port} — call prepareSocksRelays() before the scan. Connection will fail (Chromium can't auth SOCKS).`));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
144
216
|
const args = [
|
|
145
|
-
`--proxy-server=${
|
|
217
|
+
`--proxy-server=${effectiveProto}://${effectiveHost}:${effectivePort}`
|
|
146
218
|
];
|
|
147
219
|
|
|
148
|
-
// Remote DNS:
|
|
149
|
-
//
|
|
220
|
+
// Remote DNS: force proxy-side hostname resolution (prevents DNS leaks).
|
|
221
|
+
// SOCKS5 only — it can carry a hostname to the proxy for remote
|
|
222
|
+
// resolution. SOCKS4 cannot (the protocol only accepts an IPv4 address;
|
|
223
|
+
// resolution must happen client-side), so applying MAP * ~NOTFOUND there
|
|
224
|
+
// makes Chromium's local resolver fail with nothing able to resolve the
|
|
225
|
+
// hostname — every request breaks. HTTP/HTTPS proxies resolve remotely
|
|
226
|
+
// by default and need no rule.
|
|
150
227
|
const remoteDns = siteConfig.proxy_remote_dns ?? siteConfig.socks5_remote_dns;
|
|
151
|
-
if (
|
|
228
|
+
if (parsed.protocol === 'socks5' && remoteDns !== false) {
|
|
152
229
|
args.push('--host-resolver-rules=MAP * ~NOTFOUND , EXCLUDE 127.0.0.1');
|
|
230
|
+
} else if (parsed.protocol === 'socks4' && remoteDns === true) {
|
|
231
|
+
console.warn(formatLogMessage('proxy', `proxy_remote_dns ignored: SOCKS4 cannot do proxy-side DNS resolution (use SOCKS5)`));
|
|
153
232
|
}
|
|
154
233
|
|
|
155
234
|
// Bypass list: domains that skip the proxy
|
|
@@ -182,6 +261,20 @@ async function applyProxyAuth(page, siteConfig, forceDebug = false) {
|
|
|
182
261
|
const parsed = parseProxyUrl(proxyUrl);
|
|
183
262
|
if (!parsed || !parsed.username) return false;
|
|
184
263
|
|
|
264
|
+
// Chromium can't authenticate SOCKS proxies, and page.authenticate() is
|
|
265
|
+
// HTTP-407-only. SOCKS5+creds is handled out-of-band by the local
|
|
266
|
+
// no-auth relay (prepareSocksRelays + getProxyArgs rewrite) — Chromium
|
|
267
|
+
// talks no-auth to 127.0.0.1, so there's nothing for page.authenticate
|
|
268
|
+
// to do here; return quietly. SOCKS4 auth (userId-only, near-extinct)
|
|
269
|
+
// stays genuinely unsupported.
|
|
270
|
+
if (parsed.protocol === 'socks5') {
|
|
271
|
+
return false; // relay handles upstream auth
|
|
272
|
+
}
|
|
273
|
+
if (parsed.protocol === 'socks4') {
|
|
274
|
+
console.warn(formatLogMessage('proxy', `SOCKS4 proxy auth is unsupported (use SOCKS5, which is auto-relayed, or an authenticated HTTP proxy).`));
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
|
|
185
278
|
try {
|
|
186
279
|
await page.authenticate({
|
|
187
280
|
username: parsed.username,
|
|
@@ -265,9 +358,14 @@ function getModuleInfo() {
|
|
|
265
358
|
return { version: PROXY_MODULE_VERSION, name: 'Proxy Handler' };
|
|
266
359
|
}
|
|
267
360
|
|
|
361
|
+
// Re-export relay teardown so nwss.js cleanup paths can close listeners.
|
|
362
|
+
const { closeAllRelays: closeAllSocksRelays } = require('./socks-relay');
|
|
363
|
+
|
|
268
364
|
module.exports = {
|
|
269
365
|
parseProxyUrl,
|
|
270
366
|
needsProxy,
|
|
367
|
+
prepareSocksRelays,
|
|
368
|
+
closeAllSocksRelays,
|
|
271
369
|
getProxyArgs,
|
|
272
370
|
applyProxyAuth,
|
|
273
371
|
testProxy,
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local no-auth SOCKS5 relay for authenticated SOCKS5 upstreams.
|
|
3
|
+
*
|
|
4
|
+
* Chromium cannot authenticate SOCKS5 proxies (crbug.com/256785 — it only
|
|
5
|
+
* implements the no-auth method 0x00; credentials in --proxy-server are
|
|
6
|
+
* discarded, and page.authenticate() is HTTP-407-only so it can't help —
|
|
7
|
+
* SOCKS auth happens at the TCP handshake before any HTTP).
|
|
8
|
+
*
|
|
9
|
+
* Workaround: run an in-process no-auth SOCKS5 server bound to 127.0.0.1.
|
|
10
|
+
* Chromium connects to it without auth (which it CAN do); for each
|
|
11
|
+
* connection we open an authenticated tunnel to the real upstream via the
|
|
12
|
+
* `socks` package (RFC 1929 user/pass) and pipe the two together. Domain
|
|
13
|
+
* address types are forwarded as hostnames so remote DNS still works
|
|
14
|
+
* end-to-end (no DNS leak).
|
|
15
|
+
*
|
|
16
|
+
* Relays are keyed by upstream identity and reused. closeAllRelays() must
|
|
17
|
+
* be called on scan exit / signal so listening sockets don't leak.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const net = require('net');
|
|
21
|
+
const { SocksClient } = require('socks');
|
|
22
|
+
const { formatLogMessage } = require('./colorize');
|
|
23
|
+
|
|
24
|
+
// upstreamKey -> { server, port, activeSockets:Set<net.Socket> }
|
|
25
|
+
const _relays = new Map();
|
|
26
|
+
|
|
27
|
+
function upstreamKey(u) {
|
|
28
|
+
return `${u.host}:${u.port}:${u.username || ''}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Handle one Chromium->relay connection: minimal SOCKS5 server handshake,
|
|
33
|
+
* then an authenticated upstream tunnel, then bidirectional pipe.
|
|
34
|
+
*/
|
|
35
|
+
// Bail on a client that connects and never completes SOCKS5 negotiation.
|
|
36
|
+
// Generous enough for a Chromium loopback handshake (microseconds), short
|
|
37
|
+
// enough to catch a stalled / half-open client before the OS TCP keepalive
|
|
38
|
+
// notices (default ~2 hours on Linux).
|
|
39
|
+
const HANDSHAKE_TIMEOUT_MS = 10000;
|
|
40
|
+
|
|
41
|
+
function handleClient(client, upstream, forceDebug) {
|
|
42
|
+
let phase = 'greeting';
|
|
43
|
+
let buf = Buffer.alloc(0);
|
|
44
|
+
let upstreamSock = null;
|
|
45
|
+
let settled = false;
|
|
46
|
+
// Handshake-phase watchdog handle. Assigned after cleanup is declared so
|
|
47
|
+
// both references in this scope resolve unambiguously.
|
|
48
|
+
let handshakeTimer = null;
|
|
49
|
+
|
|
50
|
+
const cleanup = () => {
|
|
51
|
+
if (settled) return;
|
|
52
|
+
settled = true;
|
|
53
|
+
if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
|
|
54
|
+
try { client.destroy(); } catch (_) {}
|
|
55
|
+
if (upstreamSock) { try { upstreamSock.destroy(); } catch (_) {} }
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Bail on a client that connects and never completes SOCKS5 negotiation
|
|
59
|
+
// (stalled / half-open / non-SOCKS protocol). Without this, such a socket
|
|
60
|
+
// sits in activeSockets until the OS TCP keepalive notices — default
|
|
61
|
+
// ~2 hours on Linux. unref'd so a pending watchdog never holds the
|
|
62
|
+
// process alive after closeAllRelays().
|
|
63
|
+
handshakeTimer = setTimeout(() => {
|
|
64
|
+
if (phase !== 'piping') {
|
|
65
|
+
if (forceDebug) {
|
|
66
|
+
console.log(formatLogMessage('proxy', `[socks-relay] handshake timeout (phase=${phase}) — closing`));
|
|
67
|
+
}
|
|
68
|
+
cleanup();
|
|
69
|
+
}
|
|
70
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
71
|
+
if (typeof handshakeTimer.unref === 'function') handshakeTimer.unref();
|
|
72
|
+
|
|
73
|
+
const onData = async (chunk) => {
|
|
74
|
+
buf = Buffer.concat([buf, chunk]);
|
|
75
|
+
try {
|
|
76
|
+
if (phase === 'greeting') {
|
|
77
|
+
// [0x05, NMETHODS, METHODS...]
|
|
78
|
+
if (buf.length < 2) return;
|
|
79
|
+
const nMethods = buf[1];
|
|
80
|
+
if (buf.length < 2 + nMethods) return;
|
|
81
|
+
const offered = buf.subarray(2, 2 + nMethods);
|
|
82
|
+
buf = buf.subarray(2 + nMethods);
|
|
83
|
+
// We only speak no-auth (0x00) to the local client. Chromium always
|
|
84
|
+
// offers it; if a client somehow didn't, reply "no acceptable
|
|
85
|
+
// methods" rather than violate the protocol by selecting unoffered.
|
|
86
|
+
if (!offered.includes(0x00)) {
|
|
87
|
+
try { client.write(Buffer.from([0x05, 0xFF])); } catch (_) {}
|
|
88
|
+
return cleanup();
|
|
89
|
+
}
|
|
90
|
+
client.write(Buffer.from([0x05, 0x00])); // select "no auth"
|
|
91
|
+
phase = 'request';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (phase === 'request') {
|
|
95
|
+
// [0x05, CMD, 0x00, ATYP, ADDR..., PORT(2 BE)]
|
|
96
|
+
if (buf.length < 4) return;
|
|
97
|
+
if (buf[0] !== 0x05) { failReply(client, 0x01); return cleanup(); }
|
|
98
|
+
const cmd = buf[1];
|
|
99
|
+
const atyp = buf[3];
|
|
100
|
+
let host, port, hdrLen;
|
|
101
|
+
|
|
102
|
+
if (atyp === 0x01) { // IPv4
|
|
103
|
+
if (buf.length < 10) return;
|
|
104
|
+
host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
|
|
105
|
+
port = buf.readUInt16BE(8);
|
|
106
|
+
hdrLen = 10;
|
|
107
|
+
} else if (atyp === 0x03) { // domain
|
|
108
|
+
if (buf.length < 5) return;
|
|
109
|
+
const dLen = buf[4];
|
|
110
|
+
if (buf.length < 7 + dLen) return;
|
|
111
|
+
host = buf.subarray(5, 5 + dLen).toString('utf8');
|
|
112
|
+
port = buf.readUInt16BE(5 + dLen);
|
|
113
|
+
hdrLen = 7 + dLen;
|
|
114
|
+
} else if (atyp === 0x04) { // IPv6
|
|
115
|
+
if (buf.length < 22) return;
|
|
116
|
+
const seg = [];
|
|
117
|
+
for (let i = 0; i < 16; i += 2) seg.push(buf.readUInt16BE(4 + i).toString(16));
|
|
118
|
+
host = seg.join(':');
|
|
119
|
+
port = buf.readUInt16BE(20);
|
|
120
|
+
hdrLen = 22;
|
|
121
|
+
} else {
|
|
122
|
+
failReply(client, 0x08); // address type not supported
|
|
123
|
+
return cleanup();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (cmd !== 0x01) { // only CONNECT
|
|
127
|
+
failReply(client, 0x07);
|
|
128
|
+
return cleanup();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Hand the stream to .pipe() from here. Pause + detach this handler
|
|
132
|
+
// so a data event during the upstream connect can't re-enter.
|
|
133
|
+
phase = 'connecting';
|
|
134
|
+
client.pause();
|
|
135
|
+
client.off('data', onData);
|
|
136
|
+
const early = buf.subarray(hdrLen); // any bytes after the request header
|
|
137
|
+
buf = null;
|
|
138
|
+
|
|
139
|
+
let info;
|
|
140
|
+
try {
|
|
141
|
+
info = await SocksClient.createConnection({
|
|
142
|
+
proxy: {
|
|
143
|
+
host: upstream.host,
|
|
144
|
+
port: upstream.port,
|
|
145
|
+
type: 5,
|
|
146
|
+
userId: upstream.username,
|
|
147
|
+
password: upstream.password || '',
|
|
148
|
+
},
|
|
149
|
+
command: 'connect',
|
|
150
|
+
destination: { host, port },
|
|
151
|
+
timeout: 20000,
|
|
152
|
+
});
|
|
153
|
+
} catch (e) {
|
|
154
|
+
if (forceDebug) {
|
|
155
|
+
console.log(formatLogMessage('proxy', `[socks-relay] upstream connect failed (${host}:${port}): ${e.message}`));
|
|
156
|
+
}
|
|
157
|
+
failReply(client, 0x05); // connection refused
|
|
158
|
+
return cleanup();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
upstreamSock = info.socket;
|
|
162
|
+
try { upstreamSock.setNoDelay(true); } catch (_) {}
|
|
163
|
+
upstreamSock.on('error', cleanup);
|
|
164
|
+
upstreamSock.on('close', cleanup);
|
|
165
|
+
client.on('error', cleanup);
|
|
166
|
+
client.on('close', cleanup);
|
|
167
|
+
|
|
168
|
+
// SOCKS5 success (BND.ADDR 0.0.0.0:0 — Chromium ignores it for CONNECT)
|
|
169
|
+
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
|
|
170
|
+
if (early && early.length) upstreamSock.write(early);
|
|
171
|
+
client.pipe(upstreamSock);
|
|
172
|
+
upstreamSock.pipe(client);
|
|
173
|
+
client.resume();
|
|
174
|
+
phase = 'piping';
|
|
175
|
+
// Negotiation complete — disarm the handshake watchdog so a
|
|
176
|
+
// long-running download isn't killed mid-transfer.
|
|
177
|
+
if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
if (forceDebug) {
|
|
181
|
+
console.log(formatLogMessage('proxy', `[socks-relay] handler error: ${e.message}`));
|
|
182
|
+
}
|
|
183
|
+
cleanup();
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
client.on('data', onData);
|
|
188
|
+
client.on('error', cleanup);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// SOCKS5 failure reply (valid only before piping starts).
|
|
192
|
+
function failReply(client, code) {
|
|
193
|
+
try { client.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); } catch (_) {}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Ensure a relay exists for the given upstream; returns its local port.
|
|
198
|
+
* Idempotent — repeated calls for the same upstream reuse one relay.
|
|
199
|
+
*
|
|
200
|
+
* @param {{host:string,port:number,username:string,password:string}} upstream
|
|
201
|
+
* @param {boolean} forceDebug
|
|
202
|
+
* @returns {Promise<number>} local 127.0.0.1 port the relay listens on
|
|
203
|
+
*/
|
|
204
|
+
async function ensureRelay(upstream, forceDebug = false) {
|
|
205
|
+
const key = upstreamKey(upstream);
|
|
206
|
+
const existing = _relays.get(key);
|
|
207
|
+
if (existing) return existing.port;
|
|
208
|
+
|
|
209
|
+
const activeSockets = new Set();
|
|
210
|
+
const server = net.createServer((clientSock) => {
|
|
211
|
+
// Disable Nagle: page scanning is full of small-packet phases (per-origin
|
|
212
|
+
// TLS handshakes, small XHR/API calls, the SOCKS handshake itself).
|
|
213
|
+
// Nagle + delayed-ACK adds ~40ms stalls on those; relays should not.
|
|
214
|
+
try { clientSock.setNoDelay(true); } catch (_) {}
|
|
215
|
+
activeSockets.add(clientSock);
|
|
216
|
+
clientSock.on('close', () => activeSockets.delete(clientSock));
|
|
217
|
+
handleClient(clientSock, upstream, forceDebug);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
await new Promise((resolve, reject) => {
|
|
221
|
+
const onErr = (e) => reject(e);
|
|
222
|
+
server.once('error', onErr);
|
|
223
|
+
server.listen(0, '127.0.0.1', () => {
|
|
224
|
+
server.removeListener('error', onErr);
|
|
225
|
+
// Keep a listener so a late server error doesn't crash the process.
|
|
226
|
+
server.on('error', (e) => {
|
|
227
|
+
if (forceDebug) console.log(formatLogMessage('proxy', `[socks-relay] server error: ${e.message}`));
|
|
228
|
+
});
|
|
229
|
+
resolve();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const port = server.address().port;
|
|
234
|
+
_relays.set(key, { server, port, activeSockets });
|
|
235
|
+
if (forceDebug) {
|
|
236
|
+
console.log(formatLogMessage('proxy', `[socks-relay] 127.0.0.1:${port} -> ${upstream.host}:${upstream.port} (auth user "${upstream.username}")`));
|
|
237
|
+
}
|
|
238
|
+
return port;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Sync lookup of an already-started relay's port. Returns null if no relay
|
|
243
|
+
* has been started for this upstream (caller should have called ensureRelay
|
|
244
|
+
* upfront).
|
|
245
|
+
*/
|
|
246
|
+
function getRelayPort(upstream) {
|
|
247
|
+
const r = _relays.get(upstreamKey(upstream));
|
|
248
|
+
return r ? r.port : null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Tear down every relay: destroy in-flight sockets, close listeners.
|
|
253
|
+
* Safe to call multiple times.
|
|
254
|
+
*/
|
|
255
|
+
async function closeAllRelays(forceDebug = false) {
|
|
256
|
+
for (const [key, r] of _relays) {
|
|
257
|
+
for (const s of r.activeSockets) { try { s.destroy(); } catch (_) {} }
|
|
258
|
+
await new Promise((res) => {
|
|
259
|
+
try { r.server.close(() => res()); } catch (_) { res(); }
|
|
260
|
+
});
|
|
261
|
+
if (forceDebug) console.log(formatLogMessage('proxy', `[socks-relay] closed relay for ${key}`));
|
|
262
|
+
}
|
|
263
|
+
_relays.clear();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = { ensureRelay, getRelayPort, closeAllRelays };
|
package/nwss.js
CHANGED
|
@@ -9,6 +9,7 @@ const fs = require('fs');
|
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const psl = require('psl');
|
|
11
11
|
const path = require('path');
|
|
12
|
+
const dnsPromises = require('node:dns/promises');
|
|
12
13
|
const { createGrepHandler, validateGrepAvailability } = require('./lib/grep');
|
|
13
14
|
const { compressMultipleFiles, formatFileSize } = require('./lib/compress');
|
|
14
15
|
const { parseSearchStrings, createResponseHandler, createCurlHandler } = require('./lib/searchstring');
|
|
@@ -50,7 +51,7 @@ const { isGhostCursorAvailable, createGhostCursor, ghostMove, ghostClick, ghostR
|
|
|
50
51
|
const { createGlobalHelpers, getTotalDomainsSkipped, getDetectedDomainsCount } = require('./lib/domain-cache');
|
|
51
52
|
const { createSmartCache } = require('./lib/smart-cache'); // Smart cache system
|
|
52
53
|
const { clearPersistentCache } = require('./lib/smart-cache');
|
|
53
|
-
const { needsProxy, getProxyArgs, applyProxyAuth, getProxyInfo, testProxy } = require('./lib/proxy');
|
|
54
|
+
const { needsProxy, getProxyArgs, applyProxyAuth, getProxyInfo, testProxy, prepareSocksRelays, closeAllSocksRelays } = require('./lib/proxy');
|
|
54
55
|
// Dry run functionality
|
|
55
56
|
const { initializeDryRunCollections, addDryRunMatch, addDryRunNetTools, processDryRunResults, writeDryRunOutput } = require('./lib/dry-run');
|
|
56
57
|
// Enhanced site data clearing functionality
|
|
@@ -266,6 +267,13 @@ if (fs.existsSync(NWSSCONFIG_PATH)) {
|
|
|
266
267
|
}
|
|
267
268
|
|
|
268
269
|
const headfulMode = args.includes('--headful');
|
|
270
|
+
// Sites (esp. video/streaming) call element.requestFullscreen() on load or
|
|
271
|
+
// click. In --headful that hijacks the real Chrome window into true
|
|
272
|
+
// fullscreen, forcing a manual ESC. Neutralize the Fullscreen API by
|
|
273
|
+
// default so it can't. Harmless in headless (no screen — the API is
|
|
274
|
+
// already inert there), so default-on keeps headful consistent with the
|
|
275
|
+
// primary headless path. --allow-fullscreen restores native behavior.
|
|
276
|
+
const allowFullscreen = args.includes('--allow-fullscreen');
|
|
269
277
|
const SOURCES_FOLDER = 'sources';
|
|
270
278
|
|
|
271
279
|
let outputFile = null;
|
|
@@ -326,6 +334,31 @@ const cacheRequests = args.includes('--cache-requests');
|
|
|
326
334
|
const dnsCacheMode = args.includes('--dns-cache');
|
|
327
335
|
if (dnsCacheMode) enableDiskCache();
|
|
328
336
|
|
|
337
|
+
// DNS pre-check before page.goto() — default-on, --no-dns-precheck disables.
|
|
338
|
+
// Filters NXDOMAIN / unresolvable hostnames in <100ms before paying the
|
|
339
|
+
// ~5-15s Puppeteer + Cloudflare detection round-trip on each.
|
|
340
|
+
const dnsPrecheckEnabled = !args.includes('--no-dns-precheck');
|
|
341
|
+
const dnsPrecheckTimeoutMs = 2000;
|
|
342
|
+
|
|
343
|
+
// Per-scan cache of negative DNS lookups. OS resolvers don't always cache
|
|
344
|
+
// NXDOMAIN responses, and a scan can hit the same dead hostname many times
|
|
345
|
+
// (different URL paths on the same site). Positive results are left to the
|
|
346
|
+
// OS cache; failure-cache avoids repeated lookup latency for known-dead hosts.
|
|
347
|
+
// FIFO eviction at DNS_NEGATIVE_CACHE_MAX so pathological scans (thousands
|
|
348
|
+
// of unique dead hosts) can't grow the cache unboundedly. Same pattern as
|
|
349
|
+
// the rest of the codebase's in-memory caches.
|
|
350
|
+
const dnsNegativeCache = new Map(); // hostname -> { error, timestamp }
|
|
351
|
+
const DNS_NEGATIVE_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
352
|
+
const DNS_NEGATIVE_CACHE_MAX = 1000;
|
|
353
|
+
let dnsPrecheckSkips = 0;
|
|
354
|
+
|
|
355
|
+
function dnsNegativeCacheSet(hostname, error) {
|
|
356
|
+
if (dnsNegativeCache.size >= DNS_NEGATIVE_CACHE_MAX) {
|
|
357
|
+
dnsNegativeCache.delete(dnsNegativeCache.keys().next().value);
|
|
358
|
+
}
|
|
359
|
+
dnsNegativeCache.set(hostname, { error, timestamp: Date.now() });
|
|
360
|
+
}
|
|
361
|
+
|
|
329
362
|
let validateRulesFile = null;
|
|
330
363
|
const validateRulesIndex = args.findIndex(arg => arg === '--validate-rules');
|
|
331
364
|
if (validateRulesIndex !== -1 && args[validateRulesIndex + 1] && !args[validateRulesIndex + 1].startsWith('--')) {
|
|
@@ -643,6 +676,8 @@ General Options:
|
|
|
643
676
|
--custom-json <file> Use a custom config JSON file instead of config.json
|
|
644
677
|
--headful Launch browser with GUI (not headless)
|
|
645
678
|
--keep-open Keep browser open after scan completes (use with --headful)
|
|
679
|
+
--allow-fullscreen Allow sites to use the Fullscreen API. By default it is
|
|
680
|
+
neutralized so sites can't hijack the window in --headful
|
|
646
681
|
--use-puppeteer-core Use puppeteer-core with system Chrome instead of bundled Chromium
|
|
647
682
|
--use-obscura Connect to running Obscura CDP server (ws://127.0.0.1:9222 or OBSCURA_WS env)
|
|
648
683
|
Skips fingerprint injection — Obscura provides built-in stealth
|
|
@@ -659,6 +694,9 @@ General Options:
|
|
|
659
694
|
Validation Options:
|
|
660
695
|
--cache-requests Cache HTTP requests to avoid re-requesting same URLs within scan
|
|
661
696
|
--dns-cache Persist dig/whois results to disk between runs (3hr/4hr TTL)
|
|
697
|
+
--no-dns-precheck Disable per-URL DNS resolution check before page navigation.
|
|
698
|
+
By default, URLs whose hostname doesn't resolve are skipped
|
|
699
|
+
immediately (saves ~5-15s of Puppeteer time per dead host).
|
|
662
700
|
--validate-config Validate config.json file and exit
|
|
663
701
|
--validate-rules [file] Validate rule file format (uses --output/--compare files if no file specified)
|
|
664
702
|
--clean-rules [file] Clean rule files by removing invalid lines and optionally duplicates (uses --output/--compare files if no file specified)
|
|
@@ -1831,6 +1869,7 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
1831
1869
|
ovpnDisconnectAll(forceDebug);
|
|
1832
1870
|
cleanupCloudflareCache();
|
|
1833
1871
|
purgeStaleTrackers();
|
|
1872
|
+
try { await closeAllSocksRelays(forceDebug); } catch (_) {}
|
|
1834
1873
|
}
|
|
1835
1874
|
|
|
1836
1875
|
let siteCounter = 0;
|
|
@@ -2438,6 +2477,29 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
2438
2477
|
} else if (forceDebug) {
|
|
2439
2478
|
console.log(formatLogMessage('debug', `Skipping fingerprint injection — Obscura provides built-in stealth`));
|
|
2440
2479
|
}
|
|
2480
|
+
|
|
2481
|
+
// Neutralize the Fullscreen API before any page script runs so a
|
|
2482
|
+
// site can't force the real browser window fullscreen in --headful
|
|
2483
|
+
// (or trip an anti-bot check that reads document.fullscreenElement).
|
|
2484
|
+
// requestFullscreen is stubbed to a resolved no-op — which is also
|
|
2485
|
+
// how browsers already behave when it's called without a user
|
|
2486
|
+
// gesture, so this looks normal, not automated. fullscreenElement
|
|
2487
|
+
// stays null naturally since we never enter fullscreen.
|
|
2488
|
+
if (!allowFullscreen) {
|
|
2489
|
+
try {
|
|
2490
|
+
await page.evaluateOnNewDocument(() => {
|
|
2491
|
+
const noop = function () { return Promise.resolve(); };
|
|
2492
|
+
const legacyNoop = function () {};
|
|
2493
|
+
try { Element.prototype.requestFullscreen = noop; } catch (_) {}
|
|
2494
|
+
try { Element.prototype.webkitRequestFullscreen = legacyNoop; } catch (_) {}
|
|
2495
|
+
try { Element.prototype.webkitRequestFullScreen = legacyNoop; } catch (_) {}
|
|
2496
|
+
try { Element.prototype.mozRequestFullScreen = legacyNoop; } catch (_) {}
|
|
2497
|
+
try { Element.prototype.msRequestFullscreen = legacyNoop; } catch (_) {}
|
|
2498
|
+
});
|
|
2499
|
+
} catch (fsErr) {
|
|
2500
|
+
if (forceDebug) console.log(formatLogMessage('debug', `Fullscreen neutralization injection failed: ${fsErr.message}`));
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2441
2503
|
|
|
2442
2504
|
// Client Hints protection for Chrome user agents (skipped under Obscura — it sets its own)
|
|
2443
2505
|
if (!useObscura && siteConfig.userAgent && siteConfig.userAgent.toLowerCase().includes('chrome')) {
|
|
@@ -4300,6 +4362,19 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
4300
4362
|
// Sort tasks so proxy groups are contiguous — direct connections first, then each proxy
|
|
4301
4363
|
allTasks.sort((a, b) => proxyKeyFor(a.config).localeCompare(proxyKeyFor(b.config)));
|
|
4302
4364
|
|
|
4365
|
+
// Pre-start local no-auth SOCKS5 relays for any authenticated socks5://
|
|
4366
|
+
// upstreams. Done once here (the only async step) so getProxyArgs stays a
|
|
4367
|
+
// sync lookup in the per-batch browser-launch path. Chromium can't auth
|
|
4368
|
+
// SOCKS5; the relay does the upstream auth transparently.
|
|
4369
|
+
try {
|
|
4370
|
+
const relayCount = await prepareSocksRelays(sites, forceDebug);
|
|
4371
|
+
if (relayCount > 0 && !silentMode) {
|
|
4372
|
+
console.log(messageColors.processing(`Started ${relayCount} SOCKS5 auth relay(s)`));
|
|
4373
|
+
}
|
|
4374
|
+
} catch (relayErr) {
|
|
4375
|
+
console.warn(formatLogMessage('proxy', `SOCKS5 relay setup failed: ${relayErr.message}`));
|
|
4376
|
+
}
|
|
4377
|
+
|
|
4303
4378
|
let results = [];
|
|
4304
4379
|
let processedUrlCount = 0;
|
|
4305
4380
|
let urlsSinceLastCleanup = 0;
|
|
@@ -4580,6 +4655,66 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
4580
4655
|
if (!silentMode) console.log(formatLogMessage('info', `Skipping ${task.url} — ${taskDomain} timed out ${DOMAIN_TIMEOUT_THRESHOLD} times`));
|
|
4581
4656
|
return { url: task.url, rules: [], success: false, error: 'Domain repeatedly timed out', skipped: true };
|
|
4582
4657
|
}
|
|
4658
|
+
|
|
4659
|
+
// DNS pre-check — fails fast on NXDOMAIN/unresolvable hosts before
|
|
4660
|
+
// we pay ~5-15s for Puppeteer navigation + Cloudflare detection.
|
|
4661
|
+
// Skips IP literals. Respects an in-memory negative cache so a dead
|
|
4662
|
+
// host hit by many URL paths only costs one DNS round-trip per TTL.
|
|
4663
|
+
//
|
|
4664
|
+
// Uses dns.resolve* (c-ares, async network I/O) NOT dns.lookup
|
|
4665
|
+
// (getaddrinfo, libuv threadpool). Under scan concurrency Puppeteer
|
|
4666
|
+
// saturates the default 4-slot threadpool with filesystem I/O, so
|
|
4667
|
+
// dns.lookup calls sit queued and blow the timeout while never
|
|
4668
|
+
// actually starting — wrongly skipping live domains. c-ares isn't
|
|
4669
|
+
// threadpool-bound so it's immune to that contention.
|
|
4670
|
+
if (dnsPrecheckEnabled && taskDomain && !/^[\d.:]+$|^\[/.test(taskDomain)) {
|
|
4671
|
+
const cached = dnsNegativeCache.get(taskDomain);
|
|
4672
|
+
if (cached && Date.now() - cached.timestamp < DNS_NEGATIVE_CACHE_TTL_MS) {
|
|
4673
|
+
dnsPrecheckSkips++;
|
|
4674
|
+
if (forceDebug) console.log(formatLogMessage('debug', `DNS pre-check (cached): ${taskDomain} — ${cached.error}`));
|
|
4675
|
+
return { url: task.url, rules: [], success: false, error: `DNS: ${cached.error}`, skipped: true };
|
|
4676
|
+
}
|
|
4677
|
+
const dnsResolve = async () => {
|
|
4678
|
+
// resolve4 first; on no-IPv4 (ENODATA / ENOTFOUND) fall back to
|
|
4679
|
+
// resolve6 so IPv6-only hosts aren't wrongly skipped. Only a
|
|
4680
|
+
// failure of BOTH means the host is genuinely unresolvable.
|
|
4681
|
+
// 2s timeout kept as a real safety net — with c-ares off the
|
|
4682
|
+
// threadpool it should now rarely fire.
|
|
4683
|
+
let timer;
|
|
4684
|
+
try {
|
|
4685
|
+
const timeoutP = new Promise((_, reject) => {
|
|
4686
|
+
timer = setTimeout(() => reject(new Error('DNS timeout')), dnsPrecheckTimeoutMs);
|
|
4687
|
+
});
|
|
4688
|
+
const resolveChain = dnsPromises.resolve4(taskDomain)
|
|
4689
|
+
.catch(() => dnsPromises.resolve6(taskDomain));
|
|
4690
|
+
await Promise.race([resolveChain, timeoutP]);
|
|
4691
|
+
} finally {
|
|
4692
|
+
if (timer) clearTimeout(timer);
|
|
4693
|
+
}
|
|
4694
|
+
};
|
|
4695
|
+
// c-ares transient codes — retry once so a momentary resolver
|
|
4696
|
+
// hiccup doesn't poison the negative cache for 5 minutes.
|
|
4697
|
+
const TRANSIENT = new Set(['ETIMEOUT', 'ESERVFAIL', 'EREFUSED', 'ECONNREFUSED']);
|
|
4698
|
+
try {
|
|
4699
|
+
try {
|
|
4700
|
+
await dnsResolve();
|
|
4701
|
+
} catch (firstErr) {
|
|
4702
|
+
const code = firstErr && firstErr.code;
|
|
4703
|
+
if (TRANSIENT.has(code) || (firstErr && firstErr.message === 'DNS timeout')) {
|
|
4704
|
+
if (forceDebug) console.log(formatLogMessage('debug', `DNS pre-check transient (${code || 'timeout'}) for ${taskDomain}, retrying once`));
|
|
4705
|
+
await dnsResolve();
|
|
4706
|
+
} else {
|
|
4707
|
+
throw firstErr;
|
|
4708
|
+
}
|
|
4709
|
+
}
|
|
4710
|
+
} catch (dnsErr) {
|
|
4711
|
+
const errCode = dnsErr.code || dnsErr.message || 'DNS resolve failed';
|
|
4712
|
+
dnsNegativeCacheSet(taskDomain, errCode);
|
|
4713
|
+
dnsPrecheckSkips++;
|
|
4714
|
+
if (forceDebug) console.log(formatLogMessage('debug', `DNS pre-check failed: ${taskDomain} — ${errCode}`));
|
|
4715
|
+
return { url: task.url, rules: [], success: false, error: `DNS: ${errCode}`, skipped: true };
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4583
4718
|
} catch {}
|
|
4584
4719
|
|
|
4585
4720
|
// Per-URL timeout so a single hung processUrl can't block the batch
|
|
@@ -4896,6 +5031,9 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
4896
5031
|
if (cloudflareScanStats.errorPages > 0) {
|
|
4897
5032
|
console.log(formatLogMessage('debug', `Cloudflare 5xx origin-error pages: ${cloudflareScanStats.errorPages} (no bypass possible — origin unreachable)`));
|
|
4898
5033
|
}
|
|
5034
|
+
if (dnsPrecheckEnabled && dnsPrecheckSkips > 0) {
|
|
5035
|
+
console.log(formatLogMessage('debug', `DNS pre-check skipped: ${dnsPrecheckSkips} URL(s) via ${dnsNegativeCache.size} unresolvable host(s)`));
|
|
5036
|
+
}
|
|
4899
5037
|
// Log smart cache statistics (if cache is enabled)
|
|
4900
5038
|
// Adblock statistics
|
|
4901
5039
|
if (adblockEnabled) {
|
|
@@ -5113,6 +5251,7 @@ function setupFrameHandling(page, forceDebug) {
|
|
|
5113
5251
|
try { wgDisconnectAll(forceDebug); } catch (_) {}
|
|
5114
5252
|
try { ovpnDisconnectAll(forceDebug); } catch (_) {}
|
|
5115
5253
|
try { purgeStaleTrackers(); } catch (_) {}
|
|
5254
|
+
try { await closeAllSocksRelays(forceDebug); } catch (_) {}
|
|
5116
5255
|
|
|
5117
5256
|
// Clean process termination
|
|
5118
5257
|
if (forceDebug) console.log(formatLogMessage('debug', `About to exit process...`));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fanboynz/network-scanner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.66",
|
|
4
4
|
"description": "A Puppeteer-based network scanner for analyzing web traffic, generating adblock filter rules, and identifying third-party requests. Features include fingerprint spoofing, Cloudflare bypass, content analysis with curl/grep, and multiple output formats.",
|
|
5
5
|
"main": "nwss.js",
|
|
6
6
|
"scripts": {
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
"lru-cache": "^11.3.5",
|
|
15
15
|
"p-limit": "^7.3.0",
|
|
16
16
|
"psl": "^1.15.0",
|
|
17
|
-
"puppeteer": ">=20.0.0"
|
|
17
|
+
"puppeteer": ">=20.0.0",
|
|
18
|
+
"socks": "^2.8.9"
|
|
18
19
|
},
|
|
19
20
|
"overrides": {
|
|
20
21
|
"tar-fs": "3.1.1",
|
|
21
|
-
"ws": "8.
|
|
22
|
+
"ws": ">=8.20.1",
|
|
22
23
|
"yauzl": ">=3.2.1"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|