Files
holesail-browser/native-host/proxy/connect-proxy.js
T
Raven Scott 80759f8b0c
CI / Build & Test (push) Successful in 3m12s
chore(native-host): group modules into proxy/, managers/, host/
- Move connect-proxy and https-proxy into proxy/
- Move certificate-authority, backup-manager, ssh-manager, rdp-manager into managers/
- Move messenger.js into host/
- Move test-dirname.cjs into test/
- Update imports, CI lint paths, and ARCHITECTURE.md
2026-03-03 23:53:51 -05:00

222 lines
7.1 KiB
JavaScript

/**
* 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 };