Files
peardock/server/handlers/system.js
T
Raven Scott 637c386e33
Release rolling / release (push) Successful in 7m55s
Bring Back Realtime Table Stats
2026-08-04 22:37:41 -04:00

454 lines
14 KiB
JavaScript

/**
* System info, health, events, df, browse, registry, metrics.
*/
import fs from 'fs'
import path from 'path'
import { docker } from '../services/docker.js'
import * as validation from '../utils/validation.js'
import { PROTOCOL, PROTOCOL_VERSION } from '../../shared/protocol.js'
import { getMetricsSnapshot } from '../services/metrics.js'
import { peers } from '../core/peer-registry.js'
import { isSwarmEnabled } from './swarm.js'
import { isPluginsEnabled } from './plugins.js'
import {
listSchedules,
upsertSchedule,
deleteSchedule,
} from '../services/schedules.js'
import {
getStatsConfig,
updateStatsConfig,
setPeerStatsInterest,
getStatsFilePath,
STATS_LIMITS,
} from '../services/stats.js'
import logger from '../utils/logger.js'
/** @type {Map<string, { username: string, serveraddress?: string }>} */
const registryAuth = new Map()
/**
* Cached Docker probe for high-frequency ping RPCs.
* Clients poll health every ~10s; version() is heavier than needed every tick.
* TTL keeps degraded detection responsive without hammering the socket.
*/
const PING_DOCKER_CACHE_TTL_MS = 10_000
/** @type {{ at: number, ok: boolean, apiVersion: string|null, os: string|null, error: string|null }|null} */
let pingDockerCache = null
/**
* Lightweight Docker health for ping. Uses docker.ping() for liveness; refreshes
* apiVersion/os via version() only when cache is cold or after a failure.
* @returns {Promise<{ ok: boolean, apiVersion: string|null, os: string|null, error: string|null }>}
*/
export async function probeDockerForPing() {
const now = Date.now()
if (
pingDockerCache &&
now - pingDockerCache.at < PING_DOCKER_CACHE_TTL_MS &&
pingDockerCache.ok
) {
return {
ok: true,
apiVersion: pingDockerCache.apiVersion,
os: pingDockerCache.os,
error: null,
}
}
let dockerOk = false
let apiVersion = pingDockerCache?.apiVersion ?? null
let osType = pingDockerCache?.os ?? null
let error = null
try {
// Prefer cheap ping; fall back to version if ping is unavailable
if (typeof docker.ping === 'function') {
await docker.ping()
dockerOk = true
// Refresh version metadata when cache empty or expired
if (!apiVersion || !pingDockerCache || now - pingDockerCache.at >= PING_DOCKER_CACHE_TTL_MS) {
try {
const version = await docker.version()
apiVersion = version.ApiVersion || version.apiVersion || apiVersion
osType = version.Os || version.os || osType
} catch {
// ok remains true from ping; version is optional decoration
}
}
} else {
const version = await docker.version()
dockerOk = true
apiVersion = version.ApiVersion || version.apiVersion || null
osType = version.Os || version.os || null
}
} catch (err) {
error = err.message || String(err)
dockerOk = false
}
pingDockerCache = {
at: Date.now(),
ok: dockerOk,
apiVersion,
os: osType,
error,
}
return {
ok: dockerOk,
apiVersion,
os: osType,
error,
}
}
/** Test helper: clear ping Docker probe cache */
export function clearPingDockerCache() {
pingDockerCache = null
}
export function registerSystemHandlers(session) {
session.respond('ping', async () => {
const dockerProbe = await probeDockerForPing()
return {
success: true,
pong: Date.now(),
protocol: PROTOCOL,
protocolVersion: PROTOCOL_VERSION,
role: session.role,
docker: {
ok: dockerProbe.ok,
apiVersion: dockerProbe.apiVersion,
os: dockerProbe.os,
error: dockerProbe.error,
},
features: {
swarm: isSwarmEnabled(),
plugins: isPluginsEnabled(),
},
}
})
session.respond('getSystemInfo', async () => {
const [info, version] = await Promise.all([docker.info(), docker.version()])
return { type: 'systemInfo', data: { info, version } }
})
session.respond('getSystemDf', async () => {
const data = await docker.df()
return { type: 'systemDf', data, success: true }
})
/**
* Docker system prune (style "clean unused data").
* @param {{ volumes?: boolean, all?: boolean }} args
*/
session.respond('systemPrune', async (args = {}) => {
const volumes = Boolean(args.volumes)
const all = Boolean(args.all)
let data = null
if (typeof docker.pruneSystem === 'function') {
data = await docker.pruneSystem({ volumes, all })
} else {
// Fallback: dial Engine /system/prune directly
data = await new Promise((resolve, reject) => {
const qs = new URLSearchParams()
if (volumes) qs.set('volumes', '1')
if (all) qs.set('all', '1')
const path = `/system/prune${qs.toString() ? `?${qs}` : ''}`
docker.modem.dial(
{
path,
method: 'POST',
statusCodes: {
200: true,
500: 'server error',
},
},
(err, result) => (err ? reject(err) : resolve(result))
)
})
}
logger.info('systemPrune completed', {
volumes,
all,
peerId: session.id?.slice?.(0, 12),
})
return {
success: true,
type: 'systemPrune',
message: volumes
? 'System pruned (including unused volumes)'
: 'System pruned (containers, networks, images; volumes kept)',
data,
}
})
session.respond('getMetrics', async () => {
return getMetricsSnapshot({ peerCount: peers.size })
})
/**
* Docker stats collection knobs (Settings → Performance).
* Viewer can read; only admin can update (see updateStatsConfig).
*/
session.respond('getStatsConfig', async () => {
return {
success: true,
config: getStatsConfig(),
meta: { limits: STATS_LIMITS, path: getStatsFilePath() },
}
})
session.respond('updateStatsConfig', async (args = {}) => {
const replace = Boolean(args.replace)
const partial =
args.config && typeof args.config === 'object' ? args.config : args
const config = updateStatsConfig(partial, {
replace,
persist: args.persist !== false,
})
return { success: true, config }
})
/**
* Client declares whether it needs live fleet stats for the current UI view.
* Server only opens Docker stats streams while ≥1 peer has active interest.
*/
session.respond(
'setStatsInterest',
async (args = {}) => {
const active = args.active === true || args.active === 1 || args.active === '1'
const view = args.view != null ? String(args.view) : undefined
const config = setPeerStatsInterest(session.id, active, { view })
return { success: true, active, config }
},
{ hot: true }
)
session.respond('listSchedules', async () => {
return { success: true, schedules: listSchedules() }
})
session.respond('upsertSchedule', async (args = {}) => {
const job = upsertSchedule(args)
return { success: true, schedule: job }
})
session.respond('deleteSchedule', async (args = {}) => {
const id = validation.sanitizeString(args.id || '', 64)
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
const ok = deleteSchedule(id)
if (!ok) throw Object.assign(new Error('Schedule not found'), { code: 'INVALID_ARGS' })
return { success: true, id, deleted: true }
})
session.respond('getDockerEvents', async (args) => {
// Snapshot: recent events via stream with timeout (best-effort)
const since = args?.since || Math.floor(Date.now() / 1000) - 300
const until = args?.until || Math.floor(Date.now() / 1000)
const events = []
try {
await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
try {
stream?.destroy?.()
} catch {
// ignore
}
resolve()
}, args?.timeoutMs || 1500)
let stream
docker.getEvents({ since, until }, (err, s) => {
if (err) {
clearTimeout(timer)
reject(err)
return
}
stream = s
stream.on('data', (chunk) => {
try {
const lines = chunk.toString().split('\n').filter(Boolean)
for (const line of lines) {
events.push(JSON.parse(line))
}
} catch {
// ignore partial
}
})
stream.on('end', () => {
clearTimeout(timer)
resolve()
})
stream.on('error', (e) => {
clearTimeout(timer)
reject(e)
})
})
})
} catch (err) {
logger.debug('getDockerEvents snapshot failed', { error: err.message })
}
return {
type: 'dockerEvents',
data: events,
success: true,
note: 'Live events are also pushed as push:dockerEvent when streaming.',
}
})
session.respond('registryLogin', async (args) => {
const username = validation.sanitizeString(args.username, 128)
const password = args.password
const serveraddress = validation.sanitizeString(args.serveraddress || 'https://index.docker.io/v1/', 512)
if (!username || !password) throw new Error('username and password required')
const authconfig = { username, password, serveraddress }
// dockerode checkAuth
try {
await new Promise((resolve, reject) => {
docker.checkAuth(authconfig, (err, response) => {
if (err) reject(err)
else resolve(response)
})
})
} catch (err) {
// Some engines return status in different shapes; still store for pull attempts
logger.warn('checkAuth soft-fail, storing credentials', { error: err.message })
}
setSessionAuthconfig(session, { username, serveraddress, password })
return {
success: true,
message: `Authenticated as ${username}`,
data: { username, serveraddress },
}
})
session.respond('getAuthStatus', async () => {
const auth = getSessionAuthconfig(session)
return {
success: true,
authenticated: Boolean(auth),
username: auth?.username || null,
serveraddress: auth?.serveraddress || null,
credentialId: auth?.credentialId || null,
label: auth?.label || null,
}
})
session.respond('registryLogout', async () => {
clearSessionAuthconfig(session)
return { success: true, message: 'Registry session credentials cleared' }
})
session.respond('browseDirectory', async (args) => {
const requestedPath = args?.path || '/'
if (!validation.isValidDirectoryPath(requestedPath)) {
throw new Error('Invalid directory path')
}
// Default-deny host FS unless PEARDOCK_BROWSE_ROOTS is set (or PEARDOCK_BROWSE_OPEN=1 for legacy open browse)
const roots = (process.env.PEARDOCK_BROWSE_ROOTS || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
const openBrowse =
process.env.PEARDOCK_BROWSE_OPEN === '1' || process.env.PEARDOCK_BROWSE_OPEN === 'true'
const safePath = validation.sanitizeDirectoryPath(requestedPath)
if (roots.length === 0 && !openBrowse) {
throw new Error(
'Host filesystem browse is disabled (default-deny). Set PEARDOCK_BROWSE_ROOTS=/allowed/path or PEARDOCK_BROWSE_OPEN=1'
)
}
if (roots.length > 0) {
const ok = roots.some((root) => safePath === root || safePath.startsWith(root.endsWith('/') ? root : root + '/'))
if (!ok) throw new Error('Path not in allowlisted browse roots')
}
try {
const stats = fs.statSync(safePath)
if (!stats.isDirectory()) {
throw new Error('Not a directory: The specified path is not a directory')
}
} catch (statError) {
if (statError.code === 'ENOENT') {
throw new Error('Directory not found: The specified path does not exist')
}
if (statError.code === 'EACCES') {
throw new Error('Permission denied: You do not have permission to access this directory')
}
throw statError
}
const contents = []
try {
const items = fs.readdirSync(safePath, { withFileTypes: true })
for (const item of items) {
try {
const itemPath = path.join(safePath, item.name)
const stats = fs.statSync(itemPath)
contents.push({
name: item.name,
type: item.isDirectory() ? 'directory' : 'file',
size: stats.size,
modified: stats.mtime.toISOString(),
permissions: stats.mode.toString(8).slice(-3),
})
} catch {
// skip
}
}
return { success: true, contents, path: safePath }
} catch (readError) {
if (readError.code === 'EACCES') {
throw new Error('Permission denied: You do not have permission to access this directory')
}
if (readError.code === 'ENOENT') {
throw new Error('Directory not found: The specified path does not exist')
}
if (readError.code === 'ENOTDIR') {
throw new Error('Not a directory: The specified path is not a directory')
}
throw new Error(`Failed to read directory: ${readError.message}`)
}
})
}
/**
* Auth config for docker pull/push for this session, if any.
* @param {import('../rpc/session.js').PeerSession} session
*/
export function getSessionAuthconfig(session) {
return session.state.get('registryAuth') || registryAuth.get(session.id) || null
}
/**
* @param {import('../rpc/session.js').PeerSession} session
* @param {{ username: string, password: string, serveraddress?: string, credentialId?: string, label?: string }} auth
*/
export function setSessionAuthconfig(session, auth) {
if (!auth?.username || !auth?.password) return
const normalized = {
username: auth.username,
password: auth.password,
serveraddress: auth.serveraddress || 'https://index.docker.io/v1/',
credentialId: auth.credentialId || null,
label: auth.label || null,
}
registryAuth.set(session.id, normalized)
session.state.set('registryAuth', normalized)
}
/**
* @param {import('../rpc/session.js').PeerSession} session
*/
export function clearSessionAuthconfig(session) {
registryAuth.delete(session.id)
session.state.delete('registryAuth')
}