@@ -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
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user