/** * Service tunnel management. * Connects to a remote Holesail peer and binds it directly to a user-chosen local port. * Unlike virtual hosts, these are not HTTP-proxied — any TCP client can connect directly. */ let Holesail = null; try { Holesail = require('holesail'); } catch (e) { if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n'); } 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 serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt, reconnectTimer, reconnectDelay } let nextServiceTunnelId = 0; 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 _scheduleSvcReconnect(tunnelId) { const t = serviceTunnels.get(tunnelId); if (!t) return; if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; } const autoReconnect = _getAutoReconnect ? _getAutoReconnect() : false; if (!autoReconnect) return; const delay = t.reconnectDelay || RECONNECT_BASE_MS; t.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS); if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' reconnecting in ' + Math.round(delay / 1000) + 's\n'); t.reconnectTimer = setTimeout(async () => { t.reconnectTimer = null; const cur = serviceTunnels.get(tunnelId); if (!cur || cur.state === 'ready') return; if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' auto-reconnect attempt\n'); await startServiceTunnel({ tunnelId, label: cur.label, hsUrl: cur.hsUrl, localPort: cur.localPort }); }, delay); } /** * Advance the service tunnel ID counter to avoid collisions after a restart. * @param {number} loadedNextServiceTunnelId - The `nextServiceTunnelId` value read from state.json. */ function applyLoaded(loadedNextServiceTunnelId) { if (loadedNextServiceTunnelId > nextServiceTunnelId) nextServiceTunnelId = loadedNextServiceTunnelId; } function generateServiceTunnelId() { nextServiceTunnelId += 1; return 'svc-' + nextServiceTunnelId; } 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); }); }); } /** * Start a service tunnel, connecting a remote Holesail peer to a local port. * Any TCP client can connect to `127.0.0.1:` directly (no HTTP proxy). * @param {object} payload * @param {string} payload.label - Human-readable label (required). * @param {string} payload.hsUrl - The hs:// key of the remote peer (required). * @param {number} payload.localPort - Local port to bind (1–65535, required). * @param {string} [payload.tunnelId] - Optional explicit ID; auto-generated if omitted. * @returns {Promise<{ok: boolean, tunnelId?: string, label?: string, hsUrl?: string, localPort?: number, state?: string, error?: string}>} */ async function startServiceTunnel(payload) { if (!Holesail) return { ok: false, error: 'Holesail module not installed' }; const { label, hsUrl, localPort } = payload; if (!label) return { ok: false, error: 'label required' }; if (!hsUrl || !hsUrl.startsWith('hs://')) return { ok: false, error: 'valid hs:// key required' }; if (!localPort || localPort < 1 || localPort > 65535) return { ok: false, error: 'localPort must be 1–65535' }; const tunnelId = payload.tunnelId || generateServiceTunnelId(); debugLog('startServiceTunnel: id=', tunnelId, 'label=', label, 'localPort=', localPort); const existing = serviceTunnels.get(tunnelId); if (existing) { if (existing.reconnectTimer) { clearTimeout(existing.reconnectTimer); existing.reconnectTimer = null; } if (existing.holesail) { try { await existing.holesail.close(); } catch (_) {} } } 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] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n'); const t = serviceTunnels.get(tunnelId); if (t && t.holesail === hs) { t.state = 'error'; _scheduleSvcReconnect(tunnelId); } if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: err && err.message }); }); hs.on('close', () => { const t = serviceTunnels.get(tunnelId); if (t && t.holesail === hs) { t.state = 'closed'; _scheduleSvcReconnect(tunnelId); } if (_emit) _emit('serviceTunnelClosed', { tunnelId, label }); }); } await readyWithTimeout(hs, 'svc:' + tunnelId); serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS }); if (_emit) _emit('serviceTunnelReady', { tunnelId, label, localPort }); if (_saveState) _saveState(); debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort); return { ok: true, tunnelId, label, hsUrl, localPort, state: 'ready' }; } catch (e) { debugLog('startServiceTunnel: error', e.message); try { hs.removeAllListeners(); } catch (_) {} serviceTunnels.set(tunnelId, { label, hsUrl, localPort, state: 'error', error: e.message, createdAt: Date.now() }); if (_saveState) _saveState(); if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: e.message }); return { ok: false, error: e.message }; } } /** * Stop a service tunnel, close the Holesail connection, and remove it from state. * @param {object} payload * @param {string} payload.tunnelId - ID of the service tunnel to stop. * @returns {Promise<{ok: boolean, error?: string}>} */ async function stopServiceTunnel(payload) { const { tunnelId } = payload; debugLog('stopServiceTunnel: id=', tunnelId); const entry = serviceTunnels.get(tunnelId); if (!entry) return { ok: false, error: 'Service tunnel not found' }; if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; } if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} } serviceTunnels.delete(tunnelId); if (_saveState) _saveState(); if (_emit) _emit('serviceTunnelClosed', { tunnelId, label: entry.label }); return { ok: true }; } /** * Return a snapshot of all service tunnels (including errored/closed ones). * @returns {Array<{id: string, label: string, hsUrl: string, localPort: number, state: string, createdAt: number}>} */ function getServiceTunnels() { const list = []; for (const [id, t] of serviceTunnels) { list.push({ id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort, state: t.state || 'unknown', createdAt: t.createdAt }); } return list; } function getNextServiceTunnelId() { return nextServiceTunnelId; } /** * Cancel all reconnect timers and close all service tunnel connections. * Called during native host shutdown. * @returns {Promise} */ async function cleanupServiceTunnels() { for (const [, t] of serviceTunnels) { if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; } if (t.holesail) { try { await t.holesail.close(); } catch (_) {} } } serviceTunnels.clear(); } module.exports = { init, applyLoaded, startServiceTunnel, stopServiceTunnel, getServiceTunnels, getNextServiceTunnelId, cleanupServiceTunnels };