/** * 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, 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 }); }, 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. * @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; debugLog('setVirtualHost: hostname=', hostname, 'hsUrl=', hsUrl); 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); virtualHosts.set(hostname, { hsUrl, 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; virtualHosts.set(hostname, { hsUrl, 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, 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, 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 }` backend object for the HTTPS proxy SNI resolver, * or null if the virtual host does not exist or is not yet ready. * @param {string} hostname * @returns {{host: string, port: number}|null} */ function getLocalBackend(hostname) { const v = virtualHosts.get(hostname); const out = (!v || v.localPort == null) ? null : { host: v.localHost ?? '127.0.0.1', port: v.localPort }; 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} */ 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 };