/** * HTTP CONNECT proxy for PAC. Listens in plain HTTP; accepts CONNECT, replies * 200 Connection established, then tunnels the raw stream to the HTTPS proxy * (127.0.0.1:8443). PAC must point to this port so the browser sends CONNECT * here instead of TLS to the HTTPS proxy. */ const tcp = require('bare-tcp'); const DEFAULT_PORT = 8442; const UPSTREAM_HOST = '127.0.0.1'; const UPSTREAM_PORT = 8443; const HEADER_END = Buffer.from('\r\n\r\n'); const RESPONSE_200 = Buffer.from('HTTP/1.1 200 Connection established\r\n\r\n'); const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true'; function debugLog(...args) { if (!DEBUG) return; const msg = '[connect-proxy:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '); if (process.stderr) process.stderr.write(msg + '\n'); } function stderrLog(...args) { const msg = '[connect-proxy] ' + args.join(' '); if (process.stderr) process.stderr.write(msg + '\n'); } let server = null; let listenPort = null; const activeClientSockets = new Set(); /** * Start the HTTP CONNECT proxy server. * If the server is already running the callback is called immediately with no error. * Exits the process if the port is already in use (EADDRINUSE) — this indicates * another native host instance is running. * @param {number} [port=8442] - Port to listen on. * @param {number} [upstreamPort=8443] - Port of the upstream HTTPS proxy to tunnel to. * @param {Function} [callback] - Called as `callback(err)` once listening (or on error). * @returns {object|null} The bare-tcp server instance, or null on error. */ function start(port, upstreamPort, callback) { if (server) { if (callback) callback(null); return server; } const connectPort = port || DEFAULT_PORT; const upstream = upstreamPort || UPSTREAM_PORT; let callbackCalled = false; function done(err) { if (callbackCalled) return; callbackCalled = true; if (err) { stderrLog('start failed:', err.message); server = null; listenPort = null; } if (callback) callback(err || null); } stderrLog('starting on 127.0.0.1:' + connectPort + ' -> ' + UPSTREAM_HOST + ':' + upstream); const HEADER_READ_TIMEOUT_MS = 10000; try { server = tcp.createServer((clientSocket) => { debugLog('new client connection'); activeClientSockets.add(clientSocket); clientSocket.once('close', () => activeClientSockets.delete(clientSocket)); let buffer = Buffer.alloc(0); let tunneled = false; const pendingChunks = []; let upstreamSocket = null; // Protect against clients that connect but never send the CONNECT header const headerTimeout = setTimeout(() => { if (!tunneled) { debugLog('header read timeout — destroying idle client socket'); clientSocket.destroy(); } }, HEADER_READ_TIMEOUT_MS); function flushPending() { if (!upstreamSocket) return; for (const c of pendingChunks) upstreamSocket.write(c); pendingChunks.length = 0; } function tryTunnel() { const idx = bufferIndexOf(buffer, HEADER_END); if (idx === -1) return; const header = buffer.subarray(0, idx); const firstLine = header.toString('utf8').split('\r\n')[0] || ''; const connectTarget = firstLine.replace(/^CONNECT\s+/i, '').trim(); debugLog('CONNECT target=', connectTarget, 'headerLen=', header.length); const rest = buffer.subarray(idx + HEADER_END.length); buffer = null; tunneled = true; clearTimeout(headerTimeout); if (rest.length > 0) pendingChunks.push(rest); clientSocket.write(RESPONSE_200, (err) => { if (err) { debugLog('write 200 failed:', err.message); clientSocket.destroy(err); return; } // Register error handler synchronously before the connect callback can // fire, so an immediate ECONNREFUSED is never an unhandled error event. upstreamSocket = tcp.connect(upstream, UPSTREAM_HOST); upstreamSocket.on('error', (err) => { debugLog('upstream socket error:', err.message); clientSocket.destroy(err); }); clientSocket.on('error', () => { if (upstreamSocket) upstreamSocket.destroy(); }); upstreamSocket.on('connect', () => { debugLog('tunnel established connectTarget=', connectTarget, 'upstream=', UPSTREAM_HOST + ':' + upstream); flushPending(); clientSocket.removeAllListeners('data'); clientSocket.pipe(upstreamSocket); upstreamSocket.pipe(clientSocket); }); }); } clientSocket.on('data', (chunk) => { if (tunneled) { if (upstreamSocket) upstreamSocket.write(chunk); else pendingChunks.push(chunk); return; } buffer = Buffer.concat([buffer, chunk]); // Reject oversized headers to prevent OOM from malicious/buggy clients. if (buffer.length > 65536) { debugLog('header too large (' + buffer.length + ' bytes) — destroying client socket'); clientSocket.destroy(); return; } tryTunnel(); }); clientSocket.on('error', (err) => { clearTimeout(headerTimeout); debugLog('client socket error:', err.message); }); clientSocket.on('close', () => { clearTimeout(headerTimeout); if (upstreamSocket) { try { upstreamSocket.destroy(); } catch (_) {} } }); }); } catch (err) { stderrLog('createServer threw:', err.message); done(err); return null; } server.on('error', (err) => { if (err.code === 'EADDRINUSE') { stderrLog('port ' + connectPort + ' already in use — another instance is running, exiting'); process.exit(0); return; } stderrLog('server error:', err.message); done(err); }); try { server.listen(connectPort, '127.0.0.1', () => { listenPort = connectPort; stderrLog('Listening on 127.0.0.1:' + connectPort + ' -> ' + UPSTREAM_HOST + ':' + upstream); done(null); }); } catch (err) { stderrLog('listen threw:', err.message); done(err); return null; } return server; } function bufferIndexOf(buf, needle) { if (buf.length < needle.length) return -1; const n = needle.length; for (let i = 0; i <= buf.length - n; i++) { if (buf.subarray(i, i + n).equals(needle)) return i; } return -1; } /** * Return the port the server is currently listening on, or null if not started. * @returns {number|null} */ function getPort() { return listenPort; } /** * Destroy all active client sockets and close the server. * @param {Function} [callback] - Called as `callback(null)` once closed. */ function stop(callback) { if (!server) { if (callback) callback(null); return; } for (const sock of activeClientSockets) { try { sock.destroy(); } catch (_) {} } activeClientSockets.clear(); server.close(() => { server = null; listenPort = null; if (callback) callback(null); }); } module.exports = { start, stop, getPort, DEFAULT_PORT };