#!/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 // ============================================================================= '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'); let deploying = false; 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) { if (!SECRET) { log('WARN: WEBHOOK_SECRET not set — skipping signature check'); return true; } const hmac = crypto.createHmac('sha256', SECRET); hmac.update(payload); const expected = 'sha256=' + hmac.digest('hex'); try { return crypto.timingSafeEqual( Buffer.from(expected, 'utf8'), Buffer.from(signature, 'utf8') ); } catch (_) { return false; } } 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 })); 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 deploy: invalid HMAC signature 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'); return; } if (deploying) { log('Deploy already in progress — request queued'); res.writeHead(202).end('Deploy already in progress'); return; } const pusher = payload.pusher ? payload.pusher.login || payload.pusher.name : 'unknown'; const commit = payload.after ? payload.after.slice(0, 8) : 'unknown'; log(`Deploy triggered — pusher: ${pusher}, commit: ${commit}`); res.writeHead(202).end('Deploy started'); deploying = true; execFile('bash', [DEPLOY_SCRIPT], { timeout: 600_000 }, (err, stdout, stderr) => { deploying = false; if (err) { log(`Deploy FAILED: ${err.message}`); } else { log(`Deploy succeeded (commit ${commit})`); } }); }); }); server.listen(PORT, HOST, () => { log(`Webhook receiver listening on ${HOST}:${PORT}`); log(`Deploy script: ${DEPLOY_SCRIPT}`); log(`HMAC secret: ${SECRET ? 'configured' : 'NOT SET (insecure)'}`); }); process.on('SIGTERM', () => { log('SIGTERM received — shutting down'); process.exit(0); }); process.on('SIGINT', () => { log('SIGINT received — shutting down'); process.exit(0); });