/** * 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(`
${hostname}The Holesail Browser extension has no active tunnel for this hostname.
Open the Holesail Browser dashboard → Virtual Hosts to reconnect or add a new virtual host.
`); 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 };