Zwei Entwuerfe: Hostnamen-Trennung, und der Helfer, der sich selbst nachzieht

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 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 14:05:13 +02:00
parent 5fe33553ad
commit 295444f432
2 changed files with 361 additions and 0 deletions

View File

@ -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.

View File

@ -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 13 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.