#!/usr/bin/env node /** * Rebreak GitHub + Gitea Webhook Listener * * Empfängt GitHub- und Gitea-push-Events, validiert HMAC-SHA256-Signatur, * und triggert das deploy.sh Script im Hintergrund. * * Port: 9000 (intern, wird von nginx reverse-proxied) * Secret: GITHUB_WEBHOOK_SECRET in /etc/environment (via Infisical) */ import http from "http"; import crypto from "crypto"; import { spawn } from "child_process"; import { readFileSync } from "fs"; const REPO_ROOT = "/srv/rebreak"; const DEPLOY_SCRIPT = `${REPO_ROOT}/scripts/deploy.sh`; const PORT = 9000; // Lade GITHUB_WEBHOOK_SECRET aus /etc/environment function loadEnvFile() { try { const content = readFileSync("/etc/environment", "utf-8"); for (const line of content.split("\n")) { const match = line.match(/^([^=]+)="?([^"]*)"?$/); if (match) process.env[match[1]] = match[2]; } } catch { // /etc/environment nicht lesbar – Env-Vars müssen anderweitig gesetzt sein } } loadEnvFile(); const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET; if (!WEBHOOK_SECRET) { console.error( "[Webhook] FATAL: GITHUB_WEBHOOK_SECRET nicht in /etc/environment gesetzt", ); process.exit(1); } function verifyGitHubSignature(secret, signature, payload) { const hmac = crypto.createHmac("sha256", secret); hmac.update(payload, "utf-8"); const digest = `sha256=${hmac.digest("hex")}`; try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(digest), ); } catch { return false; } } function verifyGiteaSignature(secret, signature, payload) { const hmac = crypto.createHmac("sha256", secret); hmac.update(payload, "utf-8"); const digest = hmac.digest("hex"); try { return crypto.timingSafeEqual( Buffer.from(signature), Buffer.from(digest), ); } catch { return false; } } // Deploy-Queue: verhindert parallele Builds (OOM-Schutz auf 4 GB RAM) let deployRunning = false; let pendingDeploy = false; function runDeploy() { if (deployRunning) { pendingDeploy = true; console.log("[Webhook] Deploy already running – queued next deploy"); return; } deployRunning = true; console.log("[Webhook] Starting deploy.sh..."); const proc = spawn("bash", [DEPLOY_SCRIPT], { cwd: REPO_ROOT, stdio: ["ignore", "pipe", "pipe"], detached: false, }); proc.stdout.on("data", (d) => process.stdout.write(`[deploy] ${d}`)); proc.stderr.on("data", (d) => process.stderr.write(`[deploy:err] ${d}`)); proc.on("close", (code) => { deployRunning = false; console.log(`[Webhook] deploy.sh exited with code ${code}`); if (pendingDeploy) { pendingDeploy = false; console.log("[Webhook] Running queued deploy..."); runDeploy(); } }); proc.on("error", (err) => { deployRunning = false; console.error("[Webhook] deploy.sh spawn error:", err.message); if (pendingDeploy) { pendingDeploy = false; runDeploy(); } }); } const server = http.createServer((req, res) => { 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", () => { const githubSig = req.headers["x-hub-signature-256"]; const giteaSig = req.headers["x-gitea-signature"]; const event = req.headers["x-github-event"] || req.headers["x-gitea-event"]; if (!githubSig && !giteaSig) { res.writeHead(401); res.end(JSON.stringify({ error: "Missing signature" })); return; } const valid = githubSig ? verifyGitHubSignature(WEBHOOK_SECRET, githubSig, body) : verifyGiteaSignature(WEBHOOK_SECRET, giteaSig, body); if (!valid) { res.writeHead(401); res.end(JSON.stringify({ error: "Invalid signature" })); return; } if (event && event !== "push") { console.log(`[Webhook] Ignoring non-push event: ${event}`); res.writeHead(200); res.end(JSON.stringify({ ok: false, reason: "Not a push event", event })); return; } let payload; try { payload = JSON.parse(body); } catch { res.writeHead(400); res.end(JSON.stringify({ error: "Invalid JSON" })); return; } const branch = payload.ref?.split("/").pop(); if (branch !== "main") { console.log(`[Webhook] Ignoring non-main branch: ${branch}`); res.writeHead(200); res.end(JSON.stringify({ ok: false, reason: "Not main branch", branch })); return; } const changedFiles = (payload.commits || []).flatMap((c) => [ ...(c.added || []), ...(c.modified || []), ...(c.removed || []), ]); console.log( `[Webhook] Push to main by ${payload.pusher?.name} – ${changedFiles.length} files changed`, ); console.log("[Webhook] Changed files:", changedFiles.slice(0, 10)); res.writeHead(202); res.end( JSON.stringify({ ok: true, message: deployRunning ? "Deploy queued (previous still running)" : "Deploy started", files: changedFiles.length, }), ); // Asynchron deployen – Response ist bereits gesendet runDeploy(); }); }); server.listen(PORT, "127.0.0.1", () => { console.log(`[Webhook] Listening on http://127.0.0.1:${PORT}/webhook`); console.log(`[Webhook] Secret loaded: ${WEBHOOK_SECRET ? "yes" : "NO – FATAL"}`); }); process.on("SIGTERM", () => { console.log("[Webhook] SIGTERM received, shutting down"); server.close(() => process.exit(0)); });