17 KiB
Clusev — End-to-End-Design: Install, Domain/TLS & In-Dashboard-Updates
Entscheidung des Lead-Architekten. Verbindlich, opinionated. Priorität: Korrektheit > Sicherheit > Operator-Einfachheit > wenige bewegliche Teile.
0. Kurzfassung (TL;DR)
- Install = dünnes
install.sh(Secrets + Infra + Schema + erster Admin) plus ein authentifizierter In-App-Onboarding-Wizard (Passwortwechsel, 2FA-Pflicht, erster Server). Ein unauthentifizierter/install-Webwizard wird abgelehnt. - Ports/Domain sind vollständig
.env-getrieben. Eine einzige Stellschraube —APP_DOMAIN— entscheidet HTTP-auf-IP vs. HTTPS-auf-Domain. Alles andere (APP_URL,REVERB_*,SITE_ADDRESS) wird daraus abgeleitet, nichts hardcoded. - Proxy/TLS = Caddy als einziger host-exponierter Dienst im Stack (Auto-Let's-Encrypt, Auto-Renew, Reverb-wss über dasselbe :443). Traefik und certbot+nginx werden verworfen.
- Admin wird im Shell-Flow per
artisan clusev:installmit Zufallspasswort angelegt, das genau einmal im Operator-Terminal erscheint. Kein Default-Credential, kein „first-visitor-wins"-Claim-Fenster. - In-Dashboard-Update = die App schreibt nur eine HMAC-signierte Intent-Datei; ein host-seitiger Updater (systemd path+oneshot) führt aus. Die App fasst den Docker-Socket nie an. Digest-Pinning + cosign-Verify + 2FA + Audit + Backup-vor-Migration + Rollback-by-Digest.
1. EMPFEHLUNG je Nutzerfrage
(1) Install: Anleitung vs. install.sh vs. In-App-Wizard
Klarer Split — beides, mit Trennlinie an der Trust-Boundary „wer SSH/Docker auf der Box hat":
install.sh(PRIMÄR) macht genau das, was vor Existenz der App nötig ist: Preflight, Repo materialisieren, Secrets idempotent generieren,.envschreiben, Stack hochfahren, auf DB-Health warten,migrate --force, ersten Admin perartisan clusev:installanlegen. Dünn, keine Business-Logik.- In-App-Wizard (SEKUNDÄR, AUTHENTIFIZIERT) = Onboarding nach dem ersten Login: Passwort-Pflichtwechsel, 2FA-Pflicht-Enrolment, ersten Server hinzufügen, SMTP/Branding. Kein unauthentifizierter
/install-Endpoint. - Schriftliche Anleitung (FALLBACK) = nur als README-Appendix für Operatoren, die
curl|bashmisstrauen und jeden Befehl selbst lesen wollen.
Begründung: Ein offener, privilegierter /install-Route auf einem Security-First-Fleet-Panel ist genau die Art Endpoint, die vergessen und ausgenutzt wird (historische „Setup-Wizard"-RCEs). Die Shell hat die gewünschte Trust-Boundary bereits: nur wer SSH/Docker auf der VM hat, kann sie ausführen, und das Einmalpasswort erscheint nur in dessen Terminal.
(2) Ports/Domain NICHT hardcoded — env-getrieben
Eine Master-Variable, alles andere abgeleitet:
.env-Knopf |
leer / IP-Modus | gesetzt / Domain-Modus |
|---|---|---|
APP_DOMAIN |
(leer) | clusev.example.com |
APP_SCHEME |
http |
https |
APP_PORT |
z. B. 80 (host bind) |
80+443 von Caddy belegt |
SITE_ADDRESS (abgeleitet) |
:${APP_PORT} |
https://${APP_DOMAIN} |
APP_URL (abgeleitet) |
http://<IP>:${APP_PORT} |
https://${APP_DOMAIN} |
REVERB_HOST / REVERB_PORT / REVERB_SCHEME (abgeleitet) |
<IP> / ${APP_PORT} / http |
${APP_DOMAIN} / 443 / https |
ACME_EMAIL |
— | Admin-E-Mail |
VITE_REVERB_* interpolieren bereits aus REVERB_* (siehe .env.example), folgen also automatisch. Der Installer berechnet SITE_ADDRESS/APP_URL/REVERB_* einmal aus APP_DOMAIN und schreibt sie idempotent. Eine einzige Caddyfile-Vorlage deckt beide Modi ab.
(3) Optionale Domain + Auto-TLS — welcher Proxy
Caddy 2 (caddy:2-alpine, per @sha256-Digest gepinnt), als einziger host-exponierter Dienst.
- Caddy gewinnt gegen Traefik (Label-Sprawl + Docker-Socket-Mount für den Provider — unnötige Angriffsfläche bei nur 2 Backends) und gegen certbot+nginx (zweites nginx, Renewal-Timer, Reload-Hook — die meisten beweglichen Teile). ~12-Zeilen-Caddyfile, Auto-HTTPS + Auto-Renew + HTTP→HTTPS-Redirect ohne ein einziges Script, WebSocket-Upgrade implizit.
- Reverb-wss wird per Pfad (
/app/*,/apps/*) aufreverb:8080über dasselbe :443 geroutet — kein extra exponierter Port, kein Mixed-Content. - App & Reverb verlieren ihre
ports:und sind nur noch im internenclusev-Bridge-Netz erreichbar (app:80,reverb:8080). Kleinere öffentliche Angriffsfläche. - Zertifikate persistieren im Named Volume
caddy-data→ Stack-Recreate beim Update triggert kein erneutes ACME (keine Rate-Limit-Probleme).
Scharfe Kante (muss in die Prompts): Let's Encrypt HTTP-01/TLS-ALPN-01 braucht öffentlich erreichbare 80/443. Die Ziel-IP 10.10.90.136 ist RFC1918-privat → öffentliches Cert schlägt dort fehl. Der Installer muss das erkennen und entweder bare-IP-HTTP anbieten oder einen DNS-01-Caddy-Build (anderes Image + DNS-Credentials) dokumentieren. Niemals bei gesetzter Domain still auf HTTP zurückfallen — laut scheitern.
(4) Admin-User + Random-Passwort am Ende
artisan clusev:install --email=…ist idempotent: existiert bereits ein Admin → No-op („bereits installiert"). Sonst: Admin mitStr::password(20), Flagsmust_change_password=true+two_factor-noch-nicht-enrolled, nur den bcrypt-Hash speichern.- Das Klartext-Passwort wird ausschließlich auf stdout (eine Marker-Zeile) ausgegeben; die Shell zeigt es im Schlussbanner — nie in
.env, Datei, DB oder History. - Kein Default-Credential (Umami-Antipattern), kein Claim-Fenster (Coolify/Plausible/Kuma). Erzwungener Passwortwechsel + 2FA-Pflicht beim ersten Login sind die kompensierenden Kontrollen gegen Scrollback/tmux-Leaks.
installed-Marker (DB-Flag/Sentinel) deaktiviert den First-Run-Pfad hart nach erstem Lauf (GiteaINSTALL_LOCK-Idiom).
(5) In-Dashboard-Updates — Mechanismus + Sicherheit + Rollback
Gewählter Mechanismus: App schreibt Intent, host-seitiger Updater führt aus (out-of-band). Verworfen: Socket-Sidecar in/neben der App (ein RCE = Host-Root, und die App hält den Fleet-SSH-Vault) und Watchtower (unbeaufsichtigt, kein 2FA, keine Migration, kein Rollback).
Löst „ein Container kann seinen eigenen Parent-Stack nicht neu erzeugen", weil der Executor außerhalb des clusev-Compose-Lifecycles lebt (eigene systemd-Unit) — App/reverb/queue neu zu erzeugen tötet nicht den Prozess, der das Update fährt. Die App bekommt keinerlei Docker-Privileg — ihre einzige neue Fähigkeit ist „eine signierte Datei schreiben".
2. Konkrete Install-UX
One-Liner: curl -fsSL https://raw.githubusercontent.com/<owner>/clusev/main/install.sh | bash — oder aus einem Clone ./install.sh. Interaktiv nur am TTY; ohne TTY (gepiped) liest er CLUSEV_DOMAIN / CLUSEV_ADMIN_EMAIL aus env oder fällt auf bare-IP/HTTP zurück.
Prompts (Deutsch, kein Emoji):
Clusev Installer
================
Domain (leer = Zugriff per IP über HTTP): > clusev.example.com
HTTP-Port (nur ohne Domain, Standard 80): > 80
Admin E-Mail (Login + Let's Encrypt): > admin@example.com
Generiert (idempotent — set_kv schreibt nur bei leer/Platzhalter, regeneriert NIE):
APP_KEYviadocker compose run --rm --no-deps app php artisan key:generate --show(kein Host-PHP)DB_PASSWORD,DB_ROOT_PASSWORD,REVERB_APP_SECRETviaopenssl rand -hex 32REVERB_APP_ID,REVERB_APP_KEYviaopenssl rand -hex 16APP_ENV=production,APP_DEBUG=false,REDIS_PASSWORDgesetzt;HOST_UID/GIDausid- abgeleitet:
SITE_ADDRESS,APP_URL,REVERB_HOST/PORT/SCHEME
Phasen-Fortschritt:
[1/7] Voraussetzungen prüfen … Docker + compose vorhanden, Ports frei — ok
[2/7] Secrets generieren … .env vorhanden, vorhandene Werte bleiben erhalten
[3/7] Images holen … ghcr.io/<owner>/clusev@sha256:… gezogen
[4/7] Stack starten … 6 Container laufen (inkl. caddy)
[5/7] Datenbank bereit … MariaDB healthy nach 9s
[6/7] Migrationen … 3 ausgeführt; config:cache/route:cache gebaut
[7/7] Admin anlegen … erstellt
Finale Ausgabe (Passwort erscheint NUR hier):
============================================================
Clusev ist bereit.
URL: https://clusev.example.com
Admin: admin@example.com
Passwort: 7Qf2-Kp9x-Lm4v-Rt8z <-- nur jetzt sichtbar, nicht gespeichert
Beim ersten Login: Passwort ändern + 2FA einrichten (Pflicht).
============================================================
3. Reverse-Proxy/TLS-Design
Topologie (prod): Heute publizieren app:80 und reverb:8080 direkt auf den Host. Neu: nur Caddy bindet Host-Ports; app/reverb verlieren ports:.
Caddy-Service (in docker-compose.prod.yml): image: caddy:2-alpine (per Digest pinnen), ports: ${APP_PORT:-80}:80, 443:443, 443:443/udp (HTTP/3); Volumes ./docker/caddy/Caddyfile:ro, caddy-data (ACME-Account + Certs — muss persistieren), caddy-config; depends_on: [app, reverb].
Caddyfile (eine Vorlage, beide Modi):
{
email {$ACME_EMAIL}
}
{$SITE_ADDRESS} {
@reverb path /app/* /apps/*
reverse_proxy @reverb reverb:8080
reverse_proxy app:80
}
Die Magie: SITE_ADDRESS=:${APP_PORT} → Caddy serviert Klartext-HTTP, kein Cert. SITE_ADDRESS=https://${APP_DOMAIN} → Auto-Let's-Encrypt, :443, Auto-Renew ~30 Tage vorher, Auto-Redirect 80→443. WebSocket-Upgrade ist implizit, daher funktioniert /app/* als wss auf 443 ohne Spezialdirektive.
Vor dem Shippen verifizieren (OFFENER PUNKT): config/reverb.php / config/broadcasting.php sind im Repo noch nicht publiziert. Die exakten Echo-WS-Pfade (/app/* WS, /apps/* Event-API) müssen gegen die Config geprüft werden, sonst bricht Realtime still.
Bare-IP-Warnung: IP-Modus serviert Panel inkl. 2FA + Audit über Klartext-HTTP. Für ein Security-First-Produkt: lauter Warnhinweis in Install-Ausgabe und UI; optional self-signed/Internal-CA-Option für IP-only.
4. Self-Update-Mechanismus
Kontroll-/Datenfluss:
- UI-Aktion (
app/Livewire/System/Update.php, Vollseite, Deutsch): GHCR-API liefert Tags/Digests + Changelog (read-only Token). Zeigt aktuellen Digest vs. Ziel-Digest + Release-Notes. - Gate: frische TOTP-2FA-Challenge (Step-up, nicht aus der Session geerbt) +
can.update-Authorization. AuditEvent vor jeder Aktion. - Intent schreiben (kein Socket): App schreibt
update-request.jsonins geteilte Volumeclusev-control:{target_digest, target_tag, changelog_hash, requested_by, requested_at, nonce, hmac}. HMAC-Key separat vonAPP_KEY, nur App+Updater bekannt → ein Volume-Write-Angreifer kann kein Update fälschen. - Host-Updater führt aus: systemd
clusev-updater.path(überwacht die Datei) +clusev-updater.service(Type=oneshot, läuft als docker-group-User, nicht als App). Einmalige Human-sudo-Installation zweier Unit-Files — die einzige Host-Konzession (vom Brief erlaubt). - Update-Script (
docker/update.sh), idempotent:- HMAC + Nonce verifizieren (Anti-Replay); abbrechen wenn
target_digest == current. - Compose-Kontext aus Labels entdecken (
com.docker.compose.project=clusev,config_files,working_dir) — null Hardcoding. - cosign keyless verify (GitHub-OIDC, Identity = Release-Workflow) des Ziel-Digests vor Pull/Swap. Fail-closed.
docker pull …@<digest>, Digest nach Pull erneut prüfen (Tag-Race-Schutz).- Snapshot: aktuellen Digest aller Services +
mariadb-dumpnach./backups/pre-update-<ts>.sql.gz(0600). - Digest idempotent in
.envpinnen (CLUSEV_IMAGE=…@sha256:…). - Migration (expand/contract): Queue stoppen →
docker compose run --rm app php artisan migrate --forcewährend alter App-Code noch läuft (nur sicher für additive Changes) →reverb+queue, dannappzuletzt neu erzeugen → Queue zuletzt starten. Destruktive (contract) Changes erst in einem späteren Release. CI-Lint blockt expand+contract in einem Release. - Healthcheck + Auto-Rollback:
/uppollen; bei Fehler vorigen Digest re-pinnen,up -d, DB-Dump restoren falls Migration lief,update_status='rolled_back'.
- HMAC + Nonce verifizieren (Anti-Replay); abbrechen wenn
- App beobachtet: Updater schreibt
update-result.jsonzurück; App zeigt Fortschritt/Ergebnis, finaler AuditEvent, Request-Datei geräumt.
Sicherheits-Controls (verdichtet):
- App fasst Docker-Socket nie an → Laravel-RCE kann keine Container neu erzeugen.
- Digest-Pinning end-to-end (
:latest-Default +OWNER-Platzhalter müssen weg), cosign-Verify fail-closed, HMAC+Nonce auf Intent, frisches 2FA, lückenloses Audit. - Caddy bleibt aus der Update-Transaktion (eigener gepinnter Tag, separat aktualisiert) → ein App-Update kann TLS-Terminierung nie mid-flight wegreißen.
Rollback-Story: Jeder Deploy pinnt einen immutablen Digest und snapshottet den vorigen + DB-Dump. Rollback = vorigen Digest re-pinnen + compose up (+ optional DB-Restore). Expand-only-Updates sind immer rollback-sicher. Cross-contract-Rollback ist verlustbehaftet (Writes seit Dump gehen verloren) → in der UI explizit warnen + Extra-Bestätigung.
5. Datei-/Artefakt-Liste fürs Repo
Neu:
install.sh(Repo-Root,chmod +x, raw-curlbar) — Preflight, Clone, idempotente.envviaset_kv(), Proxy-Wiring, Pull/Build, Up, Wait-Health, Migrate,clusev:install.docker/caddy/Caddyfile— env-getemplatet,@reverb-Matcher + 2reverse_proxy.app/Console/Commands/Install.php— artisanclusev:install, idempotenter Admin-Bootstrap + Einmalpasswort-Marker.database/migrations/…_add_admin_onboarding_columns.php—must_change_password+two_factor_*.app/Services/UpdateService.php— GHCR-Query, Tag→Digest, HMAC-Intent schreiben, Status lesen, AuditEvents.app/Livewire/System/Update.php(+ kebab-View) — Update/Rollback-UI, 2FA-Step-up, Live-Fortschritt.docker/update.sh— host-seitiges Update/Rollback-Script.clusev-updater.path+clusev-updater.service— host systemd Units (einmal sudo).docker-compose.prod.yml(override/Fragment) — Caddy-Service +caddy-data/caddy-config/clusev-control-Volumes.
Geändert:
docker-compose.prod.yml—ports:vonapp/reverbentfernen; Caddy als einziger Host-Publisher; Image-Ref aufCLUSEV_IMAGE-Digest-Var umstellen;OWNER+:latest-Default raus..env.example—APP_DOMAIN,APP_SCHEME,ACME_EMAIL,SITE_ADDRESS,CLUSEV_IMAGE,UPDATE_HMAC_KEYergänzen;APP_ENV=production/APP_DEBUG=falsefür den Prod-Pfad; Platzhalter-Secrets dokumentieren als „werden vom Installer generiert".README.md— One-Liner + Fallback-Manual-Appendix.- CI (
.github/workflows/…) — cosign-Signierung der GHCR-Images + Migrations-Lint (expand/contract).
Unverändert (referenziert): Dockerfile (rm -f .env im Prod-Stage), docker/entrypoint.sh, docker/nginx/nginx.conf, docker/supervisor/supervisord.conf.
6. Phasenplan
JETZT (v1-Scaffold):
install.sh+set_kv()+clusev:install+ Onboarding-Migration → near-one-command-Install mit gesetztem Admin.- Caddy-Service + Caddyfile + abgeleitete
.env-Vars → env-getriebenes TLS (IP-HTTP / Domain-HTTPS / Reverb-wss). - Prod-Compose härten:
ports:raus außer Caddy,OWNER/:latest-Default ersetzen,APP_ENV=production/APP_DEBUG=false,REDIS_PASSWORDsetzen. - Authentifizierter Onboarding-Wizard (Passwortwechsel + 2FA-Pflicht).
- CI cosign-Signierung zuerst verdrahten (Voraussetzung für (3) unten, fail-closed).
SPÄTER (v1.x):
6. Voller In-Dashboard-Updater (Intent-Protokoll, update.sh, systemd Units, UI, Rollback, Backup-Gate). Nicht shippen, bevor CI-Signierung steht.
7. DNS-01-Caddy-Variante für private-IP/Internal-Domain-Deployments.
8. Migrations-Lint (CI) erzwingt expand/contract.
9. Blue/Green-App-Replicas für echte Zero-Downtime (über v1-Single-VM-Scope hinaus).
7. Entscheidungen, die der Owner VOR dem Bauen bestätigen muss
- Updater-Executor: systemd
path+oneshot(empfohlen, eine einmalige Human-sudo-Installation) — oder separaterclusev-ops-Container mit gehärtetem Socket-Proxy (kein sudo, aber Socket wieder im Spiel)? Empfehlung: systemd. - Private-IP-Realität: Ist
10.10.90.136final/intern? Falls ja und trotzdem eine Domain gewünscht → DNS-01-Build nötig (anderes Caddy-Image + DNS-Provider-Credentials). Bestätigen, ob das in v1 gehört. - Bare-IP-HTTP zulassen? 2FA/Audit über Klartext ist ein Security-Kompromiss. Erlauben (mit lauter Warnung) oder bei fehlender Domain self-signed/Internal-CA erzwingen?
- cosign-Signierung zuerst: Owner bestätigt, dass CI Images keyless signiert, bevor Self-Update ausgeliefert wird (sonst Verify = Theater, Update muss fail-closed blocken).
- Image-Referenz-Variable: Umstieg von
IMAGE_TAG=latestauf eine Digest-gepinnteCLUSEV_IMAGEin Prod-Compose — bricht keinen Dev-Flow, muss aber bewusst durchgewunken werden. curl|bash-Vertrauen: One-Liner als Primärpfad akzeptiert, oder gepinnte Checksum + dokumentierter „clone, lesen, dann ausführen"-Pfad als Default für die Security-Bewussten?