Commit Graph

127 Commits (15230a01faff60074802a4766edb5b422a1dcaeb)

Author SHA1 Message Date
nexxo 033578d75d Release-Decke: einen Server aus der Konsole auf eine Version festnageln
Bei zehn Servern liess sich eine Auslieferung nicht staffeln: entweder alle
nehmen die neueste Version oder niemand. Der einzige Griff, der eine bestimmte
setzt, war RELEASE=vX.Y.Z bash deploy/update.sh auf der Kommandozeile.

Der Kern ist eine geklemmte Variable. Der Agent uebergab in Zeile 669 ohnehin
schon RELEASE="$TARGET_RELEASE"; wird die an der Decke geklemmt, faellt
`behind` aus derselben Rechnung, und Knopf wie Wartungsfenster folgen von
selbst. Task 4 belegt genau das mit einem Test, der KEINEN Produktivcode
braucht: es gibt keinen zweiten Weg in eine Auslieferung.

Die Decke faellt zu, nicht auf. Unlesbar, formwidrig oder ins Leere zeigend
heisst: nichts wird angeboten. Ein Rueckfall auf "neueste Version"
installierte genau das, wovon weggenagelt wurde.

Nicht enthalten: Zurueckrollen. Das ist verboten (update.sh:222), und der
Datenbank-Schnappschuss, auf den die Fehlermeldung dort verweist, wird
nirgends genommen. Eigene Baustelle, ihr fehlendes Stueck ist der
Schnappschuss, nicht der Knopf.

Unterwegs gefunden und mitbehoben: zwei Stellen, an denen eine Zuweisung aus
einer Kommandoersetzung unter set -e + pipefail den Agenten toetete, BEVOR er
eine Statusdatei schreiben konnte (sync_vpn_certificate, release_manifest_
version) — dieselbe Ausfallart, die die Konsole eine nie endende Pruefung
zeigen laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:37:33 +02:00
nexxo 24eb7b3d80 Fix-Welle: Schlussreview-Befunde 1-6 zur Release-Decke
Sechs Befunde aus dem Schlussreview, in einer Welle behoben:

- BEFUND 1 (Important): eine leere Auswahl im Festnageln-Feld liess
  pinRelease() ueber `$this->ceilingChoice ?: null` in setCeiling(null)
  laufen — die Gegenhandlung (Decke abnehmen) — und meldete dabei die
  Erfolgsmeldung des Festnagelns. ConfirmPinRelease::confirm() schickt die
  Version jetzt als Event-Nutzlast (wie ConfirmSaveSecret den Schluessel),
  und pinRelease(string $version) weist eine leere Version ausdruecklich
  ab, mit einer eigenen Meldung (release_pin_empty).
- BEFUND 2 (Minor, durch 1 miterledigt): Modal und Seite lasen bisher zwei
  getrennte Eigenschaften ($version vs. $ceilingChoice). Der Fix oben
  beseitigt die Trennung.
- BEFUND 3 (Important, Text only): der Kommentar bei release_tag_exists()
  in deploy/lib/release.sh und der Fehlerbehandlungs-Abschnitt der Spec
  behaupteten, ceiling_missing schuetze gegen einen vom Release-Prozess
  geloeschten Tag. Tut es nicht: `git fetch --tags --force` (ohne
  --prune-tags, bewusst) entfernt keine lokal bereits geholten Tags, die
  drueben verschwunden sind. Beide Stellen beschreiben jetzt, wogegen die
  Pruefung tatsaechlich schuetzt (ein nie geholter oder nie existierender
  Tag) und wogegen nicht. Kein --prune-tags hinzugefuegt.
- BEFUND 4 (Minor): ConfirmPinRelease hatte keinen Test. Zwei neue Tests
  nach dem Vorbild von ConfirmSaveSecret in IntegrationsPageTest.
- BEFUND 5 (Minor): ceilingChoice wurde nie aus dem gesetzten Zustand
  vorbelegt. UpdateChannel::ceiling() ist jetzt public, Settings::mount()
  belegt das Feld damit vor.
- BEFUND 6 (Minor): eine von Hand geleerte Deckendatei liest die Konsole
  als "keine Decke" (ceiling() -> null), der Agent meldet dafuer aber
  ceiling_error. Der "Decke abnehmen"-Knopf stand nur hinter
  @if($update['ceiling']) und verschwand damit genau in dem Zustand, aus
  dem er zurueckfuehren muesste. Bedingung erweitert auf
  ($update['ceiling'] || $update['ceiling_error']).

Jeder Befund traegt einen eigenen Test in ReleaseCeilingConsoleTest.php.
Volle Suite: 2973 passed (10389 assertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:25:48 +02:00
nexxo 432ecfbf4d Plan: der Rueckverweis-Test braucht eine vorhandene Zeile, sonst prueft er nichts
Wie zuerst geschrieben haette er den Anhaenge-Zweig getroffen und waere auch
gegen die fehlerhafte Fassung gruen gewesen. Der Implementer hat das gemerkt und
SITE_HOST= leer vorbelegt — genau die Form, die .env.example ausliefert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:56:21 +02:00
nexxo 5c9ee2c6d5 Plan: apply() darf den Wert nicht als Regex-Ersatz behandeln
preg_replace deutet $1, \1 und \\ im ERSATZ als Rueckverweise, auch ohne
Gruppen im Muster. Der Wert kommt von der Befehlszeile, das Ziel ist die Datei
mit allen Zugangsdaten, und das Ergebnis waere still verstuemmelt statt
abgelehnt — isValidLine() sieht weiterhin ein gueltiges KEY=value.

Jetzt zeilenweise, ohne Regex auf dem Schreibweg. Dazu faengt der Befehl
InvalidEnvContentException ab, statt dem Betreiber einen Stapelabzug zu zeigen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:56:21 +02:00
nexxo 3a71cdc7cf Plan: Tests laufen im Worktree, nicht im geteilten Haupt-Checkout
Am Haupt-Checkout arbeiten parallel andere Sitzungen. Deren unfertige Aenderungen
gerieten in den eigenen Testlauf und haben eine Fehlersuche in die falsche
Richtung geschickt. Der Worktree ist jetzt eigenstaendig: vendor hartverlinkt,
.env kopiert, storage-Verzeichnisse angelegt.

Ein Symlink auf vendor taeugt dafuer nicht — Composer leitet seine Basispfade aus
dem aufgeloesten Verzeichnis ab und laedt dann die fremden Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:56:21 +02:00
nexxo 9d3f3c27b5 Plan: das Zurueckstellen der Umgebung war falsch, und die Pruefhuerde zu niedrig
Der Testcode im Plan sicherte $_SERVER und stellte daraus zurueck. phpunit haelt
seine <env>-Werte aber in $_ENV und putenv() — gemessen: ADMIN_HOSTS und
FILES_HOST fehlen in $_SERVER voellig. Das Zurueckstellen loeschte sie damit,
und jeder Test danach las ADMIN_HOSTS aus der echten .env des Containers. Die
Suite fiel an zwei Stellen, die mit Hostnamen nichts zu tun haben.

Gefunden hat es nicht der Filterlauf, den der Plan verlangte — der war gruen.
Gefunden hat es der Vergleich zweier voller Laeufe, mit und ohne die Aenderung:
2939 gruen gegen zwei Fehlschlaege. Deshalb verlangt Schritt 5 jetzt den vollen
Lauf und nennt den Kontrollversuch beim Namen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:56:21 +02:00
nexxo 78d6c9c320 Plan: Merge im Haupt-Checkout, Versionsuntergrenze auf v1.8.2
Beides beim Vorabcheck aufgefallen: git switch main scheitert in einem
Worktree, in dem main anderswo ausgecheckt ist, und main ist waehrend des
Planens auf v1.8.2 weitergezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:56:21 +02:00
nexxo 5a2e6564db Umsetzungsplan Hostnamen-Trennung: vier Aufgaben, Testcode im Container gelaufen
Der Testcode im Plan ist keine Skizze. Er lief: 123 Routen, zwoelf ohne Domain,
und die Original-Anwendung danach lesend UND schreibend intakt. Letzteres nicht
selbstverstaendlich — die zweite Anwendungsinstanz zeigt Eloquents statischen
Connection-Resolver auf ihre eigene, leere :memory:-Datenbank, und der halbe
Testlauf faende danach keine Tabelle mehr. Genau so ist es beim ersten Versuch
passiert; das Zurueckstellen steht deshalb mit Begruendung im Plan.

Ebenfalls vorher geprueft statt geraten: dass Pest-Expectations eine eigene
Fehlermeldung als zweites Argument nehmen, und dass dynamische Properties in
beforeEach hier Hausstil sind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:56:21 +02:00
nexxo 295444f432 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>
2026-08-04 15:56:21 +02:00
nexxo 5fe33553ad Zertifikats-Spec auf Weg B umgeschrieben, Registereintrag gestrichen
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Die Spec fuer die Host-Konsole (docs/superpowers/specs/2026-08-01-host-
konsole-zertifikat-design.md) war auf einer eigenen DNS-Zone und Proxmox'
Alias-Modus fuer DNS-01 gebaut. Hetzner kennt keine Subzonen (docs.hetzner.
com/networking/dns/faq/zones/, Artikel NE-7597D: "Subzones are not
supported") -- damit entfallen eigene Zone, CNAME je Host, Alias-Modus und
der pro Host verteilte DNS-Token ersatzlos. Der Grund bleibt in der Spec
stehen, statt geloescht zu werden, damit niemand denselben Weg in einem
halben Jahr erneut vorschlaegt.

Weg B, wie im Register vorgegeben: CluPilot stellt zentral aus, DNS-01
ueber den vorhandenen kontoweiten Hetzner-Token, Zertifikat per SSH
(RemoteShell::putFile + `pvenode cert set --force --restart`, geprueft
gegen die Proxmox-Dokumentation) auf den Host, Erneuerung als geplanter Job
auf der Bereitstellungs-Warteschlange (dieselbe Grenze wie SyncVpnPeers --
nur queue-provisioning erreicht einen Host ueber den Tunnel). Dazu ein
Vergleich mit der Kundeninstanz (ConfigureDnsAndTls, HTTP-01) und eine
genaue Bestandsaufnahme der Bereitschaftsseite: sie kennt heute kein
Zertifikat, weder fuer Hosts noch, trotz ersten Anscheins, uebertragbar
fuer die Plattform -- CertificateSweep/ProxyHost misst nur oeffentlich
erreichbare Namen und laeuft im falschen Container fuer einen Host-FQDN.

Im Code bestaetigt und in der Spec vermerkt: RrsetId::zone() ist heute fest
auf die Kundenzone verdrahtet, ein Host-FQDN liegt aber in der
Plattformzone -- das ist Bauarbeit, keine offene Entscheidung. Offen bleibt
nur, welches Werkzeug das ACME-Protokoll auf CluPilot-Seite spricht (keine
Bibliothek/kein Tool dafuer im Repo) und ob Plattform- und Kundenzone im
selben Hetzner-Projekt liegen -- beides als offene Fragen benannt, keine
davon blockiert den Rest des Ablaufs.

Registereintrag in OpenWork.php gestrichen: die Spec beschreibt keinen
toten Weg mehr, und genau das war der einzige Punkt, den der Eintrag
festhielt.

Getestet: php artisan test --filter=OpenWork, 8 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 15:31:28 +02:00
nexxo d4a9166c7a Plan-Korrektur vor der Umsetzung: Task-2-Test isolieren
Zwei Defekte im Vorab-Abgleich gefunden, beide im Testaufbau von Task 2:

- Der Test haette echte Tags (v9.9.8, v9.9.9) im GETEILTEN Repository
  angelegt. Tags liegen im gemeinsamen .git und sind damit auch fuer den
  Hauptbaum und jede Parallelsitzung sichtbar. Stirbt der Test vor seinem
  Aufraeumen, beantwortet ein liegengebliebenes v9.9.9 die Frage
  `git tag -l 'v*' --sort=-v:refname | head -1` falsch — und die entscheidet,
  wohin ein Server aktualisiert. Jetzt laeuft der Agent in einem Wegwerf-
  Checkout mit eigenem .git; er bestimmt seine Wurzel ohnehin aus dem eigenen
  Pfad, es genuegt also, deploy/ dorthin zu kopieren.
- Falscher Manifest-Pfad: der Test schrieb nach storage/app/deploy/
  deployment.json, gelesen wird storage/app/deployment.json (lib/release.sh:19)
  — eine Ebene darueber. Der Test haette die ausgelieferte Version nie gesetzt
  und etwas anderes gemessen, als er behauptet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:38:59 +02:00
nexxo 40e42a88fe Umsetzungsplan: Release-Decke in fuenf Schritten
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Fuenf Tasks, jeder mit eigenem Testzyklus: Versions-Arithmetik mit Decke,
Agent klemmt Ziel und Zaehler, Kanal schreibt atomar und reicht durch, Beleg
dass das Wartungsfenster von selbst folgt, und der Griff in der Konsole.

Task 4 hat bewusst KEINEN Produktivcode. Wenn der Test ohne Aenderung an
AutoUpdate.php gruen wird, ist genau das der Befund — und wenn nicht, ist die
Antwort nicht eine Decken-Sonderregel in der Automatik, sondern ein Fehler in
state(). Der Plan sagt das ausdruecklich, weil ein zweiter Weg in eine
Auslieferung genau das ist, wovor AutoUpdate.php im Kopfkommentar warnt.

Bei der Selbstpruefung gegen die Spec fielen drei Fehler auf:

- Ein Apostroph in einem einfach zitierten Testnamen — Syntaxfehler.
- `Operator::factory()->create()->givePermissionTo(...)` gibt es hier nicht;
  Berechtigungen haengen ueber Rollen am operator-Guard.
- Eine Spec-Anforderung ohne Task: der Zustand „Decke unter dem
  Ausgelieferten". Er ist ueber das Auswahlfeld nicht erreichbar, ueber die
  Kommandozeile schon, und er darf nicht als „aktuell" durchgehen. Jetzt mit
  eigenem `ceiling_passed`, eigenem Satz und zwei Tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:34:41 +02:00
nexxo cddba14993 Entwurf: Release-Decke — gezielt vorwaerts festnageln aus der Konsole
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details
Bei zehn Servern laesst sich eine Auslieferung heute nicht staffeln: entweder
alle nehmen die neueste Version oder niemand. Der einzige Griff, der eine
bestimmte setzt, ist `RELEASE=vX.Y.Z bash deploy/update.sh` auf der
Kommandozeile — zehnmal.

Zwei Funde haben den Entwurf geformt:

- Zurueckrollen ist kein fehlender Knopf, sondern eine bewusst verbotene
  Handlung (update.sh:222). Die Fehlermeldung dort verweist auf einen
  Datenbank-Schnappschuss vor dem Update — den nimmt niemand, in update.sh
  steht kein einziger Dump. Echtes Zurueckrollen ist deshalb eine eigene
  Baustelle, und ihr fehlendes Stueck ist der Schnappschuss, nicht der Knopf.
- `clupilot:auto-update` haette einen einmaligen Sprung beim naechsten
  Wartungsfenster sofort wieder auf die neueste Version gehoben. Festnageln
  muss also eine stehende Obergrenze sein, sonst haelt es nicht.

Der Ansatz ist dadurch klein: der Agent uebergibt in Zeile 669 ohnehin schon
`RELEASE="$TARGET_RELEASE"`. Wird diese eine Variable auf die Decke geklemmt,
folgt alles andere — `behind` faellt aus derselben Rechnung, und Knopf wie
Automatik lesen beide `UpdateChannel::state()`. Kein zweiter Weg in eine
Auslieferung.

Entscheidend beim Fehlerverhalten: die Decke faellt zu, nicht auf. Eine
unlesbare oder ins Leere zeigende Decke darf nicht auf „neueste Version"
zurueckfallen — das installierte genau das, wovon weggenagelt wurde. Dass ein
Tag verschwindet, ist dabei kein Randfall: der Release-Prozess loescht falsche
Tags und ueberspringt die Nummer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 14:25:21 +02:00
nexxo df64b17c77 Plan: Kuendigung Teil B1 — der Abbau
Beim Ausarbeiten hat sich der Entwurf als zu vorsichtig erwiesen. Zwei Funde:

Die Hosts machen LAENGST Momentaufnahmen — RegisterBackup legt fuer jede
Kundeninstanz einen naechtlichen vzdump an, mode: snapshot, storage: local.
Die Frage, die im Entwurf als riskanteste Voraussetzung stand ("friert Proxmox
eine laufende Maschine brauchbar ein?"), ist im Betrieb seit Monaten
beantwortet.

Und der Ablageort ist da: dieselbe lokale Ablage. Der Speicherserver ist der
ZWEITE Ort, nicht der erste.

Damit ist die Haelfte von Teil B baubar, die Geld kostet: heute laeuft jede
gekuendigte Maschine fuer immer weiter und bindet einen Platz, den niemand mehr
verkaufen kann. Der Kunden-Export (B2) wartet weiter — ein zusaetzlicher
Tarball von 175 GB neben dem Archiv braucht ein Ziel ausserhalb des Hosts.

Die tragende Regel des Plans steht ueber allem: niemals loeschen, bevor das
Archiv NACHWEISLICH existiert. Ein vzdump kann mit einer Auftragskennung enden
und trotzdem nichts hinterlassen — volle Ablage, abgebrochener Lauf. Wer sich
auf die Kennung verlaesst statt nachzusehen, loescht eine Maschine, deren
Archiv es nicht gibt.
2026-08-04 10:40:33 +02:00
nexxo 76cf3d0555 Entwurf: ein Terminal fuer den CluPilot-Server selbst
Ein Host-Terminal reicht auf eine Maschine, dieses auf alles. Deshalb
eigene Faehigkeit, eigener Schluessel, und die Netzliste ohne ihren
Schalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 09:41:18 +02:00
nexxo 82ace84d01 Plan: ein Streifen im Dashboard zeigt die Restfrist
Vom Betreiber gefordert, und der Ort stimmt: eine Mail sieben Tage vorher kann
im Postfach untergehen, die eigene Uebersicht oeffnet der Kunde ohnehin.

Er nennt, wie lange der ZUGANG noch steht — nicht, wann etwas geloescht wird.
Das ist die Frist, die den Kunden betrifft. Und er ist der natuerliche Platz
fuer die Export-Antwort: wer liest, dass die Zeit laeuft, will es sich im
selben Atemzug anders ueberlegen koennen.
2026-08-04 07:57:21 +02:00
nexxo 0ba3a285c2 Plan: Kuendigung Teil A — die Frage und die Warnung
tests / pest (push) Has been cancelled Details
tests / assets (push) Has been cancelled Details
tests / release (push) Has been cancelled Details
Bewusst nur die erste Haelfte des Entwurfs. Teil B (Export, Abbau, Archiv)
haengt an einem Speicherserver, den es noch nicht gibt: keine Adresse, keine
Zugangsdaten, kein Wissen darueber, welches Werkzeug auf den Proxmox-Hosts
wirklich steht. Dafuer jetzt Code zu schreiben hiesse, Pfade und Fehlerfaelle zu
erfinden und sie als Plan auszugeben — an diesem Vorhaben sind heute neun
Befunde aufgelaufen, und jeder stammte aus einem Plan, der an einer Stelle
selbstsicher war, an der niemand nachgesehen hatte.

Teil A traegt fuer sich den Punkt, der heute am meisten weh tut: ab dem
Laufzeitende zieht EndInstanceService die Adresse ein, und der Kunde kommt nicht
mehr an seine Nextcloud. Wer selbst herunterladen will, muss es vorher tun — und
das sagt ihm bisher niemand.

Drei Voraussetzungen fuer Teil B stehen am Ende des Plans. Die dritte ist die
wichtigste: dass eine Momentaufnahme einmal von Hand gefahren und das Ergebnis
aufgeschrieben wurde. Ob Proxmox eine laufende Maschine so einfriert, dass das
Dateisystem darin brauchbar ist, gehoert gemessen und nicht angenommen.
2026-08-04 02:53:17 +02:00
nexxo 8174ad77cc Entwurf: die Seitenleiste der Konsole neu geordnet
Achtundzwanzig Eintraege in sieben Gruppen, und 'System' war der Platz fuer
alles, was sonst nirgends hinpasste. Der Entwurf haelt zwei Entscheidungen des
Betreibers fest: Selten-Benutztes verlaesst die Seitenleiste in einen eigenen
Einrichtungsbereich, und die drei Seiten, auf denen etwas wartet, stehen als
eigener Block ganz oben — mit einer Zahl daneben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:47:03 +02:00
nexxo ceefaf1745 Der Export braucht eine Momentaufnahme, sonst ist er keiner
Beim Ausarbeiten von Weg A aufgefallen: die Maschine LAEUFT zu dem Zeitpunkt
noch — sie muss, sie ist die Rueckfahrkarte der vierzehn Tage. Aus der Platte
einer laufenden Maschine zu lesen liefert aber keinen verlaesslichen Stand: ein
Dateisystem, in das gerade geschrieben wird, ist mitten im Lesen inkonsistent.

Man bekaeme ein Archiv, das aussieht wie eines und halb geschriebene Dateien
enthaelt — die schlimmste Sorte Attrappe, weil sie erst auffaellt, wenn jemand
die Daten wirklich braucht.

Der Weg geht deshalb ueber eine Momentaufnahme. Kein Zusatz, sondern die
Bedingung dafuer, dass Weg A ueberhaupt traegt.
2026-08-04 01:46:20 +02:00
nexxo 9ad802c4c9 Archivfrist entschieden: zwoelf Monate
Und sie wird nicht nur angekuendigt, sondern vollstreckt. Eine Frist, die in
einer Mail steht und die niemand durchsetzt, ist genau die Attrappe, gegen die
dieses Projekt seine Regeln geschrieben hat: der Speicher wuechse weiter, und
niemand merkte es, bis die Rechnung kommt.
2026-08-04 01:45:42 +02:00
nexxo 460d0db35b Entwurf: Kuendigung — Export, Archiv und Abbau
Beim Kuendigen verspricht CluPilot schriftlich einen fertigen Datenexport. Im
Code steht dazu "(both mocked for now)" — es gibt keinen, und es gab nie einen.
Und die gekuendigte Maschine wird nie abgebaut: sie laeuft weiter, fuer immer,
auf einem Platz, den niemand mehr verkaufen kann.

Der Ablauf stammt vom Betreiber, und sein Angelpunkt ist, VORHER zu fragen: wer
keinen Export braucht, loest keine Arbeit aus und wartet auf nichts. Wer einen
bestellt, bekommt ihn am Laufzeitende samt Link, bestaetigt, dass alles da ist —
und ohne Bestaetigung gilt er nach der Frist als angenommen. Das steht in
derselben Mail, sonst waere es keine Zustimmung.

Eine technische Huerde ist benannt statt ueberspielt: der Export passt NICHT auf
die Platte des Kunden. Ein Business-Paket hat 175 GB Kontingent bei 200 GB
Platte — wer sein Kontingent nutzt, hat keinen Platz fuer ein Archiv seiner
eigenen Daten, und der Gastagent ist kein Weg, hundert Gigabyte herauszutragen.
Drei Wege stehen mit ihren Kosten da; empfohlen ist der ueber den Host, an der
Maschine vorbei.

Offen und ausdruecklich als Frage markiert: wie lange ein VM-Archiv liegen
bleibt. Ohne Frist waechst dieser Speicher unbegrenzt.
2026-08-04 01:42:13 +02:00
nexxo 87ecb4d064 Runbook fuer den Nachweis gegen echte Hardware: Einladung und Speicherplatz
Zwei Dinge, die sich gegen einen Fake nicht beweisen lassen. Der zweite kann
den Entwurf umwerfen: ob Nextcloud "0 B" als null oder als unbegrenzt
auslegt, steht in keiner Dokumentation.

Beide Nachweise stehen aus, nicht simuliert: mail.clupilot.cloud existiert
noch nicht. Das Runbook haelt Voraussetzungen, Ablauf und Fehlersuche fest,
inklusive einer Luecke, die der Aufgabenzettel nicht kannte -- die Konsole
kann heute nur ein bestehendes Postfach bearbeiten, keines mit dem
Schluessel "instance-relay" neu anlegen -- und traegt ein leeres
Ergebnisfeld mit dem Vermerk "steht aus" fuer beide Nachweise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:12:36 +02:00
nexxo 43d4c02353 Plan: der Inhaber-Sitz muss auch zur Laufzeit verknuepft werden
Die Wanderung aus Aufgabe 5 loest das fuer den Bestand. Users::mount() legt
aber beim ersten Besuch einen owner-Sitz an — und der stuende ohne
Verknuepfung auf 'none'. Das Panel boete dem Inhaber dann an, sich selbst
einzuladen, und der Auftrag traefe auf ein Konto, das die Bereitstellung
laengst angelegt hat.

Beim Pruefen von Aufgabe 5 aufgefallen, nicht beim Planen.
2026-08-03 20:55:21 +02:00
nexxo d861c34c26 Plan berichtigt: der Nachruestbefehl haette nie einen Lauf gefahren
AdvanceRunJob sucht per uuid, mein Zettel uebergab die Autoincrement-Id.
HasUuid erzeugt beide unabhaengig; sie sind nie gleich. In Produktion haette
der Auftrag nie eine Zeile gefunden, still geendet, und jeder Lauf waere fuer
immer auf running stehengeblieben — waehrend der Befehl "gestartet" meldet.
Dazu STATUS_RUNNING statt STATUS_PENDING beim Anlegen.

Die eigentliche Luecke war die fehlende Zusicherung: die Pruefungen zaehlten
Laufzeilen und sahen nie nach, WAS an den Auftrag ging. Sie ist ergaenzt.

Ausserdem zwei Reste in der Spezifikation: mail_smtpauth folgt seit Aufgabe 1
dem Postfach statt einem festen Wert, und der Anzeigename je Kunde entfaellt —
`instances` traegt keine Spalte, aus der er kommen koennte.
2026-08-03 20:32:25 +02:00
nexxo 62e69ecde2 Aufgabe 2 gestrichen: das Passwortversteck schuetzte vor nichts
Die Pruefung hat nachgerechnet, was ich im Entwurf behauptet hatte, und es
stimmte nicht. guestExec faehrt die GANZE Zeile als /bin/sh -c auf der
Kunden-VM; die innere Shell setzt den Wert vor dem exec ein, er steht also
auch im Argv von php occ; und Nextcloud legt ihn danach ohnehin im Klartext
in config.php ab. Eingespart wurde einzig das Argv des docker-Aufrufs — auf
einer Maschine, die denselben Wert an zwei anderen Stellen zeigt.

Statt den Kommentar schoenzureden faellt der Mechanismus weg. Aufgabe 3
schreibt das Passwort als gewoehnliches Argument und sagt in ihrem
Kopfkommentar, wo es ueberall sichtbar ist. Geprueft wird stattdessen, was
wirklich gilt: dass der Wert maskiert ist und keinen zweiten Befehl starten
kann — der Aufruf laeuft als root auf einer Kundenmaschine.

Die Eingrenzung liegt in der Infrastruktur: Versandkonto nur mit Senderecht,
Versandport nur fuer die eigenen Hostadressen, Sendelimit je Konto.
2026-08-03 19:45:35 +02:00
nexxo e99da32aa2 Plan: Testname in Aufgabe 3 sagte das Gegenteil seiner Zusicherung
Die Pruefung stellt fest, dass der Schritt sich NICHT mit einem Merker
sperrt, sondern beim zweiten Lauf erneut schreibt — das ist bei einer
Nachruestung ueber den Bestand der Normalfall.
2026-08-03 19:02:01 +02:00
nexxo f1fb06699f Plan: Mitarbeiterverwaltung in acht Aufgaben
Aufgabe 1-7 sind ohne den neuen Mailserver pruefbar und koennen vollstaendig
gebaut werden, bevor er steht. Aufgabe 8 ist der Nachweis gegen echte
Hardware — und der zweite Punkt darin kann den Entwurf umwerfen: ob Nextcloud
"0 B" als null oder als unbegrenzt auslegt, steht in keiner Dokumentation.

Beim Vorabdurchgang drei eigene Fehler gefunden und behoben: StepResult hat
kein failed(), und drei von vier Rueckrufen in NextcloudUsers trugen eine
andere Signatur als der vierte.
2026-08-03 18:55:40 +02:00
nexxo d257deae01 Entwurf: Mitarbeiterverwaltung im Userpanel (Projekt A)
Die Sitzverwaltung im Portal ist vollstaendig gebaut und erreicht die
Nextcloud nie — resend() traegt seit jeher "Invite delivery is mocked for
now.". Das Modul wird verkauft und tut nichts.

Beim Nachsehen kam der groessere Fund: die Kunden-Nextcloud hat ueberhaupt
keinen Mailversand eingerichtet. Keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung und wird zuerst
gebaut — ueber eine EIGENE Absenderdomain (clupilot.cloud), damit der Ruf der
Domain, ueber die Rechnungen und Sicherheitsmeldungen gehen, nicht am
Mailaufkommen der Kunden haengt.

Tragende Entscheidung: niemand kennt ein fremdes Passwort. Nextcloud erzeugt
es selbst (user:add --generate-password --email) und mailt dem Mitarbeiter
einen Link, an dem er sein eigenes setzt. Ein Wechselzwang beim ersten Login
ist in Nextcloud fuer einen EINZELNEN Benutzer nicht moeglich — das steht so
im Entwurf, statt es zu umschreiben.

Anlegen und Einladen sind getrennt, wie gewuenscht. Entziehen sperrt und
loescht nichts. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde.

Eine technische Unsicherheit ist benannt statt angenommen: ob Speicherplatz
0 B "nichts" oder "unbegrenzt" heisst, bekommt einen eigenen Nachweisschritt
im Plan.
2026-08-03 18:14:43 +02:00
nexxo 6ebbaa82aa Ein Host, der die Sperrmengen nicht kennt, laesst sich jetzt nachziehen
Fix-Welle nach dem Gesamt-Review, zweite Haelfte von Punkt 3.

Die beiden nftables-Mengen clupilot_blocked und clupilot_blocked6 kamen mit dem
Fruehwarnsystem ins Regelwerk. Jeder Host, der VORHER uebernommen wurde, traegt
noch die alte Datei — dort scheitert `nft add element` bei jedem Versuch, und
die Sperre steht in Datenbank, Portal, Konsole und in der Mail an den Kunden als
aktiv, in der Firewall aber nie. Gemeldet wird dieser Fall seit dem Commit
davor; das hier ist der Griff, mit dem man ihn abstellt.

Der Uebernahme-Lauf hilft nicht, und genau das stand im Runbook falsch:
SecureHostFirewall kuerzt sich ueber den `host_firewall`-Brotkrumen ab, und der
haengt am LAUF, nicht am Host. Auf einem bereits uebernommenen Host taete der
Schritt gar nichts und meldete trotzdem "erledigt".

Das Repo hat fuer genau das ein Muster, und es passt: clupilot:apply-quotas
faehrt ueber eine EIN-SCHRITT-Pipeline (`quota`) einen einzelnen Schritt gegen
ein bestehendes Subjekt — gedrosselt, wiederholt, protokolliert und in der
Konsole sichtbar wie jede andere Fernarbeit, statt dass ein Konsolenbefehl
selbst auf die Maschine greift. Ein zweiter Weg, dieselbe Datei zu schreiben,
wuerde driften.

Also dasselbe hier:

- Pipeline `host-firewall` mit Host\SecureHostFirewall als einzigem Schritt. Ein
  frischer Lauf hat den Brotkrumen nicht, fuehrt den Schritt also wirklich aus —
  und weil der Schritt die Datei ohnehin vollstaendig neu schreibt und vorher
  den Tunnel von der HOSTSEITE aus nachprueft, ist das dieselbe Arbeit wie beim
  ersten Mal, nicht eine zweite Umsetzung davon.

- EIGENE Pipeline und nicht `host`, und das ist kein Ordnungssinn:
  RunRunner::failRun() loest den Subjekt-Haken nur aus, wenn der gescheiterte
  Lauf DER Lauf des Subjekts ist. Unter `host` wuerde ein gescheitertes
  Nachziehen einen laufenden, bezahlten Host auf 'error' stellen. Dafuer gibt es
  einen eigenen Test.

- php artisan clupilot:refresh-host-firewall, mit --dry-run und --host=, und mit
  derselben "der Grund ist der Bericht"-Ausgabe wie beim Vorbild: "12
  uebersprungen" und sonst nichts ist keine Auskunft, mit der jemand etwas
  anfangen kann.

Kein Zeitplan, aus den drei Gruenden, die schon ueber clupilot:apply-quotas
stehen: das Loch ist endlich und schliesst sich endgueltig, ein naechtlicher
Lauf waere eine zweite Instanz, die dieselbe Datei auf laufende Maschinen
schreibt und am Tag eines still kaputten Pipeline-Schritts fuer ihn einspraenge,
und eine Reparatur, die der Betreiber anstoesst, ist eine, deren Ausgabe er
liest.

docs/runbooks/tunnel-recovery.md ist richtiggestellt. Dort stand, man solle nach
dem Notfallskript "den Schritt SecureHostFirewall erneut laufen lassen" — jetzt
steht dort der Befehl, mit der Warnung darunter, warum der alte Rat nicht trug.

Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:08 +02:00
nexxo 2ab6bd22a3 Plan: der Versandtakt in sieben Aufgaben
Beim Lesen des Frameworks fielen zwei Annahmen des Entwurfs um, und beide
haetten Tage gekostet:

SendQueuedMailable hat KEINE middleware() — die Klasse hat nur handle, backoff,
retryUntil, failed, displayName, __clone. Eine middleware() auf der Mailklasse
liest also niemand. Der Weg fuehrt ueber einen eigenen Auftrag.

Dafuer gibt es einen sauberen Haken: Mailable::newQueuedJob() erzeugt den
Auftrag ueber den Container. Eine Bindung tauscht ihn fuer alle Mails aus, ohne
dass eine einzige Absendestelle sich aendert — womit die Zusage des Entwurfs
haelt.

Und die Spur muss vor dem Einreihen feststehen, weil Mailable::queue() den
Schlangennamen liest und an pushOn() weiterreicht. Ein Trait, das queue()
ueberschreibt, greift rechtzeitig.

Zwei Teststellen stehen bewusst als Rumpf statt als fertiger Code: der Nachweis,
dass eine gedrosselte Rueckstellung den Versuchszaehler nicht erschoepft, und
das Alter des aeltesten Auftrags einer Redis-Schlange. Beide haengen an
Umgebungsverhalten, das ich nicht gemessen habe; geratener Testcode waere dort
schlimmer als ein benannter Auftrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:26:19 +02:00
nexxo 9e890243f6 Entwurf: der Versandtakt
Der Betreiber will nicht, dass zweihundert Rechnungen als Schub bei einem
Empfaengerserver ankommen und die Absenderadresse dort als Spam eingestuft wird.
Heute sind es elf Kunden und zwei Rechnungen — die Regel wird fuer den Zustand
gebaut, in dem sie fehlen wuerde.

Drei Spuren, rund um die Uhr, kein Zeitfenster: Direkt (ungedrosselt), Wichtig
(30 je 5 Minuten), Zeit lassen (20 je 10 Minuten). Die Trennung laeuft nicht
zwischen Massenversand und Einzelmail, wie ich zuerst annahm, sondern zwischen
dringend und nicht dringend — eine Ausfallmeldung geht an alle UND eilt, und
bekommt deshalb ein hoeheres Kontingent statt gar keines.

Die sieben Direkt-Mails entstehen einzeln, weil ein Mensch gerade geklickt hat
und wartet. Sie koennen keinen Schub bilden; Drosseln nuetzt dort nichts und
kostet einen Supportfall je verzoegertem Kennwort. Sie lassen sich deshalb auch
nicht nach unten verschieben.

Gebaut wird es als Zwischenschicht an der Mail selbst: alle vierzehn Klassen
sind bereits ShouldQueue, und kein einziger Aufrufer aendert sich. Verworfen:
eine eigene Ausgangstabelle (zwanzig Absendestellen umbauen, zwei
Warteschlangen nebeneinander) und drei Arbeiter mit festem Takt (die Zahlen
staenden im Container statt in der Konsole).

Die Falle steht im Entwurf, weil sie den Bau still kaputtmachen kann: der
Arbeiter laeuft mit --tries=3, und eine gedrosselte Rueckstellung zaehlt als
Versuch. Ohne Vorkehrung waere jede Rechnung nach dem dritten Drosseln
gescheitert statt verschickt. Das ist der erste Test, vor der ersten Zeile
Drossel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:18:47 +02:00
nexxo e3f2e9a88b Plan berichtigt: zwei Annahmen im Vorabdurchgang gefunden
InstanceFactory kennt keinen active()-Zustand — die Tests im Plan haetten nicht
einmal kompiliert. Status und vmid stehen jetzt ausgeschrieben.

Und eine Zusicherung in Aufgabe 1 war leer: sie prueft, dass der Nachbar NICHT
auf 'info' zeigt, ohne ein Postfach fuer Abrechnung anzulegen — der Resolver
liefert dann null, und null ist nun einmal nicht 'info'. Genau die Sorte Test,
die gruen bleibt, waehrend die Sache kaputt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:32:11 +02:00
nexxo 850a9ebd5b Plan: Fruehwarnsystem, sechs Aufgaben
tests / pest (push) Has been cancelled Details
tests / assets (push) Has been cancelled Details
tests / release (push) Has been cancelled Details
Reihenfolge nach Abhaengigkeit: Wegwahl je Mailart (steht fuer sich), dann die
Sperrliste in der Firewall, darauf der Datensatz mit Verdopplung und
Ausnahmeliste, darauf die Melder, dann die Mail, zuletzt die Ansichten.

Zwei Zusagen haengen an genau einer Zeile und bekommen deshalb einen Test, der
das ERGEBNIS prueft und nicht den Quelltext: die Sperrregel muss unter
'ct state established,related accept' stehen (sonst fliegt jeder mitten aus
seiner Sitzung), und beim Wiedereintragen nach einem Neustart muss die
RESTlaufzeit gesetzt werden (sonst verlaengert sich eine Sperre bei jedem
Neustart des Hosts).

Beim Selbstdurchgang gegen die Spec fielen zwei fehlende Pruefungen auf und sind
nachgetragen: Versuche ueber zwei Fenster verteilt duerfen nicht sperren, und
eine gescheiterte Mail darf die Sperre nicht mitnehmen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:27:49 +02:00
nexxo cbb0747732 Spec richtiggestellt: Gaeste werden ueber den Proxmox-Gastagenten gelesen, nicht ueber SSH
Ich hatte 'ueber den bestehenden SSH-Weg' geschrieben. Das stimmt fuer Hosts und
ist fuer Kundeninstanzen falsch: jeder Befehl im Gast laeuft im Produkt ueber
guestExec, also den QEMU-Gastagenten via Proxmox-API — so macht es jeder
occ-Aufruf. Falsch beschrieben haette der Plan den Melder auf einen Weg gebaut,
den es fuer Gaeste gar nicht gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:23:41 +02:00
nexxo 00b3ddcae0 Spec erweitert: aus welchem Postfach eine Mail geht, je Mailart
Der Betreiber will die Meldungen dieses Systems aus einem eigenen Postfach
schicken, ohne dass jede andere Mail mitwandert. Heute kennt die Zuordnung nur
fuenf Zwecke — alles mit demselben Zweck geht aus demselben Postfach.

Dazu kommt eine Ebene UEBER den Zwecken, kein Ersatz: je Mailart ein Postfach,
Vorgabe 'wie der Zweck'. Ohne Eintrag aendert sich nichts. Der Katalog der 16
Mailarten existiert schon fuer die Vorschau und wird dabei zur EINEN Liste,
damit es keine zweite gibt, die man vergessen kann.

Ausdruecklich NICHT darin: Postfaecher anlegen und loeschen. Das ist ein eigenes
Projekt — ein Postfach zu loeschen, auf das noch drei Mailarten zeigen, darf
nicht still geschehen. Die fuenf vorhandenen genuegen fuer dieses System.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:18:36 +02:00
nexxo 9deb5eca60 Spec: Fruehwarnsystem gegen Anmeldeversuche
Entworfen mit dem Betreiber. Vier Entscheidungen stehen fest: gesperrt wird die
ADRESSE des Angreifers (nicht das Konto, nicht die Instanz), ab 10 Fehlversuchen
in 10 Minuten, fuer eine Stunde mit Verdopplung bei Wiederholung, und aufgehoben
wird von dem, den es angeht — der Inhaber im Portal, der Betreiber in der
Konsole.

Der Kern des Entwurfs ist eine nftables-Menge mit Ablaufzeit, deren Regel UNTER
'ct state established,related accept' steht. Damit ist 'wer drin ist, bleibt
drin' eine Eigenschaft des Netzes und kein Versprechen in unserem Code. Und die
Stunde laeuft im Kernel ab — eine Sperre kann nicht liegenbleiben, weil eine
Warteschlange klemmt.

Ehrlich benannt: auf einem fertig uebernommenen Host gibt es kein oeffentliches
SSH mehr, der Host-Melder greift also enger als sein Name klingt. Und eine
SSH-Verbindung je Instanz und Minute traegt heute, aber nicht bei hundert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:12:38 +02:00
nexxo 5f3d983658 Der Tunnel bekommt eine feste Adresse — damit ist die conntrack-Falle weg statt behandelt
Die Wurzel des ganzen Uebels, endlich an der Wurzel: Docker traegt den
veroeffentlichten UDP-Port als Weiterleitung auf die Container-Adresse ein, und
der Kernel merkt sich jeden laufenden Strom samt dieser Adresse. Bekam der
Container beim Neubau eine andere — und er bekam die naechste freie —, zeigten
alle gemerkten Stroeme ins Leere. Und sie verfallen nicht, weil WireGuards
Lebenszeichen sie alle 25 Sekunden auffrischt.

Deshalb kam ein Telefon nach Aus/Ein sofort zurueck und ein Host mit festem
Quellport ueberhaupt nicht.

Jetzt hat vpn-hub eine feste Adresse (172.18.0.240, ueber CLUPILOT_VPN_HUB_IP
aenderbar) in einem Netz mit erklaertem Subnetz. Nach einem Neubau entsteht exakt
dieselbe Weiterleitung, die gemerkten Stroeme bleiben gueltig, und es gibt nichts
mehr aufzuraeumen. Nachgemessen: 172.18.0.240 vor und nach
`up -d --force-recreate vpn-hub`.

UND DIE UMSTELLUNG SELBST, die mich beim Bauen fast den Stapel gekostet haette:
Docker kann ein bestehendes Netz nicht umdefinieren. Es muss neu angelegt werden,
und das scheitert, solange auch nur EIN Container daranhaengt — `up -d` bricht
dann mit "network … has active endpoints" ab und laesst den Stapel halb unten
stehen. Genau so hier passiert, mit einem Container, der gar nicht zum Stapel
gehoerte. Das Deployment vergleicht deshalb vorher das erklaerte Subnetz mit dem
tatsaechlichen und faehrt bei Abweichung EINMAL geordnet herunter, statt darueber
zu stolpern. Danach stimmen die Werte ueberein und der Block tut nie wieder etwas.

Das conntrack-Aufraeumen bleibt trotzdem drin: als Rueckfahrkarte fuer Server auf
aelterem Stand und fuer den Fall, dass jemand das Subnetz aendert.

2510 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:10:54 +02:00
nexxo e3c781f067 Der Tunnel bekommt einen eigenen Container
Bisher wohnte wg0 im Container des Provisionierungs-Arbeiters, also im Abbild
`clupilot-app`. Das wird bei fast jeder Freigabe neu gebaut — und ein neu
gebauter Container bekommt eine neue Adresse im Compose-Netz, womit die
Weiterleitung fuer UDP 51820 neu geschrieben wird und JEDE bestehende
WireGuard-Sitzung abreisst. Der Tunnel hing damit am Veroeffentlichungstakt der
Anwendung, und zusaetzlich daran, dass ein PHP-Prozess nicht abstuerzt.

Jetzt gehoert der Netz-Namensraum einem eigenen Container `vpn-hub` mit eigenem,
winzigem Abbild (Alpine plus wireguard-tools), das sich fast nie aendert.
Provisionierungs-Arbeiter, Terminal-Bruecke, interner DNS und internes Gateway
steigen dort ein, statt einer von ihnen den Namensraum zu besitzen.

NACHGEMESSEN, nicht angenommen: App-Abbild neu gebaut, Arbeiter, Bruecke und
Gateway per --force-recreate neu erzeugt — der Hub blieb Container 92e928cf53b0,
wg0 und beide Zugaenge unangetastet, und nginx erreichte die Bruecke weiter
(HTTP 426). Genau der Vorgang, der bisher jedes Mal alles abgerissen hat.

Nachgezogen:
- nginx spricht die Bruecke unter `vpn-hub:8082` an — dem Namen des
  Namensraum-Eigentuemers; ein Mitbewohner hat keinen eigenen DNS-Eintrag.
- update.sh baut vpn-hub mit und haengt Nachbar-Neustarts und den
  conntrack-Griff an die Frage, ob der Hub WIRKLICH neu gebaut wurde.
- update-agent.sh startet den Arbeiter nicht mehr neu, sondern signalisiert ihm.
  Diese Stelle laeuft unbeaufsichtigt hinter dem Knopf „Dienste neu starten" —
  wer den drueckt, rechnet nicht damit, sich selbst auszusperren.
- Vier Meldungen in der Konsole rieten dem Betreiber, genau den Befehl von Hand
  auszufuehren, der ihm den Tunnel abreisst. Auch die sind korrigiert.
- rescue-tunnel.sh und das Runbook zeigen auf den neuen Besitzer.

Eine Kleinigkeit unterwegs, die ich falsch angekuendigt hatte: `[[ … ]] && x=true`
bricht unter `set -e` NICHT ab — bash nimmt die linke Seite einer &&-Liste
ausdruecklich aus. Nachgeprueft; die if-Form bleibt trotzdem, aus Lesbarkeit, und
der Kommentar sagt jetzt den wahren Grund.

2509 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:49:29 +02:00
nexxo 7b336d49f9 Runbook: Wenn der Tunnel weg ist
tests / pest (push) Failing after 8m44s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
Der Rettungsweg war bisher muendliche Ueberlieferung, verteilt ueber einen
Chatverlauf. Jetzt aufgeschrieben, mit der einen Regel darueber: kein Weg zurueck
darf durch den Tunnel fuehren, der gerade kaputt ist.

Vier Wege hinein (SSH auf den Server, Anbieterkonsole, das Notfallskript auf dem
Host, eine offline abgelegte WireGuard-Konfiguration), ein Entscheidungsbaum zum
Eingrenzen, und je Fall die Befehle. Dazu vier Dinge, die man EINMAL einrichtet,
solange nichts brennt — darunter der sudoers-Eintrag, ohne den der conntrack-
Griff aus v1.4.6 nur als Warnung im Protokoll landet statt zu laufen.

Und ein Abschnitt darueber, was seit v1.4.4/v1.4.6 nicht mehr passieren sollte:
wer es trotzdem sieht, hat einen Server auf altem Stand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 07:15:10 +02:00
nexxo c6403bf829 Terminal-Bruecke: ein Container, eine Aufgabe, ein Ticket
Der Beiwagen nach dem Vorbild von kuma-bridge: Python, ein Zweck, kein
Laravel. Er steht im Netz-Namensraum von queue-provisioning, weil dort wg0
lebt und nur von dort ein Host ueberhaupt erreichbar ist.

Drei Punkte, die das Review von Aufgabe 2 offen gelassen hat, sind hier
entschieden:

- Das Ticket reist als Sec-WebSocket-Protocol, nicht in der Adresszeile.
  Die Adresse eines Upgrade-Antrags schreibt jeder Reverse Proxy mit, und
  auf der Strecke stehen zwei, von denen nur einer aus diesem Repo
  konfiguriert wird.
- /terminal/ws bleibt an der Wurzel, unabhaengig von AdminArea::prefix():
  nginx kann die Konfiguration der Anwendung nicht lesen, der Ort muss ein
  fester Text sein. In beiden Dateien steht die Begruendung.
- Die Bruecke sendet ausschliesslich Binaerrahmen.

Abweichungen vom Plan, jeweils gemessen statt vermutet:

- Der Schluessel in Redis traegt REDIS_PREFIX (clupilot-database-), nicht
  CACHE_PREFIX: TerminalTicket schreibt ueber Redis::connection('cache'),
  und phpredis stellt die Praefix-Option dieser Verbindung voran.
- Port 8082 statt 8081: im selben Namensraum lauscht der VPN-Gateway schon
  auf VPN_HEALTH_PORT, und der zweite Zuhoerer auf einem Port stirbt.
- SSH ueber paramiko.Transport statt SSHClient. SSHClient prueft gegen
  known_hosts, die dieser Container nicht hat und aus einem Fingerabdruck
  auch nicht bilden kann; RejectPolicy verbaende nie, AutoAddPolicy
  meldete sich mit einem Root-Schluessel an, bevor irgendetwas geprueft
  ist. Der Transport erlaubt die richtige Reihenfolge: Handschlag,
  Fingerabdruck, dann erst Anmeldung.
- Der Fingerabdruck wird gebildet wie in PhpseclibRemoteShell, nicht wie
  bei OpenSSH: gehasht wird "<algorithmus> <base64-blob>", nicht der Blob.
- proxy_pass ueber eine Variable mit resolver, damit nginx nicht beim
  Start scheitert, wenn die Bruecke gerade nicht laeuft.
- /terminal/ws antwortet auf einem oeffentlichen Hostnamen mit 404,
  dieselbe Regel wie /admin eine Zeile darueber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:09:15 +02:00
nexxo 8a99f83556 Umsetzungsplan: Terminal-Container in drei Aufgaben 2026-08-02 17:25:30 +02:00
nexxo 632d3213a4 Entwurf: Terminal-Container mit eigenem Ticket, nachdem Proxmox' termproxy gemessen und verworfen wurde 2026-08-02 17:19:02 +02:00
nexxo 4cd848eead Fix-Runde 2: Warnung fuer nicht reparierte Hosts, case-sichere Vorabpruefung, wiederholbare Migration
Vier Befunde aus dem Abschluss-Review ueber d62a2c8/c815aee/11ba7ee:

- Die Migration renamt nur Hosts mit dns_name; pve-fsn-1/pve-hel-1 blieben
  unrepariert und stumm. Sie werden jetzt gesammelt und gemeldet (Log +
  Konsole), ohne die Migration abzubrechen - diese Hosts laufen weiter.
- Die Vorabpruefung verglich in PHP byteweise, die Spalte liegt auf
  utf8mb4_unicode_ci. Umgestellt auf GROUP BY/HAVING in SQL, damit dieselbe
  Kollation entscheidet, die spaeter den Unique-Index baut.
- Ein Fehlschlag nach der Vorabpruefung liess einen zweiten Anlauf sofort an
  "Duplicate column name" sterben. Die beiden betroffenen Schema-Schritte
  stehen jetzt hinter Schema::hasColumn(), macht den Kommentar darueber wahr.
- Seeder (DatabaseSeeder, DemoCustomerSeeder) sind auf pve-*-Namen sitzen
  geblieben, weil sie dns_name nie benutzt hatten. Auf fsn-01/hel-01
  umgestellt, next_host_number entsprechend vorbelegt.

Dazu vier Kleinigkeiten: ein Test nagelte den falschen Config-Schluessel fest
(dns.zone statt platform_zone), HostName::claim() erzwingt jetzt wirklich
eine Transaktion statt es nur zu verlangen, down() vergisst nicht mehr den
Settings-Cache, und der Kommentar ueber HostName::free() nennt jetzt ehrlich
die Einschraenkung auf einen einzelnen Thread.

Alle vier Migrationslaeufe (Vorabpruefung-Kollision, Meldung fuer
unreparierte Hosts, Fehlschlag-und-erneuter-Anlauf, Rueckbau mit
Cache-Invalidierung) gegen echtes MariaDB auf einer Scratch-Datenbank
geprueft, um die parallele Billing-Session nicht zu beruehren. Voller
Testlauf: 2395 bestanden. Bericht mit allen Befehlen und Ausgaben unter
.superpowers/sdd/2026-08-01-hostname-vergabe/final-fix-report.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:52:15 +02:00
nexxo 0ac76fb05b Plan: zwei Tests, die nichts bewiesen haben
Der eine rechnete mit festen Zahlen und pruefte damit, ob PHP dividieren kann;
er liest jetzt Katalog und Konfiguration und schlaegt an, wenn ein Preis so
gesetzt wird, dass Stapeln sich wieder lohnt. Der andere legte einen Vertrag an
und pruefte die Werte, die er gerade selbst geschrieben hatte; er geht jetzt
ueber den Weg, ueber den eine Bereitstellung das Kontingent holt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:14:04 +02:00
nexxo 1e570f17fc Plan: die neuen Pakete in zehn Aufgaben
Die Reihenfolge ist der Plan: erst die drei Reparaturen am Zusatzspeicher, dann
die Umschaltung. Wer die Konfiguration zuerst umstellt, verkleinert gekaufte
Bloecke.

Eine Abweichung vom Entwurf, mit Grund: die Umschaltung wird eine Migration und
kein Befehl. Der Katalog wird von einer Migration gesetzt, mit fest
eingetragenen Werten und der Begruendung, sie muesse in fuenf Jahren dieselben
Werte setzen — ein Befehl allein liesse jede Neuinstallation und die gesamte
Testsuite auf der alten Leiter zurueck. Die 28 Testdateien mit alten Zahlen
gehoeren deshalb in dieselbe Aufgabe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:06:53 +02:00
nexxo 02957a038d Plan: vmbr0 automatisch bauen — neun Tasks, TDD
Task 1-4  bridge.sh: Erkennung, Strophe, Rueckfahrkarte, Nachsehen
Task 5    bridge-run.sh, der Treiber
Task 6-8  EnsureNetworkBridge: Guard, Start/Abriss, Abbestellen
Task 9    Pipeline, Sprachdateien, ganze Suite

Der wertvollste Test steht in Task 2: der Strophengenerator wird gegen
eine echte Shell gefahren und gegen vier Anbieterfaelle geprueft
(Hetzner /32 routed, DHCP, Subnetz, statisches IPv6 mit fe80::1). Die
Strophe ist das, was eine Maschine umbringt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:02:54 +02:00
nexxo a7c7f81131 Entwurf: geprueft, dass ein laufender Vertrag seine Version einfriert
Die Frage des Betreibers war, ob die Umschaltung einem Startkunden mit 100 GB
ein Kontingent von 30 GB auf eine volle Cloud schreibt. Nachgesehen statt
vermutet: snapshotFrom() kopiert die Groessen beim Kauf auf die Vertragszeile,
CustomerStep::plan() liest die Vertragsspalten, StorageAllowance::for() nimmt
instances.quota_gb — den Katalog fragt keiner von ihnen.

Die Pakete sind also sicher, die Module nicht: dort steht die Packungsgroesse
live in der Konfiguration. Die Reihenfolge gilt fuer die Bloecke, und fuer die
gilt sie zwingend.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:55:30 +02:00
nexxo e4d4fd0cfb Entwurf: die neuen Pakete
Die Leiter wird auf die Maschine zugeschnitten, die dasteht. Speicher bindet die
Kundenzahl je Host, nicht Rechenleistung: die alte Leiter band bei drei Kunden je
Server und ließ sechs RAM-Plaetze verfallen. Neu sind es neun Kunden a 40 GB
Platte, 351 EUR statt 147 EUR je Server.

Kopfraum waechst mit: max(10 GB, 12 %). Eine 175-GB-Nextcloud mit 50 Nutzern hat
eine groessere Datenbank und mehr Versionsstaende als eine mit drei Leuten, und
eine volle Platte legt nicht den Upload still, sondern die Instanz.

Der Zusatzblock geht auf 20 GB fuer 15 EUR und ist bei drei gedeckelt. Bei 12 EUR
waere er mit 0,60 EUR/GB billiger gewesen als jeder Aufstieg (0,73 und 0,67) —
dann stapeln Kunden, statt umzusteigen, und belegen den knappsten Rohstoff zum
niedrigsten Preis. Ein Block belegt 22 GB Platte, damit die Kopfraum-Regel auch
fuer ein gestapeltes Paket gilt.

Drei Reparaturen haengen daran, und eine davon steht seit laengerem im Code
angeschrieben: StorageAllowance liest die Packungsgroesse live aus der
Konfiguration, mit dem Vermerk, dass sie an dem Tag auf die Buchung gehoert, an
dem sie sich bewegt. Dieser Tag ist heute — ohne die Aenderung schrumpft ein
gekaufter 100-GB-Block in der Sekunde der Umstellung auf 20 GB.

Das Testpaket heisst Intern und verschwindet aus dem Verkauf, bleibt aber
verschenkbar. Enterprise verlaesst den Shop, weil 500 GB auf 388 GB keinen Platz
finden und der Kunde das erst nach der Zahlung erfuehre. Und damit "eigener
Server" mehr ist als ein Wort: Hosts bekommen eine Reservierung — heute nimmt
placeableIn() jeden aktiven Host, und die Kapazitaetszahlen zaehlen eine exklusiv
verkaufte Maschine mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:50:42 +02:00
nexxo 5cae870dd9 Entwurf: vmbr0 automatisch bauen
Der letzte Handgriff der Host-Uebernahme. Derselbe Mechanismus wie
BuildVmTemplate — bridge.sh + bridge-run.sh hochladen und abgekoppelt
fahren, nicht in PHP nachbauen.

Die tragenden Punkte:

- Abgekoppelt ist Bedingung, nicht Optimierung: ifreload -a nimmt die
  Leitung, ueber die der Befehl laeuft.
- Der Zeitgeber steht vor jeder Aenderung, und nur er stellt zurueck.
  Kein zweiter Ruecknahmeweg.
- Abbestellt wird erst, wenn BEIDE Richtungen stimmen: Internet
  erreichbar UND frischer WireGuard-Handshake. Nur die erste zu pruefen
  laesst den Fall zu, in dem der Host oeffentlich lebt, CluPilot
  ausgesperrt ist und die Rueckfahrkarte gerade weggeworfen wurde.
- Kein ping — Hetzners Debian-Basis hat keins, und network.sh:190 haette
  deshalb immer 'nicht erreichbar' gesagt.
- Bruecke schon da heisst nichts anfassen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:47:38 +02:00
nexxo dc35e5310f Release v1.3.93 — die Seite konnte den Host gar nicht fragen
tests / pest (push) Failing after 9m23s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
Auf echter Hardware blieb jede Messkachel leer, während der Host sichtbar
online war und vor zwei Minuten geantwortet hatte. Kein Zufall und kein
Aussetzer: ein Entwurfsfehler.

Nur der queue-provisioning-Container hängt im WireGuard-Netz — NET_ADMIN,
/dev/net/tun, das wireguard-Volume. Der app-Container, der die Konsole rendert,
hat überhaupt keine Route zu 10.66.0.x. Ich hatte den Proxmox-Aufruf in
render() gelegt, also in den einen Container, der die Management-Adresse nicht
erreichen kann. Ein Aufruf von dort konnte nie etwas anderes sein als eine
Zeitüberschreitung.

PingHosts schreibt dieselbe Regel seit Langem in seinen Kopf: "runs on the
provisioning queue, which is where the Proxmox credentials are usable." Ich
habe sie gelesen und nicht angewendet.

Verschlimmert hat es mein eigenes catch (Throwable): der Grund wurde
verschluckt, und die Kachel sagte "keine Messwerte" — ununterscheidbar davon,
dass der Host schweigt. Auf dem Testhost fiel nichts auf, weil TEST-NET
ohnehin nie antwortet.

Jetzt zwei Hälften:

- HostLoadSeries::collect() holt und legt ab, aus Jobs\CollectHostLoad auf der
  provisioning-Warteschlange, minütlich — der Takt, in dem Proxmox einen
  frischen Messwert schreibt. Ein Fehlschlag wird protokolliert, mit Host,
  Node und Grund.
- HostLoadSeries::forHost() liest nur aus dem Zwischenspeicher und öffnet nie
  eine Verbindung. Ein Test hält das mit Http::assertNothingSent() fest.

Der Eintrag lebt fünf Minuten bei minütlichem Sammeln: länger als der Takt,
damit ein ausgefallener Lauf keine Seite leert, die eine Sekunde vorher in
Ordnung war — und kurz genug, dass ein stehengebliebener Sammler die Zahlen
mitnimmt, statt eine alte Stunde als aktuell stehenzulassen.

Der Sammler ist ShouldBeUnique (Codex-Befund, P1). Die provisioning-Warteschlange
ist DIESELBE, auf der Kunden-Bereitstellung läuft; ein stiller Host kostet den
vollen HTTP-Zeitablauf, und ohne diese Sperre stauten sich minütlich neue Läufe
hinter dem alten und verzögerten bezahlte Arbeit. Dasselbe Mittel, das
CollectInstanceTraffic nebenan schon benutzt.

Dazu: der Zustands-Punkt war mit 62 px so groß wie der Speicher-Ring nebenan.
Eine gefüllte Scheibe wiegt optisch weit mehr als ein dünner Ring und erschlug
die Kachel — jetzt 32 px.

Und ein Test, der aus Versehen recht behielt: die Kachel-Prüfung verglich mit
"50", was auch die 500 GB in der Instanzenliste darunter trifft. Sie prüft
jetzt Zahlen, die sonst nirgends auf der Seite vorkommen.

Noch offen, nicht hier angefasst: VmTemplateCheck fragt die Proxmox-API
ebenfalls aus dem app-Container heraus, von der Bereitschaftsseite aus. Selber
Fehler, Bestand, eigener Punkt.

Geprüft: 2299 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit
eingespielten Messwerten: sechs gefüllte Kacheln, Zustands-Scheibe in
Proportion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 11:23:00 +02:00