forked from snxraven/peardock
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.
537 lines
14 KiB
JavaScript
537 lines
14 KiB
JavaScript
/**
|
||
* 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 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<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,
|
||
}
|