Complete roadmap optionals: Holesail, Swarm UI, GitOps, virtualization.
CI / test (push) Successful in 9m58s
CI / test (push) Successful in 9m58s
Add tunnel persistence and container one-click tunnels with local Holesail client bind, Swarm services/nodes/tasks view, GitOps stack sync from git, deploy wizard step chrome, virtualized container lists, and structure regression tests. Mark Tracks A–D and optional future done.
This commit is contained in:
@@ -23,7 +23,10 @@ const AUDIT_METHODS = new Set([
|
||||
'removeImage',
|
||||
'removeStack',
|
||||
'deployStack',
|
||||
'syncStackFromGit',
|
||||
'deployContainer',
|
||||
'createTunnel',
|
||||
'closeTunnel',
|
||||
'createContainer',
|
||||
'buildImage',
|
||||
'pushImage',
|
||||
|
||||
@@ -5,6 +5,8 @@ import * as composeManager from '../utils/composeManager.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { docker } from '../services/docker.js'
|
||||
import { broadcastContainers } from './containers.js'
|
||||
import { fetchComposeFromGit } from '../utils/gitops.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
export function registerStackHandlers(session) {
|
||||
session.respond('deployStack', async (args) => {
|
||||
@@ -70,4 +72,45 @@ export function registerStackHandlers(session) {
|
||||
composeContent: args.composeContent,
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* GitOps: fetch compose from git and deploy.
|
||||
* args: { stackName, repoUrl, ref?, composePath?, build?, rollback? }
|
||||
*/
|
||||
session.respond('syncStackFromGit', async (args = {}) => {
|
||||
const stackName = validation.sanitizeString(args.stackName, 63)
|
||||
if (!stackName || !validation.isValidContainerName(stackName)) {
|
||||
throw new Error('Valid stackName required')
|
||||
}
|
||||
if (!args.repoUrl) throw new Error('repoUrl required')
|
||||
|
||||
logger.info('GitOps sync starting', {
|
||||
stackName,
|
||||
repo: String(args.repoUrl).slice(0, 80),
|
||||
ref: args.ref || 'main',
|
||||
})
|
||||
const fetched = await fetchComposeFromGit({
|
||||
repoUrl: args.repoUrl,
|
||||
ref: args.ref,
|
||||
composePath: args.composePath,
|
||||
})
|
||||
composeManager.validateComposeFile(fetched.composeContent)
|
||||
const result = await composeManager.deployComposeStack(
|
||||
docker,
|
||||
fetched.composeContent,
|
||||
stackName,
|
||||
{
|
||||
build: Boolean(args.build),
|
||||
rollback: args.rollback !== false,
|
||||
}
|
||||
)
|
||||
await broadcastContainers()
|
||||
return {
|
||||
success: true,
|
||||
message: `Stack "${stackName}" deployed from git${fetched.commit ? ` @ ${fetched.commit.slice(0, 8)}` : ''}`,
|
||||
commit: fetched.commit || null,
|
||||
composePath: fetched.path,
|
||||
...result,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Holesail tunnel RPC handlers.
|
||||
* Gated by ENABLE_HOLESAIL=1.
|
||||
*/
|
||||
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. Set ENABLE_HOLESAIL=1 to enable.')
|
||||
err.code = 'FEATURE_DISABLED'
|
||||
throw err
|
||||
}
|
||||
if (!isHolesailAvailable()) {
|
||||
const err = new Error(
|
||||
'Holesail package is not available on this server. Install dependency "holesail".'
|
||||
)
|
||||
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,
|
||||
})
|
||||
logger.info('Tunnel created via RPC', {
|
||||
id: tunnel.id,
|
||||
peerId: session.id?.slice?.(0, 12),
|
||||
name: tunnel.name,
|
||||
})
|
||||
return { success: true, tunnel }
|
||||
})
|
||||
|
||||
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 }
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { registerPeerHandlers } from '../handlers/peers.js'
|
||||
import { registerVaultHandlers } from '../handlers/vault.js'
|
||||
import { registerBinaryStreamHandlers } from './binary-stream.js'
|
||||
import { registerSuggestionHandlers } from '../handlers/suggestions.js'
|
||||
import { registerTunnelHandlers } from '../handlers/tunnels.js'
|
||||
|
||||
/**
|
||||
* @param {import('./session.js').PeerSession} session
|
||||
@@ -40,6 +41,7 @@ export function registerAllHandlers(session) {
|
||||
registerVaultHandlers(session)
|
||||
registerBinaryStreamHandlers(session)
|
||||
registerSuggestionHandlers(session)
|
||||
registerTunnelHandlers(session)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -16,6 +16,12 @@ import { startDockerEventStream, stopDockerEventStream } from './services/events
|
||||
import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js'
|
||||
import { isPeerRevoked } from './core/peer-policy.js'
|
||||
import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js'
|
||||
import {
|
||||
closeAllTunnels,
|
||||
isHolesailEnabled,
|
||||
holesailStatus,
|
||||
restoreTunnelsFromDisk,
|
||||
} from './services/holesail-tunnels.js'
|
||||
import logger from './utils/logger.js'
|
||||
|
||||
const { keyPair, publicKeyHex } = loadOrCreateKeyPair()
|
||||
@@ -64,16 +70,36 @@ console.log('══════════════════════
|
||||
console.log(' peardock server ready')
|
||||
console.log(` Public key (paste into the client):`)
|
||||
console.log(` ${publicKeyHex}`)
|
||||
if (isHolesailEnabled()) {
|
||||
const hs = holesailStatus()
|
||||
console.log(
|
||||
` Holesail tunnels: ${hs.available ? 'enabled' : 'enabled but package missing'} (max ${hs.max})`
|
||||
)
|
||||
} else {
|
||||
console.log(' Holesail tunnels: off (set ENABLE_HOLESAIL=1 to enable)')
|
||||
}
|
||||
console.log('═══════════════════════════════════════════════════════════')
|
||||
console.log('')
|
||||
|
||||
startDockerEventStream()
|
||||
startStatsBroadcast()
|
||||
|
||||
// Recreate persisted Holesail tunnels (same hs:// keys when possible)
|
||||
if (isHolesailEnabled()) {
|
||||
restoreTunnelsFromDisk().catch((err) => {
|
||||
logger.warn('Tunnel restore failed', { error: err.message })
|
||||
})
|
||||
}
|
||||
|
||||
async function shutdown() {
|
||||
console.log('[INFO] Server shutting down…')
|
||||
stopStatsBroadcast()
|
||||
stopDockerEventStream()
|
||||
try {
|
||||
await closeAllTunnels()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
peers.clear()
|
||||
try {
|
||||
await server.close()
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
/**
|
||||
* Holesail tunnel manager with optional disk persistence.
|
||||
*
|
||||
* peardock control plane: HyperDHT + protomux-rpc
|
||||
* Data plane tunnels: Holesail L4 TCP/UDP reverse proxy (hs:// keys)
|
||||
*
|
||||
* Gated by ENABLE_HOLESAIL=1. Package: holesail (AGPL-3.0).
|
||||
*
|
||||
* @see https://github.com/holesail/holesail
|
||||
* @see docs/HOLESAIL.md
|
||||
*/
|
||||
import { createRequire } from 'module'
|
||||
import { randomBytes } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
/** @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()
|
||||
|
||||
let HolesailCtor = null
|
||||
let holesailLoadError = null
|
||||
let restoreDone = false
|
||||
|
||||
function loadHolesail() {
|
||||
if (HolesailCtor) return HolesailCtor
|
||||
if (holesailLoadError) throw holesailLoadError
|
||||
try {
|
||||
HolesailCtor = require('holesail')
|
||||
return HolesailCtor
|
||||
} catch (err) {
|
||||
holesailLoadError = err
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function isHolesailEnabled() {
|
||||
return process.env.ENABLE_HOLESAIL === '1' || process.env.ENABLE_HOLESAIL === '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>}
|
||||
*/
|
||||
export async function createTunnel(opts = {}) {
|
||||
if (!isHolesailEnabled()) {
|
||||
const err = new Error('Holesail tunnels disabled. Set ENABLE_HOLESAIL=1 to enable.')
|
||||
err.code = 'FEATURE_DISABLED'
|
||||
throw err
|
||||
}
|
||||
if (tunnels.size >= MAX_TUNNELS) {
|
||||
throw Object.assign(new Error(`Maximum concurrent tunnels (${MAX_TUNNELS}) reached`), {
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
})
|
||||
}
|
||||
|
||||
const Holesail = loadHolesail()
|
||||
const protocol = opts.protocol === 'udp' ? 'udp' : 'tcp'
|
||||
const secure = opts.secure !== false
|
||||
const { host, port } = assertTunnelTarget(opts.host || '127.0.0.1', opts.port)
|
||||
const name =
|
||||
String(opts.name || `${protocol}-${host}:${port}`).slice(0, 80) || `tunnel-${port}`
|
||||
const persist = opts.persist !== false
|
||||
|
||||
// Avoid duplicate host:port:protocol
|
||||
for (const t of tunnels.values()) {
|
||||
if (
|
||||
t.info.host === host &&
|
||||
t.info.port === port &&
|
||||
t.info.protocol === protocol &&
|
||||
t.info.state === 'listening'
|
||||
) {
|
||||
throw Object.assign(
|
||||
new Error(`Tunnel already active for ${protocol}://${host}:${port} (${t.info.id})`),
|
||||
{ code: 'DOCKER_CONFLICT' }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* @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() {
|
||||
return {
|
||||
enabled: isHolesailEnabled(),
|
||||
available: isHolesailAvailable(),
|
||||
active: tunnels.size,
|
||||
max: MAX_TUNNELS,
|
||||
allowedHosts: [...allowedTunnelHosts()],
|
||||
persistPath: persistPath(),
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
isHolesailEnabled,
|
||||
isHolesailAvailable,
|
||||
listTunnels,
|
||||
getTunnel,
|
||||
createTunnel,
|
||||
closeTunnel,
|
||||
closeAllTunnels,
|
||||
restoreTunnelsFromDisk,
|
||||
saveTunnelsToDisk,
|
||||
loadTunnelDefsFromDisk,
|
||||
holesailStatus,
|
||||
assertTunnelTarget,
|
||||
getTunnelsPersistPath,
|
||||
}
|
||||
@@ -89,6 +89,8 @@ export function getMetricsSnapshot(extra = {}) {
|
||||
features: {
|
||||
swarm: process.env.ENABLE_SWARM === '1' || process.env.ENABLE_SWARM === 'true',
|
||||
plugins: process.env.ENABLE_PLUGINS === '1' || process.env.ENABLE_PLUGINS === 'true',
|
||||
holesail:
|
||||
process.env.ENABLE_HOLESAIL === '1' || process.env.ENABLE_HOLESAIL === 'true',
|
||||
peerAllowlist:
|
||||
process.env.PEARDOCK_PEER_ALLOWLIST === '1' ||
|
||||
process.env.PEARDOCK_PEER_ALLOWLIST === 'true',
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Minimal GitOps helper: shallow-clone a repo and read a compose file.
|
||||
* Requires `git` on PATH on the peardock server host.
|
||||
*/
|
||||
import { spawn } from 'child_process'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
/**
|
||||
* @param {string} cmd
|
||||
* @param {string[]} args
|
||||
* @param {{ cwd?: string, timeoutMs?: number }} [opts]
|
||||
* @returns {Promise<{ code: number, stdout: string, stderr: string }>}
|
||||
*/
|
||||
function run(cmd, args, opts = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(cmd, args, {
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const timer = setTimeout(() => {
|
||||
child.kill('SIGKILL')
|
||||
reject(new Error(`Command timed out: ${cmd} ${args.join(' ')}`))
|
||||
}, opts.timeoutMs || 120_000)
|
||||
child.stdout?.on('data', (d) => {
|
||||
stdout += d.toString()
|
||||
})
|
||||
child.stderr?.on('data', (d) => {
|
||||
stderr += d.toString()
|
||||
})
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(timer)
|
||||
resolve({ code: code ?? 1, stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Shallow clone and read compose YAML.
|
||||
* @param {{
|
||||
* repoUrl: string,
|
||||
* ref?: string,
|
||||
* composePath?: string,
|
||||
* }} opts
|
||||
* @returns {Promise<{ composeContent: string, commit?: string, path: string }>}
|
||||
*/
|
||||
export async function fetchComposeFromGit(opts) {
|
||||
const repoUrl = String(opts.repoUrl || '').trim()
|
||||
if (!repoUrl) throw Object.assign(new Error('repoUrl required'), { code: 'INVALID_ARGS' })
|
||||
if (!/^https?:\/\//i.test(repoUrl) && !/^git@/i.test(repoUrl)) {
|
||||
throw Object.assign(
|
||||
new Error('repoUrl must be http(s) or git@ URL'),
|
||||
{ code: 'INVALID_ARGS' }
|
||||
)
|
||||
}
|
||||
const ref = String(opts.ref || 'main').trim() || 'main'
|
||||
let composePath = String(opts.composePath || 'docker-compose.yml').trim() || 'docker-compose.yml'
|
||||
// Path traversal guard
|
||||
if (composePath.includes('..') || path.isAbsolute(composePath)) {
|
||||
throw Object.assign(new Error('composePath must be a relative path without ..'), {
|
||||
code: 'INVALID_ARGS',
|
||||
})
|
||||
}
|
||||
|
||||
const tmpRoot = path.join(os.tmpdir(), `peardock-gitops-${randomBytes(6).toString('hex')}`)
|
||||
fs.mkdirSync(tmpRoot, { recursive: true })
|
||||
|
||||
try {
|
||||
const clone = await run(
|
||||
'git',
|
||||
['clone', '--depth', '1', '--branch', ref, '--single-branch', repoUrl, tmpRoot],
|
||||
{ timeoutMs: 180_000 }
|
||||
)
|
||||
if (clone.code !== 0) {
|
||||
// Retry without branch if ref is a tag/sha that needs full history hint
|
||||
const clone2 = await run('git', ['clone', '--depth', '1', repoUrl, tmpRoot], {
|
||||
timeoutMs: 180_000,
|
||||
})
|
||||
if (clone2.code !== 0) {
|
||||
throw new Error(
|
||||
`git clone failed: ${(clone.stderr || clone2.stderr || clone.stdout).slice(0, 400)}`
|
||||
)
|
||||
}
|
||||
if (ref && ref !== 'main' && ref !== 'master') {
|
||||
await run('git', ['checkout', ref], { cwd: tmpRoot, timeoutMs: 60_000 })
|
||||
}
|
||||
}
|
||||
|
||||
const full = path.join(tmpRoot, composePath)
|
||||
if (!fs.existsSync(full)) {
|
||||
// try compose.yaml
|
||||
const alt = path.join(tmpRoot, 'compose.yaml')
|
||||
if (composePath === 'docker-compose.yml' && fs.existsSync(alt)) {
|
||||
composePath = 'compose.yaml'
|
||||
} else {
|
||||
throw new Error(`Compose file not found in repo: ${composePath}`)
|
||||
}
|
||||
}
|
||||
const filePath = path.join(tmpRoot, composePath)
|
||||
const composeContent = fs.readFileSync(filePath, 'utf8')
|
||||
if (!composeContent.trim()) throw new Error('Compose file is empty')
|
||||
|
||||
let commit = ''
|
||||
try {
|
||||
const rev = await run('git', ['rev-parse', 'HEAD'], { cwd: tmpRoot, timeoutMs: 10_000 })
|
||||
if (rev.code === 0) commit = rev.stdout.trim()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return { composeContent, commit, path: composePath }
|
||||
} finally {
|
||||
try {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true })
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default { fetchComposeFromGit }
|
||||
Reference in New Issue
Block a user