homelab-showcase/08-monitoring.md

22 KiB
Raw Blame History

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-13-standard_13.6-1_amd64.tar.zst \
  --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")
  |> 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.

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_1.39.1-1_amd64.deb
dpkg -i /tmp/telegraf.deb

⚠️ NICHT TUN: Das offizielle InfluxData-apt-Repo (repos.influxdata.com/debian) auf Debian 13 (trixie) 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-13-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.

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:

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

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

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

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

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

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

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.

Dienst Quelle
Guacamole (VPS) Docker-Logs (InMemoryAuthenticationFailureTracker) + guacamole_user_history-Tabelle für Erfolge
Vaultwarden (CT103) Docker-Logs, (login) POST /identity/connect/token => <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

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:

  • 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

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

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

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

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

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.

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 in Vaultwarden
Uptime Kuma http://<LAN_IP_MONITORING>:3001 Admin-Passwort in Vaultwarden
InfluxDB http://<LAN_IP_MONITORING>:8086 Admin-Passwort + Token in Vaultwarden
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