test
CI / Build & Test (push) Successful in 3m22s

This commit is contained in:
Raven Scott
2026-02-28 20:59:38 -05:00
parent c734bfa1e3
commit 15dd9b7bd1
3 changed files with 377 additions and 190 deletions
+18
View File
@@ -435,10 +435,28 @@ function isRootCAInstalled(callback) {
} }
} }
/**
* Get or create a wildcard certificate for a two-label base domain.
* e.g. getOrCreateWildcardCert('hole.sail') -> cert covering *.hole.sail + hole.sail
*
* This is the preferred API for the SNI-aware HTTPS proxy — one cert per TLD,
* selected at handshake time based on the SNI hostname.
*/
function getOrCreateWildcardCert (baseDomain) {
if (!baseDomain || baseDomain.split('.').length < 2) return null;
const certKey = 'wildcard.' + baseDomain;
const altNames = [
{ type: 2, value: '*.' + baseDomain },
{ type: 2, value: baseDomain }
];
return getOrCreateDomainCert(certKey, altNames);
}
module.exports = { module.exports = {
installRootCA, installRootCA,
isRootCAInstalled, isRootCAInstalled,
getOrCreateDomainCert, getOrCreateDomainCert,
getOrCreateWildcardCert,
getCaCertPath, getCaCertPath,
getCertsDir, getCertsDir,
/** Resolves when the CA is ready (either already existed or was generated). */ /** Resolves when the CA is ready (either already existed or was generated). */
+2 -25
View File
@@ -37,29 +37,8 @@ const rdpManager = require('./rdp-manager.js');
httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname)); httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname));
/** Extract unique two-label base domains from current virtual hosts for the multi-SAN cert */ // With SNI-aware proxy, certs are selected per-connection — no restart needed
function getActiveBaseDomains() { // when virtual hosts change. refreshProxyCert() is kept only for port changes.
const seen = new Set(['hole.sail']);
for (const v of holesailManager.getVirtualHosts()) {
if (v && v.hostname) {
const parts = v.hostname.split('.');
if (parts.length >= 3) seen.add(parts.slice(-2).join('.'));
}
}
return Array.from(seen);
}
/** Restart the HTTPS proxy with a fresh multi-SAN cert covering all current TLDs */
function refreshProxyCert() {
const domains = getActiveBaseDomains();
httpsProxy.restart(domains, (err) => {
if (err) {
if (process.stderr) process.stderr.write('[host] proxy cert refresh failed: ' + err.message + '\n');
} else {
if (process.stderr) process.stderr.write('[host] proxy cert refreshed for: ' + domains.join(', ') + '\n');
}
});
}
const PROXY_PORT = 8443; const PROXY_PORT = 8443;
const CONNECT_PROXY_PORT = 8442; const CONNECT_PROXY_PORT = 8442;
@@ -333,7 +312,6 @@ async function handleMessageAsync(send, msg) {
const result = await holesailManager.setVirtualHost(payload); const result = await holesailManager.setVirtualHost(payload);
debugLog('setVirtualHost: result=', JSON.stringify(result)); debugLog('setVirtualHost: result=', JSON.stringify(result));
reply(result); reply(result);
if (result && result.ok) refreshProxyCert();
break; break;
} }
case 'removeVirtualHost': { case 'removeVirtualHost': {
@@ -341,7 +319,6 @@ async function handleMessageAsync(send, msg) {
const result = await holesailManager.removeVirtualHost(payload); const result = await holesailManager.removeVirtualHost(payload);
debugLog('removeVirtualHost: result=', JSON.stringify(result)); debugLog('removeVirtualHost: result=', JSON.stringify(result));
reply(result); reply(result);
if (result && result.ok) refreshProxyCert();
break; break;
} }
case 'getProxyPort': { case 'getProxyPort': {
+345 -153
View File
@@ -1,23 +1,28 @@
/** /**
* HTTPS proxy for virtual hosts. Listens with a wildcard cert (*.hs) * HTTPS reverse proxy for virtual hosts with JS-layer SNI support.
* and forwards requests to the local port of the corresponding Holesail client. *
* 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 path = require('bare-path'); const net = require('bare-tcp');
const fs = require('bare-fs'); const tls = require('bare-tls');
const http1 = require('bare-http1');
let https = null;
let http = null;
try {
https = require('bare-https');
http = require('bare-http1');
} catch (e) {
if (process.stderr) process.stderr.write('[https-proxy] Missing bare-https/bare-http1: ' + e.message + '\n');
}
const DEFAULT_PORT = 8443; const DEFAULT_PORT = 8443;
/** Timeout for connecting to and receiving response from backend (ms) */
const BACKEND_TIMEOUT_MS = 30000; 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 DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true'; const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
function debugLog (...args) { function debugLog (...args) {
@@ -28,105 +33,158 @@ function debugLog(...args) {
let proxyServer = null; let proxyServer = null;
let proxyPort = null; let proxyPort = null;
let proxyCertsDirOrCA = null; // saved so restart() can regenerate the cert let proxyCertsDirOrCA = null;
/** Resolver: hostname -> { host, port } or port number (then host defaults to 127.0.0.1) or null */
/** Resolver: hostname -> { host, port } | port | null */
let getBackendForHostname = null; let getBackendForHostname = null;
function setHostnameResolver (fn) { function setHostnameResolver (fn) {
getBackendForHostname = fn; getBackendForHostname = fn;
} }
// ---------------------------------------------------------------------------
// Pure-JS TLS ClientHello SNI parser
// ---------------------------------------------------------------------------
/** /**
* Build a multi-SAN wildcard cert covering all given base domains. * Extract the SNI server_name from a raw TLS ClientHello buffer.
* e.g. baseDomains = ['hole.sail', 'heheh.jungle'] * Returns the hostname string or null if not found / not parseable.
* produces SANs: *.hole.sail, hole.sail, *.heheh.jungle, heheh.jungle *
* The cert is keyed by a stable name "multi-wildcard-<sorted-domains>" so it * TLS record layout (RFC 5246 / 8446):
* is regenerated whenever the domain list changes. * 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 buildMultiSanCert(certsDirOrCA, baseDomains) { function extractSNI (buf) {
const sorted = Array.from(new Set(['hole.sail', ...baseDomains])).sort(); try {
// The cert key encodes the exact domain list — a different list gets a different if (!buf || buf.length < 5) return null;
// directory, so the cache naturally invalidates when TLDs are added/removed. // Must be a TLS handshake record
const certKey = 'multi-wildcard-' + sorted.join('_'); if (buf[0] !== 0x16) return null;
const altNames = []; // Must be TLS 1.0 / 1.2 / 1.3 outer version
for (const bd of sorted) { if (buf[1] !== 0x03) return null;
altNames.push({ type: 2, value: '*.' + bd });
altNames.push({ type: 2, value: bd });
}
return certsDirOrCA.getOrCreateDomainCert(certKey, altNames);
}
function start(port, certsDirOrCA, callback, baseDomains) { const recordLen = (buf[3] << 8) | buf[4];
if (!https || !http) { if (buf.length < 5 + recordLen) return null;
if (callback) callback(new Error('bare-https or bare-http1 not available'));
// 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; return null;
} }
if (proxyServer) { off += extLen;
if (callback) callback(null);
return proxyServer;
} }
return null;
if (typeof certsDirOrCA !== 'object' || !certsDirOrCA.getOrCreateDomainCert) { } catch (_) {
if (callback) callback(new Error('certsDirOrCA must provide getOrCreateDomainCert'));
return null; return null;
} }
proxyCertsDirOrCA = certsDirOrCA;
const result = buildMultiSanCert(certsDirOrCA, baseDomains || []);
if (!result) {
if (callback) callback(new Error('Could not generate multi-SAN wildcard cert'));
return null;
} }
const opts = { /**
cert: typeof result.cert === 'string' ? Buffer.from(result.cert) : result.cert, * Extract the two-label base domain from a hostname.
key: typeof result.key === 'string' ? Buffer.from(result.key) : result.key * e.g. "test.haha.wooo" -> "haha.wooo"
* "myapp.hole.sail" -> "hole.sail"
* Returns null if the hostname has fewer than 2 labels.
*/
function getBaseDomain (hostname) {
if (!hostname) return null;
const parts = hostname.split('.');
if (parts.length < 2) return null;
return parts.slice(-2).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;
}; };
let callbackCalled = false; return rawSocket;
function done(err) {
if (callbackCalled) return;
callbackCalled = true;
if (err) {
if (process.stderr) process.stderr.write('[https-proxy] start failed: ' + err.message + '\n');
proxyServer = null;
}
if (callback) callback(err || null);
}
try {
proxyServer = https.createServer(opts, onRequest);
// Forward WebSocket upgrade requests to the backend tunnel
proxyServer.on('upgrade', onUpgrade);
} catch (err) {
if (process.stderr) process.stderr.write('[https-proxy] createServer threw: ' + err.message + '\n');
done(err);
return null;
}
const listenPort = port || DEFAULT_PORT;
proxyServer.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
if (process.stderr) process.stderr.write('[https-proxy] port ' + listenPort + ' already in use — another instance is running, exiting\n');
process.exit(0);
return;
}
if (process.stderr) process.stderr.write('[https-proxy] Error: ' + err.message + '\n');
done(err);
});
try {
proxyServer.listen(listenPort, '127.0.0.1', () => {
proxyPort = listenPort;
if (process.stderr) process.stderr.write('[https-proxy] Listening on 127.0.0.1:' + listenPort + '\n');
done(null);
});
} catch (err) {
if (process.stderr) process.stderr.write('[https-proxy] listen threw: ' + err.message + '\n');
done(err);
return null;
}
return proxyServer;
} }
// ---------------------------------------------------------------------------
// HTTP request / upgrade handlers (same logic as before)
// ---------------------------------------------------------------------------
function parseUrlPathAndQuery (url) { function parseUrlPathAndQuery (url) {
if (!url || typeof url !== 'string') return { path: '/', query: {}, search: '' }; if (!url || typeof url !== 'string') return { path: '/', query: {}, search: '' };
const q = url.indexOf('?'); const q = url.indexOf('?');
@@ -151,10 +209,9 @@ function onRequest(req, res) {
const hostFromQuery = (query._host || '').trim(); const hostFromQuery = (query._host || '').trim();
const hostname = hostFromQuery || hostFromHeader; const hostname = hostFromQuery || hostFromHeader;
const method = req.method || 'GET'; const method = req.method || 'GET';
debugLog('request: method=', method, 'url=', req.url, 'hostHeader=', hostHeader, 'hostFromHeader=', hostFromHeader, 'hostFromQuery=', hostFromQuery, 'hostname=', hostname); debugLog('request: method=', method, 'hostname=', hostname);
if (!getBackendForHostname || !hostname) { if (!getBackendForHostname || !hostname) {
debugLog('request: 400 Bad Request (no resolver or hostname)');
res.statusCode = 400; res.statusCode = 400;
res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Type', 'text/plain');
res.end('Bad Request'); res.end('Bad Request');
@@ -169,15 +226,9 @@ function onRequest(req, res) {
} else if (typeof backend === 'number') { } else if (typeof backend === 'number') {
targetPort = backend; targetPort = backend;
} }
debugLog('request: backend=', backend, 'targetHost=', targetHost, 'targetPort=', targetPort);
if (targetPort == null) { if (targetPort == null) {
debugLog('request: 502 No tunnel for hostname'); if (process.stderr) process.stderr.write('[https-proxy] 502 no tunnel for "' + hostname + '"\n');
// Log the known hostnames to help diagnose mismatches
if (process.stderr) {
const knownHosts = getBackendForHostname ? '(resolver set)' : '(no resolver)';
process.stderr.write('[https-proxy] 502 no tunnel for "' + hostname + '" ' + knownHosts + '\n');
}
res.statusCode = 502; res.statusCode = 502;
res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<!DOCTYPE html> res.end(`<!DOCTYPE html>
@@ -202,12 +253,13 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
</body></html>`); </body></html>`);
return; return;
} }
let proxyPath = req.url || '/'; let proxyPath = req.url || '/';
if (hostFromQuery) { if (hostFromQuery) {
const withoutHost = proxyPath.replace(/[?&]_host=[^&]*/g, '').replace(/\?&/, '?').replace(/\?$/, ''); const withoutHost = proxyPath.replace(/[?&]_host=[^&]*/g, '').replace(/\?&/, '?').replace(/\?$/, '');
proxyPath = withoutHost || '/'; proxyPath = withoutHost || '/';
} }
debugLog('request: proxying to', targetHost + ':' + targetPort, 'path=', proxyPath);
const opts = { const opts = {
host: targetHost, host: targetHost,
port: targetPort, port: targetPort,
@@ -218,27 +270,25 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
delete opts.headers['proxy-connection']; delete opts.headers['proxy-connection'];
delete opts.headers['proxy-authorization']; delete opts.headers['proxy-authorization'];
opts.headers.host = hostname + (hostHeader && hostHeader.includes(':') ? ':' + hostHeader.split(':')[1] : ''); opts.headers.host = hostname + (hostHeader && hostHeader.includes(':') ? ':' + hostHeader.split(':')[1] : '');
let timedOut = false; let timedOut = false;
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
timedOut = true; timedOut = true;
debugLog('request: TIMEOUT hostname=', hostname, 'target=', targetHost + ':' + targetPort);
proxyReq.destroy(); proxyReq.destroy();
if (!res.writableEnded) { if (!res.writableEnded) {
res.statusCode = 504; res.statusCode = 504;
res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Type', 'text/plain');
res.end('Gateway Timeout: backend did not respond in time. The tunnel may still be connecting.'); res.end('Gateway Timeout');
} }
}, BACKEND_TIMEOUT_MS); }, BACKEND_TIMEOUT_MS);
const proxyReq = http.request(opts, (proxyRes) => {
const proxyReq = http1.request(opts, (proxyRes) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
debugLog('request: backend response hostname=', hostname, 'status=', proxyRes.statusCode);
if (timedOut) return; if (timedOut) return;
res.statusCode = proxyRes.statusCode; res.statusCode = proxyRes.statusCode;
const headers = proxyRes.headers; const headers = proxyRes.headers;
for (const k in headers) { for (const k in headers) {
try { try { res.setHeader(k, headers[k]); } catch (_) {}
res.setHeader(k, headers[k]);
} catch (_) {}
} }
proxyRes.on('data', (chunk) => !timedOut && res.write(chunk)); proxyRes.on('data', (chunk) => !timedOut && res.write(chunk));
proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end()); proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end());
@@ -246,7 +296,6 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
}); });
proxyReq.on('error', (err) => { proxyReq.on('error', (err) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
debugLog('request: proxyReq error hostname=', hostname, 'err=', err.message, 'timedOut=', timedOut);
if (!res.writableEnded) { if (!res.writableEnded) {
res.statusCode = 502; res.statusCode = 502;
res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Type', 'text/plain');
@@ -257,19 +306,12 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
req.on('end', () => proxyReq.end()); req.on('end', () => proxyReq.end());
} }
/**
* Handle WebSocket upgrade requests by piping the raw socket to the backend.
* This allows ws:// / wss:// connections through *.hole.sail virtual hosts.
*/
function onUpgrade (req, socket, head) { function onUpgrade (req, socket, head) {
const hostHeader = req.headers && (req.headers.host || req.headers.Host); const hostHeader = req.headers && (req.headers.host || req.headers.Host);
const hostname = hostHeader ? hostHeader.split(':')[0].trim() : ''; const hostname = hostHeader ? hostHeader.split(':')[0].trim() : '';
debugLog('upgrade: hostname=', hostname, 'url=', req.url); debugLog('upgrade: hostname=', hostname);
if (!getBackendForHostname || !hostname) { if (!getBackendForHostname || !hostname) { socket.destroy(); return; }
socket.destroy();
return;
}
const backend = getBackendForHostname(hostname); const backend = getBackendForHostname(hostname);
let targetHost = '127.0.0.1'; let targetHost = '127.0.0.1';
let targetPort = null; let targetPort = null;
@@ -281,41 +323,192 @@ function onUpgrade(req, socket, head) {
} }
if (targetPort == null) { if (targetPort == null) {
debugLog('upgrade: no backend for hostname=', hostname);
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
socket.destroy(); socket.destroy();
return; return;
} }
// Open a raw TCP connection to the backend and pipe the socket let bareTcp = null;
let net = null; try { bareTcp = require('bare-tcp'); } catch (_) {}
try { net = require('bare-tcp'); } catch (_) {} if (!bareTcp) { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); return; }
if (!net) {
debugLog('upgrade: bare-tcp not available');
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
socket.destroy();
return;
}
const upstream = net.connect(targetPort, targetHost, () => { const upstream = bareTcp.connect(targetPort, targetHost, () => {
// Reconstruct the HTTP upgrade request and forward it const headers = Object.entries(req.headers).map(([k, v]) => k + ': ' + v).join('\r\n');
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'; const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
upstream.write(requestLine + headers + '\r\n\r\n'); upstream.write(requestLine + headers + '\r\n\r\n');
if (head && head.length > 0) upstream.write(head); if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream); socket.pipe(upstream);
upstream.pipe(socket); upstream.pipe(socket);
}); });
upstream.on('error', () => { try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {} });
socket.on('error', () => { try { upstream.destroy(); } catch (_) {} });
}
upstream.on('error', (err) => { // ---------------------------------------------------------------------------
debugLog('upgrade: upstream error hostname=', hostname, 'err=', err.message); // SNI-aware connection handler
try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {} // ---------------------------------------------------------------------------
/**
* 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);
const baseDomain = getBaseDomain(sni) || 'hole.sail';
// Get or create a wildcard cert for this base domain
let certResult = null;
if (proxyCertsDirOrCA && typeof proxyCertsDirOrCA.getOrCreateWildcardCert === 'function') {
certResult = proxyCertsDirOrCA.getOrCreateWildcardCert(baseDomain);
} else if (proxyCertsDirOrCA && typeof proxyCertsDirOrCA.getOrCreateDomainCert === 'function') {
// Fallback: use the old multi-SAN approach with just this domain
certResult = proxyCertsDirOrCA.getOrCreateDomainCert(
'wildcard.' + baseDomain,
[
{ type: 2, value: '*.' + baseDomain },
{ type: 2, value: baseDomain }
]
);
}
if (!certResult) {
if (process.stderr) process.stderr.write('[https-proxy] no cert for baseDomain=' + baseDomain + ', 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
}); });
socket.on('error', () => { } catch (err) {
try { upstream.destroy(); } catch (_) {} 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);
}); });
// 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
// ---------------------------------------------------------------------------
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', handleRawConnection);
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;
} }
function stop (callback) { function stop (callback) {
@@ -323,30 +516,29 @@ function stop(callback) {
if (callback) callback(); if (callback) callback();
return; return;
} }
const s = proxyServer; const fakeServer = proxyServer;
const tcpServer = fakeServer._tcpServer;
proxyServer = null; proxyServer = null;
proxyPort = null; proxyPort = null;
// Destroy the raw TCP connections tracked by bare-tcp's internal _connections
// set. This is necessary because bare-tcp's _closeMaybe() only fires the if (!tcpServer) {
// 'close' event once _connections is empty — destroying only the TLS-wrapped if (callback) callback();
// sockets (which we no longer track) would leave the raw sockets open and return;
// s.close() would never call its callback. }
const rawConns = s._connections;
// Destroy all raw TCP connections so close() fires immediately
const rawConns = tcpServer._connections;
if (rawConns && typeof rawConns[Symbol.iterator] === 'function') { if (rawConns && typeof rawConns[Symbol.iterator] === 'function') {
for (const socket of rawConns) { for (const socket of rawConns) {
try { socket.destroy(); } catch (_) {} try { socket.destroy(); } catch (_) {}
} }
} }
s.close(() => {
tcpServer.close(() => {
if (callback) callback(); if (callback) callback();
}); });
} }
/**
* Restart the HTTPS proxy with an updated multi-SAN cert covering the given
* base domains (e.g. ['hole.sail', 'heheh.jungle']). Existing connections
* finish naturally; new connections get the new cert immediately.
*/
function restart (baseDomains, callback) { function restart (baseDomains, callback) {
const savedPort = proxyPort || DEFAULT_PORT; const savedPort = proxyPort || DEFAULT_PORT;
const ca = proxyCertsDirOrCA; const ca = proxyCertsDirOrCA;