/** * Holesail tunnel RPC handlers. * On by default; opt out with ENABLE_HOLESAIL=0. */ import { isHolesailEnabled, isHolesailAvailable, listTunnels, getTunnel, createTunnel, closeTunnel, holesailStatus, } from '../services/holesail-tunnels.js' import { docker } from '../services/docker.js' import * as validation from '../utils/validation.js' import logger from '../utils/logger.js' function assertHolesail() { if (!isHolesailEnabled()) { const err = new Error( 'Holesail tunnels disabled. Remove ENABLE_HOLESAIL=0 (enabled by default).' ) err.code = 'FEATURE_DISABLED' throw err } if (!isHolesailAvailable()) { const err = new Error( 'Holesail package is not available. Ensure dependency "holesail" is installed (required).' ) err.code = 'FEATURE_DISABLED' throw err } } /** * Resolve a published container port to host bind address. * @param {string} containerId * @param {number} containerPort * @param {'tcp'|'udp'} [protocol] */ async function resolveContainerPublish(containerId, containerPort, protocol = 'tcp') { const id = validation.sanitizeString(containerId, 128) if (!id) throw Object.assign(new Error('containerId required'), { code: 'INVALID_ARGS' }) const c = docker.getContainer(id) const inspect = await c.inspect() const ports = inspect?.NetworkSettings?.Ports || {} const key = `${containerPort}/${protocol}` const bindings = ports[key] if (!bindings?.length) { throw Object.assign( new Error( `Container has no published binding for ${key}. Publish the port (e.g. -p 8080:${containerPort}) first.` ), { code: 'INVALID_ARGS' } ) } const b = bindings[0] let host = b.HostIp || '127.0.0.1' // Docker often uses 0.0.0.0 for "all interfaces" — tunnel to loopback is safer/default if (!host || host === '0.0.0.0' || host === '::') host = '127.0.0.1' const hostPort = parseInt(b.HostPort, 10) if (!hostPort) { throw Object.assign(new Error(`Invalid host port mapping for ${key}`), { code: 'INVALID_ARGS', }) } const name = (inspect.Name || '').replace(/^\//, '') || id.slice(0, 12) return { host, port: hostPort, containerId: inspect.Id, containerName: name, } } export function registerTunnelHandlers(session) { // Always register so clients get FEATURE_DISABLED when off. session.respond('listTunnels', async () => { assertHolesail() return { success: true, tunnels: listTunnels(), status: holesailStatus(), } }) session.respond('getHolesailStatus', async () => { return { success: true, status: holesailStatus(), } }) session.respond('createTunnel', async (args = {}) => { assertHolesail() const protocol = args.protocol === 'udp' ? 'udp' : 'tcp' let host = args.host let port = args.port let containerId = args.containerId || null let containerName = args.containerName || null if (args.containerId && args.containerPort != null) { const resolved = await resolveContainerPublish( args.containerId, Number(args.containerPort), protocol ) host = resolved.host port = resolved.port containerId = resolved.containerId containerName = resolved.containerName } if (port == null) { throw Object.assign(new Error('port is required (or containerId + containerPort)'), { code: 'INVALID_ARGS', }) } const tunnel = await createTunnel({ name: args.name, host, port: Number(port), protocol, secure: args.secure !== false, containerId, containerName, }) const existing = Boolean(tunnel?.existing) logger.info(existing ? 'Tunnel reused via RPC' : 'Tunnel created via RPC', { id: tunnel.id, peerId: session.id?.slice?.(0, 12), name: tunnel.name, existing, }) // Strip internal flag from tunnel payload but surface on envelope const { existing: _e, ...tunnelOut } = tunnel return { success: true, tunnel: tunnelOut, existing } }) session.respond('closeTunnel', async (args = {}) => { assertHolesail() const id = validation.sanitizeString(args.id || args.tunnelId, 64) if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' }) const existing = getTunnel(id) if (!existing) { throw Object.assign(new Error(`Tunnel not found: ${id}`), { code: 'INVALID_ARGS' }) } await closeTunnel(id) return { success: true, id, closed: true } }) session.respond('getTunnel', async (args = {}) => { assertHolesail() const id = validation.sanitizeString(args.id || args.tunnelId, 64) if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' }) const tunnel = getTunnel(id) if (!tunnel) { throw Object.assign(new Error(`Tunnel not found: ${id}`), { code: 'INVALID_ARGS' }) } return { success: true, tunnel } }) }