@@ -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 = {
|
||||
installRootCA,
|
||||
isRootCAInstalled,
|
||||
getOrCreateDomainCert,
|
||||
getOrCreateWildcardCert,
|
||||
getCaCertPath,
|
||||
getCertsDir,
|
||||
/** Resolves when the CA is ready (either already existed or was generated). */
|
||||
|
||||
+2
-25
@@ -37,29 +37,8 @@ const rdpManager = require('./rdp-manager.js');
|
||||
|
||||
httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname));
|
||||
|
||||
/** Extract unique two-label base domains from current virtual hosts for the multi-SAN cert */
|
||||
function getActiveBaseDomains() {
|
||||
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');
|
||||
}
|
||||
});
|
||||
}
|
||||
// With SNI-aware proxy, certs are selected per-connection — no restart needed
|
||||
// when virtual hosts change. refreshProxyCert() is kept only for port changes.
|
||||
|
||||
const PROXY_PORT = 8443;
|
||||
const CONNECT_PROXY_PORT = 8442;
|
||||
@@ -333,7 +312,6 @@ async function handleMessageAsync(send, msg) {
|
||||
const result = await holesailManager.setVirtualHost(payload);
|
||||
debugLog('setVirtualHost: result=', JSON.stringify(result));
|
||||
reply(result);
|
||||
if (result && result.ok) refreshProxyCert();
|
||||
break;
|
||||
}
|
||||
case 'removeVirtualHost': {
|
||||
@@ -341,7 +319,6 @@ async function handleMessageAsync(send, msg) {
|
||||
const result = await holesailManager.removeVirtualHost(payload);
|
||||
debugLog('removeVirtualHost: result=', JSON.stringify(result));
|
||||
reply(result);
|
||||
if (result && result.ok) refreshProxyCert();
|
||||
break;
|
||||
}
|
||||
case 'getProxyPort': {
|
||||
|
||||
+345
-153
@@ -1,23 +1,28 @@
|
||||
/**
|
||||
* HTTPS proxy for virtual hosts. Listens with a wildcard cert (*.hs)
|
||||
* and forwards requests to the local port of the corresponding Holesail client.
|
||||
* 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 path = require('bare-path');
|
||||
const fs = require('bare-fs');
|
||||
|
||||
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 net = require('bare-tcp');
|
||||
const tls = require('bare-tls');
|
||||
const http1 = require('bare-http1');
|
||||
|
||||
const DEFAULT_PORT = 8443;
|
||||
/** Timeout for connecting to and receiving response from backend (ms) */
|
||||
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';
|
||||
function debugLog (...args) {
|
||||
@@ -28,105 +33,158 @@ function debugLog(...args) {
|
||||
|
||||
let proxyServer = null;
|
||||
let proxyPort = null;
|
||||
let proxyCertsDirOrCA = null; // saved so restart() can regenerate the cert
|
||||
/** Resolver: hostname -> { host, port } or port number (then host defaults to 127.0.0.1) or null */
|
||||
let proxyCertsDirOrCA = null;
|
||||
|
||||
/** Resolver: hostname -> { host, port } | port | null */
|
||||
let getBackendForHostname = null;
|
||||
|
||||
function setHostnameResolver (fn) {
|
||||
getBackendForHostname = fn;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure-JS TLS ClientHello SNI parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a multi-SAN wildcard cert covering all given base domains.
|
||||
* e.g. baseDomains = ['hole.sail', 'heheh.jungle']
|
||||
* produces SANs: *.hole.sail, hole.sail, *.heheh.jungle, heheh.jungle
|
||||
* The cert is keyed by a stable name "multi-wildcard-<sorted-domains>" so it
|
||||
* is regenerated whenever the domain list changes.
|
||||
* 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 buildMultiSanCert(certsDirOrCA, baseDomains) {
|
||||
const sorted = Array.from(new Set(['hole.sail', ...baseDomains])).sort();
|
||||
// The cert key encodes the exact domain list — a different list gets a different
|
||||
// directory, so the cache naturally invalidates when TLDs are added/removed.
|
||||
const certKey = 'multi-wildcard-' + sorted.join('_');
|
||||
const altNames = [];
|
||||
for (const bd of sorted) {
|
||||
altNames.push({ type: 2, value: '*.' + bd });
|
||||
altNames.push({ type: 2, value: bd });
|
||||
}
|
||||
return certsDirOrCA.getOrCreateDomainCert(certKey, altNames);
|
||||
}
|
||||
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;
|
||||
|
||||
function start(port, certsDirOrCA, callback, baseDomains) {
|
||||
if (!https || !http) {
|
||||
if (callback) callback(new Error('bare-https or bare-http1 not available'));
|
||||
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;
|
||||
}
|
||||
if (proxyServer) {
|
||||
if (callback) callback(null);
|
||||
return proxyServer;
|
||||
off += extLen;
|
||||
}
|
||||
|
||||
if (typeof certsDirOrCA !== 'object' || !certsDirOrCA.getOrCreateDomainCert) {
|
||||
if (callback) callback(new Error('certsDirOrCA must provide getOrCreateDomainCert'));
|
||||
return null;
|
||||
} catch (_) {
|
||||
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,
|
||||
key: typeof result.key === 'string' ? Buffer.from(result.key) : result.key
|
||||
/**
|
||||
* Extract the two-label base domain from a hostname.
|
||||
* 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;
|
||||
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;
|
||||
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('?');
|
||||
@@ -151,10 +209,9 @@ function onRequest(req, res) {
|
||||
const hostFromQuery = (query._host || '').trim();
|
||||
const hostname = hostFromQuery || hostFromHeader;
|
||||
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) {
|
||||
debugLog('request: 400 Bad Request (no resolver or hostname)');
|
||||
res.statusCode = 400;
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.end('Bad Request');
|
||||
@@ -169,15 +226,9 @@ function onRequest(req, res) {
|
||||
} else if (typeof backend === 'number') {
|
||||
targetPort = backend;
|
||||
}
|
||||
debugLog('request: backend=', backend, 'targetHost=', targetHost, 'targetPort=', targetPort);
|
||||
|
||||
if (targetPort == null) {
|
||||
debugLog('request: 502 No tunnel for hostname');
|
||||
// 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');
|
||||
}
|
||||
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>
|
||||
@@ -202,12 +253,13 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
|
||||
</body></html>`);
|
||||
return;
|
||||
}
|
||||
|
||||
let proxyPath = req.url || '/';
|
||||
if (hostFromQuery) {
|
||||
const withoutHost = proxyPath.replace(/[?&]_host=[^&]*/g, '').replace(/\?&/, '?').replace(/\?$/, '');
|
||||
proxyPath = withoutHost || '/';
|
||||
}
|
||||
debugLog('request: proxying to', targetHost + ':' + targetPort, 'path=', proxyPath);
|
||||
|
||||
const opts = {
|
||||
host: targetHost,
|
||||
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-authorization'];
|
||||
opts.headers.host = hostname + (hostHeader && hostHeader.includes(':') ? ':' + hostHeader.split(':')[1] : '');
|
||||
|
||||
let timedOut = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
debugLog('request: TIMEOUT hostname=', hostname, 'target=', targetHost + ':' + targetPort);
|
||||
proxyReq.destroy();
|
||||
if (!res.writableEnded) {
|
||||
res.statusCode = 504;
|
||||
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);
|
||||
const proxyReq = http.request(opts, (proxyRes) => {
|
||||
|
||||
const proxyReq = http1.request(opts, (proxyRes) => {
|
||||
clearTimeout(timeoutId);
|
||||
debugLog('request: backend response hostname=', hostname, 'status=', proxyRes.statusCode);
|
||||
if (timedOut) return;
|
||||
res.statusCode = proxyRes.statusCode;
|
||||
const headers = proxyRes.headers;
|
||||
for (const k in headers) {
|
||||
try {
|
||||
res.setHeader(k, headers[k]);
|
||||
} catch (_) {}
|
||||
try { res.setHeader(k, headers[k]); } catch (_) {}
|
||||
}
|
||||
proxyRes.on('data', (chunk) => !timedOut && res.write(chunk));
|
||||
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) => {
|
||||
clearTimeout(timeoutId);
|
||||
debugLog('request: proxyReq error hostname=', hostname, 'err=', err.message, 'timedOut=', timedOut);
|
||||
if (!res.writableEnded) {
|
||||
res.statusCode = 502;
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
const hostHeader = req.headers && (req.headers.host || req.headers.Host);
|
||||
const hostname = hostHeader ? hostHeader.split(':')[0].trim() : '';
|
||||
debugLog('upgrade: hostname=', hostname, 'url=', req.url);
|
||||
debugLog('upgrade: hostname=', hostname);
|
||||
|
||||
if (!getBackendForHostname || !hostname) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!getBackendForHostname || !hostname) { socket.destroy(); return; }
|
||||
const backend = getBackendForHostname(hostname);
|
||||
let targetHost = '127.0.0.1';
|
||||
let targetPort = null;
|
||||
@@ -281,41 +323,192 @@ function onUpgrade(req, socket, head) {
|
||||
}
|
||||
|
||||
if (targetPort == null) {
|
||||
debugLog('upgrade: no backend for hostname=', hostname);
|
||||
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
// Open a raw TCP connection to the backend and pipe the socket
|
||||
let net = null;
|
||||
try { net = require('bare-tcp'); } catch (_) {}
|
||||
if (!net) {
|
||||
debugLog('upgrade: bare-tcp not available');
|
||||
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; }
|
||||
|
||||
const upstream = net.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 upstream = bareTcp.connect(targetPort, targetHost, () => {
|
||||
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.pipe(upstream);
|
||||
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);
|
||||
try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {}
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
|
||||
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', () => {
|
||||
try { upstream.destroy(); } catch (_) {}
|
||||
} 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);
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@@ -323,30 +516,29 @@ function stop(callback) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
const s = proxyServer;
|
||||
const fakeServer = proxyServer;
|
||||
const tcpServer = fakeServer._tcpServer;
|
||||
proxyServer = 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
|
||||
// 'close' event once _connections is empty — destroying only the TLS-wrapped
|
||||
// sockets (which we no longer track) would leave the raw sockets open and
|
||||
// s.close() would never call its callback.
|
||||
const rawConns = s._connections;
|
||||
|
||||
if (!tcpServer) {
|
||||
if (callback) callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy all raw TCP connections so close() fires immediately
|
||||
const rawConns = tcpServer._connections;
|
||||
if (rawConns && typeof rawConns[Symbol.iterator] === 'function') {
|
||||
for (const socket of rawConns) {
|
||||
try { socket.destroy(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
s.close(() => {
|
||||
|
||||
tcpServer.close(() => {
|
||||
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) {
|
||||
const savedPort = proxyPort || DEFAULT_PORT;
|
||||
const ca = proxyCertsDirOrCA;
|
||||
|
||||
Reference in New Issue
Block a user