#!/usr/bin/env python3 """ Empfaengt den Codeberg-Webhook fuer fds2610/monitor und stoesst bei einem Push auf den konfigurierten Branch (Standard: main, d.h. nach jedem manuell gemergten PR) ein Update per update.sh an. Laeuft direkt auf dem Host (nicht im Docker-Compose-Stack), weil update.sh selbst "docker compose" auf dem Host aufruft. Konfiguration ueber Umgebungsvariablen: MONITOR_DEPLOY_WEBHOOK_SECRET - Pflicht. HMAC-Secret, dasselbe wie beim Einrichten des Webhooks im Codeberg-Repo (Einstellungen -> Webhooks -> Forgejo). Ohne gesetztes Secret weist der Endpunkt jede Anfrage mit 401 ab. MONITOR_DEPLOY_WEBHOOK_PORT - Port, auf dem gelauscht wird (Default: 9099). MONITOR_DEPLOY_WEBHOOK_BRANCH - Branch, dessen Push ein Update ausloest (Default: main). MONITOR_DEPLOY_UPDATE_SCRIPT - Pfad zu update.sh (Default: update.sh neben diesem Skript). MONITOR_DEPLOY_LOG_FILE - Pfad, in den die update.sh-Ausgabe geschrieben wird (Default: deploy-webhook.log neben diesem Skript). Siehe README_UPDATE.md fuer die Einrichtung auf einem Produktionsserver. """ from __future__ import annotations import hashlib import hmac import json import os import subprocess import sys import threading from datetime import datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path REPO_DIR = Path(__file__).resolve().parent SIGNATURE_HEADERS = ("X-Forgejo-Signature", "X-Gitea-Signature") # Verhindert ueberlappende update.sh-Laeufe, falls mehrere Webhook-Zustellungen kurz # hintereinander eintreffen (z.B. Codeberg-Retry). do_POST versucht den Lock nicht-blockierend # zu erwerben, run_update() gibt ihn im finally-Block wieder frei. update_lock = threading.Lock() def verify_signature(secret: str, body: bytes, headers) -> bool: expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest() for name in SIGNATURE_HEADERS: provided = headers.get(name) if provided and hmac.compare_digest(expected, provided.strip().lower()): return True return False def run_update(update_script: Path, log_file: Path) -> None: try: with open(log_file, "ab") as log: log.write( f"\n--- update.sh gestartet ({datetime.now().isoformat()}) ---\n".encode("utf-8") ) log.flush() try: subprocess.run( [str(update_script)], cwd=str(update_script.parent), stdout=log, stderr=subprocess.STDOUT, check=False, ) except OSError as exc: log.write(f"--- update.sh konnte nicht gestartet werden: {exc} ---\n".encode("utf-8")) finally: update_lock.release() class Handler(BaseHTTPRequestHandler): server_version = "monitor-deploy-webhook/1" def log_message(self, fmt, *args): sys.stderr.write("[deploy-webhook] %s - %s\n" % (self.address_string(), fmt % args)) def do_POST(self): secret = os.environ.get("MONITOR_DEPLOY_WEBHOOK_SECRET", "") length = int(self.headers.get("Content-Length", "0") or "0") body = self.rfile.read(length) if length else b"" if not secret: self._respond(401, {"error": "kein MONITOR_DEPLOY_WEBHOOK_SECRET konfiguriert"}) return if not verify_signature(secret, body, self.headers): self._respond(401, {"error": "Signatur fehlt oder ist ungueltig"}) return event = self.headers.get("X-Forgejo-Event") or self.headers.get("X-Gitea-Event") or "" if event == "ping": self._respond(200, {"status": "pong"}) return try: payload = json.loads(body or b"{}") except json.JSONDecodeError: payload = {} branch = os.environ.get("MONITOR_DEPLOY_WEBHOOK_BRANCH", "main") ref = payload.get("ref", "") if event != "push" or ref != f"refs/heads/{branch}": self._respond(200, {"status": "ignoriert"}) return update_script = Path( os.environ.get("MONITOR_DEPLOY_UPDATE_SCRIPT", str(REPO_DIR / "update.sh")) ) log_file = Path( os.environ.get("MONITOR_DEPLOY_LOG_FILE", str(REPO_DIR / "deploy-webhook.log")) ) if not update_lock.acquire(blocking=False): self._respond(202, {"status": "update laeuft bereits"}) return threading.Thread(target=run_update, args=(update_script, log_file), daemon=True).start() self._respond(202, {"status": "update gestartet"}) def _respond(self, status: int, payload: dict) -> None: body = json.dumps(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def main() -> None: if not os.environ.get("MONITOR_DEPLOY_WEBHOOK_SECRET"): print( "[deploy-webhook] WARNUNG: MONITOR_DEPLOY_WEBHOOK_SECRET ist nicht gesetzt - " "der Endpunkt weist jede Anfrage mit 401 ab.", file=sys.stderr, ) port = int(os.environ.get("MONITOR_DEPLOY_WEBHOOK_PORT", "9099")) server = ThreadingHTTPServer(("0.0.0.0", port), Handler) print(f"[deploy-webhook] lausche auf Port {port}", file=sys.stderr) server.serve_forever() if __name__ == "__main__": main()