/** * Holesail tunnel manager with optional disk persistence. * * peardock control plane: HyperDHT + protomux-rpc * Data plane tunnels: Holesail L4 TCP/UDP reverse proxy (hs:// keys) * * Enabled by default. Set ENABLE_HOLESAIL=0 to disable. * Package: holesail (AGPL-3.0) — required dependency. * * @see https://github.com/holesail/holesail * @see docs/HOLESAIL.md */ import { randomBytes } from 'crypto' import fs from 'fs' import path from 'path' // Static import so bare-pack embeds holesail and Bare resolves it at load time. // Dynamic createRequire(import.meta.url) fails in standalone bundles (parent URL // does not match pack resolutions → "package missing"). import HolesailModule from 'holesail' import logger from '../utils/logger.js' /** @typedef {{ * id: string, * name: string, * host: string, * port: number, * protocol: 'tcp'|'udp', * secure: boolean, * url: string, * key: string, * publicKey?: string, * containerId?: string|null, * containerName?: string|null, * createdAt: string, * state: string, * persist?: boolean, * }} TunnelInfo */ const MAX_TUNNELS = Math.max(1, Number(process.env.PEARDOCK_MAX_TUNNELS || 20)) const DEFAULT_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '0.0.0.0']) function persistPath() { return ( process.env.PEARDOCK_TUNNELS_PATH || path.join(process.cwd(), 'peardock-tunnels.json') ) } /** @type {Map} */ const tunnels = new Map() /** In-flight create promises keyed by protocol|host|port (coalesce concurrent creates). */ /** @type {Map>} */ const pendingCreates = new Map() let HolesailCtor = null let holesailLoadError = null let restoreDone = false /** * Normalize host for duplicate matching (localhost ↔ 127.0.0.1). * @param {string} host */ function normalizeTunnelHost(host) { const h = String(host || '127.0.0.1').trim().toLowerCase() if (h === 'localhost' || h === '::1' || h === '0.0.0.0' || h === '::') return '127.0.0.1' return h } /** * @param {string} host * @param {number} port * @param {'tcp'|'udp'} [protocol] */ function targetKey(host, port, protocol = 'tcp') { return `${protocol === 'udp' ? 'udp' : 'tcp'}|${normalizeTunnelHost(host)}|${Number(port)}` } /** * Find an active tunnel for the same target (any non-closed state). * @param {string} host * @param {number} port * @param {'tcp'|'udp'} [protocol] * @returns {TunnelInfo|null} */ export function findTunnelByTarget(host, port, protocol = 'tcp') { const key = targetKey(host, port, protocol) for (const t of tunnels.values()) { if (targetKey(t.info.host, t.info.port, t.info.protocol) === key) { return { ...t.info } } } return null } function loadHolesail() { if (HolesailCtor) return HolesailCtor if (holesailLoadError) throw holesailLoadError try { // CJS interop: module.exports = class Holesail → default under ESM const mod = HolesailModule?.default ?? HolesailModule if (typeof mod !== 'function') { throw new Error( `holesail export is not a constructor (got ${typeof mod})` ) } HolesailCtor = mod return HolesailCtor } catch (err) { holesailLoadError = err logger.error('Failed to load holesail package', { error: err?.message || String(err), stack: err?.stack, }) throw err } } /** * Holesail tunnels are on by default. * Opt out with ENABLE_HOLESAIL=0 / false / off / no. */ export function isHolesailEnabled() { const v = String(process.env.ENABLE_HOLESAIL ?? '1').trim().toLowerCase() if (v === '0' || v === 'false' || v === 'off' || v === 'no') return false return true } export function isHolesailAvailable() { try { loadHolesail() return true } catch { return false } } export function getTunnelsPersistPath() { return persistPath() } /** * @returns {Set} */ export function allowedTunnelHosts() { const set = new Set(DEFAULT_HOSTS) const extra = process.env.PEARDOCK_TUNNEL_HOSTS || '' for (const part of extra.split(/[,\s]+/)) { const h = part.trim().toLowerCase() if (h) set.add(h) } return set } /** * @param {string} host * @param {number} port */ export function assertTunnelTarget(host, port) { const h = String(host || '127.0.0.1').trim().toLowerCase() const p = Number(port) if (!Number.isInteger(p) || p < 1 || p > 65535) { throw Object.assign(new Error('port must be an integer 1–65535'), { code: 'INVALID_ARGS' }) } const allowed = allowedTunnelHosts() if (!allowed.has(h)) { throw Object.assign( new Error( `Host "${host}" is not allowed for tunnels. Allowed: ${[...allowed].join(', ')}. ` + 'Extend with PEARDOCK_TUNNEL_HOSTS=host1,host2' ), { code: 'PERMISSION_DENIED' } ) } return { host: h === 'localhost' ? '127.0.0.1' : host, port: p } } function newId() { return `tnl_${Date.now().toString(36)}_${randomBytes(4).toString('hex')}` } /** * Persistable definitions (no live sockets). Keys are capabilities — file mode 600. * @returns {object[]} */ function buildPersistPayload() { return [...tunnels.values()] .filter((t) => t.info.persist !== false) .map((t) => ({ id: t.info.id, name: t.info.name, host: t.info.host, port: t.info.port, protocol: t.info.protocol, secure: t.info.secure, key: t.info.key, containerId: t.info.containerId || null, containerName: t.info.containerName || null, createdAt: t.info.createdAt, persist: true, })) } export function saveTunnelsToDisk() { if (!isHolesailEnabled()) return false try { const payload = { version: 1, updatedAt: new Date().toISOString(), tunnels: buildPersistPayload(), } const file = persistPath() const tmp = `${file}.${process.pid}.tmp` fs.writeFileSync(tmp, JSON.stringify(payload, null, 2), { encoding: 'utf8', mode: 0o600 }) fs.renameSync(tmp, file) try { fs.chmodSync(file, 0o600) } catch { // ignore } return true } catch (err) { logger.warn('Failed to persist tunnels', { error: err.message }) return false } } /** * Read definitions from disk (does not start tunnels). * @returns {object[]} */ export function loadTunnelDefsFromDisk() { try { const file = persistPath() if (!fs.existsSync(file)) return [] const raw = JSON.parse(fs.readFileSync(file, 'utf8')) const list = Array.isArray(raw?.tunnels) ? raw.tunnels : Array.isArray(raw) ? raw : [] return list.filter((t) => t && t.port) } catch (err) { logger.warn('Failed to read tunnel persist file', { error: err.message }) return [] } } /** * Public list shape (includes connection URL — treat as secret capability). * @returns {TunnelInfo[]} */ export function listTunnels() { return [...tunnels.values()].map((t) => ({ ...t.info })) } /** * @param {string} id * @returns {TunnelInfo|null} */ export function getTunnel(id) { const t = tunnels.get(id) return t ? { ...t.info } : null } /** * Start a Holesail server tunnel to host:port. * @param {{ * id?: string, * name?: string, * host?: string, * port: number, * protocol?: 'tcp'|'udp', * secure?: boolean, * key?: string, * containerId?: string|null, * containerName?: string|null, * persist?: boolean, * createdAt?: string, * skipPersistWrite?: boolean, * }} opts * @returns {Promise} */ export async function createTunnel(opts = {}) { if (!isHolesailEnabled()) { const err = new Error('Holesail tunnels disabled. Remove ENABLE_HOLESAIL=0 to re-enable (on by default).') err.code = 'FEATURE_DISABLED' throw err } const protocol = opts.protocol === 'udp' ? 'udp' : 'tcp' const { host, port } = assertTunnelTarget(opts.host || '127.0.0.1', opts.port) const key = targetKey(host, port, protocol) // Idempotent: return existing tunnel for same target (do not start a second Holesail) const existing = findTunnelByTarget(host, port, protocol) if (existing) { logger.info('Tunnel already active — reusing', { id: existing.id, target: `${protocol}://${host}:${port}`, }) return { ...existing, existing: true } } // Coalesce concurrent createTunnel calls for the same target const inflight = pendingCreates.get(key) if (inflight) { logger.info('Tunnel create already in flight — joining', { target: `${protocol}://${host}:${port}` }) const joined = await inflight return { ...joined, existing: true } } if (tunnels.size >= MAX_TUNNELS) { throw Object.assign(new Error(`Maximum concurrent tunnels (${MAX_TUNNELS}) reached`), { code: 'RATE_LIMIT_EXCEEDED', }) } const work = createTunnelUnlocked(opts, { host, port, protocol, key }) pendingCreates.set(key, work) try { return await work } finally { pendingCreates.delete(key) } } /** * @param {object} opts * @param {{ host: string, port: number, protocol: 'tcp'|'udp', key: string }} resolved * @returns {Promise} */ async function createTunnelUnlocked(opts, resolved) { // Re-check after awaiting any prior work / race window const again = findTunnelByTarget(resolved.host, resolved.port, resolved.protocol) if (again) return { ...again, existing: true } if (tunnels.size >= MAX_TUNNELS) { throw Object.assign(new Error(`Maximum concurrent tunnels (${MAX_TUNNELS}) reached`), { code: 'RATE_LIMIT_EXCEEDED', }) } const Holesail = loadHolesail() const { host, port, protocol } = resolved const secure = opts.secure !== false const name = String(opts.name || `${protocol}-${host}:${port}`).slice(0, 80) || `tunnel-${port}` const persist = opts.persist !== false const id = opts.id && String(opts.id).startsWith('tnl_') ? String(opts.id) : newId() logger.info('Starting Holesail tunnel', { id, host, port, protocol, secure, persist }) const ctorOpts = { server: true, secure, port, host, udp: protocol === 'udp', log: false, } // Reuse connection key so restarts keep the same hs:// URL if (opts.key) ctorOpts.key = String(opts.key) const instance = new Holesail(ctorOpts) try { await instance.ready() } catch (err) { try { await instance.close() } catch { // ignore } throw Object.assign(new Error(`Failed to start Holesail tunnel: ${err.message || err}`), { code: 'DOCKER_ERROR', cause: err, }) } // Another concurrent path may have registered first const raced = findTunnelByTarget(host, port, protocol) if (raced) { await safeClose(instance) return { ...raced, existing: true } } const raw = instance.info || {} /** @type {TunnelInfo} */ const info = { id, name, host, port, protocol, secure: Boolean(raw.secure ?? secure), url: String(raw.url || ''), key: String(raw.key || opts.key || ''), publicKey: raw.publicKey ? String(raw.publicKey) : undefined, containerId: opts.containerId || null, containerName: opts.containerName || null, createdAt: opts.createdAt || new Date().toISOString(), state: String(raw.state || 'listening'), persist, } if (!info.url) { await safeClose(instance) throw new Error('Holesail started but returned no connection URL') } tunnels.set(id, { instance, info }) if (!opts.skipPersistWrite) saveTunnelsToDisk() logger.info('Holesail tunnel ready', { id, name: info.name, target: `${protocol}://${host}:${port}`, urlPrefix: info.url.slice(0, 12) + '…', }) return { ...info, existing: false } } /** * @param {string} id * @returns {Promise} */ export async function closeTunnel(id) { const entry = tunnels.get(id) if (!entry) return false tunnels.delete(id) await safeClose(entry.instance) saveTunnelsToDisk() logger.info('Holesail tunnel closed', { id, name: entry.info.name }) return true } export async function closeAllTunnels() { const ids = [...tunnels.keys()] for (const id of ids) { try { const entry = tunnels.get(id) tunnels.delete(id) if (entry) await safeClose(entry.instance) } catch (err) { logger.warn('Failed to close tunnel on shutdown', { id, error: err.message }) } } // Keep persist file so next boot restores } /** * Recreate tunnels from peardock-tunnels.json (call once at server boot). * @returns {Promise<{ restored: number, failed: number }>} */ export async function restoreTunnelsFromDisk() { if (restoreDone) return { restored: 0, failed: 0 } restoreDone = true if (!isHolesailEnabled()) return { restored: 0, failed: 0 } const defs = loadTunnelDefsFromDisk() if (!defs.length) return { restored: 0, failed: 0 } let restored = 0 let failed = 0 for (const def of defs) { try { await createTunnel({ id: def.id, name: def.name, host: def.host, port: def.port, protocol: def.protocol, secure: def.secure !== false, key: def.key, containerId: def.containerId, containerName: def.containerName, createdAt: def.createdAt, persist: true, skipPersistWrite: true, }) restored += 1 } catch (err) { failed += 1 logger.warn('Failed to restore tunnel', { id: def.id, port: def.port, error: err.message, }) } } saveTunnelsToDisk() if (restored || failed) { logger.info('Holesail tunnel restore complete', { restored, failed }) } return { restored, failed } } async function safeClose(instance) { if (!instance) return try { if (typeof instance.close === 'function') await instance.close() else if (typeof instance.destroy === 'function') await instance.destroy() } catch { // ignore } } export function holesailStatus() { const available = isHolesailAvailable() return { enabled: isHolesailEnabled(), available, active: tunnels.size, max: MAX_TUNNELS, allowedHosts: [...allowedTunnelHosts()], persistPath: persistPath(), /** Present when available === false — helps diagnose bare packing / load issues */ loadError: available ? null : holesailLoadError?.message || holesailLoadError ? String(holesailLoadError) : 'unknown', } } export default { isHolesailEnabled, isHolesailAvailable, listTunnels, getTunnel, findTunnelByTarget, createTunnel, closeTunnel, closeAllTunnels, restoreTunnelsFromDisk, saveTunnelsToDisk, loadTunnelDefsFromDisk, holesailStatus, assertTunnelTarget, getTunnelsPersistPath, }