From 295444f4323c6bfb600c0cb26870db7f35cc1455 Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 14:05:13 +0200 Subject: [PATCH] Zwei Entwuerfe: Hostnamen-Trennung, und der Helfer, der sich selbst nachzieht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Hostnamen-Trennung ist gebaut und wird nie eingeschaltet. Gemessen: mit gesetzten APP_HOST/SITE_HOST/STATUS_HOST/FILES_HOST bleiben von 123 Routen zwoelf ohne Domain, und davon sind zehn die begruendeten. Fortify haengt laengst an fortify.domain, livewire/* bleibt geteilt, PublicSiteGate behaelt seine Routennamen. Die .env der laufenden Maschine kennt die vier Schluessel nur nicht — weil install.sh nach den Namen fragt und die Antworten wegwirft, und dabei an einer Funktion stirbt, die erst dreizehn Zeilen spaeter definiert wird. Der zweite Entwurf haelt eine Entscheidung fest, bevor sie gebaut wird: wer ein Release veroeffentlichen kann, kann damit die sudoers aller Server aendern. Das ist der Preis dafuer, dass der root-Helfer sich selbst nachzieht, und er wird bewusst bezahlt. Das Dienstkonto bekommt dabei nichts dazu — deshalb ein root-eigener Spiegel und nicht der Checkout, den es besitzt. Co-Authored-By: Claude Opus 5 --- .../2026-08-04-host-step-automatik-design.md | 164 +++++++++++++++ .../2026-08-04-hostnamen-trennung-design.md | 197 ++++++++++++++++++ 2 files changed, 361 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-04-host-step-automatik-design.md create mode 100644 docs/superpowers/specs/2026-08-04-hostnamen-trennung-design.md 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.