@fanboynz/network-scanner 2.0.65 → 3.0.0

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.
@@ -0,0 +1,267 @@
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, messageColors } = require('./colorize');
23
+ const SOCKS_RELAY_TAG = messageColors.processing('[socks-relay]');
24
+
25
+ // upstreamKey -> { server, port, activeSockets:Set<net.Socket> }
26
+ const _relays = new Map();
27
+
28
+ function upstreamKey(u) {
29
+ return `${u.host}:${u.port}:${u.username || ''}`;
30
+ }
31
+
32
+ /**
33
+ * Handle one Chromium->relay connection: minimal SOCKS5 server handshake,
34
+ * then an authenticated upstream tunnel, then bidirectional pipe.
35
+ */
36
+ // Bail on a client that connects and never completes SOCKS5 negotiation.
37
+ // Generous enough for a Chromium loopback handshake (microseconds), short
38
+ // enough to catch a stalled / half-open client before the OS TCP keepalive
39
+ // notices (default ~2 hours on Linux).
40
+ const HANDSHAKE_TIMEOUT_MS = 10000;
41
+
42
+ function handleClient(client, upstream, forceDebug) {
43
+ let phase = 'greeting';
44
+ let buf = Buffer.alloc(0);
45
+ let upstreamSock = null;
46
+ let settled = false;
47
+ // Handshake-phase watchdog handle. Assigned after cleanup is declared so
48
+ // both references in this scope resolve unambiguously.
49
+ let handshakeTimer = null;
50
+
51
+ const cleanup = () => {
52
+ if (settled) return;
53
+ settled = true;
54
+ if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
55
+ try { client.destroy(); } catch (_) {}
56
+ if (upstreamSock) { try { upstreamSock.destroy(); } catch (_) {} }
57
+ };
58
+
59
+ // Bail on a client that connects and never completes SOCKS5 negotiation
60
+ // (stalled / half-open / non-SOCKS protocol). Without this, such a socket
61
+ // sits in activeSockets until the OS TCP keepalive notices — default
62
+ // ~2 hours on Linux. unref'd so a pending watchdog never holds the
63
+ // process alive after closeAllRelays().
64
+ handshakeTimer = setTimeout(() => {
65
+ if (phase !== 'piping') {
66
+ if (forceDebug) {
67
+ console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} handshake timeout (phase=${phase}) — closing`));
68
+ }
69
+ cleanup();
70
+ }
71
+ }, HANDSHAKE_TIMEOUT_MS);
72
+ if (typeof handshakeTimer.unref === 'function') handshakeTimer.unref();
73
+
74
+ const onData = async (chunk) => {
75
+ buf = Buffer.concat([buf, chunk]);
76
+ try {
77
+ if (phase === 'greeting') {
78
+ // [0x05, NMETHODS, METHODS...]
79
+ if (buf.length < 2) return;
80
+ const nMethods = buf[1];
81
+ if (buf.length < 2 + nMethods) return;
82
+ const offered = buf.subarray(2, 2 + nMethods);
83
+ buf = buf.subarray(2 + nMethods);
84
+ // We only speak no-auth (0x00) to the local client. Chromium always
85
+ // offers it; if a client somehow didn't, reply "no acceptable
86
+ // methods" rather than violate the protocol by selecting unoffered.
87
+ if (!offered.includes(0x00)) {
88
+ try { client.write(Buffer.from([0x05, 0xFF])); } catch (_) {}
89
+ return cleanup();
90
+ }
91
+ client.write(Buffer.from([0x05, 0x00])); // select "no auth"
92
+ phase = 'request';
93
+ }
94
+
95
+ if (phase === 'request') {
96
+ // [0x05, CMD, 0x00, ATYP, ADDR..., PORT(2 BE)]
97
+ if (buf.length < 4) return;
98
+ if (buf[0] !== 0x05) { failReply(client, 0x01); return cleanup(); }
99
+ const cmd = buf[1];
100
+ const atyp = buf[3];
101
+ let host, port, hdrLen;
102
+
103
+ if (atyp === 0x01) { // IPv4
104
+ if (buf.length < 10) return;
105
+ host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
106
+ port = buf.readUInt16BE(8);
107
+ hdrLen = 10;
108
+ } else if (atyp === 0x03) { // domain
109
+ if (buf.length < 5) return;
110
+ const dLen = buf[4];
111
+ if (buf.length < 7 + dLen) return;
112
+ host = buf.subarray(5, 5 + dLen).toString('utf8');
113
+ port = buf.readUInt16BE(5 + dLen);
114
+ hdrLen = 7 + dLen;
115
+ } else if (atyp === 0x04) { // IPv6
116
+ if (buf.length < 22) return;
117
+ const seg = [];
118
+ for (let i = 0; i < 16; i += 2) seg.push(buf.readUInt16BE(4 + i).toString(16));
119
+ host = seg.join(':');
120
+ port = buf.readUInt16BE(20);
121
+ hdrLen = 22;
122
+ } else {
123
+ failReply(client, 0x08); // address type not supported
124
+ return cleanup();
125
+ }
126
+
127
+ if (cmd !== 0x01) { // only CONNECT
128
+ failReply(client, 0x07);
129
+ return cleanup();
130
+ }
131
+
132
+ // Hand the stream to .pipe() from here. Pause + detach this handler
133
+ // so a data event during the upstream connect can't re-enter.
134
+ phase = 'connecting';
135
+ client.pause();
136
+ client.off('data', onData);
137
+ const early = buf.subarray(hdrLen); // any bytes after the request header
138
+ buf = null;
139
+
140
+ let info;
141
+ try {
142
+ info = await SocksClient.createConnection({
143
+ proxy: {
144
+ host: upstream.host,
145
+ port: upstream.port,
146
+ type: 5,
147
+ userId: upstream.username,
148
+ password: upstream.password || '',
149
+ },
150
+ command: 'connect',
151
+ destination: { host, port },
152
+ timeout: 20000,
153
+ });
154
+ } catch (e) {
155
+ if (forceDebug) {
156
+ console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} upstream connect failed (${host}:${port}): ${e.message}`));
157
+ }
158
+ failReply(client, 0x05); // connection refused
159
+ return cleanup();
160
+ }
161
+
162
+ upstreamSock = info.socket;
163
+ try { upstreamSock.setNoDelay(true); } catch (_) {}
164
+ upstreamSock.on('error', cleanup);
165
+ upstreamSock.on('close', cleanup);
166
+ client.on('error', cleanup);
167
+ client.on('close', cleanup);
168
+
169
+ // SOCKS5 success (BND.ADDR 0.0.0.0:0 — Chromium ignores it for CONNECT)
170
+ client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
171
+ if (early && early.length) upstreamSock.write(early);
172
+ client.pipe(upstreamSock);
173
+ upstreamSock.pipe(client);
174
+ client.resume();
175
+ 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; }
179
+ }
180
+ } catch (e) {
181
+ if (forceDebug) {
182
+ console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} handler error: ${e.message}`));
183
+ }
184
+ cleanup();
185
+ }
186
+ };
187
+
188
+ client.on('data', onData);
189
+ client.on('error', cleanup);
190
+ }
191
+
192
+ // SOCKS5 failure reply (valid only before piping starts).
193
+ function failReply(client, code) {
194
+ try { client.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); } catch (_) {}
195
+ }
196
+
197
+ /**
198
+ * Ensure a relay exists for the given upstream; returns its local port.
199
+ * Idempotent — repeated calls for the same upstream reuse one relay.
200
+ *
201
+ * @param {{host:string,port:number,username:string,password:string}} upstream
202
+ * @param {boolean} forceDebug
203
+ * @returns {Promise<number>} local 127.0.0.1 port the relay listens on
204
+ */
205
+ async function ensureRelay(upstream, forceDebug = false) {
206
+ const key = upstreamKey(upstream);
207
+ const existing = _relays.get(key);
208
+ if (existing) return existing.port;
209
+
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
+ });
220
+
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}`));
229
+ });
230
+ resolve();
231
+ });
232
+ });
233
+
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;
240
+ }
241
+
242
+ /**
243
+ * Sync lookup of an already-started relay's port. Returns null if no relay
244
+ * has been started for this upstream (caller should have called ensureRelay
245
+ * upfront).
246
+ */
247
+ function getRelayPort(upstream) {
248
+ const r = _relays.get(upstreamKey(upstream));
249
+ return r ? r.port : null;
250
+ }
251
+
252
+ /**
253
+ * Tear down every relay: destroy in-flight sockets, close listeners.
254
+ * Safe to call multiple times.
255
+ */
256
+ async function closeAllRelays(forceDebug = false) {
257
+ for (const [key, r] of _relays) {
258
+ for (const s of r.activeSockets) { try { s.destroy(); } catch (_) {} }
259
+ await new Promise((res) => {
260
+ try { r.server.close(() => res()); } catch (_) { res(); }
261
+ });
262
+ if (forceDebug) console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} closed relay for ${key}`));
263
+ }
264
+ _relays.clear();
265
+ }
266
+
267
+ module.exports = { ensureRelay, getRelayPort, closeAllRelays };
@@ -0,0 +1,137 @@
1
+ // === Shared async-spawn helper ===
2
+ // Single canonical implementation of "spawn an external command, collect
3
+ // stdout/stderr with hard caps, enforce a kill timeout" — extracted from
4
+ // what used to be ~400 lines of near-identical Promise wrappers across
5
+ // lib/curl.js, lib/searchstring.js, and lib/grep.js (two sites in the
6
+ // latter). Each caller previously had its own copy of the same pattern:
7
+ // truncate-on-cap, SIGKILL belt-and-braces timer, stdout buffer concat,
8
+ // error/close handlers.
9
+ //
10
+ // Design notes:
11
+ // - Resolves (never rejects). Callers inspect the result object to
12
+ // distinguish failure modes instead of needing try/catch. Pre-existing
13
+ // call sites all converted failure into a result-object anyway —
14
+ // this just standardizes the shape.
15
+ // - stdout/stderr returned as Buffer so the caller decides decoding
16
+ // (curl's `--write-out` metadata is line-oriented and tolerant of
17
+ // UTF-8 decoding mid-multibyte; binary HTTP responses are not).
18
+ // - lib/wireguard_vpn.js intentionally stays on spawnSync — its calls
19
+ // are startup-only (validation, health check) and the synchronous
20
+ // semantics are simpler. Not all spawn calls benefit from async.
21
+
22
+ const { spawn } = require('child_process');
23
+
24
+ const DEFAULT_TIMEOUT_MS = 30000;
25
+ const DEFAULT_MAX_STDOUT = 50 * 1024 * 1024; // 50MB
26
+ const KILL_GRACE_MS = 5000; // belt-and-braces SIGKILL after timeout+grace
27
+
28
+ /**
29
+ * Spawn a process asynchronously, collect stdout/stderr with hard caps,
30
+ * enforce a kill timeout. Resolves with a result object describing what
31
+ * happened — never rejects.
32
+ *
33
+ * @param {string} cmd - Executable name (no shell — args are not parsed)
34
+ * @param {string[]} args - Argument array
35
+ * @param {object} [opts]
36
+ * @param {number} [opts.timeout=30000] - Soft timeout in ms; primary
37
+ * limit is whatever the executable itself enforces (e.g. curl
38
+ * --max-time). This is the belt-and-braces SIGKILL deadline fired at
39
+ * `timeout + 5000` ms.
40
+ * @param {number} [opts.maxStdout=52428800] - Cap on stdout collection.
41
+ * When the cap is exceeded the child is killed with SIGTERM and the
42
+ * result has `truncated: true`.
43
+ * @param {string|Buffer} [opts.input] - Data to write to the child's
44
+ * stdin then close. EPIPE on stdin is swallowed (child may exit early).
45
+ * @param {boolean} [opts.collectStderr=true] - When false, stderr is
46
+ * drained but not retained (saves memory when caller doesn't need it).
47
+ * @returns {Promise<{
48
+ * code: number|null,
49
+ * signal: string|null,
50
+ * stdout: Buffer,
51
+ * stderr: Buffer,
52
+ * truncated: boolean,
53
+ * error: string|null
54
+ * }>}
55
+ */
56
+ function runProcess(cmd, args, opts = {}) {
57
+ const {
58
+ timeout = DEFAULT_TIMEOUT_MS,
59
+ maxStdout = DEFAULT_MAX_STDOUT,
60
+ input,
61
+ collectStderr = true
62
+ } = opts;
63
+
64
+ return new Promise((resolve) => {
65
+ let child;
66
+ try {
67
+ child = spawn(cmd, args);
68
+ } catch (spawnErr) {
69
+ resolve({
70
+ code: null, signal: null,
71
+ stdout: Buffer.alloc(0), stderr: Buffer.alloc(0),
72
+ truncated: false, error: spawnErr.message
73
+ });
74
+ return;
75
+ }
76
+
77
+ const stdoutChunks = [];
78
+ const stderrChunks = [];
79
+ let stdoutBytes = 0;
80
+ let truncated = false;
81
+
82
+ child.stdout.on('data', (chunk) => {
83
+ if (truncated) return;
84
+ if (stdoutBytes + chunk.length > maxStdout) {
85
+ truncated = true;
86
+ try { child.kill('SIGTERM'); } catch (_) {}
87
+ return;
88
+ }
89
+ stdoutBytes += chunk.length;
90
+ stdoutChunks.push(chunk);
91
+ });
92
+
93
+ if (collectStderr) {
94
+ child.stderr.on('data', (chunk) => { stderrChunks.push(chunk); });
95
+ } else {
96
+ child.stderr.on('data', () => {}); // drain but discard
97
+ }
98
+
99
+ // SIGKILL belt-and-braces after timeout+grace. unref'd so the timer
100
+ // doesn't keep the event loop alive on its own; if the process exits
101
+ // earlier, the timer is cleared in the close handler.
102
+ const killTimer = setTimeout(() => {
103
+ try { child.kill('SIGKILL'); } catch (_) {}
104
+ }, timeout + KILL_GRACE_MS);
105
+ if (typeof killTimer.unref === 'function') killTimer.unref();
106
+
107
+ child.on('error', (err) => {
108
+ clearTimeout(killTimer);
109
+ resolve({
110
+ code: null, signal: null,
111
+ stdout: Buffer.concat(stdoutChunks),
112
+ stderr: Buffer.concat(stderrChunks),
113
+ truncated, error: err.message
114
+ });
115
+ });
116
+
117
+ child.on('close', (code, signal) => {
118
+ clearTimeout(killTimer);
119
+ resolve({
120
+ code, signal,
121
+ stdout: Buffer.concat(stdoutChunks),
122
+ stderr: Buffer.concat(stderrChunks),
123
+ truncated, error: null
124
+ });
125
+ });
126
+
127
+ if (input !== undefined) {
128
+ // EPIPE if the child exited before we finished writing (e.g. grep
129
+ // matched and bailed early, or our truncation kill fired). Swallow
130
+ // so it doesn't surface as an unhandled stream error.
131
+ child.stdin.on('error', () => {});
132
+ child.stdin.end(input);
133
+ }
134
+ });
135
+ }
136
+
137
+ module.exports = { runProcess };