3 08 Monitoring CT104
arnol edited this page 2026-08-07 18:06:11 +02:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Monitoring — Rebuild-Runbook

Neue LXC CT104 monitoring (<LAN_IP_MONITORING>, Tailscale-Mitglied monitoring.<TAILNET>) hostet als Docker-Compose-Stack: InfluxDB (Metrik-Datenbank), Grafana (Dashboards), Uptime Kuma (Erreichbarkeit + Alert-Routing), ntfy (Push-Benachrichtigungen). Ergänzt um PVE-Metric-Server-Export, Telegraf auf dem VPS, ein HDD-SMART-Tracking-Script und Login-/Zugriffs-Tracking je Dienst.

1. CT104 anlegen

pct create 104 local:vztmpl/<DEBIAN_TEMPLATE> \
  --hostname monitoring --memory 1536 --swap 512 --cores 2 \
  --rootfs local-lvm:12 \
  --net0 name=eth0,bridge=vmbr0,ip=<LAN_IP_MONITORING>/24,gw=<LAN_IP_ROUTER> \
  --nameserver <LAN_IP_ADGUARD> --unprivileged 1 --features nesting=1 \
  --onboot 1 --timezone Europe/Berlin --ostype debian
pct start 104

pct stop 104
cat >> /etc/pve/lxc/104.conf <<'EOF'
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file 0 0
EOF
pct start 104

Danach Docker (curl -fsSL https://get.docker.com | sh) und Tailscale (curl -fsSL https://tailscale.com/install.sh | sh && tailscale up --hostname=monitoring --accept-routes=false) installieren.

⚠️ NICHT vergessen: CT104 ins nächtliche vzdump-Backup aufnehmen (crontab -e auf dem PVE-Host, 104 zur ID-Liste ergänzen) — sonst ist der Monitoring-Stack selbst nicht gesichert.

2. Docker-Compose-Stack

/opt/monitoring/docker-compose.yml (Auszug, vollständige Datei im laufenden System): vier Services influxdb, grafana, uptime-kuma, ntfy, gemeinsamer x-logging-Anchor (10m×3 Dateien, bestehende Konvention). Ports: InfluxDB 8086, Grafana 3000, Uptime Kuma 3001, ntfy 8090→80.

⚠️ NICHT TUN: Grafana-Datenverzeichnis (./grafana) beim ersten Start nicht vor-chownen — der Container läuft als UID 472, ein per pct exec/root angelegtes Bind-Mount-Verzeichnis gehört aber root:root. Fix: chown -R 472:472 /opt/monitoring/grafana vor dem ersten Start von Grafana, sonst Crash-Loop ("not writable").

mkdir -p /opt/monitoring/{influxdb,grafana,uptime-kuma,ntfy}
chown -R 472:472 /opt/monitoring/grafana
cd /opt/monitoring && docker compose up -d

3. InfluxDB: Setup, Buckets, Retention, Downsampling

docker exec influxdb influx setup --username admin --password '<PW>' \
  --org homelab --bucket raw --retention 14d --force
docker exec influxdb influx bucket create --name aggregated --org homelab --retention 730d

Zwei Buckets statt vieler kleiner: raw (14 Tage, volle Auflösung — deckt die längste Rohdaten-Anforderung aller Datentypen ab) und aggregated (2 Jahre, stündliche Mittelwerte). Downsampling-Task (influx task create -f downsample.flux):

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" 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_ccreateEmpty: 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

pvesh create /cluster/metrics/server/monitoring \
  --type influxdb --server <LAN_IP_MONITORING> --port 8086 \
  --influxdbproto http --organization homelab --bucket raw --token '<TOKEN>'

Pusht Host- und LXC-Metriken automatisch alle ~10s, kein Agent in den LXCs nötig. Guest-Metriken landen unter Measurement system (Felder cpu, mem, maxmem, …, Tag object=lxc), Host-Metriken unter cpustat/memory/blockstat/nics (Tag object=nodes) — unterschiedliches Schema, beim Dashboard-Bau beide Fälle abdecken.

5. Telegraf auf dem VPS

VPS ist kein PVE-Cluster-Mitglied, braucht eigenen Agent:

apt install -y gnupg
curl -sL -o /tmp/telegraf.deb https://dl.influxdata.com/telegraf/releases/telegraf_<TELEGRAF_VERSION>_amd64.deb
dpkg -i /tmp/telegraf.deb

⚠️ NICHT TUN: Das offizielle InfluxData-apt-Repo (repos.influxdata.com/debian) auf Debian <DEBIAN_RELEASE> (<DEBIAN_CODENAME>) einbinden — die GPG-Signatur wird von apt/sqv mit "Missing key" abgelehnt (Schlüssel-Rotation nicht mit dem dokumentierten _compat.key synchron). Stattdessen das .deb direkt von dl.influxdata.com/telegraf/releases/ laden und mit dpkg -i installieren.

⚠️ 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.

⚠️ 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 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).

# /etc/systemd/system/hdd-smart-to-influx.timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min

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.

7. Grafana: Datasource + Dashboard per Provisioning

/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 (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.

⚠️ NICHT vergessen: GF_SECURITY_ADMIN_PASSWORD wirkt nur bei der ersten DB-Initialisierung. Ist Grafana schon einmal mit Default-Credentials (admin/admin) gestartet, hilft nur docker exec grafana grafana cli admin reset-admin-password '<PW>' (neuere Grafana-Versionen: grafana cli, nicht mehr das alte grafana-cli-Binary).

8. Uptime Kuma: Monitore + ntfy-Notification

Kein offizielles REST-API für Monitor-Verwaltung — Setup über die Python-Bibliothek uptime-kuma-api (nutzt das interne Socket.IO-Protokoll):

python3 -m venv /opt/monitoring/kuma-venv
/opt/monitoring/kuma-venv/bin/pip install uptime-kuma-api
from uptime_kuma_api import UptimeKumaApi, MonitorType, NotificationType
api = UptimeKumaApi('http://localhost:3001')
api.setup('admin', '<PW>')  # nur beim allerersten Mal
api.login('admin', '<PW>')
api.add_notification(name='ntfy-homelab', type=NotificationType.NTFY, isDefault=True,
                      ntfyserverurl='http://ntfy:80', ntfytopic='homelab-alerts',
                      ntfyPriority=4, ntfyAuthenticationMethod='none')
api.add_monitor(type=MonitorType.HTTP, name='...', url='...', interval=60, maxretries=2)
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.

- 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.

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
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:

- 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):

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 (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

tailscale serve --bg http://localhost:8090

⚠️ NICHT TUN: https+insecure://localhost:8090 verwenden (das Muster aus 01-pve-host.md/PVE-Webinterface) — dort war es richtig, weil das Backend (pveproxy) selbst HTTPS mit selbstsigniertem Zertifikat spricht. ntfy spricht intern nur plain HTTP; https+insecure:// gegen einen HTTP-Backend führt zu 502 Bad Gateway. Korrekt: http://localhost:8090.

Nach Einrichtung NTFY_BASE_URL in der Compose-Datei auf https://monitoring.<TAILNET> setzen und docker compose up -d ntfy (Recreate).

10. Backup-Cronjobs: Fehlschlag-Alerts

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: 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
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
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

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

Fix: smartctl -n standby -A <dev> — überspringt (ohne Weckvorgang) die Abfrage, wenn die Platte schon schläft; Skript erkennt das am Exit-Code/der Meldung Device is in STANDBY mode und schreibt für diesen Durchlauf einfach keinen Datenpunkt für die schlafende Platte (InfluxDB/Grafana kommen mit Lücken problemlos klar).

⚠️ 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. Hardware-Sensoren (CPU-Temp + Lüfter)

Herstellereigener Sensorchip, nicht automatisch aktiv:

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 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 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":

from(bucket: "aggregated")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "smart" and r._field == "load_cycle_count")
  |> aggregateWindow(every: 1d, fn: last, createEmpty: false)
  |> difference(nonNegative: true)

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:

# /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):

- 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:

# 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):

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:

{
  "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:

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:

# 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 guacds 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.

VNC selbst funktioniert einwandfrei (davon unabhängig). Für eine vollständige Lösung: sshd_config auf CT102 um ältere, libssh2-kompatible KexAlgorithms/HostKeyAlgorithms erweitern (Kompatibilitäts-Fallback, nicht global schwächen) und erneut mit aktiviertem enable-sftp testen — noch nicht gemacht (Priorität war, den Nutzer schnell wieder reinzulassen).

Referenz: Zugänge

Dienst URL Hinweis
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