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