The alloy chart's 1.x line pins its config-reloader sidecar by digest. Kubernetes then reports that container's image as a bare sha256 and puts the usable reference in image_spec alone, which the derivation was not reading. An hour after the chart bump the sidecar would have gone unscanned — the exact hole #0106 exists to close, reopened by an upgrade rather than by neglect. Worse than missing: the bare digest passed normalisation as repository 'sha256' with the hex as its tag, so it would have entered targets.txt, failed every pull, and shown up as a permanent coverage gap pointing at nothing. Both ends are closed and both are asserted, including that the query still asks for image_spec — an assertion on the parsing alone would stay green while the data never arrives. Ten more decisions cover what is fixed in the repo but not yet rolled out on the operating host, dated a week out so the alert speaks up if the deploy does not happen.
230 lines
11 KiB
Markdown
230 lines
11 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.
|
|
|
|
⚠️ **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.
|