diff --git a/09-Gast-Zugang.md b/09-Gast-Zugang.md index 5ff965d..c848f4a 100644 --- a/09-Gast-Zugang.md +++ b/09-Gast-Zugang.md @@ -1,17 +1,20 @@ # 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". +Cross-cutting, kein eigener Host: nutzt CT101 (NAS), CT104 (Monitoring) und den VPS (Caddy). Vollständige Architektur-Begründung und 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. +Pro Gast ein **eigenes** Konto mit eigenem Passwort und eigener Laufzeit, angelegt bei Bedarf und automatisch ablaufend (`gast-zugang.py`, Abschnitt 8). Ursprünglich gab es zwei feste Sammelkonten (`gast-freunde`, `gast-bewerbung`) — abgelöst, weil sie nicht zurechenbar waren (mehrere Personen unter einem Login) und nach jeder Nutzung von Hand abgeschaltet werden mussten, was in der Praxis unterblieb. -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 anonymisierten Portfolio-Runbooks) -3. **Uptime-Kuma-Status-Page** (CT104) — öffentliche Ampel-Ansicht ohne technische Details +Read-only Showcase-Konten, standardmäßig nicht existent, pro Anlass angelegt (mit Ablaufzeit als Sicherheitsnetz). Kein Terminal, kein Schreibzugriff, keine Sicht auf interne Login-/Zugriffs-Daten. Bewusst **kein** Zugriff auf den bestehenden Management-Desktop — der hat ein Terminal und sitzt im flachen LAN, für Gäste zu riskant. -Alle drei über neue öffentliche Caddy-Vhosts auf dem VPS erreichbar (gleiches Muster wie `guac.`). +Bausteine: +1. **Grafana-Gast-Dashboard** — kuratierte Kopie ohne Login-Daten, plus fail2ban-Statistik als Sicherheits-Showcase. **Der einzige Baustein mit echten Gast-Konten.** +2. ~~Zweite Filebrowser-Instanz~~ — abgebaut, siehe Abschnitt 2. Die Runbooks stehen im Showcase-Wiki, der Filebrowser-Umweg war überflüssig. +3. **Uptime-Kuma-Status-Page** — Ampel-Ansicht ohne technische Details +4. **Code-Showcase** (Git-Hosting) — read-only Blick ins Showcase-Repo, dauerhaft öffentlich, kein Konto nötig + +Erreichbar über öffentliche Caddy-Vhosts auf dem VPS (gleiches Muster wie der bestehende Passwortmanager-Vhost). ## 1. Grafana: Ordner-getrenntes Gast-Dashboard @@ -33,7 +36,10 @@ Anschließend Ordner-Berechtigungen umbauen (klassische Permissions-API, **nicht 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 +# Gast: Viewer-Rolle raus, stattdessen die konkreten User explizit erlauben. +# WICHTIG: dieser POST ERSETZT die komplette Berechtigungsliste des Ordners. +# gast-zugang.py liest sie deshalb erst, haengt den neuen User an und schreibt +# alles zurueck - sonst verlieren die uebrigen Gaeste ihren Zugriff. 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}]}' ``` @@ -52,9 +58,9 @@ Deaktivieren/Aktivieren eines Users: 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) +### Echte Hostnamen aus den Legenden entfernt (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: +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 Hostnamen des Hauptservers. 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: @@ -67,11 +73,17 @@ Die Ressourcen-Panels (CPU/RAM/Disk je LXC) wurden zunächst 1:1 aus dem interne |> 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. +Der Host des Hauptservers 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: „Hauptserver" statt Gerätename, „NVMe-Speicher" statt Markenname, „Dateiablage"/„Backup-Speicher" statt Dataset-Pfad, „VPN-Gateway" statt „VPS". Das interne Dashboard 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 +## 2. Zweite Filebrowser-Instanz auf der NAS — ABGEBAUT + +> **Nicht mehr in Betrieb.** Entfernt wurden: der Gast-Filebrowser-Service, seine Datenbank, das Demo-Verzeichnis, der dedizierte Linux-User, der zugehörige Login-Tracking-Cron-Job und der Caddy-Vhost dafür. Eine Sicherung von DB und Unit liegt lokal als Referenz. Der zugehörige DNS-Eintrag kann gelöscht werden (manueller Schritt). +> +> ⚠️ **NICHT TUN: beim Abbau das gemeinsam genutzte Filebrowser-Binary mit entfernen.** Es wird von weiteren, weiterhin aktiven Filebrowser-Instanzen genutzt (u.a. ein Backup-Browser). Nur die Gast-Instanz anfassen. +> +> Der folgende Abschnitt bleibt als Referenz stehen, falls die Instanz je wieder gebraucht wird. 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): @@ -79,7 +91,7 @@ Eigener Systemd-Unit, eigene DB, eigener Port, eigener dedizierter Linux-User (k 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 anonymisierten Portfolio-Runbooks (siehe Abschnitt 4) +# Inhalt: die generischen 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 \ @@ -101,16 +113,17 @@ Restart=on-failure ⚠️ **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. +**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. ## 3. Caddy-Vhosts (VPS) -Gleiches Muster wie `guac.`, `/etc/caddy/Caddyfile`: +Gleiches Muster wie der bestehende Passwortmanager-Vhost, `/etc/caddy/Caddyfile`: ``` grafana-gast. { reverse_proxy :3000 } +# ENTFERNT (Gast-Filebrowser abgebaut) - bleibt als Referenz: dateien-gast. { reverse_proxy :8081 } @@ -121,15 +134,13 @@ status-gast. { `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`) +## 4. Portfolio-Runbooks — Quelle für den öffentlichen Showcase -Anonymisierte 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. +Generische Kopien aller internen Rebuild-Runbooks liegen in diesem Ordner — echte LAN-/Tailscale-IPs, Domain, Tailnet-Name, MAC-Adresse, Benutzername ersetzt durch Platzhalter (``, ``, ``, ``, ``, ``, …). Erklärung der Platzhalter in `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 -``` +Vor jeder Veröffentlichung läuft ein eigenes Publish-Gate (Secret-Scan + Regex-Prüfung auf die echten Werte) — Details dazu am Ende dieses Runbooks, Abschnitt „Showcase-Runbooks als Wiki". -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. +Nach jeder Änderung an den echten internen Runbooks: diese Portfolio-Kopie manuell nachziehen (kein automatischer Sync) und erneut gegen die bekannten echten Werte prüfen. ## 5. Uptime Kuma: Gast-Monitore + Status-Page @@ -149,7 +160,7 @@ api.save_status_page(slug='gast', title='Homelab Showcase', published=True, show ⚠️ **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) +### Zweite Gruppe: echte Infrastruktur-Telemetrie hinter Rollen-Namen 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: @@ -167,74 +178,462 @@ for name, ip in specs: 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. +Verifiziert über die öffentliche URL: alle sieben neuen Monitore liefern echte Ping-Antwortzeiten, 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): +**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 den Passwortmanager, 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. +Funktioniert bei laufendem Container (SQLite/WAL erlaubt den kurzen Fremdzugriff für ein einzelnes UPDATE). **Lehre:** Admin-Zugangsdaten für alle Dienste tatsächlich im Passwortmanager 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): +A-Records im Provider-Panel für ``, alle auf die VPS-IP (gleiche IP wie der bestehende Passwortmanager-Eintrag): - `grafana-gast` → VPS-IP -- `dateien-gast` → VPS-IP +- ~~`dateien-gast` → VPS-IP~~ (Vhost entfernt, DNS-Eintrag kann gelöscht werden) - `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. +⚠️ **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. Fix: erst alle DNS-Einträge verifizieren (`dig @1.1.1.1 . A`, gegen mehrere öffentliche Resolver prüfen, der eigene DNS-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 dem produktiven ACME-Endpunkt (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. +**Stolperstein bei diesem Durchlauf:** ein Subdomain-Eintrag im Provider-Panel landete zunächst auf dem providereigenen Parking-/Webhosting-Server statt der eingetragenen Ziel-IP — vermutlich ein falsch vorausgewählter Eintragstyp im Formular (z.B. „Webhosting" statt „eigene IP-Adresse"). Per `dig` direkt gegen die autoritativen Nameserver des Providers 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 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: +Anders als bei den internen Diensten 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`. +- **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 den Login-Namen als Tag. `track-grafana-gast.sh` (Cron alle 5 Min.) zählt deshalb Fehlschläge generisch und deutet „Aktivität" über das Vorkommen dieser Tags. +- ~~**Filebrowser:**~~ entfällt seit dem Abbau der Gast-Instanz. Die Einschränkung als solche bleibt dokumentiert, weil sie für die übrigen Filebrowser-Instanzen weiter gilt: Filebrowser loggt nur Fehlschläge mit IP, keine Erfolge — gleiche Lücke wie beim DNS-Blocking-Dienst. -Beide schreiben ins bestehende `logins`-Measurement (`service=grafana-gast`/`service=filebrowser-gast`), gleiches Muster wie die übrigen Tracker. +`track-grafana-gast.sh` schreibt ins bestehende `logins`-Measurement (`service=grafana-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. +**ntfy-Push:** `track-grafana-gast.sh` pusht bei >3 Fehlversuchen (hohe Priorität) und bei jeder erkannten Aktivität (Standard-Priorität — das deckt „erfolgreicher Login" für Grafana ab, da ein echtes Login-Event selbst nicht separat loggbar ist). -## 8. Aktivieren/Deaktivieren + Ablaufzeit-Sicherheitsnetz +## 8. Gast-Zugänge verwalten (`gast-zugang.py`) -Zwei Skripte auf dem PVE-Host (steuern CT104 + CT101 gemeinsam über `pct exec`): +Ein Skript auf dem Hauptserver, ausgerollt nach `/usr/local/bin/` (chmod 750). Ersetzt die früheren Aktivieren-/Deaktivieren-Skripte, die nur zwei fest angelegte Sammelkonten ein- und ausschalten konnten. ```bash -gast-aktivieren.sh freunde|bewerbung [stunden] -gast-deaktivieren.sh freunde|bewerbung|alle +gast-zugang.py neu [name] [stunden] # anlegen, 0 Stunden = unbegrenzt (fragt interaktiv, wenn Argumente fehlen) +gast-zugang.py liste # alle Gäste mit Status, Ablauf, letzter Aktivität +gast-zugang.py sperren # sofort sperren +gast-zugang.py loeschen # endgültig entfernen +gast-zugang.py ablauf-pruefen # abgelaufene sperren (Cron, alle 15 Min) +gast-zugang.py aufraeumen [tage] # gesperrte Gäste endgültig löschen (Standard 30 Tage) ``` -`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). +Der Name bekommt automatisch das Präfix `gast-` (Eingabe `lena` → Login `gast-lena`), damit Gastkonten auf einen Blick erkennbar sind und nicht mit echten Konten kollidieren. Das Passwort (12 Zeichen, ohne verwechselbare Zeichen wie `0`/`O`/`1`/`l`/`I`) wird bei der Anlage einmal ausgegeben und **nirgends gespeichert**. + +**Warum Python statt Bash:** die Grafana-API ist durchgehend JSON. Die alten Bash-Skripte haben JSON per Hand in `curl`-Strings zusammengesetzt und `python3` zum Auslesen wieder dazugeholt — das war die Hauptquelle für Quoting-Fehler. Grafana ist vom Hauptserver aus direkt über den Tailnet erreichbar (`:3000`), ein Umweg über den Container-Host entfällt damit. + +**Zustand:** eine lokale JSON-Datei (chmod 600) hält Anlagedatum und Ablauf je Gast. Grafana selbst bleibt die Quelle der Wahrheit dafür, *ob* ein Gast existiert und gesperrt ist — die Datei ergänzt nur, was Grafana nicht weiß. + +### Ablauf per Cron-Sweep statt Einzel-Jobs ```bash -apt-get install -y at && systemctl enable --now atd +*/15 * * * * /usr/local/bin/gast-zugang.py ablauf-pruefen >/dev/null 2>&1 ``` -⚠️ **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. +Bewusst ein wiederkehrender Sweep statt eines Einzel-Jobs pro Gast: der Sweep ist idempotent, übersteht Reboots und verwaiste Jobs, und der Zustand ist an einer Stelle nachlesbar statt über eine Job-Queue verstreut. -### Automatisches Passwort-Cycling +⚠️ **NICHT TUN: den Ordner-Berechtigungs-POST ohne vorheriges GET absetzen.** `POST /api/folders//permissions` **ersetzt** die komplette Liste. Wer nur den neuen User schickt, nimmt allen anderen Gästen und der Editor-Rolle den Zugriff. `gast-zugang.py` liest deshalb erst, hängt an und schreibt zurück. -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: +⚠️ **NICHT TUN: sich darauf verlassen, dass die Viewer-Rolle reicht.** Der Gast-Ordner vergibt View **pro Benutzer**; die Viewer-Rolle allein sieht kein einziges Dashboard. Ein neu angelegter Gast ohne diesen Eintrag loggt sich erfolgreich ein und sieht nichts. + +**Verifiziert, jeder Schritt gegen den echten Zustand:** Anlegen → Login `200`, sichtbar ist genau der Gast-Ordner mit einem Dashboard, der interne Ordner bleibt unsichtbar. Bestehende Ordner-Berechtigungen nach dem Anlegen unverändert vorhanden. Ablauf künstlich in die Vergangenheit gesetzt → Sweep sperrt, Login danach `401`. `aufraeumen` löscht den gesperrten Gast endgültig, der Ordner-Eintrag verschwindet automatisch mit. Altkonten ohne Datum werden vom Aufräumer übersprungen statt blind gelöscht. + +### Status-Page: Basic Auth mit denselben Konten + +Uptime Kuma **kann Status-Pages nicht selbst mit einem Passwort schützen**. Die Tabelle `status_page` hat zwar eine Spalte `password`, aber der zuständige Servercode referenziert sie in der eingesetzten Version mit keiner Zeile — ein Platzhalter, der nie implementiert wurde. Vor dem Bauen im Schema *und* im Servercode nachgesehen, nicht aus dem Gedächtnis entschieden. + +Der Schutz sitzt deshalb im Caddy davor, mit **denselben** Konten und Passwörtern wie Grafana — ein Gast merkt sich nur eines: + +```caddyfile +status-gast. { + import security_headers + import gast-basicauth.conf + reverse_proxy :3001 { ... } +} +``` + +`gast-zugang.py` schreibt die Basic-Auth-Konfigurationsdatei bei jeder Zustandsänderung neu (anlegen, sperren, ablaufen, löschen, aufräumen) und lädt Caddy neu. Der bcrypt-Hash wird auf dem Hauptserver erzeugt (`python3-bcrypt`) und lokal abgelegt — das Klartext-Passwort verlässt die Maschine nie und wird nirgends gespeichert. + +Ohne aktiven Gast schreibt das Skript einen Platzhalter-Eintrag mit einem Zufalls-Hash, den niemand kennt: ein leerer `basicauth`-Block wäre eine ungültige Config, und die Seite soll im Zweifel **zu** sein, nicht offen. + +⚠️ **NICHT TUN: die Datei per Heredoc über `ssh` schreiben.** bcrypt-Hashes enthalten `$` (`$2b$12$...`). Ein unquotiertes Heredoc auf der Gegenseite expandiert das als Shell-Variablen — der Hash kommt zerstört an und Caddy scheitert mit einem irreführenden `illegal base64 data`. Deshalb `scp` einer fertigen Datei. + +⚠️ **NICHT TUN: `caddy validate` als Freigabe-Gate für diese Datei nehmen.** `validate` lief hier als root durch und meldete „Valid configuration", während `systemctl reload caddy` scheiterte: der Reload läuft als eigener Caddy-User und konnte die root-only-lesbare Datei nicht lesen. Korrekt ist Gruppenbesitz + `640` — das Skript setzt es bei jedem Sync fest. **Nach einem Reload immer den tatsächlichen HTTP-Status prüfen, nicht nur `validate`.** + +⚠️ **Ältere Caddy-Versionen (vor 2.7) kennen `basic_auth` noch nicht** — die Direktive heißt dort `basicauth`. Bei einem Caddy-Update mitziehen. + +**Verifiziert:** ohne Auth `401`, mit dem Gast-Passwort `200`, mit falschem Passwort `401`. Ein Gast angelegt → dasselbe Passwort öffnet Grafana **und** Status-Page. Ablauf-Sweep → beide Türen `401`. Ohne aktiven Gast → `401`, auch für ein zuvor gültiges Konto. + +### ntfy-Push + +Anlegen, automatisches Sperren und Aufräumen melden sich als Push-Benachrichtigung (Standard-Priorität, kein Alarmton). Fehlschlägt der Push, läuft das Skript trotzdem durch — eine Benachrichtigung darf die Zugangsverwaltung nicht blockieren. + +## 9. Automatisierte Vergabe per Mail-Anfrage (`gast-auto-anfrage.py`) + +Bisher lief `gast-zugang.py neu` ausschließlich manuell — jemand fragt an, es wird von Hand +angelegt und die Zugangsdaten selbst weitergegeben. Ziel dieser Ergänzung: das automatisieren, ohne +dabei E-Mail-Adressen zu speichern (DSGVO-Minimierung, explizite Anforderung an das eigene Projekt). + +**Architektur-Entscheidung: Mail-Polling statt öffentliches Web-Formular.** Ein Formular wäre ein +neuer, unauthentifizierter öffentlicher Endpunkt, der echte Konten anlegt — widerspricht der +Architektur-Linie „so wenig öffentliche Angriffsfläche wie möglich" und bräuchte eigenen Spam-/ +Abuse-Schutz. Stattdessen pollt ein Cron-Skript (root) das ohnehin vorhandene Postfach `gast@` +per IMAP. ```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" +gast-auto-anfrage.py # kein Subcommand, ein Lauf = ein Poll-Zyklus ``` -Altes Passwort wird dadurch automatisch ungültig — keine manuelle Rotation nötig, das alte Passwort muss auch nirgends aufgehoben werden. +⚠️ **NICHT TUN: davon ausgehen, `gast@` lande im `INBOX`.** Der Mail-Provider leitet Mail an +diese Adresse in einen eigenen IMAP-Ordner namens `gast` innerhalb derselben Mailbox um (sichtbar über +`imap.list()`) — nicht ins `INBOX`, das für die Haupt-Adresse reserviert bleibt. Das Skript muss +`imap.select("gast")` explizit selektieren. -⚠️ **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: sich auf das `\Seen`-Flag als „schon verarbeitet"-Marker verlassen.** Im Test stand +eine frisch zugestellte Mail bereits als `\Seen`, ohne dass irgendein eigener Prozess sie angefasst +hatte — vermutlich ein Nebeneffekt der providereigenen Spam-/Vorschau-Verarbeitung, nicht +kontrollierbar. Stattdessen UID-basiertes Tracking in einer lokalen Datei — IMAP-UIDs sind serverseitig +stabil, das Flag-Verhalten war es nicht. -⚠️ **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. +**DSGVO-Minimierung, konkret umgesetzt:** +- Die Absenderadresse existiert nur als Variable während des einzelnen Skriptlaufs — landet + nirgends im dauerhaften Zustand, keinem Log, keiner separaten Datei. +- Die verarbeitete Anfrage-Mail wird nach erfolgreichem Versand der Zugangsdaten **gelöscht** + (`\Deleted` + `expunge()`), nicht nur als gelesen markiert — im Postfach selbst bleibt keine Kopie. +- Bei einem Fehlschlag (SMTP down, Grafana nicht erreichbar, …) bleibt die Mail bewusst **erhalten** + und ungetrackt, damit der nächste Lauf es automatisch erneut versucht, bis es entweder klappt oder + manuell nachgesehen wird — kein Fehlschlag verschwindet spurlos. -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. +**Bewusst kein Allowlist-Gate.** Anforderung: „muss für alle offen sein, damit Recruiter und +Headhunter das beantragen können" — der Zugang ist absichtlich öffentlich per Mail anfragbar, keine +Vorab-Bekanntschaft nötig. -### ntfy-Push bei Aktivieren/Deaktivieren (2026-07-20) +**Login-Schema: `gast-`**, z. B. `gast-firmaxyzcom7` bei einer Anfrage von +`jane@firma-xyz.com`. Zwei bewusste Design-Entscheidungen dahinter: +- **Domain statt Zufallsstring** — Anforderung für Zuordenbarkeit („welches Unternehmen hat + wie oft angefragt", ohne die volle Adresse zu speichern). Nur die Domain landet im Login-Namen + (und damit dauerhaft, aber generisch, im gespeicherten Zustand), die volle Absenderadresse bleibt + weiterhin ausschließlich im Arbeitsspeicher des jeweiligen Skriptlaufs. +- **Fortlaufende Nummer statt Zufallssuffix** (global über alle Domains hinweg hochgezählt) — eine + Domain allein würde bei mehreren Anfragen vom selben Unternehmen kollidieren (`gast-zugang.py` + bricht bei bereits existierendem Login ab). -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). +⚠️ **NICHT TUN: den Login direkt als `gast-...` (ohne Bindestrich nach „gast") bauen.** +`gast-zugang.py` erkennt **alle** Guest-Konten intern am festen Präfix `gast-` (`liste`, +`ablauf-pruefen`, `aufraeumen` filtern alle danach) — ein Login ohne diesen exakten Bindestrich +würde vom bestehenden Lifecycle-Management schlicht nicht gefunden, liefe also nie automatisch ab. +Der Trick: `gast-auto-anfrage.py` übergibt nur `` (ganz ohne „gast") als Namen — +`gast-zugang.py`s eigene Präfix-Logik ergänzt das `gast-` automatisch und korrekt, ganz ohne Änderung +an `gast-zugang.py` selbst nötig. + +**Laufzeit 14 Tage fest**, nicht `unbegrenzt` wie bei manueller Anlage möglich — Begründung: +vollautomatisch **und** öffentlich anfragbar darf nicht unbegrenzt offen bleiben. 14 Tage deckt die +typische Dauer eines Bewerbungsprozesses (1–2 Wochen) mit Puffer. + +**Rate-Limit:** max. 10 automatische Zugänge pro Kalendertag. Danach nur noch ein ntfy-Hinweis +(„Tageslimit erreicht") statt automatischer Anlage — echte, verspätete Anfragen landen dann eben zur +manuellen Prüfung, statt dass das System bei einem Anfrage-Schwall (Spam/Scan) unkontrolliert Konten +produziert. + +**Bei erreichtem Tageslimit bekommt auch der Anfragende eine Antwort** (vorher komplettes +Schweigen) — eine kurze Mail „Anfrage angekommen, wird zeitnah manuell freigeschaltet". Wichtig gerade +bei Recruitern/Headhuntern: Stille sieht nach einer verlorenen oder ignorierten Anfrage aus, nicht nach +einem ausgeschöpften Kontingent. Die zugehörige Anfrage-Mail im `gast`-Ordner wird in diesem Fall +bewusst **nicht** gelöscht (anders als bei erfolgreicher Auto-Vergabe) — die Absenderadresse wird noch +für die manuelle Freischaltung gebraucht. Schlägt der Versand dieser Zwischenmeldung selbst fehl (z. B. +SMTP kurzzeitig down), bricht das nicht den Lauf ab — der ntfy-Hinweis zählt in diesem Fall mehr als +die Bestätigung an den Anfragenden. + +**Spam-Schutz (ohne kostenpflichtigen Zusatzfilter des Providers):** dreistufig. +1. **Der eingebaute Serverside-Antispam des Mail-Providers** — kostenlos in jedem Mail-Tarif enthalten + (kein separater Kauf nötig), muss aber **manuell** aktiviert werden. Ohne diesen manuellen Schritt + filtert der Provider serverseitig gar nichts, obwohl der `Spam`-Ordner in der Mailbox schon + existiert. +2. **Strukturelle Hürde:** kein öffentliches Web-Formular, es braucht eine echte E-Mail — filtert + die Masse reiner Web-Bots bereits aus, ohne eigenen Code. +3. **Flut-Schutz im Skript:** kommen in einem Poll-Zyklus mehr als 15 neue Nachrichten auf einmal + rein, verarbeitet das Skript **keine einzige** davon automatisch, sondern schickt **eine** + Sammel-ntfy-Meldung und lässt alles liegen. Ohne diese Schwelle würde ab dem Tageslimit für **jede + weitere** Spam-Mail einzeln „Tageslimit erreicht" gepusht — bei einem echten Schwall (z. B. 50 + Spam-Mails) wäre das Handy selbst die Störquelle. + +**Verifiziert:** echte Testmail geschickt, Skript manuell gestartet — Konto automatisch angelegt +(korrektes Domain-Nummer-Schema, 335h), Zugangsdaten-Mail kam beim Absender an (DKIM-signiert, +bestätigt die automatische Signierung durch den Provider), `gast`-Ordner danach leer, ntfy-Meldungen +korrekt. Testkonto, Testmail und Zähler anschließend manuell wieder zurückgesetzt, damit die erste +echte Anfrage bei Nummer 1 startet. + +## 10. Täglicher Bericht + status-gast-Login-Tracking + +**Täglicher Bericht** (`gast-bericht.py`, Cron `0 7 * * *`): wrapt `gast-zugang.py liste` +unverändert und schickt die Tabelle per ntfy — bewusst **keine** zweite Logik zur Restlaufzeit- +Berechnung, `gast-zugang.py` fragt Grafana live ab und ist damit die einzige Quelle der Wahrheit für +„aktiv/gesperrt". Ein zweiter, eigenständig berechnender Bericht könnte mit der Zeit auseinanderlaufen +(z. B. bei manuellem `sperren`, das den lokalen Zustand nicht anfasst). + +**status-gast-Login-Tracking** (`track-status-gast-logins.py`, VPS, Cron `*/5 * * * *`): bis dahin +eine echte Lücke — `grafana-gast` wurde schon länger überwacht, `status-gast` (dieselben +Zugangsdaten, andere Anwendung) gar nicht. Grund: Caddy loggte für diesen Vhost überhaupt nichts +(keine `log`-Direktive im Caddyfile-Block). + +```caddyfile +status-gast. { + import security_headers + log { + output file /var/log/caddy/status-gast.log + format json + } + import gast-basicauth.conf + reverse_proxy :3001 { ... } +} +``` + +**Fund, der die Umsetzung erst einfach gemacht hat:** Caddys strukturiertes JSON-Access-Log enthält +bei Basic-Auth automatisch ein `user_id`-Feld — leer bei `401`, gesetzt auf den authentifizierten +Login-Namen bei `200`. Kein zusätzlicher Log-Format-Aufwand nötig, kein Parsen von +`Authorization`-Headern von Hand. Verifiziert: ein Login mit korrektem Passwort → `user_id` mit dem +Kontonamen in der geloggten Zeile, mit falschem Passwort → `user_id` leer. + +Byte-Offset-Tailing statt Neu-Parsen bei jedem Lauf — gleiches Muster wie beim übrigen Login-Tracking. +Schreibt ins bestehende `logins`-Measurement (`service=status-gast`), Alarm bei >3 Fehlversuchen/5 +Min., Standard-Push bei jeder erkannten Aktivität inkl. Kontoname(n). + +**Verifiziert:** echter Login mit korrektem Passwort → `user_id` korrekt erfasst, ntfy-Meldung mit +richtigem Kontonamen, InfluxDB-Zähler stimmen. Zwei Fehlversuche mit falschem Passwort → +`fail`-Zähler korrekt erhöht, `user_id` leer wie erwartet. + +⚠️ **NICHT TUN: den eigenen Uptime-Kuma-Monitor als Login-Quelle ignorieren.** Direkt nach dem +Go-Live meldete der Tracker minütlich mehrere „fehlgeschlagene Logins" — sah nach einem aktiven +Scan/Angriff aus, war aber der **eigene** `status-gast`-Kuma-Monitor (Abschnitt 5), der die Seite +bewusst **ohne** Basic-Auth abfragt (er soll nur Erreichbarkeit prüfen, nicht den Login) und dabei +jedes Mal ein `401` bekommt — Anzahl der Checks pro Zeitfenster deckungsgleich mit der gemeldeten +Zahl. Erkennbar am eigenen User-Agent-String der Monitoring-Software im Caddy-Log. Fix: Requests mit +diesem User-Agent werden vor der Fail/Success-Zählung explizit übersprungen — dieselbe Falle würde +bei jedem weiteren Caddy-basicauth-geschützten Vhost mit einem eigenen Kuma-Monitor erneut zuschlagen, +falls dort mal ein Access-Log dazukommt. + +## 11. IMAP-IDLE-Push statt Cron-Polling + +**Anlass:** Nutzerkritik, zu Recht — bis zu 10 Minuten auf die Zugangsdaten warten ist für einen +Recruiter/Techniker, der gerade eine Bewerbung prüft, zu lang. Statt das Cron-Intervall einfach zu +verkürzen (mehr Last, gleiches Grundproblem), echtes Push per IMAP `IDLE` (RFC 2177) — der Mail-Server +unterstützt es (`IDLE` steht in der `CAPABILITY`-Antwort). + +`gast-auto-anfrage.py` hat jetzt zwei Betriebsarten: +```bash +gast-auto-anfrage.py # Ein Durchlauf, wie bisher -- jetzt nur noch Sicherheitsnetz +gast-auto-anfrage.py watch # Dauerhafter Prozess, IMAP-IDLE-Push +``` + +**`watch` läuft als systemd-Service** (`Restart=always`), **der Cron-Job bleibt zusätzlich aktiv** +(Intervall von 10 auf 30 Min. entschärft, da nur noch Auffangnetz für den Fall, dass die +IDLE-Verbindung mal haengt): +```ini +[Unit] +Description=Gast-Auto-Anfrage: IMAP-IDLE-Watch (Push statt Poll) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 /usr/local/bin/gast-auto-anfrage.py watch +Restart=always +RestartSec=15 +User=root + +[Install] +WantedBy=multi-user.target +``` + +⚠️ **NICHT TUN: nach einem IDLE-Timeout versuchen, per `DONE` sauber auf derselben Verbindung +auszusteigen und weiterzulesen.** `imaplib` kennt `IDLE` nicht nativ (Stand Python 3.13), der +Zugriff läuft über den rohen Protokoll-Kanal (`imap.send()`/`imap.readline()`). Ein +`socket.settimeout()`, der tatsächlich auslöst, hinterlässt imaplibs internes File-Objekt +(`socket.makefile()`-Wrapper) dauerhaft kaputt — jeder weitere `readline()` auf **derselben** +Connection wirft `OSError: cannot read from timed out object`, auch nach `settimeout(None)`. Live +reproduziert. Betrifft nur den Timeout-Pfad (der häufigste — die meiste Zeit passiert nichts), nicht +den Fall „Server hat wirklich `EXISTS` geschickt". Fix: kein `DONE`-Handshake, keine +Wiederverwendung der Connection nach `IDLE` — für jeden Zyklus (Timeout wie neue Mail) wird die +Verbindung über den `with`-Block geschlossen und beim nächsten Zyklus neu aufgebaut. +`imaplib.IMAP4.__exit__` fängt das dabei entstehende `OSError` selbst ab (`except OSError: pass`) — +kein zusätzlicher Try/Except im eigenen Code nötig, solange kein manuelles `logout()` außerhalb von +`with` aufgerufen wird (das hat in der Testphase einmal unnötig einen Traceback erzeugt). + +⚠️ **NICHT TUN: bei einem Fehlschlag von `gast-zugang.py neu` nur den Exit-Code loggen.** Genau das +ist beim ersten produktiven Lauf passiert — die generische Fehlerdarstellung zeigt nur Befehl und +Exit-Code, nicht den eigentlichen Grund, und der reale `stderr`-Text war unwiederbringlich weg (nur +„exit status 1" kam bei ntfy an). Konnte trotz mehrfacher Reproduktionsversuche (auch exakt im +gleichen systemd-Kontext) nicht reproduziert werden — wahrscheinlich eine einmalige Race Condition mit +dem parallel laufenden `ablauf-pruefen`-Cron (beide griffen ungeschützt auf denselben Zustand **und** +die Grafana-API zu). Jetzt behoben durch zwei Maßnahmen: +1. Die Fehlerbehandlung fasst `stdout`+`stderr` bei einem Fehlschlag explizit in die Meldung ein statt + nur den Exit-Code, Truncation-Länge im ntfy-Handler deutlich erhöht. +2. **`gast-zugang.py` bekam ein systemweites Lock** (`fcntl.flock`) um den **gesamten** Unterbefehl, + nicht nur um das Lesen/Schreiben des Zustands — mit jetzt drei möglichen gleichzeitigen Aufrufern + (Watch-Daemon, 30-Min-Cron, 15-Min-`ablauf-pruefen`) reicht ein reiner Datei-Lock um den Zustand + nicht: zwei parallele Prozesse könnten trotzdem gleichzeitig kollidierende Grafana-API-Aufrufe + machen. Ein Lock um den kompletten Aufruf serialisiert beides. Bewusst blockierend (kein + `LOCK_NB`) — der zweite Aufruf soll kurz warten, nicht mit einem Fehler abbrechen. Mit zwei + gleichzeitig gestarteten `neu`-Aufrufen verifiziert: beide erfolgreich, sauber nacheinander, kein + Überschreiben. + +**Verifiziert, echte End-to-End-Zeit:** Testmail gesendet, Konto angelegt und Antwortmail +verschickt nach **9 Sekunden** — vorher bis zu 10 Minuten (Cron-Intervall). Mehrere Testläufe mit +unterschiedlichen Szenarien (Push während aktivem IDLE, Katch-up-Scan nach Service-Neustart, +gleichzeitige Aufrufe fürs Lock) alle erfolgreich, Testkonten/Zähler danach zurückgesetzt. + +## Code-Showcase über Git-Hosting + +**Dauerhaft an statt schaltbar**, öffentlicher Weg für Code/Runbooks. Seit dem Abbau der +Filebrowser-Gast-Instanz der **einzige** Weg zu den generischen Runbooks — vorher lief er parallel +dazu. Voraussetzung: ein selbst gehostetes Git-Hosting (eigenes Runbook) mit anonymer Lesefreigabe für +einzelne Repos. + +### 1. Separates öffentliches Repo + +```bash +# Vor dem Anlegen: Secret-/Infra-Scan auf die Portfolio-Runbooks +gitleaks dir ./rebuild-portfolio/ -v +grep -rniE '|||||passwo|token|secret' ./rebuild-portfolio/ +``` + +⚠️ **NICHT TUN:** die lokale Git-Identität (`user.email`) beim Erstinit auf die echte E-Mail-Adresse +stehen lassen, wenn das Ziel-Repo öffentlich wird. Vor dem allerersten Push explizit eine +Noreply-Adresse setzen (`git config user.email "@users.noreply.example"`), sonst landet +die echte Adresse im öffentlich einsehbaren Commit-Autor. Falls doch passiert: sofort +`git commit --amend --reset-author` + `git push --force`, bevor irgendjemand Zugriff hatte. + +```bash +PAT= +curl -s -X POST "https://git./api/v1/user/repos" \ + -H "Authorization: token $PAT" -H "Content-Type: application/json" \ + -d '{"name":"homelab-showcase","private":false,"description":"..."}' + +# Frisches, eigenständiges Git-Repo (NICHT im privaten Haupt-Checkout arbeiten) +cd /pfad/zu/frischer/kopie/rebuild-portfolio-inhalt +git init && git config user.email "@users.noreply.example" +git add . && git commit -m "Initial import" +git remote add origin "https://:${PAT}@git.//homelab-showcase.git" +git push -u origin master +``` + +### 2. Anonyme Sichtbarkeit + +Eine Einstellung im Git-Hosting macht **nur** Repos, die explizit auf öffentlich gestellt sind, +anonym sichtbar — private Repos bleiben `404` (der Server verrät nicht mal deren Existenz). Bereits +bei der Ersteinrichtung des Git-Hostings gesetzt. + +### 3. Caddy: Pfad-Allowlist statt offenem Vhost + +```caddyfile +code. { + import security_headers + @showcase { + path //homelab-showcase //homelab-showcase/* + path //homelab-showcase.git/* //homelab-showcase/info/refs + path /assets/* /avatars/* /repo-avatars/* /user/avatar/* /favicon.ico /img/* /css/* /js/* + } + handle @showcase { + reverse_proxy https://git. { + header_up Host git. + header_down -X-Frame-Options + header_down -X-Content-Type-Options + } + } + handle { + respond "Not found" 404 + } +} +``` + +Default-Deny (`handle { respond 404 }` als Fallback) statt Blocklist — jeder nicht explizit erlaubte +Pfad (Login-Seite, Admin-Bereich, API, Explore-Ansicht, vor allem auch das private Repo selbst) ist +automatisch dicht, ganz ohne ihn einzeln aufzählen zu müssen. + +⚠️ **NICHT TUN:** die öffentliche Root-URL der Git-Software umstellen, um „sauberere" Links zu +bekommen. Das bricht den Tailnet-/Admin-Zugang (Login-Fläche verschwindet aus der Tailnet-URL). +Navigation/Dateibaum/Raw-Zugriff laufen ohnehin über relative Links und funktionieren für anonyme +Besucher auch mit der internen Tailnet-Root-URL einwandfrei — nur zwei **absolute** Links bleiben auf +der Tailnet-Adresse stehen (Clone-URL-Button, Open-Graph-Vorschaubild), für Außenstehende schlicht +unerreichbar, aber ohne Einfluss auf die eigentliche Navigation. Ein echter Fix bräuchte ein +HTML-Body-Rewrite-Modul (nicht im Standard-Caddy-Build enthalten, Custom-Build nötig) — für zwei +kosmetische Links nicht gerechtfertigt. + +⚠️ **NICHT TUN:** für einen Down-Test des neuen Vhosts den gesamten Caddy-Dienst stoppen — das reißt +**alle** öffentlichen Vhosts mit (inkl. Passwortmanager-Zugang), nicht nur den neuen. Bei geteilter +Infrastruktur wie diesem Caddy nur gezielt den betroffenen Vhost isoliert testen (oder, wenn das +Alert-Muster an anderer Stelle schon verifiziert ist, auf einen Live-Ausfalltest verzichten und nur +die Monitor-Konfiguration prüfen). + +### 4. Monitoring + fail2ban + +Uptime-Kuma-HTTP-Monitor auf die öffentliche Showcase-URL, gleiches `uptime-kuma-api`-Python-Muster +wie überall sonst (siehe `08-monitoring.md`). Kein fail2ban nötig — ohne öffentliche Login-Fläche +(alles außer dem Showcase-Pfad ist `404`) gibt es keinen Angriffsverkehr zum Bannen. + +### Verifikation + +```bash +curl -s -o /dev/null -w "%{http_code}\n" https://code.//homelab-showcase # 200 +curl -s -o /dev/null -w "%{http_code}\n" https://code.//homelab-config # 404 (privates Repo) +curl -s -o /dev/null -w "%{http_code}\n" https://code./user/login # 404 +curl -s -o /dev/null -w "%{http_code}\n" https://code./explore # 404 +``` +Zusätzlich von einem Gerät **ohne** Tailscale-Verbindung gegenprüfen (z.B. Handy im Mobilfunknetz, +Tailscale aus) — von einem Tailnet-Mitglied aus lässt sich nicht ausschließen, dass man versehentlich +über die Subnet-Route statt den öffentlichen Pfad testet. + +### Stolperstein: Status-Page-Root landet auf dem Admin-Login + +Die nackte Domain des Status-Page-Vhosts leitete standardmäßig auf den Admin-Login von Uptime Kuma, +nicht die öffentliche Ampel-Seite. Die tatsächlich öffentliche Status-Page liegt unter einem eigenen +Pfad (`/status/`). Auf der Projekt-Übersichtsseite verlinkte der Demo-Chip dafür zunächst auf +die nackte Domain — Besucher landeten also auf einem Login, obwohl der Chip „öffentlich" versprach. + +**Fix, zweistufig:** +1. Direkter Link-Fix: `href` auf die volle Status-Page-URL geändert, neu deployed. +2. Robuster gemacht: Kumas globale „Entry Page" (Standard-Landing für die nackte Domain) auf die + Gast-Status-Page umgestellt, per `uptime-kuma-api`: + ```python + api.set_settings(entryPage='statusPage-gast') + ``` + Damit landet jetzt auch die nackte Domain direkt öffentlich — der Admin-Login bleibt weiterhin + erreichbar, nur nicht mehr der Default. + +**Verifikation:** die nackte Domain (ohne Pfad) liefert jetzt direkt die öffentliche Ampel-Seite +(200, Titel „Homelab Showcase"), der Admin-Login weiterhin separat erreichbar (200, Login). + +## Showcase-Runbooks als Wiki + +Die generischen Runbooks liegen als **Wiki** des öffentlichen Showcase-Repos, nicht als einzelne +Dateien im Repo selbst — mehrere Seiten (Übersicht, die einzelnen Runbook-Kapitel, Entscheidungen, +Variablen, Disclaimer) mit eigener Seiten-Navigation. Das Repo selbst hat nur noch ein `README.md`, +das aufs Wiki verweist. + +Die Caddy-Pfad-Allowlist aus dem vorigen Abschnitt deckt die Wiki-Pfade bereits mit ab — kein +zusätzlicher Caddy-Schritt nötig, das Wiki liegt unter demselben Repo-Pfad-Präfix. + +⚠️ **NICHT TUN:** Wiki-Seiten mit Bindestrich im Titel anlegen. Manche Wiki-Implementierungen +verstümmeln die interne sub_url dabei, die Seite ist dann nicht mehr aufrufbar (Redirect auf die +Seitenliste). Stattdessen leerzeichen-basierte Titel verwenden — die Software wandelt Leerzeichen +selbst sauber in Bindestriche für die sub_url um. + +⚠️ **NICHT TUN:** Querverweise zwischen Runbooks unverändert aus der Dateisystem-Fassung übernehmen +(`[..](dateiname.md)`). Im Wiki gibt es keine Dateien, nur Seiten mit eigenen sub_urls — solche Links +laufen ins Leere. Beim Migrieren jeden Querverweis auf die tatsächliche Wiki-sub_url umschreiben. + +⚠️ **NICHT TUN:** beim programmatischen Anlegen neuer Repo-Dateien die Update-Methode der API +verwenden. Die verlangt eine Prüfsumme der Vorversion — für eine noch nicht existierende Datei +schlägt das fehl. Neue Dateien brauchen die Create-Methode, nicht die Update-Methode. + +**Pflege künftig:** Runbook-Änderungen weiterhin im internen Haupt-Repo pflegen, dann manuell ins +Showcase-Wiki nachziehen — kein automatischer Sync. Vor jeder Veröffentlichung läuft ein eigenes +Publish-Gate (gitleaks + Regex-Prüfung auf die echten Werte dieser Instanz) — findet es etwas, +wird nicht veröffentlicht, bis der Fund entweder entfernt oder als begründete Ausnahme +dokumentiert ist.