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
689 lines
24 KiB
JavaScript
689 lines
24 KiB
JavaScript
/**
|
|
* HTTPS reverse proxy for virtual hosts with JS-layer SNI support.
|
|
*
|
|
* Because bare-tls does not expose SSL_CTX_set_tlsext_servername_callback we
|
|
* implement SNI entirely in JavaScript:
|
|
*
|
|
* 1. Accept raw TCP connections via bare-tcp.
|
|
* 2. Read the first data chunk (TLS ClientHello) and extract the SNI
|
|
* hostname using a pure-JS TLS record parser.
|
|
* 3. Derive the two-label base domain from the SNI hostname and look up (or
|
|
* generate) a wildcard cert for that base domain.
|
|
* 4. Create a bare-tls.Socket with that cert, replaying the already-read
|
|
* bytes into it so the handshake can proceed.
|
|
* 5. Wrap the TLS socket in a bare-http1.ServerConnection so that HTTP
|
|
* request/response handling is identical to bare-https.
|
|
*/
|
|
|
|
const net = require('bare-tcp');
|
|
const tls = require('bare-tls');
|
|
const http1 = require('bare-http1');
|
|
|
|
const DEFAULT_PORT = 8443;
|
|
const BACKEND_TIMEOUT_MS = 30000;
|
|
// How long to wait for the first TLS bytes before giving up (ms)
|
|
const SNI_READ_TIMEOUT_MS = 5000;
|
|
|
|
const HOP_BY_HOP = new Set([
|
|
'transfer-encoding', 'connection', 'keep-alive', 'proxy-connection',
|
|
'proxy-authorization', 'proxy-authenticate', 'te', 'trailer', 'upgrade'
|
|
]);
|
|
|
|
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
|
|
function debugLog (...args) {
|
|
if (!DEBUG) return;
|
|
const msg = '[https-proxy:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
|
if (process.stderr) process.stderr.write(msg + '\n');
|
|
}
|
|
|
|
let proxyServer = null;
|
|
let proxyPort = null;
|
|
let proxyCertsDirOrCA = null;
|
|
|
|
const trafficStats = { bytesIn: 0, bytesOut: 0, requests: 0 };
|
|
|
|
/**
|
|
* Return a snapshot of cumulative traffic counters since the last reset.
|
|
* @returns {{bytesIn: number, bytesOut: number, requests: number}}
|
|
*/
|
|
function getTrafficStats() { return { ...trafficStats }; }
|
|
/**
|
|
* Reset all traffic counters to zero.
|
|
*/
|
|
function resetTrafficStats() { trafficStats.bytesIn = 0; trafficStats.bytesOut = 0; trafficStats.requests = 0; }
|
|
|
|
/** Resolver: hostname -> { host, port } | port | null */
|
|
let getBackendForHostname = null;
|
|
|
|
/**
|
|
* Register the function used to resolve a virtual hostname to its local backend.
|
|
* Must be called before `start`. Called by message-router with `holesailManager.getLocalBackend`.
|
|
* @param {Function} fn - Called as `fn(hostname)` and should return `{host, port, tls?: boolean}` or null.
|
|
*/
|
|
function setHostnameResolver (fn) {
|
|
getBackendForHostname = fn;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pure-JS TLS ClientHello SNI parser
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Extract the SNI server_name from a raw TLS ClientHello buffer.
|
|
* Returns the hostname string or null if not found / not parseable.
|
|
*
|
|
* TLS record layout (RFC 5246 / 8446):
|
|
* Byte 0 : content type (0x16 = handshake)
|
|
* Bytes 1-2 : protocol version (0x03 0x01..0x03)
|
|
* Bytes 3-4 : record length (big-endian)
|
|
* Byte 5 : handshake type (0x01 = ClientHello)
|
|
* Bytes 6-8 : handshake length (3-byte big-endian)
|
|
* Bytes 9-10 : client_version
|
|
* Bytes 11-42 : random (32 bytes)
|
|
* Byte 43 : session_id length
|
|
* ...variable fields...
|
|
* Then cipher suites, compression methods, extensions...
|
|
* Extension type 0x0000 = server_name
|
|
*/
|
|
function extractSNI (buf) {
|
|
try {
|
|
if (!buf || buf.length < 5) return null;
|
|
// Must be a TLS handshake record
|
|
if (buf[0] !== 0x16) return null;
|
|
// Must be TLS 1.0 / 1.2 / 1.3 outer version
|
|
if (buf[1] !== 0x03) return null;
|
|
|
|
const recordLen = (buf[3] << 8) | buf[4];
|
|
if (buf.length < 5 + recordLen) return null;
|
|
|
|
// Handshake header inside the record
|
|
let off = 5;
|
|
if (buf[off] !== 0x01) return null; // ClientHello
|
|
off += 1;
|
|
const hsLen = (buf[off] << 16) | (buf[off + 1] << 8) | buf[off + 2];
|
|
off += 3;
|
|
if (buf.length < off + hsLen) return null;
|
|
|
|
// client_version (2) + random (32)
|
|
off += 2 + 32;
|
|
|
|
// session_id
|
|
const sidLen = buf[off]; off += 1 + sidLen;
|
|
|
|
// cipher suites
|
|
const csLen = (buf[off] << 8) | buf[off + 1]; off += 2 + csLen;
|
|
|
|
// compression methods
|
|
const cmLen = buf[off]; off += 1 + cmLen;
|
|
|
|
// extensions length
|
|
if (off + 2 > buf.length) return null;
|
|
const extTotal = (buf[off] << 8) | buf[off + 1]; off += 2;
|
|
const extEnd = off + extTotal;
|
|
|
|
while (off + 4 <= extEnd) {
|
|
const extType = (buf[off] << 8) | buf[off + 1]; off += 2;
|
|
const extLen = (buf[off] << 8) | buf[off + 1]; off += 2;
|
|
if (extType === 0x0000) {
|
|
// server_name extension
|
|
// server_name_list length (2) + name_type (1) + name_length (2) + name
|
|
if (off + 5 > buf.length) return null;
|
|
const listLen = (buf[off] << 8) | buf[off + 1]; off += 2;
|
|
const listEnd = off + listLen;
|
|
while (off + 3 <= listEnd) {
|
|
const nameType = buf[off]; off += 1;
|
|
const nameLen = (buf[off] << 8) | buf[off + 1]; off += 2;
|
|
if (nameType === 0x00) {
|
|
// host_name
|
|
return buf.slice(off, off + nameLen).toString('ascii');
|
|
}
|
|
off += nameLen;
|
|
}
|
|
return null;
|
|
}
|
|
off += extLen;
|
|
}
|
|
return null;
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Derive the wildcard parent domain for a hostname — i.e. everything except
|
|
* the leftmost label. This is what the cert's SAN wildcard must cover.
|
|
*
|
|
* Examples:
|
|
* "myapp.hole.sail" -> "hole.sail" (cert: *.hole.sail)
|
|
* "test.haha.wooo" -> "haha.wooo" (cert: *.haha.wooo)
|
|
* "i.love.hole.sail" -> "love.hole.sail" (cert: *.love.hole.sail)
|
|
* "a.b.c.my.internal" -> "b.c.my.internal"
|
|
*
|
|
* Returns null if the hostname has fewer than 2 labels.
|
|
*/
|
|
function getWildcardParent (hostname) {
|
|
if (!hostname) return null;
|
|
const parts = hostname.split('.');
|
|
if (parts.length < 2) return null;
|
|
return parts.slice(1).join('.');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake-stream helper: replay buffered bytes into a Duplex-compatible stream
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Wraps a raw TCP socket so that the first chunk of already-read bytes is
|
|
* replayed as a 'data' event before the socket's own data events fire.
|
|
*
|
|
* bare-tls.Socket attaches to the underlying socket via _attach() which
|
|
* registers 'data', 'drain', 'end', 'error' listeners. We need to replay
|
|
* the peeked bytes AFTER bare-tls has attached its listener but BEFORE the
|
|
* next real data arrives from the network.
|
|
*
|
|
* Strategy: we intercept the socket's .on('data', ...) call. The first time
|
|
* a 'data' listener is added (by bare-tls._attach) we schedule a microtask
|
|
* to emit the buffered bytes into that listener, then restore normal behaviour.
|
|
*/
|
|
function makeReplaySocket (rawSocket, peekedBuf) {
|
|
if (!peekedBuf || peekedBuf.length === 0) return rawSocket;
|
|
|
|
let replayed = false;
|
|
const origOn = rawSocket.on.bind(rawSocket);
|
|
|
|
rawSocket.on = function (event, listener) {
|
|
const result = origOn(event, listener);
|
|
if (event === 'data' && !replayed) {
|
|
replayed = true;
|
|
// Restore original .on immediately so subsequent calls are unaffected
|
|
rawSocket.on = origOn;
|
|
// Replay the peeked bytes in the next microtask so bare-tls has
|
|
// finished its constructor before we feed it data.
|
|
queueMicrotask(() => {
|
|
try { listener(peekedBuf); } catch (_) {}
|
|
});
|
|
}
|
|
return result;
|
|
};
|
|
|
|
return rawSocket;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTTP request / upgrade handlers (same logic as before)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function parseUrlPathAndQuery (url) {
|
|
if (!url || typeof url !== 'string') return { path: '/', query: {}, search: '' };
|
|
const q = url.indexOf('?');
|
|
const path = q === -1 ? url : url.slice(0, q);
|
|
const search = q === -1 ? '' : url.slice(q);
|
|
const query = {};
|
|
if (search) {
|
|
const params = search.slice(1).split('&');
|
|
for (const p of params) {
|
|
const eq = p.indexOf('=');
|
|
if (eq === -1) query[decodeURIComponent(p)] = '';
|
|
else query[decodeURIComponent(p.slice(0, eq))] = decodeURIComponent(p.slice(eq + 1));
|
|
}
|
|
}
|
|
return { path, query, search };
|
|
}
|
|
|
|
function onRequest (req, res) {
|
|
const hostHeader = req.headers && (req.headers.host || req.headers.Host);
|
|
const hostFromHeader = hostHeader ? hostHeader.split(':')[0].trim() : '';
|
|
const { path: urlPath, query } = parseUrlPathAndQuery(req.url);
|
|
const hostFromQuery = (query._host || '').trim();
|
|
const hostname = hostFromQuery || hostFromHeader;
|
|
const method = req.method || 'GET';
|
|
debugLog('request: method=', method, 'hostname=', hostname);
|
|
|
|
if (!getBackendForHostname || !hostname) {
|
|
res.statusCode = 400;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.end('Bad Request');
|
|
return;
|
|
}
|
|
const backend = getBackendForHostname(hostname);
|
|
let targetHost = '127.0.0.1';
|
|
let targetPort = null;
|
|
const backendTls = backend != null && typeof backend === 'object' && backend.tls === true;
|
|
if (backend != null && typeof backend === 'object' && typeof backend.port === 'number') {
|
|
targetHost = backend.host ?? '127.0.0.1';
|
|
targetPort = backend.port;
|
|
} else if (typeof backend === 'number') {
|
|
targetPort = backend;
|
|
}
|
|
|
|
if (targetPort == null) {
|
|
if (process.stderr) process.stderr.write('[https-proxy] 502 no tunnel for "' + hostname + '"\n');
|
|
res.statusCode = 502;
|
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
res.end(`<!DOCTYPE html>
|
|
<html>
|
|
<head><title>No tunnel — Holesail Browser</title>
|
|
<style>body{font-family:system-ui,sans-serif;max-width:600px;margin:60px auto;padding:0 20px;color:#1a1a1a}
|
|
h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-size:.9em}
|
|
.hint{background:#fff8e1;border-left:4px solid #f39c12;padding:12px 16px;margin:16px 0;border-radius:0 4px 4px 0}
|
|
</style></head>
|
|
<body>
|
|
<h1>No tunnel for <code>${hostname}</code></h1>
|
|
<p>The Holesail Browser extension has no active tunnel for this hostname.</p>
|
|
<div class="hint">
|
|
<strong>Possible causes:</strong>
|
|
<ul>
|
|
<li>The tunnel is still connecting — wait a few seconds and refresh</li>
|
|
<li>The tunnel failed to connect — check the Virtual Hosts page in the dashboard</li>
|
|
<li>The hostname in the dashboard doesn't match exactly (check for typos)</li>
|
|
</ul>
|
|
</div>
|
|
<p>Open the <strong>Holesail Browser dashboard</strong> → Virtual Hosts to reconnect or add a new virtual host.</p>
|
|
</body></html>`);
|
|
return;
|
|
}
|
|
|
|
let proxyPath = req.url || '/';
|
|
if (hostFromQuery) {
|
|
const withoutHost = proxyPath.replace(/[?&]_host=[^&]*/g, '').replace(/\?&/, '?').replace(/\?$/, '');
|
|
proxyPath = withoutHost || '/';
|
|
}
|
|
|
|
// Hop-by-hop headers must not be forwarded — bare-http1 handles these
|
|
// transparently. Forwarding Transfer-Encoding: chunked would cause
|
|
// ERR_INVALID_CHUNKED_ENCODING because the HTTP library already decodes
|
|
// the chunked body before emitting 'data' events.
|
|
|
|
const reqHeaders = {};
|
|
for (const k of Object.keys(req.headers)) {
|
|
if (!HOP_BY_HOP.has(k.toLowerCase())) reqHeaders[k] = req.headers[k];
|
|
}
|
|
reqHeaders.host = hostname + (hostHeader && hostHeader.includes(':') ? ':' + hostHeader.split(':')[1] : '');
|
|
|
|
const opts = {
|
|
host: targetHost,
|
|
port: targetPort,
|
|
path: proxyPath,
|
|
method: req.method || 'GET',
|
|
headers: reqHeaders
|
|
};
|
|
if (backendTls) {
|
|
opts.backendTls = true;
|
|
opts.servername = hostname;
|
|
const tlsAgent = new http1.Agent();
|
|
tlsAgent.createConnection = (o) => {
|
|
if (o.backendTls && o.servername) {
|
|
const tcp = net.createConnection(o.port, o.host);
|
|
return new tls.Socket(tcp, { host: o.servername });
|
|
}
|
|
return net.createConnection(o);
|
|
};
|
|
opts.agent = tlsAgent;
|
|
}
|
|
|
|
let timedOut = false;
|
|
let proxyReq;
|
|
const timeoutId = setTimeout(() => {
|
|
timedOut = true;
|
|
if (proxyReq) proxyReq.destroy();
|
|
if (!res.writableEnded) {
|
|
res.statusCode = 504;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.end('Gateway Timeout');
|
|
}
|
|
}, BACKEND_TIMEOUT_MS);
|
|
|
|
proxyReq = http1.request(opts, (proxyRes) => {
|
|
clearTimeout(timeoutId);
|
|
if (timedOut) return;
|
|
res.statusCode = proxyRes.statusCode;
|
|
// Strip hop-by-hop headers from the backend response before forwarding
|
|
// to the browser. In particular, Transfer-Encoding must be removed because
|
|
// bare-http1 decodes chunked bodies internally — the 'data' events already
|
|
// contain the raw payload bytes, not chunked-encoded wire bytes.
|
|
for (const k of Object.keys(proxyRes.headers)) {
|
|
if (!HOP_BY_HOP.has(k.toLowerCase())) {
|
|
try { res.setHeader(k, proxyRes.headers[k]); } catch (_) {}
|
|
}
|
|
}
|
|
proxyRes.on('data', (chunk) => { if (!timedOut) { trafficStats.bytesOut += chunk.length; res.write(chunk); } });
|
|
proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end());
|
|
// Destroy the client socket on a mid-stream backend error so the browser
|
|
// receives a connection reset rather than a silently truncated 200 body.
|
|
proxyRes.on('error', () => { try { if (res.socket) res.socket.destroy(); } catch (_) {} });
|
|
});
|
|
proxyReq.on('error', (err) => {
|
|
clearTimeout(timeoutId);
|
|
if (!res.writableEnded) {
|
|
res.statusCode = 502;
|
|
res.setHeader('Content-Type', 'text/plain');
|
|
res.end('Proxy error: ' + (timedOut ? 'timeout' : err.message));
|
|
}
|
|
});
|
|
trafficStats.requests++;
|
|
req.on('data', (chunk) => { trafficStats.bytesIn += chunk.length; proxyReq.write(chunk); });
|
|
req.on('end', () => proxyReq.end());
|
|
}
|
|
|
|
function onUpgrade (req, socket, head) {
|
|
const hostHeader = req.headers && (req.headers.host || req.headers.Host);
|
|
const hostname = hostHeader ? hostHeader.split(':')[0].trim() : '';
|
|
debugLog('upgrade: hostname=', hostname);
|
|
|
|
if (!getBackendForHostname || !hostname) { socket.destroy(); return; }
|
|
const backend = getBackendForHostname(hostname);
|
|
let targetHost = '127.0.0.1';
|
|
let targetPort = null;
|
|
const backendTls = backend != null && typeof backend === 'object' && backend.tls === true;
|
|
if (backend != null && typeof backend === 'object' && typeof backend.port === 'number') {
|
|
targetHost = backend.host ?? '127.0.0.1';
|
|
targetPort = backend.port;
|
|
} else if (typeof backend === 'number') {
|
|
targetPort = backend;
|
|
}
|
|
|
|
if (targetPort == null) {
|
|
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
|
socket.destroy();
|
|
return;
|
|
}
|
|
|
|
let bareTcp = null;
|
|
try { bareTcp = require('bare-tcp'); } catch (_) {}
|
|
if (!bareTcp) { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); return; }
|
|
|
|
function writeUpgradeAndPipe(upstream) {
|
|
const headers = Object.entries(req.headers).map(([k, v]) => k + ': ' + v).join('\r\n');
|
|
const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
|
|
upstream.write(requestLine + headers + '\r\n\r\n');
|
|
if (head && head.length > 0) upstream.write(head);
|
|
socket.on('data', (c) => { trafficStats.bytesIn += c.length; });
|
|
upstream.on('data', (c) => { trafficStats.bytesOut += c.length; });
|
|
socket.pipe(upstream);
|
|
upstream.pipe(socket);
|
|
}
|
|
|
|
if (backendTls) {
|
|
const tcpSocket = bareTcp.connect(targetPort, targetHost);
|
|
const tlsSocket = new tls.Socket(tcpSocket, { host: hostname });
|
|
function _preConnectError() {
|
|
try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {}
|
|
try { tlsSocket.destroy(); } catch (_) {}
|
|
}
|
|
tlsSocket.on('error', _preConnectError);
|
|
socket.on('error', () => { try { tlsSocket.destroy(); } catch (_) {} });
|
|
tlsSocket.on('connect', () => {
|
|
tlsSocket.removeListener('error', _preConnectError);
|
|
tlsSocket.on('error', () => { try { socket.destroy(); } catch (_) {} });
|
|
writeUpgradeAndPipe(tlsSocket);
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Plain TCP backend
|
|
const upstream = bareTcp.connect(targetPort, targetHost);
|
|
function _preConnectError() {
|
|
try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {}
|
|
try { upstream.destroy(); } catch (_) {}
|
|
}
|
|
upstream.on('error', _preConnectError);
|
|
socket.on('error', () => { try { upstream.destroy(); } catch (_) {} });
|
|
upstream.on('connect', () => {
|
|
upstream.removeListener('error', _preConnectError);
|
|
upstream.on('error', () => { try { socket.destroy(); } catch (_) {} });
|
|
writeUpgradeAndPipe(upstream);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SNI-aware connection handler
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Called for each new raw TCP connection. Peeks the ClientHello, extracts
|
|
* the SNI hostname, selects the right wildcard cert, then wraps the socket
|
|
* in bare-tls and bare-http1.
|
|
*/
|
|
function handleRawConnection (rawSocket) {
|
|
// Pause the socket so we can read the first chunk without losing data
|
|
rawSocket.pause();
|
|
|
|
let sniTimer = null;
|
|
let firstChunk = null;
|
|
|
|
function proceed (peekedBuf) {
|
|
if (sniTimer) { clearTimeout(sniTimer); sniTimer = null; }
|
|
|
|
const sni = extractSNI(peekedBuf);
|
|
debugLog('SNI extracted:', sni);
|
|
|
|
// Derive the wildcard parent: strip the leftmost label so the cert's
|
|
// *.parent SAN exactly covers the SNI hostname at any depth.
|
|
// e.g. "myapp.hole.sail" -> parent "hole.sail" -> cert *.hole.sail
|
|
// "i.love.hole.sail" -> parent "love.hole.sail" -> cert *.love.hole.sail
|
|
const wildcardParent = getWildcardParent(sni) || 'hole.sail';
|
|
|
|
// Get or create a wildcard cert for this parent domain
|
|
let certResult = null;
|
|
if (proxyCertsDirOrCA && typeof proxyCertsDirOrCA.getOrCreateWildcardCert === 'function') {
|
|
certResult = proxyCertsDirOrCA.getOrCreateWildcardCert(wildcardParent);
|
|
} else if (proxyCertsDirOrCA && typeof proxyCertsDirOrCA.getOrCreateDomainCert === 'function') {
|
|
certResult = proxyCertsDirOrCA.getOrCreateDomainCert(
|
|
'wildcard.' + wildcardParent,
|
|
[
|
|
{ type: 2, value: '*.' + wildcardParent },
|
|
{ type: 2, value: wildcardParent }
|
|
]
|
|
);
|
|
}
|
|
|
|
if (!certResult) {
|
|
if (process.stderr) process.stderr.write('[https-proxy] no cert for wildcardParent=' + wildcardParent + ', dropping connection\n');
|
|
rawSocket.destroy();
|
|
return;
|
|
}
|
|
|
|
const certBuf = typeof certResult.cert === 'string' ? Buffer.from(certResult.cert) : certResult.cert;
|
|
const keyBuf = typeof certResult.key === 'string' ? Buffer.from(certResult.key) : certResult.key;
|
|
|
|
// Replay the peeked bytes into the socket before bare-tls attaches
|
|
const replaySocket = makeReplaySocket(rawSocket, peekedBuf);
|
|
|
|
// Create the TLS socket with the SNI-selected cert
|
|
let tlsSocket;
|
|
try {
|
|
tlsSocket = new tls.Socket(replaySocket, {
|
|
isServer: true,
|
|
cert: certBuf,
|
|
key: keyBuf,
|
|
allowHalfOpen: false
|
|
});
|
|
} catch (err) {
|
|
if (process.stderr) process.stderr.write('[https-proxy] bare-tls.Socket error: ' + err.message + '\n');
|
|
rawSocket.destroy();
|
|
return;
|
|
}
|
|
|
|
tlsSocket.on('error', (err) => {
|
|
debugLog('tls socket error:', err.message);
|
|
try { rawSocket.destroy(); } catch (_) {}
|
|
});
|
|
|
|
// Hand to bare-http1 for HTTP parsing — emits 'request' on the fakeServer
|
|
const fakeServer = proxyServer;
|
|
if (!fakeServer) { rawSocket.destroy(); return; }
|
|
|
|
const conn = new http1.ServerConnection(fakeServer, tlsSocket, {});
|
|
|
|
// Resume the raw socket now that everything is wired up
|
|
rawSocket.resume();
|
|
}
|
|
|
|
// Set a timeout in case the client never sends data
|
|
sniTimer = setTimeout(() => {
|
|
if (process.stderr) process.stderr.write('[https-proxy] SNI read timeout, dropping connection\n');
|
|
rawSocket.destroy();
|
|
}, SNI_READ_TIMEOUT_MS);
|
|
|
|
// Read the first chunk (the TLS ClientHello)
|
|
rawSocket.once('data', (chunk) => {
|
|
firstChunk = chunk;
|
|
proceed(chunk);
|
|
});
|
|
|
|
rawSocket.once('error', () => {
|
|
if (sniTimer) { clearTimeout(sniTimer); sniTimer = null; }
|
|
});
|
|
|
|
rawSocket.resume();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fake HTTP server object — bare-http1.ServerConnection emits 'request' and
|
|
// 'upgrade' on the server object it receives. We create a minimal EventEmitter
|
|
// that forwards those events to our handlers.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const EventEmitter = require('bare-events');
|
|
|
|
class FakeHttpServer extends EventEmitter {
|
|
constructor () {
|
|
super();
|
|
this.timeout = 0;
|
|
this.closing = false;
|
|
this.connections = new Set();
|
|
this.on('request', onRequest);
|
|
this.on('upgrade', onUpgrade);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Start the SNI-aware HTTPS reverse proxy.
|
|
* Exits the process if the port is already in use (another instance running).
|
|
* @param {number} [port=8443] - Port to listen on.
|
|
* @param {object} certsDirOrCA - The certificate-authority module (provides `getOrCreateWildcardCert`).
|
|
* @param {Function} [callback] - Called as `callback(err)` once listening.
|
|
* @param {string[]} [_baseDomains] - Reserved; unused in current implementation.
|
|
* @returns {object} The fake HTTP server EventEmitter.
|
|
*/
|
|
function start (port, certsDirOrCA, callback, _baseDomains) {
|
|
if (proxyServer) {
|
|
if (callback) callback(null);
|
|
return proxyServer;
|
|
}
|
|
|
|
if (typeof certsDirOrCA !== 'object' || certsDirOrCA === null) {
|
|
if (callback) callback(new Error('certsDirOrCA must be provided'));
|
|
return null;
|
|
}
|
|
|
|
proxyCertsDirOrCA = certsDirOrCA;
|
|
|
|
// Create the fake HTTP server (for request/upgrade events)
|
|
const fakeServer = new FakeHttpServer();
|
|
proxyServer = fakeServer;
|
|
|
|
// Create the raw TCP server
|
|
const tcpServer = net.createServer({ allowHalfOpen: false });
|
|
|
|
// Attach the TCP server to the fake server so stop() can close it
|
|
fakeServer._tcpServer = tcpServer;
|
|
|
|
tcpServer.on('connection', (sock) => {
|
|
fakeServer.connections.add(sock);
|
|
sock.once('close', () => fakeServer.connections.delete(sock));
|
|
handleRawConnection(sock);
|
|
});
|
|
|
|
tcpServer.on('error', (err) => {
|
|
if (err.code === 'EADDRINUSE') {
|
|
if (process.stderr) process.stderr.write('[https-proxy] port ' + (port || DEFAULT_PORT) + ' already in use\n');
|
|
process.exit(0);
|
|
return;
|
|
}
|
|
if (process.stderr) process.stderr.write('[https-proxy] TCP server error: ' + err.message + '\n');
|
|
if (callback) { callback(err); callback = null; }
|
|
});
|
|
|
|
const listenPort = port || DEFAULT_PORT;
|
|
tcpServer.listen(listenPort, '127.0.0.1', () => {
|
|
proxyPort = listenPort;
|
|
if (process.stderr) process.stderr.write('[https-proxy] Listening on 127.0.0.1:' + listenPort + ' (SNI mode)\n');
|
|
if (callback) { callback(null); callback = null; }
|
|
});
|
|
|
|
return fakeServer;
|
|
}
|
|
|
|
/**
|
|
* Destroy all active connections and close the TCP server.
|
|
* @param {Function} [callback] - Called once the server is fully closed.
|
|
*/
|
|
function stop (callback) {
|
|
if (!proxyServer) {
|
|
if (callback) callback();
|
|
return;
|
|
}
|
|
const fakeServer = proxyServer;
|
|
const tcpServer = fakeServer._tcpServer;
|
|
proxyServer = null;
|
|
proxyPort = null;
|
|
|
|
if (!tcpServer) {
|
|
if (callback) callback();
|
|
return;
|
|
}
|
|
|
|
// Destroy all tracked raw TCP connections so close() fires immediately.
|
|
// We use fakeServer.connections (populated in the 'connection' handler above)
|
|
// instead of the undocumented tcpServer._connections internal.
|
|
for (const socket of fakeServer.connections) {
|
|
try { socket.destroy(); } catch (_) {}
|
|
}
|
|
fakeServer.connections.clear();
|
|
|
|
tcpServer.close(() => {
|
|
if (callback) callback();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Stop and restart the proxy on the same port, re-using the existing CA.
|
|
* Used when the proxy port setting changes.
|
|
* @param {string[]} baseDomains - Unused; reserved for future pre-generation of certs.
|
|
* @param {Function} [callback] - Called as `callback(err)` once the new server is listening.
|
|
*/
|
|
function restart (baseDomains, callback) {
|
|
const savedPort = proxyPort || DEFAULT_PORT;
|
|
const ca = proxyCertsDirOrCA;
|
|
if (!ca) {
|
|
if (callback) callback(new Error('proxy not yet started'));
|
|
return;
|
|
}
|
|
stop(() => {
|
|
start(savedPort, ca, callback, baseDomains);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Return the port the proxy is currently listening on, or null if not started.
|
|
* @returns {number|null}
|
|
*/
|
|
function getPort () {
|
|
return proxyPort;
|
|
}
|
|
|
|
module.exports = {
|
|
setHostnameResolver,
|
|
start,
|
|
stop,
|
|
restart,
|
|
getPort,
|
|
getTrafficStats,
|
|
resetTrafficStats
|
|
};
|