3 09 Gast Zugang
arnol edited this page 2026-08-07 18:06:11 +02:00
This file contains ambiguous Unicode characters

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:

  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

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
  • dateien-gast → VPS-IP (Vhost entfernt, DNS-Eintrag kann gelöscht werden)
  • status-gast → VPS-IP

⚠️ NICHT TUN: Caddy neu laden/starten, bevor alle A-Records korrekt gesetzt sind, und dann erwarten, dass es sich von selbst korrigiert. Certmagic (Caddys ACME-Bibliothek) merkt sich fehlgeschlagene Versuche pro Hostname und schaltet nach mehreren Fehlschlägen zur Schonung des Let's-Encrypt-Produktiv-Rate-Limits automatisch auf die Staging-CA um (liefert kein von Browsern akzeptiertes Zertifikat) plus einen Backoff von bis zu 20 Minuten zwischen Versuchen. 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 erzeugt level=info msg="Failed to authenticate request" client=auth.client.form + eine „Request Completed"-Zeile mit path=/login status=401 — aber ohne Username, könnte auch ein vertippter Admin-Login sein. Ein Erfolg erzeugt gar keine Log-Zeile beim Login selbst; einzig nachfolgende authentifizierte Requests tragen den Login-Namen als Tag. track-grafana-gast.sh (Cron alle 5 Min.) zählt deshalb Fehlschläge generisch und deutet „Aktivität" über das Vorkommen dieser Tags.
  • Filebrowser: entfällt seit dem Abbau der Gast-Instanz. Die Einschränkung als solche bleibt dokumentiert, weil sie für die übrigen Filebrowser-Instanzen weiter gilt: Filebrowser loggt nur Fehlschläge mit IP, keine Erfolge — gleiche Lücke wie beim DNS-Blocking-Dienst.

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.py bricht 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 (12 Wochen) mit Puffer.

Rate-Limit: max. 10 automatische Zugänge pro Kalendertag. Danach nur noch ein ntfy-Hinweis („Tageslimit erreicht") statt automatischer Anlage — echte, verspätete Anfragen landen dann eben zur manuellen Prüfung, statt dass das System bei einem Anfrage-Schwall (Spam/Scan) unkontrolliert Konten produziert.

Bei erreichtem Tageslimit bekommt auch der Anfragende eine Antwort (vorher komplettes Schweigen) — eine kurze Mail „Anfrage angekommen, wird zeitnah manuell freigeschaltet". Wichtig gerade bei Recruitern/Headhuntern: Stille sieht nach einer verlorenen oder ignorierten Anfrage aus, nicht nach einem ausgeschöpften Kontingent. Die zugehörige Anfrage-Mail im gast-Ordner wird in diesem Fall bewusst nicht gelöscht (anders als bei erfolgreicher Auto-Vergabe) — die Absenderadresse wird noch für die manuelle Freischaltung gebraucht. Schlägt der Versand dieser Zwischenmeldung selbst fehl (z. B. SMTP kurzzeitig down), bricht das nicht den Lauf ab — der ntfy-Hinweis zählt in diesem Fall mehr als die Bestätigung an den Anfragenden.

Spam-Schutz (ohne kostenpflichtigen Zusatzfilter des Providers): dreistufig.

  1. Der eingebaute Serverside-Antispam des Mail-Providers — kostenlos in jedem Mail-Tarif enthalten (kein separater Kauf nötig), muss aber manuell aktiviert werden. Ohne diesen manuellen Schritt filtert der Provider serverseitig gar nichts, obwohl der Spam-Ordner in der Mailbox schon existiert.
  2. Strukturelle Hürde: kein öffentliches Web-Formular, es braucht eine echte E-Mail — filtert die Masse reiner Web-Bots bereits aus, ohne eigenen Code.
  3. Flut-Schutz im Skript: kommen in einem Poll-Zyklus mehr als 15 neue Nachrichten auf einmal rein, verarbeitet das Skript keine einzige davon automatisch, sondern schickt eine Sammel-ntfy-Meldung und lässt alles liegen. Ohne diese Schwelle würde ab dem Tageslimit für jede weitere Spam-Mail einzeln „Tageslimit erreicht" gepusht — bei einem echten Schwall (z. B. 50 Spam-Mails) wäre das Handy selbst die Störquelle.

Verifiziert: echte Testmail geschickt, Skript manuell gestartet — Konto automatisch angelegt (korrektes Domain-Nummer-Schema, 335h), Zugangsdaten-Mail kam beim Absender an (DKIM-signiert, bestätigt die automatische Signierung durch den Provider), gast-Ordner danach leer, ntfy-Meldungen korrekt. Testkonto, Testmail und Zähler anschließend manuell wieder zurückgesetzt, damit die erste echte Anfrage bei Nummer 1 startet.

10. Täglicher Bericht + status-gast-Login-Tracking

Täglicher Bericht (gast-bericht.py, Cron 0 7 * * *): wrapt gast-zugang.py liste unverändert und schickt die Tabelle per ntfy — bewusst keine zweite Logik zur Restlaufzeit- Berechnung, gast-zugang.py fragt Grafana live ab und ist damit die einzige Quelle der Wahrheit für „aktiv/gesperrt". Ein zweiter, eigenständig berechnender Bericht könnte mit der Zeit auseinanderlaufen (z. B. bei manuellem sperren, das den lokalen Zustand nicht anfasst).

status-gast-Login-Tracking (track-status-gast-logins.py, VPS, Cron */5 * * * *): bis dahin eine echte Lücke — grafana-gast wurde schon länger überwacht, status-gast (dieselben Zugangsdaten, andere Anwendung) gar nicht. Grund: Caddy loggte für diesen Vhost überhaupt nichts (keine log-Direktive im Caddyfile-Block).

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:

  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

# 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:

  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:
    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.