diff --git a/docs/superpowers/specs/2026-08-04-host-step-automatik-design.md b/docs/superpowers/specs/2026-08-04-host-step-automatik-design.md new file mode 100644 index 0000000..844d7f0 --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-host-step-automatik-design.md @@ -0,0 +1,164 @@ +# Der Helfer aktualisiert sich selbst — Entwurf + +**Stand:** 2026-08-04 +**Zustand:** Vom Betreiber freigegeben. Der Entscheidungsvermerk unten ist +ausdrücklich getroffen, nicht als Nebenwirkung entstanden. + +**Reihenfolge:** Wird **nach** der Hostnamen-Trennung gebaut +(`2026-08-04-hostnamen-trennung-design.md`). Eigene Grenze, eigenes Release. + +## Worum es geht + +Erweitert ein Update `clupilot-host-step`, muss der Betreiber auf **jedem** +Server von Hand `sudo bash /opt/clupilot/deploy/install-agent.sh` ausführen. Bei +zehn Servern zehnmal. Bis dahin läuft der neue Schritt still ins Leere — der +Helfer kennt ihn nicht, und `update.sh` kann nur einen Hinweis drucken. + +Gewünscht ist Automatik. + +## Warum es heute Handarbeit ist + +Das ist kein Versäumnis, sondern eine gezogene Grenze. Ein Update läuft als +Dienstkonto `clupilot`. Dieses Konto **besitzt den Checkout** — es holt und +checkt aus. `install-agent.sh` schreibt `sudoers`. Also: + +> *„The obvious fix is to let the service account run THIS script under sudo. +> That would be handing it root outright: it owns the checkout, so it can +> rewrite the very script it is allowed to run privileged. A grant is only +> worth anything if the holder cannot change what it grants."* +> — [`deploy/install-agent.sh:29`](../../../deploy/install-agent.sh) + +Deshalb steht der privilegierte Teil **außerhalb** des Checkouts, unter +`/usr/local/sbin/clupilot-host-step`, aus einem gequoteten Here-Dokument +geschrieben. Was das Dienstkonto als root darf, sind zwei wörtliche +Befehlszeilen in `sudoers.d/clupilot-host-step` — `ensure-rsync` und +`apply-proxy-hosts`. `install-server-terminal` steht bewusst nicht darin, weil +es nach `/root/.ssh/authorized_keys` schreibt. + +`tests/Feature/HostStepTest.php` hält das fest, indem es den Helfer wirklich +ausführt und prüft, was er verweigert. + +## Der Befund + +Ein root-Dienst, der `/opt/clupilot/deploy/install-agent.sh` startet, liest eine +Datei, die dem Dienstkonto gehört. Er verschiebt damit nur, wer den Startknopf +drückt — das Dienstkonto könnte sich weiterhin selbst root geben, **ohne je ein +Release zu veröffentlichen.** Das ist ein größerer Sprengradius als der, um den +es hier geht. + +Verschärfend: `update.sh` prüft **keine Tag-Signatur**. Der Tag kommt per +`git fetch --tags --force origin` ([`update.sh:202`](../../../deploy/update.sh)), +ein `git verify-tag` gibt es nirgends. „Aus dem getaggten Stand" trägt für sich +allein also nichts — auch die Tag-Objekte liegen in einem `.git`, das dem +Dienstkonto gehört. + +## Der Entscheidungsvermerk + +**Bewusst getroffen, am 2026-08-04, vom Betreiber:** + +> Wer ein Release veröffentlichen kann, kann damit die `sudoers` aller Server +> ändern und sich auf jedem davon root verschaffen. + +Das ist der Preis der Automatik und wird bezahlt. Die Veröffentlichungsrechte am +Repository sind ab dieser Änderung **root-Rechte auf der gesamten +installierten Basis**. Wer sie vergibt, vergibt beides. + +**Nicht mit entschieden, und ausdrücklich ausgeschlossen:** Das Dienstkonto +`clupilot` bekommt dadurch **nichts** dazu. Die Grenze aus `install-agent.sh` +und `HostStepTest` bleibt in Kraft — es gibt weiterhin keine sudo-Freigabe auf +`install-agent.sh`, und der neue Weg führt an keiner Stelle durch Inhalt, den +das Dienstkonto beeinflussen kann. + +Genau daraus folgt die Bauform unten. Ohne sie wäre der Vermerk falsch. + +## Was entschieden ist + +| Frage | Entscheidung | +|---|---| +| Wer führt aus | Ein eigener root-Dienst, ein einziger Zweck | +| Woher der Code | **Eigener root-eigener Spiegel**, direkt von `origin` | +| Welcher Stand | Der neueste `v*`-Tag auf `origin` | +| Was er tut | `install-agent.sh` ausführen. Keine Parameter, kein zweiter Zweck | +| Wann | Wenn der geforderte Vertrag über dem installierten liegt | + +### Warum ein eigener Spiegel + +Er ist der ganze Unterschied zwischen dem Vermerk oben und einer Aussage, die +nicht stimmt. Der Dienst holt **als root** von `origin` in ein Verzeichnis, das +nur root gehört (`/var/lib/clupilot-host-step/`, 0700), und führt +`install-agent.sh` von **dort** aus. Das Dienstkonto kann kein Byte davon +beeinflussen — weder den Arbeitsbaum, noch die Objektdatenbank, noch die Tags. + +Der Preis ist ein zweites Checkout auf jedem Server. Es ist flach und wird nur +gelesen. + +### Warum der neueste Tag, und nicht der ausgerollte + +Der ausgerollte Stand steht in einer Datei, die das Dienstkonto schreibt. Ihn zu +lesen hieße, dem Dienstkonto die Auswahl zu überlassen — und sei es nur zwischen +echten Releases. + +Der neueste `v*`-Tag ist dagegen eine Tatsache auf `origin`, die das Dienstkonto +nicht herstellen kann. Dass der Helfer damit dem Anwendungsstand **vorauslaufen** +kann, ist unschädlich: `CONTRACT` wächst nur, der Helfer wird nur erweitert, und +die Anwendung fragt ausschließlich `have >= needs`. Voraus ist die harmlose +Richtung; hinterher ist die, um die es hier geht. + +### Warum ein Zeitgeber und kein Anstoß aus der Anwendung + +Die Anwendung weiß bereits, wann der Helfer zu alt ist — `update.sh:753` und +`UpdateChannel` sagen es. Sie darf den Dienst trotzdem nicht mit einem Wert +anstoßen. + +Ein `.path`-Unit auf die Zustandsdatei des Updates ist als **Wecker** dagegen +unbedenklich, und es gibt ihn als Muster schon +([`install-agent.sh:445`](../../../deploy/install-agent.sh)): der Inhalt der +Datei wird nie gelesen, ihre Änderung weckt den Dienst nur. Das Dienstkonto kann +den Dienst also *laufen lassen* — aber nichts daran ändern, was er tut. Ein +Zeitgeber läuft als Rückfall daneben, damit die Automatik auch dann greift, wenn +die Datei ausbleibt. + +## Die Strecke + +1. **`install-agent.sh` legt den Spiegel an.** Root-eigenes Verzeichnis, flacher + Klon von `origin`. Die Remote-Adresse wird zu diesem Zeitpunkt **einmal** aus + dem vorhandenen Checkout übernommen und in eine root-eigene Konfiguration + geschrieben (0600, wegen des Gitea-Tokens darin). Danach wird sie nie wieder + von dort gelesen. Das ist dasselbe Vertrauen, das der Betreiber ohnehin + aufbringt, wenn er `install-agent.sh` startet — ein Mensch ist dabei. +2. **`clupilot-host-step-sync.service`**, root, `Type=oneshot`: fetchen, + neuesten `v*`-Tag bestimmen, dessen `HOST_STEP_NEEDS` gegen + `clupilot-host-step contract` halten, und nur bei „gefordert > installiert" + das `install-agent.sh` dieses Tags ausführen. Sonst nichts tun und das auch + sagen. +3. **`.path`-Wecker plus `.timer`-Rückfall**, beide wecken nur. +4. **Sichtbarkeit.** Der Dienst hinterlässt Zustand dort, wo die Konsole schon + nachsieht, damit „der Helfer ist zu alt" nicht mehr nur ein Satz in einem + Update-Protokoll ist. Anschlussstelle ist `UpdateChannel`. +5. **Tests** nach dem Muster von `HostStepTest`: den Dienst als Skript wirklich + laufen lassen und prüfen, was er **nicht** tut — kein Ausführen bei gleichem + Vertrag, kein Ausführen aus dem Anwendungs-Checkout, kein Rückwärtsgang. + Und die bestehende Behauptung, dass es keine sudo-Freigabe auf + `install-agent.sh` gibt, bleibt stehen. + +## Was das nicht löst + +**Der erste Handgriff bleibt.** Der Dienst wird von `install-agent.sh` +installiert, also muss der Betreiber es **einmal** auf jedem Server ausführen, +um die Automatik zu bekommen. Zehn Server, zehnmal, einmal. Danach nie wieder. +Es gibt keinen Weg daran vorbei, der die Grenze oben nicht wieder aufgäbe. + +**Ein Release, das den Dienst selbst kaputtmacht, heilt nicht von allein.** Er +erneuert sich über sich selbst. Fällt er aus, ist der Weg zurück wieder der +Handgriff. + +## Abnahme + +1. Ein Server mit altem Helfer zieht nach einem Release mit höherem + `HOST_STEP_NEEDS` von selbst nach — ohne Anmeldung auf der Maschine. +2. Bei gleichem Vertrag tut der Dienst nachweislich nichts. +3. Der Dienst führt nachweislich **nicht** aus `/opt/clupilot` aus. Eine + Veränderung dort ändert das Ergebnis nicht. +4. `HostStepTest` bleibt grün: weiterhin keine sudo-Freigabe auf + `install-agent.sh`. +5. Voller Testlauf grün. diff --git a/docs/superpowers/specs/2026-08-04-hostnamen-trennung-design.md b/docs/superpowers/specs/2026-08-04-hostnamen-trennung-design.md new file mode 100644 index 0000000..579071e --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-hostnamen-trennung-design.md @@ -0,0 +1,197 @@ +# Hostnamen-Trennung einschalten und festhalten — Entwurf + +**Stand:** 2026-08-04 +**Zustand:** Vom Betreiber freigegeben. Zuschnitt und Nachrüstweg für den +Bestand sind entschieden. + +## Worum es geht + +Gemeldet wurde: der Betreiber erreicht die Website unter `app.` und den Login +unter `www.`. Gemessen auf `main`: 116 Routen, davon 67 ohne Domain-Bindung. + +Gewünscht ist die Trennung nach Hostnamen: + +| Hostname | zeigt | +|---|---| +| `www.clupilot.com` | nur die öffentliche Website | +| `app.clupilot.com` | nur Anmeldung und Kundenportal | +| `status.clupilot.com` | nur die Statusseite | +| `admin.clupilot.com` | die Konsole | +| `files.clupilot.com` | Rechtsdokumente und Bootstrap-Archiv | + +## Der Befund, der den Zuschnitt bestimmt + +**Der Umbau ist bereits gebaut.** Dieselbe Messung mit gesetzten Hostnamen: + +``` +APP_HOST=app.… SITE_HOST=www.…,… STATUS_HOST=status.… FILES_HOST=files.… +→ 123 Routen, 12 ohne Domain +``` + +Diese zwölf sind bis auf zwei genau die Liste der begründeten Ausnahmen: + +| Route | Warum ungebunden | +|---|---| +| `livewire/update`, `livewire/upload-file`, `livewire/preview-file/{filename}`, `livewire/livewire.js`, `livewire/livewire.min.js.map` | Konsole und Portal teilen den Endpunkt — `RestrictAdminHost::SHARED` | +| `up` | Gesundheitsprüfung, ebenda | +| `broadcasting/auth` | Der Live-Feed der Konsole meldet sich hier an, ebenda | +| `webhooks/stripe` | Stripe postet an die eine URL, die es bekommen hat | +| `/` (`portal.home`) | „/" auf einem dritten Namen ist kein 404 wert | +| `status` (`status.elsewhere`) | Weiterleitung auf den Statushost, für vorhandene Lesezeichen | +| **`storage/{path}` GET + PUT** | **von niemandem begründet** | + +Die drei benannten Fallen sind bereits entschärft, nachgemessen statt vermutet: + +- **Fortify.** `config/fortify.php:91` setzt `'domain' => env('APP_HOST')`. Alle + 24 Auth-Routen — `login`, `forgot-password`, `reset-password/{token}`, + `two-factor-challenge`, `user/confirm-password`, `email/verify/{id}/{hash}` — + liegen gebunden auf `app.`. +- **`livewire/update`.** Ungebunden, `RestrictAdminHost` unangetastet. +- **`PublicSiteGate`.** `bootstrap.archive` und `files.public` existieren unter + `files.` weiter, unter denselben Namen. + +`route:cache` läuft mit gesetzten Hostnamen sauber durch. Geprüft. + +**Die Trennung wird nur nie eingeschaltet.** In der `.env` der laufenden +Maschine stehen genau zwei Host-Zeilen: `ADMIN_HOSTS` und +`ADMIN_HOST_EXCLUSIVE`. Kein `APP_HOST`, kein `SITE_HOST`, kein `STATUS_HOST`, +kein `FILES_HOST`. Mit leeren Werten laufen Website- und Portalgruppe +host-unabhängig und antworten überall — genau das gemeldete Verhalten. + +Der Installer ist der Grund. [`deploy/install.sh:111-118`](../../../deploy/install.sh) +**fragt** nach `APP_DOMAIN`, `WWW_DOMAIN`, `STATUS_DOMAIN` und schreibt davon +nur `APP_URL`. Dazu ein harter Fehler: + +``` +295: optional_env STATUS_HOST "$STATUS_DOMAIN" ← Aufruf +308: optional_env() { ← Definition +``` + +Bei `set -euo pipefail` (Zeile 18) ist das `command not found`, Exit 127 — +**jede Neuinstallation bricht dort ab.** `APP_HOST` und `SITE_HOST` schreibt das +Skript nirgends. + +## Was entschieden ist + +| Frage | Entscheidung | +|---|---| +| Routing umbauen? | Nein. Gemessen korrekt, kein Grund | +| Kriterium 5 | Als Test, gegen die **vollständige** Routentabelle | +| `storage/{path}` | Wird geschlossen (`'serve' => false`) | +| Neuinstallation | `install.sh` schreibt alle vier Hostnamen | +| Bestand | Artisan-Befehl trägt fehlende Schlüssel nach | + +## Die Strecke + +### 1. `tests/Feature/HostSeparationTest.php` + +Der Test bootet eine **zweite Anwendungsinstanz** mit allen vier Hostnamen und +misst deren vollständige Routentabelle. Verifiziert: reproduziert +`artisan route:list` exakt — 123 Routen, 12 ohne Domain. + +Das ist der Grund für den Umweg. Der vorhandene `routerWithHosts()`-Kniff aus +`PortalHostTest` lädt nur `routes/web.php` in einen frischen Router und hätte +`storage/{path}` **nie gesehen** — also genau den Befund nicht, um den es geht. +Ein Test, der die Hälfte der Tabelle misst, prüft Kriterium 5 nicht. + +Behauptet wird: + +- Jede Route hat eine Domain, außer **genau zehn** namentlich aufgezählten — + jede mit ihrer Begründung im Test selbst. Die Liste ist die Tabelle oben ohne + `storage/{path}`. +- Die Anmeldung liegt auf `app.`: `login`, `password.request`, `password.reset`, + `two-factor.login`, `password.confirm.store`, `verification.verify`, `logout`. + Das ist Kriterium 4 und hält `config/fortify.php:91` fest, damit die Zeile + niemandes Aufräumen zum Opfer fällt. +- `bootstrap.archive` und `files.public` heißen weiterhin so — die Namen, auf + die `PublicSiteGate::isDownload()` prüft. + +Kriterien 1–3 sind bereits behauptet — `PortalHostTest` für Portal und Website +(`www./dashboard` → 404, `app./` → Portal, Alias-Weiterleitungen), +`WelcomeTest:53` für den Statushost. Wird nicht verdoppelt. + +### 2. `storage/{path}` schließen + +`config/filesystems.php:36` — `'serve' => true` auf der `local`-Disk. Laravel +meldet dafür zwei host-unabhängige Routen an, die aus `storage/app/private` +ausliefern, auf **jedem** Hostnamen. + +Niemand benutzt sie. Die einzigen `temporaryUrl()`-Aufrufe im Repo +(`admin/finance.blade.php:78`, `settings.blade.php:332`) sind Livewire-Uploads +und erzeugen `livewire/preview-file/…`, keine `storage/…`-Adresse. Jede Datei, +die dieses Produkt ausliefert, geht durch einen eigenen Controller — +`PublicFileController`, `BootstrapArchiveController`, `invoices.pdf`, +`dpa.file`. → `'serve' => false`. + +### 3. `deploy/install.sh` + +- **Die Reihenfolge-Panne.** `optional_env()` wird vor seine erste Verwendung + gezogen. Ohne das stirbt jede Neuinstallation bei Exit 127. +- `set_env APP_HOST "$APP_DOMAIN"`, `set_env SITE_HOST "$WWW_DOMAIN"`, und ein + neues `ask FILES_DOMAIN` (leerlassbar wie `STATUS_DOMAIN`) mit + `optional_env FILES_HOST "$FILES_DOMAIN"`. Der bereits vorhandene + `optional_env STATUS_HOST` funktioniert dann. + +`ADMIN_HOST_EXCLUSIVE=false` bleibt. Das Skript begründet im eigenen Kommentar, +warum es aus startet — vor dem DNS-Eintrag eingeschaltet macht es die Konsole +unerreichbar. + +### 4. `clupilot:bind-hosts {--dry-run}` + +Der Installer schreibt `.env` nur bei einer **neuen** Installation („Keeping the +existing .env"). Die laufende Maschine bekommt die vier Zeilen also nie. + +Auf `App\Services\Env\EnvFileEditor` — der bringt Zeilenvalidierung und eine +Zeitstempel-Sicherung des alten Stands mit, beides vor dem Schreiben. + +- Trägt **nur fehlende** Schlüssel nach. Ein gesetzter Wert wird nie + überschrieben: wer von Hand etwas eingestellt hat, behält es. +- `APP_HOST` wird aus dem Host von `APP_URL` vorgeschlagen; die übrigen kommen + als Option oder Nachfrage. +- Nennt vor dem Schreiben die Namen, die er binden will, und verlangt eine + Bestätigung. **Ein Hostname ohne DNS und ohne Eintrag im Reverse Proxy wird + durch das Binden unerreichbar** — dieselbe Aussperrung, vor der `AdminArea` + und `install.sh` an mehreren Stellen warnen. +- Meldet am Ende, was er gesetzt und was er stehen gelassen hat. + +### 5. `.env.example` + +`STATUS_HOST` steht dort **überhaupt nicht**, obwohl `install.sh` danach fragt +und `config/admin_access.php` es liest. Wird ergänzt. Der Block zwischen Zeile +218 und 239 ist außerdem verrutscht: der `FILES_HOST`-Kommentar steht mitten im +`APP_HOST`-Absatz, und der `SITE_HOST`-Absatz bricht mitten im Satz ab +(„Leer heisst: Startseite ueberall,"). Wird sortiert. + +## Was nicht angefasst wird + +`routes/web.php`, `RestrictAdminHost`, `config/fortify.php`, `PublicSiteGate`. +Gemessen, korrekt, kein Grund. + +## Abnahme + +| # | Kriterium | Wie geprüft | +|---|---|---| +| 1 | `www./dashboard` → 404 | `PortalHostTest:69`, vorhanden | +| 2 | `app./` → Anmeldung oder Portal | `PortalHostTest:91`, vorhanden | +| 3 | `status./` → Statusseite | `WelcomeTest:53`, vorhanden | +| 4 | Anmelden über `app.`, samt Passwort und Zwei-Faktor | `HostSeparationTest`, neu | +| 5 | Keine Route ohne Domain außer den begründeten | `HostSeparationTest`, neu | +| 6 | Voller Testlauf grün | — | + +## Beobachtet, nicht in diesem Zuschnitt + +Die drei deutschen Weiterleitungen in der `legal`-Gruppe +(`routes/web.php:298-300`) rufen kein `->name()` auf und erben damit den +Gruppenpräfix als vollständigen Namen: drei Routen heißen `legal.`. Ohne +Wirkung, weil niemand `route('legal.')` aufruft, und `route:cache` nimmt es hin. +Geparkt statt mitgenommen — es gehört nicht zur Hostnamen-Bindung und ein Diff, +der beides mischt, ist schlechter zu prüfen. + +## Außerhalb des Repos + +Nach dem Einschalten brauchen `www.` (und je nach Wahl `status.` / `files.`) +einen DNS-Eintrag und einen Block im Caddyfile, sonst ist die Website danach +schlicht nicht erreichbar. Portal auf `app.` und Konsole auf `admin.` laufen +unabhängig davon weiter; es ist also nicht gefährlich, aber es ist ein Schritt, +den das Repo nicht tun kann. Kommt in die Release-Notiz, und +`clupilot:bind-hosts` sagt es beim Ausführen.