clusev/docs/install-update-design.md

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: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://<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/*) 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/<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_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/<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:

  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>, Digest nach Pull erneut prüfen (Tag-Race-Schutz).
    • Snapshot: aktuellen Digest aller Services + mariadb-dump nach ./backups/pre-update-<ts>.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.phpmust_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.ymlports: von app/reverb entfernen; Caddy als einziger Host-Publisher; Image-Ref auf CLUSEV_IMAGE-Digest-Var umstellen; OWNER+:latest-Default raus.
  • .env.exampleAPP_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?