mission-control/ops/mc-provisioner-daemon.js

303 lines
10 KiB
JavaScript

#!/usr/bin/env node
const fs = require('fs')
const net = require('net')
const { spawn } = require('child_process')
const path = require('path')
const SOCKET_PATH = process.env.MC_PROVISIONER_SOCKET || '/run/mc-provisioner.sock'
const TOKEN = String(process.env.MC_PROVISIONER_TOKEN || '')
const SOCKET_GROUP = process.env.MC_PROVISIONER_GROUP || 'openclaw'
const REPO_ROOT = process.env.MISSION_CONTROL_REPO_ROOT || path.resolve(__dirname, '..')
const DATA_DIR = process.env.MISSION_CONTROL_DATA_DIR || path.join(REPO_ROOT, '.data')
const TENANT_HOME_ROOT = String(process.env.MC_TENANT_HOME_ROOT || '/home').trim() || '/home'
const TENANT_WORKSPACE_DIRNAME = String(process.env.MC_TENANT_WORKSPACE_DIRNAME || 'workspace').trim() || 'workspace'
const TEMPLATE_OPENCLAW_JSON = process.env.MC_SUPER_TEMPLATE_OPENCLAW_JSON || (process.env.OPENCLAW_HOME ? path.join(process.env.OPENCLAW_HOME, 'openclaw.json') : '')
const GATEWAY_SYSTEMD_TEMPLATE = path.join(REPO_ROOT, 'ops', 'templates', 'openclaw-gateway@.service')
if (!TOKEN) {
console.error('MC_PROVISIONER_TOKEN is required')
process.exit(1)
}
function escapeRegExp(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function isSafeUser(user) {
return /^[a-z_][a-z0-9_-]{1,30}$/.test(user)
}
function pathJoinPosix(...parts) {
// Use posix paths for allowlisting because provisioner executes linux commands.
const cleaned = parts.map((p) => String(p || '').replace(/\/+$/g, ''))
return path.posix.join(...cleaned)
}
function isSafeHomePath(path, user, suffix) {
return path === pathJoinPosix(TENANT_HOME_ROOT, user, suffix)
}
function validateCommand(command, args) {
const cmd = String(command || '').split('/').pop()
if (!command || !Array.isArray(args)) return 'Invalid command payload'
if (cmd === 'useradd') {
if (args.length !== 4) return 'useradd argument mismatch'
const [a, b, shell, user] = args
if (a !== '-m' || b !== '-s' || shell !== '/bin/bash') return 'useradd args not allowed'
if (!isSafeUser(user)) return 'Invalid username'
return null
}
if (cmd === 'install') {
if (args.length !== 8) return 'install argument mismatch'
const [d, mFlag, mode, oFlag, userA, gFlag, userB, target] = args
if (d !== '-d' || mFlag !== '-m' || oFlag !== '-o' || gFlag !== '-g') return 'install args not allowed'
if (!['0750', '0700'].includes(mode)) return 'install mode not allowed'
const isRootOwned = userA === 'root' && userB === 'root'
const isTenantOwned = isSafeUser(userA) && isSafeUser(userB) && userA === userB
if (!isRootOwned && !isTenantOwned) return 'install ownership not allowed'
const openclawPath = pathJoinPosix(TENANT_HOME_ROOT, userA, '.openclaw')
const workspacePath = pathJoinPosix(TENANT_HOME_ROOT, userA, TENANT_WORKSPACE_DIRNAME)
if (isRootOwned && target === '/etc/openclaw-tenants') return null
if (![openclawPath, workspacePath].includes(target)) return 'install path not allowed'
return null
}
if (cmd === 'cp') {
if (args.length !== 3) return 'cp argument mismatch'
const [flag, source, target] = args
if (!['-n', '-f'].includes(flag)) return 'cp flag not allowed'
if (TEMPLATE_OPENCLAW_JSON && source === TEMPLATE_OPENCLAW_JSON) {
if (flag !== '-n') return 'openclaw config copy must use -n'
const homeRootRe = escapeRegExp(pathJoinPosix(TENANT_HOME_ROOT))
const match = new RegExp(`^${homeRootRe}\\/([a-z_][a-z0-9_-]{1,30})\\/\\.openclaw\\/openclaw\\.json$`).exec(target)
if (!match) return 'cp target not allowed'
return null
}
if (source === GATEWAY_SYSTEMD_TEMPLATE) {
if (flag !== '-n') return 'template copy must use -n'
if (target !== '/etc/systemd/system/openclaw-gateway@.service') return 'gateway template target not allowed'
return null
}
const provisionerEnvRe = new RegExp(`^${escapeRegExp(path.join(DATA_DIR, 'provisioner'))}\\/([a-z0-9-]{3,32})\\/openclaw-gateway\\.env$`)
if (provisionerEnvRe.test(source)) {
if (flag !== '-f') return 'tenant env copy must use -f'
if (!/^\/etc\/openclaw-tenants\/[a-z_][a-z0-9_-]{1,30}\.env$/.test(target)) return 'tenant env target not allowed'
return null
}
return 'cp source not allowed'
}
if (cmd === 'chown') {
if (args.length !== 3) return 'chown argument mismatch'
const [rFlag, owner, target] = args
if (rFlag !== '-R') return 'chown must use -R'
const [userA, userB] = owner.split(':')
if (!isSafeUser(userA) || userA !== userB) return 'chown owner not allowed'
if (target !== pathJoinPosix(TENANT_HOME_ROOT, userA)) return 'chown target not allowed'
return null
}
if (cmd === 'rm') {
if (args.length !== 2) return 'rm argument mismatch'
const [flag, target] = args
if (flag === '-f') {
if (!/^\/etc\/openclaw-tenants\/[a-z_][a-z0-9_-]{1,30}\.env$/.test(target)) {
return 'rm -f target not allowed'
}
return null
}
if (flag === '-rf') {
const homeRootRe = escapeRegExp(pathJoinPosix(TENANT_HOME_ROOT))
const ws = escapeRegExp(TENANT_WORKSPACE_DIRNAME)
const match = new RegExp(`^${homeRootRe}\\/([a-z_][a-z0-9_-]{1,30})\\/(\\.openclaw|${ws})$`).exec(target)
if (!match) return 'rm -rf target not allowed'
return null
}
return 'rm flag not allowed'
}
if (cmd === 'userdel') {
if (args.length !== 2) return 'userdel argument mismatch'
if (args[0] !== '-r') return 'userdel must use -r'
if (!isSafeUser(args[1])) return 'Invalid username'
return null
}
if (cmd === 'true') {
if (args.length !== 0) return 'true takes no args'
return null
}
if (cmd === 'systemctl') {
if (args.length === 1 && args[0] === 'daemon-reload') return null
if (args.length === 3 && args[0] === 'enable' && args[1] === '--now') {
if (/^openclaw-gateway@[a-z_][a-z0-9_-]{1,30}\.service$/.test(args[2])) return null
return 'systemctl service name not allowed'
}
if (args.length === 3 && args[0] === 'disable' && args[1] === '--now') {
if (/^openclaw-gateway@[a-z_][a-z0-9_-]{1,30}\.service$/.test(args[2])) return null
return 'systemctl service name not allowed'
}
return 'systemctl args not allowed'
}
return `Command not allowlisted: ${command}`
}
function run(command, args, timeoutMs) {
return new Promise((resolve) => {
const child = spawn(command, args, { shell: false })
let stdout = ''
let stderr = ''
let timedOut = false
const timer = setTimeout(() => {
timedOut = true
child.kill('SIGKILL')
}, Math.max(1000, Number(timeoutMs || 10000)))
child.stdout.on('data', (d) => { stdout += d.toString('utf8') })
child.stderr.on('data', (d) => { stderr += d.toString('utf8') })
child.on('close', (code) => {
clearTimeout(timer)
resolve({
ok: !timedOut && code === 0,
code: timedOut ? 124 : code,
stdout,
stderr: timedOut ? `${stderr}\nTimed out` : stderr,
})
})
child.on('error', (err) => {
clearTimeout(timer)
resolve({ ok: false, code: 1, stdout, stderr: `${stderr}\n${err.message}` })
})
})
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async function runWithRetry(command, args, timeoutMs) {
const cmd = String(command || '').split('/').pop()
const maxAttempts = cmd === 'useradd' ? 6 : 1
let last = null
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const result = await run(command, args, timeoutMs)
last = result
if (result.ok) return result
const transientLock =
cmd === 'useradd' &&
/cannot lock \/etc\/passwd/i.test(String(result.stderr || ''))
if (!transientLock || attempt === maxAttempts) {
return result
}
await sleep(800)
}
return last || { ok: false, code: 1, stdout: '', stderr: 'Unknown execution failure' }
}
function writeResp(socket, obj) {
try {
socket.write(JSON.stringify(obj) + '\n')
} catch {
// no-op
} finally {
socket.end()
}
}
if (fs.existsSync(SOCKET_PATH)) {
try {
fs.unlinkSync(SOCKET_PATH)
} catch (err) {
console.error(`Failed to remove stale socket ${SOCKET_PATH}:`, err.message)
process.exit(1)
}
}
const server = net.createServer((socket) => {
let buf = ''
socket.on('data', async (chunk) => {
buf += chunk.toString('utf8')
const idx = buf.indexOf('\n')
if (idx === -1) return
const line = buf.slice(0, idx)
buf = buf.slice(idx + 1)
let req
try {
req = JSON.parse(line)
} catch {
writeResp(socket, { ok: false, error: 'Invalid JSON' })
return
}
if (!req || req.token !== TOKEN) {
writeResp(socket, { ok: false, error: 'Unauthorized' })
return
}
const command = String(req.command || '')
const args = Array.isArray(req.args) ? req.args.map((a) => String(a)) : []
const dryRun = !!req.dryRun
const timeoutMs = Number(req.timeoutMs || 10000)
const validationErr = validateCommand(command, args)
if (validationErr) {
writeResp(socket, { ok: false, error: validationErr })
return
}
if (dryRun) {
writeResp(socket, { ok: true, code: 0, stdout: '', stderr: '', skipped: true })
return
}
const result = await runWithRetry(command, args, timeoutMs)
if (!result.ok) {
writeResp(socket, { ok: false, code: result.code, stdout: result.stdout, stderr: result.stderr, error: `Command failed: ${command}` })
return
}
writeResp(socket, { ok: true, code: result.code, stdout: result.stdout, stderr: result.stderr, skipped: false })
})
})
server.listen(SOCKET_PATH, () => {
fs.chmodSync(SOCKET_PATH, 0o660)
try {
const group = require('child_process').execSync(`getent group ${SOCKET_GROUP} | cut -d: -f3`).toString('utf8').trim()
const gid = Number(group)
if (Number.isInteger(gid)) {
fs.chownSync(SOCKET_PATH, 0, gid)
}
} catch {
// fallback: keep root:root
}
console.log(`mc-provisioner listening on ${SOCKET_PATH}`)
})
function shutdown() {
try { server.close() } catch {}
try { fs.unlinkSync(SOCKET_PATH) } catch {}
process.exit(0)
}
process.on('SIGINT', shutdown)
process.on('SIGTERM', shutdown)