import http from 'node:http' import { execFile } from 'node:child_process' import { createHmac, timingSafeEqual } from 'node:crypto' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { config } from 'dotenv' import { env } from '../services/env.js' const __dirname = dirname(fileURLToPath(import.meta.url)) // Shares backend/.env with the audit endpoint rather than keeping a second // secrets file - one chmod 600 file to manage, not two. config({ path: join(__dirname, '..', '.env') }) const PORT = Number(env('WEBHOOK_PORT', '3002')) const SECRET = env('WEBHOOK_SECRET') const BRANCH = env('WEBHOOK_BRANCH', 'refs/heads/master') if (!SECRET) { console.error('WEBHOOK_SECRET is not configured. Refusing to start.') process.exit(1) } // Only one deploy runs at a time. Two pushes seconds apart would otherwise // race two concurrent `git reset --hard` + npm install in the same checkout. let deploying = false /** * Gitea/Forgejo send `X-Gitea-Signature: `. * GitHub-compatible integrations send `X-Hub-Signature-256: sha256=`. * Accept either so this works regardless of which the instance uses. */ function verifySignature(rawBody, header) { if (!header) return false const provided = header.startsWith('sha256=') ? header.slice(7) : header const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex') const a = Buffer.from(provided) const b = Buffer.from(expected) // Length check first: timingSafeEqual throws on mismatched lengths rather // than returning false. return a.length === b.length && timingSafeEqual(a, b) } function runDeploy() { if (deploying) { console.log('Deploy already in progress, ignoring this push.') return } deploying = true console.log('Deploy started.') // No shell, no string interpolation - argv is a fixed one-element array. execFile( 'bash', ['deploy.sh'], { cwd: __dirname, timeout: 5 * 60 * 1000 }, (error, stdout, stderr) => { deploying = false if (error) { console.error('Deploy failed:', error.message) if (stderr) console.error(stderr.trim()) return } console.log(stdout.trim() || 'Deploy finished.') } ) } // Extracted so it can back two separate http.Server instances - see the // dual-listen comment below. function requestHandler(req, res) { if (req.method !== 'POST' || req.url !== '/deploy-webhook') { res.writeHead(404).end() return } const chunks = [] let size = 0 req.on('data', (chunk) => { size += chunk.length // A webhook payload is a few KB. Anything past 1 MB is not Gitea. if (size > 1_000_000) { req.destroy() return } chunks.push(chunk) }) req.on('end', () => { const rawBody = Buffer.concat(chunks) const signatureHeader = req.headers['x-gitea-signature'] ?? req.headers['x-hub-signature-256'] const signature = Array.isArray(signatureHeader) ? signatureHeader[0] : signatureHeader if (!verifySignature(rawBody, signature)) { console.warn('Webhook signature mismatch - ignoring request.') res.writeHead(401).end() return } let payload try { payload = JSON.parse(rawBody.toString('utf8')) } catch { res.writeHead(400).end('Invalid JSON.') return } if (payload.ref !== BRANCH) { res.writeHead(200).end(`Ignored (push to ${payload.ref}, watching ${BRANCH}).`) return } // Respond before deploying: Gitea has its own webhook timeout, and the // deploy (npm install + restart) can take longer than that. res.writeHead(202).end('Deploy queued.') runDeploy() }) } http.createServer(requestHandler).listen(PORT, '127.0.0.1', () => { console.log(`Deploy webhook listening on 127.0.0.1:${PORT}`) }) // Optional second listener for when Gitea/Forgejo (or the reverse proxy in // front of it, e.g. Caddy) runs in Docker: 127.0.0.1 inside that container is // the container's own loopback, not this host's, so it cannot reach the // listener above. Binds only the Docker bridge's gateway address rather than // 0.0.0.0, so this stays unreachable from the VPS's public interface. See // the matching comment in ../server.js for how to find the right value. const dockerBridgeHost = env('DOCKER_BRIDGE_HOST') if (dockerBridgeHost) { http.createServer(requestHandler).listen(PORT, dockerBridgeHost, () => { console.log(`Deploy webhook also listening on ${dockerBridgeHost}:${PORT} (Docker bridge)`) }) }