@fanboynz/network-scanner
Version:
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.
268 lines (243 loc) • 10.2 kB
JavaScript
/**
* Local no-auth SOCKS5 relay for authenticated SOCKS5 upstreams.
*
* Chromium cannot authenticate SOCKS5 proxies (crbug.com/256785 — it only
* implements the no-auth method 0x00; credentials in --proxy-server are
* discarded, and page.authenticate() is HTTP-407-only so it can't help —
* SOCKS auth happens at the TCP handshake before any HTTP).
*
* Workaround: run an in-process no-auth SOCKS5 server bound to 127.0.0.1.
* Chromium connects to it without auth (which it CAN do); for each
* connection we open an authenticated tunnel to the real upstream via the
* `socks` package (RFC 1929 user/pass) and pipe the two together. Domain
* address types are forwarded as hostnames so remote DNS still works
* end-to-end (no DNS leak).
*
* Relays are keyed by upstream identity and reused. closeAllRelays() must
* be called on scan exit / signal so listening sockets don't leak.
*/
const net = require('net');
const { SocksClient } = require('socks');
const { formatLogMessage, messageColors } = require('./colorize');
const SOCKS_RELAY_TAG = messageColors.processing('[socks-relay]');
// upstreamKey -> { server, port, activeSockets:Set<net.Socket> }
const _relays = new Map();
function upstreamKey(u) {
return `${u.host}:${u.port}:${u.username || ''}`;
}
/**
* Handle one Chromium->relay connection: minimal SOCKS5 server handshake,
* then an authenticated upstream tunnel, then bidirectional pipe.
*/
// Bail on a client that connects and never completes SOCKS5 negotiation.
// Generous enough for a Chromium loopback handshake (microseconds), short
// enough to catch a stalled / half-open client before the OS TCP keepalive
// notices (default ~2 hours on Linux).
const HANDSHAKE_TIMEOUT_MS = 10000;
function handleClient(client, upstream, forceDebug) {
let phase = 'greeting';
let buf = Buffer.alloc(0);
let upstreamSock = null;
let settled = false;
// Handshake-phase watchdog handle. Assigned after cleanup is declared so
// both references in this scope resolve unambiguously.
let handshakeTimer = null;
const cleanup = () => {
if (settled) return;
settled = true;
if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
try { client.destroy(); } catch (_) {}
if (upstreamSock) { try { upstreamSock.destroy(); } catch (_) {} }
};
// Bail on a client that connects and never completes SOCKS5 negotiation
// (stalled / half-open / non-SOCKS protocol). Without this, such a socket
// sits in activeSockets until the OS TCP keepalive notices — default
// ~2 hours on Linux. unref'd so a pending watchdog never holds the
// process alive after closeAllRelays().
handshakeTimer = setTimeout(() => {
if (phase !== 'piping') {
if (forceDebug) {
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} handshake timeout (phase=${phase}) — closing`));
}
cleanup();
}
}, HANDSHAKE_TIMEOUT_MS);
if (typeof handshakeTimer.unref === 'function') handshakeTimer.unref();
const onData = async (chunk) => {
buf = Buffer.concat([buf, chunk]);
try {
if (phase === 'greeting') {
// [0x05, NMETHODS, METHODS...]
if (buf.length < 2) return;
const nMethods = buf[1];
if (buf.length < 2 + nMethods) return;
const offered = buf.subarray(2, 2 + nMethods);
buf = buf.subarray(2 + nMethods);
// We only speak no-auth (0x00) to the local client. Chromium always
// offers it; if a client somehow didn't, reply "no acceptable
// methods" rather than violate the protocol by selecting unoffered.
if (!offered.includes(0x00)) {
try { client.write(Buffer.from([0x05, 0xFF])); } catch (_) {}
return cleanup();
}
client.write(Buffer.from([0x05, 0x00])); // select "no auth"
phase = 'request';
}
if (phase === 'request') {
// [0x05, CMD, 0x00, ATYP, ADDR..., PORT(2 BE)]
if (buf.length < 4) return;
if (buf[0] !== 0x05) { failReply(client, 0x01); return cleanup(); }
const cmd = buf[1];
const atyp = buf[3];
let host, port, hdrLen;
if (atyp === 0x01) { // IPv4
if (buf.length < 10) return;
host = `${buf[4]}.${buf[5]}.${buf[6]}.${buf[7]}`;
port = buf.readUInt16BE(8);
hdrLen = 10;
} else if (atyp === 0x03) { // domain
if (buf.length < 5) return;
const dLen = buf[4];
if (buf.length < 7 + dLen) return;
host = buf.subarray(5, 5 + dLen).toString('utf8');
port = buf.readUInt16BE(5 + dLen);
hdrLen = 7 + dLen;
} else if (atyp === 0x04) { // IPv6
if (buf.length < 22) return;
const seg = [];
for (let i = 0; i < 16; i += 2) seg.push(buf.readUInt16BE(4 + i).toString(16));
host = seg.join(':');
port = buf.readUInt16BE(20);
hdrLen = 22;
} else {
failReply(client, 0x08); // address type not supported
return cleanup();
}
if (cmd !== 0x01) { // only CONNECT
failReply(client, 0x07);
return cleanup();
}
// Hand the stream to .pipe() from here. Pause + detach this handler
// so a data event during the upstream connect can't re-enter.
phase = 'connecting';
client.pause();
client.off('data', onData);
const early = buf.subarray(hdrLen); // any bytes after the request header
buf = null;
let info;
try {
info = await SocksClient.createConnection({
proxy: {
host: upstream.host,
port: upstream.port,
type: 5,
userId: upstream.username,
password: upstream.password || '',
},
command: 'connect',
destination: { host, port },
timeout: 20000,
});
} catch (e) {
if (forceDebug) {
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} upstream connect failed (${host}:${port}): ${e.message}`));
}
failReply(client, 0x05); // connection refused
return cleanup();
}
upstreamSock = info.socket;
try { upstreamSock.setNoDelay(true); } catch (_) {}
upstreamSock.on('error', cleanup);
upstreamSock.on('close', cleanup);
client.on('error', cleanup);
client.on('close', cleanup);
// SOCKS5 success (BND.ADDR 0.0.0.0:0 — Chromium ignores it for CONNECT)
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
if (early && early.length) upstreamSock.write(early);
client.pipe(upstreamSock);
upstreamSock.pipe(client);
client.resume();
phase = 'piping';
// Negotiation complete — disarm the handshake watchdog so a
// long-running download isn't killed mid-transfer.
if (handshakeTimer) { clearTimeout(handshakeTimer); handshakeTimer = null; }
}
} catch (e) {
if (forceDebug) {
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} handler error: ${e.message}`));
}
cleanup();
}
};
client.on('data', onData);
client.on('error', cleanup);
}
// SOCKS5 failure reply (valid only before piping starts).
function failReply(client, code) {
try { client.write(Buffer.from([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0])); } catch (_) {}
}
/**
* Ensure a relay exists for the given upstream; returns its local port.
* Idempotent — repeated calls for the same upstream reuse one relay.
*
* @param {{host:string,port:number,username:string,password:string}} upstream
* @param {boolean} forceDebug
* @returns {Promise<number>} local 127.0.0.1 port the relay listens on
*/
async function ensureRelay(upstream, forceDebug = false) {
const key = upstreamKey(upstream);
const existing = _relays.get(key);
if (existing) return existing.port;
const activeSockets = new Set();
const server = net.createServer((clientSock) => {
// Disable Nagle: page scanning is full of small-packet phases (per-origin
// TLS handshakes, small XHR/API calls, the SOCKS handshake itself).
// Nagle + delayed-ACK adds ~40ms stalls on those; relays should not.
try { clientSock.setNoDelay(true); } catch (_) {}
activeSockets.add(clientSock);
clientSock.on('close', () => activeSockets.delete(clientSock));
handleClient(clientSock, upstream, forceDebug);
});
await new Promise((resolve, reject) => {
const onErr = (e) => reject(e);
server.once('error', onErr);
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', onErr);
// Keep a listener so a late server error doesn't crash the process.
server.on('error', (e) => {
if (forceDebug) console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} server error: ${e.message}`));
});
resolve();
});
});
const port = server.address().port;
_relays.set(key, { server, port, activeSockets });
if (forceDebug) {
console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} 127.0.0.1:${port} -> ${upstream.host}:${upstream.port} (auth user "${upstream.username}")`));
}
return port;
}
/**
* Sync lookup of an already-started relay's port. Returns null if no relay
* has been started for this upstream (caller should have called ensureRelay
* upfront).
*/
function getRelayPort(upstream) {
const r = _relays.get(upstreamKey(upstream));
return r ? r.port : null;
}
/**
* Tear down every relay: destroy in-flight sockets, close listeners.
* Safe to call multiple times.
*/
async function closeAllRelays(forceDebug = false) {
for (const [key, r] of _relays) {
for (const s of r.activeSockets) { try { s.destroy(); } catch (_) {} }
await new Promise((res) => {
try { r.server.close(() => res()); } catch (_) { res(); }
});
if (forceDebug) console.log(formatLogMessage('proxy', `${SOCKS_RELAY_TAG} closed relay for ${key}`));
}
_relays.clear();
}
module.exports = { ensureRelay, getRelayPort, closeAllRelays };