Files
threadnet-operating/monitoring/README.md
T
Thore Cimbal 937a17ecf8 cve: seventeen decisions pointing at nothing, found the first time the counter could see
The unblinded counter went live with this pull and immediately reported
seventeen. Eleven belong to images the operating-stack deploy replaced — those
decisions did their job and are gone.

The other six are one CVE. Trivy's database moved CVE-2026-57433 in perl-base
from critical to high, and with that a single reclassification made six
decisions across six images obsolete at once. A severity is not a property of
our estate; it belongs to somebody else's database and changes underneath us.
That is now written next to the format description, because the next person will
otherwise wonder why an entry rots without anything here changing.

Fifteen entries and 58 identifiers remain.
2026-08-21 12:00:00 +00:00

14 KiB

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

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:

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:

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:

{ "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).

⚠️ Eintraege veralten aus zwei Gruenden, und beide sind normal. Entweder das Image verschwindet aus dem Bestand (Fassungssprung) — oder der Befund selbst verschwindet, weil Trivys Datenbank ihn umbewertet. Am 2026-08-23 fiel CVE-2026-57433 (perl-base) von CRITICAL auf HIGH und machte damit sechs Entscheidungen auf einen Schlag gegenstandslos. Ein Schweregrad ist keine Eigenschaft unseres Bestands, sondern einer fremden Datenbank, die sich unter uns aendert. cve_entscheidungen_ohne_befund faengt beide Faelle. 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.