From caf81d5f39c55a6ac9745ca0a9a5cb5449e4daa0 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 14 Jul 2026 09:27:44 +0000 Subject: [PATCH] =?UTF-8?q?Add=20shell=20exec=20endpoint=20=E2=80=94=20POS?= =?UTF-8?q?T=20/exec=20runs=20SSH=20command=20on=20any=20known=20container?= =?UTF-8?q?=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- updater/src/index.js | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/updater/src/index.js b/updater/src/index.js index df492ed..8a534be 100644 --- a/updater/src/index.js +++ b/updater/src/index.js @@ -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) { try { 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 } } - 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 }) }) +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) => { const sig = request.headers['x-hub-signature-256'] || request.headers['x-forgejo-signature']