Merge pull request 'Auto-Update per Push-Webhook fuer Produktionsserver einrichten' (#37) from fds2610-bot/monitor:main into main
Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/37
This commit is contained in:
commit
5471940389
6 changed files with 283 additions and 0 deletions
13
.env.example
13
.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
|
||||
# <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
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ api/vendor/
|
|||
api/data/
|
||||
.env
|
||||
node_modules/
|
||||
__pycache__/
|
||||
deploy-webhook.log
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
94
README_UPDATE.md
Normal file
94
README_UPDATE.md
Normal 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
145
deploy-webhook.py
Executable 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()
|
||||
22
deploy/monitor-deploy-webhook.service
Normal file
22
deploy/monitor-deploy-webhook.service
Normal 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
|
||||
Loading…
Add table
Reference in a new issue