Files
holesail-browser/native-host/holesail-manager/virtual-hosts.js
T
Raven Scott f1e98a7edd
CI / Build & Test (push) Successful in 2m54s
docs: add CONTRIBUTING.md, CHANGELOG.md, and JSDoc to entire codebase
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all
npm scripts, how to add new native host message types, code style, and
debugging guidance.

Add CHANGELOG.md at the project root documenting all features and fixes
across the 1.0.0 release.

Add JSDoc (@param, @returns) to all previously undocumented exported
functions across 35 JS files:
- native-host/holesail-manager/ (index, virtual-hosts, service-tunnels,
  servers, port-allocator)
- native-host top-level managers (startup, connect-proxy, https-proxy,
  certificate-authority, ssh-manager, rdp-manager)
- extension/background/ (logs, native-messaging, proxy, message-router)
- extension/dashboard/core/ (utils, navigation, init)
- extension/dashboard/ui/ (modal, toast, state-tag)
- extension/dashboard/pages/ (all 10 page files)
- extension/dashboard/refresh.js, events.js
- extension/dashboard/data/hostname-validator.js
- scripts/ (build-host, run-install)
2026-03-01 00:40:53 -05:00

209 lines
9.2 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, 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<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 };