# 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:install` mit **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, `.env` schreiben, Stack hochfahren, auf DB-Health warten, `migrate --force`, **ersten Admin** per `artisan clusev:install` anlegen. 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|bash` misstrauen 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://:${APP_PORT}` | `https://${APP_DOMAIN}` | | `REVERB_HOST` / `REVERB_PORT` / `REVERB_SCHEME` (abgeleitet) | `` / `${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/*`) auf `reverb:8080` über **dasselbe :443** geroutet — kein extra exponierter Port, kein Mixed-Content. - **App & Reverb verlieren ihre `ports:`** und sind nur noch im internen `clusev`-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 mit `Str::password(20)`, Flags `must_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 (Gitea `INSTALL_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//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_KEY` via `docker compose run --rm --no-deps app php artisan key:generate --show` (kein Host-PHP) - `DB_PASSWORD`, `DB_ROOT_PASSWORD`, `REVERB_APP_SECRET` via `openssl rand -hex 32` - `REVERB_APP_ID`, `REVERB_APP_KEY` via `openssl rand -hex 16` - `APP_ENV=production`, `APP_DEBUG=false`, `REDIS_PASSWORD` gesetzt; `HOST_UID/GID` aus `id` - 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//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:** 1. **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. 2. **Gate:** frische **TOTP-2FA-Challenge** (Step-up, nicht aus der Session geerbt) + `can.update`-Authorization. AuditEvent **vor** jeder Aktion. 3. **Intent schreiben (kein Socket):** App schreibt `update-request.json` ins geteilte Volume `clusev-control`: `{target_digest, target_tag, changelog_hash, requested_by, requested_at, nonce, hmac}`. HMAC-Key **separat von `APP_KEY`**, nur App+Updater bekannt → ein Volume-Write-Angreifer kann kein Update fälschen. 4. **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). 5. **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 nach Pull erneut prüfen (Tag-Race-Schutz). - **Snapshot:** aktuellen Digest aller Services + `mariadb-dump` nach `./backups/pre-update-.sql.gz` (0600). - Digest idempotent in `.env` pinnen (`CLUSEV_IMAGE=…@sha256:…`). - **Migration (expand/contract):** Queue stoppen → `docker compose run --rm app php artisan migrate --force` während alter App-Code noch läuft (nur sicher für additive Changes) → `reverb`+`queue`, dann `app` **zuletzt** 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:** `/up` pollen; bei Fehler vorigen Digest re-pinnen, `up -d`, DB-Dump restoren falls Migration lief, `update_status='rolled_back'`. 6. **App beobachtet:** Updater schreibt `update-result.json` zurü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 `.env` via `set_kv()`, Proxy-Wiring, Pull/Build, Up, Wait-Health, Migrate, `clusev:install`. - `docker/caddy/Caddyfile` — env-getemplatet, `@reverb`-Matcher + 2 `reverse_proxy`. - `app/Console/Commands/Install.php` — artisan `clusev: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:` von `app`/`reverb` entfernen; Caddy als einziger Host-Publisher; Image-Ref auf `CLUSEV_IMAGE`-Digest-Var umstellen; `OWNER`+`:latest`-Default raus. - `.env.example` — `APP_DOMAIN`, `APP_SCHEME`, `ACME_EMAIL`, `SITE_ADDRESS`, `CLUSEV_IMAGE`, `UPDATE_HMAC_KEY` ergänzen; `APP_ENV=production`/`APP_DEBUG=false` fü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):** 1. `install.sh` + `set_kv()` + `clusev:install` + Onboarding-Migration → near-one-command-Install mit gesetztem Admin. 2. Caddy-Service + Caddyfile + abgeleitete `.env`-Vars → env-getriebenes TLS (IP-HTTP / Domain-HTTPS / Reverb-wss). 3. Prod-Compose härten: `ports:` raus außer Caddy, `OWNER`/`:latest`-Default ersetzen, `APP_ENV=production`/`APP_DEBUG=false`, `REDIS_PASSWORD` setzen. 4. Authentifizierter Onboarding-Wizard (Passwortwechsel + 2FA-Pflicht). 5. **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 1. **Updater-Executor:** systemd `path`+`oneshot` (empfohlen, eine einmalige Human-sudo-Installation) — **oder** separater `clusev-ops`-Container mit gehärtetem Socket-Proxy (kein sudo, aber Socket wieder im Spiel)? Empfehlung: systemd. 2. **Private-IP-Realität:** Ist `10.10.90.136` final/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. 3. **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? 4. **cosign-Signierung zuerst:** Owner bestätigt, dass CI Images keyless signiert, bevor Self-Update ausgeliefert wird (sonst Verify = Theater, Update muss fail-closed blocken). 5. **Image-Referenz-Variable:** Umstieg von `IMAGE_TAG=latest` auf eine Digest-gepinnte `CLUSEV_IMAGE` in Prod-Compose — bricht keinen Dev-Flow, muss aber bewusst durchgewunken werden. 6. **`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?