215 lines
17 KiB
Markdown
215 lines
17 KiB
Markdown
# 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.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? |