# Monitoring-Stack (Operating-Host) Prometheus + Loki + Grafana + Alloy + node-exporter fuer den Operating-Server (`CFGMON`). Vollstaendig provisioniert: Datasources und Dashboards liegen als Code in diesem Repo, ein `docker compose up -d` auf einem frischen Host stellt den kompletten Stack wieder her. ## Deployment ```bash cp .env.example .env # Werte setzen (nur beim allerersten Start relevant) docker network create traefik # falls noch nicht vorhanden docker compose up -d ``` ### Config-Aenderungen: was wirklich ankommt **Prometheus und Alertmanager mounten seit 2026-08-15 ihr Config-VERZEICHNIS** (`./prometheus`, `./alertmanager`) statt einzelner Dateien. Damit ist die frueher hier beschriebene Inode-Falle fuer sie beseitigt: `git pull` ersetzt Dateien per Rename (neuer Inode); ein Einzeldatei-Mount zeigt danach weiter auf die **alte** Datei, waehrend `SIGHUP` seelenruhig Erfolg meldet. Verzeichnis-Mounts loesen bei jedem Zugriff ueber den Pfad auf. Warum strukturell statt per Anleitung: genau diese Falle war hier bereits ausfuehrlich dokumentiert -- samt richtigem Kommando und Pruefbefehl -- und hat am 2026-08-14 trotzdem zugeschlagen (geladen wurden die alten Alert-Regeln, Reload meldete Erfolg). Eine Fussangel, die man nur durch Lesen umgeht, umgeht man irgendwann nicht. Nach einem `git pull`, der Configs anfasst: ```bash docker compose up -d # Mount-/Service-Aenderungen -> Container wird neu erstellt ``` Reicht bei Prometheus/Alertmanager fuer den Inhalt bereits ein Reload, ist das in Ordnung -- die Datei ist jetzt wirklich die neue. **Noch als Einzeldatei gemountet** (gleiche Falle, dort weiterhin `--force-recreate` noetig): `loki/loki-config.yaml`, `alloy/config.alloy` sowie die Skripte `matrix-alerts.py`, `release-watch.py`, `cve-exporter.py`. Ob eine Aenderung angekommen ist, sieht man nur **im Container**, nie auf der Platte: ```bash docker exec prometheus md5sum /etc/prometheus/alerts.yml md5sum prometheus/alerts.yml # muessen uebereinstimmen ``` Grafana liest **Provider-Definitionen** nur beim Start: ein neuer Ordner in `provisioning/dashboards/dashboards.yml` braucht `docker compose restart grafana`. Dashboard-JSONs innerhalb eines bestehenden Providers werden laufend nachgezogen. ## Struktur | Pfad | Inhalt | |---|---| | `docker-compose.yml` | Stack-Definition, Images auf feste Versionen gepinnt | | `prometheus/prometheus.yml` | Scrape-Konfiguration | | `loki/loki-config.yaml` | Loki Single-Binary-Konfiguration, 90 Tage Retention | | `alloy/config.alloy` | Docker-Log-Collection -> Loki, node-exporter -> Prometheus | | `grafana/provisioning/` | Datasources (feste UIDs!) und Dashboard-Provider | | `grafana/dashboards//*.json` | Dashboards, je Unterordner ein Grafana-Ordner | | `cve/targets.py` | Bildet die Soll-Menge der Scan-Ziele aus Cluster, Betriebs-Host und Registry | | `cve/test_targets.py` | Zusicherungen dazu, je mit Gegenprobe (`python3 test_targets.py`) | | `cve/test_scan_loop.sh` | Zusicherungen fuer die Scan-Schleife (`sh cve/test_scan_loop.sh`) | | `cve/scan-loop.sh` | Trivy-Scan-Schleife (24h), schreibt JSON-Reports | | `cve/cve-exporter.py` | Reports -> Prometheus-Metriken, mit First-Seen-State | ## Externe Abhaengigkeiten - **Remote-Write eingehend** auf `:9090`: k3s-Cluster und Matrix-Server pushen Metriken (`flux`, `kube_state_metrics`, `synapse`). - **Log-Push eingehend** auf `:3100` (Loki) von externen Alloys. - **Scrape ausgehend**: k3s-Host (`10.0.0.2:9100`), Game-Host privat ueber den vSwitch (`10.0.0.4:9100`, `:8081` cadvisor, `:9810` gameserver-exporter). cadvisor liegt dort auf 8081, weil Host-Port 8080 von coolify-proxy belegt ist. Der Exporter auf `:9810` loest den alten auf `:9531` ab -- jener rief die Client-API mit einem Application-Key ueber http auf und lieferte deshalb nie eine einzige `pterodactyl_*`-Metrik. - `cadvisor` und `traefik` werden gescraped, laufen aber in **anderen** Stacks (cadvisor: Portainer-Stack `thread-net-git`; beide haengen im externen `traefik`-Netz). ## Dashboards aendern Provisionierte Dashboards sind in der UI editierbar (`allowUiUpdates: true`), aber die JSON-Datei im Repo ist die Quelle der Wahrheit: UI-Aenderungen muessen exportiert und committet werden, sonst gehen sie beim naechsten Datei-Update verloren. ## CVE-Pipeline (gitops#47) `cve-scan` scannt alle 24h die Ziele aus `/targets/targets.txt` mit Trivy und legt JSON-Reports in ein Volume; `cve-exporter` serviert sie als Metriken auf `:9101`, Prometheus scraped sie als Job `cve_exporter`. Dashboard: **Security / CVE-Uebersicht (Trivy)**. Der Exporter merkt sich je `(CVE, Target)` den Erstfund in einem persistenten State (`cve_exporter_state`-Volume) -- daher kommt die Spalte "erstmals gesehen". Verschwindet ein Finding, faellt der Eintrag raus (= "geschlossen"). ### Die Zielmenge wird abgeleitet, NICHT gepflegt (#0106, ADR-0026) Bis zum 2026-08-21 stand die Zielliste in `cve/images.txt` und musste bei Stack-Aenderungen von Hand nachgezogen werden. Sie wurde es nicht: Gemessen an dem Tag deckte sie **27 von 51** laufenden Images ab (52 %). Ungescannt blieben unter anderem der Web-Client, den jeder Nutzer laedt, **beide** Traefik-Schichten und die Registry selbst; gleichzeitig lief ein Scan gegen ein Image, das nirgends mehr laeuft. **Es gibt daher nichts mehr nachzuziehen.** Der `cve-exporter` bildet die Soll-Menge bei jeder Herleitung neu aus drei Quellen und schreibt sie nach `/targets/targets.txt` (Volume `cve_targets`, fuer den Scanner nur lesbar): | Quelle | woher | |---|---| | Cluster | `kube_pod_container_info` aus dem eigenen Prometheus — **`image_spec`, nicht `image`** (siehe Warnung unten) | | Betriebs-Host | `container_last_seen{job="operating_cadvisor"}` — schliesst `gameserver_cadvisor` aus | | Registry | rohana `/v2`, anonymer Token; die letzten drei Fassungen je Repo | Ein Ziel, das aus der Menge faellt, verliert seinen Bericht — sonst meldete ein ausgemustertes Image weiter. ⚠️ **Ein per Digest festgenagelter Container (`repo:tag@sha256:...`) traegt in `kube_pod_container_info` unter `image` nur den nackten `sha256:...`.** Die brauchbare Referenz steht allein in `image_spec`. Am 2026-08-21 kam mit dem alloy-Chart 1.x genau so ein Beiwagen herein; ohne diesen Zusatz waere er still aus der Zielmenge gefallen. Der nackte Digest wird ausserdem ausdruecklich abgewiesen, sonst landete er als Repo `sha256` in `targets.txt` und zaehlte dauerhaft als Deckungsluecke. ⚠️ **Faellt eine Quelle aus, wird die Soll-Menge kleiner — und die Deckung sieht dadurch BESSER aus, nicht schlechter.** Deshalb traegt jede Quelle einen Frische-Zeitstempel (`cve_target_source_stale`), auf dem eine Alarmregel steht. Wer hier etwas aendert, darf diese Absicherung nicht wegkuerzen; ohne sie ist ein Ausfall der Herleitung von Erfolg nicht zu unterscheiden. Deckung im Blick: `cve_target_coverage_ratio`, `cve_targets_missing`, `cve_targets_orphaned` — im Dashboard *Security / CVE-Uebersicht*. ### Entschieden oder offen (#0051) Ein CRITICAL, zu dem es nichts zu tun gibt, ist erlaubt — aber nur **benannt, begruendet und mit Pruefdatum**. Diese Entscheidungen stehen in `cve/entscheidungen.json`, je Eintrag ein Ziel mit den **einzelnen** CVE-Kennungen: ```json { "ziel": "coturn/coturn:4.17.2", "art": "hingenommen", "grund": "4.17.2 ist die neueste veroeffentlichte Fassung ...", "pruefen_am": "2026-11-21", "cves": ["CVE-...", "..."] } ``` `art` ist `hingenommen` (es gibt nichts zu tun) oder `geplant` (die Arbeit steht aus). Daraus zaehlt der Exporter `cve_critical_offen`, `cve_critical_entschieden`, `cve_entscheidungen_abgelaufen` und `cve_entscheidungen_ohne_befund`; vier Alarmregeln stehen darauf. ⚠️ **Bewusst keine Trivy-Ignore-Datei.** Trivy koennte die Befunde einfach weglassen — dann saehe „0 CRITICAL, weil behoben" genauso aus wie „0 CRITICAL, weil weggeschaut". Hier bleibt jeder Befund in `trivy_vuln_info` sichtbar; entschieden heisst **gezaehlt, nicht versteckt**. ⚠️ **Pauschalen je Image sind nicht ausdrueckbar** (der Lader weist einen Eintrag ohne CVE-Kennungen ab). Sonst verschluckte eine einmal getroffene Entscheidung auch jeden **neuen** Befund an demselben Image. ⚠️ **Eine unlesbare Datei laesst alles als offen zaehlen**, nicht als entschieden — die Richtung, die auffaellt. `CveEntscheidungenUnlesbar` nennt den Grund sofort, `CveCriticalOffen` erst nach 24h. Pruefen: `python3 cve/test_entscheidungen.py` (15 Zusicherungen, ohne Netz) und `docker run --rm --entrypoint promtool -v "$PWD/prometheus:/p:ro" prom/prometheus:v3.14.0 test rules /p/alerts_test.yml`. ### Alarm-Zustellung (frueher stummgeschaltet — seit gitops#51 wieder scharf) **Stand 2026-08-15: Alarme werden zugestellt.** `alertmanager.yml` hat nur noch die Default-Route auf den `matrix`-Receiver; die frueher hier beschriebene `room="security"`-Route auf den Null-Receiver existiert nicht mehr. Alarme **ohne** `room`-Label (u.a. die `axion-basics`- und `axion-backup`-Gruppen) laufen ueber diese Default-Route. Historie, damit der Grund der damaligen Stummschaltung nicht verlorengeht: die CVE-Regeln erzeugten je eine Alarm-Instanz **pro CVE pro Image** (beim ersten Lauf 59 CRITICAL + 445 HIGH), `matrix-alerts.py` schickte eine Nachricht pro Alarm, Synapse rate-limitete nach ~10 Nachrichten mit 429, und weil `save_state()` hinter der Sende-Schleife stand, wiederholte Alertmanager die komplette Gruppe mit noch leerer Deduplizierung. Behoben durch Aggregation auf `count by (target, severity)` (eine Instanz je Image statt je CVE); Details im Dashboard `cve-overview`. Vollstaendige Historie: gitops#51 / AAR 2026-08-01. **Zustellfehler sind seit 2026-08-15 selbst ueberwacht:** Prometheus scrapt Alertmanager (`operating_alertmanager`) und `AlertDeliveryFailing` schlaegt an, wenn `alertmanager_notifications_failed_total` steigt. Vorher war Alertmanager zwar Alarm-Ziel, aber kein Scrape-Target — eine reissende Alarmkette haette sich also selbst nicht melden koennen. ### Kleinere offene Punkte der Pipeline - `TrivyScanStale` kann ein Image, das **nie** erfolgreich gescannt wurde, nicht melden: ohne ersten Report gibt es keine Serie, an der `time() - trivy_last_scan_timestamp` haengen koennte. Ein dauerhaft fehlschlagendes Image bleibt damit still. `TargetDown` deckt nur den toten Exporter ab, nicht den einzelnen blinden Fleck. - `coturn/coturn:latest` ist als einziges Image ungepinnt -- Scan-Ergebnisse sind dadurch nicht reproduzierbar. - Der Exporter prunt den First-Seen-State bei **jedem** Scrape anhand der gerade gelesenen Reports. Ein transienter Lesefehler (`except: continue`) loescht die Erstfund-Zeitstempel des betroffenen Targets dauerhaft. ## Offene Punkte / Sicherheit - `9090`, `3100`, `9100` sind auf der oeffentlichen IP ohne Auth erreichbar (Docker umgeht ufw). Per **Hetzner Cloud Firewall** auf die IPs der bekannten Absender einschraenken. - Game-Host: **erledigt**. Host-Seite am 2026-08-20 deployt und verifiziert, die Ports liegen auf `10.0.0.4` (nicht `0.0.0.0` -- das umginge ufw). Der lokale Prometheus/Grafana/Loki-Stack des Game-Hosts ist zurueckgebaut; er liefert nur noch Sammler, promtail schickt nach `10.0.0.3:3100`. Das Label-Konzept des Game-Hosts lebte in `metric_relabel_configs` und ist mit dem Rueckbau hierher gewandert -- ohne es blieben Container nur unter ihrer UUID sichtbar und je Serie rund 20 `container_label_*` uebrig. - Loki `table_manager` ist deprecated; Retention-Enforcement laeuft aktuell nicht ueber den Compactor -- bei Gelegenheit auf Compactor-Retention umstellen.