17 KiB
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:
- Zeile des aktiven Modus
- leer und der Eintrag erlaubt Rückfall → Zeile
live - 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_oderrk_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.modewird auftestgesetzt. Ist gar kein Stripe-Schlüssel da, bleibt es beilive.
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üsselheartbeat.schedulermit Zeitstempel. - Bereitstellungs-Worker: der Zeitplaner stellt einen winzigen Auftrag in
die
provisioning-Warteschlange, derSettings-Schlüsselheartbeat.queue_provisioningschreibt. 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ängt10.10.90.185:51820.VmTemplateCheck— dieselbe Frage, dieVerifyVmTemplatestellt, 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:
IssueInvoiceverweigert bei unvollständigen FirmendatenVerifyVmTemplateverweigert 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.
- Je Prüfung ein Test in beide Richtungen — meldet fehlend, wenn es fehlt; meldet erfüllt, wenn es da ist.
- Jeder
REGISTRY-Eintrag muss auf der Bereitschaftsseite auftauchen. Damit kann ein künftig hinzugefügtes Zugangsdatum nicht still an der Übersicht vorbeigehen. - Rückfall Test → Live greift für die nicht-strikten Einträge.
- Stripe fällt nicht zurück, in beide Richtungen geprüft — der wichtigste Test dieser Spec, weil sein Ausfall echtes Geld kostet.
- Die Kasse weist ab, wenn dem aktiven Modus der Stripe-Schlüssel fehlt.
- Migration: ein
sk_test_landet im Testplatz und setzt den Modus auftest; einsk_live_landet im Live-Platz und lässtlivestehen. - Webhook-Auswahl: bei nicht erreichbaren Einstellungen wird der Live-Schlüssel benutzt, nicht der Test-Schlüssel.
- Kein
wire:confirmim neuen Modal (R23 gilt schon repoweit).
8. Reihenfolge
OperatingMode+ Migration +SecretVault-Auflösung (Kern, ohne Oberfläche)- Stripe-Ausnahme + Kassensperre + Webhook-Auswahl
Readinessmit den reinen Feldprüfungen- Die drei Prüfungen mit Knopf
- Herzschläge
- 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
livebei 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.EndInstanceServicenimmt 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.