Files
peardock/server/handlers/tunnels.js
T
snxraven 74cd2f4a2e
CI / test (push) Successful in 10m5s
Idempotent tunnel create: reuse existing, block UI double-submit
Return the active tunnel for the same host:port:protocol instead of
starting a second Holesail instance; coalesce concurrent creates.
UI disables the create button and shows “already exists” feedback.
2026-07-10 23:28:07 -04:00

165 lines
4.9 KiB
JavaScript

/**
* 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 }
})
}