#!/usr/bin/env node // ============================================================================= // /opt/timmy-tower/webhook.js // Lightweight webhook receiver: validates Gitea HMAC signature, runs deploy.sh // Runs as systemd service: timmy-deploy-hook // // Security: fails closed — if WEBHOOK_SECRET is not set, the server refuses // to start rather than accepting unsigned requests. // ============================================================================= 'use strict'; const http = require('http'); const crypto = require('crypto'); const { execFile } = require('child_process'); const fs = require('fs'); const path = require('path'); const PORT = 9000; const HOST = '127.0.0.1'; const SECRET = process.env.WEBHOOK_SECRET || ''; const DEPLOY_SCRIPT = path.join(__dirname, 'deploy.sh'); const LOG_FILE = path.join(__dirname, 'deploy.log'); // Fail closed — do not start without a secret if (!SECRET) { process.stderr.write( '[webhook] FATAL: WEBHOOK_SECRET environment variable is not set.\n' + ' Set it in /opt/timmy-tower/.env and restart the service.\n' ); process.exit(1); } // Single-slot deploy queue: tracks current deploy + whether another is pending let deploying = false; let pendingDeploy = null; // { pusher, commit } of the queued request, or null function log(msg) { const line = `[${new Date().toISOString()}] [webhook] ${msg}\n`; process.stdout.write(line); try { fs.appendFileSync(LOG_FILE, line); } catch (_) {} } function verifySignature(payload, signature) { const hmac = crypto.createHmac('sha256', SECRET); hmac.update(payload); const hexDigest = hmac.digest('hex'); // Gitea sends raw hex in X-Gitea-Signature; GitHub/others send sha256= // Normalise both sides to raw hex before comparing const incomingHex = signature.startsWith('sha256=') ? signature.slice(7) : signature; if (!incomingHex || Buffer.byteLength(hexDigest) !== Buffer.byteLength(incomingHex)) return false; try { return crypto.timingSafeEqual( Buffer.from(hexDigest, 'utf8'), Buffer.from(incomingHex, 'utf8') ); } catch (_) { return false; } } function runDeploy(pusher, commit) { deploying = true; log(`Running deploy — pusher: ${pusher}, commit: ${commit}`); execFile('bash', [DEPLOY_SCRIPT], { timeout: 600_000 }, (err) => { deploying = false; if (err) { log(`Deploy FAILED: ${err.message}`); } else { log(`Deploy succeeded (commit ${commit})`); } // If another push arrived while we were deploying, run it now if (pendingDeploy) { const next = pendingDeploy; pendingDeploy = null; log(`Running queued deploy — pusher: ${next.pusher}, commit: ${next.commit}`); runDeploy(next.pusher, next.commit); } }); } const server = http.createServer((req, res) => { if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, deploying, queued: !!pendingDeploy })); return; } if (req.method !== 'POST' || req.url !== '/deploy') { res.writeHead(404).end('Not found'); return; } const chunks = []; req.on('data', chunk => chunks.push(chunk)); req.on('end', () => { const body = Buffer.concat(chunks); const sig = req.headers['x-gitea-signature'] || req.headers['x-hub-signature-256'] || ''; if (!verifySignature(body, sig)) { log(`Rejected: invalid HMAC from ${req.socket.remoteAddress}`); res.writeHead(401).end('Unauthorized'); return; } let payload = {}; try { payload = JSON.parse(body.toString()); } catch (_) {} const branch = (payload.ref || '').replace('refs/heads/', ''); if (branch && branch !== 'main') { log(`Skipping push to non-main branch: ${branch}`); res.writeHead(200).end('Skipped non-main branch'); return; } const pusher = payload.pusher ? (payload.pusher.login || payload.pusher.name || 'unknown') : 'unknown'; const commit = payload.after ? payload.after.slice(0, 8) : 'unknown'; if (deploying) { // Queue the latest push — overwrite any previous queued one pendingDeploy = { pusher, commit }; log(`Deploy in progress — queued commit ${commit} (will run after current deploy)`); res.writeHead(202).end('Queued — will deploy after current deploy completes'); return; } res.writeHead(202).end('Deploy started'); runDeploy(pusher, commit); }); }); server.listen(PORT, HOST, () => { log(`Webhook receiver listening on ${HOST}:${PORT}`); log(`Deploy script: ${DEPLOY_SCRIPT}`); log('HMAC secret: configured'); }); process.on('SIGTERM', () => { log('SIGTERM — shutting down'); process.exit(0); }); process.on('SIGINT', () => { log('SIGINT — shutting down'); process.exit(0); });