CI / Build & Test (push) Successful in 3m12s
- 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
222 lines
7.1 KiB
JavaScript
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 };
|