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:
parent
20a8d50084
commit
caf81d5f39
1 changed files with 38 additions and 1 deletions
|
|
@ -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']
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue