CI / Build & Test (push) Failing after 2m44s
- Add "Use TLS (secure connection)" checkbox in Add Virtual Host modal - Persist and restore useTls in state; show TLS badge in table - When enabled, HTTPS proxy connects to tunnel backend over TLS (SNI = hostname) for HTTP and WebSocket; supports services on port 443
219 lines
10 KiB
JavaScript
219 lines
10 KiB
JavaScript
/**
|
|
* Virtual host tunnel management.
|
|
* Connects to a remote Holesail peer and binds it to a local port for HTTP proxying.
|
|
*/
|
|
|
|
let Holesail = null;
|
|
try { Holesail = require('holesail'); } catch (e) {
|
|
if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n');
|
|
}
|
|
|
|
const { allocateTunnelPort, releaseTunnelPort } = require('./port-allocator.js');
|
|
|
|
const TUNNEL_HOST = '127.0.0.1';
|
|
|
|
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
|
|
function debugLog(...args) {
|
|
if (!DEBUG) return;
|
|
const msg = '[holesail-manager:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
|
if (process.stderr) process.stderr.write(msg + '\n');
|
|
}
|
|
|
|
const virtualHosts = new Map(); // hostname -> { hsUrl, useTls, holesail, localHost, localPort, state, createdAt, reconnectTimer, reconnectDelay }
|
|
let _saveState = null;
|
|
let _emit = null;
|
|
let _getReadyTimeoutMs = null;
|
|
let _getAutoReconnect = null;
|
|
|
|
const RECONNECT_BASE_MS = 5000;
|
|
const RECONNECT_MAX_MS = 120000;
|
|
|
|
/**
|
|
* Inject shared dependencies from the parent holesail-manager.
|
|
* Must be called once before any other function in this module.
|
|
* @param {Function} saveStateFn - Callback that persists the current state to disk.
|
|
* @param {Function} emitFn - Callback to emit tunnel lifecycle events to the extension.
|
|
* @param {Function} getReadyTimeoutMsFn - Returns the configured ready-timeout in ms (0 = no timeout).
|
|
* @param {Function} getAutoReconnectFn - Returns whether auto-reconnect is enabled.
|
|
*/
|
|
function init(saveStateFn, emitFn, getReadyTimeoutMsFn, getAutoReconnectFn) {
|
|
_saveState = saveStateFn;
|
|
_emit = emitFn;
|
|
_getReadyTimeoutMs = getReadyTimeoutMsFn;
|
|
_getAutoReconnect = getAutoReconnectFn;
|
|
}
|
|
|
|
function _scheduleVhostReconnect(hostname) {
|
|
const v = virtualHosts.get(hostname);
|
|
if (!v) return;
|
|
if (v.reconnectTimer) { clearTimeout(v.reconnectTimer); v.reconnectTimer = null; }
|
|
const autoReconnect = _getAutoReconnect ? _getAutoReconnect() : false;
|
|
if (!autoReconnect) return;
|
|
const delay = v.reconnectDelay || RECONNECT_BASE_MS;
|
|
v.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS);
|
|
if (process.stderr) process.stderr.write('[holesail-manager] vhost ' + hostname + ' reconnecting in ' + Math.round(delay / 1000) + 's\n');
|
|
v.reconnectTimer = setTimeout(async () => {
|
|
v.reconnectTimer = null;
|
|
const cur = virtualHosts.get(hostname);
|
|
if (!cur || cur.state === 'ready') return;
|
|
if (process.stderr) process.stderr.write('[holesail-manager] vhost ' + hostname + ' auto-reconnect attempt\n');
|
|
await setVirtualHost({ hostname, hsUrl: cur.hsUrl, useTls: cur.useTls });
|
|
}, delay);
|
|
}
|
|
|
|
function readyWithTimeout(hs, label) {
|
|
const ms = _getReadyTimeoutMs ? _getReadyTimeoutMs() : 0;
|
|
if (!ms || ms <= 0) return hs.ready();
|
|
return new Promise((resolve, reject) => {
|
|
const t = setTimeout(() => reject(new Error('Tunnel ready timeout after ' + ms + 'ms for ' + label)), ms);
|
|
hs.ready().then(() => { clearTimeout(t); resolve(); }, (e) => { clearTimeout(t); reject(e); });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Create or replace a virtual host tunnel.
|
|
* Connects to the remote Holesail peer and binds it to a dynamically allocated
|
|
* local port so the HTTPS proxy can forward browser requests to it.
|
|
* If a tunnel already exists for the hostname it is closed and replaced.
|
|
* @param {object} payload
|
|
* @param {string} payload.hostname - The virtual hostname (e.g. `myapp.hs`). Normalised to lowercase.
|
|
* @param {string} payload.hsUrl - The hs:// key of the remote peer.
|
|
* @param {boolean} [payload.useTls] - If true, proxy connects to backend over TLS (for HTTPS/443 backends).
|
|
* @returns {Promise<{ok: boolean, hostname?: string, localHost?: string, localPort?: number, state?: string, error?: string}>}
|
|
*/
|
|
async function setVirtualHost(payload) {
|
|
let hostname = (payload.hostname || payload.hostName || '').trim();
|
|
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
|
|
const hsUrl = payload.hsUrl || payload.url;
|
|
const useTls = payload.useTls === true;
|
|
debugLog('setVirtualHost: hostname=', hostname, 'hsUrl=', hsUrl, 'useTls=', useTls);
|
|
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
|
|
if (!hostname || !hsUrl) return { ok: false, error: 'hostname and hsUrl required' };
|
|
|
|
const existing = virtualHosts.get(hostname);
|
|
if (existing) {
|
|
debugLog('setVirtualHost: replacing existing', hostname);
|
|
if (existing.reconnectTimer) { clearTimeout(existing.reconnectTimer); existing.reconnectTimer = null; }
|
|
if (existing.localPort) releaseTunnelPort(existing.localPort);
|
|
if (existing.holesail) { try { await existing.holesail.close(); } catch (_) {} }
|
|
}
|
|
|
|
const localPort = allocateTunnelPort();
|
|
try {
|
|
const hs = new Holesail({ client: true, key: hsUrl, host: TUNNEL_HOST, port: localPort });
|
|
if (typeof hs.on === 'function') {
|
|
hs.on('error', (err) => {
|
|
if (process.stderr) process.stderr.write('[holesail-manager] tunnel error ' + hostname + ': ' + (err && err.message) + '\n');
|
|
const v = virtualHosts.get(hostname);
|
|
if (v && v.holesail === hs) { v.state = 'error'; _scheduleVhostReconnect(hostname); }
|
|
if (_emit) _emit('tunnelError', { hostname, error: err && err.message });
|
|
});
|
|
hs.on('close', () => {
|
|
const v = virtualHosts.get(hostname);
|
|
if (v && v.holesail === hs) { v.state = 'closed'; _scheduleVhostReconnect(hostname); }
|
|
if (_emit) _emit('tunnelClosed', { hostname });
|
|
});
|
|
}
|
|
await readyWithTimeout(hs, 'vhost:' + hostname);
|
|
const entryUseTls = existing ? (payload.useTls === true || existing.useTls === true) : useTls;
|
|
virtualHosts.set(hostname, { hsUrl, useTls: entryUseTls, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
|
|
if (_emit) _emit('tunnelReady', { hostname, hsUrl, localHost: TUNNEL_HOST, localPort });
|
|
if (_saveState) _saveState();
|
|
debugLog('setVirtualHost: ok hostname=', hostname, 'localPort=', localPort);
|
|
return { ok: true, hostname, localHost: TUNNEL_HOST, localPort, state: 'ready' };
|
|
} catch (e) {
|
|
debugLog('setVirtualHost: error hostname=', hostname, 'error=', e.message);
|
|
try { hs.removeAllListeners(); } catch (_) {}
|
|
releaseTunnelPort(localPort);
|
|
const existingCreatedAt = existing ? existing.createdAt : undefined;
|
|
const entryUseTls = existing ? (payload.useTls === true || existing.useTls === true) : useTls;
|
|
virtualHosts.set(hostname, { hsUrl, useTls: entryUseTls, holesail: null, localHost: null, localPort: null, state: 'error', createdAt: existingCreatedAt || Date.now() });
|
|
if (_emit) _emit('tunnelError', { hostname, error: e.message });
|
|
return { ok: false, error: e.message };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove a virtual host, close its tunnel, and release its local port.
|
|
* @param {object} payload
|
|
* @param {string} payload.hostname - The hostname to remove.
|
|
* @returns {Promise<{ok: boolean, error?: string}>}
|
|
*/
|
|
async function removeVirtualHost(payload) {
|
|
const hostname = payload.hostname || payload.hostName;
|
|
debugLog('removeVirtualHost: hostname=', hostname);
|
|
const entry = virtualHosts.get(hostname);
|
|
if (!entry) return { ok: false, error: 'Virtual host not found' };
|
|
if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; }
|
|
if (entry.localPort) releaseTunnelPort(entry.localPort);
|
|
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
|
|
virtualHosts.delete(hostname);
|
|
if (_saveState) _saveState();
|
|
if (_emit) _emit('tunnelClosed', { hostname });
|
|
return { ok: true };
|
|
}
|
|
|
|
/**
|
|
* Return a snapshot of all virtual hosts (including errored/closed ones).
|
|
* @returns {Array<{hostname: string, hsUrl: string, useTls: boolean, localHost: string|null, localPort: number|null, state: string, createdAt: number}>}
|
|
*/
|
|
function getVirtualHosts() {
|
|
const list = [];
|
|
for (const [hostname, v] of virtualHosts) {
|
|
list.push({ hostname, hsUrl: v.hsUrl, useTls: v.useTls === true, localHost: v.localHost ?? null, localPort: v.localPort ?? null, state: v.state || 'unknown', createdAt: v.createdAt });
|
|
}
|
|
return list;
|
|
}
|
|
|
|
/**
|
|
* Return the local tunnel port for a hostname, or null if not ready.
|
|
* @param {string} hostname
|
|
* @returns {number|null}
|
|
*/
|
|
function getLocalPortForHostname(hostname) {
|
|
const v = virtualHosts.get(hostname);
|
|
return v && v.localPort != null ? v.localPort : null;
|
|
}
|
|
|
|
/**
|
|
* Return the `{ host, port, tls? }` backend object for the HTTPS proxy SNI resolver,
|
|
* or null if the virtual host does not exist or is not yet ready.
|
|
* When useTls is true, the proxy should connect to the backend over TLS.
|
|
* @param {string} hostname
|
|
* @returns {{host: string, port: number, tls?: boolean}|null}
|
|
*/
|
|
function getLocalBackend(hostname) {
|
|
const v = virtualHosts.get(hostname);
|
|
if (!v || v.localPort == null) {
|
|
debugLog('getLocalBackend: hostname=', hostname, 'result=', null, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
|
|
return null;
|
|
}
|
|
const out = { host: v.localHost ?? '127.0.0.1', port: v.localPort };
|
|
if (v.useTls === true) out.tls = true;
|
|
debugLog('getLocalBackend: hostname=', hostname, 'result=', out, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
|
|
return out;
|
|
}
|
|
|
|
function getVirtualHostMap() {
|
|
const m = new Map();
|
|
for (const [hostname, v] of virtualHosts) {
|
|
if (v.localPort != null) m.set(hostname, v.localPort);
|
|
}
|
|
return m;
|
|
}
|
|
|
|
/**
|
|
* Cancel all reconnect timers and close all virtual host tunnels.
|
|
* Called during native host shutdown.
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async function cleanupVirtualHosts() {
|
|
for (const [, v] of virtualHosts) {
|
|
if (v.reconnectTimer) { clearTimeout(v.reconnectTimer); v.reconnectTimer = null; }
|
|
if (v.holesail) { try { await v.holesail.close(); } catch (_) {} }
|
|
}
|
|
virtualHosts.clear();
|
|
}
|
|
|
|
module.exports = { init, setVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts, RECONNECT_BASE_MS, RECONNECT_MAX_MS };
|