From 37684337923690c80925642d4ca28a446e1f0d2d Mon Sep 17 00:00:00 2001 From: nexxo Date: Thu, 30 Jul 2026 10:11:26 +0200 Subject: [PATCH] Design a test/live switch and a page that says what is missing Co-Authored-By: Claude Opus 5 --- ...bsmodus-und-betriebsbereitschaft-design.md | 382 ++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md diff --git a/docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md b/docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md new file mode 100644 index 0000000..5e82941 --- /dev/null +++ b/docs/superpowers/specs/2026-07-30-betriebsmodus-und-betriebsbereitschaft-design.md @@ -0,0 +1,382 @@ +# Spec — Betriebsmodus (Test/Live) und Betriebsbereitschaft + +**Datum:** 2026-07-30 +**Status:** entworfen, noch nicht umgesetzt +**Vorgänger-Kontext:** `docs/handoffs/2026-07-30-real-run-handoff.md` §4 + +--- + +## 1. Ziel + +Zwei Dinge, die zusammengehören: + +**A — Betriebsmodus.** Ein Schalter in der Konsole, `test` oder `live`. Jedes +Zugangsdatum im Tresor bekommt zwei Plätze; der aktive Modus entscheidet, +welcher gilt. Ist der Testplatz leer, gilt der Live-Platz — mit **einer** +Ausnahme, siehe §4. + +**B — Betriebsbereitschaft.** Eine Seite, die jedes Pflichtfeld dieser +Installation aufführt, seinen Zustand zeigt und sagt, **was kaputtgeht, wenn +es fehlt**. Der Zweck ist genau einer: den Liveserver vollständig befüllen zu +können, bevor der erste Host onboardet wird — statt es nach der Zahlung eines +Kunden zu erfahren. + +### Nicht Teil dieses Vorhabens + +- **Testdaten-Trennung** (eigene Belegserie, Markierung, Ausschluss aus dem + Umsatz, Aufräumen inkl. VM und DNS). Eigener Entwurf, hängt an diesem hier. + Beschlossen, aber getrennt — dort wird auf einem Host eine VM zerstört, und + das braucht eigene Sorgfalt. Siehe §9. +- Zwei Plätze für die **einfachen Einstellungen** (DNS-Zone, WireGuard-Endpunkt, + Traefik-Pfad). Das sind keine Zugangsdaten, und eine getrennte Testzone wurde + bewusst nicht gewählt. +- Blöcke A–D aus dem Handoff (goldene Vorlage, `vmbr0`, Traefik als Systemdienst). + +--- + +## 2. Ausgangsbefund — gemessen, nicht gelesen + +Alles hier am 2026-07-30 auf dem Entwicklungsserver nachgemessen. + +| Befund | Beleg | +|---|---| +| Der Tresor hat **einen** Platz je Zugangsdatum | `SecretVault::REGISTRY`, fünf Einträge, je ein `config`-Schlüssel | +| Der hinterlegte Stripe-Schlüssel ist ein **Testschlüssel** | Präfix `sk_test_` | +| `STRIPE_WEBHOOK_SECRET` liegt bewusst **nicht** im Tresor | `SecretVault` Kopfkommentar; `StripeWebhookController:23` liest `config()` | +| Es gibt **keine** Bereitschaftsprüfung | Einziges Vorbild: `CompanyProfile::missingForInvoicing()`, benutzt in `Finance` und `NewInvoice` | +| `mail.default` steht auf `log` | Zugangsdaten-Mail landet in der Datei, nicht beim Kunden | +| Zwei Hosts stehen `active` **ohne** `api_token_ref` | `public_ip` `203.0.113.11`/`.21` — RFC 5737, Dokumentationsbereich | +| `CLUPILOT_WG_ENDPOINT` war eine private Adresse | `10.10.90.185:51820` | +| Kein Worker meldet, dass er lebt | Weder Zeitplaner noch `queue-provisioning` hinterlassen eine Spur | + +Die letzten vier sind der Grund für diese Spec. Jeder davon ist in der Konsole +heute **unsichtbar**, und jeder bricht die Kette an einer anderen Stelle — +zwei davon erst nach der Zahlung. + +--- + +## 3. Der Schalter + +**Speicherort:** `App\Support\Settings`, Schlüssel `platform.mode`, Werte `test` +oder `live`. + +Nicht die `.env`. `Settings` ist zwischengespeichert und verwirft den +Zwischenspeicher beim Schreiben — ein Umlegen wirkt sofort, auch in den +Warteschlangen-Prozessen, die seit Stunden laufen. Eine `.env`-Variable bräuchte +einen Containerneustart; genau diese Falle hat am 2026-07-30 die korrigierte +DNS-Zone acht Stunden lang wirkungslos gelassen. + +**Gelesen über** `App\Support\OperatingMode::current(): Mode`, damit nirgends +sonst im Code ein String verglichen wird. `Mode` ist ein Enum mit `Test` und +`Live`. + +**Vorgabe:** `live`. Eine Installation, die nichts gespeichert hat, soll nicht +in einem Modus stehen, der stillschweigend andere Zugangsdaten benutzt. + +**Wer darf umlegen:** `secrets.manage` (Inhaber) **und** bestätigtes Passwort — +dieselbe Sperre wie der Tresor, weil der Wechsel auf Live der Moment ist, ab dem +echtes Geld fließt. Bestätigung im Modal nach **R23**, nie `wire:confirm`. + +**Wo:** Integrationsseite, Reiter `services`, **über** allen Zugangsdaten — er +regiert sie. Zusätzlich eine Plakette im Kopf der Konsole (`layouts.admin`), +solange der Modus `test` ist. „Bin ich im Test?" darf keine Frage sein, die man +durch Navigieren beantwortet. + +--- + +## 4. Zwei Plätze je Zugangsdatum + +Die kuratierte Liste bleibt, wie sie ist — der Tresor bleibt ein Tresor und kein +Schlüssel/Wert-Speicher. Nur die **Zeile** wird zweigeteilt: + +``` +stripe.secret:test stripe.secret:live +dns.token:test dns.token:live +monitoring.token:test monitoring.token:live +inbound_mail.password:test inbound_mail.password:live +ssh.private_key:test ssh.private_key:live +``` + +### Auflösungsregel + +`SecretVault::get($key)` löst in dieser Reihenfolge auf: + +1. Zeile des **aktiven Modus** +2. leer **und** der Eintrag erlaubt Rückfall → Zeile `live` +3. immer noch leer → `config()`, also `.env` — wie heute + +Schritt 2 ist die Regel des Inhabers: für DNS, Monitoring, Postfach und SSH gibt +es nur ein Konto, und zwei Felder mit zwingend gleichem Inhalt wären eine +Attrappe. + +Schritt 3 bleibt **einwertig und modusfrei**. Die `.env` ist der Weg für eine +Installation, in der noch gar nichts gespeichert ist; ein zweiter Satz +Variablen dort würde den Anfangszustand verdoppeln, den es nur einmal gibt. + +### Stripe ist ausgenommen — in beide Richtungen + +Ein Merkmal `'strict' => true` in `REGISTRY`, das **nur** `stripe.secret` trägt. + +Bedeutung: kein Rückfall, weder von Test auf Live noch umgekehrt. Fehlt der +Schlüssel des aktiven Modus, liefert `get()` `null`. + +Ohne diese Ausnahme führte die Rückfallregel dazu, dass ein Testkauf bei +fehlendem Testschlüssel still den **Live-Schlüssel** benutzt und **echtes Geld** +abbucht, während die Konsole „Testbetrieb aktiv" anzeigt. Das ist genau die +Klasse von stillem Fehler, gegen die der ganze erste Durchlauf gebaut ist. + +**Folge für die Aufrufer:** `HttpStripeClient::secret()` wirft eine sprechende +Ausnahme statt einen Aufruf ohne Token abzusetzen. Die Kasse fragt vorher die +Bereitschaftsprüfung und weist die Bestellung mit lesbarem Text ab (§6). + +### Der Webhook-Schlüssel + +Bleibt in der `.env`. Seine Begründung gilt unverändert: er wird bei **jedem** +eingehenden Zahlungsereignis gelesen, und ein Datenbankproblem würde die +Signaturprüfung still fehlschlagen lassen. + +Zwei Variablen, ausgewählt nach Modus: + +| Modus | Variable | +|---|---| +| `live` | `STRIPE_WEBHOOK_SECRET` | +| `test` | `STRIPE_WEBHOOK_SECRET_TEST` | + +**Der Sonderfall ist bedacht.** Die Auswahl liest den Modus aus der Datenbank — +also aus genau der Quelle, die hier eigentlich vermieden werden sollte. Ist sie +nicht erreichbar, liefert `Settings::get()` seinen Vorgabewert `live` und +schreibt eine Warnung. Die Auswahl fällt damit auf den **Live**-Schlüssel, und +das ist die sichere Richtung: ein echtes Zahlungsereignis wird weiter korrekt +geprüft, ein Testereignis scheitert laut. Der umgekehrte Vorgabewert wäre der +gefährliche. + +### Migration — sie rät nicht + +- Jede bestehende Tresor-Zeile wandert auf `:live`. +- **Ausnahme `stripe.secret`:** einsortiert am Präfix. `sk_test_` oder + `rk_test_` → `:test`, alles andere → `:live`. +- **Der Anfangsmodus folgt daraus**, statt aus der Vorgabe: landet der + vorhandene Stripe-Schlüssel im Testplatz, war die Installation erkennbar im + Testbetrieb, und `platform.mode` wird auf `test` gesetzt. Ist gar kein + Stripe-Schlüssel da, bleibt es bei `live`. + +Auf dem Entwicklungsserver heißt das: der laufende Aufbau bleibt heil, ohne dass +jemand von Hand nachsortiert. + +--- + +## 5. Betriebsbereitschaft + +`App\Support\Readiness` — nach dem Muster von `CompanyProfile::missingForInvoicing()`, +nur über alle Bereiche und mit mehr als einem Feldnamen je Eintrag. + +Jede Prüfung liefert: Schlüssel, Beschriftung, **Gruppe** (was sie blockiert), +Schweregrad (`blocking` oder `warning`), einen Satz dazu, was kaputtgeht, und +den Reiter, der sie behebt. + +Gruppiert nach dem, was sie blockiert — **nicht** danach, wo der Wert +gespeichert ist. Ein Betreiber, der eine Installation befüllt, denkt in „was +kann ich noch nicht", nicht in „liegt das im Tresor oder in den Einstellungen". + +### 5.1 Abrechnung + +| Prüfung | Grad | Bricht | +|---|---|---| +| Stripe-Schlüssel des aktiven Modus | blocking | Kein Auftrag entsteht | +| Webhook-Schlüssel des aktiven Modus | blocking | Zahlung wird nie verbucht | +| Firmendaten (`missingForInvoicing()` aufrufen, nicht verdoppeln) | blocking | `IssueInvoice` verweigert, Kunde bekommt keinen Beleg | +| Rechnungsserie je Belegart vorhanden | blocking | Beleg kann keine Nummer ziehen | +| Jede veröffentlichte Paketversion hat beide Stripe-Preise | blocking | Geprüfter EU-Firmenkunde kann nicht bestellen | + +Der **Steuersatz** ist bewusst keine Prüfung: `CompanyProfile::taxRate()` hat +einen `.env`-Rückfall und ist nie leer. Die **Einrichtungsgebühr** ebenso nicht: +`0` ist ein gültiger Zustand, bei dem der Satz von der Preisseite verschwindet. + +### 5.2 Host-Onboarding + +| Prüfung | Grad | Bricht | +|---|---|---| +| `ssh.private_key` | blocking | `EstablishSshTrust`, phpseclib-Parserfehler | +| `SECRETS_KEY` | blocking | Jeder Proxmox-Token unlesbar, keine Bereitstellung | +| `VPN_CONFIG_KEY` | blocking | WireGuard-Konfiguration nicht lesbar | +| `CLUPILOT_WG_HUB_PUBKEY`, `_ENDPOINT`, `_SUBNET` | blocking | Onboarding scheitert am Handshake | +| Mindestens ein Rechenzentrum | blocking | `ValidateHostInput` kommt nicht durch | + +### 5.3 Bereitstellung + +| Prüfung | Grad | Bricht | +|---|---|---| +| `dns.token` | blocking | A-Record entsteht nicht, Kunde hat keine Adresse | +| DNS-Zone gesetzt | blocking | Name wird in der falschen Zone gesucht | +| Traefik-Pfad, dnsmasq-Verzeichnis | warning | Vorgabe greift, aber ungeprüft | +| **Mindestens ein aktiver Host mit lesbarem `api_token_ref`** | blocking | Auftrag reserviert einen Host, den es nicht gibt — **nach** der Zahlung | +| Jede veröffentlichte Version zeigt auf eine Vorlage, die auf mindestens einem aktiven Host existiert | blocking | `CloneVirtualMachine` stirbt nach der Zahlung | +| `monitoring.token` | warning | Überwachung fehlt, Bereitstellung läuft | + +Die vierte Zeile fängt genau die zwei Phantom-Hosts aus §2: `active`, aber ohne +Token. Sie sind heute in der Konsole von einem echten Host nicht zu +unterscheiden. + +### 5.4 Zustellung + +| Prüfung | Grad | Bricht | +|---|---|---| +| Mailversand geht **nicht** an `log` | blocking | VM läuft, Kunde erfährt nie davon | +| Mindestens ein Postfach | blocking | Kein Absender | +| Mail-Vorlagen vorhanden | warning | Fallback-Text statt gestalteter Mail | +| `inbound_mail.password` | warning | Nur eingehende Post betroffen | + +### 5.5 Betrieb — Herzschläge + +Zwei Prüfungen, die es heute nirgends gibt und die neu entstehen müssen: + +- **Zeitplaner:** eine Aufgabe je Minute schreibt `Settings`-Schlüssel + `heartbeat.scheduler` mit Zeitstempel. +- **Bereitstellungs-Worker:** der Zeitplaner stellt einen winzigen Auftrag in + die `provisioning`-Warteschlange, der `Settings`-Schlüssel + `heartbeat.queue_provisioning` schreibt. Das beweist den **Worker**, nicht nur + den Zeitplaner — die beiden fallen getrennt aus. + +Beide in `Settings`, nicht im Zwischenspeicher: ein Herzschlag, der mit dem +Redis-Neustart verschwindet, meldet einen Ausfall, den es nicht gab. + +Die Seite meldet einen Herzschlag als veraltet, wenn er älter als fünf Minuten +ist. Damit wird „ohne Worker passiert schlicht nichts, ohne Fehlermeldung" +sichtbar. + +### 5.6 Prüfungen mit Knopf + +Drei Einträge sind keine Feldabfragen, sondern echte Prüfungen — nach dem +Vorbild von `StripeCheck`, das schon existiert und in `REGISTRY` als `check` +eingetragen ist: + +- **`DnsTokenCheck`** — fragt die Hetzner-API, ob der Token die konfigurierte + Zone sieht **und beschreiben darf**. Ein Leserecht-Token sieht in der Konsole + identisch aus wie ein Schreibrecht-Token; das ist der Unterschied zwischen + „gesetzt" und „funktioniert". +- **`WireguardEndpointCheck`** — ein UDP-Port lässt sich von außen nicht sauber + anklopfen, aber ob dort eine private Adresse (RFC 1918) oder eine + Dokumentationsadresse (RFC 5737) steht, ist entscheidbar. Fängt + `10.10.90.185:51820`. +- **`VmTemplateCheck`** — dieselbe Frage, die `VerifyVmTemplate` stellt, nur vor + dem Kauf statt danach. + +### 5.7 Darstellung + +Eigene Seite `App\Livewire\Admin\Readiness`, Route `/admin/readiness`, in der +Navigation. Auf der Übersicht ein Hinweis, sobald etwas Blockierendes offen ist. + +Oben die eine Zeile, auf die es ankommt: **Bereit für Testbetrieb** bzw. +**Bereit für Livebetrieb** — welche Schlüssel Pflicht sind, hängt am Modus. + +Jeder Eintrag verlinkt auf den Reiter, der ihn behebt. Icons nach **R18** +(`size-4` in Tabellen, einzeilig). + +### 5.8 Die Seite ist kein Tor + +Sie berichtet. Die harten Sperren bleiben dort, wo sie hingehören und heute +schon stehen: + +- `IssueInvoice` verweigert bei unvollständigen Firmendaten +- `VerifyVmTemplate` verweigert einen Host ohne Vorlage + +Dazu kommt **genau eine neue**: die Kasse nimmt keine Bestellung an, wenn dem +aktiven Modus der Stripe-Schlüssel fehlt. + +Eine Bereitschaftsseite, die selbst sperrt, wäre eine zweite Wahrheit neben den +Prüfungen im Ablauf — und zwei Quellen für eine Frage sind der Weg, auf dem sie +auseinanderlaufen. + +--- + +## 6. Betroffene Stellen + +| Datei | Änderung | +|---|---| +| `app/Support/OperatingMode.php` | **neu** — Enum + `current()` | +| `app/Support/Readiness.php` | **neu** — die Prüfungsliste | +| `app/Services/Secrets/SecretVault.php` | Zeilenschlüssel je Modus, `strict`-Merkmal, Auflösungsregel | +| `app/Services/Stripe/HttpStripeClient.php` | `secret()` wirft bei fehlendem Schlüssel des aktiven Modus | +| `app/Http/Controllers/StripeWebhookController.php` | Webhook-Schlüssel nach Modus | +| `app/Livewire/Admin/Integrations.php` | Umschalter, zwei Felder je Eintrag | +| `app/Livewire/Admin/Readiness.php` | **neu** | +| `app/Livewire/Admin/ConfirmSwitchMode.php` | **neu** — Bestätigung nach R23 | +| `app/Livewire/Admin/Overview.php` | Hinweis bei blockierenden Lücken | +| `app/Services/Dns/DnsTokenCheck.php` | **neu** | +| `app/Services/Vpn/WireguardEndpointCheck.php` | **neu** | +| `app/Services/Proxmox/VmTemplateCheck.php` | **neu** | +| `routes/`, `layouts.admin` | Route, Navigationseintrag, Modus-Plakette | +| Migration | Zeilenschlüssel umstellen, Stripe am Präfix einsortieren, Anfangsmodus setzen | +| Zeitplaner | zwei Herzschläge | + +--- + +## 7. Tests + +Nach dem Muster, das dieses Repo für seine Regeln benutzt (`IconLayoutTest`, +`ConfirmInModalTest`, `DisplayTimezoneTest`): die Regel wird **per Test +erzwungen**, nicht per Merksatz. + +1. **Je Prüfung ein Test in beide Richtungen** — meldet fehlend, wenn es fehlt; + meldet erfüllt, wenn es da ist. +2. **Jeder `REGISTRY`-Eintrag muss auf der Bereitschaftsseite auftauchen.** + Damit kann ein künftig hinzugefügtes Zugangsdatum nicht still an der + Übersicht vorbeigehen. +3. **Rückfall Test → Live** greift für die nicht-strikten Einträge. +4. **Stripe fällt nicht zurück**, in beide Richtungen geprüft — der wichtigste + Test dieser Spec, weil sein Ausfall echtes Geld kostet. +5. **Die Kasse weist ab**, wenn dem aktiven Modus der Stripe-Schlüssel fehlt. +6. **Migration:** ein `sk_test_` landet im Testplatz und setzt den Modus auf + `test`; ein `sk_live_` landet im Live-Platz und lässt `live` stehen. +7. **Webhook-Auswahl:** bei nicht erreichbaren Einstellungen wird der + Live-Schlüssel benutzt, nicht der Test-Schlüssel. +8. **Kein `wire:confirm`** im neuen Modal (R23 gilt schon repoweit). + +--- + +## 8. Reihenfolge + +1. `OperatingMode` + Migration + `SecretVault`-Auflösung (Kern, ohne Oberfläche) +2. Stripe-Ausnahme + Kassensperre + Webhook-Auswahl +3. `Readiness` mit den reinen Feldprüfungen +4. Die drei Prüfungen mit Knopf +5. Herzschläge +6. Oberfläche: Umschalter, Bereitschaftsseite, Plakette, Übersichts-Hinweis + +Schritt 1–2 ist der Teil, der falsch sein kann und Geld kostet. Schritt 3–6 ist +Fleißarbeit. + +--- + +## 9. Risiken + +- **Der Rückfall ist bequem und gefährlich.** Für vier von fünf Einträgen + richtig, für Stripe tödlich. Die `strict`-Ausnahme ist die einzige Zeile + dieser Spec, deren Ausfall echtes Geld kostet — sie gehört in den Test, nicht + in einen Kommentar. +- **Der Modus in der Datenbank** ist eine neue Abhängigkeit für die + Webhook-Prüfung, die bisher bewusst keine hatte. Entschärft durch den + Vorgabewert `live` bei Nichterreichbarkeit (§4), aber es bleibt eine + Abhängigkeit, die vorher nicht da war. +- **Die Bereitschaftsseite kann Vollständigkeit vortäuschen.** Sie prüft, was + jemand aufgeschrieben hat. Was niemand aufschreibt, bleibt unsichtbar — und + genau das war der Befund vom 2026-07-30: eine grüne Testsuite bei einer + Installation, die keinen Host durchinstallieren konnte. Deshalb Test 2, der + wenigstens für Zugangsdaten erzwingt, dass nichts vergessen wird. + +--- + +## 10. Folgepunkte + +- **Testdaten-Trennung** (Teil C, beschlossen): Markierung an Auftrag, Vertrag, + Beleg, Instanz und Kunde; eigene Rechnungsserie je Belegart für den + Testbetrieb, damit die lückenlose `RE`-Serie sauber bleibt und Testbelege + löschbar sind; Ausschluss aus Umsatz, Dashboard und Finanzansicht; ein + Aufräumen, das die ganze Kette entfernt — **einschließlich der VMs auf dem + Host und ihrer DNS-Einträge**. `EndInstanceService` nimmt Route und Eintrag + weg, rührt die VM aber ausdrücklich nicht an; `ProxmoxClient::deleteVm()` + existiert, der Aufrufer fehlt. +- Die zwei Phantom-Hosts (`pve-fsn-1`, `pve-hel-1`) aus dem Pool nehmen. Die + Bereitschaftsseite macht sie sichtbar, entfernt sie aber nicht. +- Idempotenz-Schlüssel im Stripe-Abgleich deckt die Metadaten nicht ab — + eigener Folgepunkt, am 2026-07-30 als Aufgabe abgelegt.