Showcase nachgezogen: aktueller Stand (gast-zugang.py, Diun, Grafana-Alerts, IMAP-IDLE-Push)
parent
3f626d6f96
commit
0418256c84
1 changed files with 451 additions and 52 deletions
|
|
@ -1,17 +1,20 @@
|
||||||
# Gast-Zugang: Read-only Showcase für Freunde/Bekannte und Bewerbungsgespräche — Rebuild-Runbook
|
# 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
|
## 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:
|
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.
|
||||||
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
|
|
||||||
|
|
||||||
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
|
## 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 \
|
curl -u admin:$PW -X POST http://localhost:3000/api/folders/$INTERN_UID/permissions \
|
||||||
-d '{"items":[{"role":"Editor","permission":2}]}'
|
-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 \
|
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}]}'
|
-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
|
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
|
```flux
|
||||||
|> map(fn: (r) => ({ r with host:
|
|> 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"])
|
|> 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.
|
⚠️ **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):
|
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
|
useradd --system --no-create-home --shell /usr/sbin/nologin gastdemo
|
||||||
mkdir -p /srv/gast-demo /var/lib/filebrowser-gast
|
mkdir -p /srv/gast-demo /var/lib/filebrowser-gast
|
||||||
chown -R gastdemo:gastdemo /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 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 \
|
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.
|
⚠️ **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)
|
## 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> {
|
grafana-gast.<DOMAIN> {
|
||||||
reverse_proxy <TS_IP_MONITORING>:3000
|
reverse_proxy <TS_IP_MONITORING>:3000
|
||||||
}
|
}
|
||||||
|
# ENTFERNT (Gast-Filebrowser abgebaut) - bleibt als Referenz:
|
||||||
dateien-gast.<DOMAIN> {
|
dateien-gast.<DOMAIN> {
|
||||||
reverse_proxy <TS_IP_NAS>:8081
|
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).
|
`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
|
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".
|
||||||
sed -i -e 's/192\.168\.178\.10\b/<LAN_IP_PVE>/g' ... *.md # vollständige sed-Kette: siehe Session-Log
|
|
||||||
```
|
|
||||||
|
|
||||||
Nach jeder Änderung an den echten `rebuild/*.md`-Dateien: Portfolio-Kopie manuell nachziehen (kein automatischer Sync) und erneut gegen die bekannten echten Werte grep-verifizieren.
|
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
|
## 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.
|
⚠️ **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:
|
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).
|
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,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
|
```bash
|
||||||
HASH=$(docker exec uptime-kuma node -e "const bcrypt=require('bcryptjs'); console.log(bcrypt.hashSync(process.argv[1], 10));" "$NEWPW")
|
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;"
|
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)
|
## 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
|
- `grafana-gast` → VPS-IP
|
||||||
- `dateien-gast` → VPS-IP
|
- ~~`dateien-gast` → VPS-IP~~ (Vhost entfernt, DNS-Eintrag kann gelöscht werden)
|
||||||
- `status-gast` → VPS-IP
|
- `status-gast` → VPS-IP
|
||||||
|
|
||||||
⚠️ **NICHT TUN:** Caddy neu laden/starten, *bevor* alle A-Records korrekt gesetzt sind, und dann erwarten, dass es sich von selbst korrigiert. Certmagic (Caddys ACME-Bibliothek) merkt sich fehlgeschlagene Versuche pro Hostname und schaltet nach mehreren Fehlschlägen zur Schonung des Let's-Encrypt-Produktiv-Rate-Limits automatisch auf die **Staging-CA** um (liefert kein von Browsern akzeptiertes Zertifikat) plus einen Backoff von bis zu 20 Minuten zwischen Versuchen. Gleiches Muster wie beim ursprünglichen `guac`-Setup. Fix: erst alle DNS-Einträge verifizieren (`dig @1.1.1.1 <name>.<DOMAIN> A`, gegen mehrere öffentliche Resolver prüfen, eigener AdGuard-Resolver cached u.U. noch den alten Stand), danach `systemctl restart caddy` (nicht nur `reload` — ein Neustart setzt den internen Fehlversuch-/Backoff-Zustand zurück, ein reines `reload` nicht zuverlässig). Direkt danach in `journalctl -u caddy` auf `"certificate obtained successfully"` mit `ca":"https://acme-v02.api.letsencrypt.org/directory"` (nicht `-staging-`) prüfen.
|
⚠️ **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)
|
## 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.
|
- **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:** 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`.
|
- ~~**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
|
```bash
|
||||||
gast-aktivieren.sh freunde|bewerbung [stunden]
|
gast-zugang.py neu [name] [stunden] # anlegen, 0 Stunden = unbegrenzt (fragt interaktiv, wenn Argumente fehlen)
|
||||||
gast-deaktivieren.sh freunde|bewerbung|alle
|
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
|
```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
|
```bash
|
||||||
NEWPASS=$(tr -dc 'ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789' < /dev/urandom | head -c 12 || true)
|
gast-auto-anfrage.py # kein Subcommand, ein Lauf = ein Poll-Zyklus
|
||||||
curl -u admin:$GF_PASS -X PUT http://localhost:3000/api/admin/users/$ID/password -d "{\"password\":\"$NEWPASS\"}"
|
|
||||||
filebrowser users update gast-freunde -d /var/lib/filebrowser-gast/filebrowser.db -p "$NEWPASS"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Altes Passwort wird dadurch automatisch ungültig — keine manuelle Rotation nötig, das alte Passwort muss auch nirgends aufgehoben werden.
|
⚠️ **NICHT TUN: 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 (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.<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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue