Initial import: genericized homelab rebuild runbooks

This commit is contained in:
arnol 2026-07-22 14:20:51 +02:00
commit eb9bbe6455
13 changed files with 1784 additions and 0 deletions

36
00-uebersicht.md Normal file
View file

@ -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) | `<PVE_HOSTNAME>` | `<LAN_IP_PVE>` | `<TS_IP_PVE>` | Proxmox-Host, Subnet-Router, ZFS, WoL-Trigger |
| [02-ct100-adguard.md](02-ct100-adguard.md) | `adguard` (CT 100) | `<LAN_IP_ADGUARD>` | — | DNS/Ad-Blocking |
| [03-ct101-nas.md](03-ct101-nas.md) | `nas` (CT 101) | `<LAN_IP_NAS>` | — | Samba/SFTP/Filebrowser |
| [04-ct102-management-desktop.md](04-ct102-management-desktop.md) | `browser` (CT 102) | `<LAN_IP_DESKTOP>` | `<TS_IP_DESKTOP>` | Gemeinsamer Browser-Desktop |
| [05-ct103-vaultwarden.md](05-ct103-vaultwarden.md) | `vaultwarden` (CT 103) | `<LAN_IP_VAULTWARDEN>` | `<TS_IP_VAULTWARDEN>` | Passwortmanager |
| [06-vps-strato.md](06-vps-strato.md) | `vps` (STRATO) | — | `<TS_IP_VPS>` | Guacamole-Gateway, öffentlicher Fallback-Zugang |
| [08-monitoring.md](08-monitoring.md) | `monitoring` (CT 104) | `<LAN_IP_MONITORING>` | `<TS_IP_MONITORING>` | 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 <LAN_IP_ADGUARD>`. 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.

176
01-pve-host.md Normal file
View file

@ -0,0 +1,176 @@
# PVE-Host (`<PVE_HOSTNAME>`) — 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=<AUTH_KEY> --advertise-routes=<LAN_SUBNET> --accept-risk=lose-ssh
```
**Danach zwingend in der Tailscale-Admin-Konsole (login.tailscale.com/admin/machines):**
- Bei `<PVE_HOSTNAME>` → „Edit route settings" → Route `<LAN_SUBNET>` genehmigen (Subnet-Routes werden nie automatisch aktiviert)
- Unter Settings → DNS → Nameserver `<LAN_IP_ADGUARD>` (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 <LAN_BROADCAST> <PC1_MAC_ADDRESS>
echo "Magic packet an <PC1_MAC_ADDRESS> 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 (`<PC1_MAC_ADDRESS>`), 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 <LAN_IP_ADGUARD>
```
**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 <ID> --nameserver <LAN_IP_ADGUARD>
```
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 <ID>
cat >> /etc/pve/lxc/<ID>.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 <ID>
```
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_SUBNET>`-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 `<LAN_SUBNET> 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://<PVE_HOSTNAME>.<TAILNET>` (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
```

154
02-ct100-adguard.md Normal file
View file

@ -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 `<LAN_IP_ADGUARD>/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://<LAN_IP_ADGUARD>: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/]<LAN_IP_ROUTER>'
```
Private Reverse-DNS aktivieren (für Gerätenamen im Query-Log):
```yaml
local_ptr_upstreams:
- <LAN_IP_ROUTER>
```
(Im Web-UI: Einstellungen → DNS-Einstellungen → Private Reverse-DNS-Server aktivieren, `<LAN_IP_ROUTER>`, privates Subnetz `<LAN_SUBNET>`)
## 4. Filterlisten
`filters:`-Block (Filter-IDs müssen eindeutig sein, 12 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: <LAN_IP_NAS>
enabled: true
- domain: adguard.pve
answer: <LAN_IP_ADGUARD>
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="<neues Passwort>"
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: `<LAN_IP_ADGUARD>`.
⚠️ **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 @<ULA-Adresse> 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 @<LAN_IP_ADGUARD> doubleclick.net # sollte 0.0.0.0 liefern (geblockt)
dig @<LAN_IP_ADGUARD> google.com # normale Antwort
dig @<LAN_IP_ADGUARD> fritz.box # <LAN_IP_ROUTER> (Conditional Forwarding)
dig @<LAN_IP_ADGUARD> nas.pve # <LAN_IP_NAS> (Rewrite)
```

145
03-ct101-nas.md Normal file
View file

@ -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=<LAN_IP_NAS>/24,gw=<LAN_IP_ROUTER> \
--features nesting=1 \
--timezone Europe/Berlin \
--onboot 1 \
--nameserver <LAN_IP_ADGUARD>
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 = <PRIMARY_USER>
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 <PRIMARY_USER>
pct exec 101 -- smbpasswd -a <PRIMARY_USER>
pct exec 101 -- smbpasswd -e <PRIMARY_USER>
pct exec 101 -- chpasswd # gleiches Passwort auch als Unix-Login setzen (für SSH/SFTP)
pct exec 101 -- chown -R <PRIMARY_USER>:<PRIMARY_USER> /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 = <PRIMARY_USER>
```
Kein separater Nutzer nötig, `<PRIMARY_USER>` 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 <PRIMARY_USER>:<PRIMARY_USER> /var/lib/filebrowser
pct exec 101 -- su -s /bin/bash <PRIMARY_USER> -c '/usr/local/bin/filebrowser config init -d /var/lib/filebrowser/filebrowser.db'
pct exec 101 -- su -s /bin/bash <PRIMARY_USER> -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 <PRIMARY_USER> -c "/usr/local/bin/filebrowser users add <PRIMARY_USER> '<PASSWORT>' --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=<PRIMARY_USER>
Group=<PRIMARY_USER>
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 //<LAN_IP_NAS> -U <PRIMARY_USER>%'<PASSWORT>'
sftp <PRIMARY_USER>@<LAN_IP_NAS>
curl -s -o /dev/null -w "%{http_code}\n" http://<LAN_IP_NAS>:8080/
```

View file

@ -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=<LAN_IP_DESKTOP>/24,gw=<LAN_IP_ROUTER> \
--features nesting=1 \
--timezone Europe/Berlin \
--onboot 1 \
--nameserver <LAN_IP_ADGUARD>
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="<VNC-Passwort>"
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://<PVE_HOSTNAME>.<TAILNET>|http://nas.pve:8080|https://guac.<DOMAIN>|https://vaultwarden.<TAILNET>\");
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 `<zufallsstring>.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/<echter-profil-ordner>/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=<AUTH_KEY> --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_SUBNET>`-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 (`<TS_IP_DESKTOP>`), 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:<Passwort>' | 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 = <TS_IP_DESKTOP>
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 <TS_IP_DESKTOP> 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@<LAN_IP_DESKTOP> # sollte nach /root/Downloads chrooten (pwd zeigt "/")
```

116
05-ct103-vaultwarden.md Normal file
View file

@ -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=<LAN_IP_VAULTWARDEN>/24,gw=<LAN_IP_ROUTER> \
--features nesting=1 \
--timezone Europe/Berlin \
--onboot 1 \
--nameserver <LAN_IP_ADGUARD>
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=<AUTH_KEY> --hostname=vaultwarden
```
Kein `--accept-routes` nötig — anders als CT 102 sitzt dieser Container nicht zusätzlich im `<LAN_SUBNET>`-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 <<EOF
services:
vaultwarden:
image: vaultwarden/server:latest
container_name: vaultwarden
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
environment:
DOMAIN: "https://vaultwarden.<TAILNET>"
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.<TAILNET>`, 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.<TAILNET>` (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.<TAILNET>/ # von einem Tailnet-Mitglied
tailscale serve status
```

312
06-vps-strato.md Normal file
View file

@ -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=<AUTH_KEY> --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_SUBNET>`-LAN, braucht die vom PVE-Host advertiste Subnet-Route also tatsächlich, um das Heimnetz zu erreichen (NAS, AdGuard etc.).
Verifikation:
```bash
ping -c3 <LAN_IP_ADGUARD> # AdGuard
ping -c3 <LAN_IP_NAS> # 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 <<EOF
services:
guacd:
image: guacamole/guacd:1.6.0
container_name: guacd
restart: unless-stopped
networks:
- guac-net
postgres:
image: postgres:16-alpine
container_name: guac-postgres
restart: unless-stopped
environment:
POSTGRES_DB: guacamole_db
POSTGRES_USER: guacamole_user
POSTGRES_PASSWORD: "$DBPASS"
volumes:
- ./pg-data:/var/lib/postgresql/data
- ./jdbc-schema:/docker-entrypoint-initdb.d:ro
networks:
- guac-net
guacamole:
image: guacamole/guacamole:1.6.0
container_name: guacamole
restart: unless-stopped
environment:
GUACD_HOSTNAME: guacd
POSTGRESQL_HOSTNAME: postgres
POSTGRESQL_DATABASE: guacamole_db
POSTGRESQL_USER: guacamole_user
POSTGRESQL_PASSWORD: "$DBPASS"
volumes:
- ./extensions:/etc/guacamole/extensions:ro
- ./guac-home:/etc/guacamole/data
ports:
- "127.0.0.1:8080:8080"
- "<TS_IP_VPS>: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 = '<Passwort>'
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="<oben berechnet>"
SALT="<oben berechnet>"
docker exec -i guac-postgres psql -U guacamole_user -d guacamole_db <<EOSQL
BEGIN;
INSERT INTO guacamole_entity (name, type) VALUES ('<PRIMARY_USER>', '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='<PRIMARY_USER>' AND type='USER';
INSERT INTO guacamole_system_permission (entity_id, permission)
SELECT entity_id, 'ADMINISTER' FROM guacamole_entity WHERE name='<PRIMARY_USER>' 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', '<LAN_IP_NAS>' 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', '<PRIMARY_USER>' 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='<PRIMARY_USER>' 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`, `<PVE_HOSTNAME>`) 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: `<LAN_IP_NAS>` (NAS), `<LAN_IP_PC1>` (PC1), `<LAN_IP_PVE>` (PVE-Host für den WoL-Trigger).
Weitere Verbindungen nach demselben Muster:
- **"PC1 RDP"**: `protocol=rdp`, `hostname=<LAN_IP_PC1>`, `domain=pc1` (lokales Windows-Konto, nicht Domain-Konto!), `port=3389`, `username=<PRIMARY_USER>`, `ignore-cert=true`
- **"Wake PC1"**: `protocol=ssh`, `hostname=<LAN_IP_PVE>`, `port=22`, `username=wolonly`, `private-key=<Inhalt von /root/.ssh/wol_vps_key vom PVE-Host>`, `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 57 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 = <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.<DOMAIN>``<VPS-öffentliche-IP>` im STRATO-DNS-Panel gesetzt (kein CLI-Schritt).
```bash
apt-get install -y caddy
cat > /etc/caddy/Caddyfile <<'EOF'
guac.<DOMAIN> {
@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.<DOMAIN>` | öffentlich, überall | Caddy/Let's-Encrypt-TLS + Guacamole-Login (Passwort + TOTP) + Brute-Force-Ban (5 Versuche → 5 Min Sperre) |
| `http://<TS_IP_VPS>: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.<DOMAIN>/guacamole/
docker ps --format 'table {{.Names}}\t{{.Status}}'
```

99
07-backup-restore.md Normal file
View file

@ -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@<TS_IP_VPS> "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@<TS_IP_VPS>: /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@<TS_IP_VPS>:` — das adressiert die Wurzel des Restricted-Verzeichnisses.
## 7. Restore
**LXC (jede der 4 Instanzen):**
```bash
pct restore <vmid> /nas/backups/pve/vzdump-lxc-<vmid>-<timestamp>.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_<date>.tar.gz | tar x -C /opt # falls /opt/guacamole fehlt
cd /opt/guacamole && docker compose up -d postgres guacd
gunzip -c guacamole_db_<date>.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 (100103) | 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 |

235
08-monitoring.md Normal file
View file

@ -0,0 +1,235 @@
# Monitoring — Rebuild-Runbook
Neue LXC `CT104 monitoring` (`<LAN_IP_MONITORING>`, Tailscale-Mitglied `monitoring.<TAILNET>`) hostet als Docker-Compose-Stack: InfluxDB (Metrik-Datenbank), Grafana (Dashboards), Uptime Kuma (Erreichbarkeit + Alert-Routing), ntfy (Push-Benachrichtigungen). Ergänzt um PVE-Metric-Server-Export, Telegraf auf dem VPS, ein HDD-SMART-Tracking-Script und Login-/Zugriffs-Tracking je Dienst.
## 1. CT104 anlegen
```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=<LAN_IP_MONITORING>/24,gw=<LAN_IP_ROUTER> \
--nameserver <LAN_IP_ADGUARD> --unprivileged 1 --features nesting=1 \
--onboot 1 --timezone Europe/Berlin --ostype debian
pct start 104
pct stop 104
cat >> /etc/pve/lxc/104.conf <<'EOF'
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file 0 0
EOF
pct start 104
```
Danach Docker (`curl -fsSL https://get.docker.com | sh`) und Tailscale (`curl -fsSL https://tailscale.com/install.sh | sh && tailscale up --hostname=monitoring --accept-routes=false`) installieren.
⚠️ **NICHT vergessen:** CT104 ins nächtliche `vzdump`-Backup aufnehmen (`crontab -e` auf dem PVE-Host, `104` zur ID-Liste ergänzen) — sonst ist der Monitoring-Stack selbst nicht gesichert.
## 2. Docker-Compose-Stack
`/opt/monitoring/docker-compose.yml` (Auszug, vollständige Datei im laufenden System): vier Services `influxdb`, `grafana`, `uptime-kuma`, `ntfy`, gemeinsamer `x-logging`-Anchor (10m×3 Dateien, bestehende Konvention). Ports: InfluxDB 8086, Grafana 3000, Uptime Kuma 3001, ntfy 8090→80.
⚠️ **NICHT TUN:** Grafana-Datenverzeichnis (`./grafana`) beim ersten Start nicht vor-chownen — der Container läuft als UID 472, ein per `pct exec`/root angelegtes Bind-Mount-Verzeichnis gehört aber root:root. Fix: `chown -R 472:472 /opt/monitoring/grafana` **vor** dem ersten Start von Grafana, sonst Crash-Loop ("not writable").
```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 '<PW>' \
--org homelab --bucket raw --retention 14d --force
docker exec influxdb influx bucket create --name aggregated --org homelab --retention 730d
```
Zwei Buckets statt vieler kleiner: `raw` (14 Tage, volle Auflösung — deckt die längste Rohdaten-Anforderung aller Datentypen ab) und `aggregated` (2 Jahre, stündliche Mittelwerte). Downsampling-Task (`influx task create -f downsample.flux`):
```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 <LAN_IP_MONITORING> --port 8086 \
--influxdbproto http --organization homelab --bucket raw --token '<TOKEN>'
```
Pusht Host- und LXC-Metriken automatisch alle ~10s, kein Agent in den LXCs nötig. Guest-Metriken landen unter Measurement `system` (Felder `cpu`, `mem`, `maxmem`, …, Tag `object=lxc`), Host-Metriken unter `cpustat`/`memory`/`blockstat`/`nics` (Tag `object=nodes`) — **unterschiedliches Schema**, beim Dashboard-Bau beide Fälle abdecken.
## 5. Telegraf auf dem VPS
VPS ist kein PVE-Cluster-Mitglied, braucht eigenen Agent:
```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://<CT104-Tailscale-IP>:8086`, Bucket `raw`, Org `homelab`; Inputs `cpu`, `mem`, `disk`, `net`, `system`.
## 6. HDD-SMART-Tracking (PVE-Host)
`/usr/local/bin/hdd-smart-to-influx.sh` liest `smartctl -A` für beide Platten (Attribute `Load_Cycle_Count`, `Start_Stop_Count`, `Temperature_Celsius` — Raw-Wert ist das erste Feld vor evtl. Klammertext), `zfs list -H -o name,used,avail,refer -p` für `nas`/`nas/freigabe`/`nas/backups` (Measurement `zfs_usage`, `/` im Dataset-Namen zu `_` normalisiert für den Tag-Wert) **und** NVMe-Belegung (Measurement `nvme_usage`) — die VG `pve` deckt praktisch die gesamte Intenso-NVMe ab (nur EFI-/Boot-Partitionen liegen außerhalb, vernachlässigbar). Schreibt alles per InfluxDB-Line-Protocol via `curl` in den `raw`-Bucket. systemd-Timer `hdd-smart-to-influx.timer`, alle 5 Minuten:
⚠️ **NICHT TUN:** Bei LVM-Thin-Provisioning (hier: LV `pve/data`, 137GB Pool für alle LXC-Disks) `vgs -o vg_size,vg_free` als "wie voll ist die Platte" interpretieren. Das misst nur, wie viel VG-Fläche noch **unallokiert** ist (also Platz für neue LVs) — nicht, wie viel der bereits allokierten LVs tatsächlich mit Daten beschrieben ist. Bei diesem Setup zeigte `vgs` 93% "belegt", obwohl real nur ~8% Daten auf der Platte lagen (root-LV zu 68GB allokiert, aber nur 6GB genutzt; Thin-Pool zu 137GB allokiert, aber nur 8,88% beschrieben). Für eine ehrliche Nutzungsanzeige: `df --output=used -B1 /` (root-FS) + `lvs -o lv_size,data_percent pve/data` (Thin-Pool-Ist-Nutzung) addieren → Feld `actual_used_gb`, getrennt von `total_gb`/`free_gb` (VG-Allokation, weiterhin nützlich um zu wissen, wie viel Raum für neue LVs übrig ist).
```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 '<PW>'` (neuere Grafana-Versionen: `grafana cli`, nicht mehr das alte `grafana-cli`-Binary).
## 8. Uptime Kuma: Monitore + ntfy-Notification
Kein offizielles REST-API für Monitor-Verwaltung — Setup über die Python-Bibliothek `uptime-kuma-api` (nutzt das interne Socket.IO-Protokoll):
```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', '<PW>') # nur beim allerersten Mal
api.login('admin', '<PW>')
api.add_notification(name='ntfy-homelab', type=NotificationType.NTFY, isDefault=True,
ntfyserverurl='http://ntfy:80', ntfytopic='homelab-alerts',
ntfyPriority=4, ntfyAuthenticationMethod='none')
api.add_monitor(type=MonitorType.HTTP, name='...', url='...', interval=60, maxretries=2)
api.add_monitor(type=MonitorType.PING, name='...', hostname='...', interval=60, maxretries=2)
```
⚠️ **NICHT TUN:** Sich auf `isDefault=True` bei `add_notification` verlassen, um die Notification automatisch an alle (auch später erstellte) Monitore zu hängen — greift über die API nicht zuverlässig. Stattdessen nach dem Anlegen explizit für jeden Monitor `api.edit_monitor(id, notificationIDList={'<notif_id>': True})` setzen.
⚠️ **NICHT TUN:** `retries=` als Kwarg für `add_monitor` verwenden — die Bibliothek erwartet `maxretries`, sonst `TypeError`.
Angelegte Monitore: 5× HTTP (Guacamole öffentlich, Vaultwarden/PVE-Web über Tailnet-Namen, AdGuard/Filebrowser über LAN-IP — bewusst IP statt `*.pve`-Name bei AdGuard, um keine Zirkularität zu erzeugen, falls AdGuard selbst der Ausfall ist), 6× Ping (VPS über Tailscale-IP, PVE-Host + 4 LXCs über LAN-IP).
## 9. ntfy: Erreichbarkeit + tailscale serve
```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.<TAILNET>` setzen und `docker compose up -d ntfy` (Recreate).
## 10. Backup-Cronjobs: Fehlschlag-Alerts
Bestehende Cronjobs (`vzdump`, `backup-guacamole.sh`, rsync-Pull) auf Wrapper-Skripte umgestellt, die bei Fehlschlag (`if ! <command>; then curl ntfy; fi` bzw. `trap ... ERR`) eine Push-Nachricht senden. Bewusst nur bei Fehlschlag, kein täglicher Erfolgs-Spam. Ersetzt die bis dahin wirkungslose `vzdump`-Mail-Benachrichtigung (Postfix auf dem PVE-Host ist `inet_interfaces = loopback-only`, kein Relay, kein root-Postfach — die Mail ging faktisch ins Leere).
## 11. Login-/Zugriffs-Tracking
Kleine Skripte (alle 5 Min. per Cron) statt eines Loki/Promtail-Log-Stacks — schreiben Erfolgs-/Fehlschlag-Zähler nach InfluxDB, pushen bei Schwellenwert (>3 Fehlschläge/5 Min.) einen ntfy-Alert. Zusätzlich pusht jedes Skript bei `SUCCESS -gt 0` eine niedrigprioritäre ntfy-Meldung für erfolgreiche Logins (eigener Block je Skript, `Priority: default` statt `high`, Tag `white_check_mark` statt `warning`).
⚠️ **Bekannte Rauschquelle:** Vaultwardens Clients nutzen denselben `/identity/connect/token`-Endpunkt auch für OAuth-Token-Refreshes, nicht nur für echte Logins — der Erfolgs-Alert kann dadurch öfter feuern als ein tatsächlicher neuer Login stattfindet. Bewusst so belassen (Nutzeranforderung), aber beim nächsten Mal ggf. auf `grant_type=password` in den Logs filtern, um echte Logins von Refreshes zu trennen.
| Dienst | Quelle |
|---|---|
| Guacamole (VPS) | Docker-Logs (`InMemoryAuthenticationFailureTracker`) + `guacamole_user_history`-Tabelle für Erfolge |
| Vaultwarden (CT103) | Docker-Logs, `(login) POST /identity/connect/token => <Status>` |
| PVE-Weboberfläche (Host) | `/var/log/pveproxy/access.log`, byte-offset-basiertes Tailing (kein natives `--since`) |
| SSH (PVE-Host) | `journalctl -u ssh --since "5 min ago"` |
| SSH/fail2ban (VPS) | `fail2ban-client status sshd` (Currently/Total failed/banned) — kein Log-Parsing nötig, fail2ban liefert die Zahlen direkt; Alert nur wenn „Currently banned" gegenüber letzter Messung steigt |
| AdGuard-Web-UI (CT100) | **nicht umgesetzt** — AdGuard loggt fehlgeschlagene Web-UI-Logins nachweislich nicht (getestet: weder Journal noch mit `verbose`-Flag), trotz eingebautem Lockout (`auth_attempts: 5`, `block_auth_min: 15`). Bewusste, dokumentierte Lücke statt einer fragilen Behelfslösung |
**Update 2026-07-19 — Wer/IP statt nur Zahlen:** Alle fünf Skripte (außer fail2ban, das schon Zahlen liefert) hängen den echten Login-Kontext an den ntfy-Text an:
- **SSH (Host):** `user@ip` per `sed -nE` aus `Accepted`/`Failed password`/`Invalid user`-Zeilen extrahiert
- **PVE-Web:** Quell-IP (erstes Feld in `access.log`, `::ffff:`-Präfix entfernt)
- **fail2ban:** von reinem Status-Polling auf `fail2ban.log`-Tailing (byte-offset) umgestellt — liefert die tatsächlich gesperrte(n) IP(s) aus den `NOTICE [sshd] Ban <ip>`-Zeilen statt nur "eine neue IP"
- **Vaultwarden:** brauchte `IP_HEADER: "X-Forwarded-For"` in der Compose-Datei (Vaultwarden loggt IPs sonst gar nicht) — `tailscale serve` setzt den Header bereits automatisch, nur Vaultwarden vertraute ihm nicht. Danach erscheint bei Fehlschlägen `Username or password is incorrect... IP: X. Username: Y.` im Log, extrahierbar. Bei **Erfolg** bleibt die IP unverfügbar (dieser Log-Pfad existiert nur im Fehlerfall)
- **Guacamole:** brauchte `REMOTE_IP_VALVE_ENABLED: "true"` in der Compose-Datei (Tomcats RemoteIpValve, liest `X-Forwarded-For` von Caddy). Vorher zeigte der Fehlschlag-Tracker nur die interne Docker-Gateway-IP (`172.18.0.1`), nach dem Fix die echte Client-IP. Bei **Erfolg** bleibt `guacamole_user_history.remote_host` trotzdem auf der internen Adresse — der DB-Verlauf nutzt offenbar einen anderen internen Pfad als der Live-Fehlschlag-Tracker, der die Valve-korrigierte Adresse honoriert. Nicht weiter debuggt (Aufwand/Nutzen), Username bei Erfolg ist trotzdem neu und nützlich
⚠️ **NICHT TUN:** `paste -sd', ' -` verwenden, um mehrere Werte mit `, ` zu verbinden. `paste -s -d LISTE` zykelt durch die **einzelnen Zeichen** der Delimiter-Liste statt sie als einen mehrzeichigen Trenner zu nutzen — bei 3+ Werten entsteht `a,b c,d e,f` (abwechselnd Komma und Leerzeichen) statt `a, b, c, d, e, f`. Fällt bei nur 1-2 Werten nicht auf (Zufallstreffer), erst bei mehreren wird der Bug sichtbar. Fix: `paste -sd',' - | sed 's/,/, /g'`.
## 12. HDD-Tracking hat sich selbst sabotiert (kritischer Fund)
Das 5-Minuten-Tracking-Script (`hdd-smart-to-influx.sh`) weckte mit jedem Lauf die schlafenden Platten — `smartctl -A` liest per Default auch bei einer Platte im Standby, was sie aufweckt. Da Check-Takt (5 Min.) und Spindown-Timeout (5 Min., siehe `01-pve-host.md`) identisch sind, hat das Monitoring den gerade erst eingerichteten Spindown fast vollständig ausgehebelt — bewiesen durch manuellen Test (Platte per `hdparm -y` schlafen gelegt, `smartctl -A` drüber, Status sofort wieder `active/idle`).
**Fix:** `smartctl -n standby -A <dev>` — überspringt (ohne Weckvorgang) die Abfrage, wenn die Platte schon schläft; Skript erkennt das am Exit-Code/der Meldung `Device is in STANDBY mode` und schreibt für diesen Durchlauf einfach keinen Datenpunkt für die schlafende Platte (InfluxDB/Grafana kommen mit Lücken problemlos klar).
⚠️ **NICHT TUN:** `smartctl -A` (ohne `-n standby`) in einem Monitoring-Script verwenden, das eine Platte im Standby beobachten soll — das ist ein Widerspruch in sich, das Skript verhindert genau das, was es messen will.
## 13. Esprimo-Hardware-Sensoren (CPU-Temp + Lüfter)
Fujitsu-eigener Sensorchip, nicht automatisch aktiv:
```bash
apt install -y lm-sensors
yes | sensors-detect --auto
echo -e "ftsteutates\ncoretemp" > /etc/modules-load.d/ftsteutates.conf
modprobe ftsteutates coretemp
```
Liefert `coretemp-isa-0000` (`Package id 0` = CPU-Temperatur) und `ftsteutates-i2c-0-73` (`fan2` = einziger belegter Lüfterkanal von 8, Rest `FAULT`/nicht verbaut). Werte per `sensors <chip>` parsen, ins `hdd-smart-to-influx.sh`-Script integriert (Measurement `esprimo_hw`, Felder `cpu_temp_c`/`fan_rpm`) — läuft im selben 5-Min-Timer mit statt einen eigenen zu brauchen.
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://<LAN_IP_MONITORING>:3000` | Admin-Passwort in Vaultwarden |
| Uptime Kuma | `http://<LAN_IP_MONITORING>:3001` | Admin-Passwort in Vaultwarden |
| InfluxDB | `http://<LAN_IP_MONITORING>:8086` | Admin-Passwort + Token in Vaultwarden |
| ntfy (Push) | `https://monitoring.<TAILNET>`, Topic `homelab-alerts` | Android/iOS-App auf den self-hosted Server zeigen lassen |
| NAS-Backups (SMB) | `\\nas.pve\backups` | Read-only, gleicher Nutzer `<PRIMARY_USER>` wie `freigabe` |

240
09-gast-zugang.md Normal file
View file

@ -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.<DOMAIN>`).
## 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 (`<PVE_HOSTNAME>`). 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** (`<TS_IP_NAS>: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 <TS_IP_NAS> -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.<DOMAIN>`, `/etc/caddy/Caddyfile`:
```
grafana-gast.<DOMAIN> {
reverse_proxy <TS_IP_MONITORING>:3000
}
dateien-gast.<DOMAIN> {
reverse_proxy <TS_IP_NAS>:8081
}
status-gast.<DOMAIN> {
reverse_proxy <TS_IP_MONITORING>: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 (`<LAN_SUBNET>`, `<DOMAIN>`, `<TAILNET>`, `<PC1_MAC_ADDRESS>`, `<PVE_HOSTNAME>`, `<PRIMARY_USER>`, …). 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/<LAN_IP_PVE>/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://<TS_IP_MONITORING>:3001')
api.login('admin', PASSWORD)
api.add_monitor(type=MonitorType.HTTP, name='Grafana (Gast)', url='https://grafana-gast.<DOMAIN>',
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', '<LAN_IP_PVE>'), ('DNS / Ad-Blocking', '<LAN_IP_ADGUARD>'),
('NAS / Dateiablage', '<LAN_IP_NAS>'), ('Remote-Desktop', '<LAN_IP_DESKTOP>'),
('Passwort-Manager', '<LAN_IP_VAULTWARDEN>'), ('Monitoring-Stack', '<LAN_IP_MONITORING>'),
('VPN-Gateway', '<TS_IP_VPS>'),
]
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.<DOMAIN>/api/status-page/heartbeat/gast`): alle sieben neuen Monitore liefern echte Ping-Antwortzeiten (0,0525 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 `<DOMAIN>`, 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 <name>.<DOMAIN> 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 <ip> <err>`), 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 <wer>`) — 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.<DOMAIN>` und `https://dateien-gast.<DOMAIN>`, 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).

16
ACHTUNG-DISCLAIMER.md Normal file
View file

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

64
ENTSCHEIDUNGEN.md Normal file
View file

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

27
VARIABLEN.md Normal file
View file

@ -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 |
|---|---|---|
| `<LAN_SUBNET>` | Das eigene LAN-Subnetz | `192.168.1.0/24` |
| `<LAN_IP_ROUTER>` | IP des Heimrouters | `192.168.1.1` |
| `<LAN_IP_ADGUARD>` | IP des DNS-/Ad-Blocking-Containers | `192.168.1.9` |
| `<LAN_IP_PVE>` | IP des Proxmox-Hosts | `192.168.1.10` |
| `<LAN_IP_NAS>` | IP des NAS-Containers | `192.168.1.11` |
| `<LAN_IP_DESKTOP>` | IP des Management-Desktop-Containers | `192.168.1.12` |
| `<LAN_IP_VAULTWARDEN>` | IP des Passwortmanager-Containers | `192.168.1.13` |
| `<LAN_IP_MONITORING>` | IP des Monitoring-Containers | `192.168.1.14` |
| `<LAN_IP_PC1>` | IP des Windows-Rechners (Wake-on-LAN-Ziel) | `192.168.1.254` |
| `<LAN_BROADCAST>` | Broadcast-Adresse des LAN-Subnetzes | `192.168.1.255` |
| `<DOMAIN>` | Die eigene registrierte Domain | `meinhomelab.example` |
| `<TAILNET>` | Der eigene Tailscale-Tailnet-Name (MagicDNS-Suffix) | `tailXXXXXX.ts.net` |
| `<TS_IP_*>` | Jeweilige Tailscale-IP der Instanz (vergibt Tailscale automatisch beim Beitritt) | `100.x.x.x` |
| `<PC1_MAC_ADDRESS>` | MAC-Adresse des Wake-on-LAN-Ziels | `AA:BB:CC:DD:EE:FF` |
| `<PVE_HOSTNAME>` | Frei gewählter Hostname des Proxmox-Hosts | `mein-pve` |
| `<PRIMARY_USER>` | Der eigene Haupt-Benutzername (Samba/SSH/Guacamole) | `max` |
| `<AUTH_KEY>` | 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.