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
This commit is contained in:
fds2610-bot 2026-08-04 12:20:41 +02:00
parent 58eb022113
commit 6bad9b470c
6 changed files with 283 additions and 0 deletions

View file

@ -39,3 +39,16 @@ MONITOR_SYNC_INTERVAL=600
# Der Endpunkt ist der einzige, den Codeberg von aussen erreichen muss - Proxy und Firewall # 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. # muessen POST /api/v1/webhooks/codeberg also aus dem Internet durchlassen.
CODEBERG_WEBHOOK_SECRET= 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
# <deploy-webhook-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

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ api/vendor/
api/data/ api/data/
.env .env
node_modules/ node_modules/
__pycache__/
deploy-webhook.log

View file

@ -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 Webhook eingerichtet ist, innerhalb von Sekunden nach jeder Issue-Änderung
(`POST /api/v1/webhooks/codeberg`, HMAC-signiert per `CODEBERG_WEBHOOK_SECRET`). Siehe (`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. [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 - 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 eine Datenquelle für den System-Monitor; die alte tabellarische Statusseite ist mit
Phase 7 entfallen (F16), `status.txt` speist weiterhin den System-Monitor 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) - `docker-compose.yml` — komplette Entwicklungsumgebung (siehe unten)
- `update.sh` — holt die neueste Version vom konfigurierten Git-Remote und aktualisiert die - `update.sh` — holt die neueste Version vom konfigurierten Git-Remote und aktualisiert die
Docker-Compose-Umgebung (Abhängigkeiten, Images, Dienste, Tests) 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 - `register-agent.sh` — Hilfsskript, um einen neuen Agenten per `POST /api/v1/agents` zu
registrieren (siehe [API.md](API.md) Abschnitt "Agenten") registrieren (siehe [API.md](API.md) Abschnitt "Agenten")

94
README_UPDATE.md Normal file
View file

@ -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=<MONITOR_DEPLOY_WEBHOOK_SECRET aus .env>
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://<server>: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.

145
deploy-webhook.py Executable file
View file

@ -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()

View file

@ -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