/** * 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} */ 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') }