From eb9bbe6455357e4b26e3f5bf33c8f8cc3854bd34 Mon Sep 17 00:00:00 2001 From: arnol Date: Wed, 22 Jul 2026 14:20:51 +0200 Subject: [PATCH] Initial import: genericized homelab rebuild runbooks --- 00-uebersicht.md | 36 ++++ 01-pve-host.md | 176 +++++++++++++++++++ 02-ct100-adguard.md | 154 ++++++++++++++++ 03-ct101-nas.md | 145 +++++++++++++++ 04-ct102-management-desktop.md | 164 +++++++++++++++++ 05-ct103-vaultwarden.md | 116 ++++++++++++ 06-vps-strato.md | 312 +++++++++++++++++++++++++++++++++ 07-backup-restore.md | 99 +++++++++++ 08-monitoring.md | 235 +++++++++++++++++++++++++ 09-gast-zugang.md | 240 +++++++++++++++++++++++++ ACHTUNG-DISCLAIMER.md | 16 ++ ENTSCHEIDUNGEN.md | 64 +++++++ VARIABLEN.md | 27 +++ 13 files changed, 1784 insertions(+) create mode 100644 00-uebersicht.md create mode 100644 01-pve-host.md create mode 100644 02-ct100-adguard.md create mode 100644 03-ct101-nas.md create mode 100644 04-ct102-management-desktop.md create mode 100644 05-ct103-vaultwarden.md create mode 100644 06-vps-strato.md create mode 100644 07-backup-restore.md create mode 100644 08-monitoring.md create mode 100644 09-gast-zugang.md create mode 100644 ACHTUNG-DISCLAIMER.md create mode 100644 ENTSCHEIDUNGEN.md create mode 100644 VARIABLEN.md diff --git a/00-uebersicht.md b/00-uebersicht.md new file mode 100644 index 0000000..94c7ed6 --- /dev/null +++ b/00-uebersicht.md @@ -0,0 +1,36 @@ +# Rebuild-Dokumentation – Übersicht + +Diese Dateien enthalten alle tatsächlich verwendeten CLI-Befehle, in Reihenfolge, um jede Instanz von Grund auf neu aufzusetzen — ohne Rückfrage. Fehlgeschlagene Zwischenschritte sind **nicht** enthalten, nur das jeweils korrekte Endergebnis plus eine explizite Warnung, was nicht funktioniert (siehe „⚠️ NICHT TUN"-Blöcke). + +Für den Kontext/die Begründung hinter Entscheidungen: `ENTSCHEIDUNGEN.md` (hier rein technisch als Runbook, dort die Trade-offs/Alternativen). Zum Hintergrund dieser Freigabe: `ACHTUNG-DISCLAIMER.md`. + +## Instanzen + +| Datei | Host | IP (LAN) | IP (Tailscale) | Rolle | +|---|---|---|---|---| +| [01-pve-host.md](01-pve-host.md) | `` | `` | `` | Proxmox-Host, Subnet-Router, ZFS, WoL-Trigger | +| [02-ct100-adguard.md](02-ct100-adguard.md) | `adguard` (CT 100) | `` | — | DNS/Ad-Blocking | +| [03-ct101-nas.md](03-ct101-nas.md) | `nas` (CT 101) | `` | — | Samba/SFTP/Filebrowser | +| [04-ct102-management-desktop.md](04-ct102-management-desktop.md) | `browser` (CT 102) | `` | `` | Gemeinsamer Browser-Desktop | +| [05-ct103-vaultwarden.md](05-ct103-vaultwarden.md) | `vaultwarden` (CT 103) | `` | `` | Passwortmanager | +| [06-vps-strato.md](06-vps-strato.md) | `vps` (STRATO) | — | `` | Guacamole-Gateway, öffentlicher Fallback-Zugang | +| [08-monitoring.md](08-monitoring.md) | `monitoring` (CT 104) | `` | `` | Grafana/InfluxDB/Uptime Kuma/ntfy | + +Cross-cutting, kein eigener Host: +- [07-backup-restore.md](07-backup-restore.md) — nächtliches Backup des Laufzeit-Zustands aller Instanzen (LXCs, Guacamole-DB) plus Restore-Anleitung. +- [09-gast-zugang.md](09-gast-zugang.md) — read-only Showcase-Zugang (genau die Oberfläche, über die diese Dateien hier gerade ausgeliefert werden) für Freunde/Bekannte und Bewerbungsgespräche. + +## Reihenfolge beim Neuaufbau + +1. PVE-Host (Proxmox-Grundinstallation wird vorausgesetzt, danach Tailscale + ZFS + WoL) +2. CT 100 (AdGuard) — muss vor DHCP-Umstellung stehen, da alle anderen Container/das LAN danach über AdGuard auflösen +3. CT 101 (NAS) — ZFS-Pool muss vorher existieren +4. VPS (STRATO) — unabhängig von den LXCs, kann parallel/vorher passieren +5. CT 102 (Management-Desktop) und CT 103 (Vaultwarden) — brauchen Tailscale (Punkt 1) und idealerweise Guacamole (VPS) für vollen Funktionsumfang, technisch aber auch eigenständig baubar + +## Globale Konventionen, die für alle Instanzen gelten + +* **Nameserver bei jedem `pct create` explizit setzen:** `--nameserver `. Grund: siehe `01-pve-host.md`, Abschnitt „DNS-Falle bei neuen Containern". +* **IP-Schema:** `.1` Fritzbox, `.9` AdGuard, `.10` PVE-Host, `.11` NAS, `.12` Management-Desktop, `.13` Vaultwarden, `.14` Monitoring, `.254` PC1 (Windows). `.1`–`.20` sind im Fritzbox-DHCP-Pool ausgeschlossen, für feste Zuweisungen reserviert. +* **Alle LXCs unprivileged**, außer es gibt einen zwingenden Grund (Kernel-Module/Storage-Passthrough) für privileged/VM. +* **Passwörter/Tokens/private Keys stehen NICHT in diesen Dateien** — die liegen im Chat-Verlauf bzw. sind vom Nutzer selbst zu setzen (Vaultwarden-Master-Passwort) oder in Vaultwarden zu hinterlegen, sobald der Umzug dorthin gemacht ist. diff --git a/01-pve-host.md b/01-pve-host.md new file mode 100644 index 0000000..948b1df --- /dev/null +++ b/01-pve-host.md @@ -0,0 +1,176 @@ +# PVE-Host (``) — Rebuild-Runbook + +Basis: Proxmox VE, frisch installiert (Fujitsu Esprimo, i3, 32GB RAM). Alles ab hier ist tatsächlich ausgeführt. + +## 1. Grundpakete + +```bash +apt-get install -y git tree +``` + +## 2. Tailscale (Subnet-Router) + +```bash +curl -fsSL https://tailscale.com/install.sh | sh + +cat > /etc/sysctl.d/99-tailscale.conf <<'EOF' +net.ipv4.ip_forward=1 +net.ipv6.conf.all.forwarding=1 +EOF +sysctl -p /etc/sysctl.d/99-tailscale.conf + +tailscale up --authkey= --advertise-routes= --accept-risk=lose-ssh +``` + +**Danach zwingend in der Tailscale-Admin-Konsole (login.tailscale.com/admin/machines):** +- Bei `` → „Edit route settings" → Route `` genehmigen (Subnet-Routes werden nie automatisch aktiviert) +- Unter Settings → DNS → Nameserver `` (AdGuard) hinzufügen + „Override local DNS" aktivieren → macht Ad-Blocking im ganzen Tailnet aktiv, auch mobil +- Unter Settings → DNS → „HTTPS Certificates" aktivieren (wird für `tailscale cert`/`tailscale serve` auf allen Tailnet-Mitgliedern gebraucht, z.B. Vaultwarden) +- Auth-Key erzeugen unter Settings → Keys → „Generate auth key" (reusable, ~90 Tage), wird für alle weiteren Instanzen gebraucht + +⚠️ **NICHT TUN:** `--advertise-exit-node` setzen. Bewusste Entscheidung: kein Exit-Node, sonst würde jeglicher mobiler Internet-Traffic über die Heimleitung (DS-Lite) zurückgeroutet — nur die Subnet-Route wird gebraucht. + +## 3. NAS: ZFS-Mirror + +Vorher: Laufwerke identifizieren, SMART-Check, Datenprüfung auf vorhandenen Inhalt (**vor** dem Löschen — hier nur der Soll-Zustand, nicht das chronologische Nachschauen-was-noch-drauf-war im Detail, das nicht Teil dieser Freigabe ist). + +```bash +# Stabile Device-Namen ermitteln +ls -la /dev/disk/by-id/ | grep -E "ata-SAMSUNG|ata-ST1000" + +# Beide Platten komplett löschen (IRREVERSIBEL — vorher SMART + Dateninhalt prüfen!) +wipefs -a /dev/sda /dev/sdb +sgdisk --zap-all /dev/sda +sgdisk --zap-all /dev/sdb + +# Mirror anlegen +zpool create -o ashift=12 nas mirror \ + /dev/disk/by-id/ata-SAMSUNG_HD103SJ_S246J9AZ807325 \ + /dev/disk/by-id/ata-ST1000DM003-1CH162_Z1D7GZ5Q + +zfs create -o compression=lz4 -o atime=off nas/freigabe +``` + +## 4. HDD-Spindown (Stromsparen) + +```bash +hdparm -S 241 /dev/disk/by-id/ata-SAMSUNG_HD103SJ_S246J9AZ807325 /dev/disk/by-id/ata-ST1000DM003-1CH162_Z1D7GZ5Q + +cat >> /etc/hdparm.conf <<'EOF' + +# NAS-HDDs: Standby nach 30 Minuten Inaktivität (Stromsparen, ZFS-Mirror "nas") +/dev/disk/by-id/ata-SAMSUNG_HD103SJ_S246J9AZ807325 { + spindown_time = 241 +} +/dev/disk/by-id/ata-ST1000DM003-1CH162_Z1D7GZ5Q { + spindown_time = 241 +} +EOF +``` + +`smartd` (Debian-Default-Config, `-n standby`) weckt schlafende Platten nicht — nichts weiter zu tun. Der ZFS-Pool ist bewusst **nicht** als PVE-Storage registriert, damit `pvestatd` ihn nicht permanent pollt. + +## 5. Git-Repo (dieses Repo) + +```bash +mkdir -p /root/projects/homelab-config +# Eigenen Projekt-Kontext + chronologisches Log anlegen (Format/Inspiration: +# ENTSCHEIDUNGEN.md und der Aufbau dieses Portfolios selbst) +cd /root/projects/homelab-config +git init +git add . +git commit -m "Initial project setup" +``` + +Später auf die NAS-Freigabe verlagert (nachdem CT 101 steht): + +```bash +mv /root/projects/homelab-config /nas/freigabe/homelab-config +chown -R 101000:101000 /nas/freigabe/homelab-config +git config --global --add safe.directory /nas/freigabe/homelab-config +``` + +⚠️ **Warum `chown 101000:101000` und `safe.directory`:** Der Ordner liegt physisch im ZFS-Dataset, das per Bind-Mount in CT 101 (unprivileged, UID-Offset 100000) eingebunden ist. Ohne `safe.directory`-Eintrag verweigert Git auf dem PVE-Host die Arbeit ("dubious ownership"), weil der Owner (101000) nicht root ist. + +## 6. Wake-on-LAN-Trigger (für PC1, ausgelöst von Guacamole/VPS) + +```bash +apt-get install -y wakeonlan +useradd -m -s /bin/bash wolonly +mkdir -p /home/wolonly/.ssh +ssh-keygen -t ed25519 -f /root/.ssh/wol_vps_key -N "" -C "vps-wol-trigger" + +cat > /usr/local/bin/wake-pc1.sh <<'EOF' +#!/usr/bin/env bash +wakeonlan -i +echo "Magic packet an gesendet." +EOF +chmod +x /usr/local/bin/wake-pc1.sh + +KEY=$(cat /root/.ssh/wol_vps_key.pub) +echo "no-port-forwarding,no-x11-forwarding,no-agent-forwarding,command=\"/usr/local/bin/wake-pc1.sh\" $KEY" > /home/wolonly/.ssh/authorized_keys +chown -R wolonly:wolonly /home/wolonly/.ssh +chmod 700 /home/wolonly/.ssh +chmod 600 /home/wolonly/.ssh/authorized_keys +``` + +Der private Key (`/root/.ssh/wol_vps_key`) wird in die Guacamole-Verbindung "Wake PC1" eingebettet (siehe `06-vps-strato.md`). + +⚠️ **NICHT TUN:** `restrict,command="..."` als Kurzform in `authorized_keys` verwenden. `restrict` schließt `no-pty` mit ein — Guacamoles SSH-Client fordert aber immer ein Pseudo-Terminal an, auch für einen reinen Einzelbefehl. Mit `no-pty` schlägt die Verbindung mit „Unable to allocate PTY" fehl. Stattdessen die Einzel-Restriktionen explizit auflisten (siehe oben), `no-pty` weglassen. + +⚠️ **NICHT TUN:** MAC-Adresse mit Bindestrichen (`D8-BB-C1-90-66-3F`, Windows-`ipconfig`-Format) an `wakeonlan` übergeben. Erwartet Doppelpunkte (``), sonst Fehler "not a hardware address". + +## 7. DNS-Falle bei neuen Containern (wichtig für alle folgenden `pct create`-Schritte) + +Seit der PVE-Host selbst Tailscale-Mitglied ist, zeigt sein `/etc/resolv.conf` auf `100.100.100.100` (Tailscales interner Stub-Resolver). Neue Container erben das automatisch (kein `--nameserver` bei `pct create` angegeben) — sind selbst aber noch keine Tailscale-Mitglieder und können diese Adresse nicht erreichen. Resultat: `apt-get update` hängt endlos, ohne Fehlermeldung. + +**Immer bei `pct create` mitgeben:** +```bash +--nameserver +``` + +**Für bereits existierende Container dauerhaft fixen** (übersteht Neustarts, im Gegensatz zum manuellen Editieren von `/etc/resolv.conf`, das bei jedem Containerstart überschrieben wird): +```bash +pct set --nameserver +``` +Angewendet auf CT 100, 101, 102, 103. + +## 8. TUN-Device für Tailscale-in-LXC (für CT 102, CT 103) + +Unprivileged Container haben standardmäßig kein `/dev/net/tun` — ohne das startet `tailscaled` nicht. + +```bash +pct stop +cat >> /etc/pve/lxc/.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 +``` + +Danach sollte `ls -la /dev/net/tun` im Container ein Character-Device zeigen (nicht "No such file or directory"). + +## 9. Tailscale-Route-Konflikt bei LAN-eigenen Containern + +⚠️ **NICHT TUN:** `--accept-routes` bei einem Container setzen, der selbst schon direkt im ``-LAN hängt (z.B. CT 102). Der PVE-Host advertised dieses Subnetz als Tailscale-Route — akzeptiert der Container das, landet in Tailscales eigener Policy-Routing-Tabelle (`ip route show table 52`) ein Eintrag ` dev tailscale0`, der Rückverkehr zur eigenen LAN-IP fälschlich über Tailscale statt direkt über `eth0` schickt. Symptom: Dienste auf dem Container sind über die LAN-IP nicht mehr erreichbar (Timeout, nicht Refused), obwohl sie laufen. + +Für Container, die bereits nativ im LAN sitzen: `tailscale up ... --accept-routes=false`. Peer-zu-Peer-Erreichbarkeit zu anderen Tailnet-Mitgliedern funktioniert davon unabhängig. + +## 10. PVE-Weboberfläche mit echtem Zertifikat (via `tailscale serve`) + +Proxmox' Standard-Webinterface (`:8006`) nutzt ein selbstsigniertes Zertifikat → Browser-Warnung bei jedem Zugriff. Gleicher Trick wie bei Vaultwarden: **nicht** Proxmox' eigene Zertifikatsdateien anfassen (die sind für die interne Cluster-Kommunikation kritisch), sondern per `tailscale serve` reverse-proxyen. + +```bash +tailscale serve --bg https+insecure://localhost:8006 +``` + +`https+insecure://` ist hier bewusst nötig — das Backend (`pveproxy` auf `:8006`) hat selbst ein selbstsigniertes Zertifikat, das `tailscale serve` beim lokalen Proxy-Hop akzeptieren muss, ohne es zu verifizieren (nur der äußere, dem Nutzer sichtbare Hop bekommt das echte Tailscale-Zertifikat). + +Erreichbar unter `https://.` (kein `:8006` mehr nötig, Port 443 Standard), automatisch gültiges Let's-Encrypt-Zertifikat, automatische Erneuerung. Voraussetzung: „HTTPS Certificates" in der Tailscale-Admin-Konsole aktiviert (siehe Abschnitt 2). + +## Referenz: aktuelle LXC-Übersicht + +```bash +pct list +for id in 100 101 102 103; do echo "CT $id:"; pct config $id | grep -E "memory|cores|net0"; done +``` diff --git a/02-ct100-adguard.md b/02-ct100-adguard.md new file mode 100644 index 0000000..25c6600 --- /dev/null +++ b/02-ct100-adguard.md @@ -0,0 +1,154 @@ +# CT 100 — AdGuard Home — Rebuild-Runbook + +## 1. Container erstellen (via Proxmox Community Script) + +Interaktiv am PVE-Host ausführen (whiptail-Dialog): + +```bash +bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/adguard.sh)" +``` + +⚠️ **NICHT TUN:** `adguardhome.sh` als Dateiname verwenden — 404. Der korrekte Dateiname im Repo ist `ct/adguard.sh`. + +**Einstellungen im Advanced-Install-Dialog:** +- Unprivileged, Debian 13 +- 1 CPU-Core, **1024 MiB RAM** (Standard 512 MiB reicht nicht für die großen Filterlisten, siehe unten), 2 GB Disk +- Bridge `vmbr0`, statische IPv4 `/24` +- IPv6: none (Container bekommt trotzdem automatisch SLAAC-Adressen, unkritisch) +- Timezone Europe/Berlin, kein HTTP-Proxy + +Falls mit 512 MiB angelegt, nachträglich erhöhen: +```bash +pct set 100 --memory 1024 +``` + +## 2. Web-Wizard (einmalig, interaktiv unter `http://:3000` bzw. später Port 80) + +- Admin-User: `root` (oder eigener Name) +- Passwort: eigenes wählen +- Upstream-DNS: `https://dns10.quad9.net/dns-query` (Quad9 DoH) + +Alle folgenden Schritte patchen `/opt/AdGuardHome/AdGuardHome.yaml` direkt (kein Web-UI-Login nötig, da Root-Zugriff auf den Container besteht). **Immer:** +```bash +pct exec 100 -- systemctl stop AdGuardHome +# Datei anpassen (siehe unten) +pct exec 100 -- systemctl start AdGuardHome +``` + +⚠️ **NICHT TUN:** die Config bei laufendem Dienst editieren — Änderungen werden beim nächsten internen Save von AdGuard überschrieben. + +## 3. Conditional Forwarding für `fritz.box` + +In `dns:` → `upstream_dns:` ergänzen: +```yaml + upstream_dns: + - https://dns10.quad9.net/dns-query + - '[/fritz.box/]' +``` + +Private Reverse-DNS aktivieren (für Gerätenamen im Query-Log): +```yaml + local_ptr_upstreams: + - +``` +(Im Web-UI: Einstellungen → DNS-Einstellungen → Private Reverse-DNS-Server aktivieren, ``, privates Subnetz ``) + +## 4. Filterlisten + +`filters:`-Block (Filter-IDs müssen eindeutig sein, 1–2 sind Standard-AdGuard-Defaults): + +```yaml +filters: + - enabled: true + url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt + name: AdGuard DNS filter + id: 1 + - enabled: false + url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_2.txt + name: AdAway Default Blocklist + id: 2 + - enabled: true + url: https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt + name: HaGeZi's Pro Blocklist + id: 3 + - enabled: true + url: https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/tif.txt + name: HaGeZi's Threat Intelligence Feeds + id: 4 + - enabled: true + url: https://filters.adtidy.org/extension/chromium/filters/11.txt + name: AdGuard Mobile Ads filter + id: 5 + - enabled: true + url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_53.txt + name: AWAvenue Ads Rule + id: 7 + - enabled: true + url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_59.txt + name: AdGuard DNS Popup Hosts filter + id: 8 +whitelist_filters: + - enabled: true + url: https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/whitelist-referral.txt + name: HaGeZi's Allowlist Referral + id: 6 +``` + +⚠️ **NICHT TUN (bewusst weggelassen, nicht aus Versehen):** oisd NSFW, HaGeZi Anti-Piracy, ShadowWhisperer Dating List, HaGeZi Safesearch Not Supported (reine Content-Filter, kein Ad-Blocking-Zweck), HaGeZi DynDNS-Blocklist (Konflikt mit eigenem STRATO-DynDNS), HaGeZi Encrypted DNS/VPN/TOR/Proxy Bypass (Risiko für den Firmen-VPN-Tunnel der Ehefrau), Dandelion Sprout's Anti-Malware List + ShadowWhisperer's Malware List (redundant zu HaGeZi TIF, nur RAM-Verschwendung). + +## 5. DNS-Rewrites (interne Kurznamen) + +```yaml + rewrites: + - domain: nas.pve + answer: + enabled: true + - domain: adguard.pve + answer: + enabled: true +``` + +⚠️ **NICHT TUN:** das Feld `enabled: true` weglassen. AdGuard Home ergänzt es beim nächsten Neustart automatisch mit dem Default `enabled: false` — der Rewrite bleibt dann wirkungslos (Query geht bis zu den Root-DNS-Servern durch, NXDOMAIN), ohne erkennbaren Fehler in der Config selbst. + +## 6. Passwort-Reset (falls nötig) + +AdGuard speichert nur den bcrypt-Hash, keine Wiederherstellung möglich — neues Passwort setzen statt altes wiederherstellen: + +```bash +pct exec 100 -- apt-get install -y apache2-utils # für htpasswd -B +NEWPASS="" +HASH=$(pct exec 100 -- htpasswd -bnBC 10 "" "$NEWPASS" | tr -d ':\n') +# In AdGuardHome.yaml, Zeile "password:" unter users: ersetzen durch $HASH +``` + +Der von `htpasswd -B` erzeugte Hash hat `$2y$`-Präfix statt AdGuards eigenem `$2a$` — wird von Go/AdGuard-Home-bcrypt trotzdem korrekt akzeptiert. + +## 7. Fritzbox-DHCP umstellen (manueller Schritt am Router, kein CLI) + +Fritzbox-Oberfläche → **Heimnetz → Netzwerk → Netzwerkeinstellungen** → IPv4-Adressen bearbeiten → Abschnitt **DNS-Server** → „Andere DNSv4-Server verwenden" → Bevorzugter DNSv4-Server: ``. + +⚠️ **NICHT TUN (verworfene Variante):** Client fragt die Fritzbox, die AdGuard als Upstream nutzt. Stattdessen: Client fragt AdGuard direkt (wie oben), Fritzbox nur noch für `fritz.box`-Auflösung via Conditional Forwarding. + +## 8. IPv6-DNS aktivieren + +Standardmäßig lauscht AdGuard Home nur auf IPv4 (`bind_hosts: [0.0.0.0]`), obwohl der Container per SLAAC auch IPv6-Adressen bekommt — DNS-Anfragen über IPv6 werden dann gar nicht erst beantwortet, unabhängig davon, was die Fritzbox verteilt. + +```yaml +dns: + bind_hosts: + - 0.0.0.0 + - "::" +``` + +⚠️ **NICHT TUN:** direkt vom PVE-Host aus testen, falls der selbst kein SLAAC-IPv6 auf der Bridge hat (`ip -6 addr show vmbr0` prüfen — meist nur Link-Local vorhanden). Test stattdessen von einem Container aus, der eine globale IPv6-Adresse hat (z. B. `pct exec 101 -- dig @ google.com`). + +**Manueller Schritt an der Fritzbox:** Heimnetz → Netzwerk → Netzwerkeinstellungen → IPv6-Adressen bearbeiten → DNS-Server → eigenen DNSv6-Server eintragen. **Die ULA-Adresse verwenden** (`fd23:...`, per `ip -6 addr show eth0` im Container ermitteln — steht als "scope global dynamic mngtmpaddr"), **nicht** die GUA (`2a02:...`) — die ULA bleibt stabil, die GUA kann sich bei einem ISP-Präfixwechsel ändern und die Konfiguration stillschweigend brechen. + +## Verifikation + +```bash +dig @ doubleclick.net # sollte 0.0.0.0 liefern (geblockt) +dig @ google.com # normale Antwort +dig @ fritz.box # (Conditional Forwarding) +dig @ nas.pve # (Rewrite) +``` diff --git a/03-ct101-nas.md b/03-ct101-nas.md new file mode 100644 index 0000000..0d34f4f --- /dev/null +++ b/03-ct101-nas.md @@ -0,0 +1,145 @@ +# CT 101 — NAS (Samba/SFTP/Filebrowser) — Rebuild-Runbook + +Voraussetzung: ZFS-Pool `nas` mit Dataset `nas/freigabe` existiert auf dem PVE-Host (siehe `01-pve-host.md`). + +## 1. Container erstellen + +```bash +pct create 101 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst \ + --hostname nas \ + --unprivileged 1 \ + --cores 1 \ + --memory 512 \ + --swap 512 \ + --rootfs local-lvm:4 \ + --net0 name=eth0,bridge=vmbr0,ip=/24,gw= \ + --features nesting=1 \ + --timezone Europe/Berlin \ + --onboot 1 \ + --nameserver + +pct set 101 -mp0 /nas/freigabe,mp=/srv/freigabe +chown -R 100000:100000 /nas/freigabe +pct start 101 +``` + +Ownership `100000:100000` = Standard-UID-Offset für unprivileged Container (Container-UID 0 = Host-UID 100000). + +## 2. Samba + +```bash +pct exec 101 -- apt-get update +pct exec 101 -- apt-get install -y samba openssh-server +``` + +Debians `samba`-Metapaket zieht standardmäßig `samba-ad-dc` + `winbind` mit (nicht gebraucht, kein AD-DC-Zweck): +```bash +pct exec 101 -- systemctl disable --now winbind +pct exec 101 -- systemctl mask samba-ad-dc +``` + +`/etc/samba/smb.conf` ergänzen: +```ini +[freigabe] + path = /srv/freigabe + browseable = yes + read only = no + guest ok = no + valid users = + create mask = 0664 + directory mask = 0775 +``` + +Workgroup und Mindest-Protokoll im `[global]`-Block: +```ini + workgroup = WORKGROUP + server min protocol = SMB2 +``` + +⚠️ **NICHT TUN:** das Standard-`[homes]`-Share per `sed` nur an der Kopfzeile auskommentieren. Der Block hat mehrere Folgezeilen (`browseable`, `create mask`, `valid users = %S` etc.) — werden die nicht mit auskommentiert, hängen sie als „verwaiste" Zeilen im vorherigen Abschnitt (`[global]`) und überschreiben dort Werte, u.a. `valid users = %S`. Folge: IPC$-Zugriff für **alle** Nutzer blockiert (`NT_STATUS_ACCESS_DENIED`), auch für die eigentlich erlaubte Freigabe. Immer den **kompletten** `[homes]`-Block (Kopfzeile + alle Optionszeilen bis zur nächsten Sektion) auskommentieren. Mit `testparm -s` nach jeder Änderung prüfen, dass `[global]` sauber bleibt. + +Benutzer anlegen: +```bash +pct exec 101 -- useradd -M -s /bin/bash -d /srv/freigabe +pct exec 101 -- smbpasswd -a +pct exec 101 -- smbpasswd -e +pct exec 101 -- chpasswd # gleiches Passwort auch als Unix-Login setzen (für SSH/SFTP) +pct exec 101 -- chown -R : /srv/freigabe +pct exec 101 -- systemctl restart smbd nmbd +``` + +Unix- und Samba-Passwort bewusst identisch gehalten — ein Passwort für SMB, SFTP und Filebrowser. + +## 3. SSH/SFTP-Zugriff + +Mit obigem `useradd -s /bin/bash` ist SSH bereits nutzbar (Shell + Home = `/srv/freigabe`, Passwort per `chpasswd` gesetzt). Kein separater Schritt nötig, wenn Schritt 2 komplett ausgeführt wurde. + +⚠️ **NICHT TUN:** einen passwortlosen SSH-Key für Fernzugriff (z.B. aus Guacamole) auf dieses Share hinterlegen. Zugriff auf sensible Daten (Dokumente, Fotos) sollte immer noch ein Passwort verlangen — bei Kompromittierung der aufrufenden Instanz (z.B. Guacamole/VPS) sonst direkter, ungeschützter Zugriff. Auch verschlüsselte Keys mit Passphrase lösen kein echtes UX-Problem (siehe `06-vps-strato.md`, Abschnitt Guacamole-Verbindungen) — einfach normales Passwort verwenden. + +## 3b. Zweite Freigabe: Backups (read-only) + +Bind-Mount und Samba-Share für `/nas/backups` (siehe `07-backup-restore.md`) — bewusst **read-only**, da die Backups volle Container-Images inkl. aller Secrets enthalten: + +```bash +pct set 101 -mp1 /nas/backups,mp=/srv/backups,backup=0 +``` + +`/etc/samba/smb.conf` ergänzen: +```ini +[backups] + path = /srv/backups + browseable = yes + read only = yes + guest ok = no + valid users = +``` + +Kein separater Nutzer nötig, `` deckt beide Shares ab. Erreichbar unter `\\nas.pve\backups`. + +## 4. Filebrowser (grafische Web-Oberfläche) + +```bash +VER=$(curl -s https://api.github.com/repos/filebrowser/filebrowser/releases/latest | grep -o '"tag_name": "[^"]*"' | cut -d'"' -f4) +curl -fsSL -o /tmp/filebrowser.tar.gz "https://github.com/filebrowser/filebrowser/releases/download/$VER/linux-amd64-filebrowser.tar.gz" +tar xzf /tmp/filebrowser.tar.gz -C /tmp filebrowser +pct push 101 /tmp/filebrowser /usr/local/bin/filebrowser +pct exec 101 -- chmod +x /usr/local/bin/filebrowser +``` + +⚠️ **NICHT TUN:** das offizielle `curl | bash`-Installskript von Filebrowser verwenden — stattdessen Release-Binary direkt von GitHub laden (transparenter, keine Shell-Pipe von einem Skript, das man vorher nicht liest). + +Config + Service: +```bash +pct exec 101 -- mkdir -p /var/lib/filebrowser +pct exec 101 -- chown : /var/lib/filebrowser +pct exec 101 -- su -s /bin/bash -c '/usr/local/bin/filebrowser config init -d /var/lib/filebrowser/filebrowser.db' +pct exec 101 -- su -s /bin/bash -c '/usr/local/bin/filebrowser config set -a 0.0.0.0 -p 8080 -r /srv/freigabe -d /var/lib/filebrowser/filebrowser.db' +pct exec 101 -- su -s /bin/bash -c "/usr/local/bin/filebrowser users add '' --perm.admin -d /var/lib/filebrowser/filebrowser.db" + +cat > /etc/systemd/system/filebrowser.service <<'EOF' +[Unit] +Description=Filebrowser Web UI +After=network.target srv-freigabe.mount + +[Service] +User= +Group= +ExecStart=/usr/local/bin/filebrowser -d /var/lib/filebrowser/filebrowser.db +Restart=on-failure + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload +systemctl enable --now filebrowser +``` + +Erreichbar unter `http://nas.pve:8080` (LAN + Tailnet). **Bewusst nicht öffentlich exponiert** — für den Internetcafé-Fall gibt's den SFTP-Dateibrowser innerhalb der TOTP-gesicherten Guacamole-Verbindung (siehe `06-vps-strato.md`). + +## Verifikation + +```bash +smbclient -L // -U %'' +sftp @ +curl -s -o /dev/null -w "%{http_code}\n" http://:8080/ +``` diff --git a/04-ct102-management-desktop.md b/04-ct102-management-desktop.md new file mode 100644 index 0000000..9d3ad91 --- /dev/null +++ b/04-ct102-management-desktop.md @@ -0,0 +1,164 @@ +# CT 102 — Management-Desktop (Browser-VNC) — Rebuild-Runbook + +Zweck: gemeinsamer grafischer Zugang (für Nutzer + Ehefrau) zu allen Web-Oberflächen, ohne die RDP-Session von PC1 zu belegen (Windows Pro erlaubt nur eine aktive Sitzung). + +## 1. Container erstellen + +```bash +pct create 102 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst \ + --hostname browser \ + --unprivileged 1 \ + --cores 2 \ + --memory 1536 \ + --swap 512 \ + --rootfs local-lvm:6 \ + --net0 name=eth0,bridge=vmbr0,ip=/24,gw= \ + --features nesting=1 \ + --timezone Europe/Berlin \ + --onboot 1 \ + --nameserver +pct start 102 +``` + +## 2. TUN-Device für Tailscale (siehe `01-pve-host.md`, Abschnitt 8) + +```bash +pct stop 102 +cat >> /etc/pve/lxc/102.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 102 +``` + +## 3. Desktop-Umgebung + Browser + VNC + +```bash +pct exec 102 -- apt-get update +pct exec 102 -- apt-get install -y curl ca-certificates +pct exec 102 -- apt-get install -y xfce4 xfce4-terminal firefox-esr tigervnc-standalone-server tigervnc-common dbus-x11 fonts-liberation +``` + +VNC-Konfiguration: +```bash +pct exec 102 -- mkdir -p /root/.config/tigervnc +VNCPASS="" +pct exec 102 -- bash -c "printf '%s\n%s\nn\n' '$VNCPASS' '$VNCPASS' | vncpasswd -f > /root/.config/tigervnc/passwd && chmod 600 /root/.config/tigervnc/passwd" + +pct exec 102 -- bash -c "cat > /root/.config/tigervnc/xstartup <<'EOF' +#!/bin/sh +unset SESSION_MANAGER +unset DBUS_SESSION_BUS_ADDRESS +exec startxfce4 +EOF +chmod +x /root/.config/tigervnc/xstartup" +``` + +⚠️ **NICHT TUN:** Config unter `~/.vnc/` anlegen. Neuere TigerVNC-Versionen erwarten `~/.config/tigervnc/` (XDG-Standard) und versuchen beim Start eine automatische Migration von `~/.vnc/`, die zuverlässig fehlschlägt ("Could not migrate ... to ... tigervnc", Dienst crasht in einer Neustart-Schleife). Config direkt im neuen Pfad anlegen, `~/.vnc/` gar nicht erst befüllen. + +Systemd-Service: +```bash +cat > /etc/systemd/system/vncserver.service <<'EOF' +[Unit] +Description=TigerVNC Server +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/root +ExecStartPre=-/usr/bin/vncserver -kill :1 +ExecStart=/usr/bin/vncserver -fg -localhost no -geometry 1600x900 -depth 24 :1 +ExecStop=/usr/bin/vncserver -kill :1 +Restart=on-failure + +[Install] +WantedBy=multi-user.target +EOF +systemctl daemon-reload +systemctl enable --now vncserver +``` + +`-localhost no` ist Pflicht — sonst nimmt TigerVNC nur lokale Verbindungen an, Guacamole (auf einer anderen Maschine) käme nicht durch. + +## 4. Firefox-Profil + Startseiten + +```bash +pct exec 102 -- bash -c "DISPLAY=:1 firefox-esr -CreateProfile 'default /root/.mozilla/firefox/default'" + +pct exec 102 -- bash -c "cat > /root/.mozilla/firefox/default/user.js <<'EOF' +user_pref(\"browser.startup.homepage\", \"http://adguard.pve|https://.|http://nas.pve:8080|https://guac.|https://vaultwarden.\"); +user_pref(\"browser.startup.page\", 1); +user_pref(\"browser.tabs.warnOnClose\", false); +EOF +" +``` + +⚠️ **NICHT TUN:** sich darauf verlassen, dass dieses manuell erstellte `default`-Profil auch tatsächlich benutzt wird. `firefox-esr` legt beim **allerersten echten Start** (nicht beim `-CreateProfile`-Aufruf, sondern beim ersten normalen Öffnen über die GUI) automatisch ein zusätzliches Profil an (Name-Muster `.default-esr`) und trägt es in `profiles.ini` unter einem `[InstallXXXX]`-Block mit `Locked=1` als tatsächlichen Standard ein — das überschreibt die klassische `Default=1`-Markierung des manuell erstellten Profils. Ergebnis: die `user.js` im manuellen Profil wird nie gelesen, Startseiten erscheinen einfach nicht, ohne Fehlermeldung. + +**Nach dem ersten echten GUI-Start prüfen und ggf. korrigieren:** +```bash +cat /root/.mozilla/firefox/profiles.ini # zeigt unter [InstallXXXX] den tatsächlich genutzten Profil-Pfad +cp /root/.mozilla/firefox/default/user.js /root/.mozilla/firefox//user.js +``` + +Öffnet beim Start automatisch: AdGuard, PVE-Weboberfläche, NAS-Filebrowser, Guacamole, Vaultwarden. + +## 5. Tailscale-Mitgliedschaft + +```bash +pct exec 102 -- bash -c "curl -fsSL https://tailscale.com/install.sh | sh" +pct exec 102 -- tailscale up --authkey= --hostname=browser --accept-routes=false +``` + +⚠️ **NICHT TUN:** `--accept-routes` (ohne `=false`) setzen. Siehe `01-pve-host.md`, Abschnitt 9 — der Container sitzt selbst schon im ``-LAN, das Akzeptieren der vom PVE-Host advertisten Subnet-Route erzeugt einen Policy-Routing-Konflikt (Tabelle 52), der die eigene LAN-Erreichbarkeit über Timeouts killt. Peer-zu-Peer zu anderen Tailnet-Mitgliedern (z.B. Vaultwarden) funktioniert auch ohne `--accept-routes`. + +## 6. Guacamole-Verbindung (auf dem VPS, siehe `06-vps-strato.md`) + +VNC-Verbindung "Management-Desktop (Browser)", Hostname zeigt auf die **Tailscale-IP** des Containers (``), nicht die LAN-IP — damit läuft die Backend-Strecke Guacamole↔VNC-Server über den WireGuard-verschlüsselten Tailscale-Tunnel statt übers offene LAN. + +⚠️ **Bekannte Einschränkung:** Guacamoles VNC-Protokoll-Implementierung unterstützt kein TLS/Zertifikat (anders als RDP mit `security`/`ignore-cert`-Parametern) — das ist eine echte Protokoll-Grenze, keine Fehlkonfiguration. Der Tailscale-Tunnel ist der bestmögliche Ersatz dafür. + +## 7. Downloads aus der VNC-Sitzung aufs lokale Gerät übertragen + +Ein Download in Firefox innerhalb der Guacamole-VNC-Sitzung landet zunächst nur auf CT 102 selbst (`/root/Downloads`), nicht automatisch auf dem tatsächlichen Endgerät des Nutzers. Fix: dedizierter, stark eingeschränkter SFTP-Only-User als Guacamole-Begleitverbindung. + +```bash +useradd -M -s /usr/sbin/nologin transfer +echo 'transfer:' | chpasswd + +cat >> /etc/ssh/sshd_config <<'EOF' + +Match User transfer + ChrootDirectory /root/Downloads + ForceCommand internal-sftp + AllowTcpForwarding no + X11Forwarding no +EOF + +mkdir -p /run/sshd +systemctl restart ssh +``` + +⚠️ **NICHT TUN:** nach einer `sshd_config`-Änderung `systemctl reload ssh` statt `restart` verwenden. Reload löst ein SIGHUP-getriggertes Re-Exec des laufenden sshd-Prozesses aus, **ohne** die vollständige systemd-Start-Sequenz zu durchlaufen — dabei fehlt `/run/sshd` (Privilege-Separation-Verzeichnis), Ergebnis: `fatal: Cannot bind any address`, Dienst stirbt komplett. Immer `restart` nutzen (oder `mkdir -p /run/sshd` vor einem Reload sicherstellen). + +`/root/Downloads` erfüllt die OpenSSH-Chroot-Anforderungen bereits von selbst (root:root, kein Gruppen-/Other-Schreibrecht, Standard-Firefox-Download-Ziel für root). + +In Guacamole die bestehende VNC-Verbindung um SFTP-Begleitparameter ergänzen (**gleiche Tailscale-IP wie der Hostname-Parameter**, aus Konsistenzgründen — sonst liefe der Dateiinhalt beim Download plaintext übers LAN, während die VNC-Steuerung schon verschlüsselt tunnelt): +```sql +enable-sftp = true +sftp-hostname = +sftp-port = 22 +sftp-username = transfer +sftp-root-directory = / +``` +Passwort nicht als Parameter setzen — wird beim ersten Datei-Transfer-Zugriff im Guacamole-Seitenpanel abgefragt. + +## Verifikation + +```bash +nc -zv -w3 5901 # sollte "open" zeigen (von einem Tailnet-Mitglied aus) +pct exec 102 -- systemctl is-active vncserver +pct exec 102 -- systemctl is-active ssh +sftp transfer@ # sollte nach /root/Downloads chrooten (pwd zeigt "/") +``` diff --git a/05-ct103-vaultwarden.md b/05-ct103-vaultwarden.md new file mode 100644 index 0000000..d54367b --- /dev/null +++ b/05-ct103-vaultwarden.md @@ -0,0 +1,116 @@ +# CT 103 — Vaultwarden (Passwortmanager) — Rebuild-Runbook + +Sensibelster Dienst im gesamten Setup — bewusst **nur über Tailscale** erreichbar, nie öffentlich. + +## 1. Container erstellen + +```bash +pct create 103 local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst \ + --hostname vaultwarden \ + --unprivileged 1 \ + --cores 1 \ + --memory 512 \ + --swap 512 \ + --rootfs local-lvm:4 \ + --net0 name=eth0,bridge=vmbr0,ip=/24,gw= \ + --features nesting=1 \ + --timezone Europe/Berlin \ + --onboot 1 \ + --nameserver +pct start 103 +``` + +## 2. TUN-Device für Tailscale (siehe `01-pve-host.md`, Abschnitt 8) + +```bash +pct stop 103 +cat >> /etc/pve/lxc/103.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 103 +``` + +## 3. Docker + +```bash +pct exec 103 -- apt-get update +pct exec 103 -- apt-get install -y curl ca-certificates +pct exec 103 -- bash -c "curl -fsSL https://get.docker.com | sh" +``` + +## 4. Tailscale-Mitgliedschaft + HTTPS-Zertifikat + +```bash +pct exec 103 -- bash -c "curl -fsSL https://tailscale.com/install.sh | sh" +pct exec 103 -- tailscale up --authkey= --hostname=vaultwarden +``` + +Kein `--accept-routes` nötig — anders als CT 102 sitzt dieser Container nicht zusätzlich im ``-LAN in einer Weise, die einen Routing-Konflikt erzeugen würde (Netz-Interface ist zwar auf dem LAN, aber der Dienst wird ausschließlich über die Tailscale-IP angesprochen). + +Voraussetzung in der Tailscale-Admin-Konsole: **Settings → DNS → „HTTPS Certificates"** muss aktiviert sein (einmalig für den ganzen Tailnet, siehe `01-pve-host.md`). + +## 5. Vaultwarden via Docker Compose + +```bash +mkdir -p /opt/vaultwarden/data +ADMIN_TOKEN=$(openssl rand -base64 48 | tr -d '\n') + +cat > /opt/vaultwarden/docker-compose.yml <" + ADMIN_TOKEN: "$ADMIN_TOKEN" + SIGNUPS_ALLOWED: "true" + WEBSOCKET_ENABLED: "true" + volumes: + - ./data:/data + ports: + - "127.0.0.1:8080:80" +EOF + +cd /opt/vaultwarden && docker compose up -d +``` + +`SIGNUPS_ALLOWED` bewusst zuerst `true`, um das/die Konto(s) anzulegen. **Nach der Konto-Anlage zwingend zurückstellen:** +```bash +sed -i 's/SIGNUPS_ALLOWED: "true"/SIGNUPS_ALLOWED: "false"/' /opt/vaultwarden/docker-compose.yml +cd /opt/vaultwarden && docker compose up -d +``` + +## 6. HTTPS-Exposition via `tailscale serve` (kein Caddy, kein manuelles Zertifikat) + +```bash +tailscale serve --bg http://127.0.0.1:8080 +``` + +Erreichbar unter `https://vaultwarden.`, automatisch gültiges Let's-Encrypt-Zertifikat, automatische Erneuerung, **nicht öffentlich** (nur Tailnet-Mitglieder). + +⚠️ **NICHT TUN:** `tailscale serve --bg https / http://...` (alte Syntax). Neuere Tailscale-CLI-Versionen erwarten nur noch `tailscale serve --bg http://127.0.0.1:8080` (HTTPS wird automatisch angenommen). + +⚠️ **NICHT TUN:** Vaultwarden zusätzlich über Caddy auf dem VPS öffentlich exponieren (wie bei Guacamole). Bewusste Entscheidung: der Passwort-Tresor ist der sensibelste Dienst im gesamten Setup, öffentliche Erreichbarkeit erhöht die Angriffsfläche unnötig — der Internetcafé-Fall wird stattdessen über Guacamole selbst abgedeckt (SFTP/RDP-Zugriff auf andere Dienste, nicht direkt auf den Tresor). + +## 7. Konto-Anlage (manueller Web-Schritt, nicht per CLI) + +Über `https://vaultwarden.` (nur von einem Tailnet-Mitglied aus erreichbar) im Browser ein Konto mit **selbst gewähltem Master-Passwort** anlegen. Das Master-Passwort wird bewusst **nie** programmatisch gesetzt oder irgendwo notiert — es ist der einzige Schlüssel zum gesamten Tresor. + +## Bekannte Stolpersteine (bereits behoben, hier nur als Hinweis) + +* Beim Passwort-Import versehentlich Duplikate erzeugt (zweimal importiert) → behoben durch: alle Einträge auswählen → löschen → Papierkorb leeren → sauber neu importieren. + +## Verifikation + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/ # lokal, im Container +curl -s -o /dev/null -w "%{http_code}\n" https://vaultwarden./ # von einem Tailnet-Mitglied +tailscale serve status +``` diff --git a/06-vps-strato.md b/06-vps-strato.md new file mode 100644 index 0000000..c6d68db --- /dev/null +++ b/06-vps-strato.md @@ -0,0 +1,312 @@ +# VPS (STRATO) — Tailscale-Bridge + Guacamole-Gateway — Rebuild-Runbook + +Rolle: Tailscale-Mitglied + Subnet-Router-Bridge (für den Fall, dass kein Tailscale-Client verfügbar ist), Guacamole als öffentlich erreichbares HTML5-Gateway für den Internetcafé-Fall. + +## 1. OS-Installation (manueller Schritt im STRATO-Panel, nicht CLI) + +- Debian 13 (Konsistenz mit dem Rest der Infrastruktur) +- Public SSH-Key beim Anlegen hinterlegen (Key-only, kein Passwort-Login) — Key vom PVE-Host: `cat /root/.ssh/id_rsa.pub` +- Root-Passwort trotzdem setzen (nur als VNC-Konsolen-Fallback, falls SSH mal nicht geht) + +⚠️ **NICHT TUN:** Plesk oder n8n beim Setup mitinstallieren. Die VPS-Rolle bleibt bewusst minimal (nur Tailscale-Bridge + Guacamole) — Plesk ist für Webhosting mit mehreren Domains gedacht (hier nicht gebraucht, frisst RAM auf einem 2-GB-VPS), n8n ist ein eigenes Projekt-Thema. + +## 2. Grundsetup + +```bash +hostnamectl set-hostname vps +echo '127.0.1.1 vps' >> /etc/hosts + +export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a +apt-get update -qq +apt-get upgrade -y -qq -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" +``` + +## 3. Docker + +```bash +curl -fsSL https://get.docker.com | sh +``` + +## 4. Tailscale (mit Subnet-Route-Akzeptanz — dieser Host braucht sie tatsächlich) + +```bash +curl -fsSL https://tailscale.com/install.sh | sh +tailscale up --authkey= --accept-routes +``` + +Anders als bei CT 102 (siehe `04-ct102-management-desktop.md`) ist `--accept-routes` hier **richtig und nötig** — der VPS sitzt nicht selbst im ``-LAN, braucht die vom PVE-Host advertiste Subnet-Route also tatsächlich, um das Heimnetz zu erreichen (NAS, AdGuard etc.). + +Verifikation: +```bash +ping -c3 # AdGuard +ping -c3 # NAS +getent hosts nas.pve adguard.pve # DNS-Override auf AdGuard sollte greifen +``` + +## 5. Guacamole: Grundgerüst (guacd + Postgres + Webapp) + +```bash +mkdir -p /opt/guacamole/{extensions,guac-home,jdbc-schema,pg-data} + +# Postgres-JDBC-Schema besorgen (nur die Schema-Datei, NICHT die postgresql-Extension-Jar — siehe unten) +curl -fsSL -o /tmp/jdbc.tar.gz https://downloads.apache.org/guacamole/1.6.0/binary/guacamole-auth-jdbc-1.6.0.tar.gz +tar xzf /tmp/jdbc.tar.gz -C /tmp +cp /tmp/guacamole-auth-jdbc-1.6.0/postgresql/schema/001-create-schema.sql /opt/guacamole/jdbc-schema/ +rm -rf /tmp/jdbc.tar.gz /tmp/guacamole-auth-jdbc-1.6.0 + +# TOTP-Extension (wird tatsächlich gebraucht, nicht im Image enthalten) +curl -fsSL -o /tmp/totp.tar.gz https://downloads.apache.org/guacamole/1.6.0/binary/guacamole-auth-totp-1.6.0.tar.gz +tar xzf /tmp/totp.tar.gz -C /tmp +cp /tmp/guacamole-auth-totp-1.6.0/guacamole-auth-totp-1.6.0.jar /opt/guacamole/extensions/ +rm -rf /tmp/totp.tar.gz /tmp/guacamole-auth-totp-1.6.0 +``` + +⚠️ **NICHT TUN:** die `guacamole-auth-jdbc-postgresql-*.jar` manuell in `extensions/` legen. Das offizielle `guacamole/guacamole`-Docker-Image bringt diese Extension bereits eingebaut mit und aktiviert sie automatisch über die `POSTGRESQL_*`-Umgebungsvariablen (siehe Compose-Datei unten). Eine manuell hinzugefügte zweite Kopie führt zu einer Namenskollision (zwei `[postgresql]`-Provider gleichzeitig geladen) und macht den Login kaputt ("Invalid login" trotz korrektem Passwort). + +⚠️ **NICHT TUN:** `002-create-admin-user.sql` mit einspielen. Das legt den Standard-Account `guacadmin`/`guacadmin` an. Stattdessen den eigenen Nutzer direkt per SQL anlegen (Schritt 7) — kein Zeitfenster mit bekanntem Standard-Passwort. + +## 6. Docker Compose + +```bash +DBPASS=$(openssl rand -base64 24 | tr -d '=+/') + +cat > /opt/guacamole/docker-compose.yml <:8080:8080" + networks: + - guac-net + depends_on: + - guacd + - postgres + +networks: + guac-net: +EOF + +cd /opt/guacamole +docker compose up -d postgres # erst Postgres, damit das Schema importiert wird +sleep 10 +docker compose up -d # dann den Rest +``` + +`127.0.0.1:8080` + Tailscale-IP `:8080` gebunden — **nie an `0.0.0.0`**, sonst wäre Guacamole ungeschützt (kein TLS) direkt öffentlich erreichbar. Öffentlicher Zugriff läuft ausschließlich über Caddy (Schritt 9). + +## 7. Nutzer + Verbindungen per SQL anlegen + +Passwort-Hash-Formel (Guacamole-JDBC-spezifisch, **nicht** bcrypt wie bei AdGuard Home): +``` +password_hash = SHA256(UTF8(password + HEX_UPPERCASE(salt))) +``` +Salt wird als **Hex-String an das Passwort angehängt**, nicht als rohe Bytes verknüpft. + +```bash +python3 -c " +import hashlib, secrets +pw = '' +salt = secrets.token_bytes(32) +salt_hex = salt.hex().upper() +h = hashlib.sha256((pw + salt_hex).encode('utf-8')).hexdigest() +print('HASH:' + h) +print('SALT:' + salt_hex) +" +``` + +⚠️ **NICHT TUN:** Passwort und **rohe** Salt-Bytes verketten (`password.encode() + salt_bytes`). Das ist falsch und führt zu "Invalid login" trotz korrektem Passwort — die tatsächliche Formel verkettet den **Hex-String** des Salts (als Text), nicht die Binärdaten. (Nachgeprüft im Guacamole-Quellcode: `SHA256PasswordEncryptionService`.) + +Nutzer + Admin-Recht: +```bash +HASH="" +SALT="" +docker exec -i guac-postgres psql -U guacamole_user -d guacamole_db <', 'USER'); +INSERT INTO guacamole_user (entity_id, password_hash, password_salt, password_date) +SELECT entity_id, decode('${HASH}','hex'), decode('${SALT}','hex'), now() +FROM guacamole_entity WHERE name='' AND type='USER'; +INSERT INTO guacamole_system_permission (entity_id, permission) +SELECT entity_id, 'ADMINISTER' FROM guacamole_entity WHERE name='' AND type='USER'; +COMMIT; +EOSQL +``` + +Verbindungen (Beispiel NAS-SFTP, analog für RDP/SSH): +```sql +INSERT INTO guacamole_connection (connection_name, protocol) VALUES ('NAS Dateien (SSH/SFTP)', 'ssh'); +INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) +SELECT connection_id, 'hostname', '' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; +INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) +SELECT connection_id, 'port', '22' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; +INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) +SELECT connection_id, 'username', '' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; +INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) +SELECT connection_id, 'enable-sftp', 'true' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; +INSERT INTO guacamole_connection_parameter (connection_id, parameter_name, parameter_value) +SELECT connection_id, 'sftp-root-directory', '/srv/freigabe' FROM guacamole_connection WHERE connection_name='NAS Dateien (SSH/SFTP)'; +INSERT INTO guacamole_connection_permission (entity_id, connection_id, permission) +SELECT e.entity_id, c.connection_id, 'READ' +FROM guacamole_entity e, guacamole_connection c +WHERE e.name='' AND e.type='USER' AND c.connection_name='NAS Dateien (SSH/SFTP)'; +``` + +**Passwort bewusst nicht als Parameter gesetzt** — Guacamole fragt es beim Verbinden ab, kein Klartext in der DB. + +⚠️ **NICHT TUN:** Hostnamen wie `nas.pve` oder MagicDNS-Namen (`pc1`, ``) als `hostname`-Parameter in Guacamole-Verbindungen verwenden. Der `guacd`-Container läuft in einem eigenen Docker-Netzwerk und löst diese Namen nicht zuverlässig auf (Docker ersetzt den DNS-Resolver). Stattdessen **immer IP-Adressen** verwenden: `` (NAS), `` (PC1), `` (PVE-Host für den WoL-Trigger). + +Weitere Verbindungen nach demselben Muster: +- **"PC1 RDP"**: `protocol=rdp`, `hostname=`, `domain=pc1` (lokales Windows-Konto, nicht Domain-Konto!), `port=3389`, `username=`, `ignore-cert=true` +- **"Wake PC1"**: `protocol=ssh`, `hostname=`, `port=22`, `username=wolonly`, `private-key=`, `command=/usr/local/bin/wake-pc1.sh` + +⚠️ **NICHT TUN (RDP):** Falls „Authentication failure (invalid credentials?)" trotz korrektem Passwort kommt — meist fehlt der `domain`-Parameter bei einem lokalen Windows-Konto. Mit `whoami` auf dem Windows-Rechner den echten Kontonamen prüfen (`RECHNERNAME\Username`), `domain` entsprechend setzen. Zum Testen, ob ein Windows-Passwort grundsätzlich stimmt (unabhängig von RDP): `runas /user:RECHNERNAME\Username cmd` direkt am Windows-Gerät. + +⚠️ **NICHT TUN (private-key für "Wake PC1"):** Verschlüsselte SSH-Keys im neuen OpenSSH-Format (ed25519, mit Passphrase) funktionieren oft nicht mit Guacamoles `libssh2` ("Unable to extract public key ... Unsupported private key file format"). Falls ein Key mit Passphrase gebraucht wird: RSA im alten PEM-Format erzeugen (`ssh-keygen -m PEM -t rsa -b 4096 ...`), das ist kompatibel. Für "Wake PC1" reicht aber ein **unverschlüsselter** Key (kein Passphrase-Prompt gewünscht, da automatisiert ausgelöst). + +Zusätzliche Nutzer (z.B. Ehefrau) mit eingeschränktem Zugriff, gleiches Muster wie oben, aber nur die gewünschte(n) Verbindung(en) per `guacamole_connection_permission` freigeben, kein `guacamole_system_permission`. + +## 8. TOTP-2FA aktivieren + +TOTP funktioniert **nur** mit Datenbank-Auth (nicht mit dateibasierter `user-mapping.xml`-Auth) — Voraussetzung ist also bereits mit Schritt 5–7 erfüllt. + +```bash +chown -R 1001:1001 /opt/guacamole/guac-home +docker restart guacamole +``` + +⚠️ **NICHT TUN:** `/opt/guacamole/guac-home` root-owned lassen. Der Guacamole-Container läuft intern als UID 1001 (`guacamole`-User) — ohne Schreibrecht auf dieses Verzeichnis schlägt die TOTP-Erstregistrierung **still** fehl (kein Fehler im Log, einfach kein QR-Code, Login funktioniert normal ohne 2FA weiter). + +Danach: beim ersten Login jedes Nutzers erscheint automatisch ein QR-Code zur Ersteinrichtung (kein weiterer Konfigurationsschritt nötig). + +**Backup/Recovery falls Gerät mit Authenticator-App verloren geht:** +- Bevorzugt: TOTP-Secret zusätzlich auf ein zweites, unabhängiges Gerät legen (z.B. Partner/in), bevor es gebraucht wird +- Absoluter Notfall (kein Gerät mit dem Secret mehr verfügbar): direkter DB-Reset erzwingt bei nächstem Login einen neuen QR-Code + ```sql + DELETE FROM guacamole_user_attribute WHERE user_id = AND attribute_name LIKE 'guac-totp%'; + ``` + TOTP-Secret selbst liegt in `guacamole_user_attribute` (Attribute `guac-totp-key-secret`, `guac-totp-key-confirmed`), keine eigene Tabelle. + +⚠️ **NICHT TUN:** Email-basiertes 2FA suchen/erwarten. Guacamole unterstützt offiziell nur **Duo** und **TOTP**, kein E-Mail-OTP. + +## 9. Caddy (öffentlicher Reverse Proxy mit Auto-HTTPS) + +Voraussetzung: Domain bei STRATO konnektiert (dauert ggf., bis DENIC-Registrierung durch ist — separater Schritt im STRATO-Panel), A-Record `guac.` → `` im STRATO-DNS-Panel gesetzt (kein CLI-Schritt). + +```bash +apt-get install -y caddy + +cat > /etc/caddy/Caddyfile <<'EOF' +guac. { + @root path / + redir @root /guacamole/ + reverse_proxy 127.0.0.1:8080 +} +EOF + +systemctl reload caddy +``` + +Caddy holt sich automatisch ein Let's-Encrypt-Zertifikat, sobald der A-Record propagiert ist (mehrere Versuche in Intervallen, kein manuelles Eingreifen nötig — ggf. `systemctl restart caddy` um einen sofortigen neuen Versuch zu erzwingen). + +⚠️ **NICHT TUN:** Guacamole zusätzlich direkt öffentlich auf Port 8080 exponieren (`0.0.0.0:8080` in der Compose-Datei). Nur Caddy (80/443) und SSH (22, Key-only) sind öffentlich — Guacamole selbst bleibt hinter `127.0.0.1` + Tailscale-IP. + +## 10. Log-Hygiene: fail2ban + journald-Deckel + +Ohne das wächst SSH-Scan-Rauschen unbegrenzt und kann den VPS-Speicher füllen (in der Praxis beobachtet: ~4 GB/Woche bei einem vergleichbaren Setup ohne diese Maßnahmen). + +```bash +apt-get install -y fail2ban + +cat > /etc/fail2ban/jail.local <<'EOF' +[DEFAULT] +bantime = 1h +findtime = 10m +maxretry = 4 +backend = systemd + +[sshd] +enabled = true +port = 22 +EOF +systemctl enable --now fail2ban +``` + +```bash +sed -i 's/^#SystemMaxUse=/SystemMaxUse=200M/' /etc/systemd/journald.conf +systemctl restart systemd-journald +``` + +⚠️ **NICHT TUN:** fail2ban auch auf Containern installieren, die nur über Tailscale erreichbar sind (z.B. CT103/Vaultwarden). Ohne öffentliche Erreichbarkeit gibt es keinen Angriffsverkehr zum Bannen — reine Verschwendung. + +## 11. Docker-Log-Limits + +Docker-Container loggen standardmäßig **unbegrenzt** (`json-file`-Treiber ohne Limit) — betrifft normale Betriebslogs, nicht nur Angriffsverkehr, daher unabhängig von fail2ban relevant. In `docker-compose.yml` per YAML-Anchor für alle Services: + +```yaml +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" + +services: + guacd: + # ... + logging: *default-logging + postgres: + # ... + logging: *default-logging + guacamole: + # ... + logging: *default-logging +``` + +Nach Änderung: `docker compose up -d` (Container-Neustart nötig, Limits gelten nur für neu erstellte Container, nicht rückwirkend). + +## Zugriffswege (Zusammenfassung) + +| Weg | Erreichbarkeit | Absicherung | +|---|---|---| +| `https://guac.` | öffentlich, überall | Caddy/Let's-Encrypt-TLS + Guacamole-Login (Passwort + TOTP) + Brute-Force-Ban (5 Versuche → 5 Min Sperre) | +| `http://:8080/guacamole/` | nur Tailnet | wie oben, ohne öffentliche Exposition | + +## Verifikation + +```bash +curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/guacamole/ +curl -s -o /dev/null -w "%{http_code}\n" https://guac./guacamole/ +docker ps --format 'table {{.Names}}\t{{.Status}}' +``` diff --git a/07-backup-restore.md b/07-backup-restore.md new file mode 100644 index 0000000..4e225a3 --- /dev/null +++ b/07-backup-restore.md @@ -0,0 +1,99 @@ +# Backup & Restore — Laufzeit-Zustand (PVE-LXCs + VPS-Guacamole-Stack) + +Sichert den Laufzeit-Zustand, nicht nur Dateien: LXC-Configs, Datenbanken, Guacamole-Verbindungsdefinitionen/TOTP-Secrets, Vaultwardens verschlüsselten Tresor. Der ZFS-Mirror (`nas/freigabe`) deckt reinen Dateiverlust ab — dieses Kapitel deckt den Fall "Server/Container ist weg und muss aus dem Nichts wiederhergestellt werden". + +## 1. PVE-Host: Backup-Dataset + Ausschluss der NAS-Freigabe + +```bash +zfs create nas/backups +mkdir -p /nas/backups/pve /nas/backups/vps +pct set 101 -mp0 /nas/freigabe,mp=/srv/freigabe,backup=0 +``` + +`nas/backups` bewusst **nicht** als PVE-Storage registrieren (gleiche Begründung wie beim `nas`-Pool selbst, siehe `01-pve-host.md` Abschnitt 3) — `vzdump --dumpdir` schreibt direkt dorthin, kein `pvestatd`-Polling nötig. CT101s `mp0` (Bind-Mount von `/nas/freigabe`, 37GB) wird von Container-Backups ausgeschlossen, sonst läge die komplette NAS-Freigabe redundant auch im Backup-Ziel auf demselben Pool. + +## 2. PVE-Host: nächtlicher vzdump für alle 4 LXCs + +```bash +(crontab -l 2>/dev/null; echo "0 3 * * * vzdump 100 101 102 103 --mode snapshot --compress zstd --dumpdir /nas/backups/pve --prune-backups keep-last=3 >> /var/log/vzdump-cron.log 2>&1") | crontab - +``` + +LVM-Thin-Snapshot-Modus (`--mode snapshot`) → alle 4 Container laufen auf `local-lvm`, minimale Downtime. Vaultwarden (CT103, SQLite im WAL-Modus) ist damit ohne separates DB-Skript crash-konsistent abgedeckt — SQLite ist genau für diesen Fall ausgelegt. + +## 3. VPS: rsync/cron nachinstallieren + +```bash +apt update && apt install -y rsync cron +systemctl enable --now cron +``` + +⚠️ **NICHT vergessen:** Die minimale Debian-13-Installation auf dem VPS hat weder `rsync` (liefert `rrsync` mit, ab Version 3.4.x fertig unter `/usr/bin/rrsync`) noch `cron` vorinstalliert. Ohne `cron`-Paket schlägt `crontab -l`/`crontab -` mit "command not found" fehl. + +## 4. VPS: Backup-Skript für die Guacamole-Stack + +`/usr/local/bin/backup-guacamole.sh`: +```bash +#!/bin/bash +set -e +DEST=/root/backups +DATE=$(date +%F) +mkdir -p "$DEST" +docker exec guac-postgres pg_dump -U guacamole_user guacamole_db | gzip > "$DEST/guacamole_db_$DATE.sql.gz" +tar czf "$DEST/guacamole-opt_$DATE.tar.gz" --exclude=pg-data -C /opt guacamole +find "$DEST" -mtime +7 -delete +``` +```bash +chmod +x /usr/local/bin/backup-guacamole.sh +(crontab -l 2>/dev/null; echo "0 2 * * * /usr/local/bin/backup-guacamole.sh >> /var/log/backup-guacamole.log 2>&1") | crontab - +``` + +`pg_dump` statt rohem Kopieren von `pg-data` — transaktionssicher bei laufendem Postgres, roher Kopiervorgang eines laufenden Datenverzeichnisses wäre das nicht. Rotation: 7 Tage lokal auf dem VPS (das eigentliche Archiv liegt nach Schritt 6 auf dem NAS). + +## 5. Restriktiver Pull-Key (VPS → NAS) + +Dediziertes Keypair auf dem PVE-Host, **nicht** `id_rsa` oder `wol_vps_key` wiederverwenden: +```bash +ssh-keygen -t ed25519 -f /root/.ssh/backup_pull_key -N "" -C "backup-pull@pve" +``` + +Public Key auf dem VPS in `authorized_keys` ergänzen: +```bash +PUBKEY=$(cat /root/.ssh/backup_pull_key.pub) +ssh root@ "echo 'restrict,command=\"rrsync -ro /root/backups/\" $PUBKEY' >> /root/.ssh/authorized_keys" +``` + +→ Der Key kann ausschließlich lesend aus `/root/backups/` syncen, keine Shell, kein Port-/Agent-Forwarding. Im Gegensatz zum `wolonly`-Key (`01-pve-host.md` Abschnitt 6) ist `restrict` hier unproblematisch — der Pull läuft per Cron ohne PTY-Anforderung, das PTY-Problem betraf nur Guacamoles SSH-Client. + +## 6. PVE-Host: nächtlicher Pull-Cron + +```bash +(crontab -l 2>/dev/null; echo '0 4 * * * rsync -av -e "ssh -i /root/.ssh/backup_pull_key" root@: /nas/backups/vps/ >> /var/log/vps-backup-pull.log 2>&1') | crontab - +``` + +Zeitlich nach beiden vorherigen Jobs (02:00 VPS-Backup, 03:00 vzdump). + +⚠️ **NICHT TUN:** Auf dem Client den vollen Pfad wiederholen (`root@vps:/root/backups/ ziel/`). `rrsync -ro /root/backups/` fixiert das Restricted-Verzeichnis bereits serverseitig; ein vom Client mitgegebener Pfad wird (nach Entfernen des führenden `/`) daran **angehängt** — Ergebnis ist der doppelte, nicht existierende Pfad `/root/backups/root/backups/` (Fehler "No such file or directory"). Korrekt ist ein leerer Pfad nach dem Doppelpunkt: `root@:` — das adressiert die Wurzel des Restricted-Verzeichnisses. + +## 7. Restore + +**LXC (jede der 4 Instanzen):** +```bash +pct restore /nas/backups/pve/vzdump-lxc--.tar.zst --storage local-lvm +``` +Vaultwarden (CT103) braucht keinen separaten Restore-Schritt — Tresor + `rsa_key.pem` sind im LXC-Restore enthalten. + +**Guacamole-Stack (VPS):** Compose-Stack aus dem Tar-Archiv wiederherstellen, dann DB einspielen: +```bash +gunzip -c guacamole-opt_.tar.gz | tar x -C /opt # falls /opt/guacamole fehlt +cd /opt/guacamole && docker compose up -d postgres guacd +gunzip -c guacamole_db_.sql.gz | docker exec -i guac-postgres psql -U guacamole_user guacamole_db +docker compose up -d guacamole +``` + +## Referenz: Backup-Übersicht + +| Was | Wo (Quelle) | Mechanismus | Ziel | Rotation | +|---|---|---|---|---| +| 4 LXCs (100–103) | PVE-Host | `vzdump` Snapshot, 03:00 | `/nas/backups/pve` | keep-last=3 | +| Guacamole-DB + Configs | VPS | `pg_dump` + `tar`, 02:00 | `/root/backups` (VPS, Zwischenstand) | 7 Tage | +| — Pull der VPS-Backups | PVE-Host | `rsync` über `backup_pull_key`, 04:00 | `/nas/backups/vps` | folgt VPS-Rotation | diff --git a/08-monitoring.md b/08-monitoring.md new file mode 100644 index 0000000..a9277fd --- /dev/null +++ b/08-monitoring.md @@ -0,0 +1,235 @@ +# Monitoring — Rebuild-Runbook + +Neue LXC `CT104 monitoring` (``, Tailscale-Mitglied `monitoring.`) 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 + +```bash +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=/24,gw= \ + --nameserver --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"). + +```bash +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 + +```bash +docker exec influxdb influx setup --username admin --password '' \ + --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`): + +```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 + +```bash +pvesh create /cluster/metrics/server/monitoring \ + --type influxdb --server --port 8086 \ + --influxdbproto http --organization homelab --bucket raw --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: + +```bash +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://: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). + +```ini +# /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 ''` (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): + +```bash +python3 -m venv /opt/monitoring/kuma-venv +/opt/monitoring/kuma-venv/bin/pip install uptime-kuma-api +``` + +```python +from uptime_kuma_api import UptimeKumaApi, MonitorType, NotificationType +api = UptimeKumaApi('http://localhost:3001') +api.setup('admin', '') # nur beim allerersten Mal +api.login('admin', '') +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={'': 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 + +```bash +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.` 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 ! ; 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 => ` | +| 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 `-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 ` — ü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: +```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 ` 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": +```flux +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 `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. + +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://:3000` | Admin-Passwort in Vaultwarden | +| Uptime Kuma | `http://:3001` | Admin-Passwort in Vaultwarden | +| InfluxDB | `http://:8086` | Admin-Passwort + Token in Vaultwarden | +| ntfy (Push) | `https://monitoring.`, Topic `homelab-alerts` | Android/iOS-App auf den self-hosted Server zeigen lassen | +| NAS-Backups (SMB) | `\\nas.pve\backups` | Read-only, gleicher Nutzer `` wie `freigabe` | diff --git a/09-gast-zugang.md b/09-gast-zugang.md new file mode 100644 index 0000000..b89d1b6 --- /dev/null +++ b/09-gast-zugang.md @@ -0,0 +1,240 @@ +# Gast-Zugang: Read-only Showcase für Freunde/Bekannte und Bewerbungsgespräche — Rebuild-Runbook + +Cross-cutting, kein eigener Host: nutzt CT101 (NAS), CT104 (Monitoring) und VPS (Caddy). Die Sicherheitsdiskussion, die zu diesem Design geführt hat: siehe `ENTSCHEIDUNGEN.md`, Abschnitt „Warum der Gast-Zugang eine eigene, komplett neue Oberfläche bekam". + +## Prinzip + +Zwei komplett read-only Showcase-Konten (`gast-freunde`, `gast-bewerbung`), standardmäßig **deaktiviert**, manuell vor einem Termin aktiviert (plus optionales Ablaufzeit-Sicherheitsnetz). Kein Terminal, kein Schreibzugriff, keine Sicht auf interne Login-/Zugriffs-Daten. Bewusst **kein** Zugriff auf den bestehenden Management-Desktop (CT102) — der hat ein Terminal und sitzt im flachen LAN, für Gäste zu riskant. + +Drei Bausteine: +1. **Grafana-Gast-Dashboard** (CT104) — kuratierte Kopie ohne Login-Daten, plus fail2ban-Statistik als Sicherheits-Showcase +2. **Zweite Filebrowser-Instanz** (CT101) — eigener Port/eigene DB, read-only, zeigt nur `/srv/gast-demo` (= die genericierten Portfolio-Runbooks) +3. **Uptime-Kuma-Status-Page** (CT104) — öffentliche Ampel-Ansicht ohne technische Details + +Alle drei über neue öffentliche Caddy-Vhosts auf dem VPS erreichbar (gleiches Muster wie `guac.`). + +## 1. Grafana: Ordner-getrenntes Gast-Dashboard + +Grafanas eingebaute Rolle „Viewer" reicht **nicht** als Isolation — ein Viewer sieht standardmäßig alle Dashboards der Org. Für echte Trennung: zwei Ordner mit gebrochener Berechtigungs-Vererbung. + +```bash +# Dashboards in getrennte Unterordner der Provisionierung legen +mkdir -p /opt/monitoring/grafana-provisioning/dashboards/intern /opt/monitoring/grafana-provisioning/dashboards/gast +mv .../dashboards/homelab.json .../dashboards/intern/ +mv .../dashboards/homelab-gast.json .../dashboards/gast/ +``` + +`provider.yaml` mit zwei Providern (je eigenem `folder:` und `path:`), danach `docker compose restart grafana` (reine Datei-Provisionierung reicht sonst nicht für neue Ordner-Zuordnung). + +Anschließend Ordner-Berechtigungen umbauen (klassische Permissions-API, **nicht** die neue `/api/access-control/...`-RBAC-API — letztere zeigt zwar den Ist-Zustand an, ist aber für Änderungen umständlicher): + +```bash +# Intern: Viewer-Rolle komplett entfernen -> kein Viewer-Konto sieht das je automatisch +curl -u admin:$PW -X POST http://localhost:3000/api/folders/$INTERN_UID/permissions \ + -d '{"items":[{"role":"Editor","permission":2}]}' + +# Gast: Viewer-Rolle raus, stattdessen nur die zwei konkreten User explizit erlauben +curl -u admin:$PW -X POST http://localhost:3000/api/folders/$GAST_UID/permissions \ + -d '{"items":[{"role":"Editor","permission":2},{"userId":2,"permission":1},{"userId":3,"permission":1}]}' +``` + +Nutzer anlegen (Rolle Viewer ist bei Admin-erstellten Usern bereits Default): + +```bash +curl -u admin:$PW -X POST http://localhost:3000/api/admin/users \ + -d '{"name":"Gast Freunde","login":"gast-freunde","password":"...","OrgId":1}' +``` + +⚠️ **NICHT TUN:** sich auf `isDefault`/Standard-Ordner-Vererbung verlassen und nur ein Dashboard ohne Ordner-Trennung anlegen — ein Viewer-Account sieht dann trotzdem alle anderen Dashboards der Instanz mit (bestätigt: `curl -u gast-freunde ... /api/dashboards/uid/homelab-overview` gab vor der Ordner-Trennung 200 zurück, keine echte Isolation). + +Deaktivieren/Aktivieren eines Users: +```bash +curl -u admin:$PW -X POST http://localhost:3000/api/admin/users/$ID/enable # bzw. /disable +``` + +### Echte Hostnamen aus den Legenden entfernt (2026-07-20, Nutzerwunsch) + +Die Ressourcen-Panels (CPU/RAM/Disk je LXC) wurden zunächst 1:1 aus dem internen Dashboard übernommen — die Grafana-Legende zeigt dabei den `host`-Tag aus InfluxDB, also die echten Container-Hostnamen (`adguard`, `nas`, `browser`, `vaultwarden`, `monitoring`) bzw. den PVE-Hostnamen (``). Auf Nutzerwunsch durch **Rollen-Bezeichnungen statt Servernamen** ersetzt — man soll erkennen können, *was* läuft (DNS-Blocking, NAS, Passwort-Manager, …), ohne die echten internen Namen zu erfahren. Umsetzung per Flux `map()`, das den `host`-Wert vor dem finalen `keep()` umschreibt: + +```flux +|> map(fn: (r) => ({ r with host: + if r.host == "adguard" then "DNS / Ad-Blocking" + else if r.host == "nas" then "NAS / Dateiablage" + else if r.host == "browser" then "Remote-Desktop" + else if r.host == "vaultwarden" then "Passwort-Manager" + else if r.host == "monitoring" then "Monitoring-Stack" + else r.host })) +|> keep(columns: ["_time", "host", "_value"]) +``` + +Der PVE-Host selbst hat nur eine Zeile (kein Tag-Vergleich nötig): `map(fn: (r) => ({ r with host: "Hauptserver" }))`. Zusätzlich Panel-**Titel** generisiert, die Hardware-Marken/Dataset-Namen im Klartext hatten: „Esprimo (PVE-Host)" → „Hauptserver", „Intenso NVMe" → „NVMe-Speicher", „nas/freigabe"/"nas/backups" → „Dateiablage"/"Backup-Speicher", „VPS" → „VPN-Gateway". Das interne Dashboard (`Intern`-Ordner) bleibt bewusst unverändert mit den echten Namen — nur die Gast-Kopie wurde angepasst. Per Live-Query gegen InfluxDB verifiziert: alle fünf LXC-Rollen + Hauptserver lösen korrekt auf, keine rohen Hostnamen mehr in den Ergebnissen. + +⚠️ **NICHT TUN:** Rollen-Namen so generisch wählen, dass der Showcase-Zweck verloren geht (z.B. "System A"/"System B"). Ziel war ausdrücklich, dass ein Betrachter weiterhin erkennt, *welche Art* Dienst läuft — nur der konkrete interne Name/Hostname soll verborgen bleiben. + +## 2. Zweite Filebrowser-Instanz auf CT101 + +Eigener Systemd-Unit, eigene DB, eigener Port, eigener dedizierter Linux-User (kein Zugriff auf sonstige Homelab-Pfade — Defense in Depth für den einzigen wirklich öffentlich erreichbaren Datei-Endpunkt): + +```bash +useradd --system --no-create-home --shell /usr/sbin/nologin gastdemo +mkdir -p /srv/gast-demo /var/lib/filebrowser-gast +chown -R gastdemo:gastdemo /srv/gast-demo /var/lib/filebrowser-gast +# Inhalt: die genericierten Portfolio-Runbooks (siehe Abschnitt 4) + +runuser -u gastdemo -- filebrowser config init -d /var/lib/filebrowser-gast/filebrowser.db --root /srv/gast-demo +runuser -u gastdemo -- filebrowser users add gast-freunde '...' -d /var/lib/filebrowser-gast/filebrowser.db \ + --perm.admin=false --perm.execute=false --perm.create=false --perm.rename=false \ + --perm.modify=false --perm.delete=false --perm.share=false --perm.download=true --scope / +``` + +Systemd-Unit bindet **nur an die Tailscale-IP** (`:8081`), nicht ans LAN — der einzige Zugriffsweg ist über den VPS-Caddy-Vhost + Tailscale, LAN-Nutzer erreichen die Instanz gar nicht: + +```ini +[Service] +User=gastdemo +Group=gastdemo +ExecStart=/usr/local/bin/filebrowser -d /var/lib/filebrowser-gast/filebrowser.db -a -p 8081 +Restart=on-failure +``` + +⚠️ **NICHT TUN:** `--perm.execute` vergessen. Der `users add`-Default für Execute ist `true` (Command-Runner-Feature) — beim ersten Anlegen übersehen, per `users update ... --perm.execute=false` nachgezogen. Immer nach dem Anlegen mit `filebrowser users ls -d ...` verifizieren, dass wirklich nur `Download=true` gesetzt ist. + +⚠️ **NICHT TUN:** die bestehende NAS-Freigabe read-only exponieren wollen. Ein Berechtigungs-Bug in der Konfiguration würde dann echte Daten zeigen. Stattdessen komplett eigenes Verzeichnis + eigene Instanz, die von Anfang an nichts anderes kennt. + +**Bekannte Einschränkung:** Filebrowser hat in dieser Version **kein** Feld/Flag zum Deaktivieren einzelner Nutzer (anders als Grafana). Der „Aus"-Zustand wird deshalb über den ganzen Systemd-Service abgebildet (`systemctl stop filebrowser-gast`) — das deaktiviert zwangsläufig beide Gast-Logins gleichzeitig auf dieser Ebene. Da beide Konten ohnehin unabhängige Passwörter mit identischen Rechten haben, ist das funktional unkritisch, aber beachten: „nur `freunde` aktiv lassen" gilt nur für Grafana, nicht für die Dateifreigabe. + +## 3. Caddy-Vhosts (VPS) + +Gleiches Muster wie `guac.`, `/etc/caddy/Caddyfile`: + +``` +grafana-gast. { + reverse_proxy :3000 +} +dateien-gast. { + reverse_proxy :8081 +} +status-gast. { + reverse_proxy :3001 +} +``` + +`caddy validate --config /etc/caddy/Caddyfile` vor `systemctl reload caddy`. TLS-Zertifikate zieht Caddy automatisch, sobald die DNS-A-Records existieren (siehe Abschnitt 6). + +## 4. Portfolio-Runbooks (Inhalt von `/srv/gast-demo`) + +Genericierte Kopien aller `rebuild/*.md` in `../rebuild-portfolio/` — echte LAN-/Tailscale-IPs, Domain, Tailnet-Name, MAC-Adresse, Benutzername ersetzt durch Platzhalter (``, ``, ``, ``, ``, ``, …). Erklärung der Platzhalter in `../rebuild-portfolio/VARIABLEN.md`. Debugging-Geschichten/„NICHT TUN"-Blöcke bleiben bewusst unverändert drin — nach der Anonymisierung nicht mehr auf die echte Instanz rückführbar, und genau das zeigt in einem Bewerbungsgespräch, dass reale Probleme selbst gefunden und gelöst wurden. + +```bash +sed -i -e 's/192\.168\.178\.10\b//g' ... *.md # vollständige sed-Kette: siehe Session-Log +``` + +Nach jeder Änderung an den echten `rebuild/*.md`-Dateien: Portfolio-Kopie manuell nachziehen (kein automatischer Sync) und erneut gegen die bekannten echten Werte grep-verifizieren. + +## 5. Uptime Kuma: Gast-Monitore + Status-Page + +Kein REST-API für Monitor-/Status-Page-Verwaltung — Python-Bibliothek `uptime-kuma-api` (Socket.IO-Client) nötig: + +```python +from uptime_kuma_api import UptimeKumaApi, MonitorType +api = UptimeKumaApi('http://:3001') +api.login('admin', PASSWORD) +api.add_monitor(type=MonitorType.HTTP, name='Grafana (Gast)', url='https://grafana-gast.', + interval=60, retryInterval=60, maxretries=2, notificationIDList=[]) +api.add_status_page('gast', 'Homelab Showcase') +api.save_status_page(slug='gast', title='Homelab Showcase', published=True, showPoweredBy=False, + publicGroupList=[{'name': 'Showcase', 'weight': 1, + 'monitorList': [{'id': 12}, {'id': 13}, {'id': 14}]}]) +``` + +⚠️ **NICHT TUN:** den drei Gast-Monitoren die ntfy-Notification zuweisen (weder explizit noch über `isDefault`). Die Konten werden absichtlich manuell rauf-/runtergefahren — mit Notification würde jedes Deaktivieren einen „Down"-Alarm auslösen. `notificationIDList=[]` beim Anlegen explizit setzen. + +### Zweite Gruppe: echte Infrastruktur-Telemetrie hinter Rollen-Namen (2026-07-20) + +Die Status-Page wirkte mit nur drei Kacheln leer. Statt reiner Ampeln (Up/Down) zeigt eine zweite Gruppe „Infrastruktur" jetzt **echte Ping-Telemetrie** (Antwortzeit-Verlauf, Uptime-%) für die Kern-Systeme — mit denselben generischen Rollen-Namen wie im Gast-Grafana-Dashboard (siehe Abschnitt 1), damit beide Oberflächen konsistent wirken: + +```python +specs = [ + ('Hauptserver', ''), ('DNS / Ad-Blocking', ''), + ('NAS / Dateiablage', ''), ('Remote-Desktop', ''), + ('Passwort-Manager', ''), ('Monitoring-Stack', ''), + ('VPN-Gateway', ''), +] +for name, ip in specs: + api.add_monitor(type=MonitorType.PING, name=name, hostname=ip, + interval=60, retryInterval=60, maxretries=2, notificationIDList=[]) +``` + +Uptime Kuma zeigt auf der Status-Page nur den konfigurierten Namen + Antwortzeit-Graph, nicht die Ziel-IP — die echten LAN-IPs sind also nur intern für den Check relevant, nie öffentlich sichtbar. Zweite Gruppe per `publicGroupList` mit eigenem `weight` ergänzt (bestehende „Showcase"-Gruppe bleibt unverändert stehen, Gruppen werden per Name identifiziert und beim Speichern komplett ersetzt — beide Gruppen müssen bei jedem `save_status_page`-Aufruf mit angegeben werden, sonst verschwindet die andere). + +Verifiziert über die öffentliche URL (`curl https://status-gast./api/status-page/heartbeat/gast`): alle sieben neuen Monitore liefern echte Ping-Antwortzeiten (0,05–25 ms je nach Ziel), Gruppennamen und Monitor-Namen kommen unverändert als reine Rollen-Bezeichnungen an — keine IP/Hostname in der API-Antwort sichtbar. + +**Uptime-Kuma-Admin-Passwort verloren gegangen:** Die ursprünglichen Zugangsdaten wurden nur im Chat einer früheren Session ausgegeben und nirgends dauerhaft abgelegt (Konvention: Passwörter gehören in Vaultwarden, das ist hier nicht passiert). Reset direkt in der SQLite-DB des laufenden Containers (Uptime Kuma bringt `node`+`bcryptjs`+`sqlite3` im Image mit): + +```bash +HASH=$(docker exec uptime-kuma node -e "const bcrypt=require('bcryptjs'); console.log(bcrypt.hashSync(process.argv[1], 10));" "$NEWPW") +docker exec uptime-kuma sqlite3 /app/data/kuma.db "UPDATE user SET password = '$HASH' WHERE id = 1;" +``` + +Funktioniert bei laufendem Container (SQLite/WAL erlaubt den kurzen Fremdzugriff für ein einzelnes UPDATE). **Lehre:** Admin-Zugangsdaten für alle Dienste dieser Session tatsächlich in Vaultwarden ablegen, nicht nur im Chat stehen lassen. + +## 6. DNS (manueller Schritt, keine API verfügbar) + +Drei A-Records im STRATO-Panel für ``, alle auf die VPS-IP (gleiche IP wie der bestehende `guac`-Eintrag): +- `grafana-gast` → VPS-IP +- `dateien-gast` → VPS-IP +- `status-gast` → VPS-IP + +⚠️ **NICHT TUN:** Caddy neu laden/starten, *bevor* alle A-Records korrekt gesetzt sind, und dann erwarten, dass es sich von selbst korrigiert. Certmagic (Caddys ACME-Bibliothek) merkt sich fehlgeschlagene Versuche pro Hostname und schaltet nach mehreren Fehlschlägen zur Schonung des Let's-Encrypt-Produktiv-Rate-Limits automatisch auf die **Staging-CA** um (liefert kein von Browsern akzeptiertes Zertifikat) plus einen Backoff von bis zu 20 Minuten zwischen Versuchen. Gleiches Muster wie beim ursprünglichen `guac`-Setup. Fix: erst alle DNS-Einträge verifizieren (`dig @1.1.1.1 . A`, gegen mehrere öffentliche Resolver prüfen, eigener AdGuard-Resolver cached u.U. noch den alten Stand), danach `systemctl restart caddy` (nicht nur `reload` — ein Neustart setzt den internen Fehlversuch-/Backoff-Zustand zurück, ein reines `reload` nicht zuverlässig). Direkt danach in `journalctl -u caddy` auf `"certificate obtained successfully"` mit `ca":"https://acme-v02.api.letsencrypt.org/directory"` (nicht `-staging-`) prüfen. + +**Stolperstein bei diesem Durchlauf:** ein Subdomain-Eintrag im STRATO-Panel landete zunächst auf `217.160.0.161` (STRATOs eigenem Parking-/Webhosting-Server) statt der eingetragenen Ziel-IP — vermutlich ein falsch vorausgewählter Eintragstyp im Formular (z.B. "STRATO-Webhosting" statt "eigene IP-Adresse"). Per `dig` direkt gegen die autoritativen STRATO-Nameserver (`docks14.rzone.de`/`shades02.rzone.de`) und mehrere öffentliche Resolver (1.1.1.1/8.8.8.8/9.9.9.9) geprüft, um Panel-Fehler von reiner Propagationsverzögerung zu unterscheiden — eine SOA-Antwort statt einer A-Antwort bedeutet "Eintrag existiert nicht", nicht "noch nicht propagiert". Ein Tippfehler in der Subdomain selbst (`datein-gast` statt `dateien-gast`) trat ebenfalls auf — beim Anlegen den tatsächlich in Caddy konfigurierten Namen exakt gegenprüfen. + +## 7. Login-Tracking (Gast-Nutzung sichtbar machen) + +Anders als bei Guacamole/Vaultwarden/PVE-Web/SSH ist bei den Gast-Oberflächen die Logausbeute schlechter: + +- **Grafana:** loggt bei Formular-Login (`/login`, POST) **weder bei Erfolg noch bei Fehlschlag den Benutzernamen**. Ein Fehlschlag erzeugt `level=info msg="Failed to authenticate request" client=auth.client.form` + eine „Request Completed"-Zeile mit `path=/login status=401` — aber ohne Username, könnte auch ein vertippter Admin-Login sein. Ein Erfolg erzeugt **gar keine** Log-Zeile beim Login selbst; einzig nachfolgende authentifizierte Requests tragen `uname=gast-freunde`/`uname=gast-bewerbung` als Tag. `track-grafana-gast.sh` (Cron alle 5 Min. auf CT104) zählt deshalb Fehlschläge generisch und deutet „Aktivität" über das Vorkommen dieser `uname=`-Tags. +- **Filebrowser:** loggt nur Fehlschläge, mit IP (`/api/login: 403 `), keine Erfolge — gleiche Einschränkung wie beim bereits dokumentierten AdGuard-Gap. `track-filebrowser-gast.sh` (Cron alle 5 Min. auf CT101, no-op wenn der Service gerade gestoppt ist) trackt entsprechend nur `fail`. + +Beide schreiben ins bestehende `logins`-Measurement (`service=grafana-gast`/`service=filebrowser-gast`), gleiches Muster wie die übrigen Tracker. + +**ntfy-Push (2026-07-20 ergänzt):** `track-grafana-gast.sh` pusht bei >3 Fehlversuchen (hohe Priorität) und bei jeder erkannten Aktivität (`uname=`-Tag, Standard-Priorität — das deckt "erfolgreicher Login" für Grafana ab, da ein echtes Login-Event selbst nicht separat loggbar ist). `track-filebrowser-gast.sh` pusht nur bei >3 Fehlversuchen — ein Push bei erfolgreichem Filebrowser-Login ist **technisch nicht möglich**: kein Verbose-/Debug-Log-Flag vorhanden (`filebrowser -h` zeigt nur `-l/--log` für das Ausgabeziel, keine Log-Level-Option), das Tool loggt nachweislich nur Fehlschläge — exakt dieselbe Einschränkung wie bei AdGuard. + +## 8. Aktivieren/Deaktivieren + Ablaufzeit-Sicherheitsnetz + +Zwei Skripte auf dem PVE-Host (steuern CT104 + CT101 gemeinsam über `pct exec`): + +```bash +gast-aktivieren.sh freunde|bewerbung [stunden] +gast-deaktivieren.sh freunde|bewerbung|alle +``` + +`gast-aktivieren.sh` aktiviert den Grafana-User, startet `filebrowser-gast.service`, und plant bei Angabe von `[stunden]` per `at` einen einmaligen Folgejob (`gast-deaktivieren.sh `) — reines Sicherheitsnetz für den Fall, dass das manuelle Abschalten vergessen wird, kein Ersatz dafür. `gast-deaktivieren.sh` stoppt `filebrowser-gast.service` nur dann mit, wenn danach **kein** Gast-Grafana-Konto mehr aktiv ist (sonst würde das Deaktivieren von `freunde` auch `bewerbung`s Dateizugriff kappen, obwohl der ggf. noch laufen soll). + +```bash +apt-get install -y at && systemctl enable --now atd +``` + +⚠️ **NICHT TUN:** `at`-Pakete für die Auto-Deaktivierung annehmen, ohne `atd` separat zu aktivieren — auf frisch installiertem Debian läuft der Dienst nach `apt-get install at` nicht automatisch. + +### Automatisches Passwort-Cycling + +Weder Grafana OSS noch Filebrowser kennen ein "Passwort muss nach Login neu gesetzt werden"-Flag — als praktisches Äquivalent erzeugt `gast-aktivieren.sh` bei **jeder** Aktivierung ein frisches Zufallspasswort (12 Zeichen, nur Buchstaben/Ziffern ohne verwechselbare Zeichen wie `0`/`O`/`1`/`l`/`I` — bewusst niedrige Komplexität, da es dem Gast mündlich/per Kurznachricht mitgeteilt werden soll) und setzt es für **beide** Dienste gleich, damit sich der Gast nur ein Passwort merken muss: + +```bash +NEWPASS=$(tr -dc 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789' < /dev/urandom | head -c 12 || true) +curl -u admin:$GF_PASS -X PUT http://localhost:3000/api/admin/users/$ID/password -d "{\"password\":\"$NEWPASS\"}" +filebrowser users update gast-freunde -d /var/lib/filebrowser-gast/filebrowser.db -p "$NEWPASS" +``` + +Altes Passwort wird dadurch automatisch ungültig — keine manuelle Rotation nötig, das alte Passwort muss auch nirgends aufgehoben werden. + +⚠️ **NICHT TUN:** `tr ... | head -c N` unter `set -o pipefail` verwenden, ohne den SIGPIPE-Fall abzufangen. `head` beendet die Pipe, sobald genug Bytes gelesen sind — `tr` bekommt dadurch SIGPIPE (Exit 141), was mit `pipefail` das ganze Skript abbricht. Fix: `... | head -c N || true`. + +⚠️ **NICHT TUN:** das Filebrowser-Passwort per CLI setzen, während `filebrowser-gast.service` bereits läuft. Filebrowsers bbolt-Datenbank erlaubt nur einen Prozess gleichzeitig — CLI und laufender Dienst kollidieren mit `Error: timeout`. Reihenfolge im Skript: Service stoppen → Passwort per CLI setzen → Service starten. + +Verifiziert: `gast-aktivieren.sh freunde` → Grafana-Login + Filebrowser-Login funktionieren; `gast-deaktivieren.sh freunde` → beides sofort wieder zu; `at`-Job mit 1 Minute Vorlauf → `gast-deaktivieren.sh` feuert zuverlässig automatisch. Nach dem DNS-/Zertifikats-Fix zusätzlich vollständig über die echten öffentlichen URLs (nicht nur intern/Tailnet) durchgetestet: Login bei aktiviertem Konto funktioniert über `https://grafana-gast.` und `https://dateien-gast.`, zeigt nachweislich nur das Gast-Dashboard, Status-Page lädt; nach Deaktivierung liefern beide öffentlichen URLs wieder 401/502. Passwort-Rotation getestet: nach zweiter Aktivierung meldet das alte Passwort 401, nur das neu ausgegebene funktioniert — für beide Konten (`freunde`/`bewerbung`) einzeln bestätigt. + +### ntfy-Push bei Aktivieren/Deaktivieren (2026-07-20) + +Beide Skripte schließen jetzt mit einem `curl` an `http://localhost:8090/homelab-alerts` (via `pct exec 104`, da ntfy auf CT104 läuft) ab — Standard-Priorität, kein Alarm-Ton, reine Statusmeldung ("Gast-Zugang 'freunde' wurde soeben aktiviert (Auto-Deaktivierung in 3h)." bzw. "... deaktiviert."). Über `curl "http://localhost:8090/homelab-alerts/json?poll=1&since=2m"` gegen echte Zustellung verifiziert (nicht nur, dass der `curl`-Aufruf ohne Fehler durchläuft). diff --git a/ACHTUNG-DISCLAIMER.md b/ACHTUNG-DISCLAIMER.md new file mode 100644 index 0000000..495436d --- /dev/null +++ b/ACHTUNG-DISCLAIMER.md @@ -0,0 +1,16 @@ +# Achtung: KI-unterstützt entstandene Dokumentation + +Die Runbooks in diesem Ordner (`00`–`09`, `VARIABLEN.md`, `ENTSCHEIDUNGEN.md`) sind **nicht nachträglich für diese Freigabe geschrieben** — sie sind ein Mitschnitt des tatsächlichen Aufbau-Prozesses eines echten Homelabs, entstanden in Zusammenarbeit mit [Claude Code](https://claude.com/claude-code), Anthropics KI-gestütztem Kommandozeilen-Werkzeug für Software-/Infrastrukturarbeit. + +## Was das konkret bedeutet + +- Jeder Befehl in diesen Dateien wurde **tatsächlich so ausgeführt**, live, während des Aufbaus — keine nachträglich glattgebügelte Reinschrift. +- Die „⚠️ NICHT TUN"-Hinweise sind echte Stolpersteine, auf die der Aufbau tatsächlich gelaufen ist (falsche Protokoll-Wahl, Feldtyp-Kollisionen, Boot-Reihenfolge-Fallstricke, SIGPIPE-Bugs in eigenen Skripten, …) — nicht erfundene Lehrbuch-Beispiele. +- Architektur-Entscheidungen wurden im Dialog getroffen: der Nutzer bringt Ziel, Kontext und Rückfragen ein, die KI schlägt Optionen samt Trade-offs vor, der Nutzer entscheidet. `ENTSCHEIDUNGEN.md` fasst diese Begründungen zusammen. +- Echte Namen, IP-Adressen, die eigene Domain und andere identifizierende Details wurden nachträglich durch Platzhalter ersetzt (siehe `VARIABLEN.md`) — das ist die einzige nachträgliche Änderung an den Inhalten. Struktur, Reihenfolge und alle Fehlersuche-Geschichten sind unverändert. + +## Warum das hier offen steht + +Weil es zum eigentlichen Punkt gehört: der Umgang mit einem KI-Werkzeug wie diesem — Aufgaben klar formulieren, Ergebnisse kritisch prüfen, nachfragen, korrigieren, am Ende die Verantwortung für das Ergebnis behalten — ist selbst eine Fähigkeit. Diese Dokumentation zu verstecken oder so zu tun, als sei jede Zeile von Hand entstanden, würde diesen Teil der Arbeit unterschlagen. + +Die technischen Entscheidungen, die Fehlersuche und das Ergebnis sind trotzdem real — es lief ein echtes System, mit echten Bugs, die echt gelöst werden mussten. diff --git a/ENTSCHEIDUNGEN.md b/ENTSCHEIDUNGEN.md new file mode 100644 index 0000000..4a2e783 --- /dev/null +++ b/ENTSCHEIDUNGEN.md @@ -0,0 +1,64 @@ +# Architektur-Entscheidungen und ihre Begründung + +Die Runbooks (`00`–`09`) zeigen *wie* jede Instanz aufgebaut wird. Diese Datei erklärt *warum* — die Kompromisse und Alternativen, die vor jeder Entscheidung abgewogen wurden. Genericierte Zusammenfassung des internen Projekt-Kontexts (der im Original nicht Teil dieser Freigabe ist, siehe `ACHTUNG-DISCLAIMER.md`). + +## Ausgangslage: kein öffentliches IPv4 zuhause + +Der Internet-Anschluss läuft über Dual-Stack-Lite (DS-Lite) — nur natives IPv6 + eine geteilte, nicht erreichbare IPv4-Adresse (CGNAT) ausgehend. Das bestimmt die halbe Architektur: ein rein lokales Setup wäre von außen gar nicht erreichbar. Zwei Konsequenzen: + +- **Tailscale** (Mesh-VPN) für den Normalfall — jedes eigene Gerät mit installiertem Client kommt direkt ins Heimnetz, unabhängig von der eigenen öffentlichen IP +- **Ein VPS mit echter öffentlicher IPv4/IPv6** als Fallback für den Fall, dass kein Tailscale-Client verfügbar ist (fremder Rechner, Internetcafé) — bewusst nur als schmale Bridge, keine große Konfiguration dort + +## Warum Tailscale statt WireGuard standalone + +Tailscale nutzt WireGuard als Transportprotokoll, übernimmt aber Schlüsselverwaltung, NAT-Traversal und Gerätediscovery (MagicDNS) automatisch. Ein eigenständiges WireGuard-Setup hätte denselben Zweck erfüllt, aber jeden dieser Punkte manuell verlangt — für ein Ein-Personen/Familien-Setup ohne Compliance-Anforderungen ist der Zusatzaufwand nicht gerechtfertigt. Bewusst **kein Exit-Node** (der gesamte Internet-Traffic mobiler Geräte würde sonst über die eigene, langsamere Heimleitung laufen) — nur Subnet-Routing ins Heimnetz. + +## Warum AdGuard Home als aller erster Schritt + +DNS-basiertes Werbe-/Tracker-Blocking ersetzt Browser-Adblocker zuverlässiger, seit Browser-Erweiterungs-APIs (Manifest V3) deren Möglichkeiten eingeschränkt haben — und wirkt netzwerkweit für jedes Gerät, auch IoT/Smart-TV ohne Adblocker-Unterstützung. Es kommt bewusst vor allen anderen Diensten, weil jede spätere Instanz ohnehin über diesen DNS-Server auflösen soll (Rewrites für interne Kurznamen). + +**Filterlisten-Philosophie:** kuratiert statt maximal. Ein Abgleich mit einem deutlich umfangreicheren Fremd-Setup (~60 aktive Listen) zeigte massive Redundanz (mehrere überlappende Malware-/Werbelisten) ohne messbaren Zusatznutzen — mehr Listen bedeuten mehr RAM-Bedarf und mehr False-Positive-Risiko, nicht automatisch mehr Sicherheit. Bewusst **nicht** übernommen: Content-Filter (NSFW/Anti-Piracy/Dating — reine Lifestyle-Entscheidung, nicht Ad-Blocking-Zweck) und VPN/Proxy-Bypass-/DynDNS-Blocklisten (Kollisionsrisiko mit einem im Haushalt genutzten Firmen-VPN-Tunnel eines Familienmitglieds). + +## Warum ZFS-Mirror statt TrueNAS-VM für den NAS-Speicher + +Mit nur zwei verfügbaren Laufwerksschächten scheidet ein Storage-Passthrough-Setup mit mehr Redundanzstufen praktisch aus. Optionen waren: (a) einfacher Samba-LXC ohne Redundanz, (b) TrueNAS Scale als VM mit Storage-Passthrough, (c) ZFS-Mirror auf Host-Ebene + schlanker Samba-LXC per Bind-Mount. Entschieden für (c) — liefert dieselbe Redundanz (RAID 1) wie eine dedizierte NAS-Lösung, ohne den Ressourcen-Overhead einer vollständigen TrueNAS-VM für nur zwei Platten zu rechtfertigen. + +**NAS-Protokolle grundsätzlich nie öffentlich exponieren** — nur über den Tailscale-Subnet-Router erreichbar. SMB/NFS sind nicht für Angriffsverkehr aus dem offenen Internet gebaut; das öffentliche Zugriffsszenario läuft stattdessen über das Gateway (nächster Abschnitt). + +## Warum ein zusätzliches Gateway statt NAS direkt öffentlich + +Für den seltenen Fall eines fremden Geräts ohne Tailscale-Client übernimmt ein HTML5-Gateway auf dem VPS die Vermittlung (RDP/SFTP/VNC-Verbindungen im Browser, mit eigenem Login + Pflicht-2FA). Das hält den öffentlichen Angriffsfläche auf **einen** gehärteten Übergang beschränkt, statt mehrere Dienste (NAS, Management-Desktop, …) einzeln absichern zu müssen. Public-Key-only-SSH + `fail2ban` zusätzlich auf dem VPS selbst, da dort der einzige öffentlich erreichbare Admin-Zugang liegt. + +## Warum ein separater "Management-Desktop"-Container + +Ursprünglich nicht geplant — entstand aus einem konkreten Bedarf: ein zweites Haushaltsmitglied brauchte grafischen Zugriff (v.a. auf die Datei-Oberfläche), ohne die bestehende Fernzugriffs-Sitzung auf den Haupt-PC zu kapern (Windows erlaubt dort nur eine aktive Sitzung gleichzeitig). Lösung: ein minimaler LXC mit Desktop-Umgebung + Browser, rein für Web-Oberflächen gedacht ("Mini-Linux, das nur einen Browser können muss") — kein Vollzugriffs-Arbeitsplatz, kein Terminal für Gäste (siehe Gast-Zugang unten, warum das wichtig wurde). + +## Warum der Passwort-Tresor NIE öffentlich + +Höchste Sensitivität aller Dienste — bekommt bewusst als einziger Dienst **keinen** öffentlichen Zugriffsweg, nur Tailnet-intern über automatisches Zertifikats-Handling (statt eines eigenen Reverse-Proxys — weniger bewegliche Teile für den sensibelsten Dienst). Selbst bei einer hypothetischen Kompromittierung des öffentlichen Gateways bliebe der Tresor unerreichbar. + +## Warum Monitoring: zweistufige Datenhaltung statt einer großen Datenbank + +Die einzige verfügbare Storage für den Monitoring-Container ist eine nicht sehr große NVMe (die Festplatten sind fürs allgemeine Stromsparziel tabu, siehe unten) — unbegrenzt wachsende Rohdaten waren keine Option. Fix: kurze Rohdaten-Aufbewahrung (Tage) für Detail-Debugging, parallel dazu automatisch verdichtete Stundenmittelwerte über Jahre für Langzeit-Trends. Ergebnis: dauerhaft kleine Datenmenge, ohne auf Verlaufsvergleiche über Monate verzichten zu müssen. + +**Kein Agent pro Container** — die Virtualisierungsplattform bringt einen eingebauten Metrik-Export für Host + alle Gastsysteme bereits mit, das spart einen zusätzlichen Prozess pro Instanz. Nur das externe VPS (kein Mitglied der Virtualisierungs-Cluster) bekommt einen schlanken externen Agenten. + +**Selbstgehostete Push-Benachrichtigungen statt E-Mail** — der ursprüngliche Plan (Warnmails bei Backup-Fehlschlägen) scheiterte daran, dass der Mailversand vom Host aus nirgendwo ankam (kein Mail-Relay konfiguriert, nur lokal zustellbar). Ein selbstgehosteter Push-Dienst aufs eigene Handy war der pragmatischere Ersatz, ganz ohne Abhängigkeit von einem externen Mail-Anbieter. + +## Warum Backups per Pull vom Zuhause statt Push vom externen Server + +Der externe Server (VPS) erstellt seine eigenen Backups lokal, aber das Heimnetz **zieht** sie sich anschließend selbst ab (per stark eingeschränktem Schlüssel, nur Lesezugriff auf genau ein Verzeichnis) — nicht umgekehrt. Vorteil: die eigentliche Datenhoheit bleibt beim eigenen Netzwerk, und ein späterer Wechsel des externen Anbieters oder der Domain bleibt unkritisch (einfach vor der Kündigung den letzten Stand ziehen). Bewusst kein Push-Zugriff vom VPS auf das Heimnetz — ein kompromittierter öffentlicher Server hätte sonst Schreibzugriff auf die eigene Backup-Infrastruktur. + +## Warum Festplatten-Spindown ein echtes Projekt wurde, nicht nur ein Einzeiler + +Der Wunsch war simpel: Festplatten sollen möglichst selten aktiv sein statt dauerhaft zu laufen. Die Umsetzung eines aggressiven Spindown-Timeouts warf aber eine echte Frage auf: sind häufige Start/Stop-Zyklen schädlicher für die Plattenlebensdauer als dauerhafter Betrieb? Eine SMART-Auswertung zeigte, dass eine der beiden Platten bereits einen sehr hohen Load-Cycle-Zählerstand hatte (nahe der vom Hersteller spezifizierten Zyklengrenze), während die zweite, baugleiche Platte einen deutlich niedrigeren Wert zeigte — das machte Monitoring dieser Kennzahl zur Voraussetzung für die Entscheidung, nicht zum Nice-to-have. Details zur Fehlersuche (inkl. eines Bugs, bei dem das eigene Monitoring-Skript die Platten unbeabsichtigt bei jedem Check aufweckte) stehen in `08-monitoring.md`. + +## Warum der Gast-Zugang eine eigene, komplett neue Oberfläche bekam + +Naheliegend wäre gewesen, den bestehenden Management-Desktop für Besucher freizugeben. Bewusst verworfen: der hat ein Terminal und sitzt im selben flachen Netzwerk wie alle anderen Geräte — für Familie unkritisch, für Fremde (Bewerbungsgespräch-Kontext!) ein echtes Risiko. Stattdessen eine komplett separate, rein lesende Oberfläche (kuratiertes Dashboard, separate Dateifreigabe mit eigens dafür vorbereiteten Inhalten, öffentliche Status-Seite) — lieber ein zweites, bewusst eingeschränktes System bauen als ein bestehendes privilegiertes System nur "ein bisschen" zu öffnen. Details, inkl. der Sicherheitsdiskussion, die zu diesem Design geführt hat: `09-gast-zugang.md`. + +## Bewusst verworfene Ansätze (nicht aus Unwissenheit, sondern nach Abwägung) + +- **Eigenständige Firewall-Appliance inline** — keine passende Hardware für einen dedizierten Router-Ersatz vorhanden, kein Neukauf gerechtfertigt für den erreichten Sicherheitsgewinn +- **Cloud-Dokumentenverwaltung mit öffentlichem Reverse-Proxy** — zu viel Betriebsaufwand für reinen Eigenbedarf, das bestehende Mesh-VPN deckt den Zugriffsbedarf bereits ab +- **DNS-Anfragen über den Router statt direkt zum Ad-Blocking-Server** — ein zusätzlicher Hop ohne Mehrwert, der Router wird komplett aus dem DNS-Pfad herausgehalten diff --git a/VARIABLEN.md b/VARIABLEN.md new file mode 100644 index 0000000..68f7db9 --- /dev/null +++ b/VARIABLEN.md @@ -0,0 +1,27 @@ +# Variablen in diesen Runbooks + +Diese Dateien sind generische Kopien der tatsächlich verwendeten Rebuild-Runbooks — echte IP-Adressen, die eigene Domain, der Tailnet-Name, die MAC-Adresse und Benutzernamen wurden durch Platzhalter ersetzt. Struktur, Reihenfolge und alle "NICHT TUN"-Stolpersteine sind unverändert und tatsächlich so aufgetreten — nur die konkreten Werte gehören zur Originalinstanz und müssten durch eigene ersetzt werden, um das Setup nachzubauen. + +| Platzhalter | Bedeutung | Beispiel | +|---|---|---| +| `` | Das eigene LAN-Subnetz | `192.168.1.0/24` | +| `` | IP des Heimrouters | `192.168.1.1` | +| `` | IP des DNS-/Ad-Blocking-Containers | `192.168.1.9` | +| `` | IP des Proxmox-Hosts | `192.168.1.10` | +| `` | IP des NAS-Containers | `192.168.1.11` | +| `` | IP des Management-Desktop-Containers | `192.168.1.12` | +| `` | IP des Passwortmanager-Containers | `192.168.1.13` | +| `` | IP des Monitoring-Containers | `192.168.1.14` | +| `` | IP des Windows-Rechners (Wake-on-LAN-Ziel) | `192.168.1.254` | +| `` | Broadcast-Adresse des LAN-Subnetzes | `192.168.1.255` | +| `` | Die eigene registrierte Domain | `meinhomelab.example` | +| `` | Der eigene Tailscale-Tailnet-Name (MagicDNS-Suffix) | `tailXXXXXX.ts.net` | +| `` | Jeweilige Tailscale-IP der Instanz (vergibt Tailscale automatisch beim Beitritt) | `100.x.x.x` | +| `` | MAC-Adresse des Wake-on-LAN-Ziels | `AA:BB:CC:DD:EE:FF` | +| `` | Frei gewählter Hostname des Proxmox-Hosts | `mein-pve` | +| `` | Der eigene Haupt-Benutzername (Samba/SSH/Guacamole) | `max` | +| `` | Tailscale-Auth-Key, selbst erzeugt (Settings → Keys → Generate auth key) | — | + +Alles, was nicht in dieser Tabelle steht (Paketnamen, Befehle, Container-IDs, Rollen-Bezeichnungen wie "adguard"/"nas"/"browser"), ist bereits generisch und muss nicht angepasst werden. + +Details, Kontext und die dazugehörigen Fehlersuche-Geschichten (warum eine Einstellung so und nicht anders gewählt wurde) stehen jeweils in den einzelnen Kapiteln — die sind bewusst nicht gekürzt, nur die Werte anonymisiert.