Table of contents
- Gast-Zugang: Read-only Showcase für Freunde/Bekannte und Bewerbungsgespräche — Rebuild-Runbook
- Prinzip
- 1. Grafana: Ordner-getrenntes Gast-Dashboard
- 2. Zweite Filebrowser-Instanz auf der NAS — ABGEBAUT
- 3. Caddy-Vhosts (VPS)
- 4. Portfolio-Runbooks — Quelle für den öffentlichen Showcase
- 5. Uptime Kuma: Gast-Monitore + Status-Page
- 6. DNS (manueller Schritt, keine API verfügbar)
- 7. Login-Tracking (Gast-Nutzung sichtbar machen)
- 8. Gast-Zugänge verwalten (gast-zugang.py)
- 9. Automatisierte Vergabe per Mail-Anfrage (gast-auto-anfrage.py)
- 10. Täglicher Bericht + status-gast-Login-Tracking
- 11. IMAP-IDLE-Push statt Cron-Polling
- Code-Showcase über Git-Hosting
- 1. Separates öffentliches Repo
- 2. Anonyme Sichtbarkeit
- 3. Caddy: Pfad-Allowlist statt offenem Vhost
- 4. Monitoring + fail2ban
- Verifikation
- Stolperstein: Status-Page-Root landet auf dem Admin-Login
- Showcase-Runbooks als Wiki
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
Gast-Zugang: Read-only Showcase für Freunde/Bekannte und Bewerbungsgespräche — Rebuild-Runbook
Cross-cutting, kein eigener Host: nutzt CT101 (NAS), CT104 (Monitoring) und 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
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.
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.
Bausteine:
- Grafana-Gast-Dashboard — kuratierte Kopie ohne Login-Daten, plus fail2ban-Statistik als Sicherheits-Showcase. Der einzige Baustein mit echten Gast-Konten.
Zweite Filebrowser-Instanz— abgebaut, siehe Abschnitt 2. Die Runbooks stehen im Showcase-Wiki, der Filebrowser-Umweg war überflüssig.- Uptime-Kuma-Status-Page — Ampel-Ansicht ohne technische Details
- 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
Grafanas eingebaute Rolle „Viewer" reicht nicht als Isolation — ein Viewer sieht standardmäßig alle Dashboards der Org. Für echte Trennung: zwei Ordner mit gebrochener Berechtigungs-Vererbung.
# Dashboards in getrennte Unterordner der Provisionierung legen
mkdir -p /opt/monitoring/grafana-provisioning/dashboards/intern /opt/monitoring/grafana-provisioning/dashboards/gast
mv .../dashboards/homelab.json .../dashboards/intern/
mv .../dashboards/homelab-gast.json .../dashboards/gast/
provider.yaml mit zwei Providern (je eigenem folder: und path:), danach docker compose restart grafana (reine Datei-Provisionierung reicht sonst nicht für neue Ordner-Zuordnung).
Anschließend Ordner-Berechtigungen umbauen (klassische Permissions-API, nicht die neue /api/access-control/...-RBAC-API — letztere zeigt zwar den Ist-Zustand an, ist aber für Änderungen umständlicher):
# Intern: Viewer-Rolle komplett entfernen -> kein Viewer-Konto sieht das je automatisch
curl -u admin:$PW -X POST http://localhost:3000/api/folders/$INTERN_UID/permissions \
-d '{"items":[{"role":"Editor","permission":2}]}'
# Gast: Viewer-Rolle raus, stattdessen 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}]}'
Nutzer anlegen (Rolle Viewer ist bei Admin-erstellten Usern bereits Default):
curl -u admin:$PW -X POST http://localhost:3000/api/admin/users \
-d '{"name":"Gast Freunde","login":"gast-freunde","password":"...","OrgId":1}'
⚠️ NICHT TUN: sich auf isDefault/Standard-Ordner-Vererbung verlassen und nur ein Dashboard ohne Ordner-Trennung anlegen — ein Viewer-Account sieht dann trotzdem alle anderen Dashboards der Instanz mit (bestätigt: curl -u gast-freunde ... /api/dashboards/uid/homelab-overview gab vor der Ordner-Trennung 200 zurück, keine echte Isolation).
Deaktivieren/Aktivieren eines Users:
curl -u admin:$PW -X POST http://localhost:3000/api/admin/users/$ID/enable # bzw. /disable
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 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:
|> map(fn: (r) => ({ r with host:
if r.host == "adguard" then "DNS / Ad-Blocking"
else if r.host == "nas" then "NAS / Dateiablage"
else if r.host == "browser" then "Remote-Desktop"
else if r.host == "vaultwarden" then "Passwort-Manager"
else if r.host == "monitoring" then "Monitoring-Stack"
else r.host }))
|> keep(columns: ["_time", "host", "_value"])
Der 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 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):
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 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 \
--perm.admin=false --perm.execute=false --perm.create=false --perm.rename=false \
--perm.modify=false --perm.delete=false --perm.share=false --perm.download=true --scope /
Systemd-Unit bindet nur an die Tailscale-IP (<TS_IP_NAS>:8081), nicht ans LAN — der einzige Zugriffsweg ist über den VPS-Caddy-Vhost + Tailscale, LAN-Nutzer erreichen die Instanz gar nicht:
[Service]
User=gastdemo
Group=gastdemo
ExecStart=/usr/local/bin/filebrowser -d /var/lib/filebrowser-gast/filebrowser.db -a <TS_IP_NAS> -p 8081
Restart=on-failure
⚠️ NICHT TUN: --perm.execute vergessen. Der users add-Default für Execute ist true (Command-Runner-Feature) — beim ersten Anlegen übersehen, per users update ... --perm.execute=false nachgezogen. Immer nach dem Anlegen mit filebrowser users ls -d ... verifizieren, dass wirklich nur Download=true gesetzt ist.
⚠️ NICHT TUN: die bestehende NAS-Freigabe read-only exponieren wollen. Ein Berechtigungs-Bug in der Konfiguration würde dann echte Daten zeigen. Stattdessen komplett eigenes Verzeichnis + eigene Instanz, die von Anfang an nichts anderes kennt.
Bekannte Einschränkung: Filebrowser hat in dieser Version kein Feld/Flag zum Deaktivieren einzelner Nutzer (anders als Grafana). Der „Aus"-Zustand wird deshalb über den ganzen Systemd-Service abgebildet (systemctl stop filebrowser-gast) — das deaktiviert zwangsläufig beide Gast-Logins gleichzeitig auf dieser Ebene.
3. Caddy-Vhosts (VPS)
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
}
status-gast.<DOMAIN> {
reverse_proxy <TS_IP_MONITORING>:3001
}
caddy validate --config /etc/caddy/Caddyfile vor systemctl reload caddy. TLS-Zertifikate zieht Caddy automatisch, sobald die DNS-A-Records existieren (siehe Abschnitt 6).
4. Portfolio-Runbooks — Quelle für den öffentlichen Showcase
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.
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 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
Kein REST-API für Monitor-/Status-Page-Verwaltung — Python-Bibliothek uptime-kuma-api (Socket.IO-Client) nötig:
from uptime_kuma_api import UptimeKumaApi, MonitorType
api = UptimeKumaApi('http://<TS_IP_MONITORING>:3001')
api.login('admin', PASSWORD)
api.add_monitor(type=MonitorType.HTTP, name='Grafana (Gast)', url='https://grafana-gast.<DOMAIN>',
interval=60, retryInterval=60, maxretries=2, notificationIDList=[])
api.add_status_page('gast', 'Homelab Showcase')
api.save_status_page(slug='gast', title='Homelab Showcase', published=True, showPoweredBy=False,
publicGroupList=[{'name': 'Showcase', 'weight': 1,
'monitorList': [{'id': 12}, {'id': 13}, {'id': 14}]}])
⚠️ NICHT TUN: den drei Gast-Monitoren die ntfy-Notification zuweisen (weder explizit noch über isDefault). Die Konten werden absichtlich manuell rauf-/runtergefahren — mit Notification würde jedes Deaktivieren einen „Down"-Alarm auslösen. notificationIDList=[] beim Anlegen explizit setzen.
Zweite Gruppe: echte Infrastruktur-Telemetrie hinter Rollen-Namen
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:
specs = [
('Hauptserver', '<LAN_IP_PVE>'), ('DNS / Ad-Blocking', '<LAN_IP_ADGUARD>'),
('NAS / Dateiablage', '<LAN_IP_NAS>'), ('Remote-Desktop', '<LAN_IP_DESKTOP>'),
('Passwort-Manager', '<LAN_IP_VAULTWARDEN>'), ('Monitoring-Stack', '<LAN_IP_MONITORING>'),
('VPN-Gateway', '<TS_IP_VPS>'),
]
for name, ip in specs:
api.add_monitor(type=MonitorType.PING, name=name, hostname=ip,
interval=60, retryInterval=60, maxretries=2, notificationIDList=[])
Uptime Kuma zeigt auf der Status-Page nur den konfigurierten Namen + Antwortzeit-Graph, nicht die Ziel-IP — die echten LAN-IPs sind also nur intern für den Check relevant, nie öffentlich sichtbar. Zweite Gruppe per publicGroupList mit eigenem weight ergänzt (bestehende „Showcase"-Gruppe bleibt unverändert stehen, Gruppen werden per Name identifiziert und beim Speichern komplett ersetzt — beide Gruppen müssen bei jedem save_status_page-Aufruf mit angegeben werden, sonst verschwindet die andere).
Verifiziert über die öffentliche URL: 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 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):
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 tatsächlich im Passwortmanager ablegen, nicht nur im Chat stehen lassen.
6. DNS (manueller Schritt, keine API verfügbar)
A-Records im Provider-Panel für <DOMAIN>, alle auf die VPS-IP (gleiche IP wie der bestehende Passwortmanager-Eintrag):
grafana-gast→ VPS-IP(Vhost entfernt, DNS-Eintrag kann gelöscht werden)dateien-gast→ VPS-IPstatus-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. 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 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 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 erzeugtlevel=info msg="Failed to authenticate request" client=auth.client.form+ eine „Request Completed"-Zeile mitpath=/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.
track-grafana-gast.sh schreibt ins bestehende logins-Measurement (service=grafana-gast), gleiches Muster wie die übrigen Tracker.
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. Gast-Zugänge verwalten (gast-zugang.py)
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.
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)
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
*/15 * * * * /usr/local/bin/gast-zugang.py ablauf-pruefen >/dev/null 2>&1
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.
⚠️ 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.
⚠️ 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:
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.
gast-auto-anfrage.py # kein Subcommand, ein Lauf = ein Poll-Zyklus
⚠️ 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: 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.
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.
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.
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.pybricht bei bereits existierendem Login ab).
⚠️ 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.pys 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.
- 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. - Strukturelle Hürde: kein öffentliches Web-Formular, es braucht eine echte E-Mail — filtert die Masse reiner Web-Bots bereits aus, ohne eigenen Code.
- 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).
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:
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):
[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:
- Die Fehlerbehandlung fasst
stdout+stderrbei einem Fehlschlag explizit in die Meldung ein statt nur den Exit-Code, Truncation-Länge im ntfy-Handler deutlich erhöht. gast-zugang.pybekam 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 (keinLOCK_NB) — der zweite Aufruf soll kurz warten, nicht mit einem Fehler abbrechen. Mit zwei gleichzeitig gestartetenneu-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
# 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.
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
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
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:
- Direkter Link-Fix:
hrefauf die volle Status-Page-URL geändert, neu deployed. - Robuster gemacht: Kumas globale „Entry Page" (Standard-Landing für die nackte Domain) auf die
Gast-Status-Page umgestellt, per
uptime-kuma-api:
Damit landet jetzt auch die nackte Domain direkt öffentlich — der Admin-Login bleibt weiterhin erreichbar, nur nicht mehr der Default.api.set_settings(entryPage='statusPage-gast')
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.
Rebuild-Runbooks
Aufbau
- Übersicht
- PVE-Host
- AdGuard · CT100
- NAS · CT101
- Management-Desktop · CT102
- Vaultwarden · CT103
- VPS · STRATO
- Backup & Restore
- Monitoring · CT104
- Gast-Zugang
Referenz