diff --git a/updater/src/index.js b/updater/src/index.js index 139ad37..7108a60 100644 --- a/updater/src/index.js +++ b/updater/src/index.js @@ -1,10 +1,27 @@ import Fastify from 'fastify' import crypto from 'crypto' -import { exec, execSync } from 'child_process' +import { exec, spawn } from 'child_process' import { promisify } from 'util' const execAsync = promisify(exec) +function spawnAsync(file, args, opts = {}) { + return new Promise((resolve, reject) => { + const child = spawn(file, args, { ...opts, shell: false }) + let stdout = '', stderr = '' + child.stdout?.on('data', d => { stdout += d }) + child.stderr?.on('data', d => { stderr += d }) + child.on('error', reject) + child.on('close', code => { + if (code === 0) resolve({ stdout, stderr }) + else reject(new Error(`Exit ${code}: ${stderr.slice(-500)}`)) + }) + if (opts.timeout) { + setTimeout(() => { child.kill(); reject(new Error('Timeout')) }, opts.timeout) + } + }) +} + const app = Fastify({ logger: true }) // Capture the exact request bytes for webhook HMAC verification — @@ -89,12 +106,15 @@ function verifySignature(body, signature) { return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)) } -async function sshGet(host, cmd) { +async function sshGet(host, cmdArgs) { try { - const { stdout } = await execAsync( - `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o ConnectTimeout=2 root@${host} "${cmd}"`, - { timeout: 8_000 } - ) + const { stdout } = await spawnAsync('ssh', [ + '-i', SSH_KEY, + '-o', 'StrictHostKeyChecking=no', + '-o', 'ConnectTimeout=2', + `root@${host}`, + ...cmdArgs, + ], { timeout: 8_000 }) return stdout.trim() } catch { return null @@ -119,9 +139,13 @@ async function getForgejoCommit(repo) { async function refreshStatus(repo) { const target = getDeployTarget(repo) if (!target) return null + if (!/^\/opt\/[a-z0-9-]+$/.test(target.path)) { + app.log.error(`Rejecting suspicious path: ${target.path}`) + return null + } const [currentRaw, latest] = await Promise.all([ - sshGet(target.host, `git -C ${target.path} log -1 --format='%H|%cI' HEAD 2>/dev/null || echo not-deployed`), + sshGet(target.host, ['git', '-C', target.path, 'log', '-1', '--format=%H|%cI', 'HEAD']), getForgejoCommit(repo), ]) @@ -133,7 +157,7 @@ async function refreshStatus(repo) { const entry = { repo, host: target.host, - currentCommit: deployed ? currentSha.slice(0, 12) : (currentRaw || 'unreachable'), + currentCommit: deployed ? currentSha.slice(0, 12) : 'not-deployed', currentCommitAt: currentTimestamp || null, latestCommit: latest ? latest.sha.slice(0, 12) : 'unknown', latestCommitAt: latest?.timestamp || null, @@ -146,15 +170,24 @@ async function refreshStatus(repo) { } async function deploy(target, repoName) { - const cmd = `ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no root@${target.host} "cd ${target.path} && git pull && docker compose up -d --build"` + if (!/^\/opt\/[a-z0-9-]+$/.test(target.path)) { + app.log.error(`Rejecting suspicious path for deploy: ${target.path}`) + return + } + const remoteCmd = `cd ${target.path} && git pull && docker compose up -d --build` const entry = { repo: repoName, host: target.host, started: new Date().toISOString(), status: 'running' } deployLog.unshift(entry) if (deployLog.length > 50) deployLog.pop() try { - const out = execSync(cmd, { timeout: 300_000, encoding: 'utf8' }) + const { stdout } = await spawnAsync('ssh', [ + '-i', SSH_KEY, + '-o', 'StrictHostKeyChecking=no', + `root@${target.host}`, + 'sh', '-c', remoteCmd, + ], { timeout: 300_000 }) entry.status = 'success' - entry.output = out.slice(-500) + entry.output = stdout.slice(-500) app.log.info(`Deploy ${repoName} → success`) delete statusCache[repoName] } catch (err) {