204 lines
5.5 KiB
JavaScript
204 lines
5.5 KiB
JavaScript
#!/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));
|
||
});
|