chahinebrini 97206b7865
Some checks failed
Deploy Staging / Build backend (Nitro) (push) Has been cancelled
Deploy Staging / Deploy zu Hetzner (push) Has been cancelled
ci/woodpecker/push/woodpecker Pipeline was canceled
Support Gitea webhooks in deploy listener
2026-06-18 08:46:20 +02:00

204 lines
5.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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));
});