Spaces:
Runtime error
Runtime error
| 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) | |