Files
holesail-browser/native-host/connect-proxy.js
T
Raven Scott f1e98a7edd
CI / Build & Test (push) Successful in 2m54s
docs: add CONTRIBUTING.md, CHANGELOG.md, and JSDoc to entire codebase
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all
npm scripts, how to add new native host message types, code style, and
debugging guidance.

Add CHANGELOG.md at the project root documenting all features and fixes
across the 1.0.0 release.

Add JSDoc (@param, @returns) to all previously undocumented exported
functions across 35 JS files:
- native-host/holesail-manager/ (index, virtual-hosts, service-tunnels,
  servers, port-allocator)
- native-host top-level managers (startup, connect-proxy, https-proxy,
  certificate-authority, ssh-manager, rdp-manager)
- extension/background/ (logs, native-messaging, proxy, message-router)
- extension/dashboard/core/ (utils, navigation, init)
- extension/dashboard/ui/ (modal, toast, state-tag)
- extension/dashboard/pages/ (all 10 page files)
- extension/dashboard/refresh.js, events.js
- extension/dashboard/data/hostname-validator.js
- scripts/ (build-host, run-install)
2026-03-01 00:40:53 -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 };