diff --git a/09-gast-zugang.md b/09-gast-zugang.md deleted file mode 100644 index b89d1b6..0000000 --- a/09-gast-zugang.md +++ /dev/null @@ -1,240 +0,0 @@ -# Gast-Zugang: Read-only Showcase für Freunde/Bekannte und Bewerbungsgespräche — Rebuild-Runbook - -Cross-cutting, kein eigener Host: nutzt CT101 (NAS), CT104 (Monitoring) und VPS (Caddy). Die Sicherheitsdiskussion, die zu diesem Design geführt hat: siehe `ENTSCHEIDUNGEN.md`, Abschnitt „Warum der Gast-Zugang eine eigene, komplett neue Oberfläche bekam". - -## Prinzip - -Zwei komplett read-only Showcase-Konten (`gast-freunde`, `gast-bewerbung`), standardmäßig **deaktiviert**, manuell vor einem Termin aktiviert (plus optionales Ablaufzeit-Sicherheitsnetz). Kein Terminal, kein Schreibzugriff, keine Sicht auf interne Login-/Zugriffs-Daten. Bewusst **kein** Zugriff auf den bestehenden Management-Desktop (CT102) — der hat ein Terminal und sitzt im flachen LAN, für Gäste zu riskant. - -Drei Bausteine: -1. **Grafana-Gast-Dashboard** (CT104) — kuratierte Kopie ohne Login-Daten, plus fail2ban-Statistik als Sicherheits-Showcase -2. **Zweite Filebrowser-Instanz** (CT101) — eigener Port/eigene DB, read-only, zeigt nur `/srv/gast-demo` (= die genericierten Portfolio-Runbooks) -3. **Uptime-Kuma-Status-Page** (CT104) — öffentliche Ampel-Ansicht ohne technische Details - -Alle drei über neue öffentliche Caddy-Vhosts auf dem VPS erreichbar (gleiches Muster wie `guac.`). - -## 1. Grafana: Ordner-getrenntes Gast-Dashboard - -Grafanas eingebaute Rolle „Viewer" reicht **nicht** als Isolation — ein Viewer sieht standardmäßig alle Dashboards der Org. Für echte Trennung: zwei Ordner mit gebrochener Berechtigungs-Vererbung. - -```bash -# Dashboards in getrennte Unterordner der Provisionierung legen -mkdir -p /opt/monitoring/grafana-provisioning/dashboards/intern /opt/monitoring/grafana-provisioning/dashboards/gast -mv .../dashboards/homelab.json .../dashboards/intern/ -mv .../dashboards/homelab-gast.json .../dashboards/gast/ -``` - -`provider.yaml` mit zwei Providern (je eigenem `folder:` und `path:`), danach `docker compose restart grafana` (reine Datei-Provisionierung reicht sonst nicht für neue Ordner-Zuordnung). - -Anschließend Ordner-Berechtigungen umbauen (klassische Permissions-API, **nicht** die neue `/api/access-control/...`-RBAC-API — letztere zeigt zwar den Ist-Zustand an, ist aber für Änderungen umständlicher): - -```bash -# Intern: Viewer-Rolle komplett entfernen -> kein Viewer-Konto sieht das je automatisch -curl -u admin:$PW -X POST http://localhost:3000/api/folders/$INTERN_UID/permissions \ - -d '{"items":[{"role":"Editor","permission":2}]}' - -# Gast: Viewer-Rolle raus, stattdessen nur die zwei konkreten User explizit erlauben -curl -u admin:$PW -X POST http://localhost:3000/api/folders/$GAST_UID/permissions \ - -d '{"items":[{"role":"Editor","permission":2},{"userId":2,"permission":1},{"userId":3,"permission":1}]}' -``` - -Nutzer anlegen (Rolle Viewer ist bei Admin-erstellten Usern bereits Default): - -```bash -curl -u admin:$PW -X POST http://localhost:3000/api/admin/users \ - -d '{"name":"Gast Freunde","login":"gast-freunde","password":"...","OrgId":1}' -``` - -⚠️ **NICHT TUN:** sich auf `isDefault`/Standard-Ordner-Vererbung verlassen und nur ein Dashboard ohne Ordner-Trennung anlegen — ein Viewer-Account sieht dann trotzdem alle anderen Dashboards der Instanz mit (bestätigt: `curl -u gast-freunde ... /api/dashboards/uid/homelab-overview` gab vor der Ordner-Trennung 200 zurück, keine echte Isolation). - -Deaktivieren/Aktivieren eines Users: -```bash -curl -u admin:$PW -X POST http://localhost:3000/api/admin/users/$ID/enable # bzw. /disable -``` - -### Echte Hostnamen aus den Legenden entfernt (2026-07-20, Nutzerwunsch) - -Die Ressourcen-Panels (CPU/RAM/Disk je LXC) wurden zunächst 1:1 aus dem internen Dashboard übernommen — die Grafana-Legende zeigt dabei den `host`-Tag aus InfluxDB, also die echten Container-Hostnamen (`adguard`, `nas`, `browser`, `vaultwarden`, `monitoring`) bzw. den PVE-Hostnamen (``). Auf Nutzerwunsch durch **Rollen-Bezeichnungen statt Servernamen** ersetzt — man soll erkennen können, *was* läuft (DNS-Blocking, NAS, Passwort-Manager, …), ohne die echten internen Namen zu erfahren. Umsetzung per Flux `map()`, das den `host`-Wert vor dem finalen `keep()` umschreibt: - -```flux -|> map(fn: (r) => ({ r with host: - if r.host == "adguard" then "DNS / Ad-Blocking" - else if r.host == "nas" then "NAS / Dateiablage" - else if r.host == "browser" then "Remote-Desktop" - else if r.host == "vaultwarden" then "Passwort-Manager" - else if r.host == "monitoring" then "Monitoring-Stack" - else r.host })) -|> keep(columns: ["_time", "host", "_value"]) -``` - -Der PVE-Host selbst hat nur eine Zeile (kein Tag-Vergleich nötig): `map(fn: (r) => ({ r with host: "Hauptserver" }))`. Zusätzlich Panel-**Titel** generisiert, die Hardware-Marken/Dataset-Namen im Klartext hatten: „Esprimo (PVE-Host)" → „Hauptserver", „Intenso NVMe" → „NVMe-Speicher", „nas/freigabe"/"nas/backups" → „Dateiablage"/"Backup-Speicher", „VPS" → „VPN-Gateway". Das interne Dashboard (`Intern`-Ordner) bleibt bewusst unverändert mit den echten Namen — nur die Gast-Kopie wurde angepasst. Per Live-Query gegen InfluxDB verifiziert: alle fünf LXC-Rollen + Hauptserver lösen korrekt auf, keine rohen Hostnamen mehr in den Ergebnissen. - -⚠️ **NICHT TUN:** Rollen-Namen so generisch wählen, dass der Showcase-Zweck verloren geht (z.B. "System A"/"System B"). Ziel war ausdrücklich, dass ein Betrachter weiterhin erkennt, *welche Art* Dienst läuft — nur der konkrete interne Name/Hostname soll verborgen bleiben. - -## 2. Zweite Filebrowser-Instanz auf CT101 - -Eigener Systemd-Unit, eigene DB, eigener Port, eigener dedizierter Linux-User (kein Zugriff auf sonstige Homelab-Pfade — Defense in Depth für den einzigen wirklich öffentlich erreichbaren Datei-Endpunkt): - -```bash -useradd --system --no-create-home --shell /usr/sbin/nologin gastdemo -mkdir -p /srv/gast-demo /var/lib/filebrowser-gast -chown -R gastdemo:gastdemo /srv/gast-demo /var/lib/filebrowser-gast -# Inhalt: die genericierten Portfolio-Runbooks (siehe Abschnitt 4) - -runuser -u gastdemo -- filebrowser config init -d /var/lib/filebrowser-gast/filebrowser.db --root /srv/gast-demo -runuser -u gastdemo -- filebrowser users add gast-freunde '...' -d /var/lib/filebrowser-gast/filebrowser.db \ - --perm.admin=false --perm.execute=false --perm.create=false --perm.rename=false \ - --perm.modify=false --perm.delete=false --perm.share=false --perm.download=true --scope / -``` - -Systemd-Unit bindet **nur an die Tailscale-IP** (`:8081`), nicht ans LAN — der einzige Zugriffsweg ist über den VPS-Caddy-Vhost + Tailscale, LAN-Nutzer erreichen die Instanz gar nicht: - -```ini -[Service] -User=gastdemo -Group=gastdemo -ExecStart=/usr/local/bin/filebrowser -d /var/lib/filebrowser-gast/filebrowser.db -a -p 8081 -Restart=on-failure -``` - -⚠️ **NICHT TUN:** `--perm.execute` vergessen. Der `users add`-Default für Execute ist `true` (Command-Runner-Feature) — beim ersten Anlegen übersehen, per `users update ... --perm.execute=false` nachgezogen. Immer nach dem Anlegen mit `filebrowser users ls -d ...` verifizieren, dass wirklich nur `Download=true` gesetzt ist. - -⚠️ **NICHT TUN:** die bestehende NAS-Freigabe read-only exponieren wollen. Ein Berechtigungs-Bug in der Konfiguration würde dann echte Daten zeigen. Stattdessen komplett eigenes Verzeichnis + eigene Instanz, die von Anfang an nichts anderes kennt. - -**Bekannte Einschränkung:** Filebrowser hat in dieser Version **kein** Feld/Flag zum Deaktivieren einzelner Nutzer (anders als Grafana). Der „Aus"-Zustand wird deshalb über den ganzen Systemd-Service abgebildet (`systemctl stop filebrowser-gast`) — das deaktiviert zwangsläufig beide Gast-Logins gleichzeitig auf dieser Ebene. Da beide Konten ohnehin unabhängige Passwörter mit identischen Rechten haben, ist das funktional unkritisch, aber beachten: „nur `freunde` aktiv lassen" gilt nur für Grafana, nicht für die Dateifreigabe. - -## 3. Caddy-Vhosts (VPS) - -Gleiches Muster wie `guac.`, `/etc/caddy/Caddyfile`: - -``` -grafana-gast. { - reverse_proxy :3000 -} -dateien-gast. { - reverse_proxy :8081 -} -status-gast. { - reverse_proxy :3001 -} -``` - -`caddy validate --config /etc/caddy/Caddyfile` vor `systemctl reload caddy`. TLS-Zertifikate zieht Caddy automatisch, sobald die DNS-A-Records existieren (siehe Abschnitt 6). - -## 4. Portfolio-Runbooks (Inhalt von `/srv/gast-demo`) - -Genericierte Kopien aller `rebuild/*.md` in `../rebuild-portfolio/` — echte LAN-/Tailscale-IPs, Domain, Tailnet-Name, MAC-Adresse, Benutzername ersetzt durch Platzhalter (``, ``, ``, ``, ``, ``, …). Erklärung der Platzhalter in `../rebuild-portfolio/VARIABLEN.md`. Debugging-Geschichten/„NICHT TUN"-Blöcke bleiben bewusst unverändert drin — nach der Anonymisierung nicht mehr auf die echte Instanz rückführbar, und genau das zeigt in einem Bewerbungsgespräch, dass reale Probleme selbst gefunden und gelöst wurden. - -```bash -sed -i -e 's/192\.168\.178\.10\b//g' ... *.md # vollständige sed-Kette: siehe Session-Log -``` - -Nach jeder Änderung an den echten `rebuild/*.md`-Dateien: Portfolio-Kopie manuell nachziehen (kein automatischer Sync) und erneut gegen die bekannten echten Werte grep-verifizieren. - -## 5. Uptime Kuma: Gast-Monitore + Status-Page - -Kein REST-API für Monitor-/Status-Page-Verwaltung — Python-Bibliothek `uptime-kuma-api` (Socket.IO-Client) nötig: - -```python -from uptime_kuma_api import UptimeKumaApi, MonitorType -api = UptimeKumaApi('http://:3001') -api.login('admin', PASSWORD) -api.add_monitor(type=MonitorType.HTTP, name='Grafana (Gast)', url='https://grafana-gast.', - interval=60, retryInterval=60, maxretries=2, notificationIDList=[]) -api.add_status_page('gast', 'Homelab Showcase') -api.save_status_page(slug='gast', title='Homelab Showcase', published=True, showPoweredBy=False, - publicGroupList=[{'name': 'Showcase', 'weight': 1, - 'monitorList': [{'id': 12}, {'id': 13}, {'id': 14}]}]) -``` - -⚠️ **NICHT TUN:** den drei Gast-Monitoren die ntfy-Notification zuweisen (weder explizit noch über `isDefault`). Die Konten werden absichtlich manuell rauf-/runtergefahren — mit Notification würde jedes Deaktivieren einen „Down"-Alarm auslösen. `notificationIDList=[]` beim Anlegen explizit setzen. - -### Zweite Gruppe: echte Infrastruktur-Telemetrie hinter Rollen-Namen (2026-07-20) - -Die Status-Page wirkte mit nur drei Kacheln leer. Statt reiner Ampeln (Up/Down) zeigt eine zweite Gruppe „Infrastruktur" jetzt **echte Ping-Telemetrie** (Antwortzeit-Verlauf, Uptime-%) für die Kern-Systeme — mit denselben generischen Rollen-Namen wie im Gast-Grafana-Dashboard (siehe Abschnitt 1), damit beide Oberflächen konsistent wirken: - -```python -specs = [ - ('Hauptserver', ''), ('DNS / Ad-Blocking', ''), - ('NAS / Dateiablage', ''), ('Remote-Desktop', ''), - ('Passwort-Manager', ''), ('Monitoring-Stack', ''), - ('VPN-Gateway', ''), -] -for name, ip in specs: - api.add_monitor(type=MonitorType.PING, name=name, hostname=ip, - interval=60, retryInterval=60, maxretries=2, notificationIDList=[]) -``` - -Uptime Kuma zeigt auf der Status-Page nur den konfigurierten Namen + Antwortzeit-Graph, nicht die Ziel-IP — die echten LAN-IPs sind also nur intern für den Check relevant, nie öffentlich sichtbar. Zweite Gruppe per `publicGroupList` mit eigenem `weight` ergänzt (bestehende „Showcase"-Gruppe bleibt unverändert stehen, Gruppen werden per Name identifiziert und beim Speichern komplett ersetzt — beide Gruppen müssen bei jedem `save_status_page`-Aufruf mit angegeben werden, sonst verschwindet die andere). - -Verifiziert über die öffentliche URL (`curl https://status-gast./api/status-page/heartbeat/gast`): alle sieben neuen Monitore liefern echte Ping-Antwortzeiten (0,05–25 ms je nach Ziel), Gruppennamen und Monitor-Namen kommen unverändert als reine Rollen-Bezeichnungen an — keine IP/Hostname in der API-Antwort sichtbar. - -**Uptime-Kuma-Admin-Passwort verloren gegangen:** Die ursprünglichen Zugangsdaten wurden nur im Chat einer früheren Session ausgegeben und nirgends dauerhaft abgelegt (Konvention: Passwörter gehören in Vaultwarden, das ist hier nicht passiert). Reset direkt in der SQLite-DB des laufenden Containers (Uptime Kuma bringt `node`+`bcryptjs`+`sqlite3` im Image mit): - -```bash -HASH=$(docker exec uptime-kuma node -e "const bcrypt=require('bcryptjs'); console.log(bcrypt.hashSync(process.argv[1], 10));" "$NEWPW") -docker exec uptime-kuma sqlite3 /app/data/kuma.db "UPDATE user SET password = '$HASH' WHERE id = 1;" -``` - -Funktioniert bei laufendem Container (SQLite/WAL erlaubt den kurzen Fremdzugriff für ein einzelnes UPDATE). **Lehre:** Admin-Zugangsdaten für alle Dienste dieser Session tatsächlich in Vaultwarden ablegen, nicht nur im Chat stehen lassen. - -## 6. DNS (manueller Schritt, keine API verfügbar) - -Drei A-Records im STRATO-Panel für ``, alle auf die VPS-IP (gleiche IP wie der bestehende `guac`-Eintrag): -- `grafana-gast` → VPS-IP -- `dateien-gast` → VPS-IP -- `status-gast` → VPS-IP - -⚠️ **NICHT TUN:** Caddy neu laden/starten, *bevor* alle A-Records korrekt gesetzt sind, und dann erwarten, dass es sich von selbst korrigiert. Certmagic (Caddys ACME-Bibliothek) merkt sich fehlgeschlagene Versuche pro Hostname und schaltet nach mehreren Fehlschlägen zur Schonung des Let's-Encrypt-Produktiv-Rate-Limits automatisch auf die **Staging-CA** um (liefert kein von Browsern akzeptiertes Zertifikat) plus einen Backoff von bis zu 20 Minuten zwischen Versuchen. Gleiches Muster wie beim ursprünglichen `guac`-Setup. Fix: erst alle DNS-Einträge verifizieren (`dig @1.1.1.1 . A`, gegen mehrere öffentliche Resolver prüfen, eigener AdGuard-Resolver cached u.U. noch den alten Stand), danach `systemctl restart caddy` (nicht nur `reload` — ein Neustart setzt den internen Fehlversuch-/Backoff-Zustand zurück, ein reines `reload` nicht zuverlässig). Direkt danach in `journalctl -u caddy` auf `"certificate obtained successfully"` mit `ca":"https://acme-v02.api.letsencrypt.org/directory"` (nicht `-staging-`) prüfen. - -**Stolperstein bei diesem Durchlauf:** ein Subdomain-Eintrag im STRATO-Panel landete zunächst auf `217.160.0.161` (STRATOs eigenem Parking-/Webhosting-Server) statt der eingetragenen Ziel-IP — vermutlich ein falsch vorausgewählter Eintragstyp im Formular (z.B. "STRATO-Webhosting" statt "eigene IP-Adresse"). Per `dig` direkt gegen die autoritativen STRATO-Nameserver (`docks14.rzone.de`/`shades02.rzone.de`) und mehrere öffentliche Resolver (1.1.1.1/8.8.8.8/9.9.9.9) geprüft, um Panel-Fehler von reiner Propagationsverzögerung zu unterscheiden — eine SOA-Antwort statt einer A-Antwort bedeutet "Eintrag existiert nicht", nicht "noch nicht propagiert". Ein Tippfehler in der Subdomain selbst (`datein-gast` statt `dateien-gast`) trat ebenfalls auf — beim Anlegen den tatsächlich in Caddy konfigurierten Namen exakt gegenprüfen. - -## 7. Login-Tracking (Gast-Nutzung sichtbar machen) - -Anders als bei Guacamole/Vaultwarden/PVE-Web/SSH ist bei den Gast-Oberflächen die Logausbeute schlechter: - -- **Grafana:** loggt bei Formular-Login (`/login`, POST) **weder bei Erfolg noch bei Fehlschlag den Benutzernamen**. Ein Fehlschlag erzeugt `level=info msg="Failed to authenticate request" client=auth.client.form` + eine „Request Completed"-Zeile mit `path=/login status=401` — aber ohne Username, könnte auch ein vertippter Admin-Login sein. Ein Erfolg erzeugt **gar keine** Log-Zeile beim Login selbst; einzig nachfolgende authentifizierte Requests tragen `uname=gast-freunde`/`uname=gast-bewerbung` als Tag. `track-grafana-gast.sh` (Cron alle 5 Min. auf CT104) zählt deshalb Fehlschläge generisch und deutet „Aktivität" über das Vorkommen dieser `uname=`-Tags. -- **Filebrowser:** loggt nur Fehlschläge, mit IP (`/api/login: 403 `), keine Erfolge — gleiche Einschränkung wie beim bereits dokumentierten AdGuard-Gap. `track-filebrowser-gast.sh` (Cron alle 5 Min. auf CT101, no-op wenn der Service gerade gestoppt ist) trackt entsprechend nur `fail`. - -Beide schreiben ins bestehende `logins`-Measurement (`service=grafana-gast`/`service=filebrowser-gast`), gleiches Muster wie die übrigen Tracker. - -**ntfy-Push (2026-07-20 ergänzt):** `track-grafana-gast.sh` pusht bei >3 Fehlversuchen (hohe Priorität) und bei jeder erkannten Aktivität (`uname=`-Tag, Standard-Priorität — das deckt "erfolgreicher Login" für Grafana ab, da ein echtes Login-Event selbst nicht separat loggbar ist). `track-filebrowser-gast.sh` pusht nur bei >3 Fehlversuchen — ein Push bei erfolgreichem Filebrowser-Login ist **technisch nicht möglich**: kein Verbose-/Debug-Log-Flag vorhanden (`filebrowser -h` zeigt nur `-l/--log` für das Ausgabeziel, keine Log-Level-Option), das Tool loggt nachweislich nur Fehlschläge — exakt dieselbe Einschränkung wie bei AdGuard. - -## 8. Aktivieren/Deaktivieren + Ablaufzeit-Sicherheitsnetz - -Zwei Skripte auf dem PVE-Host (steuern CT104 + CT101 gemeinsam über `pct exec`): - -```bash -gast-aktivieren.sh freunde|bewerbung [stunden] -gast-deaktivieren.sh freunde|bewerbung|alle -``` - -`gast-aktivieren.sh` aktiviert den Grafana-User, startet `filebrowser-gast.service`, und plant bei Angabe von `[stunden]` per `at` einen einmaligen Folgejob (`gast-deaktivieren.sh `) — reines Sicherheitsnetz für den Fall, dass das manuelle Abschalten vergessen wird, kein Ersatz dafür. `gast-deaktivieren.sh` stoppt `filebrowser-gast.service` nur dann mit, wenn danach **kein** Gast-Grafana-Konto mehr aktiv ist (sonst würde das Deaktivieren von `freunde` auch `bewerbung`s Dateizugriff kappen, obwohl der ggf. noch laufen soll). - -```bash -apt-get install -y at && systemctl enable --now atd -``` - -⚠️ **NICHT TUN:** `at`-Pakete für die Auto-Deaktivierung annehmen, ohne `atd` separat zu aktivieren — auf frisch installiertem Debian läuft der Dienst nach `apt-get install at` nicht automatisch. - -### Automatisches Passwort-Cycling - -Weder Grafana OSS noch Filebrowser kennen ein "Passwort muss nach Login neu gesetzt werden"-Flag — als praktisches Äquivalent erzeugt `gast-aktivieren.sh` bei **jeder** Aktivierung ein frisches Zufallspasswort (12 Zeichen, nur Buchstaben/Ziffern ohne verwechselbare Zeichen wie `0`/`O`/`1`/`l`/`I` — bewusst niedrige Komplexität, da es dem Gast mündlich/per Kurznachricht mitgeteilt werden soll) und setzt es für **beide** Dienste gleich, damit sich der Gast nur ein Passwort merken muss: - -```bash -NEWPASS=$(tr -dc 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789' < /dev/urandom | head -c 12 || true) -curl -u admin:$GF_PASS -X PUT http://localhost:3000/api/admin/users/$ID/password -d "{\"password\":\"$NEWPASS\"}" -filebrowser users update gast-freunde -d /var/lib/filebrowser-gast/filebrowser.db -p "$NEWPASS" -``` - -Altes Passwort wird dadurch automatisch ungültig — keine manuelle Rotation nötig, das alte Passwort muss auch nirgends aufgehoben werden. - -⚠️ **NICHT TUN:** `tr ... | head -c N` unter `set -o pipefail` verwenden, ohne den SIGPIPE-Fall abzufangen. `head` beendet die Pipe, sobald genug Bytes gelesen sind — `tr` bekommt dadurch SIGPIPE (Exit 141), was mit `pipefail` das ganze Skript abbricht. Fix: `... | head -c N || true`. - -⚠️ **NICHT TUN:** das Filebrowser-Passwort per CLI setzen, während `filebrowser-gast.service` bereits läuft. Filebrowsers bbolt-Datenbank erlaubt nur einen Prozess gleichzeitig — CLI und laufender Dienst kollidieren mit `Error: timeout`. Reihenfolge im Skript: Service stoppen → Passwort per CLI setzen → Service starten. - -Verifiziert: `gast-aktivieren.sh freunde` → Grafana-Login + Filebrowser-Login funktionieren; `gast-deaktivieren.sh freunde` → beides sofort wieder zu; `at`-Job mit 1 Minute Vorlauf → `gast-deaktivieren.sh` feuert zuverlässig automatisch. Nach dem DNS-/Zertifikats-Fix zusätzlich vollständig über die echten öffentlichen URLs (nicht nur intern/Tailnet) durchgetestet: Login bei aktiviertem Konto funktioniert über `https://grafana-gast.` und `https://dateien-gast.`, zeigt nachweislich nur das Gast-Dashboard, Status-Page lädt; nach Deaktivierung liefern beide öffentlichen URLs wieder 401/502. Passwort-Rotation getestet: nach zweiter Aktivierung meldet das alte Passwort 401, nur das neu ausgegebene funktioniert — für beide Konten (`freunde`/`bewerbung`) einzeln bestätigt. - -### ntfy-Push bei Aktivieren/Deaktivieren (2026-07-20) - -Beide Skripte schließen jetzt mit einem `curl` an `http://localhost:8090/homelab-alerts` (via `pct exec 104`, da ntfy auf CT104 läuft) ab — Standard-Priorität, kein Alarm-Ton, reine Statusmeldung ("Gast-Zugang 'freunde' wurde soeben aktiviert (Auto-Deaktivierung in 3h)." bzw. "... deaktiviert."). Über `curl "http://localhost:8090/homelab-alerts/json?poll=1&since=2m"` gegen echte Zustellung verifiziert (nicht nur, dass der `curl`-Aufruf ohne Fehler durchläuft).