first commit
CI / Build & Test (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-02-27 18:13:59 -05:00
commit d58a0b6e2d
64 changed files with 30550 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
/**
* HTTPS proxy for virtual hosts. Listens with a wildcard cert (*.hs)
* and forwards requests to the local port of the corresponding Holesail client.
*/
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 DEFAULT_PORT = 8443;
/** Timeout for connecting to and receiving response from backend (ms) */
const BACKEND_TIMEOUT_MS = 30000;
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
function debugLog(...args) {
if (!DEBUG) return;
const msg = '[https-proxy:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
if (process.stderr) process.stderr.write(msg + '\n');
}
let proxyServer = null;
let proxyPort = null;
/** Resolver: hostname -> { host, port } or port number (then host defaults to 127.0.0.1) or null */
let getBackendForHostname = null;
function setHostnameResolver(fn) {
getBackendForHostname = fn;
}
function start(port, certsDirOrCA, callback) {
if (!https || !http) {
if (callback) callback(new Error('bare-https or bare-http1 not available'));
return null;
}
if (proxyServer) {
if (callback) callback(null);
return proxyServer;
}
// Wildcard cert for *.hole.sail — Chrome accepts wildcards when base domain has 2+ labels
const certDomain = '*.hole.sail';
let cert = null;
let key = null;
if (typeof certsDirOrCA === 'object' && certsDirOrCA.getOrCreateDomainCert) {
const result = certsDirOrCA.getOrCreateDomainCert(certDomain, [
{ type: 2, value: '*.hole.sail' },
{ type: 2, value: 'hole.sail' }
]);
if (result) {
cert = typeof result.cert === 'string' ? Buffer.from(result.cert) : result.cert;
key = typeof result.key === 'string' ? Buffer.from(result.key) : result.key;
}
}
if (!cert || !key) {
if (callback) callback(new Error('Could not get wildcard cert for ' + certDomain));
return null;
}
const opts = { cert, key };
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);
} 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 — previous instance still running, reusing port\n');
proxyPort = listenPort;
proxyServer.removeAllListeners('error');
proxyServer.on('error', () => {});
done(null);
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;
}
function parseUrlPathAndQuery(url) {
if (!url || typeof url !== 'string') return { path: '/', query: {}, search: '' };
const q = url.indexOf('?');
const path = q === -1 ? url : url.slice(0, q);
const search = q === -1 ? '' : url.slice(q);
const query = {};
if (search) {
const params = search.slice(1).split('&');
for (const p of params) {
const eq = p.indexOf('=');
if (eq === -1) query[decodeURIComponent(p)] = '';
else query[decodeURIComponent(p.slice(0, eq))] = decodeURIComponent(p.slice(eq + 1));
}
}
return { path, query, search };
}
function onRequest(req, res) {
const hostHeader = req.headers && (req.headers.host || req.headers.Host);
const hostFromHeader = hostHeader ? hostHeader.split(':')[0].trim() : '';
const { path: urlPath, query } = parseUrlPathAndQuery(req.url);
const hostFromQuery = (query._host || '').trim();
const hostname = hostFromQuery || hostFromHeader;
const method = req.method || 'GET';
debugLog('request: method=', method, 'url=', req.url, 'hostHeader=', hostHeader, 'hostFromHeader=', hostFromHeader, 'hostFromQuery=', hostFromQuery, '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');
return;
}
const backend = getBackendForHostname(hostname);
let targetHost = '127.0.0.1';
let targetPort = null;
if (backend != null && typeof backend === 'object' && typeof backend.port === 'number') {
targetHost = backend.host ?? '127.0.0.1';
targetPort = backend.port;
} else if (typeof backend === 'number') {
targetPort = backend;
}
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');
}
res.statusCode = 502;
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.end(`<!DOCTYPE html>
<html>
<head><title>No tunnel — Holesail Browser</title>
<style>body{font-family:system-ui,sans-serif;max-width:600px;margin:60px auto;padding:0 20px;color:#1a1a1a}
h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-size:.9em}
.hint{background:#fff8e1;border-left:4px solid #f39c12;padding:12px 16px;margin:16px 0;border-radius:0 4px 4px 0}
</style></head>
<body>
<h1>No tunnel for <code>${hostname}</code></h1>
<p>The Holesail Browser extension has no active tunnel for this hostname.</p>
<div class="hint">
<strong>Possible causes:</strong>
<ul>
<li>The tunnel is still connecting — wait a few seconds and refresh</li>
<li>The tunnel failed to connect — check the Virtual Hosts page in the dashboard</li>
<li>The hostname in the dashboard doesn't match exactly (check for typos)</li>
</ul>
</div>
<p>Open the <strong>Holesail Browser dashboard</strong> → Virtual Hosts to reconnect or add a new virtual host.</p>
</body></html>`);
return;
}
let proxyPath = req.url || '/';
if (hostFromQuery) {
const withoutHost = proxyPath.replace(/[?&]_host=[^&]*/g, '').replace(/\?&/, '?').replace(/\?$/, '');
proxyPath = withoutHost || '/';
}
debugLog('request: proxying to', targetHost + ':' + targetPort, 'path=', proxyPath);
const opts = {
host: targetHost,
port: targetPort,
path: proxyPath,
method: req.method || 'GET',
headers: { ...req.headers }
};
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.');
}
}, BACKEND_TIMEOUT_MS);
const proxyReq = http.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 (_) {}
}
proxyRes.on('data', (chunk) => !timedOut && res.write(chunk));
proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end());
proxyRes.on('error', () => !res.writableEnded && res.end());
});
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');
res.end('Proxy error: ' + (timedOut ? 'timeout' : err.message));
}
});
req.on('data', (chunk) => proxyReq.write(chunk));
req.on('end', () => proxyReq.end());
}
function stop(callback) {
if (!proxyServer) {
if (callback) callback();
return;
}
const s = proxyServer;
proxyServer = null;
proxyPort = null;
s.close(() => {
if (callback) callback();
});
}
function getPort() {
return proxyPort;
}
module.exports = {
setHostnameResolver,
start,
stop,
getPort
};