Files
peardock/server/services/holesail-tunnels.js
T
Raven Scott 06022e6e90 Fix Holesail in Bare standalone via static import
createRequire(import.meta.url) could not resolve holesail inside the
packed app bundle even though holesail was embedded. Import holesail
statically so Bare resolution works; surface loadError in status banner.
2026-07-11 15:50:56 -04:00

537 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, { instance: any, info: TunnelInfo }>} */
const tunnels = new Map()
/** In-flight create promises keyed by protocol|host|port (coalesce concurrent creates). */
/** @type {Map<string, Promise<TunnelInfo & { existing?: boolean }>>} */
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<string>}
*/
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 165535'), { 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<TunnelInfo & { existing?: boolean }>}
*/
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<TunnelInfo & { existing?: boolean }>}
*/
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<boolean>}
*/
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,
}