Showcase nachgezogen: aktueller Stand (gast-zugang.py, Diun, Grafana-Alerts, IMAP-IDLE-Push)

arnol 2026-08-07 18:06:11 +02:00
commit 37905b0347

@ -52,13 +52,19 @@ option task = {name: "downsample-hourly", every: 1h}
from(bucket: "raw")
|> range(start: -task.every)
|> filter(fn: (r) => r._field != "name" and r._field != "status" and r._field != "type" and r._field != "tags" and r._field != "uptime_format" and r._field != "content")
|> filter(fn: (r) => r._field != "name" and r._field != "status" and r._field != "type" and r._field != "tags" and r._field != "uptime_format" and r._field != "content" and r._field != "qmpstatus" and r._field != "running-machine" and r._field != "running-qemu" and r._field != "pbs-library-version" and r._field != "lock")
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> to(bucket: "aggregated", org: "homelab")
```
⚠️ **NICHT TUN:** Den `raw`-Bucket ohne Feld-Filter blind mit `aggregateWindow(fn: mean)` downsamplen. Proxmox' `system`-Measurement (LXC- und Storage-Objekte) enthält Text-Felder (`name`, `status`, `type`, `tags`, `uptime_format`, `content`) — `mean` auf einer String-Spalte lässt den Task mit `unsupported input type for mean aggregate: string` fehlschlagen. Vor dem Anlegen des Tasks die tatsächlichen Feldnamen je Measurement prüfen (`schema.fieldKeys`), nicht raten.
⚠️ **NICHT TUN: die Feld-Ausschlussliste als einmalig erledigt betrachten.** Als eine zusätzliche QEMU-VM dazukam, brachte sie neue String-Felder mit, die die alte Liste (aus LXC-Erfahrung geschrieben) nicht kannte: `qmpstatus`, `running-machine`, `running-qemu` (Measurement `system`) und `pbs-library-version` (eigenes Measurement `proxmox-support`, nur von QEMU-Gästen mit Guest-Agent gemeldet). Der Task lief deshalb mehrere Tage lang **jede Stunde** mit demselben Fehler ins Leere — ohne dass es auffiel, bis die Grafana-Panels sichtbar keine neuen Daten mehr zeigten. Zusätzlich vorsorglich ausgeschlossen: `lock` (PVE-Sperrgrund, nur während Backup/Migration gesetzt, dann Text). **Jede neue Geräteklasse** (nicht nur neue LXCs) kann neue String-Felder mitbringen — nach dem Hinzufügen eines neuen Objekttyps einmal `schema.measurementFieldKeys` über **alle** Measurements im `raw`-Bucket laufen lassen (nicht nur `system`), nicht nur das eine bekannte Measurement.
⚠️ **NICHT TUN: sich bei einem fehlgeschlagenen Backfill-Versuch per `POST .../tasks/<id>/runs` mit `scheduledFor` auf den Task selbst verlassen, wenn er inkonsistent scheitert.** Nach dem Fix scheiterten reine Backfill-Runs für bereits vergangene Stunden weiterhin mit demselben String-Fehler, obwohl derselbe Flux-Code über den normalen Query-Endpunkt (`/api/v2/query` mit demselben Skript inkl. `to()`) für exakt denselben Zeitraum anstandslos funktionierte — Ursache nicht abschließend geklärt, vermutlich eine Eigenheit des Task-Scheduler-Kontexts bei nachträglich angestoßenen Runs. Pragmatischer, zuverlässiger Fix: die Lücke stundenweise direkt über die Query-API nachfüllen (dieselbe Flux-Query mit `to(bucket: "aggregated", ...)`, explizite `start`/`stop`-Zeitstempel statt `-task.every`), nicht über den Task-Run-Mechanismus.
⚠️ **NICHT TUN: eine dünne Datenpunktdichte nach dem Backfill als Fehler interpretieren.** Bei den absichtlich meist schlafenden Festplatten (5-Min-Spindown) gibt es im `raw`-Bucket oft nur wenige Zeitpunkte pro Tag für `smart`/`temperature_c``createEmpty: false` lässt entsprechend viele Stunden im `aggregated`-Bucket ganz aus. Das ist die korrekte Abbildung des Spindown-Verhaltens, kein unvollständiger Backfill.
Retention-Prinzip (Details/Begründung siehe Plan-Historie): Rohdaten kurz (Tage), Aggregate lang (Jahre) — hält die Gesamtgröße über Jahre konstant klein statt linear mit der Laufzeit zu wachsen.
## 4. PVE Metric Server → InfluxDB
@ -85,11 +91,15 @@ dpkg -i /tmp/telegraf.deb
⚠️ **NICHT vergessen:** Minimale Debian-<DEBIAN_RELEASE>-Installationen haben oft kein `cron`-Paket (`crontab: command not found`) — `apt install -y cron && systemctl enable --now cron` vor dem ersten `crontab -e`.
`/etc/telegraf/telegraf.conf`: `outputs.influxdb_v2` auf `http://<CT104-Tailscale-IP>:8086`, Bucket `raw`, Org `homelab`; Inputs `cpu`, `mem`, `disk`, `net`, `system`.
`/etc/telegraf/telegraf.conf`: `outputs.influxdb_v2` auf `http://<CT104-Tailscale-IP>:8086`, Bucket `raw`, Org `homelab`; Inputs `cpu`, `mem`, `disk`, `net`.
⚠️ **NICHT TUN:** `[[inputs.system]]` aktivieren. Dessen Standard-Messungsname ist `system` — kollidiert mit Proxmox' eigener `system`-Messung (PVE-Metric-Export, Abschnitt 4), die ebenfalls ein Feld `uptime` schreibt. InfluxDB sperrt Feldtypen pro (Messung, Feld) **unabhängig von den Tags** — Proxmox schreibt `uptime` immer als Dezimalzahl (kein `i`-Suffix), Telegrafs `system`-Input schreibt es als Ganzzahl (`i`-Suffix). Wer nach einer InfluxDB-Tagesgrenze (00:00 UTC, Shard-Neuanlage) zuerst schreibt, legt den Feldtyp für den Rest des Tages fest — der jeweils andere scheitert ab dann mit `422 field type conflict`, lautlos bei Telegraf, laut in den PVE-Logs (`pvestatd: metrics send error`). Zwei Tage lang gewann zufällig Proxmox (kein sichtbares Problem), am dritten Tag gewann Telegraf zuerst und der komplette PVE-Metrik-Export fiel tagelang lautlos aus. Da kein Dashboard Telegrafs `system`-Felder (`load1`/`load5`/`uptime`/`n_cpus`) nutzt (VPS-Ressourcen laufen über `cpu`/`mem`/`disk`), einfach ganz weglassen.
Ein einmal gesperrter Feldtyp lässt sich **nicht** durch Löschen der betroffenen Datenpunkte oder einen InfluxDB-Neustart zurücksetzen (Schema-Index ist von den Daten selbst entkoppelt) — einzige Abhilfe im Nachhinein: den ganzen `raw`-Bucket löschen und mit identischem Namen/Retention neu anlegen (kostet die 14 Tage Rohdaten-Historie, `aggregated` mit 2 Jahren Verlauf ist davon nicht betroffen).
## 6. HDD-SMART-Tracking (PVE-Host)
`/usr/local/bin/hdd-smart-to-influx.sh` liest `smartctl -A` für beide Platten (Attribute `Load_Cycle_Count`, `Start_Stop_Count`, `Temperature_Celsius` — Raw-Wert ist das erste Feld vor evtl. Klammertext), `zfs list -H -o name,used,avail,refer -p` für `nas`/`nas/freigabe`/`nas/backups` (Measurement `zfs_usage`, `/` im Dataset-Namen zu `_` normalisiert für den Tag-Wert) **und** NVMe-Belegung (Measurement `nvme_usage`) — die VG `pve` deckt praktisch die gesamte Intenso-NVMe ab (nur EFI-/Boot-Partitionen liegen außerhalb, vernachlässigbar). Schreibt alles per InfluxDB-Line-Protocol via `curl` in den `raw`-Bucket. systemd-Timer `hdd-smart-to-influx.timer`, alle 5 Minuten:
`/usr/local/bin/hdd-smart-to-influx.sh` liest `smartctl -A` für beide Platten (Attribute `Load_Cycle_Count`, `Start_Stop_Count`, `Temperature_Celsius` — Raw-Wert ist das erste Feld vor evtl. Klammertext), `zfs list -H -o name,used,avail,refer -p` für `nas`/`nas/freigabe`/`nas/backups` (Measurement `zfs_usage`, `/` im Dataset-Namen zu `_` normalisiert für den Tag-Wert) **und** NVMe-Belegung (Measurement `nvme_usage`) — die Haupt-VG deckt praktisch die gesamte NVMe ab (nur EFI-/Boot-Partitionen liegen außerhalb, vernachlässigbar). Schreibt alles per InfluxDB-Line-Protocol via `curl` in den `raw`-Bucket. systemd-Timer `hdd-smart-to-influx.timer`, alle 5 Minuten:
⚠️ **NICHT TUN:** Bei LVM-Thin-Provisioning (hier: LV `pve/data`, 137GB Pool für alle LXC-Disks) `vgs -o vg_size,vg_free` als "wie voll ist die Platte" interpretieren. Das misst nur, wie viel VG-Fläche noch **unallokiert** ist (also Platz für neue LVs) — nicht, wie viel der bereits allokierten LVs tatsächlich mit Daten beschrieben ist. Bei diesem Setup zeigte `vgs` 93% "belegt", obwohl real nur ~8% Daten auf der Platte lagen (root-LV zu 68GB allokiert, aber nur 6GB genutzt; Thin-Pool zu 137GB allokiert, aber nur 8,88% beschrieben). Für eine ehrliche Nutzungsanzeige: `df --output=used -B1 /` (root-FS) + `lvs -o lv_size,data_percent pve/data` (Thin-Pool-Ist-Nutzung) addieren → Feld `actual_used_gb`, getrennt von `total_gb`/`free_gb` (VG-Allokation, weiterhin nützlich um zu wissen, wie viel Raum für neue LVs übrig ist).
@ -100,7 +110,7 @@ OnBootSec=2min
OnUnitActiveSec=5min
```
Grafana-Panel "HDD Load-Cycle-Rate" (`derivative(unit: 1h, nonNegative: true)` auf `load_cycle_count`) ist die eigentliche Verifikation, ob ein Spindown-Timeout in der Praxis zu häufiges Spin-up/down verursacht.
Grafana-Panel "HDD Spin-ups pro Stunde" ist die eigentliche Verifikation, ob ein Spindown-Timeout in der Praxis zu häufiges Spin-up/down verursacht. Ursprünglich mit `derivative(unit: 1h, nonNegative: true)` auf `load_cycle_count` — auf Nutzerwunsch von einer geglätteten Rate-Kurve auf diskrete Einzelwerte pro Stunde umgestellt (Balken statt Linie), gleiches Darstellungsprinzip wie die Login-Panels: `aggregateWindow(every: 1h, fn: last, createEmpty: false) |> difference(nonNegative: true)`, `custom.drawStyle: "bars"`.
⚠️ **NICHT TUN:** Bei Prozent-Berechnungen aus zwei InfluxDB-Feldern (z.B. `used_bytes / (used_bytes + avail_bytes)`) auf automatische Int→Float-Konvertierung verlassen. Line-Protocol-Felder mit `i`-Suffix sind in Flux `int`, eine Division mit einem Float-Literal (`* 100.0`) scheitert dann mit `type conflict: float != int`. Fix: `float(v: r.used_bytes) / float(v: r.used_bytes + r.avail_bytes) * 100.0`.
@ -108,12 +118,22 @@ Grafana-Panel "HDD Load-Cycle-Rate" (`derivative(unit: 1h, nonNegative: true)` a
`/opt/monitoring/grafana-provisioning/datasources/influxdb.yaml` (InfluxDB2/Flux, fester `uid: influxdb-homelab`) und `/opt/monitoring/grafana-provisioning/dashboards/{provider.yaml,homelab.json}`, gemountet nach `/etc/grafana/provisioning`. Dashboard-JSON referenziert die Datasource über die feste `uid`, nicht per Name.
Dashboard-Struktur (per `"type": "row"`-Panels gruppiert, jede Zeile bewusst `"collapsed": false`): **Übersicht** (Esprimo/PVE-Host CPU+RAM als Graph + Intenso-NVMe frei/gesamt/Belegung-%, direkt darunter dasselbe Muster für den VPS: CPU+RAM als Graph + Root-Filesystem frei/gesamt/Belegung-%), **Ressourcen** (CPU/RAM/Speicherbelegung nur LXCs — PVE-Host und VPS bewusst nicht doppelt, stehen schon in der Übersicht), **Festplatten** (ZFS-Belegung in Bytes, HDD-Load-Cycle-Count/-Rate, Temperatur), **Logins & Zugriffe** (erfolgreiche/fehlgeschlagene Logins je Dienst, gestapelte Balken). `refresh: "1m"` auf Dashboard-Ebene gesetzt.
Dashboard-Struktur (per `"type": "row"`-Panels gruppiert, jede Zeile bewusst `"collapsed": false`): **Übersicht** (Hauptserver CPU+RAM als Graph + NVMe frei/gesamt/Belegung-%, direkt darunter dasselbe Muster für den VPS: CPU+RAM als Graph + Root-Filesystem frei/gesamt/Belegung-%), **Ressourcen** (CPU/RAM für LXCs + zusätzliche VMs, Speicherbelegung nur LXCs — Grund siehe unten; Hauptserver und VPS bewusst nicht doppelt, stehen schon in der Übersicht), **Festplatten** (ZFS-Belegung in Bytes, HDD-Load-Cycle-Count/-Rate, Temperatur), **Logins & Zugriffe** (erfolgreiche/fehlgeschlagene Logins je Dienst, gestapelte Balken). `refresh: "1m"` auf Dashboard-Ebene gesetzt.
VPS-Speicherbelegung braucht kein neues Tracking — Telegrafs `disk`-Input-Plugin (bereits für die Ressourcen-Metriken konfiguriert, siehe Abschnitt 5) liefert `free`/`total`/`used_percent` für `/` schon fertig aufbereitet, kein manueller Prozent-Umweg wie bei NVMe/ZFS nötig.
**LXC-Speicherbelegung** (Panel "Speicherbelegung: LXCs", Ressourcen-Abschnitt): braucht kein neues Tracking-Script — Felder `disk`/`maxdisk` kommen schon über den PVE-Metric-Export im `system`-Measurement mit (Rootfs-Füllstand je Container, nicht zu verwechseln mit der NVMe- oder ZFS-Belegung).
### Eine zusätzliche QEMU-VM im Dashboard
Der PVE-Metric-Export deckt **automatisch auch QEMU-VMs** ab, nicht nur LXCs — kein Agent auf der VM nötig, exakt dasselbe Prinzip wie bei den Containern. Einziger Unterschied: das Tag heißt `object == "qemu"` statt `"lxc"` (Schema sonst identisch: `cpu`, `mem`/`maxmem`, `disk`/`maxdisk`, Host-Tag = VM-Name).
CPU- und RAM-Panel im Ressourcen-Abschnitt filtern jetzt auf `(r.object == "lxc" or r.object == "qemu")` — die VM läuft dort einfach als weitere Zeitreihe neben den Containern mit.
⚠️ **NICHT TUN: das Speicherbelegungs-Panel genauso erweitern.** Bei QEMU-Guests liefert der periodische PVE-Metric-Export das Feld `disk` konstant `0` (verifiziert per Query) — Proxmox liest bei LXCs das Rootfs direkt vom Host, bei VMs bräuchte es dafür einen eigenen Weg über den Guest-Agent (`qm agent <id> get-fsinfo` liefert echte Werte, wird aber von `pvestatd` nicht in den periodischen Export übernommen). Ein Panel mit dieser Query hätte für die VM dauerhaft eine falsche Nulllinie gezeigt. Bewusst weggelassen statt eines irreführenden Panels — bei Bedarf später ein eigenes Polling-Script wie `hdd-smart-to-influx.sh`, kein Aufwand, der für eine einzelne Idle-Game-VM aktuell gerechtfertigt ist.
Zusätzlich: TCP-Port-Monitor in Kuma (Port 3389/RDP über die Tailscale-IP) — aussagekräftiger als ein reiner Ping, weil er prüft, ob der Remote-Desktop-Dienst tatsächlich lauscht. Bewusst nur intern in Kuma, nicht auf der Gast-Status-Page (die VM ist kein Portfolio-Baustein).
⚠️ **NICHT TUN:** Bei Stat-Panels mit `pivot()`+`map()`-Berechnung (z.B. Prozent aus zwei Feldern) das Ergebnis ohne abschließendes `|> keep(columns: ["_time", "_value"])` zurückgeben. Grafana zeigt bei `reduceOptions.fields: ""` (Auto-Erkennung) sonst **jedes** numerische Feld der Tabelle als eigene Kachel-Zahl an — inklusive der Zwischenwerte (`used_bytes`, `avail_bytes`) neben dem eigentlichen `_value`. Gleiches Prinzip für Legenden bei Mehrserien-Timeseries-Panels: ohne `keep(columns: ["_time", "host", "_value"])` zeigt die Legende alle Tags (`nodename`, `object`, `vmid`, …) statt nur des relevanten (`host`).
⚠️ **NICHT TUN:** Bei Stat-Panels mit reinen Referenzzahlen (z.B. "frei (GB)", "gesamt (GB)") `colorMode: "value"` ohne explizite Thresholds stehen lassen. Grafanas Default-Threshold färbt ab Wert 80 automatisch rot — bei einer GB-Zahl wie 213,9 (viel freier Platz, eigentlich gut) sieht das wie ein Alarm aus, obwohl "hoch" hier positiv ist. Für reine Referenzzahlen `colorMode: "none"` setzen; Ampelfarben nur bei tatsächlich alarmwürdigen Metriken (z.B. Belegungs-Prozent, wo hoch = schlecht stimmt) verwenden.
@ -141,11 +161,127 @@ api.add_monitor(type=MonitorType.HTTP, name='...', url='...', interval=60, maxre
api.add_monitor(type=MonitorType.PING, name='...', hostname='...', interval=60, maxretries=2)
```
### PVE-Web-Logins: Erneuerungen von echten Anmeldungen trennen
**Anlass:** Die Frage, ob ein gemeldeter Login echt war — die PVE-Oberfläche stand dauerhaft in einem Browser-Tab offen. Ergebnis der Prüfung: **die Weboberfläche erneuert ihr Ticket alle 15 Minuten**, und jede Erneuerung stand im Log als erfolgreicher Login. Solange irgendwo ein PVE-Tab offen ist, feuerte deshalb alle 15 Minuten ein Push.
Das ist nicht nur lästig, sondern hebt den Alarm auf: eine echte, unerwartete Anmeldung wäre im Dauerrauschen nicht aufgefallen — der Melder ist genau dann wertlos, wenn er zählt.
**Unterscheidung** (am Zugriffslog nachgewiesen):
| Aufruf | bedeutet |
|---|---|
| `POST /api2/extjs/access/ticket` | die Anmeldemaske — echte Anmeldung |
| `POST /api2/json/access/ticket` | der laufende JavaScript-Client erneuert das Ticket |
⚠️ **NICHT TUN: allein am Pfad festmachen.** Andere Clients (`pvesh`, API-Skripte, Mobil-Apps) melden sich ebenfalls über `/api2/json/` **erstmalig** an. Nur auf `extjs` zu hören würde solche Anmeldungen verschlucken — ein Falsch-Negativ im Sicherheitsalarm. `track-pve-web-logins.sh` nutzt deshalb zwei Kriterien: `extjs` gilt immer als Anmeldung, `json` nur dann, wenn von derselben IP in den **vorhergehenden 20 Minuten** keine erfolgreiche Anmeldung im Log steht.
⚠️ **NICHT TUN: das Zeitfenster relativ zu „jetzt" berechnen.** Erste Fassung tat genau das — dadurch galt jede ältere Erneuerung als neue Anmeldung und es änderte sich nichts. Das Fenster muss relativ zum **Zeitstempel der geprüften Zeile** liegen.
Erneuerungen fließen weiterhin ins Feld `success` nach InfluxDB, damit die Statistik vollständig bleibt — unterdrückt wird nur der Push.
**Verifiziert** gegen vier Fälle: echte `extjs`-Anmeldung → Alarm; zwei 15-Minuten-Erneuerungen → kein Alarm; `json`-Anmeldung nach 55 Minuten Pause → Alarm; `json` von unbekannter IP ohne Vorgeschichte → Alarm.
### ntfy: Nachrichtenhistorie war flüchtig
⚠️ **NICHT TUN: ein Volume nach `/var/cache/ntfy` mounten und annehmen, der Cache liege darin.** Ohne `NTFY_CACHE_FILE` hält ntfy **alle Nachrichten nur im Arbeitsspeicher** — jeder Container-Neustart löscht die komplette Historie. Das Volume war seit dem Aufbau gemountet, aber leer; aufgefallen erst, als nach einem Neustart die Meldungen des Tages fehlten.
```yaml
- NTFY_CACHE_FILE=/var/cache/ntfy/cache.db
```
Gegenprobe (nicht die Config, sondern das Verhalten): Nachricht senden, Container neu starten, Nachricht muss noch abrufbar sein.
```bash
curl -d "test" http://<LAN_IP_MONITORING>:8090/homelab-alerts
docker compose restart ntfy
curl -s "http://<LAN_IP_MONITORING>:8090/homelab-alerts/json?poll=1&since=5m" | wc -l # muss >0 sein
```
`NTFY_CACHE_DURATION=12h` allein nützt nichts — die Dauer bezieht sich auf einen Cache, den es ohne Datei nicht dauerhaft gibt.
### ntfy: Browser-Benachrichtigungen per Web Push
**Anlass:** Benachrichtigungen am Rechner kamen nicht an. Der naheliegende Verdacht war fehlendes HTTPS — Browser liefern die Notification-API nur im sicheren Kontext.
**Der Verdacht war falsch.** HTTPS lag längst vor: `tailscale serve` liefert ntfy über die Tailnet-Adresse, und `NTFY_BASE_URL` sowie `NTFY_BEHIND_PROXY=true` waren gesetzt. Gefehlt hat **Web Push**.
Der Unterschied ist entscheidend:
| | braucht | wirkt wann |
|---|---|---|
| Notification-API | nur HTTPS | nur solange der Tab offen ist |
| **Web Push** | HTTPS **und** VAPID-Schlüssel am Server | auch bei geschlossenem Browser |
```bash
docker exec ntfy ntfy webpush keys # VAPID-Paar erzeugen
```
Vier Werte gehören in die Umgebung des Containers — Schlüssel in die `.env` (chmod 600), nicht ins Compose-File:
```yaml
- NTFY_WEB_PUSH_PUBLIC_KEY=${NTFY_WEB_PUSH_PUBLIC_KEY}
- NTFY_WEB_PUSH_PRIVATE_KEY=${NTFY_WEB_PUSH_PRIVATE_KEY}
- NTFY_WEB_PUSH_EMAIL_ADDRESS=${NTFY_WEB_PUSH_EMAIL_ADDRESS}
- NTFY_WEB_PUSH_FILE=/var/cache/ntfy/webpush.db
```
Die E-Mail-Adresse geht als Missbrauchskontakt an die Push-Dienste von Mozilla und Google — dafür eine eigene, dedizierte Adresse statt einer privaten.
**Gegenprobe** (nicht die Config, sondern was die Web-App tatsächlich bekommt):
```bash
curl -s https://monitoring.<TAILNET>/config.js | tr ',' '\n' | grep web_push
# erwartet: "enable_web_push": true und ein "web_push_public_key"
```
⚠️ **CT104 braucht dafür ausgehendes Internet** — der Server stellt die Pushes selbst bei Mozilla bzw. Google zu. Ohne das bleibt es bei Benachrichtigungen im offenen Tab.
⚠️ **NICHT TUN: `docker compose config` ausführen, während jemand zuschaut oder mitprotokolliert.** Der Befehl löst alle Variablen auf und gibt den **privaten** VAPID-Schlüssel im Klartext aus. Beim Einrichten genau so passiert; die Schlüssel wurden deshalb sofort neu erzeugt, bevor das erste Abonnement bestand. Zum Prüfen reicht `docker inspect` mit einem gezielten `grep` auf die unkritischen Variablen.
### Chronik: was geändert wurde und warum
Ein eigenes Skript sammelt alle Änderungen an **einer** Stelle (Git-Log-Historie des Doku-Repos, Issue-Tracker-Ereignisse, Gast-Zugangs-Log, Kuma-Änderungs-Log), damit die Frage „was ist passiert und warum" jederzeit ohne Blick in mehrere Quellen beantwortbar ist — als tägliche Kurzfassung per Push und als durchsuchbare, dauerhafte Seite (nur intern erreichbar, da Commit-Nachrichten interne Details enthalten können).
⚠️ **NICHT TUN: die Zeitstempel der Logdateien als UTC lesen.** Die Wrapper schreiben **lokale** Zeit. Ein `.replace(tzinfo=utc).astimezone()` verschiebt die Chronik um den Zeitzonen-Versatz — beim ersten Lauf standen alle Log-Ereignisse zwei Stunden in der Zukunft. Korrekt ist `datetime.fromisoformat(...).astimezone()`, das nimmt bei einem naiven Wert lokale Zeit an.
⚠️ **NICHT TUN: `.strip()` auf Blöcke anwenden, die mit `\x1f` getrennt sind.** Python zählt `\x1c``\x1f` zu den Whitespace-Zeichen — `strip()` frisst damit das eigene Feldtrennzeichen am Blockende. Bei Commits **ohne** Rumpf fällt dadurch ein Feld weg, und eine Prüfung auf die Feldanzahl verwirft sie stillschweigend: beim ersten Lauf fehlte so ein gutes Drittel aller Commits, ohne jede Fehlermeldung. Korrekt ist `lstrip("\n\r")` plus ein Feld-Default. Gegenprobe: Zahl der Chronik-Commits gegen `git log --oneline | wc -l`.
⚠️ **Der Digest geht auf den normalen Alarm-Kanal, nicht auf den rohen Audit-Strom.** Auf dem Audit-Kanal liegt jeder Session-Start und jedes Session-Ende — ein Kanal, den man bewusst nicht abonniert. Der erste Digest kam deshalb nie an, obwohl er nachweislich auf dem Server lag. **Zustellung heißt nicht Empfang:** bei ntfy prüfen, ob das Zieltopik auch abonniert ist.
⚠️ Die Chronik speist sich bewusst **nicht** aus ntfy: dort laufen Nachrichten nach Stunden ab. Sie soll auch in Monaten noch beantworten, warum etwas so ist wie es ist.
### Audit-Hooks für Claude-Sitzungen
Session-Hooks pushen bei Start/Ende einer Claude-Sitzung auf einen eigenen Audit-Kanal — Teil des Projektziels, den Umgang mit einem KI-Agenten mit erweiterten Rechten nachvollziehbar zu machen, nicht nur zu betreiben.
**Ein früher Hook war falsch beschriftet:** ein Ereignis, das nach **jeder einzelnen Antwort** feuert (nicht am Sitzungsende), war mit „Sitzung beendet" betitelt — in einem Beispiel sechs solcher Meldungen in siebzehn Minuten, während die Sitzung weiterlief. Das ist keine Rauschfrage, sondern eine **falsche Aussage in einem Audit-Protokoll**: wer es später liest, sieht Sitzungsenden, die nie stattgefunden haben. Korrektur: auf das dafür vorgesehene Sitzungsende-Ereignis umgestellt.
**Nachtrag:** Dieses Sitzungsende-Ereignis feuert in der eingesetzten Umgebung nicht zuverlässig — geprüft anhand der Prozess-Zeiten beim Sitzungswechsel, kein „beendet" kam an, obwohl die zugrunde liegende Verbindung durchgehend weiterlief. Wahrscheinliche Ursache: der Prozess wird beim Wechsel beendet statt sich regulär zu verabschieden, und abgeschossene Prozesse führen keine Hooks mehr aus.
**Konsequenz fürs Audit-Konzept:** der Sitzungsstart ist der verlässliche Marker („ab hier war der Agent aktiv"). Ein Sitzungsende ist nicht zuverlässig beobachtbar und sollte nicht Grundlage der Auswertung sein. Der eigentliche Audit-Wert liegt ohnehin nicht in „Sitzung beendet", sondern in **was getan wurde** — dafür die Wrapper-Logs und die Chronik.
### Zugriff des KI-Agenten auf Uptime Kuma über einen Wrapper
Der Agent spricht Kuma ausschließlich über ein Wrapper-Skript an, nicht über ein eigenes Konto.
**Warum ein Wrapper statt eines eigenen Kuma-Kontos:** Uptime Kuma ist in der eingesetzten Version strikt **Einbenutzer-System** — im Code nachgesehen, nicht vermutet: der Setup-Handler bricht ab, sobald bereits ein Benutzer existiert; die Nutzer-Tabelle hat **keine** Rollen-/Rechte-Spalte, im Servercode gibt es keine Rollenlogik; die API-Keys helfen ebenfalls nicht — sie schalten nur eine einzelne, lesende Metrik-Route frei, Monitore verwalten geht damit nicht.
Damit ist bei diesem Dienst nur **Attribution statt Rechtebeschränkung** möglich. Jede verändernde Aktion landet mit Zeitstempel in einem eigenen Log-File und als Push auf dem Audit-Kanal. Lesende Aktionen werden protokolliert, aber nicht gepusht.
⚠️ **Das Protokoll erzwingt nichts.** Der Agent hat auf dieser Instanz passwortlose Root-Rechte und könnte den Wrapper umgehen — es dokumentiert den Normalbetrieb, ist aber keine technische Schranke. Bewusst so: eine echte technische Schranke wäre an dieser Stelle (Einbenutzer-System ohne Rollenmodell) gar nicht baubar gewesen.
⚠️ **NICHT TUN: `save_status_page` mit einer Teilkonfiguration aufrufen.** Der Aufruf **ersetzt** die komplette Status-Page. Wer nur die Gruppenliste schickt, verliert Titel, Theme, Domains und CSS. Der Wrapper liest deshalb erst die ganze Seite, tauscht nur die Gruppenliste und schreibt alles zurück — dieselbe Falle wie bei Grafanas Ordner-Rechten (siehe `09-gast-zugang.md`).
⚠️ **Monitor löschen nimmt die Historie mit** und ist nicht umkehrbar. Vor dem Löschen den Monitor anzeigen und im Zweifel pausieren statt löschen.
⚠️ **NICHT TUN:** Sich auf `isDefault=True` bei `add_notification` verlassen, um die Notification automatisch an alle (auch später erstellte) Monitore zu hängen — greift über die API nicht zuverlässig. Stattdessen nach dem Anlegen explizit für jeden Monitor `api.edit_monitor(id, notificationIDList={'<notif_id>': True})` setzen.
⚠️ **NICHT TUN:** `retries=` als Kwarg für `add_monitor` verwenden — die Bibliothek erwartet `maxretries`, sonst `TypeError`.
Angelegte Monitore: 5× HTTP (Guacamole öffentlich, Vaultwarden/PVE-Web über Tailnet-Namen, AdGuard/Filebrowser über LAN-IP — bewusst IP statt `*.pve`-Name bei AdGuard, um keine Zirkularität zu erzeugen, falls AdGuard selbst der Ausfall ist), 6× Ping (VPS über Tailscale-IP, PVE-Host + 4 LXCs über LAN-IP).
Angelegte Monitore: 5× HTTP (Passwortmanager/PVE-Web über Tailnet-Namen, ein VNC-Gateway öffentlich, DNS-Blocking/Dateifreigabe über LAN-IP — bewusst IP statt Tailnet-Name beim DNS-Blocker, um keine Zirkularität zu erzeugen, falls der DNS-Blocker selbst der Ausfall ist), 6× Ping (VPS über Tailscale-IP, Hauptserver + 4 LXCs über LAN-IP).
⚠️ **NICHT TUN:** sich auf den Rückgabewert einer Tag-Zuweisungs-Funktion verlassen, ohne ihn zu prüfen — die eingesetzte Bibliotheksversion hat einen Bug: der eigentliche Server-Call funktioniert, aber der anschließende lokale Cache-Update-Schritt der Bibliothek wirft eine Exception (greift auf ein nicht initialisiertes Dict zu). Der Tag wird trotz der Exception korrekt gesetzt — einfach den Aufruf abfangen und per erneutem Abruf der Monitorliste verifizieren, statt sich vom Fehler abschrecken zu lassen.
## 9. ntfy: Erreichbarkeit + tailscale serve
@ -159,32 +295,42 @@ Nach Einrichtung `NTFY_BASE_URL` in der Compose-Datei auf `https://monitoring.<T
## 10. Backup-Cronjobs: Fehlschlag-Alerts
Bestehende Cronjobs (`vzdump`, `backup-guacamole.sh`, rsync-Pull) auf Wrapper-Skripte umgestellt, die bei Fehlschlag (`if ! <command>; then curl ntfy; fi` bzw. `trap ... ERR`) eine Push-Nachricht senden. Bewusst nur bei Fehlschlag, kein täglicher Erfolgs-Spam. Ersetzt die bis dahin wirkungslose `vzdump`-Mail-Benachrichtigung (Postfix auf dem PVE-Host ist `inet_interfaces = loopback-only`, kein Relay, kein root-Postfach — die Mail ging faktisch ins Leere).
Bestehende Cronjobs (`vzdump`, ein Backup-Skript, rsync-Pull) auf Wrapper-Skripte umgestellt, die bei Fehlschlag (`if ! <command>; then curl ntfy; fi` bzw. `trap ... ERR`) eine Push-Nachricht senden. Bewusst nur bei Fehlschlag, kein täglicher Erfolgs-Spam. Ersetzt die bis dahin wirkungslose `vzdump`-Mail-Benachrichtigung (Postfix auf dem PVE-Host ist `inet_interfaces = loopback-only`, kein Relay, kein root-Postfach — die Mail ging faktisch ins Leere).
## 11. Login-/Zugriffs-Tracking
Kleine Skripte (alle 5 Min. per Cron) statt eines Loki/Promtail-Log-Stacks — schreiben Erfolgs-/Fehlschlag-Zähler nach InfluxDB, pushen bei Schwellenwert (>3 Fehlschläge/5 Min.) einen ntfy-Alert. Zusätzlich pusht jedes Skript bei `SUCCESS -gt 0` eine niedrigprioritäre ntfy-Meldung für erfolgreiche Logins (eigener Block je Skript, `Priority: default` statt `high`, Tag `white_check_mark` statt `warning`).
⚠️ **Bekannte Rauschquelle:** Vaultwardens Clients nutzen denselben `/identity/connect/token`-Endpunkt auch für OAuth-Token-Refreshes, nicht nur für echte Logins — der Erfolgs-Alert kann dadurch öfter feuern als ein tatsächlicher neuer Login stattfindet. Bewusst so belassen (Nutzeranforderung), aber beim nächsten Mal ggf. auf `grant_type=password` in den Logs filtern, um echte Logins von Refreshes zu trennen.
⚠️ **Bekannte Rauschquelle:** der Passwortmanager-Client nutzt denselben Token-Endpunkt auch für OAuth-Token-Refreshes, nicht nur für echte Logins — der Erfolgs-Alert kann dadurch öfter feuern als ein tatsächlicher neuer Login stattfindet. Bewusst so belassen, aber beim nächsten Mal ggf. auf den konkreten Grant-Type in den Logs filtern, um echte Logins von Refreshes zu trennen.
| Dienst | Quelle |
|---|---|
| Guacamole (VPS) | Docker-Logs (`InMemoryAuthenticationFailureTracker`) + `guacamole_user_history`-Tabelle für Erfolge |
| Vaultwarden (CT103) | Docker-Logs, `(login) POST /identity/connect/token => <Status>` |
| VNC-Gateway (VPS) | Docker-Logs (eingebauter Fehlversuchs-Tracker) + DB-Tabelle für Erfolge |
| Passwortmanager | Docker-Logs, Login-Endpunkt mit Status |
| PVE-Weboberfläche (Host) | `/var/log/pveproxy/access.log`, byte-offset-basiertes Tailing (kein natives `--since`) |
| SSH (PVE-Host) | `journalctl -u ssh --since "5 min ago"` |
| SSH/fail2ban (VPS) | `fail2ban-client status sshd` (Currently/Total failed/banned) — kein Log-Parsing nötig, fail2ban liefert die Zahlen direkt; Alert nur wenn „Currently banned" gegenüber letzter Messung steigt |
| AdGuard-Web-UI (CT100) | **nicht umgesetzt** — AdGuard loggt fehlgeschlagene Web-UI-Logins nachweislich nicht (getestet: weder Journal noch mit `verbose`-Flag), trotz eingebautem Lockout (`auth_attempts: 5`, `block_auth_min: 15`). Bewusste, dokumentierte Lücke statt einer fragilen Behelfslösung |
| DNS-Blocking-Web-UI | **nicht umgesetzt** — die Software loggt fehlgeschlagene Web-UI-Logins nachweislich nicht (getestet: weder Journal noch mit Verbose-Flag), trotz eingebautem Lockout. Bewusste, dokumentierte Lücke statt einer fragilen Behelfslösung |
**Update 2026-07-19 — Wer/IP statt nur Zahlen:** Alle fünf Skripte (außer fail2ban, das schon Zahlen liefert) hängen den echten Login-Kontext an den ntfy-Text an:
**Wer/IP statt nur Zahlen:** Alle fünf Skripte (außer fail2ban, das schon Zahlen liefert) hängen den echten Login-Kontext an den ntfy-Text an:
- **SSH (Host):** `user@ip` per `sed -nE` aus `Accepted`/`Failed password`/`Invalid user`-Zeilen extrahiert
- **PVE-Web:** Quell-IP (erstes Feld in `access.log`, `::ffff:`-Präfix entfernt)
- **fail2ban:** von reinem Status-Polling auf `fail2ban.log`-Tailing (byte-offset) umgestellt — liefert die tatsächlich gesperrte(n) IP(s) aus den `NOTICE [sshd] Ban <ip>`-Zeilen statt nur "eine neue IP"
- **Vaultwarden:** brauchte `IP_HEADER: "X-Forwarded-For"` in der Compose-Datei (Vaultwarden loggt IPs sonst gar nicht) — `tailscale serve` setzt den Header bereits automatisch, nur Vaultwarden vertraute ihm nicht. Danach erscheint bei Fehlschlägen `Username or password is incorrect... IP: X. Username: Y.` im Log, extrahierbar. Bei **Erfolg** bleibt die IP unverfügbar (dieser Log-Pfad existiert nur im Fehlerfall)
- **Guacamole:** brauchte `REMOTE_IP_VALVE_ENABLED: "true"` in der Compose-Datei (Tomcats RemoteIpValve, liest `X-Forwarded-For` von Caddy). Vorher zeigte der Fehlschlag-Tracker nur die interne Docker-Gateway-IP (`172.18.0.1`), nach dem Fix die echte Client-IP. Bei **Erfolg** bleibt `guacamole_user_history.remote_host` trotzdem auf der internen Adresse — der DB-Verlauf nutzt offenbar einen anderen internen Pfad als der Live-Fehlschlag-Tracker, der die Valve-korrigierte Adresse honoriert. Nicht weiter debuggt (Aufwand/Nutzen), Username bei Erfolg ist trotzdem neu und nützlich
- **fail2ban:** von reinem Status-Polling auf Log-Tailing (byte-offset) umgestellt — liefert die tatsächlich gesperrte(n) IP(s) aus den Ban-Zeilen statt nur "eine neue IP"
- **Passwortmanager:** brauchte einen expliziten Forwarded-Header in der Compose-Datei (der Dienst loggt IPs sonst gar nicht) — `tailscale serve` setzt den Header bereits automatisch, nur der Dienst vertraute ihm nicht. Danach erscheint bei Fehlschlägen IP und Benutzername im Log, extrahierbar. Bei **Erfolg** bleibt die IP unverfügbar (dieser Log-Pfad existiert nur im Fehlerfall)
- **VNC-Gateway:** brauchte eine explizite Proxy-Valve-Einstellung in der Compose-Datei, damit der eingebettete Servlet-Container den Forwarded-Header von Caddy liest. Vorher zeigte der Fehlschlag-Tracker nur die interne Docker-Gateway-IP, nach dem Fix die echte Client-IP. Bei **Erfolg** bleibt die DB-Tabelle trotzdem auf der internen Adresse — nutzt offenbar einen anderen internen Pfad als der Live-Fehlschlag-Tracker. Nicht weiter debuggt (Aufwand/Nutzen), Username bei Erfolg ist trotzdem neu und nützlich
⚠️ **NICHT TUN:** `paste -sd', ' -` verwenden, um mehrere Werte mit `, ` zu verbinden. `paste -s -d LISTE` zykelt durch die **einzelnen Zeichen** der Delimiter-Liste statt sie als einen mehrzeichigen Trenner zu nutzen — bei 3+ Werten entsteht `a,b c,d e,f` (abwechselnd Komma und Leerzeichen) statt `a, b, c, d, e, f`. Fällt bei nur 1-2 Werten nicht auf (Zufallstreffer), erst bei mehreren wird der Bug sichtbar. Fix: `paste -sd',' - | sed 's/,/, /g'`.
⚠️ **NICHT TUN: sich bei Einzelfehlversuchen auf das Docker-Log des VNC-Gateways verlassen.**
Ein gemeldeter Fehlversuch unterhalb der Alarmschwelle (>3/5 Min.) landete früher **nur** als
aggregierte Zahl in InfluxDB, die Quell-IP selbst stand ausschließlich im rotierenden Docker-Log —
nach ein paar Stunden nicht mehr rekonstruierbar. fail2ban auf dem VPS deckt das nicht ab (nur ein
`sshd`-Jail). Der Tracker schreibt seitdem **jede** fehlgeschlagene Quell-IP einzeln in ein eigenes
Measurement, unabhängig von der Alarmschwelle. So bleibt auch ein einzelner, unter der Schwelle
liegender Fehlversuch dauerhaft abfragbar. Realer Fall: ein einzelner Fehlversuch nachts aus dem
Adressraum eines großen US-Cloud-Anbieters — per Whois zugeordnet, klassisches
Scanner-Grundrauschen auf öffentlich erreichbaren Login-Seiten, kein gezielter Versuch.
## 12. HDD-Tracking hat sich selbst sabotiert (kritischer Fund)
Das 5-Minuten-Tracking-Script (`hdd-smart-to-influx.sh`) weckte mit jedem Lauf die schlafenden Platten — `smartctl -A` liest per Default auch bei einer Platte im Standby, was sie aufweckt. Da Check-Takt (5 Min.) und Spindown-Timeout (5 Min., siehe `01-pve-host.md`) identisch sind, hat das Monitoring den gerade erst eingerichteten Spindown fast vollständig ausgehebelt — bewiesen durch manuellen Test (Platte per `hdparm -y` schlafen gelegt, `smartctl -A` drüber, Status sofort wieder `active/idle`).
@ -193,22 +339,22 @@ Das 5-Minuten-Tracking-Script (`hdd-smart-to-influx.sh`) weckte mit jedem Lauf d
⚠️ **NICHT TUN:** `smartctl -A` (ohne `-n standby`) in einem Monitoring-Script verwenden, das eine Platte im Standby beobachten soll — das ist ein Widerspruch in sich, das Skript verhindert genau das, was es messen will.
## 13. Esprimo-Hardware-Sensoren (CPU-Temp + Lüfter)
## 13. Hardware-Sensoren (CPU-Temp + Lüfter)
Fujitsu-eigener Sensorchip, nicht automatisch aktiv:
Herstellereigener Sensorchip, nicht automatisch aktiv:
```bash
apt install -y lm-sensors
yes | sensors-detect --auto
echo -e "ftsteutates\ncoretemp" > /etc/modules-load.d/ftsteutates.conf
modprobe ftsteutates coretemp
```
Liefert `coretemp-isa-0000` (`Package id 0` = CPU-Temperatur) und `ftsteutates-i2c-0-73` (`fan2` = einziger belegter Lüfterkanal von 8, Rest `FAULT`/nicht verbaut). Werte per `sensors <chip>` parsen, ins `hdd-smart-to-influx.sh`-Script integriert (Measurement `esprimo_hw`, Felder `cpu_temp_c`/`fan_rpm`) — läuft im selben 5-Min-Timer mit statt einen eigenen zu brauchen.
Liefert `coretemp-isa-0000` (`Package id 0` = CPU-Temperatur) und einen zweiten Sensorchip für den Lüfterkanal (einziger belegter Kanal, Rest `FAULT`/nicht verbaut). Werte per `sensors <chip>` parsen, ins `hdd-smart-to-influx.sh`-Script integriert (Measurement `hw_sensors`, Felder `cpu_temp_c`/`fan_rpm`) — läuft im selben 5-Min-Timer mit statt einen eigenen zu brauchen.
Grafana-Panel kombiniert beide Metriken in einer Kachel mit zwei Y-Achsen (Temperatur links, RPM rechts, per `byFrameRefID`-Field-Override) — zeigt auf einen Blick, ob und wann der Lüfter bei steigender Last einsetzt (Ziel: möglichst viel passive Kühlung).
## 14. Load-Cycle-Verifikation: tägliche Momentaufnahme statt Dauerkurve
Zusätzlich zur laufenden 5-Min-Erfassung (jetzt korrekt, siehe Punkt 12) schreibt `vzdump-backup.sh` einmal täglich (03:00, während die Platten wegen des Snapshots ohnehin wach sind — kein zusätzlicher Weckvorgang) den rohen `Load_Cycle_Count` nach InfluxDB. Grafana-Panel "Differenz zum Vortag":
Zusätzlich zur laufenden 5-Min-Erfassung (jetzt korrekt, siehe Punkt 12) schreibt das tägliche Backup-Skript einmal täglich (03:00, während die Platten wegen des Snapshots ohnehin wach sind — kein zusätzlicher Weckvorgang) den rohen `Load_Cycle_Count` nach InfluxDB. Grafana-Panel "Differenz zum Vortag":
```flux
from(bucket: "aggregated")
|> range(start: v.timeRangeStart, stop: v.timeRangeStop)
@ -218,6 +364,235 @@ from(bucket: "aggregated")
```
Beantwortet direkt "wie viele Spin-ups gab es seit gestern" — eindeutiger als die stündliche Rate-Kurve, die bei Datenlücken (Platte war lange am Stück wach oder lange am Stück im Standby) leicht misszuinterpretieren ist.
## 15. Image-Update-Notifier (Notify-only)
Notify-only, bewusst **kein** Auto-Update — kollidiert mit dem Versions-Pinning der Kernkomponenten
und dem Verify-alles-Stil des Projekts. Meldet neue Image-Tags über ntfy, Einspielen bleibt manueller
Schritt im Wartungsfenster.
**Bewusst ein Datei-Provider statt Docker-Socket-Zugriff** — kein `docker.sock`-Mount nötig, damit
auch keine faktische Root-Äquivalenz für den Notifier-Container. Watcht damit gleichermaßen Images auf
CT104 selbst **und** auf entfernten Hosts (VPS, weitere LXCs), ohne dass der Notifier je einen fremden
Docker-Daemon erreichen muss — reine Registry-Abfrage anhand einer statischen Liste:
```yaml
# /opt/monitoring/diun/diun.yml
watch:
workers: 10
schedule: "0 8 * * *"
firstCheckNotif: false
providers:
file:
directory: /images
notif:
ntfy:
endpoint: http://<TS_IP_MONITORING>:8090
topic: homelab-alerts
priority: 3
templateTitle: "Neues Image: {{ .Entry.Image }}"
```
`/opt/monitoring/diun/images/images.yml` (ein Eintrag je beobachtetem Image, `watch_repo: true` nur
bei Versions-Serien sinnvoll, nicht bei `:latest`):
```yaml
- name: vaultwarden/server:latest
notify_on: [new, update]
- name: guacamole/guacd:<GUACAMOLE_VERSION>
watch_repo: true
notify_on: [new, update]
# ... siehe Datei fuer die vollstaendige Liste
```
⚠️ **NICHT TUN: `watch_repo: true` auf einen fixen `:latest`-Tag anwenden und dort eine sinnvolle
Tag-Liste erwarten.** Für `:latest`-Images reicht das reine Digest-Tracking des einen Tags — genau
das ist der Mechanismus, der ein zuvor dokumentiertes "lief drei Monate auf dem falschen Image"-Problem
(siehe `05-ct103-vaultwarden.md`) künftig sichtbar macht, weil der Notifier den Digest vergleicht,
nicht nur den Tag-String.
⚠️ **NICHT TUN: `firstCheckNotif: false` für dauerhaft sicher halten, auch bei später ergänzten
Einträgen.** Beim allerersten Lauf überhaupt katalogisiert `watch_repo: true` alle vorhandenen Tags
eines Repos als "neu" — bei einem kleineren, überschaubaren Image waren das gut ein Dutzend
historischer Versions-Tags, `firstCheckNotif: false` hat das damals zuverlässig stumm gehalten.
**Das gilt aber nur für den globalen Erststart, nicht erneut für einen Eintrag, der später zu einer
bereits laufenden Instanz hinzugefügt wird.**
**Real passiert:** ein Eintrag für ein großes, offizielles Datenbank-Image kam erst nach dem Erststart
dazu und hatte `watch_repo: true`. Beim ersten Cron-Lauf danach katalogisierte der Notifier die
**komplette Tag-Historie des offiziellen Images auf der Registry** (über hundert "neue" Tags über
mehrere Major-Versionen und Varianten hinweg) und versuchte für **jeden einzelnen** eine ntfy-Push.
`firstCheckNotif: false` hat das diesmal **nicht** unterdrückt. Ergebnis: viele Dutzend echte
Push-Nachrichten kamen tatsächlich an (bestätigt über ntfys eigenen Zähler für veröffentlichte
Nachrichten), bis ntfys eigenes Rate-Limit die restlichen Versuche abgewiesen hat. Ein zweiter,
baugleicher Zeitzünder (derselbe Bild-Typ an anderer Stelle im Setup) lag unbemerkt seit dem
Erststart in der Liste, war nur ruhig, weil er von der ursprünglichen Gnadenfrist erfasst wurde —
hätte bei einem DB-Reset des Notifiers oder einem Versionswechsel dieselbe Flut ausgelöst.
**Fix:** `watch_repo: true` bei beiden betroffenen Einträgen entfernt — für eine produktiv gepinnte
DB-Version bringt das Verfolgen der ganzen Repo-Tag-Historie ohnehin nichts, reines
Digest-Tracking des einen Tags reicht (dieselbe Begründung wie beim `:latest`-Punkt oben).
**Faustregel:** `watch_repo: true` nur bei kleinen, überschaubaren Projekten mit wenigen Tags
verwenden — bei großen offiziellen Images mit hunderten historischen Tags reicht die einfache
Digest-Verfolgung des gepinnten Tags, und jeder neu hinzugefügte `watch_repo: true`-Eintrag sollte
vor dem nächsten Cron-Lauf kurz überschlagen werden, wie viele Tags das Repo real hat.
⚠️ **NICHT TUN: sich mit dem Ändern der `images.yml` allein zufriedengeben.** Das Editieren der
Config stoppt einen **bereits laufenden** Scan nicht — der Notifier hatte die Tag-Liste beim
Cron-Lauf schon geladen und arbeitete sie über **mehrere Stunden** weiter ab (die Registry selbst
bremste mit HTTP 429, dadurch zog sich der eine Lauf lange hin). Der Datei-Fix griff erst beim
nächsten Neustart.
⚠️ **NICHT TUN: den Notifier zur "Beruhigung" einfach neu starten.** Er führt bei **jedem**
Container-Start sofort einen vollen Scan aus — zusätzlich zum geplanten Cron, nicht nur zu dessen
Zeitpunkt. Genau das ist am selben Tag noch einmal passiert: ein Neustart in dem Glauben, damit den
laufenden Scan zu stoppen, hat sofort einen **frischen** Komplett-Scan losgetreten — der lief dann,
wieder durch Registry-Throttling gebremst, weiter und traf dabei auf zwei weitere, bis dahin
unbemerkte Zeitzünder derselben Art (weitere große Images mit `watch_repo: true`). Für den Betrachter
sah das wie ein neues, wiederkehrendes Problem aus, war aber derselbe Mechanismus, nur durch den
eigenen Neustart erneut ausgelöst.
**Korrekte Reihenfolge bei einem Notif-Flood:**
```bash
# 1. SOFORT stoppen (nicht restart!), damit kein weiterer Neustart-Scan losgeht:
docker stop diun
# 2. Alle betroffenen watch_repo:true-Eintraege in images.yml identifizieren und pruefen/entfernen
# (jeden einzelnen -- nicht nur den, der gerade auffaellig war):
docker start diun # kurz noetig, damit "docker exec" funktioniert
docker exec diun diun image list # zeigt Manifest-Anzahl pro Image -- Ausreisser sofort sichtbar
docker exec diun diun image remove --image=<betroffenes-image>
# 3. Config fixen (watch_repo:true entfernen, siehe oben) UND zurueckspielen
# 4. Container final stoppen und einmal sauber neu starten:
docker stop diun && docker start diun
```
Nach Schritt 4 lohnt ein zweiter `diun image list`-Blick: jedes Image außer bewusst kleinen,
überschaubaren Repos sollte nur 1 Manifest zeigen.
Verifikation über ntfys eigenen Zähler für veröffentlichte Nachrichten (im Container-Log sichtbar) ist
zuverlässiger als die eigene `/json`-Abfrage der Cache-API, da deren `since=`-Parameter nur relative
Dauern oder Unix-Timestamps akzeptiert, keine ISO-8601-Strings (leicht mit "keine Nachrichten" zu
verwechseln) — und weil der Notifier ein neues Image bereits beim reinen Entdecken loggt, bevor die
ntfy-Push überhaupt verschickt wird: der Log-Eintrag allein beweist keinen tatsächlichen Versand.
Docker-Compose-Service (kein `docker.sock`, dafür die Config + Image-Liste read-only gemountet):
```yaml
diun:
image: crazymax/diun:latest
restart: unless-stopped
logging: *default-logging
command: serve
volumes:
- ./diun/diun.yml:/etc/diun/diun.yml:ro
- ./diun/images:/images:ro
- ./diun/data:/data
environment:
- TZ=Europe/Berlin
- DIUN_DB_PATH=/data/diun.db
```
**Verifikation:** ein eingebauter Test-Befehl löst eine echte ntfy-Push aus (bestätigt angekommen).
Datenverzeichnis läuft im bestehenden `vzdump` von CT104 mit, kein Extra-Backup-Schritt nötig.
## 16. Grafana-Alerts: drittes Alert-Bein
Bisher gab es zwei Alarmwege: Kuma (Erreichbarkeit) und ntfy-Pushes einzelner Skripte
(Backup-Skript, Patch-Report, …). Was fehlte: ein Alarm, wenn ein Dienst zwar erreichbar
ist, aber ein **Metrik-Schwellwert** überschritten wird (Füllstand, Anomalie-Rate) — das ist
Grafanas eigene Alerting-Engine, nicht Kuma oder ein Einzelskript.
**Contact Point** (Typ Webhook, kein natives ntfy-Ziel in der eingesetzten Grafana-Version)
zeigt auf den ntfy-Server per HTTP-Header + eigenem Payload-Template:
```json
{
"name": "ntfy-homelab-alerts",
"type": "webhook",
"settings": {
"url": "http://<TS_IP_MONITORING>:8090/homelab-alerts",
"httpMethod": "POST",
"headers": [
{"key": "Title", "value": "Grafana-Alert"},
{"key": "Priority", "value": "4"},
{"key": "Tags", "value": "warning"}
],
"payload": {
"template": "{{ .CommonAnnotations.summary }} (Status: {{ .Status }})"
}
}
}
```
Notification Policy (Default-Route) zeigt auf diesen Contact Point — vorher lief die Default-Route
noch auf den eingebauten "empty"-Receiver, eine bis dahin unbemerkte Lücke (jeder Alert ohne
eigene Route wäre stillschweigend verschluckt worden).
**Drei Alert-Regeln** (Ordner "Intern", eigene Rule-Group, Evaluationsintervall 1 Min):
| Regel | Schwelle | `for` |
|---|---|---|
| HDD Load-Cycle-Spike | > 50 Zyklen/Tag | 5 Min |
| NVMe-Füllstand (local-lvm) | > 80 % | 15 Min |
| ZFS-Pool Füllstand | > 80 % | 15 Min |
Aufbau je Regel (dreistufig, nicht zwei — siehe NICHT-TUN unten): Flux-Query (`A`) → `reduce`-
Expression (`B`, `reducer: last`) → `threshold`-Expression (`C`, die eigentliche Bedingung).
Beispiel ZFS-Pool-Regel:
```flux
from(bucket: "raw")
|> range(start: -30m)
|> filter(fn: (r) => r._measurement == "zfs_usage" and r.dataset == "nas"
and (r._field == "used_bytes" or r._field == "avail_bytes"))
|> last()
|> pivot(rowKey: ["_time"], columnKey: ["_field"], valueColumn: "_value")
|> map(fn: (r) => ({r with _value: float(v: r.used_bytes) / float(v: r.used_bytes + r.avail_bytes) * 100.0}))
|> keep(columns: ["_time", "_value"])
```
⚠️ **NICHT TUN: eine Flux-Query direkt in eine `threshold`-Expression stecken.** Fehler "looks
like time series data, only reduced data can be alerted on" — Grafana-Alerting will einen
skalaren Wert, keine Zeitreihe. Immer erst eine `reduce`-Stufe (`type: "reduce"`,
`datasourceUid: "__expr__"`) dazwischenschalten.
⚠️ **NICHT TUN: `keep(columns: [...])` bei mehreren Tag-Werten weglassen.** Fehler "frame cannot
uniquely be identified by its labels: has duplicate results with labels {}" — ohne eindeutig
erhaltene Spalte (z. B. `disk`) kann Grafana bei mehreren Serien nicht zuordnen, welche zu
welchem Ergebnis gehört.
⚠️ **NICHT TUN: den isolierten Contact-Point-Test-Button für die Verifikation verwenden.** Die
dafür in älteren Grafana-Versionen dokumentierten Endpunkte sind in der eingesetzten Version entweder
entfernt (HTTP 410) oder liefern einen ungeklärten "unknown integration type"-Fehler (HTTP 400) —
beides nicht der eigentliche Alert-Pfad. Stattdessen echte Ende-zu-Ende-Verifikation: eine Regel
testweise auf eine garantiert wahre Bedingung setzen (`gt 0`, `for: "0s"`), Firing-Zustand über
`/api/prometheus/grafana/api/v1/rules` abwarten, echte ntfy-Push kontrollieren, danach den
Produktivwert zurücksetzen (`PUT .../api/v1/provisioning/alert-rules/<uid>` — dabei `"provenance":
"api"` im Body mitschicken, sonst `409 alerting.provenanceMismatch`, da die Regel ursprünglich per
API angelegt wurde).
**Zwei weitere Backstop-Checks bewusst *nicht* als Grafana-Alert, sondern als eigenständige
Cron-Skripte** — beide prüfen Zustände, die entweder kein sinnvoller InfluxDB-Zeitreihenwert sind
(Pool-Health ist ein String, kein Messwert — genau solche String-Felder haben den
Downsampling-Task schon einmal zum Absturz gebracht, siehe Punkt 3) oder gerade den Ausfall des
Cron-/Metrik-Pfads selbst abdecken sollen, den ein Grafana-Alert auf InfluxDB-Daten nicht erkennen
würde, wenn genau die Datenquelle betroffen ist:
```bash
# scripts/zfs-health-check.sh — alle 15 Min (Cron), meldet jeden Pool != ONLINE
# scripts/backup-alter-check.sh — stuendlich (Cron), meldet Backup > 25h alt
```
Beide nach `/usr/local/bin/` deployt, Cron auf dem PVE-Host:
```
*/15 * * * * /usr/local/bin/zfs-health-check.sh
0 * * * * /usr/local/bin/backup-alter-check.sh
```
**Verifikation:** ZFS-Pool-Regel testweise auf `gt 0`/`for: "0s"` gesetzt, Firing-Zustand
bestätigt, echte ntfy-Push kontrolliert (Titel "Grafana-Alert", Priority 4, Tag "warning", Text
"TESTALARM - ZFS-Pool nas ist ueber 0% belegt (Status: firing)") — danach zurückgesetzt auf `gt
80`/`for: "15m"`. Beide Cron-Skripte einmal live gegen den echten (unauffälligen) Zustand
gelaufen: kein Fehlalarm bei ONLINE-Pool und aktuellem Backup, wie erwartet.
## Guacamole-SFTP: bekanntes, ungelöstes Problem
Die SFTP-Begleitverbindung (Datei-Transfer-Feature, `enable-sftp`) der VNC-Verbindung "Management-Desktop (Browser)" ist aktuell **deaktiviert** (`enable-sftp: false` in der DB). Ursache war zunächst ein reines Passwort-Drift zwischen dem in Guacamole hinterlegten Passwort und dem tatsächlichen System-Passwort des `transfer`-Users auf CT102 (behoben, `chpasswd`) — aber selbst mit korrektem Passwort scheiterte `guacd`s eingebauter SSH-Client (`libssh2`) weiterhin an der Authentifizierung, während derselbe Login mit dem normalen OpenSSH-Client (von PVE-Host **und** vom VPS aus, exakt derselbe Netzwerkpfad wie `guacd`) anstandslos funktionierte. Deutet auf eine Algorithmus-/Protokoll-Inkompatibilität zwischen `libssh2` und CT102s OpenSSH-Server hin (z.B. moderne KEX-Defaults, die `libssh2` nicht unterstützt), nicht auf ein Credential-Problem.
@ -228,8 +603,8 @@ VNC selbst funktioniert einwandfrei (davon unabhängig). Für eine vollständige
| Dienst | URL | Hinweis |
|---|---|---|
| Grafana | `http://<LAN_IP_MONITORING>:3000` | Admin-Passwort in Vaultwarden |
| Uptime Kuma | `http://<LAN_IP_MONITORING>:3001` | Admin-Passwort in Vaultwarden |
| InfluxDB | `http://<LAN_IP_MONITORING>:8086` | Admin-Passwort + Token in Vaultwarden |
| Grafana | `http://<LAN_IP_MONITORING>:3000` | Admin-Passwort im Passwortmanager |
| Uptime Kuma | `http://<LAN_IP_MONITORING>:3001` | Admin-Passwort im Passwortmanager |
| InfluxDB | `http://<LAN_IP_MONITORING>:8086` | Admin-Passwort + Token im Passwortmanager |
| ntfy (Push) | `https://monitoring.<TAILNET>`, Topic `homelab-alerts` | Android/iOS-App auf den self-hosted Server zeigen lassen |
| NAS-Backups (SMB) | `\\nas.pve\backups` | Read-only, gleicher Nutzer `<PRIMARY_USER>` wie `freigabe` |