Files
holesail-browser/native-host/https-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

653 lines
23 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}` 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;
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
};
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
proxyReq.destroy();
if (!res.writableEnded) {
res.statusCode = 504;
res.setHeader('Content-Type', 'text/plain');
res.end('Gateway Timeout');
}
}, BACKEND_TIMEOUT_MS);
const 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;
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; }
// Register error handler synchronously before the connect callback can fire,
// so an immediate ECONNREFUSED is never an unhandled error event.
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 (_) {} });
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);
});
}
// ---------------------------------------------------------------------------
// 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
};