Release rolling / release (push) Successful in 7m30s
Server-side alerting for Docker/container/stack health with Discord, Slack, Teams, ntfy, Gotify, Telegram, and generic webhooks. Settings tab, client cache, and backup/restore coverage; marked experimental.
246 lines
6.8 KiB
JavaScript
246 lines
6.8 KiB
JavaScript
/**
|
|
* peardock server entry point.
|
|
*
|
|
* HyperDHT listener + protomux-rpc API for remote Docker management.
|
|
* Clients connect with the server public key printed at startup.
|
|
*/
|
|
import DHT from 'hyperdht'
|
|
import b4a from 'b4a'
|
|
import gracefulGoodbye from 'graceful-goodbye'
|
|
import { loadOrCreateKeyPair } from './core/keys.js'
|
|
import { initAuthKeys } from './core/auth-keys.js'
|
|
import { peers } from './core/peer-registry.js'
|
|
import { PeerSession } from './rpc/session.js'
|
|
import { registerAllHandlers, cleanupSession } from './rpc/register.js'
|
|
import { startDockerEventStream, stopDockerEventStream } from './services/events.js'
|
|
import { startStatsBroadcast, stopStatsBroadcast } from './services/stats.js'
|
|
import { isPeerRevoked } from './core/peer-policy.js'
|
|
import { recordPeerConnect, recordPeerDisconnect } from './services/metrics.js'
|
|
import { isInsecureOpenAdmin } from '../shared/crypto-auth.js'
|
|
import { closeConnectionInvites } from './core/connection-invites.js'
|
|
import {
|
|
closeAllTunnels,
|
|
isHolesailEnabled,
|
|
holesailStatus,
|
|
restoreTunnelsFromDisk,
|
|
} from './services/holesail-tunnels.js'
|
|
import { restoreSchedules } from './services/schedules.js'
|
|
import { startAlerts, stopAlerts } from './services/alerts.js'
|
|
import { docker } from './services/docker.js'
|
|
import logger from './utils/logger.js'
|
|
import { isSwarmEnabled } from './handlers/swarm.js'
|
|
import { isPluginsEnabled } from './handlers/plugins.js'
|
|
|
|
const bootStarted = Date.now()
|
|
const log = logger.child('server')
|
|
|
|
/** @type {{ keyPair: any, publicKeyHex: string, seedHex: string }} */
|
|
let keyPair
|
|
let publicKeyHex
|
|
let seedHex
|
|
try {
|
|
;({ keyPair, publicKeyHex, seedHex } = loadOrCreateKeyPair())
|
|
initAuthKeys({ seedHex, publicKeyHex })
|
|
} catch (err) {
|
|
log.error('Failed to load server keypair', { error: err.message })
|
|
process.exit(1)
|
|
}
|
|
|
|
if (isInsecureOpenAdmin()) {
|
|
log.warn(
|
|
'PEARDOCK_INSECURE_OPEN_ADMIN=1 — every peer is admin. Disable for production multi-operator use.'
|
|
)
|
|
}
|
|
|
|
const dht = new DHT()
|
|
const server = dht.createServer()
|
|
|
|
server.on('connection', (socket) => {
|
|
const peerId = socket.remotePublicKey
|
|
? b4a.toString(socket.remotePublicKey, 'hex')
|
|
: null
|
|
|
|
if (peerId && isPeerRevoked(peerId)) {
|
|
log.warn('Rejected revoked peer', { peerId: peerId.slice(0, 12) })
|
|
try {
|
|
socket.destroy()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return
|
|
}
|
|
|
|
const session = new PeerSession(socket, {
|
|
serverPublicKey: keyPair.publicKey,
|
|
onClose: (s) => {
|
|
cleanupSession(s)
|
|
peers.remove(s.id)
|
|
recordPeerDisconnect()
|
|
log.info('Peer disconnected', {
|
|
peerId: s.id.slice(0, 12),
|
|
role: s.role,
|
|
peers: peers.size,
|
|
})
|
|
},
|
|
})
|
|
|
|
registerAllHandlers(session)
|
|
peers.add(session)
|
|
recordPeerConnect()
|
|
log.info('Peer connected', {
|
|
peerId: session.id.slice(0, 12),
|
|
role: session.role,
|
|
peers: peers.size,
|
|
})
|
|
})
|
|
|
|
server.on('error', (err) => {
|
|
log.error('HyperDHT server error', { error: err.message, code: err.code })
|
|
})
|
|
|
|
/**
|
|
* Probe Docker socket for the boot banner.
|
|
* @returns {Promise<{ ok: boolean, apiVersion?: string, os?: string, error?: string }>}
|
|
*/
|
|
async function probeDocker() {
|
|
try {
|
|
const version = await docker.version()
|
|
return {
|
|
ok: true,
|
|
apiVersion: version.ApiVersion || version.apiVersion || undefined,
|
|
os: version.Os || version.os || undefined,
|
|
}
|
|
} catch (err) {
|
|
return { ok: false, error: err.message || String(err) }
|
|
}
|
|
}
|
|
|
|
await server.listen(keyPair)
|
|
|
|
const dockerStatus = await probeDocker()
|
|
const hs = isHolesailEnabled() ? holesailStatus() : null
|
|
const bootMs = Date.now() - bootStarted
|
|
|
|
logger.banner([
|
|
'peardock server ready',
|
|
'',
|
|
'Public key (viewer / read-only by default):',
|
|
publicKeyHex,
|
|
'',
|
|
'Admin: paste public key + SERVER_SEED in the client.',
|
|
'Operators: create a pd1. invite from Access (never share SERVER_SEED).',
|
|
'',
|
|
`Docker: ${dockerStatus.ok ? `ok · API ${dockerStatus.apiVersion || '?'} · ${dockerStatus.os || '?'}` : `unavailable · ${dockerStatus.error || 'socket error'}`}`,
|
|
`Holesail: ${
|
|
!isHolesailEnabled()
|
|
? 'off (ENABLE_HOLESAIL=0)'
|
|
: hs?.available
|
|
? `on · max ${hs.max} tunnels`
|
|
: `on but package missing${hs?.loadError ? ` · ${String(hs.loadError).slice(0, 80)}` : ''}`
|
|
}`,
|
|
`Auth: HMAC capabilities · default role viewer${isInsecureOpenAdmin() ? ' · INSECURE OPEN ADMIN' : ''}`,
|
|
`Swarm RPC: ${isSwarmEnabled() ? 'on' : 'off'} · Plugins: ${isPluginsEnabled() ? 'on' : 'off'}`,
|
|
`Log: ${logger.format} · level ${['error', 'warn', 'info', 'debug'][logger.level] || 'info'}`,
|
|
`Boot ${bootMs}ms · pid ${process.pid} · Node ${process.version}`,
|
|
])
|
|
|
|
log.info('Listening on HyperDHT', {
|
|
publicKey: publicKeyHex.slice(0, 16) + '…',
|
|
docker: dockerStatus.ok,
|
|
bootMs,
|
|
})
|
|
|
|
if (!dockerStatus.ok) {
|
|
log.warn('Docker socket not reachable — RPC that needs Docker will fail until dockerd is up', {
|
|
error: dockerStatus.error,
|
|
})
|
|
}
|
|
|
|
startDockerEventStream()
|
|
startStatsBroadcast()
|
|
try {
|
|
startAlerts()
|
|
} catch (err) {
|
|
log.warn('Alerts engine failed to start', { error: err.message })
|
|
}
|
|
|
|
if (isHolesailEnabled()) {
|
|
restoreTunnelsFromDisk()
|
|
.then((r) => {
|
|
if (r?.restored || r?.failed) {
|
|
log.info('Restored Holesail tunnels', r)
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
log.warn('Tunnel restore failed', { error: err.message })
|
|
})
|
|
}
|
|
|
|
try {
|
|
restoreSchedules()
|
|
log.debug('Schedules restored')
|
|
} catch (err) {
|
|
log.warn('Schedule restore failed', { error: err.message })
|
|
}
|
|
|
|
let shuttingDown = false
|
|
|
|
async function shutdown(signal = 'shutdown') {
|
|
if (shuttingDown) return
|
|
shuttingDown = true
|
|
log.info('Shutting down', { signal, peers: peers.size, uptimeSec: Math.round(process.uptime()) })
|
|
|
|
stopStatsBroadcast()
|
|
stopDockerEventStream()
|
|
try {
|
|
stopAlerts()
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
await closeAllTunnels()
|
|
} catch (err) {
|
|
log.warn('Error closing tunnels', { error: err.message })
|
|
}
|
|
|
|
peers.clear()
|
|
|
|
try {
|
|
await closeConnectionInvites()
|
|
} catch (err) {
|
|
log.debug('invites.close', { error: err.message })
|
|
}
|
|
|
|
try {
|
|
await server.close()
|
|
} catch (err) {
|
|
log.debug('server.close', { error: err.message })
|
|
}
|
|
|
|
try {
|
|
await dht.destroy()
|
|
} catch (err) {
|
|
log.debug('dht.destroy', { error: err.message })
|
|
}
|
|
|
|
log.info('Bye')
|
|
// Allow log flush
|
|
setTimeout(() => process.exit(0), 50).unref?.()
|
|
}
|
|
|
|
gracefulGoodbye(() => shutdown('goodbye'))
|
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
|
|
|
process.on('uncaughtException', (err) => {
|
|
log.error('Uncaught exception', { error: err.message, stack: err.stack })
|
|
shutdown('uncaughtException')
|
|
})
|
|
|
|
process.on('unhandledRejection', (reason) => {
|
|
const msg = reason instanceof Error ? reason.message : String(reason)
|
|
const stack = reason instanceof Error ? reason.stack : undefined
|
|
log.error('Unhandled rejection', { error: msg, stack })
|
|
})
|