Add shell exec endpoint — POST /exec runs SSH command on any known container host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-14 09:27:44 +00:00
parent 20a8d50084
commit caf81d5f39

View file

@ -121,6 +121,27 @@ async function sshGet(host, cmdArgs) {
} }
} }
async function sshExec(host, command) {
return new Promise(resolve => {
const child = spawn('ssh', [
'-i', SSH_KEY,
'-o', 'StrictHostKeyChecking=no',
'-o', 'ConnectTimeout=5',
`root@${host}`,
'sh', '-c', command,
], { shell: false })
let stdout = '', stderr = ''
child.stdout?.on('data', d => { stdout += d })
child.stderr?.on('data', d => { stderr += d })
const timer = setTimeout(() => {
child.kill()
resolve({ stdout, stderr: stderr + '\nTimed out after 30s', exitCode: -1 })
}, 30_000)
child.on('error', err => { clearTimeout(timer); resolve({ stdout: '', stderr: err.message, exitCode: -1 }) })
child.on('close', code => { clearTimeout(timer); resolve({ stdout, stderr, exitCode: code ?? -1 }) })
})
}
async function getForgejoCommit(repo) { async function getForgejoCommit(repo) {
try { try {
const headers = { Accept: 'application/json' } const headers = { Accept: 'application/json' }
@ -225,7 +246,7 @@ app.get('/health-status', async () => {
if (row.length >= 3) disk = { total: +row[1] * 1024, used: +row[2] * 1024 } if (row.length >= 3) disk = { total: +row[1] * 1024, used: +row[2] * 1024 }
} }
return { name, ...httpResult, ram, disk } return { name, host, ...httpResult, ram, disk }
}) })
) )
}) })
@ -257,6 +278,22 @@ app.post('/deploy/:repo', async (request, reply) => {
return reply.status(202).send({ ok: true, deploying: repo }) return reply.status(202).send({ ok: true, deploying: repo })
}) })
app.post('/exec', async (request, reply) => {
const { host, command } = request.body || {}
if (!host || !command || typeof command !== 'string' || command.length > 2000)
return reply.status(400).send({ error: 'host and command required' })
const knownHosts = new Set([
...Object.values(INFRA_HEALTH).map(v => v.host),
...Object.values(INFRA_DEPLOY).map(v => v.host),
...Object.values(appRegistry).map(v => v.host),
])
if (!knownHosts.has(host)) return reply.status(403).send({ error: 'unknown host' })
const result = await sshExec(host, command)
return result
})
app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => { app.post('/webhook', { config: { rawBody: true } }, async (request, reply) => {
const sig = request.headers['x-hub-signature-256'] const sig = request.headers['x-hub-signature-256']
|| request.headers['x-forgejo-signature'] || request.headers['x-forgejo-signature']