/** * Holesail server tunnel management. * Exposes a local TCP/UDP port to the Holesail P2P network (server mode). */ let Holesail = null; try { Holesail = require('holesail'); } catch (e) { if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n'); } 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 servers = new Map(); // serverId -> { holesail, port, host, secure, udp, label, url, createdAt } let nextServerId = 0; let _saveState = null; let _getReadyTimeoutMs = null; /** * 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} getReadyTimeoutMsFn - Returns the configured ready-timeout in ms (0 = no timeout). */ function init(saveStateFn, getReadyTimeoutMsFn) { _saveState = saveStateFn; _getReadyTimeoutMs = getReadyTimeoutMsFn; } /** * Advance the server ID counter to be at least as high as the persisted value, * ensuring IDs generated after a restart do not collide with existing ones. * @param {number} loadedNextServerId - The `nextServerId` value read from state.json. */ function applyLoaded(loadedNextServerId) { if (loadedNextServerId > nextServerId) nextServerId = loadedNextServerId; } function parseServerIdNum(serverId) { if (!serverId || typeof serverId !== 'string') return 0; const m = serverId.match(/^server_(\d+)$/); return m ? Math.max(0, parseInt(m[1], 10)) : 0; } function generateServerId() { nextServerId += 1; return 'server_' + nextServerId; } function useServerId(givenId) { if (givenId && typeof givenId === 'string' && givenId.startsWith('server_')) { const n = parseServerIdNum(givenId); if (n >= nextServerId) nextServerId = n + 1; return givenId; } return generateServerId(); } 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 Holesail server tunnel, exposing a local port to the P2P network. * @param {object} payload * @param {number} [payload.port=3000] - Local TCP port to expose. * @param {string} [payload.host='127.0.0.1'] - Local host to bind. * @param {boolean} [payload.secure=true] - Whether to use an encrypted (private) tunnel. * @param {boolean} [payload.udp=false] - Whether to use UDP mode. * @param {string} [payload.label=''] - Human-readable label for the UI. * @param {string} [payload.serverId] - Optional explicit ID; auto-generated if omitted. * @returns {Promise<{ok: boolean, serverId?: string, url?: string, port?: number, host?: string, secure?: boolean, udp?: boolean, label?: string, error?: string}>} */ async function startServer(payload) { if (!Holesail) return { ok: false, error: 'Holesail module not installed' }; const port = payload.port || 3000; const host = payload.host || '127.0.0.1'; const secure = payload.secure !== false; const udp = payload.udp === true; const label = (payload.label || '').trim(); const serverId = useServerId(payload.serverId); debugLog('startServer: serverId=', serverId, 'port=', port, 'host=', host, 'secure=', secure, 'udp=', udp, 'label=', label); if (servers.has(serverId)) { debugLog('startServer: serverId already in use'); return { ok: false, error: 'Server id already in use' }; } try { const hs = new Holesail({ server: true, port, host, secure, udp }); if (typeof hs.on === 'function') { hs.on('error', (err) => { if (process.stderr) process.stderr.write('[holesail-manager] server tunnel error ' + serverId + ': ' + (err && err.message) + '\n'); }); } await readyWithTimeout(hs, 'server:' + serverId); const url = hs.info.url; servers.set(serverId, { holesail: hs, port: hs.info.port, host: hs.info.host, secure, udp, label, url, createdAt: Date.now() }); if (_saveState) _saveState(); debugLog('startServer: ok serverId=', serverId, 'url=', url); return { ok: true, serverId, url, port: hs.info.port, host: hs.info.host, secure, udp, label }; } catch (e) { debugLog('startServer: error', e.message); return { ok: false, error: e.message }; } } /** * Stop a running server tunnel and remove it from state. * @param {object} payload * @param {string} payload.serverId - ID of the server tunnel to stop. * @returns {Promise<{ok: boolean, error?: string}>} */ async function stopServer(payload) { const serverId = payload.serverId; debugLog('stopServer: serverId=', serverId); const entry = servers.get(serverId); if (!entry) { debugLog('stopServer: not found'); return { ok: false, error: 'Server not found' }; } try { await entry.holesail.close(); } catch (_) {} servers.delete(serverId); if (_saveState) _saveState(); return { ok: true }; } /** * Return a snapshot of all active server tunnels. * @returns {Array<{id: string, serverId: string, port: number, host: string, url: string, secure: boolean, udp: boolean, label: string, createdAt: number}>} */ function getServers() { const list = []; for (const [id, s] of servers) { list.push({ id, serverId: id, port: s.port, host: s.host, url: s.url, secure: s.secure, udp: s.udp || false, label: s.label || '', createdAt: s.createdAt }); } return list; } function getNextServerId() { return nextServerId; } /** * Close all active server tunnels. Called during native host shutdown. * @returns {Promise} */ async function cleanupServers() { for (const [, s] of servers) { try { await s.holesail.close(); } catch (_) {} } servers.clear(); } module.exports = { init, applyLoaded, startServer, stopServer, getServers, getNextServerId, cleanupServers };