340 lines
11 KiB
JavaScript
340 lines
11 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 logger from '../utils/logger.js'
|
|
|
|
/** @type {Map<string, { username: string, serveraddress?: string }>} */
|
|
const registryAuth = new Map()
|
|
|
|
export function registerSystemHandlers(session) {
|
|
session.respond('ping', async () => {
|
|
let dockerOk = false
|
|
let apiVersion = null
|
|
let osType = null
|
|
let error = null
|
|
try {
|
|
const version = await docker.version()
|
|
dockerOk = true
|
|
apiVersion = version.ApiVersion || version.apiVersion || null
|
|
osType = version.Os || version.os || null
|
|
} catch (err) {
|
|
error = err.message
|
|
}
|
|
return {
|
|
success: true,
|
|
pong: Date.now(),
|
|
protocol: PROTOCOL,
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
role: session.role,
|
|
docker: {
|
|
ok: dockerOk,
|
|
apiVersion,
|
|
os: osType,
|
|
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 })
|
|
})
|
|
|
|
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')
|
|
}
|