Showcase nachgezogen: aktueller Stand (gast-zugang.py, Diun, Grafana-Alerts, IMAP-IDLE-Push)

arnol 2026-08-07 18:06:11 +02:00
commit 0418256c84

@ -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.<DOMAIN>`).
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 (`<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:
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.<DOMAIN>`, `/etc/caddy/Caddyfile`:
Gleiches Muster wie der bestehende Passwortmanager-Vhost, `/etc/caddy/Caddyfile`:
```
grafana-gast.<DOMAIN> {
reverse_proxy <TS_IP_MONITORING>:3000
}
# ENTFERNT (Gast-Filebrowser abgebaut) - bleibt als Referenz:
dateien-gast.<DOMAIN> {
reverse_proxy <TS_IP_NAS>:8081
}
@ -121,15 +134,13 @@ status-gast.<DOMAIN> {
`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 (`<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.
Generische Kopien aller internen Rebuild-Runbooks liegen in diesem Ordner — 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 `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
```
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.<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.
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 `<DOMAIN>`, alle auf die VPS-IP (gleiche IP wie der bestehende `guac`-Eintrag):
A-Records im Provider-Panel für `<DOMAIN>`, 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 <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.
⚠️ **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 <name>.<DOMAIN> 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 <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`.
- **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 <login> # sofort sperren
gast-zugang.py loeschen <login> # 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 <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).
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 (`<TS_IP_MONITORING>: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/<uid>/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.<DOMAIN> {
import security_headers
import gast-basicauth.conf
reverse_proxy <TS_IP_MONITORING>: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@<DOMAIN>`
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@<DOMAIN>` 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.<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.
**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-<DomainTLD><Nummer>`**, 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<Nummer>-...` (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 `<DomainTLD><Nummer>` (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 (12 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.<DOMAIN> {
import security_headers
log {
output file /var/log/caddy/status-gast.log
format json
}
import gast-basicauth.conf
reverse_proxy <TS_IP_MONITORING>: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 '<echtes LAN-Subnetz>|<echte Tailnet-IPs>|<Tailnet-Name>|<VPS-IP>|<eigene Domain>|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 "<PRIMARY_USER>@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=<Personal-Access-Token>
curl -s -X POST "https://git.<TAILNET>/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 "<PRIMARY_USER>@users.noreply.example"
git add . && git commit -m "Initial import"
git remote add origin "https://<PRIMARY_USER>:${PAT}@git.<TAILNET>/<PRIMARY_USER>/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.<DOMAIN> {
import security_headers
@showcase {
path /<PRIMARY_USER>/homelab-showcase /<PRIMARY_USER>/homelab-showcase/*
path /<PRIMARY_USER>/homelab-showcase.git/* /<PRIMARY_USER>/homelab-showcase/info/refs
path /assets/* /avatars/* /repo-avatars/* /user/avatar/* /favicon.ico /img/* /css/* /js/*
}
handle @showcase {
reverse_proxy https://git.<TAILNET> {
header_up Host git.<TAILNET>
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.<DOMAIN>/<PRIMARY_USER>/homelab-showcase # 200
curl -s -o /dev/null -w "%{http_code}\n" https://code.<DOMAIN>/<PRIMARY_USER>/homelab-config # 404 (privates Repo)
curl -s -o /dev/null -w "%{http_code}\n" https://code.<DOMAIN>/user/login # 404
curl -s -o /dev/null -w "%{http_code}\n" https://code.<DOMAIN>/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/<slug>`). 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.