#!/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)