homelab-showcase/09-gast-zugang.md

240 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

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