#!/usr/bin/env node /** * DashCaddy Release Webhook Handler * Receives push webhooks from Gitea, verifies HMAC signature, * and triggers build-release.sh. * * Usage: node webhook-handler.js * Env vars: * WEBHOOK_SECRET — Gitea webhook secret (required) * WEBHOOK_PORT — Listen port (default: 9090) * BUILD_SCRIPT — Path to build script (default: /opt/dashcaddy-release/build-release.sh) */ const http = require('http'); const crypto = require('crypto'); const { spawn } = require('child_process'); const fs = require('fs'); const PORT = parseInt(process.env.WEBHOOK_PORT || '9090', 10); const SECRET = process.env.WEBHOOK_SECRET; const BUILD_SCRIPT = process.env.BUILD_SCRIPT || '/opt/dashcaddy-release/build-release.sh'; const LOG_FILE = '/var/log/dashcaddy-release.log'; if (!SECRET) { console.error('WEBHOOK_SECRET environment variable is required'); process.exit(1); } let buildRunning = false; function log(msg) { const line = `[webhook] ${new Date().toISOString()} ${msg}`; console.log(line); fs.appendFileSync(LOG_FILE, `${line }\n`); } function verifySignature(body, signature) { if (!signature) return false; const hmac = crypto.createHmac('sha256', SECRET).update(body).digest('hex'); return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(hmac), ); } function triggerBuild() { if (buildRunning) { log('Build already in progress, skipping'); return; } buildRunning = true; log('Triggering build...'); const child = spawn('bash', [BUILD_SCRIPT], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, PATH: process.env.PATH }, }); child.stdout.on('data', (data) => { const lines = data.toString().trim().split('\n'); lines.forEach(line => log(`[build] ${line}`)); }); child.stderr.on('data', (data) => { const lines = data.toString().trim().split('\n'); lines.forEach(line => log(`[build:err] ${line}`)); }); child.on('close', (code) => { buildRunning = false; if (code === 0) { log('Build completed successfully'); } else { log(`Build FAILED with exit code ${code}`); } }); } const server = http.createServer((req, res) => { // Health check if (req.method === 'GET' && req.url === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', buildRunning })); return; } // Only accept POST to /webhook if (req.method !== 'POST' || req.url !== '/webhook') { res.writeHead(404); res.end('Not found'); return; } let body = ''; req.on('data', chunk => { body += chunk; }); req.on('end', () => { // Verify Gitea HMAC signature const sig = req.headers['x-gitea-signature'] || ''; if (!verifySignature(body, sig)) { log('Signature verification FAILED'); res.writeHead(403); res.end('Invalid signature'); return; } try { const payload = JSON.parse(body); const ref = payload.ref || ''; const branch = ref.replace('refs/heads/', ''); if (branch !== 'main') { log(`Ignoring push to ${branch} (not main)`); res.writeHead(200); res.end('Ignored (not main branch)'); return; } const pusher = payload.pusher?.login || 'unknown'; const commits = payload.commits?.length || 0; log(`Push to main by ${pusher}: ${commits} commit(s)`); triggerBuild(); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ accepted: true })); } catch (e) { log(`Failed to parse webhook payload: ${ e.message}`); res.writeHead(400); res.end('Invalid payload'); } }); }); server.listen(PORT, '0.0.0.0', () => { log(`Webhook handler listening on 127.0.0.1:${PORT}`); });