fix: correct SSL cert for custom TLDs and immediate PAC update on vhost changes
CI / Build & Test (push) Successful in 3m18s

- Replace SNI callback approach (unsupported by bare-tls) with a multi-SAN
  wildcard cert covering all active base domains (e.g. *.hole.sail, *.heheh.jungle)
- Add buildMultiSanCert() in https-proxy.js — cert key encodes sorted domain list
  so cache invalidates automatically when TLDs are added or removed
- Add restart(baseDomains, callback) to https-proxy.js to stop and restart the
  proxy with a fresh cert without requiring a native host restart
- Add getActiveBaseDomains() and refreshProxyCert() in host.js; call after every
  successful setVirtualHost and removeVirtualHost
- Fix background.js send handler to fetch updated virtualHosts via getState and
  re-apply PAC immediately after setVirtualHost or removeVirtualHost succeeds,
  so new TLDs are routed without waiting for the next dashboard refresh
- Remove unused tls require from https-proxy.js
- Remove invalid "permissions" entry from manifest.json permissions array
This commit is contained in:
Raven Scott
2026-02-28 20:30:26 -05:00
parent 00d9c958ca
commit 82c75b289e
4 changed files with 101 additions and 63 deletions
+26 -21
View File
@@ -441,28 +441,33 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'send') {
send(message.payload)
.then((r) => {
// After a successful setVirtualHost, request host permission for any new TLD
if (
message.payload?.type === 'setVirtualHost' &&
r && r.ok &&
message.payload?.payload?.hostname
) {
const hostname = message.payload.payload.hostname;
const parts = hostname.split('.');
if (parts.length >= 3) {
const baseDomain = parts.slice(-2).join('.');
const origin = '*://*.' + baseDomain + '/*';
// Only request if not already in extensionState virtualHosts
const alreadyKnown = (extensionState.virtualHosts || []).some(v => {
const vParts = (v.hostname || '').split('.');
return vParts.length >= 3 && vParts.slice(-2).join('.') === baseDomain;
});
if (!alreadyKnown && baseDomain !== 'hole.sail') {
browser.permissions.request({ origins: [origin] }, (granted) => {
log('permissions.request for', origin, ':', granted ? 'granted' : 'denied');
});
// After a successful setVirtualHost or removeVirtualHost, refresh virtualHosts
// and re-apply the PAC so the new TLD is routed immediately
if (r && r.ok && (
message.payload?.type === 'setVirtualHost' ||
message.payload?.type === 'removeVirtualHost'
)) {
send({ type: 'getState', payload: {} }).then((state) => {
if (state && state.ok && Array.isArray(state.virtualHosts)) {
extensionState.virtualHosts = state.virtualHosts;
const newTlds = getActiveTlds(extensionState.virtualHosts);
applyPAC(newTlds);
// Request host permission for any new TLD not already covered
if (message.payload?.type === 'setVirtualHost' && message.payload?.payload?.hostname) {
const hostname = message.payload.payload.hostname;
const parts = hostname.split('.');
if (parts.length >= 3) {
const baseDomain = parts.slice(-2).join('.');
if (baseDomain !== 'hole.sail') {
const origin = '*://*.' + baseDomain + '/*';
browser.permissions.request({ origins: [origin] }, (granted) => {
log('permissions.request for', origin, ':', granted ? 'granted' : 'denied');
});
}
}
}
}
}
}).catch(() => {});
}
reply(r);
})
+1 -2
View File
@@ -9,8 +9,7 @@
"notifications",
"tabs",
"declarativeNetRequest",
"proxy",
"permissions"
"proxy"
],
"action": {
"default_title": "Holesail Dashboard",
+27
View File
@@ -36,6 +36,31 @@ backupManager.setCertsPath(certificateAuthority.getCertsDir());
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');
}
});
}
const PROXY_PORT = 8443;
const CONNECT_PROXY_PORT = 8442;
@@ -308,6 +333,7 @@ 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': {
@@ -315,6 +341,7 @@ 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': {
+47 -40
View File
@@ -5,7 +5,6 @@
const path = require('bare-path');
const fs = require('bare-fs');
const tls = require('tls');
let https = null;
let http = null;
@@ -29,6 +28,7 @@ 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 getBackendForHostname = null;
@@ -36,7 +36,27 @@ function setHostnameResolver(fn) {
getBackendForHostname = fn;
}
function start(port, certsDirOrCA, callback) {
/**
* 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.
*/
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 start(port, certsDirOrCA, callback, baseDomains) {
if (!https || !http) {
if (callback) callback(new Error('bare-https or bare-http1 not available'));
return null;
@@ -51,48 +71,17 @@ function start(port, certsDirOrCA, callback) {
return null;
}
// Pre-generate the baseline *.hole.sail cert so the server can start immediately.
// Additional TLD certs are generated lazily via SNICallback on first connection.
const baseResult = certsDirOrCA.getOrCreateDomainCert('*.hole.sail', [
{ type: 2, value: '*.hole.sail' },
{ type: 2, value: 'hole.sail' }
]);
if (!baseResult) {
if (callback) callback(new Error('Could not get baseline wildcard cert for *.hole.sail'));
proxyCertsDirOrCA = certsDirOrCA;
const result = buildMultiSanCert(certsDirOrCA, baseDomains || []);
if (!result) {
if (callback) callback(new Error('Could not generate multi-SAN wildcard cert'));
return null;
}
// SNI callback — lazily issues a wildcard cert per two-label base domain
function sniCallback(servername, cb) {
const parts = (servername || '').split('.');
// Need at least 3 labels (host.second.tld) to derive a 2-label base domain
const baseDomain = parts.length >= 3 ? parts.slice(-2).join('.') : 'hole.sail';
const wildcard = '*.' + baseDomain;
debugLog('sniCallback: servername=', servername, 'baseDomain=', baseDomain, 'wildcard=', wildcard);
const result = certsDirOrCA.getOrCreateDomainCert(wildcard, [
{ type: 2, value: wildcard },
{ type: 2, value: baseDomain }
]);
if (!result) {
if (process.stderr) process.stderr.write('[https-proxy] SNI: no cert for ' + wildcard + '\n');
return cb(new Error('No cert for ' + wildcard));
}
try {
const ctx = tls.createSecureContext({
cert: typeof result.cert === 'string' ? result.cert : result.cert.toString(),
key: typeof result.key === 'string' ? result.key : result.key.toString()
});
cb(null, ctx);
} catch (e) {
if (process.stderr) process.stderr.write('[https-proxy] SNI createSecureContext error: ' + e.message + '\n');
cb(e);
}
}
const opts = {
cert: typeof baseResult.cert === 'string' ? Buffer.from(baseResult.cert) : baseResult.cert,
key: typeof baseResult.key === 'string' ? Buffer.from(baseResult.key) : baseResult.key,
SNICallback: sniCallback
cert: typeof result.cert === 'string' ? Buffer.from(result.cert) : result.cert,
key: typeof result.key === 'string' ? Buffer.from(result.key) : result.key
};
let callbackCalled = false;
@@ -342,6 +331,23 @@ function stop(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;
if (!ca) {
if (callback) callback(new Error('proxy not yet started'));
return;
}
stop(() => {
start(savedPort, ca, callback, baseDomains);
});
}
function getPort() {
return proxyPort;
}
@@ -350,5 +356,6 @@ module.exports = {
setHostnameResolver,
start,
stop,
restart,
getPort
};