# Handoff — vom grünen Test zum ersten echten Durchlauf **Stand:** 2026-07-30, `main` auf `v1.3.44`, **1686 Tests grün.** **Zweck:** In dieser Nacht wurden Host-Onboarding, VM-Erstellung und der Stripe-Prozess vollständig geprüft und der größte Teil der Funde behoben. Was bleibt, ist unten beschrieben. Die Kernaussage in einem Satz: **die Testsuite war grün, und trotzdem hätte kein Host durchinstalliert und keine VM ausgeliefert werden können.** --- ## 0. Die Lektion, die den ganzen Rest erklärt Alle Funde waren gegen eine grüne Suite unsichtbar, und zwar systematisch: - `HostOnboardingEndToEndTest` fährt die ganze Pipeline gegen `FakeRemoteShell`, dessen Standardantwort **Erfolg** ist. Der Test beweist, dass die Zustandsmaschine richtig durchzählt — über das Verhalten irgendeines Befehls sagt er nichts. - Rund ein Dutzend Tests schrieben in `config('provisioning.plans.*')`, einen Schlüssel, den es seit dem Katalog-Umbau nicht mehr gibt (und es existiert ein Test, der genau das behauptet: `expect(config('provisioning.plans'))->toBeNull()`). Jede dieser Zeilen war wirkungslos; die Tests waren aus dem falschen Grund grün. - Mehrere Tests prüften eine Spalte, die der Code eine Zeile vorher selbst geschrieben hatte. **Konsequenz für die nächste Session:** Ein Testlauf ist keine Abnahme. Jeder Arbeitsblock unten endet mit einem Schritt auf echter Hardware, nicht mit „grün". --- ## 1. Entscheidungen aus dem Brainstorming (verbindlich) | Frage | Entscheidung | |---|---| | Woher kommt die Software im Gast? | **Vorlage bringt Docker + Compose mit, Nextcloud installiert sich beim ersten Start** aus `NEXTCLOUD_ADMIN_*`, die die Pipeline in die `.env` schreibt. Vorlage bleibt kundenunabhängig, Passwörter werden nie in ein Image gebacken. | | Proxmox-Version | **PVE 9 auf Debian 13 trixie.** Die Codename-Tabelle in `InstallProxmoxVe` kennt beides; PVE 8/bookworm kann bleiben, getestet und unterstützt wird 9. | | Traefik | **Systemdienst (Binary + systemd-Unit), pro Host**, installiert *und geprüft* von einem neuen Pipeline-Schritt. Kein Docker auf dem Hypervisor — nftables bleibt einziger Eigentümer der Host-Firewallregeln. | | `vmbr0` | **Vollautomatisch**, mit Selbstrücknahme-Zeitgeber (siehe Block B). | | Testumgebung | **Ein dedizierter Server mit Debian 13**, neu aufsetzbar. Kein zweiter Rechner nötig. | | Testpaket | Eigene Paketfamilie **„Test"**: 20 GB Platte, 1 GB RAM, 1 Kern, 5 GB Kontingent. Nach dem Beweis wieder aus dem Verkauf nehmen. | | ACME | HTTP-01 pro Host. **Kein** Wildcard/DNS-01 — pro Kunde entsteht genau ein Zertifikat, unabhängig von der Host-Zahl. | **Nicht geeignet:** Hetzner Cloud (CPX/CX) — keine verschachtelte Virtualisierung, also `/dev/kvm` nicht vorhanden, also startet kein Gast. Unabhängig von der Größe. Bei netcup-VPS ist es produktabhängig; entscheidend ist `grep -cE 'vmx|svm' /proc/cpuinfo && ls -l /dev/kvm`. --- ## 2. Was heute Nacht behoben und ausgeliefert wurde Sechs Commits, `8440266` → `9da1358`, Tag `v1.3.44`. ### Stripe / Geld - **Nichts konnte ein Stripe-Abo beenden.** Kündigung und Widerruf schrieben nur lokale Zustände; Stripe erfuhr nie davon, und die nächste Abbuchung wurde **als echte Rechnung aus der lückenlosen Serie ausgestellt und versendet** — monatlich, unbegrenzt, für eine abgeschaltete Leistung. Jetzt: Kündigung zum Periodenende, Widerruf sofort, und eine Rechnung wird nur noch ausgestellt, wenn ihr Abrechnungszeitraum **vor** dem Vertragsende begann. - Kündigung lief auf **Monatstakt auch bei Jahresverträgen** (bis zu elf bezahlte Monate verloren). Jetzt aus `current_period_end`. - **Einrichtungsgebühr** wurde überall beworben und **nie berechnet** — jetzt eine Einmalposition am Checkout, brutto, auf der ersten Rechnung. - Das Kundenportal zeigte **fünf erfundene Rechnungen**, eine erfundene Ausgabenkurve und eine erfundene nächste Abbuchung, während die echten Belege existierten und nirgends im Portal auftauchten. Jetzt die echten. - **Widerruf erstattet voll** (Ihre Entscheidung), Stornorechnung für **jede** Abbuchung im Zeitfenster, nicht nur die erste. - Ein Absturz nach dem Auftrags-Commit ließ einen bezahlten Kauf **dauerhaft unfakturiert** — `resume()` holt das jetzt nach, idempotent. - Klick auf „Upgrade" löschte stillschweigend eine vorgemerkte Abstufung und tat sonst nichts. Behoben. - **Reverse-Charge-Kunden zahlten 20 % zu viel**: ein Stripe-Preis für alle, also Bruttobetrag auch für den, dem keine Steuer berechnet wird — und der Beleg wies den ganzen Betrag als Netto aus. Jetzt **zwei Preise je Paket** (brutto/netto), Auswahl an der Kasse über `TaxTreatment`. - **UID-Prüfung gegen VIES** gebaut (vorher setzte niemand `vat_id_verified_at`, Reverse Charge konnte also nie auslösen). Drei Ergebnisse: bei „Register nicht erreichbar" wird **nichts** geschrieben. ### Host-Onboarding - **Debian-Codename war fest auf `bookworm`** — ein heutiger Server hätte PVE-8-Quellen gegen Debian 13 bekommen. Jetzt aus `/etc/os-release`, unbekannte Releases brechen ab. - **WireGuard-Sackgasse:** Die Tunnel-IP wurde gespeichert, bevor der Tunnel bewiesen war; danach wählte *jeder* Verbindungsversuch inklusive Konsolen-Wiederholung diesen kaputten Tunnel, und der reparierende Code war unerreichbar. Rettung nur per Datenbank-Handarbeit. Jetzt gilt der Tunnel erst mit bewiesenem Handshake. - **wg0 hätte den Neustart nicht sicher überlebt** (`||`-Fallback verdeckte die fehlende systemd-Aktivierung); **der Neustart konnte eine nicht mehr bootende Maschine hinterlassen**; **`ConfigureProxmox` tat nichts** (kein `vmbr0`, Datacenter- Firewall aus → die „nur 80/443"-Regeln der Kunden-VMs waren **wirkungslos**); der Rolle fehlte **`Sys.Modify`** (jede Kundenbereitstellung wäre am Backup-Schritt gestorben); nftables verwarf **jedes ICMP** (IPv6 und PMTUD gebrochen); `api_token_ref` hing an `APP_KEY` statt am `SECRETS_KEY`. - Neu: **`VerifyVmTemplate`** — ein Host geht nicht mehr auf „aktiv", wenn die Vorlage fehlt. Vorher starb der erste bezahlte Auftrag *nach* der Zahlung. ### VM-Erstellung - **Das 14-tägige Kapazitätsparken war unerreichbar:** Abfrageintervall 120 s gegen Schritt-Zeitlimit 60 s, also lief jeder Wiedereinstieg in eine Zeitüberschreitung. Ein bezahlter Auftrag ohne freien Host war nach **sechs Minuten** ein Fehlerfall. Die ganze Warteschlange war toter Code. - **Jeder `occ`-Aufruf lief als root** statt `www-data`; Nextcloud verweigert das. Jetzt ein gemeinsamer Erbauer (`App\Support\NextcloudOcc`) plus ein Test, der jede neue `docker compose exec`-Stelle außerhalb davon verbietet. - Ein **einzelner zuckender HTTPS-Test verurteilte eine fertige Cloud** zum Fehlerfall. - Eine nachgewiesene eigene Domain wurde **nie geroutet**, wenn zufällig ein Neustart lief; gekaufter Speicher wurde **abgerechnet und nie geliefert**. Der „läuft schon etwas?"-Schutz fragt jetzt, ob der laufende Lauf die Arbeit **tatsächlich abdeckt**. - Die `address`-Pipeline lief in der falschen Reihenfolge (Zertifikat vor vertrauenswürdigem Hostnamen → gültiges Zertifikat auf Nextclouds „untrusted domain"-Fehlerseite). - Eine geänderte Gast-IP war **unreparierbar** (502 ohne Weg zurück). - Der Hetzner-DNS-Abgleich las **nur die erste Seite** → ab ~100 Einträgen ein **zweiter A-Record** für denselben Namen. --- ## 3. Was noch zu tun ist — vier Blöcke, in dieser Reihenfolge ### Block A — Goldene VM-Vorlage (kritischer Pfad, blockiert alles) Es gibt **keine** Vorlage auf `template_vmid 9000`. Ohne sie kann nichts ausgeliefert werden. Zu tun: - Debian-13-VM auf dem dedizierten Server, ~20 GB Platte, cloud-init-fähig (`qemu-guest-agent` **muss** drin sein — `WaitForGuestAgent` hängt daran). - Docker + Compose, `/opt/nextcloud/` mit `docker-compose.yml`, das `NEXTCLOUD_ADMIN_USER`/`NEXTCLOUD_ADMIN_PASSWORD`/`MYSQL_PASSWORD` aus `.env` liest und beim ersten Start selbst installiert. - **`user: www-data` am `app`-Dienst** — sonst schlagen alle `occ`-Aufrufe fehl. - Partition als **letzte** auf der Platte, **kein LVM** — `GrowGuestFilesystem` kann eine nicht-letzte Partition nicht vergrößern und erkennt LVM bewusst nicht (es würde sonst ein Kontingent über ein nicht gewachsenes Dateisystem schreiben). - Als Proxmox-Vorlage konvertieren, vmid 9000. - **Schriftliches Runbook dazu**, versioniert im Repo. Die Vorlage ist ab dann Ihr wichtigstes Artefakt und gehört nicht in mündliches Wissen. ### Block B — `vmbr0` vollautomatisch (riskantester Code des Vorhabens) Eigener Schritt, eigener Abnahmetest. Muster: 1. Aktuelle Netzkonfiguration sichern. 2. Systemd-Timer auf 5 Minuten, der sie zurückspielt und neu startet. 3. Brücke aktivieren. 4. **Von außen** prüfen, ob der Host antwortet. 5. Erst dann den Timer abbestellen. Der Schritt muss **erkennen**, was er vorfindet — DHCP auf einer Schnittstelle (Cloud) gegen geroutete Einzel-IP mit Gateway außerhalb des Subnetzes (Hetzner-Dedicated) gegen netcup. Eine Brücke, die für den einen Fall richtig ist, nimmt den anderen vom Netz. `ConfigureProxmox` bricht heute bewusst mit einer Anleitung ab; dieser Block ersetzt das. ### Block C — Traefik als Systemdienst Neuer Schritt zwischen `SecureHostFirewall` und `CompleteHostOnboarding`: - Binary installieren, systemd-Unit, statische Konfiguration mit `websecure`-Entrypoint und `letsencrypt`-certResolver, **deren Namen zu dem passen müssen, was `SshTraefikWriter::render()` schon ausgibt** — hier zuerst nachlesen, nicht raten. - Dynamisches Verzeichnis anlegen (`TRAEFIK_DYNAMIC_PATH`, Vorgabe `/etc/traefik/dynamic`), Dateiprovider darauf zeigen. - **Prüfen, nicht nur installieren:** Dienst läuft, 80/443 belegt, Ping-Endpunkt antwortet. Ein Host darf nicht „aktiv" werden, wenn das Routing nicht steht. - ACME braucht Port 80 **erreichbar** — die Firewall öffnet ihn, aber Traefik muss lauschen, bevor das erste Zertifikat angefordert wird. ### Block D — Kleinigkeiten und eine kaufmännische Entscheidung - **Rollen-Konvergenz auf bereits aktiven Hosts.** `Sys.Modify` erreicht sie nicht: ein abgeschlossenes Onboarding kann nicht wiederholt werden, ein Kommando fehlt. - **Traffic-Pakete (Ihre Entscheidung):** Der Auftrag ist einmalig, das Feld `traffic_addons` ist ein *dauerhafter* Zähler. Hochzählen = 1000 GB für immer aus einer Zahlung; nichts tun = der Kunde zahlte für nichts. Der Code protokolliert das derzeit laut, statt still eines von beiden zu tun. - `pveum acl modify … || true` und `pveum user add … || true` verdecken weiter Fehler — dieselbe Klasse wie der behobene `role add`. - `VerifyVmTemplate` prüft nur **Existenz**, nicht `template: 1`. - `applyFirewall()` setzt **nicht** `firewall=1` an `net0` der VM — ohne das greifen die Gastregeln trotz Datacenter-Firewall nicht. Prüfen, ob die Vorlage es mitbringt. - ~~`stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig (Plan-Seite schon): zwei Zeilen könnten einen Preis teilen, Archivieren würde den anderen mitentziehen.~~ **Erledigt** — eindeutig seit `2026_07_31_210000_one_row_per_stripe_price`; zugleich das Netz unter dem Wiedererkennungsschritt, siehe `docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md`. - `clupilot:end-due-services` schließt den **Vertrag** nicht — ein geschenkter Vertrag bleibt nach Ende der Instanz für immer `active` und zählt in Umsatz/Dashboard mit. - Eine Abbuchung, die **vor** dem Widerruf entstand und **danach** bezahlt wird, bekommt einen Beleg, wird aber nicht storniert oder erstattet — ohne Signal. - `Livewire\Admin\Provisioning::isStale()` erbt das neue 14-Tage-Budget: ein geparkter Lauf mit totem Worker wird zwei Wochen nicht als hängend gemeldet. --- ## 4. Was gesetzt sein muss, bevor ein echter Lauf Sinn hat Auf **diesem** Entwicklungsserver ist alles vorhanden; auf dem Live-Server ist es ungeprüft. Vor dem Durchlauf einzeln bestätigen — jedes fehlende Stück bricht die Kette an einer anderen Stelle, und mehrere brechen sie **nach** der Zahlung. **Geheimnisse (Vault, Konsole → Integrationen):** - `ssh.private_key` — ohne: `EstablishSshTrust` scheitert mit einem phpseclib-Parserfehler - `dns.token` — Hetzner-DNS-Token. Ohne: der A-Record der VM entsteht nicht, der Kunde hat keine Adresse. **Braucht Schreibrecht auf die Zone.** - `stripe.secret` und `STRIPE_WEBHOOK_SECRET` — ohne: kein Auftrag entsteht überhaupt - `monitoring.token` — Uptime-Kuma-Brücke, `RegisterMonitoring` - `SECRETS_KEY` — **seit heute Nacht auch für `hosts.api_token_ref`.** Fehlt er, ist jeder Host-Token unlesbar und keine Bereitstellung läuft. - `VPN_CONFIG_KEY` **Einstellungen:** - `CLUPILOT_DNS_ZONE` = **`clupilot.cloud`** (nicht `.com`). Vorgabe im Code ist richtig; auf diesem Server stand in der `.env` versehentlich `.com`. - `CLUPILOT_WG_HUB_PUBKEY`, `CLUPILOT_WG_ENDPOINT`, `CLUPILOT_WG_SUBNET` — und der **Hub muss laufen und auf 51820/udp erreichbar sein**. Die Hosts sind Initiatoren; der offene UDP-Port ist eine Anforderung an den Hub, nicht an die Hosts. - `CLUPILOT_DNS_HOSTS_DIR` — dnsmasq-Verzeichnis für die internen Hostnamen, geteiltes Volume mit dem `vpn-dns`-Container - `TRAEFIK_DYNAMIC_PATH` - **Firmendaten vollständig** (Name, Adresse, PLZ, Ort, UID) **und Steuersatz** — sonst verweigert `IssueInvoice`, und der Kunde bekommt keinen Beleg - **Mindestens ein Rechenzentrum** angelegt — `ValidateHostInput` verlangt es - **Postfächer** eingerichtet — sonst erreicht die Zugangsdaten-Mail den Kunden nicht **Betrieb:** - `queue`, `queue-provisioning`, `scheduler` und `reverb` laufen — die Pipeline ist warteschlangengetrieben, ohne Worker passiert schlicht nichts - Stripe-Katalog nach dem Update: `stripe:sync-catalogue` **und** `stripe:reprice-subscriptions` (je zuerst `--dry-run`). Der Abgleich legt jetzt **zwei** Preise je Paket an; bis er lief, kann ein geprüfter EU-Firmenkunde nicht bestellen — das ist Absicht. - `stripe:sweep-orphan-prices` listet jeden **aktiven wiederkehrenden** Stripe-Preis an unseren Produkten, den **keine** Zeile in `stripe_addon_prices` oder `stripe_plan_prices` kennt — mit ID, Betrag, Währung, Intervall und Metadaten. Einmalige Preise sieht es nicht; von uns angelegt ist nie einer davon, `createPrice()` schickt immer `recurring[interval]`. Ohne Argument wird nichts angefasst; mit `--archive` werden die aufgelisteten Preise bei Stripe stillgelegt, also **nicht mehr verkauft** (laufende Abos darauf verrechnet Stripe weiter). **Zuerst `stripe:sync-catalogue` laufen lassen:** das Kommando prüft nur, ob eine Zeile die ID kennt, nicht ob der Wiedererkennungsschritt die Waise übernommen hätte. Eine übernehmbare Waise vorher stillzulegen macht sie für den Abgleich unsichtbar (er fragt Stripe nur nach aktiven Preisen) — er fällt dann auf `createPrice()` mit demselben Idempotenz-Schlüssel zurück, den der abgebrochene Lauf benutzt hat, und Stripe antwortet 24 Stunden lang mit der gespeicherten Antwort: der ID des stillgelegten Preises. Danach steht im Register ein Preis, auf den Stripe keinen Checkout mehr öffnet. --- ## 5. Reihenfolge des ersten echten Durchlaufs 1. Testpaket „Test" in der Konsole anlegen (20 GB / 1 GB / 1 Kern / 5 GB) und veröffentlichen. 2. Abschnitt 4 durchgehen und **jedes** Stück bestätigen. 3. Debian 13 auf dem dedizierten Server, Host in der Konsole anlegen, onboarden. Erwartung nach Block B/C: läuft bis `active` durch. Ohne Block B bricht es bei `vmbr0` ab — das ist derzeit gewollt. 4. Vorlage bauen (Block A), `VerifyVmTemplate` muss sie finden. 5. Testpaket kaufen. Erwartung: 16 Schritte bis `CompleteProvisioning`, Nextcloud erreichbar unter `.clupilot.cloud` mit gültigem Zertifikat, Speicherkontingent greift, Zugangsdaten-Mail kommt an. 6. Eigene Domain hinterlegen, TXT-Nachweis, prüfen, dass sie **wirklich ausgeliefert** wird (nicht nur „nachgewiesen"). 7. Kündigen. Prüfen, dass Stripe es erfährt, die Leistung zum Periodenende endet, Route und DNS-Eintrag verschwinden. 8. Zweiten Kauf als Privatperson, innerhalb 14 Tagen widerrufen. Prüfen: volle Erstattung, Stornorechnung, Leistung endet. Jeder Schritt, der scheitert, ist ein Fund — und der Server darf dabei kaputtgehen, Debian ist neu eingespielt in Minuten. --- ## 6. Arbeitsweise in dieser Codebasis (für die neue Session) - **Eine zweite Claude-Session arbeitet im selben Arbeitsverzeichnis und im selben Git-Index.** In dieser Nacht hat ein einziges `git commit` ohne Pfadangabe deren vorgemerkte Dateien mitgenommen und dabei `VERSION` sowie zwei ihrer Dateien zurückgedreht. **Immer** `git add -- ` und `git commit -F - -- `, **nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`. - `pint` nur auf eigene Pfade, **nie** `--dirty` — das Repo ist unter der Standardvorgabe nicht durchgängig formatiert und würde fremde Dateien umschreiben. - Regeln R11–R24 in `CLAUDE.md` sind nicht verhandelbar und werden per Test erzwungen. R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken. - Tests laufen auf **SQLite im Speicher** (`phpunit.xml` erzwingt es), gleichzeitige Läufe kollidieren also nicht, und die Entwicklungsdatenbank wird nie angefasst. - Bricht die ganze Suite auf einmal zusammen, ist das fast immer eine Datei der anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man sucht. - Push: `git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" main` mit `GIT_ACCESS_TOKEN` aus der `.env`; ein einfaches `git push` scheitert. - Ein Update auf dem Live-Server greift nur bei einem **`v*`-Tag** — `VERSION` anheben, taggen, Tag mitpushen.