Files
threadnet-operating/monitoring/README.md
T
Thore Cimbal 9a3b1d1dd5 cve: a successful query can still be worthless, and it cost 42 reports
Restarting k3s took kube-state-metrics and alloy's log tailers down with it, so
kube_pod_container_info went empty in prometheus. The derivation asked, got a
clean response with zero rows, and counted it as success. Cluster targets went
from 39 to none, the total from 54 to 12, and the scan loop deleted every report
whose target had vanished. Coverage then read 1.0.

None of the four rules fired, and each for a defensible reason: the set was not
empty because the operating host still answered, and every timestamp was fresh
because an empty success updates it. The gap sat exactly between them.

A source that has delivered before and now delivers nothing is treated as a
failure: its previous targets are kept, its timestamp ages, and the stale rule
takes over. A fifth rule watches the total for a drop of more than 40 percent,
and it deliberately also fires on a deliberate shrink — losing 40 percent of the
checked estate is worth a line either way.

The six rancher decisions are gone too. They described versions that no longer
run: the k3s patch took all fifteen of their criticals with it.
2026-08-21 12:00:00 +00:00

261 lines
13 KiB
Markdown

# 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/<ordner>/*.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.
⚠️ **Eine Antwort kann erfolgreich und trotzdem wertlos sein.** Am 2026-08-23
legte ein k3s-Neustart kube-state-metrics und Alloy lahm; `kube_pod_container_info`
war daraufhin in Prometheus **leer**. Die Abfrage lief sauber durch, lieferte
null Zeilen — und galt als Erfolg. Die Cluster-Quelle fiel von 39 Zielen auf 0,
die Gesamtmenge von 54 auf 12, der Scanner loeschte **42 Berichte**, und das
Dashboard meldete Deckung **1,0**. Keine Regel schlug an: die Menge war nicht
leer, die Zeitstempel waren frisch.
Seitdem gilt: **Eine Quelle, die schon einmal geliefert hat und jetzt nichts
liefert, ist ein Ausfall.** Der alte Stand bleibt, der Zeitstempel altert,
`CveTargetSourceStale` greift — und `CveZielmengeEingebrochen` meldet zusaetzlich
jeden Einbruch der Gesamtmenge um mehr als 40 %.
⚠️ **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.
⚠️ **Dieselbe Lage hat je nach Zaehlweise verschiedene Zahlen.** Ein Trivy-Bericht
zaehlt **Vorkommen**, Prometheus zaehlt **Serien** — und eine Serie ist
`(CVE, Paket, installierte Fassung, Fix-Fassung)` je Ziel. Wiederholt sich
derselbe Befund ueber mehrere Binaries in einem Image, sind das viele Vorkommen
und **eine** Serie. Bei Grafana 13.2.0: 162 Vorkommen, 63 Serien, 28
verschiedene CVEs. Wer eine Fassung gegen eine andere haelt, muss auf beiden
Seiten dieselbe Zaehlweise nehmen — sonst faellt das Urteil falsch aus, wie am
2026-08-21 beim ersten Blick auf 13.x.
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`: **erledigt am 2026-08-21.** Sie standen auf `0.0.0.0`
und waren damit auf der oeffentlichen IP ohne Auth erreichbar (Docker umgeht
ufw). Jetzt an Adressen gebunden -- `10.0.0.3` fuer die pushenden Absender
ueber den vSwitch, `127.0.0.1` fuer den Host selbst; node-exporter nur
localhost. Die Hetzner-Firewall bleibt die zweite Schicht, ist aber nicht
mehr die einzige.
⚠️ **Das war auf dem Host repariert und im Repo nicht.** Vier Monate spaeter
haette ein `git pull` die Ports wieder geoeffnet, ohne dass jemand es
bemerkt haette -- verhindert nur dadurch, dass git den Pull wegen der
lokalen Aenderung abgelehnt hat. Eine Haertung, die nur auf dem Host steht,
ist keine Haertung, sondern eine Wette auf das naechste Deployment.
- 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.