CI / test (push) Successful in 10m5s
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.
165 lines
4.9 KiB
JavaScript
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 }
|
|
})
|
|
}
|