From 6bad9b470c37f0bb58c54f9a0e7f58d8d306dc43 Mon Sep 17 00:00:00 2001 From: fds2610-bot Date: Tue, 4 Aug 2026 12:20:41 +0200 Subject: [PATCH] Auto-Update per Push-Webhook fuer Produktionsserver einrichten deploy-webhook.py laeuft auf dem Host, prueft eingehende Codeberg-Webhooks per HMAC-Signatur (analog zu POST /api/v1/webhooks/codeberg) und stoesst bei einem Push auf main - also nach jedem manuell gemergten PR - das bereits vorhandene update.sh an. README_UPDATE.md dokumentiert die dafuer noetigen manuellen Vorarbeiten auf dem Server (Checkout, Docker, systemd-Unit, Webhook-Einrichtung). Schliesst #36 --- .env.example | 13 +++ .gitignore | 2 + README.md | 7 ++ README_UPDATE.md | 94 +++++++++++++++++ deploy-webhook.py | 145 ++++++++++++++++++++++++++ deploy/monitor-deploy-webhook.service | 22 ++++ 6 files changed, 283 insertions(+) create mode 100644 README_UPDATE.md create mode 100755 deploy-webhook.py create mode 100644 deploy/monitor-deploy-webhook.service diff --git a/.env.example b/.env.example index dd447c1..fd314ce 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,16 @@ MONITOR_SYNC_INTERVAL=600 # Der Endpunkt ist der einzige, den Codeberg von aussen erreichen muss - Proxy und Firewall # muessen POST /api/v1/webhooks/codeberg also aus dem Internet durchlassen. CODEBERG_WEBHOOK_SECRET= + +# Auto-Update auf einem Produktionsserver (siehe README_UPDATE.md): deploy-webhook.py laeuft +# dort auf dem Host (nicht im Compose-Stack) und stoesst bei einem Push auf +# MONITOR_DEPLOY_WEBHOOK_BRANCH (z.B. nach einem manuell gemergten PR) update.sh an. Fuer die +# lokale Entwicklungsumgebung hier ohne Bedeutung. +# Secret des Deploy-Webhooks (Repo -> Einstellungen -> Webhooks -> Forgejo, Ziel-URL +# , Event "Push"). Ohne gesetztes Secret weist deploy-webhook.py jede +# Anfrage mit 401 ab. Erzeugen z.B. mit: openssl rand -hex 32 +MONITOR_DEPLOY_WEBHOOK_SECRET= +# Port, auf dem deploy-webhook.py lauscht. +MONITOR_DEPLOY_WEBHOOK_PORT=9099 +# Branch, dessen Push ein Update ausloest. +MONITOR_DEPLOY_WEBHOOK_BRANCH=main diff --git a/.gitignore b/.gitignore index 1d4fa4e..487ea6b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ api/vendor/ api/data/ .env node_modules/ +__pycache__/ +deploy-webhook.log diff --git a/README.md b/README.md index 54c161b..b1e8882 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,10 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und Webhook eingerichtet ist, innerhalb von Sekunden nach jeder Issue-Änderung (`POST /api/v1/webhooks/codeberg`, HMAC-signiert per `CODEBERG_WEBHOOK_SECRET`). Siehe [API.md](API.md) Abschnitt „Codeberg-Sync" — dort steht auch die Einrichtung im Codeberg-Repo. +- Auto-Update auf einem Produktionsserver: Sobald ein PR nach `main` gemergt wird, kann + `deploy-webhook.py` denselben `update.sh` anstoßen, den auch ein manuelles Update aufruft + (Push-Webhook, HMAC-signiert per `MONITOR_DEPLOY_WEBHOOK_SECRET`) — siehe + [README_UPDATE.md](README_UPDATE.md). - Weiterhin: klassische Ping-Überwachung (`check_fds.sh`, `hosts.conf`, `mail_fds.py`) als eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor @@ -46,6 +50,9 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und - `docker-compose.yml` — komplette Entwicklungsumgebung (siehe unten) - `update.sh` — holt die neueste Version vom konfigurierten Git-Remote und aktualisiert die Docker-Compose-Umgebung (Abhängigkeiten, Images, Dienste, Tests) +- `deploy-webhook.py`, `deploy/` — löst `update.sh` auf einem Produktionsserver automatisch + aus, sobald ein PR nach `main` gemergt wird (Push-Webhook); siehe + [README_UPDATE.md](README_UPDATE.md) - `register-agent.sh` — Hilfsskript, um einen neuen Agenten per `POST /api/v1/agents` zu registrieren (siehe [API.md](API.md) Abschnitt "Agenten") diff --git a/README_UPDATE.md b/README_UPDATE.md new file mode 100644 index 0000000..7d6f628 --- /dev/null +++ b/README_UPDATE.md @@ -0,0 +1,94 @@ +# Auto-Update auf einem Produktionsserver + +Dieses Dokument beschreibt, wie ein bereits laufendes `monitor` auf einem Produktionsserver +sich selbst aktualisiert, sobald der Repo-Besitzer einen Pull Request nach `main` mergt. +Anders als im parallelen [Agent-Projekt](https://codeberg.org/fds2610/Agent) gibt es hier +**keine** Trennung zwischen einem unveraenderlichen "Bootstrap"-Updater und dem eigentlichen +Projekt-Code - `update.sh` gehoert direkt zum Projekt und wird bei jedem Update mit +aktualisiert. + +## Bausteine + +- **`update.sh`** (Projekt-Wurzel) - holt die neueste Version des konfigurierten Branches per + `git pull --ff-only`, baut die Docker-Images neu, startet `api`, `web` und `sync` neu und + fuehrt (per Default) die Testsuiten aus. Kann jederzeit auch manuell aufgerufen werden, siehe + Kommentar am Kopf der Datei fuer die Umgebungsvariablen `UPDATE_REMOTE`, `UPDATE_BRANCH`, + `UPDATE_RUN_TESTS`. +- **`deploy-webhook.py`** (Projekt-Wurzel) - kleiner HTTP-Server (nur Python-Standardbibliothek, + keine Abhaengigkeiten) auf Basis von `http.server`. Prueft eingehende Codeberg-Webhooks per + HMAC-Signatur (`X-Forgejo-Signature`/`X-Gitea-Signature`, analog zum bestehenden + `POST /api/v1/webhooks/codeberg` aus [API.md](API.md)) und ruft bei einem Push auf den + konfigurierten Branch `update.sh` auf. Laeuft **auf dem Host**, nicht im Docker-Compose-Stack + - `update.sh` selbst ruft `docker compose` auf dem Host auf, das waere aus einem Container + heraus nur mit riskantem Zugriff auf den Docker-Socket moeglich. +- **`deploy/monitor-deploy-webhook.service`** - systemd-Unit-Vorlage, um `deploy-webhook.py` + dauerhaft im Hintergrund laufen zu lassen. + +## Manuelle Vorarbeiten auf dem Server + +Diese Schritte sind einmalig und **nicht** Teil dieses Repos (unterschiedlich je nach Server), +daher hier nur als Checkliste: + +1. **Repo auskecken.** `git clone https://codeberg.org/fds2610/monitor.git` (oder per SSH, + falls Push-Zugangsdaten fuer `git pull` als Deploy-Key/SSH-Key eingerichtet werden) nach + z.B. `/opt/monitor`, Branch `main` ausgecheckt. Der Arbeitsbaum muss beim Update sauber + sein - `update.sh` bricht kontrolliert ab, wenn lokale Aenderungen vorhanden sind, statt sie + zu ueberschreiben. +2. **Docker + Docker Compose** installieren (`docker compose version` muss funktionieren). + Der Benutzer, unter dem `deploy-webhook.py` laeuft, braucht Mitgliedschaft in der + `docker`-Gruppe (oder aequivalente Rechte), um `docker compose` ohne `sudo` aufzurufen. +3. **Python 3** installieren (Standardbibliothek genuegt, keine weiteren Pakete noetig) - + `mail_fds.py` im selben Repo setzt das ohnehin schon voraus. +4. **`.env` anlegen** aus `.env.example`, insbesondere `MONITOR_ADMIN_API_KEY`, + `MONITOR_API_BASE` und die neuen Variablen `MONITOR_DEPLOY_WEBHOOK_SECRET`, + `MONITOR_DEPLOY_WEBHOOK_PORT`, `MONITOR_DEPLOY_WEBHOOK_BRANCH` (Details siehe dort). Das + Secret z.B. mit `openssl rand -hex 32` erzeugen. +5. **systemd-Unit einrichten:** + ```bash + sudo cp deploy/monitor-deploy-webhook.service /etc/systemd/system/ + sudo $EDITOR /etc/systemd/system/monitor-deploy-webhook.service # Pfade/User/Group anpassen + sudo systemctl daemon-reload + sudo systemctl enable --now monitor-deploy-webhook + sudo systemctl status monitor-deploy-webhook + ``` +6. **Port/Proxy freischalten.** `deploy-webhook.py` lauscht auf `MONITOR_DEPLOY_WEBHOOK_PORT` + (Default `9099`) auf allen Interfaces. Analog zum bestehenden Codeberg-Sync-Webhook muss + Proxy/Firewall eingehende Anfragen von Codeberg an diesen Port (bzw. an einen dahinter + liegenden Reverse-Proxy-Pfad) durchlassen - alle anderen Dienste (`api`, `web`, `sync`) + brauchen dafuer keine Aenderung. +7. **Webhook in Codeberg einrichten** (macht der Repo-Besitzer manuell, siehe Issue #36): + Repo-Einstellungen -> Webhooks -> Forgejo, Ziel-URL auf `deploy-webhook.py` (Port aus Schritt + 6, ggf. per Proxy auf `https://...` gemappt), Content-Type `application/json`, Secret wie in + `.env` (`MONITOR_DEPLOY_WEBHOOK_SECRET`), Ereignis **nur** "Push". Ein Merge erzeugt immer + einen Push auf den Ziel-Branch, ein eigenes "Pull Request merged"-Event ist daher nicht + noetig. + +## Testen + +Zustellung testen (Codeberg schickt beim Anlegen automatisch ein `ping`-Event, sichtbar unter +"Letzte Zustellungen" im Webhook selbst). Manuell mit korrekter Signatur: + +```bash +SECRET= +BODY='{"ref":"refs/heads/main"}' +SIG=$(python3 -c "import hmac,hashlib,sys; print(hmac.new(sys.argv[1].encode(), sys.argv[2].encode(), hashlib.sha256).hexdigest())" "$SECRET" "$BODY") +curl -i -X POST "http://:9099/" \ + -H "X-Forgejo-Event: push" \ + -H "X-Forgejo-Signature: $SIG" \ + -d "$BODY" +``` + +Antwort `202 {"status": "update gestartet"}` bedeutet, dass `update.sh` im Hintergrund laeuft. +Der Verlauf landet in `MONITOR_DEPLOY_LOG_FILE` (Default: `deploy-webhook.log` neben +`deploy-webhook.py`) sowie im systemd-Journal (`journalctl -u monitor-deploy-webhook -f`). + +## Sicherheit + +Der Endpunkt ist wie `POST /api/v1/webhooks/codeberg` ohne Secret grundsaetzlich geschlossen +(401) und akzeptiert nur Anfragen mit gueltiger HMAC-Signatur. Er loest im Erfolgsfall +lediglich denselben `git pull --ff-only` + Rebuild aus, den ein Server-Betreiber auch manuell +per `./update.sh` anstossen wuerde - er bringt also keinen Code auf den Server, der nicht +bereits reviewt und **manuell** nach `main` gemergt wurde. Diese manuelle Merge-Hürde ist laut +Repo-Besitzer (Issue #36) als alleinige Sicherheitsmassnahme ausreichend; zusaetzliche +Branch-Protection-Einstellungen im Codeberg-Repo (z.B. kein direkter Push nach `main`) bleiben +trotzdem empfehlenswert, sind aber eine manuelle Repo-Einstellung und nicht Teil dieses Skripts. diff --git a/deploy-webhook.py b/deploy-webhook.py new file mode 100755 index 0000000..c470073 --- /dev/null +++ b/deploy-webhook.py @@ -0,0 +1,145 @@ +#!/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() diff --git a/deploy/monitor-deploy-webhook.service b/deploy/monitor-deploy-webhook.service new file mode 100644 index 0000000..bdfb1ca --- /dev/null +++ b/deploy/monitor-deploy-webhook.service @@ -0,0 +1,22 @@ +[Unit] +Description=monitor Deploy-Webhook (loest update.sh bei Push auf main aus) +After=network.target docker.service +Wants=docker.service + +[Service] +Type=simple +# Anpassen: Checkout-Pfad des monitor-Repos auf diesem Server. +WorkingDirectory=/opt/monitor +EnvironmentFile=/opt/monitor/.env +ExecStart=/usr/bin/python3 /opt/monitor/deploy-webhook.py +Restart=on-failure +RestartSec=5 + +# Anpassen: Benutzer/Gruppe mit Schreibrechten auf das Repo, git-Zugangsdaten (SSH-Key oder +# gespeicherte HTTPS-Credentials fuer "git pull") und Mitgliedschaft in der docker-Gruppe +# (fuer "docker compose"). Siehe README_UPDATE.md. +User=monitor +Group=monitor + +[Install] +WantedBy=multi-user.target