Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die
Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort
dort entsteht, wo niemand sonst es sieht.
Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert
mit Grund.
Zwei bestehende Pruefungen mitgezogen: CustomerStepBaseTest erwartete eine
feste Schrittzahl (16 -> 17), und CustomerProvisioningEndToEndTest lief ohne
Mailversand-Fixtures durch die volle Pipeline und scheiterte jetzt genau dort
— beide auf dieselbe Art nachgezogen wie ApplyStorageQuota es vormacht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Methode sollte das SMTP-Passwort aus der Prozessliste der Kunden-VM
halten. Nachgerechnet: guestExec faehrt die ganze Zeile als /bin/sh -c, die
innere Shell setzt den Wert vor dem exec ein, und Nextcloud legt ihn danach
ohnehin im Klartext in config.php ab. Eingespart wurde einzig das Argv des
docker-Aufrufs, auf einer Maschine, die den Wert an zwei anderen Stellen
zeigt.
Ein Versteck, das dieselbe Person nicht aussperrt, die den Ablageort lesen
kann, ist keines — und ein Kommentar, der Schutz behauptet, haelt den
Naechsten vom Nachsehen ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Kopfkommentar versprach falsch, dass der Wert nur im Container staende.
Die Realitaet: guestExec() uebergibt die ganze Zeichenkette als sh -c, daher
steht der Praefx im Argv der VM-Shell. Debians dash optimiert ihn nicht weg.
Der echte Gewinn: Der Wert reist als Umgebungsvariable in den Container und
wird dort von sh eingesetzt — sitzt nicht im Prozessabbild DES CONTAINERS.
Das war auch bei command() mit OC_PASS=... schon so.
Testname: haelt den Wert aus dem occ-Aufruf heraus (prueft Innenteil).
Kommentar: Das "sh -c" trennt VM-Shell von Container.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
config:system:set will den Wert als Argument. Setzte ihn die aeussere Shell
ein, stuende das SMTP-Passwort in der Prozessliste der Kunden-VM. Mit `sh -c`
im Container steht dort nur der Variablenname.
Im Kopfkommentar steht ausdruecklich, dass das Hygiene ist und kein Schutz:
Nextcloud legt den Wert danach im Klartext in config.php ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Passwort-Test prüfte nur die Abwesenheit des Schlüssels
mail_smtppassword — ein Leck unter anderem Namen wäre durchgerutscht. Prüft
jetzt zusätzlich, dass der Passwort-WERT in keinem values()-Eintrag steckt.
mail_smtpauth stand fest auf 'true', obwohl Mailbox::isConfigured() ein
Postfach ohne Anmeldung ausdrücklich zulässt. Nextcloud hätte sich dann mit
leerem Passwort anzumelden versucht — jeder Versand wäre still gescheitert.
Richtet sich jetzt nach $box->authenticates, mit Test für den false-Fall.
Server aus app_settings, Zugangsdaten aus dem Postfach, an einer Stelle
zusammengelegt. Fehlt eines von beiden, wird NICHTS geschrieben: eine halb
eingetragene Mailkonfiguration laesst Nextcloud bei jedem Versand still
scheitern.
mailboxFor() ist die Naht, an der spaeter ein Konto je Kunde haengt.
gedrosselteSpur() las die Spur nur aus $this->mailable->queue. Die setzt aber
allein RidesALane, und den tragen ausschliesslich unsere vierzehn Klassen. Eine
Mailklasse aus einem Paket — Fortify, ein kuenftiges Abhaengigkeitspaket —
bekam damit gar keine Drossel.
Das ist die Gegenrichtung zu der Entscheidung aus Aufgabe 1: dort faellt eine
unbekannte Klasse ausdruecklich in die ruhige Spur, weil eine zu langsam
verschickte Mail ein kleinerer Fehler ist als ein ungedrosselter Schub, den
niemand vorhergesehen hat. Steht keine Spur am Mailable, entscheidet jetzt
MailLane::for() — dieselbe Quelle, die Aufgabe 1 dafuer gebaut hat.
Schlange und Takt fallen fuer so eine Mail auseinander: sie bleibt auf default,
weil die Schlange der Trait waehlt, und faehrt trotzdem im ruhigen Takt, weil
den der Auftrag waehlt. Der Kommentar sagt, warum das kein Versehen ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Eine Spur, die steht, sah von außen aus wie eine Spur, die leer ist —
Queue::size() unterscheidet beides nicht. Die neue Prüfung liest das
Alter des ältesten wartenden Auftrags je Spur ausdrücklich über die
Redis-Verbindung `redis` (dieselbe, mit der der Arbeiter in
docker-compose.yml läuft), unabhängig von QUEUE_CONNECTION — und bleibt
damit auch unter der auf sync gezwungenen Testsuite ehrlich, weil Redis
in beiden Umgebungen erreichbar ist. Die Grenze liegt bei einer Stunde:
20 je 10 Minuten (die langsamste Spur) sind 120 in der Stunde, und ein
Lauf dieser Größe steht bei elf Kunden nicht so lange an.
Die eigene Seite admin/mail-pace (Task 5) bekam dabei einen eigenen
tab-Wert in Readiness::checkUrl() — der bestehende ReadinessPageTest
hält jeden Verweis gegen die Stelle, an der sein Feld wirklich liegt,
und 'mail' hätte auf die falsche Seite gezeigt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wenn die Drossel je klemmt, muss der Ausweg ein Klick sein und kein Deployment.
Eigene Seite (admin/mail-pace) statt eines fünften Abschnitts auf admin/mail:
die dortige Seite ist bereits "alles in einer Wurst", und diese hier
beobachtet laufenden Betrieb statt etwas einzurichten — deshalb auch in der
Navigation unter "Betrieb", nicht unter "System".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mein eigener Befund "die Zeile erneuert sich nach dem Aufheben nicht" war
falsch — eine Pruefung ueber den ganzen Weg (Modal schickt das Ereignis,
Seite faengt es) zeigt, dass die Zeile sehr wohl in den Verlauf wandert.
Dabei fiel der echte Fehler auf, der daneben lag:
HostFirewall::release() schluckt jeden Fehlschlag und meldet ihn nur ins
Log. Beide Aufrufer verwarfen den Rueckgabewert und meldeten in jedem Fall
"Sperre aufgehoben." War der Host im Moment des Aufhebens nicht erreichbar,
stand der Datensatz auf aufgehoben und die Regel noch drin: Portal und
Konsole zeigten "Aufgehoben", waehrend die Adresse weiter ausgesperrt blieb
— bis zum Ablauf der urspruenglichen Sperrzeit, ohne dass es jemand sagen
konnte.
- release() gibt zurueck, ob die Firewall schon nachgezogen hat; alle drei
Stellen (Portal, Host-Ansicht, Kunden-Ansicht) sagen es, wenn nicht.
- releaseMany() als Gegenstueck zu blockMany(): eine SSH-Sitzung statt einer
je Adresse.
- ScanForIntrusions gleicht jetzt in BEIDE Richtungen ab. Bisher trug er nur
ein; nichts nahm je einen haengengebliebenen Eintrag wieder heraus. Eine
Adresse, die eine ANDERE aktive Sperre desselben Hosts noch traegt, bleibt
stehen.
- Jeder Loeschbefehl traegt `2>/dev/null || true`: nft scheitert am
Loeschen eines Elements, das es nicht gibt, und weg ist genau das Ziel.
Ohne das meldete der Abgleich bei jedem Lauf einen Fehlschlag.
8 neue Pruefungen, Suite 2625 gruen.
Ohne diese Zeile laege nach den beiden vorigen Aufgaben jede Mail unverschickt
in einer Schlange, die niemand abholt — still, ohne Fehlermeldung. Code und
Compose gehoeren deshalb zusammen ausgerollt.
Diese Aenderung wirkt erst, wenn der Arbeiter-Container neu gestartet wird —
ein laufender alter Arbeiter liest weiter nur `default`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fix-Welle nach dem Gesamt-Review, Punkte 4 und 5. Beide sitzen in denselben
Dateien, deshalb ein Commit.
DIE HOST-MELDUNG WAR WORTWOERTLICH DIE KUNDENMAIL (Punkt 4).
notifyHostManagers() verschickte dieselbe SecurityBlockMail. Deren Text ist fuer
den Kunden geschrieben ("bei Ihrer Cloud", "…koennen Sie die Sperre auf der
Sicherheitsuebersicht aufheben"), der Knopf zeigte auf portal.security — wo ein
Betreiber kein Konto hat und Host-Sperren nie erscheinen —, welcher Host
betroffen ist stand nirgends, und die Anrede blieb leer, weil $block->instance
null ist.
Die Mail kann jetzt beide Faelle. Unterscheidungsmerkmal in der Ansicht ist
$hostName: gesetzt heisst Host-Sperre, null heisst Instanz-Sperre. Ein zweites
Flag daneben waere eine zweite Wahrheit.
- Betreff nennt den Host.
- Eigener Textblock (mail_heading_host, mail_preheader_host, mail_intro_host mit
"fehlgeschlagene SSH-Anmeldungen am Host :host"), eigene Tabellenzeile
field_host, eigener Hinweis mail_note_host.
- Der Knopf zeigt auf admin.hosts.show, also auf die Host-Detailseite der
Konsole, wo die Host-Sperren mit ihrem Aufheben-Knopf stehen.
- Keine Anrede im Host-Fall — sie war vorher leer, und der Empfaengerkreis ist
ohnehin mehrere Personen.
- Der Fehlalarm-Satz ist ein anderer und sagt, was ein Betreiber wissen will:
bestehende Verbindungen bleiben unberuehrt, die Regel steht unter
"established,related accept".
R19: die zwei Kommentare, die fuer Host-Sperren Unwahres behaupteten, sind
richtiggestellt — der Kopfkommentar von SecurityBlockMail und der
MailCatalogue-Eintrag. Beide beschreiben jetzt zwei Empfaengerkreise. Ein
Kommentar mit falscher Begruendung haelt den Naechsten vom Nachsehen ab.
DIE WEGWAHL VERSCHOB DEN ABSENDER, NICHT DAS KONTO (Punkt 5).
SecurityBlockMail nagelte mailer('cp_system') fest und holte das Postfach ueber
die Wegwahl. MailboxTransport loeste weiter ueber den ZWECK auf und
authentifizierte mit dessen Zugangsdaten. Legte der Betreiber security-block auf
info@ — der ausdrueckliche Anlass fuer Abschnitt 3a der Spec —, ging die Mail
mit From: info@ durch die SMTP-Sitzung von no-reply@. Viele Server weisen das ab
("553 5.7.1 Sender address rejected: not owned by user"), und dann verschwindet
ausgerechnet die Mail, die er gerade umgelegt hat. Betraf ALLE Mailarten.
Die Wegwahl bestimmt jetzt auch den Mailer. Die zweitbeste Loesung — die Auswahl
in der Konsole auf Postfaecher zu begrenzen, deren Konto benutzt wird — war
nicht noetig.
- config/mail.php erzeugt AUS MailCatalogue::all() je Mailart einen Mailer
cp_mail_<mailart> mit transport, purpose und mail_key. Erzeugt statt
aufgelistet, weil der Katalog ausdruecklich die EINE Liste ist; eine zweite
hier waere die, die beim siebzehnten Mail vergessen wird. Genauso statisch wie
die fuenf Zweck-Mailer: ein reines Array, keine Datenbank, auch unter
config:cache unveraendert.
- MailboxTransport nimmt den mail_key entgegen und loest damit ueber DIESELBE
MailRoute::purposeOrMailbox() auf, die auch der Umschlag fragt. Ohne
Schluessel entscheidet wie bisher allein der Zweck — jeder Mailer, der keinen
hat, verhaelt sich unveraendert.
- Jede Mailart mit Schluessel benennt ihren Mailer ueber
MailCatalogue::mailer(). Dessen Rueckfall auf cp_<zweck> fuer einen
unbekannten Schluessel ist nicht Zierde: DunningNoticeMail baut seinen
Schluessel aus der Mahnstufe, und ohne den Rueckfall waere eine Stufe
ausserhalb 0-3 ein "Mailer [...] is not defined" und damit ein Totalausfall
statt eines falschen Absenders.
- Betrifft alle Mailarten: 13 Mailables, die CloudReady-Notification und die
cloud-ready-Vorschau. ContactRequestMail bleibt auf cp_support — es hat keinen
Schluessel im Katalog, weil es keine eigene Mailart ist.
MailSenderOwnershipTest ist mitgezogen und dabei STRENGER geworden. Die alte
Fassung verglich "From-Zweck == Mailer-Zweck"; jetzt prueft sie, dass der
Katalog dem Zweck zustimmt, den der Umschlag verlangt. Katalog-Drift war bis
hierher nur als Warnung im Docblock von MailCatalogue festgehalten und durch
nichts erzwungen. Ein Literal, das selbst kein Schluessel ist, wird als PRAEFIX
behandelt (der Mahnlauf) — dann muessen alle Schluessel dahinter zustimmen, und
es muss mindestens einen geben.
MailPreviewTest fragte den Zweck bisher, indem es 'cp_' vom Mailer-Namen
abschnitt. Das war eine Zusicherung ueber das Namensschema, nicht ueber die
Zusage; sie liest den Zweck jetzt aus der Konfiguration des Mailers selbst.
tests/Feature/Mail/SenderAddressTest.php gehoert inhaltlich zur parallel
laufenden Sitzung. Vier Zusicherungen darin pruefen genau den Mailer-Namen, den
dieser Commit aendert, also sind sie mitgezogen — die Datei war dabei sauber,
es steckt keine fremde unfertige Arbeit in diesem Commit.
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>
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>
Fix-Welle nach dem Gesamt-Review, Punkte 1, 2, 6, 7, 8, 9 und die Haelfte von 3,
die den stillen Misserfolg sichtbar macht.
BEFEHLSEINSCHLEUSUNG ALS ROOT (Punkt 1, Blocker). `remoteAddr` ist eine
beliebige Zeichenkette aus einer Datei IM GAST. `BlockAddress::isExempt()`
prueft nur Zugehoerigkeit — bei Unsinn liefert IpUtils schlicht false, und es
ging weiter. `HostFirewall::block()` schob die Zeichenkette per sprintf in ein
nft-Kommando, und PhpseclibRemoteShell::run() reicht das an /bin/sh ALS ROOT auf
dem Proxmox-Host. Ein Semikolon darin war ein zweiter Befehl.
Dicht an BEIDEN Enden, nicht an einem:
- Im Leser jede Adresse durch filter_var(FILTER_VALIDATE_IP), alles andere
verworfen — im Nextcloud-Zweig, der gar kein Gegenstueck hatte, UND im
SSH-Zweig. Dessen preg_match schliesst ein Semikolon zwar aus, laesst aber
"1.2.3" und "...." durch, und der Riegel darf nicht davon abhaengen, dass
jemand beim naechsten Aendern des Musters daran denkt.
- In HostFirewall derselbe Riegel noch einmal, vor jedem sprintf: false zurueck,
nichts ausgefuehrt, nicht einmal verbunden. Ein Dienst, der eine Wurzel-Shell
fuettert, darf sich nicht darauf verlassen, dass sein Aufrufer sauber war.
NUR OEFFENTLICHE ADRESSEN IM INSTANZ-ZWEIG (Punkt 2, Blocker). TRUSTED_PROXIES
steht in der .env des Gastes nie, waehrend Traefik auf dem Host sitzt: was
Nextcloud protokolliert, ist entweder die Bruecken-adresse des Hosts (dann
zaehlen ALLE Kunden unter einer Adresse und ein echter Angreifer nie) oder ein
X-Forwarded-For, das der Client SELBST waehlt (dann macht sich ein Angreifer mit
10.66.0.1 dauerhaft ausnahmefaehig und sperrt mit einer fremden Adresse gezielt
Unbeteiligte aus). Beide Schadensfaelle sind privat oder reserviert, ein echter
Angreifer ist es nicht — also FILTER_FLAG_NO_PRIV_RANGE|NO_RES_RANGE, aber nur
hier. Im Host-Zweig stammt die Adresse aus dem SSH-Journal und ist echt.
Absichtlich ZWEI getrennte Pruefungen mit zwei getrennten Kommentaren an
derselben Stelle, nicht ein zusammengefasster Aufruf: das eine ist ein Riegel
gegen Einschleusung, das andere eine Vorsichtsmassnahme. Verschmolzen haette die
naechste Person beim Entfernen der Vorsicht den Riegel mitgenommen. Der Kommentar
sagt auch, was die Vorsicht abloest — die Vertrauenskette zum Gast sauber setzen
und BEWEISEN.
EINE VERBINDUNG JE HOST STATT EINE JE SPERRE (Punkt 7). reapplyActiveBlocks()
rief block() je aktiver Sperre, und jede baute ihre eigene SSH-Sitzung auf: bei
dreissig Sperren dreissig Handshakes pro Minute, seriell, auf dem einzigen
Provisionierungs-Arbeiter — also genau WAEHREND eines Angriffs auf demselben
Arbeiter, der die bezahlte Kundenbereitstellung faehrt. Neu ist blockMany(), das
mehrere Elemente in einen nft-Aufruf legt; block() ruft es mit einem einzigen
Eintrag und behaelt seine Signatur. Hoechstens zwei Befehle, weil v4 und v6 in
getrennten Mengen leben, aber nur eine Verbindung.
Die Zusicherung "Restlaufzeit statt Ursprungsdauer" ist dabei ausdruecklich
verteidigt: blockMany() nimmt die Sekunden JE ADRESSE entgegen. Eine gemeinsame
Dauer fuers Buendel waere bequemer gewesen und haette genau das verwaessert,
wofuer es einen eigenen Test gibt. Der bestehende Restlaufzeit-Test laeuft
unveraendert, und der neue Buendel-Test prueft drei verschiedene Restlaufzeiten
in einem Aufruf.
Dabei aufgefallen: dieselbe Adresse kann an zwei Subjekten desselben Hosts
gesperrt sein (Host und Instanz, oder zwei Instanzen), und in der nftables-Menge
ist das EIN Element. Beim Buendeln gewinnt die laengere Restlaufzeit — die
kuerzere zuletzt einzutragen wuerde die laengere Sperre verkuerzen.
EINE SPERRE, DIE NUR IN DER DATENBANK STEHT, DARF NICHT SCHWEIGEN (Punkt 3,
erste Haelfte). Auf einem Host, der noch das Regelwerk ohne die Mengen traegt,
scheitert nft add element bei JEDEM Versuch, und beide Aufrufer verwarfen den
Rueckgabewert: die Sperre stand in Datenbank, Portal, Konsole und in der Mail an
den Kunden als aktiv und in der Firewall nie. Der report() sitzt deshalb in
HostFirewall::apply() selbst, nicht an den Aufrufern — an der Stelle, an der der
Misserfolg anfaellt, kann ihn kein dritter Aufrufer wieder vergessen. Wie man
so einen Host nachzieht, kommt im naechsten Commit.
DER RING UM scanHosts() (Punkt 6). Die Nachbarmethode hat ihn seit Fix-Runde 2,
diese ging leer aus. FailedLoginReader::fromHost() faengt seine eigene
SSH-Ausnahme zwar ab, aber alles danach nicht: SecurityBlock::create(),
Settings::set(), Spaties PermissionDoesNotExist, $host->save(). Wirft eines
davon, bricht der ganze Lauf ab — und vor allem laeuft reapplyActiveBlocks()
nicht, jede Minute erneut.
DIE AUSNAHME FUER DEN EIGENEN SERVER GRIFF NUR BEI EINER IP (Punkt 8).
Str::beforeLast(':') lieferte bei "vpn.clupilot.com:51820" einen Hostnamen, und
IpUtils::checkIp() sagt dazu immer false — in der Form, die .env.example
dokumentiert, war die eigene Adresse still NICHT ausgenommen, der Server konnte
sich also selbst aussperren. Bei IPv6 blieben zusaetzlich die Klammern stehen.
hostPartOf() kennt jetzt die drei Formen, die wirklich vorkommen, ein Name wird
aufgeloest, und ALLE Ergebnisse werden ausgenommen (nur die erste zu nehmen
hiesse, sich bei einem Wechsel im Round-Robin doch wieder auszusperren).
Schlaegt die Aufloesung fehl, faellt der Eintrag weg wie bei leerem Endpunkt.
App\Support\DnsLookup als eigene Klasse, damit ein Test sie im Container
ersetzen kann: ein Test, der fuer seine Zusicherung echtes DNS braeuchte,
prueft das Netz des Rechners, auf dem er gerade laeuft, nicht die Regel. Sie
holt A UND AAAA — gethostbynamel() allein kennt nur A, ein reiner
AAAA-Endpunkt waere sonst still nicht ausgenommen gewesen, derselbe Fehler eine
Adressfamilie weiter. Die Liste wird je BlockAddress-Instanz einmal aufgeloest
und gemerkt; ein Lauf holt sich den Dienst einmal und fragt damit jede
gesichtete Adresse.
DER ERSTE LAUF LIEST NICHT MEHR JEDES PROTOKOLL VON VORN (Punkt 9). Ist der
gespeicherte Versatz 0, wird stat -c %s als Startwert genommen und gar nichts
gelesen. Gesperrt wuerde dabei ohnehin nichts, weil das Zehn-Minuten-Fenster
jede aeltere Zeile wegwirft — es waere eine grosse Uebertragung durch den
Gastagenten, alle Instanzen im selben Auftrag, fuer ein garantiert leeres
Ergebnis. Bewusst getrennt vom Rotationsfall: dort ist die Datei KLEINER als der
Versatz und muss von vorn gelesen werden, weil das Neue am Anfang steht. Der
Code unterscheidet dafuer jetzt $stored (was in der Spalte steht) von $offset
(ab wo gelesen wird).
Der Fixture-Helfer aktiveInstanz() setzt deshalb security_log_offset auf 1: 0
heisst ab jetzt woertlich "noch nie gelesen", und die Tests dieser Datei
beschreiben den Normalfall einer Instanz, die schon einmal gelesen wurde.
Nebenbei: SecurityConsoleTests erster Fall hing 135 Sekunden am
TCP-Verbindungsaufbau, weil das Aufheben einer Sperre HostFirewall::release()
ruft und kein Fake gebunden war — mehr als der gesamte uebrige Ordner zusammen.
Ein beforeEach behebt das; am Verhalten aendert sich nichts.
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>
Eine zeitliche Grenze hebelt --tries nicht bloss fuer Rueckstellungen aus,
sondern ganz: Worker::markJobAsFailedIfWillExceedMaxAttempts prueft die
Versuchszahl ausdruecklich nur if (! $job->retryUntil()). Damit galten die drei
Versuche seit dem Takt NIRGENDS mehr — auch nicht auf der Direktspur und auch
nicht bei ausgeschaltetem Notschalter.
Eine Mail, die aus echtem Grund wirft (SMTP tot, PDF-Rendern kippt), wurde
dadurch sechs Stunden lang wiederholt statt nach drei Versuchen abgelegt, und
ohne Pause, weil der Arbeiter ohne --backoff laeuft. Ihr Fehler stand sechs
Stunden lang nicht in failed_jobs, und auf der Direktspur haette eine einzige
giftige Mail sechs Stunden lang die Kennwort-Zuruecksetzungen blockiert, sobald
Aufgabe 4 den Arbeiter in Prioritaetsreihenfolge lesen laesst.
retryUntil() haengt jetzt an derselben Bedingung wie die Drossel, beide lesen
gedrosselteSpur(). Vorher wird die Elternklasse gefragt: SendQueuedMailable
reicht an die Mailklasse weiter, und die Ueberschreibung nahm das still weg.
Dazu zwei kleinere Punkte aus derselben Durchsicht: der Kopfkommentar behauptete
weiter, eine middleware() auf der Mailklasse lese niemand — widerlegt am
Vendor-Code, jetzt steht der echte Grund dort. Und (int) Settings::get() ergab
auch ohne eingetipptes 0 eine Null (gespeicherte null, nichtnumerischer Wert);
eine Untergrenze steht jetzt an der Lesestelle, an der der Verlust passiert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SendQueuedMailable kennt keine middleware() — nachgesehen im Framework. Der Weg
fuehrt ueber einen eigenen Auftrag, den Mailable::newQueuedJob() ueber den
Container erzeugt; eine Bindung tauscht ihn fuer alle Mails aus, ohne dass eine
Absendestelle sich aendert. mail-wichtig bekommt 30 je 5 Minuten, mail-ruhig 20
je 10, mail-direkt gar keine Drossel — dort wartet gerade ein Mensch.
Der Arbeiter laeuft mit --tries=3, und eine gedrosselte Rueckstellung zaehlt
als Versuch. Ohne retryUntil() waere jede Rechnung nach dem dritten Drosseln
gescheitert statt verschickt. Der tragende Test faehrt den echten Arbeiter
gegen ein zu kleines Kontingent und belegt beides: failed_jobs bleibt leer, und
der hoechste Versuchszaehler ist vier — der Lauf hat die Linie wirklich
ueberschritten.
Die Bindung aendert den Klassennamen des eingereihten Auftrags, deshalb ziehen
zwei bestehende Zusicherungen in MailLaneRoutingTest und SenderAddressTest
nach: QueueFake legt Auftraege unter ihrem exakten Klassennamen ab.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
glob('Mail/*.php') sah nur die oberste Ebene von app/Mail. Der Ordner hat
mit Concerns/ und Transport/ schon zwei Unterordner - eine kuenftige
Mailklasse dort waere dem Waechter nie aufgefallen, und die Mail liefe
still auf MailLane::CALM statt auf eine bewusst gewaehlte Spur.
File::allFiles() sucht rekursiv; is_subclass_of(..., Mailable::class)
schraenkt danach auf echte Mailables ein, statt auf "liegt irgendwo unter
app/Mail" - ein Trait wie RidesALane selbst oder eine kuenftige
Basisklasse macht den Test damit nicht mehr faelschlich rot.
Falsifizierbarkeit erneut geprueft, diesmal an OrderConfirmationMail statt
an der zuvor benutzten VerifyEmailMail: Trait entfernt, Test rot mit genau
dieser Klasse benannt, Trait zurueckgesetzt, Test wieder gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aufgabe 6 des Fruehwarnsystems. Der Inhaber sieht im Portal die Sperren SEINER
Instanzen und hebt sie dort auf; der Betreiber sieht in der Konsole alle, an der
Kunden- und an der Host-Detailseite, und auf der Uebersicht steht ein Hinweis,
solange irgendwo eine Sperre aktiv ist.
Aufgehoben wird ueber ein Bestaetigungs-Modal (R23), und das Modal mutiert
nichts: es wirft ein Ereignis, das die Seite auffaengt und an ihre eigene
Methode weiterreicht. Die Berechtigungspruefung bleibt damit an der einen
Stelle, an der sie schon stand — noetig, weil ein Modal ohne die Middleware der
Seite erreichbar ist (R20). Dazu die Berechtigung `instances.manage`, nach dem
Muster der bestehenden `instances.restart`-Migration; Abrechnung und Read-only
bleiben unberuehrt.
ACHTUNG, was hier sonst noch drinsteckt und NICHT zu dieser Aufgabe gehoert:
rund 150 Zeilen zum Versandtakt — das Merkmal `RidesALane`, vierzehn Mailables
und `MailLaneRoutingTest`. Die stammen aus einer PARALLEL laufenden Sitzung an
einem anderen Feature.
Wie das hineingeriet: der Implementierer dieser Aufgabe brach vor dem Commit ab
und liess seine fertige Arbeit ungespeichert im Baum. Ich habe sie dateigenau
mit `git add <dateien>` vorgemerkt, um nichts Fremdes mitzunehmen — und dabei
uebersehen, dass `git add` nur HINZUFUEGT: die andere Sitzung hatte ihre Arbeit
bereits vorgemerkt, und `git commit` schreibt den ganzen Index, nicht nur das
zuletzt Hinzugefuegte. Richtig waere `git commit -- <dateien>` gewesen.
Nichts ist verloren, und die volle Suite ist auf diesem Stand gruen (2571).
Aber diese Botschaft soll nicht behaupten, sie beschreibe alles, was hier steht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Trennung laeuft zwischen dringend und nicht dringend, nicht zwischen
Massenversand und Einzelmail: eine Ausfallmeldung geht an alle UND eilt.
Die sieben Mails, auf die jemand wartet, sind gesperrt statt nur
voreingestellt — auch eine von Hand geschriebene Einstellung verschiebt sie
nicht.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Marke stand bisher VOR dem Versandversuch - scheiterte das Einreihen,
schwieg das Subjekt fuer den Rest der Stunde, obwohl null statt einer Mail
angekommen war. Jetzt steht sie in notifyInstanceOwner() erst nach dem
erfolgreichen queue() im try-Block, in notifyHostManagers() erst nach
mindestens einem geglueckten Einreihen (Merker ueber die Schleife, da ein
einzelnes abgelehntes Postfach weder die uebrigen Betreiber noch die Marke
fuer alle blockieren darf). Kein Sturm-Risiko: Sperren entstehen ohnehin
nur ab der Zehner-Schwelle, nicht bei jedem Fehlversuch.
Die Kommentare behaupteten außerdem, das try/catch finge Zustellungsfehler
ab - tatsaechlich faengt es nur, was beim EINREIHEN schiefgeht (synchron,
vor der Warteschlange); ein Zustellungsfehler passiert spaeter im
Warteschlangen-Arbeiter und steht in dessen Protokoll. Beide Docblocks
richtiggestellt.
Neuer Testfall haengt einen Wrapper vor die gefakte Mail-Fassade, dessen
erster to()-Aufruf wirft und ab dem zweiten an die echte Fake-Instanz
durchreicht - MailFake::queue() selbst kann einen Fehlschlag nicht
simulieren, weil es den Mailable nur ablegt und dabei nie wirft. Als
Gegenprobe testweise auf den alten Code zurueckgesetzt: Testfall lief rot
mit der erwarteten Meldung, Datei danach byte-identisch wiederhergestellt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Koordinator-Entscheidung statt neuer Einstellung: der Empfaengerkreis ist
per Definition richtig, weil es genau die Menschen sind, die eine
Host-Sperre in der Konsole ueberhaupt aufheben duerfen, und er pflegt sich
bei jedem Rollenwechsel von selbst mit. notifyHostManagers() nutzt Spaties
eigenen Operator::permission()-Scope (dieselbe Pruefung wie authorize()
an anderer Stelle, nur als Mengenabfrage) - kein Empfaenger heisst keine
Mail, kein Fehler, keine Drossel-Markierung; jede Adresse einzeln in ihrem
eigenen try/catch, damit ein abgelehntes Postfach nicht die uebrigen
Betreiber um ihre Meldung bringt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SecurityBlockMail geht bei einer Instanz-Sperre an die Kontoadresse des
Inhabers, aus dem SYSTEM-Postfach wie NewDeviceSignInMail. Die Drossel sitzt
in BlockAddress::notifyInstanceOwner() ueber Settings (kein neues Feld fuer
etwas, das nach einer Stunde niemanden mehr interessiert) und wird VOR dem
Versandversuch gesetzt. Ein Throwable beim Verschicken wird gemeldet und
verschluckt: die Sperre steht schon, bevor ueberhaupt versucht wird zu
verschicken, und ein kaputtes Postfach darf sie nicht rueckgaengig machen.
Host-Sperren verschicken bewusst noch keine Mail: kein Muster im Repo, wie
eine Betreiber-Meldung ihren Empfaenger findet (siehe Bericht).
Route 'portal.security' minimal angelegt (Aufgabe 6 baut die echte Seite) -
auf einem eigenen Pfad, weil sie sich mit der oeffentlichen /security-Seite
sonst lautlos gegenseitig ueberschreiben, sobald Portal und Website ohne
eigene Domain laufen (RouteCollection indiziert ueber Methode+Domain+URI,
nicht ueber den Namen).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
guestExec() wirft bei einer echten HTTP-Antwort (->throw()), nicht nur bei
einem sauberen Fehlercode — ein abgeschalteter Gast oder ein noch nicht
gestarteter Agent liess das ungefangen durch scanInstances() nach oben
laufen und beendete handle(), bevor scanHosts() und vor allem
reapplyActiveBlocks() dran waren. Eine unbeteiligte Sperre wurde dadurch
jede Minute erneut nicht wiedereingetragen.
FailedLoginReader::fromInstance() faengt Throwable jetzt genauso wie
fromHost() es schon tat; ScanForIntrusions::scanInstances() umschliesst
zusaetzlich jede Instanz einzeln, nach dem Muster von PingHosts. Dazu ein
Testfall mit einem werfenden und einem lauten Gast nebeneinander, und
Carbon::setTestNow() jetzt in finally, damit ein abgebrochener Testfall
die Uhr nicht fuer die Folgetests eingefroren laesst.
ScanForIntrusions hielt bisher nur den Zuwachs eines einzelnen Laufs gegen
die Schwelle — bei einem Lauf pro Minute wurde aus "10 in 10 Minuten"
faktisch "10 in einer Minute", und der geduldige Angreifer mit wenigen
Versuchen je Minute lief nie darueber. Ein Zaehlstand je Subjekt und
Adresse ueber Laravels RateLimiter (cache-gestuetzt, 600s, wie
OperatorLogin es fuer Anmeldeversuche schon vormacht) addiert jeden Lauf
auf den bestehenden Stand und wird nach dem Sperren zurueckgesetzt.
FailedLoginReader liest Nextclouds Protokoll ueber den Gastagenten (mit
Byte-Versatz und Rotationserkennung) und die SSH-Anmeldungen eines Hosts
ueber journalctl. ScanForIntrusions bringt beides mit BlockAddress
zusammen, jede Minute auf der provisioning-Warteschlange, und traegt am
Ende jede noch gueltige Sperre mit ihrer RESTLAUFZEIT erneut in die
Firewall ein — der Fall, der einen Neustart des Hosts uebersteht.
NextcloudOcc bekommt einen zweiten Baustein (exec()) fuer Gastbefehle
jenseits von occ, ohne die Ein-Ort-Regel fuer "docker compose exec" zu
verletzen.
Die drei hart verdrahteten Adressen waren belegt, der vierte Eintrag —
die eigene öffentliche Adresse aus CLUPILOT_WG_ENDPOINT — lief in der
Testumgebung nie durch, weil die Variable dort leer ist. Setzt die
Einstellung, prüft die Ausnahme UND die Gegenprobe am Nachbarn in
derselben Zeile, damit der Test nicht bloß beweist, dass gar nichts mehr
gesperrt wird.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
BlockAddress trifft die Sperr-Entscheidung: Ausnahmeliste (Verwaltungsnetz,
Loopback, eigene öffentliche Adresse — hart verdrahtet), laufende Sperre nicht
doppelt, Verdopplung binnen 24h bis zur 24h-Obergrenze. Der Datensatz entsteht
unabhängig vom Rückgabewert von HostFirewall::block() — eine Sperre nur in der
Datenbank ist sichtbar und wird nachgeholt (Aufgabe 4), eine Ausnahme dort
würde den Zeitplan-Auftrag mitreißen.
Migration bringt security_blocks und im selben Zug die zwei Cursor, die
Aufgabe 4 braucht: instances.security_log_offset, hosts.security_log_seen_at.
substr_count('flags timeout') lief ueber den ganzen Text inklusive
Kommentare und belegte nur "die Phrase kommt zweimal vor", nicht "beide
set-Bloecke tragen die Ablaufzeit". Ersetzt durch je einen strukturellen
Ausdruck pro Menge; der Originalkommentar aus dem Auftragszettel kann
damit wieder wortgenau stehen. Dazu zwei neue Tests mit failConnect, die
belegen, dass block()/release() bei einem nicht erreichbaren Host false
liefern statt zu werfen - der Pfad, auf dem das Wiedereintragen in
Aufgabe 4 aufbaut.
Zwei nftables-Mengen (clupilot_blocked/clupilot_blocked6, beide mit
flags timeout) im erzeugten Regelwerk, die Drop-Regel dafuer sitzt
absichtlich unter ct state established,related accept — wer drin ist,
bleibt drin, gesperrt wird nur, was neu anklopft. HostFirewall::block()/
release() tragen eine Adresse mit Ablaufzeit ein bzw. nehmen sie heraus,
ueber die WireGuard-Adresse des Hosts, und geben false statt zu werfen,
wenn der Host nicht erreichbar ist, damit eine spaetere Wiedereintrage-
Aufgabe die Sperre einfach nochmal versuchen kann.
MailCatalogue haelt die eine Liste aller sechzehn Mailarten (Schluessel,
Beschriftung, Vorgabe-Zweck), aus MailPreviews herausgezogen, damit es
nur noch eine Stelle gibt, die beim naechsten Mailtyp vergessen werden
kann. MailRoute sitzt darueber: ein Eintrag ist eine Ausnahme fuer GENAU
diese eine Mailart, keine zweite Zuordnungsebene — ohne Eintrag oder bei
abgeschaltetem Zielpostfach faellt sie unveraendert auf den Zweck
zurueck, den MailboxResolver schon kennt.
SendsFromMailbox bekommt dafuer einen optionalen $mailKey; alle
bestehenden Aufrufer (inklusive ContactRequestMail, das mailboxAddresses
selbst zusammensetzt) bleiben bei null und damit beim alten Verhalten.
Jede Mailklasse und die CloudReady-Benachrichtigung nennen jetzt ihren
Katalog-Schluessel. Die Konsole bekommt eine vierte Karte unter der
Zweck-Zuordnung: eine Zeile je Mailart, ein <select> mit den aktiven
Postfaechern und "wie der Zweck (...)" als Vorgabe.
Der wichtigste Test schickt eine Mail ohne jeden Eintrag und prueft,
dass sie exakt beim bisherigen Postfach landet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DER FUND. Auf einem uebernommenen Host blieb die Anmeldung als root MIT PASSWORT
erlaubt. Port 22 steht waehrend der ganzen Uebernahme offen im Internet — die
Host-Firewall macht ihn erst als vorletzter von sechzehn Schritten zu. Dazwischen
lag ein Fenster von Stunden, in dem jede Maschine der Welt Root-Passwoerter
durchprobieren durfte. Und wer spaeter das Notfallskript benutzt, reisst es
wieder auf.
EstablishSshTrust schreibt jetzt /etc/ssh/sshd_config.d/99-clupilot.conf und
verbietet Passwort-Anmeldung. Der Zeitpunkt ist genau richtig gewaehlt: eine
Zeile darueber hat sich `keyLogin()` erfolgreich MIT DEM SCHLUESSEL angemeldet —
wir wissen also, dass der Weg hinein steht, bevor wir den anderen zumachen.
`reload` statt `restart`, und `sshd -t` davor. Ein Fehlschlag bricht die
Uebernahme NICHT ab: eine Haertung, die einen ganzen Aufbau scheitern laesst,
wird beim naechsten Mal weggelassen.
DIE KONSOLE SAGT ES JETZT SELBST. Neue Pruefgruppe „Sicherheit" auf der
Bereitschaftsseite, drei Punkte, alle drei aus dieser Durchsicht:
- Ist die Konsole ueberhaupt eingeschraenkt? (blockierend)
- Steht in TRUSTED_RANGES nur, was dort hingehoert? Alles andere wurde von Hand
in die .env geschrieben und erscheint in der Oberflaeche als „nicht
entfernbar" — beim naechsten Anschlusswechsel ein Aussperren.
- Haengen APP_PORT/REVERB_HOST_PORT auf der Schleife? Docker traegt
veroeffentlichte Ports VOR der Firewall ein: ein Dienst auf 0.0.0.0 ist von
aussen erreichbar, auch wenn ufw zu aussieht — und wer ihn direkt anspricht,
geht am Reverse Proxy vorbei, an dessen Zugangsliste und an TLS.
Diese Entwicklungsmaschine meldet prompt zwei davon. Genau dafuer ist die Gruppe
da: eine fehlende Einrichtung faellt beim ersten Versuch auf, eine offene Tuer
nie — bis sie jemand benutzt.
WAS DIE DURCHSICHT SONST ERGAB, und was in Ordnung ist: TrustProxies traut nur
privaten Bereichen und ausdruecklich NICHT X-Forwarded-Host, eine gefaelschte
Herkunftsadresse greift also nicht. Zwei-Faktor ist erzwungen, nicht optional.
Die Anmeldung bremst nach fuenf Versuchen. Geheimnisse liegen mit eigenem
Schluessel verschluesselt. Die Host-Firewall laesst 22 und 8006 nur aus dem
Tunnel. Der Terminal-Pfad umgeht die Netzsperre bewusst — sein Riegel ist das
Einmal-Ticket, dreissig Sekunden, an Host und Betreiber gebunden.
2523 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DER WAECHTER. Ein abgebrochenes Update liess den Stapel halb unten liegen, den
Tunnel weg und die Seite eine Stunde lang mit 500 antworten — bis jemand von
Hand nachsah. Das wartet nie wieder auf einen Menschen.
deploy/watchdog.sh laeuft jede Minute als systemd-Dienst AUF DEM WIRT, nicht in
einem Container: ein Waechter im Container braeuchte den Docker-Socket
hineingereicht (Root auf dem Wirt fuer jeden, der je hineinkommt) und waere
genau dann tot, wenn man ihn braucht. Er nimmt dieselbe Sperre wie der
Update-Agent und fasst waehrend eines Updates nichts an.
Er kennt vier Fehlerbilder, alle vier heute wirklich passiert, und fuer jedes
genau einen Griff: fehlende Dienste starten; Container, die einander nicht mehr
finden, NEU ERZEUGEN (ein Neustart hilft da nicht); wg0 hochziehen; einen seit
ueber einer halben Stunde haengenden Wartungsmodus beenden. Was er nicht kennt,
protokolliert er und laesst es in Ruhe. `maintenance-hold` ist die Handbremse.
Beides nachgemessen: Dienst gestoppt -> wieder da; wg0 abgeraeumt -> wieder oben.
DREI LUECKEN, DIE DERSELBE VORFALL AUFGEDECKT HAT:
1. `phase()` machte `mkdir -p` ohne Fangnetz. Gehoerte storage/ nach einem
frueheren Fehltritt root, starb das Update mit `set -e` an seiner ERSTEN
Zeile, ohne eine einzige Ausgabe. Von aussen sah das aus wie
"haengengeblieben"; in Wahrheit war es nach einer Millisekunde vorbei.
2. Ein zweiter Aufruf meldete "Already up to date" und tat NICHTS — der Checkout
stand ja schon auf dem Ziel. Der Commit ist die falsche Frage; jetzt wird der
Zustand gefragt: laeuft jeder Dienst, ist der Wartungsmodus aus.
3. Nach einer Netz-Umstellung reicht `up -d` nicht: nur neu gestartete Container
haengen weiter am alten Netz, alle laufen, und trotzdem loest kein Name mehr
auf. Jetzt `--force-recreate`.
DIE VIER PUNKTE AUS DEM BETRIEB:
- Provisioning zeigte 15/16 in der Liste und "16 von 16" in der Karte daneben,
bei Status "Fertig" und 100 %. Die Liste rechnete `current_step + 1` — "dieser
Schritt laeuft gerade" —, und bei einem fertigen Lauf laeuft keiner mehr.
- Mahngebuehren standen in CENT im Feld. Wer eine Gebuehr eintraegt, denkt in
Euro und tippt "5"; daraus wurden fuenf Cent, ohne Widerspruch. Jetzt Euro im
Feld, Cent in der Datenbank, gerundet statt abgeschnitten ((int)(19.99*100)
ist 1998). Und Fristen und Geld stehen in zwei eigenen Bloecken mit eigener
Ueberschrift, die Einheit im Feld statt in der Beschriftung.
- Die Bueroadresse stand in der Konsole neben dem Management-Netz mit dem
Vermerk "nicht entfernbar" — sie kommt aber aus TRUSTED_RANGES in der .env und
ist sehr wohl aenderbar. Beim naechsten Umzug des Anschlusses waere das ein
Aussperren gewesen. Strukturell sind nur zwei Eintraege; alles andere steht
jetzt als das da, was es ist, mit dem Weg heraus.
- Ein Host-Zugang hiess weiter "pve-fns-1", waehrend der Host laengst "fsn-01"
hiess. Die Zeile zeigt jetzt den Namen des Hosts, nicht den einmal
gespeicherten — damit traegt sie jede kuenftige Umbenennung von selbst.
Und die Frage "wofuer brauche ich Uptime Kuma": es ist benutzt — RegisterMonitoring
legt fuer jede Kunden-Instanz eine Ueberwachung an, SyncMonitoringStatus holt den
Stand, und ein Ausfall steht auf der Uebersicht. Ohne Kunden-Instanzen gibt es
nichts zu sehen, was wie "unbenutzt" aussieht. Das steht jetzt in den
Einstellungen, statt dort nur "API-Token und wo die Bruecke erreichbar ist".
2522 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
Der Betreiber hat es dreimal hintereinander erlebt: Update gefahren, VPN weg.
Die Ursache dafuer, dass es JEDES Mal passierte, stand hier:
docker compose restart queue queue-provisioning scheduler reverb
In dessen Netz-Namensraum lebt wg0. `restart` baut den Namensraum neu auf, also
riss jede WireGuard-Sitzung ab — die des Betreibers am Telefon wie die jedes
Hosts. Fuer ein Update, das nur PHP-Code aendert, ist das ein absurder Preis.
Seit der Arbeiter dort in einer Schleife laeuft (v1.4.4), geht es billiger:
`queue:restart` setzt ein Signal, der Arbeiter beendet sich nach dem laufenden
Auftrag, die Schleife startet ihn mit dem neuen Code neu. Der Container bleibt
stehen, wg0 bleibt oben, niemand merkt etwas.
Und fuer den Fall, dass `up -d` ihn doch neu baut (neues Abbild, geaenderte
Konfiguration): das Skript merkt sich die Container-ID vorher und nachher. Hat
sie sich geaendert, raeumt es die gemerkten UDP-Stroeme selbst weg —
`sudo -n conntrack -D -p udp --dport <port>`, und wenn es das nicht darf, steht
der Befehl als Warnung im Protokoll statt gar nichts.
Dieser Handgriff war bisher muendliche Ueberlieferung. Ohne ihn zeigen die
gemerkten Stroeme weiter auf den alten Container, und sie verfallen nicht:
WireGuard schickt alle 25 Sekunden ein Lebenszeichen und haelt den kaputten
Eintrag am Leben. Genau deshalb kam ein Telefon nach Aus- und Einschalten sofort
zurueck (neuer Quellport) und ein Host mit festem Port ueberhaupt nicht.
Ein Test haelt beides fest: queue-provisioning darf nicht in der Neustart-Liste
stehen, und der conntrack-Griff muss im Skript bleiben.
2509 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Ursachen, eine davon habe ich mit v1.4.2 selbst gelegt.
1. DER NETZ-ALIAS. Damit in docker/nginx/default.conf `terminal:8082` stehen
konnte statt des Namens des Nachbarcontainers, bekam queue-provisioning einen
Eintrag unter `networks: aliases:`. Das ist Teil seiner NETZKONFIGURATION —
und Compose baut einen Container neu, sobald die sich aendert. Neu gebaut
heisst neue Adresse im Compose-Netz, heisst neu geschriebene
Weiterleitungsregeln fuer den veroeffentlichten UDP-Port 51820. Auf dem liegt
jede bestehende WireGuard-Sitzung.
Der Preis fuer einen sprechenderen Namen in einer Konfigurationszeile war
also ein Abriss saemtlicher Tunnel beim Ausrollen — der des Betreibers am
Telefon wie der jedes Hosts. Alias entfernt, nginx spricht die Bruecke unter
`queue-provisioning:8082` an. Nachgemessen: danach laesst `docker compose
up -d` den Tunnel-Container unangetastet, und nginx erreicht die Bruecke
weiterhin (426 statt 502).
2. DER ARBEITER ALS PROZESS 1. wg0 stand im Namensraum von queue-provisioning,
und dessen Prozess 1 war `exec php artisan queue:work provisioning`. Endete
der Arbeiter, endete der Container — und mit ihm der Namensraum und jede
Sitzung darin. Ein Arbeiter endet oefter, als man denkt: `--timeout=2100`
beendet ihn bei einem langen Provisionierungs-Schritt, ein fataler Fehler
beendet ihn, ein Speicherlimit beendet ihn, `queue:restart` beendet ihn
absichtlich. Aus jedem dieser vier Faelle wurde bisher "das VPN ist
unzuverlaessig".
Der Rumpf liegt jetzt in docker/provisioning-worker.sh: wg0 einmal hochziehen,
danach den Arbeiter in einer Schleife halten, SIGTERM sauber weiterreichen.
Nachgemessen: Arbeiter getoetet -> Container hat NULL Neustarts, wg0 steht
weiter, Arbeiter ist von allein wieder da.
Beide Male bleibt der Netz-Namensraum stehen. Damit verschwindet nebenbei auch
der Grund, aus dem update.sh die Bruecke hinterher neu starten musste — die
Zeile bleibt trotzdem, sie kostet nichts und traegt den Fall, dass der Container
doch einmal neu gebaut wird.
NICHT GETAGGT, mit Absicht: diese Aenderung fasst genau das an, was den Zugang
des Betreibers traegt. Sie gehoert ausgerollt, wenn jemand davorsitzt und die
Konsole des Anbieters als Rueckfahrkarte hat — nicht unbeaufsichtigt vom
Update-Agenten, waehrend der Betreiber unterwegs ist.
2508 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Betreiber oeffnete ein Terminal und las "Keine Verbindung — laeuft der
Terminal-Dienst, und steht der Tunnel?". Das war keine Diagnose, das war eine
Rueckfrage an den, der gerade unterwegs ist und nicht nachsehen kann.
Der Grund ist eine Eigenart des Protokolls: scheitert ein WebSocket schon am
Handschlag, bekommt die Seite laut Norm KEINEN HTTP-Status — `event.code` ist
1006, sonst nichts. Das ist Absicht (sonst waere ein Socket ein Portscanner) und
macht ausgerechnet die Unterscheidung unmoeglich, auf die es hier ankommt: laeuft
die Bruecke nicht, oder ist die Leitung weg? Beides sah gleich aus.
Eine gewoehnliche Anfrage an dieselbe Stelle darf den Status sehr wohl sehen.
Scheitert der Socket, ohne dass je ein Byte kam, fragt das Fenster deshalb einmal
nach und liest die Antwort:
502/503/504 nginx erreicht die Bruecke nicht -> "Der Terminal-Dienst laeuft
nicht", mit dem Befehl, der ihn zurueckholt, und dem Hinweis auf
den geteilten Netz-Namensraum
404 oeffentlicher Hostname -> "Auf diesem Namen gibt es kein Terminal"
sonst die Bruecke lebt, die Sitzung ist an etwas anderem gescheitert;
"Keine Verbindung" bleibt stehen
gar nichts die Anfrage kam nicht einmal los -> die Leitung ist wirklich weg
Alle drei Faelle nachgemessen, nicht angenommen: Bruecke laeuft -> 426,
oeffentlicher Name -> 404, `docker compose stop terminal` -> 502. Und danach mit
gestoppter Bruecke im Browser angesehen, im selben Zustand, in dem der Betreiber
gerade steht.
Dazu ein Test, der jeden Schluessel abdeckt, den terminal.js an showStage()
uebergeben kann — ein fehlender schriebe "undefined" ins Fenster, und zwar
ausgerechnet in dem Moment, in dem etwas kaputt ist.
2507 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Dinge, die beim ersten Hinsehen im Betrieb auffielen.
1. Der Knopf war weg. Er verschwand, wenn dem Host die Tunneladresse oder der
Fingerabdruck fehlte — mit der Begruendung, ein Knopf, der verlaesslich in
eine Ausnahme laeuft, sei schlechter als gar keiner. Das stimmte, solange die
Seite dahinter mit Laravels Fehlerseite aufging. Jetzt steht er auf jeder
Zeile: ein fehlender Knopf sah aus wie "hier gibt es kein Terminal" statt
"hier noch nicht, und zwar deshalb".
2. Die Seite ging mit einem Stacktrace auf. `TerminalTicket::issue()` warf,
niemand fing es, und wer den Knopf drueckte, bekam Klassenname, Dateipfad,
Zeilennummer und Quelltextauszug in einem Fenster des eigenen Produkts.
`blocker()` beantwortet die Frage jetzt VOR dem Ausstellen und gibt ein
Merkwort zurueck, keinen Satz — die Formulierung gehoert in die
Sprachdateien. `mount()` wirft nicht mehr, mit Fangzaun fuer das, womit
niemand gerechnet hat.
3. Der Abbruch war die einzige ungestaltete Stelle im Produkt: eine rote
ANSI-Zeile mitten in der eigenen Ausgabe. Der Vorspann und der Schirm waren
Geschwister, von denen abwechselnd eines `hidden` trug — das trug genau
einmal, beim Aufbau, und fuer alles danach fehlte die Rueckfahrkarte. Die
Buehne liegt jetzt UEBER dem Terminal und kann dreimal auftreten: beim
Verbinden, beim Ende, beim Abbruch. Die Sitzung darunter bleibt stehen.
Welcher Text, entscheidet der Schliesscode der Bruecke (4401 Ticket, 4502
kein SSH); dazu ein Knopf, der neu laedt, weil ein Ticket dreissig Sekunden
gilt und genau einmal.
Beim Hinsehen gefunden, nicht beim Testen:
- Die Schriftgrafik war unlesbar. Die Figlet-Zeichnung setzt darauf, dass der
Unterstrich einer Zeile den Strich der naechsten beruehrt; in IBM Plex Mono
sitzt er tiefer. Eng verschmierte das Wort, weit zerfiel es. Vollbloecke
fuellen ihre Zelle und stapeln in jeder Schrift.
- Dunkelrot auf Fast-Schwarz hatte kaum Kontrast. Die Wortmarke bleibt jetzt
immer in der Akzentfarbe — sie ist keine Statuslampe, was los ist, sagt die
Zeile darunter.
- Auf dem Schirm stand ":host antwortet nicht". Der Name war an die Erklaerung
uebergeben, an die Ueberschrift nicht. Ein Test mit
`toContain(__('...title'))` haette das nie gefunden — er verglich ":host" mit
":host". Der neue prueft das Ergebnis.
Nachgewiesen: Knopf oeffnet ein NEUES Tab (die Liste bleibt stehen), Ticket
ausgestellt, Socket verbunden, Bruecke kommt nicht auf den Host, schliesst 4502,
Buehne kommt mit "pve-fsn-1 antwortet nicht" und Knopf zurueck, Knopf laedt
wirklich neu und holt ein frisches 64-Zeichen-Ticket. Der Fingerabdruck dafuer
war geliehen und ist wieder entfernt.
2507 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
- Der Vorspann-Test prüft jetzt data-terminal-splash und die Ticketform
(64 Hexzeichen) selbst, statt sich auf ein aria-label zu verlassen, das
nur wegen des Tests existiert.
- Neuer Test: Berechtigung vor Nachschlagen — eine erfundene UUID meldet
403, nie 404.
- Der Knopf-Sichtbarkeitstest sichert jetzt auf die konkrete Terminal-Route
zu statt auf das nackte Wort "Terminal" irgendwo auf der Seite.
- Drei Kommentare (terminal.js, bare.blade.php, vite.config.js) behaupteten,
die Seite liefe ohne Livewire/Chart.js — sie ist aber eine Vollseiten-
Livewire-Komponente und zieht app.js über <x-shell.head> ohnehin mit.
Kommentare korrigiert: eigener Einstiegspunkt, damit der Terminalcode
nicht in app.js landet, nicht weil die Seite ohne Livewire liefe.
- terminal.js: ein fehlgeschlagener Socket feuert error UND danach close;
onclose schweigt jetzt, wenn nie ein Byte ankam, statt "Verbindung
beendet" hinter "Verbindung nicht möglich" zu schreiben.
- terminal.js: Textrahmen landen jetzt als String im Terminal statt als
leeres Uint8Array.
- data-host wird jetzt gelesen und steht in den Verbindungsmeldungen.
- wire:ignore auf dem Terminalschirm, bevor die Komponente ihre erste
Aktion bekommt und xterms DOM beim nächsten Render löscht.
- hosts.blade.php/host-detail.blade.php: der Terminal-Knopf trägt sein
href jetzt selbst (x-ui.button :href), statt in einem <a> zu stecken —
interaktiver Inhalt in einem Link war ungültiges HTML.
Suite: 2502 bestanden (vorher 2501 + ein neuer Test).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aufgabe 2: alles, was der Betreiber sieht, noch ohne Container dahinter.
Der Knopf bleibt fuer einen Host ohne Tunneladresse oder Fingerabdruck
absichtlich unsichtbar, statt in eine unbehandelte RuntimeException aus
Aufgabe 1 zu fuehren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cache::put() serialisierte den JSON-Inhalt zusaetzlich mit PHP serialize()
(kein 'serializer' konfiguriert), und Cache::pull() war get()+forget() in
zwei Runden statt atomar. issue()/redeem() sprechen jetzt direkt ueber
Redis::connection('cache') (setex/getdel), der volle Schluessel inkl.
REDIS_PREFIX steht im Kopfkommentar fuer Aufgabe 3. issue() weist ausserdem
Hosts ohne wg_ip oder ohne ssh_host_key zurueck, statt die Pruefung an einen
noch nicht existierenden Container zu delegieren.
Kippt die Zielfamilie eines gebuchten oder bezahlten Wechsels zwischen dem
Klick und der Ausführung auf internal, fand MoveStripeSubscriptionPrice nie
einen Stripe-Preis und parkte den Fehlschlag in stripe_price_sync – wo der
stündliche Sweep ihn für immer wiederholte, solange die Familie intern
bleibt. Der neue Deckel sitzt vor der Transaktion (Vertrag, Register und
Maschine bleiben unangetastet) und greift nur, wenn der Vertrag Stripe
überhaupt abrechnet; erkannt am internal-Flag der Zielfamilie statt an
fehlender Preis-Auflösbarkeit, weil letzteres auch den gewöhnlichen,
selbstheilenden Fall "noch nicht synchronisiert" träfe, den der bestehende
Park-und-Wiederhole-Pfad ausdrücklich abdecken soll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Heftet ein Betreiber eine geparkte Bestellung an einen Host, prüfte der
Anheft-Zweig nur die Kapazität — nicht, ob die Maschine jemand anderem
reserviert ist. Die frisch gebaute Reservierung ließ sich damit über einen ganz
normalen Weg durch die Oberfläche aushebeln, denn das Auswahlfeld auf der
Kapazitätsseite bot weiterhin alle aktiven Hosts an.
Der Schritt stellt jetzt dieselbe Bedingung wie die freie Platzierung —
unreserviert oder dem Kunden dieser Bestellung reserviert — über
Host::scopeUnreserved(), statt sie ein zweites Mal zu formulieren. Ein Pin auf
eine fremde Maschine wird nicht umgeleitet, sondern wartet
(awaiting_pinned_host): zu korrigieren ist die Wahl, nicht die Maschine.
Und die Ursache eine Ebene höher: das Auswahlfeld stellt fremd reservierte
Hosts gar nicht mehr zur Wahl, pin() lehnt sie zusätzlich ab, und ein Pin, der
seit dem Anheften reserviert wurde, sagt das in der Zeile — ein Auswahlfeld,
das dann kommentarlos wieder "Automatisch" zeigte, verschwiege, warum die
Bestellung weiter wartet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Befunde der Codex-Durchsicht an derselben Grenze — höchstens drei Blöcke
je Vertrag.
Die Mengenprüfung stand in __invoke(), also außerhalb der Buchungstransaktion,
und für Speicher wurde der Vertrag absichtlich nicht gesperrt. Zwei
Speicher-Bestellungen desselben Vertrags nahe der Grenze lasen damit beide
denselben alten Stand und fügten beide ein; der eindeutige Index über
(order_id, addon_key) greift dabei nicht, weil es zwei verschiedene
Bestellungen sind. Die Prüfung zählt jetzt in book(), unter der Sperre — und
die vorhandene Sperre gilt zusätzlich für gedeckelte Module, statt eine zweite
daneben zu nehmen. Der Idempotenz-Kurzschluss bleibt davor: eine wiederholte
Zustellung derselben Bestellung bekommt ihre Buchung zurück, statt ausgerechnet
am ausgefüllten Deckel zu scheitern (dieselbe Reihenfolge wie bei der
Kapazitätsprüfung).
Und im Bestätigungsfenster machte max(1, min($max, $packs)) aus null buchbaren
Blöcken wieder einen — dieselbe Falle, die in Billing::purchase() schon behoben
war. Bei null buchbaren Blöcken zeigt das Fenster jetzt die Absage in dem Satz,
den der Kunde zu dieser Grenze überall sonst liest, statt einen Kauf
anzubieten, den purchase() danach ablehnt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
stripe:sync-catalogue überspringt jetzt jede Familie mit internal = true —
weder Produkt noch Preis, im Trockenlauf wie im echten Lauf, und zwar bevor
irgendetwas über sie gelesen oder geschrieben wird. Ein Stripe-Preis lässt
sich nicht löschen, nur archivieren, und ein Paket, das nie über Stripe
abgerechnet wird, gehört deshalb nicht ins Konto. Eine Familie, die bereits
Stripe-IDs trägt (verkäuflich war, jetzt intern ist, wie Enterprise), bleibt
unangetastet: nichts wird gelöscht oder ersetzt, es kommt nur nichts Neues
mehr hinzu.
BillingChecks::billing.catalogue_synced bekam dieselbe Ausnahme — sonst wäre
die Bereitschaftsseite durch genau diese Änderung dauerhaft rot geworden,
weil das interne Testpaket und Enterprise veröffentlicht und sales_enabled
sind, ihre Preise aber nie synchronisiert werden.
AddonPrices, SyncStripeAddonItems, stripe:reprice-subscriptions und
stripe:sweep-orphan-prices wurden geprüft: alle vier sind bereits sicher,
weil ein verschenkter Vertrag nie ein stripe_subscription_id trägt und die
anderen beiden Befehle nur über vorhandene Stripe-Objekte laufen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei der vier Zustände waren belegt, der häufigste nicht. Eine reine
assertSee() wäre nicht falsifizierbar gewesen: die Spaltenüberschrift
"plans.live_version" übersetzt zufällig auf denselben Satz wie die Plakette
"plans.on_sale" und stünde selbst dann auf der Seite, wenn keine Zeile die
Plakette zeigte — deshalb über die Häufigkeit geprüft (Überschrift kommt genau
einmal vor, mehr als das beweist eine echte Zeile). Mit einer Mutation
(sellable-Prüfung stumpf auf false) probeweise als rot bestätigt, dann
zurückgesetzt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Umschaltmigration verglich beim Erkennen eines bereits umgeschalteten
Bestands nur Kontingent und Platte. Bei Intern waren beide schon vorher
richtig (5/20 GB), also hielt sie das Paket für erledigt und ließ RAM, Kerne
und Plätze auf ihren alten Werten stehen (1024 MB/1 Kern/5 statt 4096 MB/2/3).
Die Prüfung vergleicht jetzt alle neun Vorgaben; eine zweite, kleine Migration
hebt einen Bestand nach, auf dem die erste schon lief, und tut nichts auf einer
Neuinstallation.
Die Statusanzeige unterschied bisher nicht zwischen "kein Angebot" und "läuft,
aber nicht im Laden" — ein internes Paket zeigte "Nichts verfügbar" in der
Liste und "Im Verkauf" auf der Versionsseite darunter. Vier Zustände statt
zwei, mit fester Reihenfolge: der Notausschalter (sales_enabled) schlägt die
Konsolen-Kennzeichnung (internal).
Ein zweiter Schalter je Paketfamilie nimmt sie aus dem Preisblatt, ohne sie
unverkäuflich zu machen — nach dem Vorbild des vorhandenen Verkaufsschalters,
ohne Bestätigungsmodal, weil reversibel. Enterprise wechselt von
sales_enabled=false (weder käuflich noch verschenkbar) auf internal=true, wie
das Testpaket.
Dazu die liegengebliebenen Zahlen der alten Leiter in Produktattrappe,
Mail-Vorschau, Fabrik-Vorgaben und Seedern, sowie eine Testzusicherung, die auf
eine wandernde ID statt auf den berechneten Wert hätte treffen können.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zugeschnitten auf die Maschine, die dasteht. 388 GB vergebbar geteilt durch
40 GB Platte sind neun Startkunden statt drei — Speicher und RAM gehen damit
gleichzeitig aus, wo vorher sechs RAM-Plaetze verfielen. 351 Euro Umsatz je
Server statt 147.
Umgeschaltet wird durch Beenden und Nachfolgen, nicht durch Aendern: eine
veroeffentlichte Version ist unveraenderlich, und laufende Vertraege tragen
ihren eigenen Schnappschuss. Als Migration und nicht als Befehl, weil der
Katalog von einer Migration gesetzt wird — sonst blieben Neuinstallation und
Testsuite auf einer Leiter, die wir nicht mehr verkaufen.
Enterprise verlaesst den Verkauf (2000 GB finden auf 388 GB keinen Host), das
Testpaket heisst Intern und wird intern — es steht ausserdem zum ersten Mal im
Katalog einer frischen Installation, statt von Hand angelegt werden zu muessen.
Dreissig Testdateien nennen die neuen Zahlen. Drei Faelle waren keine Zahlen:
Vertraege auf Enterprise entstehen nur noch als Bestandsvertraege (Helfer
asGrandfathered() in tests/Pest.php), die Preisblatt-Stufe "Premium" haengt am
Paket, das den Laden verlassen hat, und ein Katalogleser, der die einzige
Preiszeile einer Familie suchte, findet seit der Handreichung zwei.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
preferredDatacenter() waehlte das Rechenzentrum ueber Host::availableGb() ohne
->unreserved() - ein Rechenzentrum mit einem grossen, aber komplett
reservierten Host sah geraeumiger aus als eines mit echtem allgemeinem
Bestand. Der Checkout haette die Bestellung dorthin gelegt, placeableIn()
haette dort niemanden gefunden, und sie waere geparkt, obwohl anderswo Platz
war. Jetzt ->unreserved(), derselbe Bestand wie largestPlaceableGb().
Die Migration nutzte nullOnDelete() und tat damit das Gegenteil der eigenen
Vorgabe: die Reservierung sollte einen Kundenaustritt nicht stillschweigend
ueberleben, loeste sich mit nullOnDelete() aber genau so auf, sobald der
Kunde verschwindet. restrictOnDelete() macht das Loeschen eines Kunden mit
eigener Maschine zum Fehler, bis ein Operator die Reservierung von Hand
gelöst hat - Migration und Modellkommentar sagen jetzt dasselbe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Betreiber-Entscheidungen aus der Durchsicht:
1. Keine zweite, feste Untergrenze mehr im Fliesstext ("ab 500 GB") -- die
eigene Maschine beginnt dort, wo das groesste Paket endet, ihre Groesse ist
Hardware-Frage einer Angebotsanfrage, kein zweiter Wert auf dem Preisblatt.
2. Die Zahl in der Ueberschrift ("Mehr als :quota?") kommt jetzt aus $plans --
derselben Liste, die baseline() und comparison() schon lesen, statt fest im
Sprachtext zu stehen. Eine spaetere Umschaltung der Paketleiter (Task 9)
aendert sonst, was "am groessten" ist, und der Satz haette es nicht gemerkt.
max() auf einer leeren Plan-Liste (Katalog nicht lesbar) waere ein Fatal
gewesen -- enterprise() gibt in diesem Fall jetzt null zurueck, mit Test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
placeableIn() nahm jeden aktiven Host im Rechenzentrum, und hasRoomFor() sowie
largestPlaceableGb() zaehlten eine exklusiv verkaufte Maschine obendrein mit —
der Shop versprach damit Platz, der bereits vergeben war. Ohne diese Markierung
ist 'eigener Server' ein Satz im Angebot und nirgends eine Tatsache.
hosts.reserved_for_customer_id (nullable, ueberlebt den Kunden) markiert die
Maschine; Host::placeableIn() und HostCapacity zaehlen sie nur noch fuer den
eigenen Mieter oder gar nicht mehr zum allgemeinen Bestand. Auf der
Host-Detailseite kann ein Operator reservieren und wieder loesen — Loesen
laeuft ueber ein eigenes Bestaetigungsmodal (R23), das selbst nichts aendert,
sondern nur an HostDetail::releaseReservation() zurueckmeldet. Host-Liste und
Kapazitaetsseite weisen eine reservierte Maschine als solche aus, statt sie
kommentarlos verschwinden zu lassen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ab 500 GB ist es eine eigene Maschine — im Shop faende placeableIn() auf 388 GB
vergebbarem Platz keinen Host, und der Kunde erfuehre das nach der Zahlung.
Der Anfrage-Block erscheint als vierter Bestandteil neben der
Vergleichstabelle, sobald PlanCatalogue::sellable() Enterprise nicht mehr
liefert; die Familie selbst bleibt bestehen, nur sales_enabled entscheidet
(gesetzt wird das erst in Task 9).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DowngradeCheck baute seine Empfehlung aus packsToCover(), und die rechnet
bloss. BookAddon lehnt aus ZWEI Gruenden ab - der Deckel bei drei Bloecken
und der Ausschluss von Enterprise -, und die Empfehlung kannte keinen davon.
Solange ein Block 100 GB brachte, war das unerreichbar; seit er 20 GB bringt,
landet jede Ueberschreitung ueber 60 GB dort.
Business -> Team mit 600 GB belegt bot "5 x Zusatzspeicher buchen (+100 GB)"
an. Das Modal klemmte still auf drei, den vierten haette BookAddon abgelehnt,
und der Kunde stand nach 45 Euro im Monat genau dort, wo er vorher stand - auf
der Karte, die sein Abo billiger machen sollte. Enterprise -> Business empfahl
Bloecke, die es fuer dieses Paket ueberhaupt nicht gibt.
AddonCatalogue::bookableQuantity() antwortet jetzt auf beide Gruende. Es gab
einem Enterprise-Vertrag "3" auf die Frage, die sein eigener Docblock stellt.
DowngradeCheck stellt diese eine Frage und klemmt daran: `packs` ist die
gebrauchte Zahl nur, wenn der Vertrag sie auch buchen darf, sonst null - und
dann traegt `short`, was nach allen buchbaren Bloecken uebrig bliebe, gemessen
an DEREN Restmenge statt am Deckel, damit ein Kunde mit einem Block nicht mehr
zu loeschen bekommt als noetig. Kein halbes Angebot: eine Dauerbuchung, die
den Wechsel trotzdem nicht freigibt, ist kein Ausweg, sondern der ausgegraute
Knopf mit Preisschild.
packsToCover() bleibt reine Arithmetik, mit einem Kommentar, der sagt warum:
die kaufmaennische Grenze steht in AddonCatalogue, und ein Klemmen an dieser
Stelle zoege eine Vertragsabfrage in jeden Kontingent-Schritt und ins Portal,
die beide keine Verkaufsfrage stellen.
Der Knopf haengt schon an `packs > 0` und verschwindet von selbst; der Satz
wechselt auf downgrade_escape.capped, der die verbleibende Luecke nennt und
nicht den Grund - "hoechstens drei Bloecke" waere im Enterprise-Fall falsch,
wo es gar keine gibt.
Zwei bestehende Tests hingen an 600 und 800 GB aus der 100-GB-Zeit, beide
inzwischen nicht mehr deckbar; einer haette das Modal gesucht, das die Karte
zu Recht nicht mehr oeffnet. Auf 550 und 560 GB umgestellt, wo sie das pruefen,
wofuer sie geschrieben wurden.
Dazu ein Test, der belegt statt annimmt, warum das max(1, min(...)) in
ConfirmBookStorage stehen bleiben darf: ein Modal ist per openModal direkt
erreichbar, aber bookStoragePacks() legt weder am Deckel noch bei Enterprise
eine Bestellzeile an. Die bestehenden Tests deckten purchase() ab, nicht diese
Weiterleitung.
Voller Testlauf: 2405 bestanden. Pint sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sales_enabled waere der falsche Hebel gewesen: es schaltet auch das
Verschenken ab. Das Testpaket ist fuer Abnahmelaeufe da und muss verschenkbar
bleiben, also zwei Felder fuer zwei Fragen. Der Checkout lehnt einen internen
Schluessel ausdruecklich ab, ueber denselben Fang wie einen unbekannten — eine
URL ist keine Liste, und die Antwort darf keinen Unterschied verraten.
GrantPlan las bislang dieselbe sellable()-Liste wie Preisblatt und Warenkorb,
sowohl fuer sein Dropdown als auch fuer die Validierung des Formularfelds —
ein interner Schluessel waere dort ebenso abgelehnt worden wie im Checkout,
und das Verschenken haette sein einziges Tor verloren. PlanCatalogue bekommt
deshalb grantable() als zweiten Leser derselben Abfrage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 GB fuer 10 Euro waren 0,10 Euro/GB — unter Einstandspreis und billiger als
jeder Aufstieg. Wer stapelt, belegt dann den knappsten Rohstoff zum niedrigsten
Preis. 0,75 Euro/GB liegen ueber beiden Aufstiegen (0,73 und 0,67), und der
Deckel bei drei liegt dort, wo Aufsteigen billiger UND besser wird.
BookAddon haelt den Ausschluss durch AddonCatalogue::availabilityRefusal()
(gleiches Muster wie CustomDomainAccess), damit greift er auch beim
Verschenken durch den Betreiber. Billing::purchase() und storageLimitNote
fragen dieselbe Regel VOR der Zahlung, sonst haette ein Enterprise-Kunde einen
zahlbaren Auftrag anlegen koennen, den BookAddon erst danach abgelehnt haette.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Der Formel-Test rechnete dieselbe Formel nach und wich dabei vom echten Code
ab (kein max(0, ...) um den neuen Summanden) — ein Test, der die Implementierung
nachrechnet und dabei abweicht, ist schlechter als keiner. Der Verhaltenstest
deckt die Anforderung bereits ab und bleibt als einziger Test der Datei.
StoragePackHeadroomTest.php hing außerdem an reservedRun() aus
CustomerStepsTest.php und brach einzeln gefahren mit einem PHP-Fatal ab, statt
mit einem ehrlichen Fehlschlag. Eigene, in sich geschlossene Hilfsfunktion
(packHeadroomRun()) nach dem Muster der Nachbardateien (hostRun(),
restartableInstance()) — die Datei läuft jetzt auch allein grün.
Der Docblock von growDisk() sprach noch vom Kopfraum, der "unchanged"
mitfährt, und widersprach damit dem neuen Summanden direkt darunter. Nachgezogen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ein Block gibt 20 GB und belegt 22. Ohne das haette ein Start mit drei Bloecken
90 GB auf 100 GB Platte gehabt — zehn Gigabyte Kopfraum, wo die Regel bei dieser
Plattengroesse zwoelf verlangt, und der gestapelte Tarif waere genau der Fall
geworden, den die Regel verhindern soll.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Durchsicht (R22): die Pruefung stand in __invoke(), vor book()s Kurzschluss
fuer einen wiederholt zugestellten Webhook ("Idempotent against a retried
webhook"). Damit fragte jede Wiederholung erneut "passt NOCH ein Block
drauf" - obwohl keiner hinzukommt - und ein laengst bezahlter, laengst
gebuchter Vorgang quittierte die Wiederholung mit einem Fehler, sobald der
Host zwischenzeitlich eng geworden war. Genau das Szenario, fuer das diese
Aufgabe gebaut wurde, nur gegen den eigenen Kunden gerichtet.
Jetzt sitzt die Pruefung in book(), hinter dem order_id+addon_key-Kurzschluss:
eine Wiederholung bekommt ihre bestehende Buchung zurueck, ohne die Frage
erneut zu stellen. Eine echte neue Buchung durchlaeuft die Pruefung wie
zuvor. Deckel (quantityRefusal) und Domain-Ausschluss bleiben unangetastet -
sie haben dasselbe Muster im Kleinen, sind aber nicht Gegenstand dieses
Befundes.
Neuer Test: derselbe Auftrag wird zweimal gebucht, der Host wird zwischen
den beiden Aufrufen eng - der zweite Aufruf gibt die vorhandene Buchung
zurueck statt zu werfen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Migration: die Reihenfolge in up() stellte den unwiderruflichsten Schritt
(app_settings loeschen, dns_name-Spalte weg) VOR den einzigen Schritt, der an
vorhandenen Daten scheitern kann (unique('name') - hosts.name trug noch nie
einen eindeutigen Index). Schlug der auf MariaDB fehl, war der Schaden nicht
mehr rueckgaengig zu machen: DDL committet dort implizit, die Migration gilt
aber mangels Eintrag in der Migrationstabelle als nicht gelaufen, und ein
zweiter up()-Versuch stirbt an der bereits fehlenden dns_name-Spalte. Jetzt
steht eine reine Vorabpruefung ganz am Anfang, die simuliert, was die
Uebertragung schreiben wuerde, und mit einer RuntimeException abbricht, bevor
irgendetwas angefasst ist; das Loeschen der app_settings-Zeilen steht jetzt
hinter dem Unique-Index, nicht davor. Gegen echtes MariaDB geprueft,
einschliesslich eines Laufs mit zwei absichtlich kollidierenden Hostnamen.
HostStepsTest: Titel und ein Kommentar praezisiert - der Schritt vergibt den
Namen nicht mehr, er veroeffentlicht ihn nur noch.
HostNamingTest: ungenutzten Import entfernt (Pint), Rueckfall-Test fuer
HostName::label() bei einem Code ohne gueltige Zeichen ergaenzt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HostCapacity wurde im Checkout, in der Bestellung und im Konsolenbereich
gefragt — beim Aufstocken nicht. Weil qcow2 duenn belegt ist, gelingt die
Ueberbuchung sofort und faellt erst auf, wenn die Gaeste wirklich schreiben.
Gefragt wird der Host der Instanz, denn eine laufende Instanz zieht nicht um.
StorageAllowanceTest: der Host in storageFixture() war mit 1000 GB (active()
Standard) schon fuer eine reine Business-Instanz (1050 GB disk_gb) zu klein —
placeableIn() haette sie nie dort platziert. Ohne Kapazitaetspruefung fiel das
nie auf; jetzt schon, deshalb auf 2000 GB angehoben.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Luecken aus der Durchsicht von Task 2, beide am selben Rand: genau am
erreichten Deckel.
purchase() klemmte $lines mit max(1, min(..., bookableQuantity, ...)) — bei
bookableQuantity=0 floort das auf eine Bestellzeile statt auf keine, und der
Speicher-Knopf war bedingungslos gerendert. Jetzt bricht purchase() fuer
type=storage frueh ab, wenn AddonCatalogue::quantityRefusal() ablehnt, und der
Knopf verschwindet zugunsten des Ablehnungssatzes.
ConfirmBookStorage klemmte an der absoluten Grenze (3), nicht an der
Restmenge — ein Vertrag mit einem gebuchten Block haette drei weitere
versprochen bekommen, obwohl nur zwei noch buchbar sind. Das Modal loest jetzt
seinen eigenen Vertrag auf (wie ConfirmCancelAddon und ConfirmRevokeSeat) und
klemmt an bookableQuantity().
Beide Faelle waren ungetestet; zwei neue Tests in StoragePackLimitTest.php
belegen sie und wurden vor dem Fix gegen den alten Stand als rot verifiziert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HostName::claim() haengt den Namen jetzt an die Rechenzentrums-Zeile
(next_host_number), nicht an MAX(hosts.name)+1: der Zaehler ueberlebt so
das Loeschen des zuletzt angelegten Hosts. hosts.dns_name faellt weg -
name ist ab jetzt der einzige Name, den Konsole, DNS, /etc/hosts und
Proxmox-Node teilen. RegisterHostDns veroeffentlicht nur noch, was
StartHostOnboarding beim Anlegen vergeben hat, statt selbst zu
nummerieren; PrepareBaseSystem baut den FQDN ueber HostName::fqdn()
statt ueber die nirgends konfigurierte clupilot.net.
Zwei Testdateien ausserhalb der Aufgabenliste (DatacenterTest,
HostTakeoverPageTest) setzten ->set('name', ...) auf HostCreate, das
Feld jetzt aber nicht mehr hat - im vollen Testlauf nachgezogen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Befunde, beide echt, und der erste ist ein Patzer:
detect_network_style wurde auf default_gateway() umgestellt,
bridge-run.sh blieb auf awk '{ print }'. Der Treiber reichte also
weiter den KARTENNAMEN als Gateway an build_bridge, und in der Strophe
stand 'gateway ens3' — der Fix half genau der Stelle nicht, fuer die er
gedacht war.
Meine Sandkiste konnte das nicht sehen: sie hatte immer ein via. Jetzt
ist sie parametrisiert, und ein Test faehrt bridge-run.sh mit einer
via-losen Standardroute durch und liest die geschriebene Strophe. Der
Beweis laeuft ueber den Produktivpfad, nicht ueber eine Einzelfunktion.
Zweitens: default_gateway suchte das erste via IRGENDWO in der Ausgabe,
detect_primary_interface das dev der ERSTEN Zeile. Bei zwei
Standardrouten baute das eine Bruecke ueber die Karte der einen mit dem
Gateway der anderen. Beide haengen jetzt an primary_default_route mit
head -1 — eine Route, eine Quelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Ansicht durfte 50 in den Warenkorb legen, die Aktion pruefte nichts. Der
Deckel liegt kaufmaennisch dort, wo Aufsteigen billiger wird als Stapeln, und
gehoert deshalb dorthin, wo gebucht wird — gezaehlt ueber alle laufenden
Buchungen, denn drei Bestellungen a einem Block sind drei Bloecke.
StorageAllowanceTest schrieb die alte Notbremse (50) als erwartete Zahl fest;
angepasst auf die jetzt engere kaufmaennische Grenze (3), wie im Aufgabenblatt
fuer DowngradeTest vorgezeichnet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Ruecknahme konnte Erfolg melden, ohne einen zu haben. Drei Wege
dorthin, alle behoben:
- Sie schaltete den entmachteten Netzverwalter nicht wieder ein. Die
Sicherung umfasst nur /etc/network/interfaces*; kam die Verbindung von
cloud-init, networkd oder NetworkManager, spielte die Ruecknahme eine
Datei zurueck, die die Maschine nie getragen hat, und liess den
Verwalter abgeschaltet. Genau der tote Host, den der Zeitgeber
verhindern soll. disown_network_manager hinterlaesst jetzt eine Notiz
(WAS entmachtet, WELCHE Strophen verdraengt), die das Ruecknahme-Skript
beim Feuern liest — aufgeschrieben statt eingebacken, weil der
Zeitgeber vor dem Entmachten gestellt wird.
- Sie setzte 'rolled-back' auch, wenn tar oder ifreload scheiterten. Das
urspruengliche network.sh hatte dafuer set -e; beim Umbau ist es
verlorengegangen. Jetzt bricht jeder Fehlschlag ab, bevor die Marke
entsteht — CluPilot pollt dann bis zur Frist statt 'ist zurueck' zu
glauben.
- Eine verdraengte interfaces.d-Strophe wurde nur umbenannt. Der Stern in
'source interfaces.d/*' fasst sie weiter; das versteckte die Kollision
vor dem Leser, statt sie zu loesen. Jetzt wandert sie aus dem
Verzeichnis heraus, und die Ruecknahme holt sie zurueck.
Dazu ein eigener Fund: das Gateway wurde mit awk '{print }' gelesen.
Bei 'default dev ens3 scope link' ist das der KARTENNAME, woraus
'gateway ens3' in der Strophe wuerde. default_gateway() liest jetzt
hinter dem via, und eine Standardroute ohne via wandert als eigene
up-Zeile mit, statt verlorenzugehen.
Zurueckgewiesen: der P2 zu extra_routes. 'ip route show dev X' laesst das
dev-Feld WEG (im Container nachgemessen), das angehaengte 'dev vmbr0' ist
also richtig.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Schritt haengt jetzt zwischen RebootIntoPveKernel und
ConfigureProxmox. Nach dem Neustart, weil dort ifupdown2 steht und der
Tunnel gerade bewiesen hat, dass er einen Neustart ueberlebt. Vor
ConfigureProxmox, dessen refuseWithoutBridge() unveraendert stehen bleibt
und damit zur Nachpruefung wird — bauen UND pruefen, dasselbe Paar wie
BuildVmTemplate -> VerifyVmTemplate.
Beschriftung in beiden Sprachen, und ein Test verlangt das kuenftig von
JEDEM Pipeline-Schritt statt nur vom neuen: wer einen einhaengt und die
Sprachdateien vergisst, faellt im Test auf statt in der Konsole.
bridge.sh und bridge-run.sh gehoeren ins Bootstrap-Archiv — es ist der
einzige Weg, auf dem das Skript auf eine nackte Maschine kommt.
Der End-to-End-Test lief rot, und zu Recht: sein Attrappen-Host sagte
nichts ueber sein Netz, also lehnte der neue Schritt ab. Das war der
Beweis, dass er wirklich haengt. Er bekommt jetzt einen Host, der seine
Bruecke schon hat — den Bau-Pfad kann eine Attrappe nicht nachstellen,
er lebt davon, dass die Verbindung abreisst und wiederkommt. Dafuer gibt
es die Schritt-Tests und die drei Sandkasten-Laeufe.
scriptBridgeState/scriptBridgeStatus liegen in tests/Pest.php, nicht in
einer einzelnen Testdatei — zwei Dateien brauchen sie.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Enden, und jedes hat eine Reihenfolge:
- Abbestellt wird erst, NACHDEM die Bruecke nachgeprueft ist. Dass der
Zweig erreicht wird, ist der Beweis: SSH kam ueber den Tunnel an und
vmbr0 traegt Standardroute und Adresse — bridge_proven, von aussen
gefragt. Und nur, wenn DIESER Lauf den Zeitgeber auch gestellt hat;
ohne Termin im Kontext gehoeren die Unit-Dateien jemand anderem.
- awaitRollback pollt, bis 'rolled-back' liegt, statt sofort zu
scheitern. Solange der Zeitgeber aussteht, steckt die Maschine mitten
in einer Umstellung, und ein fail() liesse sie dort liegen. Gedeckelt
durch die Schritt-Frist, die das Dreifache der Zeitgeber-Frist ist.
- giveUp loescht den Termin (sonst wird jedes Retry nach einem
Fehlschlag zum sofortigen zweiten Fehlschlag), behaelt bridge_attempts
(sonst ist der Deckel von zwei Versuchen keiner) und bestellt den
Zeitgeber NICHT ab — er ist die Rueckfahrkarte, und steht er noch, hat
er seinen Grund.
Der sh-n-Test ist gegengeprobt: mit absichtlich kaputtem Quoting faellt
er.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die tragende Zeile ist das try/catch um keyLogin. RunRunner:102
verwandelt jede geworfene Ausnahme in ein retry(), und retry verbraucht
das Versuchskonto — ungefangen brennt der erwartete Verbindungsabriss die
fuenf Versuche in wenigen Minuten durch und laesst den Lauf scheitern,
BEVOR der Host wieder da ist. Genau der Unterschied zwischen
'Wiederholung' und 'toter Server'.
Unterschieden wird am Termin im Run-Kontext: ohne ihn hat dieser Lauf
nichts angefasst, dann ist ein Verbindungsfehler ein gewoehnlicher und
darf einen Versuch kosten. Mit ihm laeuft gerade eine Umstellung, dann
wird gepollt.
bridge.sh und bridge-run.sh gehen wortgleich hoch (Test vergleicht Byte
fuer Byte gegen die Repo-Datei), env traegt den Hub-Schluessel mit, damit
der Treiber den Handshake gegen den RICHTIGEN Peer prueft. Start per
nohup setsid, PID-Datei abgewartet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Schritt, aber erst sein billigster Teil. Zwei Zusicherungen:
- Traegt vmbr0 schon Standardroute UND Adresse, dann advance() ohne einen
einzigen veraendernden Befehl. pve-fns-1 hat die Bruecke von Hand; ein
Wiederanlauf, der sie umbaut, baut ein funktionierendes Netz um.
- Ist die Karte mit der Standardroute keine physische (Bond, Bridge,
VLAN), dann fail() mit Klartext statt bauen. bridge_ports darauf waere
falsch, und was dabei herauskommt, ist aus der Ferne nicht mehr zu
reparieren.
readBridgeState fragt beides in einem Rundlauf und leitet aus dem
LAUFENDEN Zustand ab (ip, /sys/class/net), nie aus der Datei des
Anbieters. 'up' sind absichtlich alle drei Fakten: eine vmbr0 ohne
Adresse und ohne Standardroute ist eine Bruecke im Sinne von 'ip link'
und sonst nichts.
Start, Poll und Abbestellen folgen. Bis dahin steht dort ein fail() —
gefahrlos, weil der Schritt noch nicht in der Pipeline haengt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Verdrahtet bridge.sh in der einen Reihenfolge, die stimmen muss:
sichern -> Zeitgeber -> uebernehmen -> umstellen -> nachsehen. Alles
davor stellt nur fest und veraendert nichts; ab 'sichern' gibt es einen
Weg zurueck, und erst ab dann darf ueberhaupt etwas angefasst werden.
Abgekoppelt ist hier Bedingung, nicht Optimierung: ifreload -a nimmt die
Leitung, ueber die der Befehl laeuft. PID als allererstes, damit ein
frueher Poll nicht 'running aber nicht lebendig' liest und einen gesunden
Lauf fuer tot erklaert.
Der Treiber bestellt den Zeitgeber NIE ab — das tut CluPilot nach dem
Wiederverbinden. Ein Test haelt das fest.
Dazu die Fremdverwalter-Erkennung in bridge.sh: cloud-init, networkd,
NetworkManager werden benannt und entmachtet, Unbekanntes fuehrt zum
Abbruch statt zu einem Versuch ins Blaue. Der Zeitgeber faengt diesen
Fall NICHT ab — zu seiner Zeit war alles in Ordnung, und die Bruecke
verschwaende erst beim naechsten Neustart, mit Kunden darauf.
Drei Tests fahren den Treiber in einer Sandkiste WIRKLICH durch, statt
nur seinen Text zu lesen: die ip-Attrappe antwortet vor dem ifreload
anders als danach. Sie belegen ok, failed-ohne-Bruecke und
failed-mit-schalem-Tunnel — und in allen drei Faellen, dass die
Rueckfahrkarte stehen bleibt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nur 'komme ich raus' zu pruefen ist notwendig und NICHT hinreichend.
Der Fehlerfall: ifreload bringt vmbr0 sauber hoch, die Maschine erreicht
das Internet, der Treiber waere zufrieden — aber wg0 kommt nicht zurueck.
Dann lebt der Host oeffentlich, CluPilot ist ausgesperrt, und wer hier
abbestellt, hat die Rueckfahrkarte weggeworfen.
wg0.conf enthaelt keine Geraetebindung; der Tunnel haengt an der
Quelladresse, die die Routing-Tabelle hergibt — und genau das ist die
Groesse, die der Umbau anfasst. Das macht den Fall nicht
unwahrscheinlicher, nur unauffaelliger: kein Fehler im Protokoll, nur
ein Handshake, der ausbleibt.
Ist der Handshake schal, wird wg-quick@wg0 EINMAL neu gestartet und
nochmal nachgesehen. Gefahrlos, weil ifreload die SSH-Sitzung ohnehin
schon mitgenommen hat — und es verwandelt einen haengenden Tunnel in
einen laufenden statt in eine Ruecknahme.
Kein ping: Hetzners Debian-Basis hat keins, PrepareBaseSystem
installiert es nicht, und network.sh:190 haette damit immer 'nicht
erreichbar' gesagt. Ein Test haelt bridge.sh ping-frei.
Nebenbei ein Fehler in den Tests selbst behoben: 'if gibtsnicht; then
… else echo NEIN; fi' ist in sh unwahr, also war jeder Test, der NEIN
erwartete, gruen SOLANGE die Funktion fehlte. verdictBody() meldet jetzt
FEHLT und trennt 'falsch' von 'gibt es nicht'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Zeitgeber ist eine systemd-Einheit und kein 'sleep &': ein
Hintergrundlauf stirbt mit seiner Sitzung, und die Sitzung ist genau
das, was abreisst, wenn die Umstellung schiefgeht.
Zwei Dinge gegenueber der Fassung, die in network.sh stand:
- render_rollback_script ist vom Stellen getrennt, damit die Reihenfolge
darin ohne systemd pruefbar ist. Und die Reihenfolge ist der Punkt:
'failed' samt Grund wird geschrieben, BEVOR zurueckgespielt wird —
ein halb gegluecktes Zurueckspielen soll das Urteil trotzdem
hinterlassen. 'rolled-back' kommt zuletzt und ist das Signal, auf das
CluPilot wartet.
- Der Zeitgeber raeumt seine Unit-Dateien nach dem Feuern selbst weg.
Sonst sieht eine abgeschlossene Ruecknahme beim naechsten Hinsehen aus
wie eine ausstehende.
Der Treiber bestellt NICHT ab. Das tut CluPilot, nachdem es sich ueber
den Tunnel neu verbunden hat — ein Skript auf dem Host kann ueber seine
eigene Erreichbarkeit von aussen nur raten.
Geprueft: der Zeitgeber steht vor dem ersten veraendernden Aufruf.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
write_bridge_stanza ist von build_bridge getrennt: das Schreiben ist das,
was eine Maschine umbringt, und so ist es pruefbar, ohne dafuer ein Netz
neu laden zu muessen. Beide Pfade sind ueberschreibbar
(CLUPILOT_INTERFACES_FILE, CLUPILOT_IFRELOAD), dieselbe Technik wie
CLUPILOT_STORAGE_CFG beim Vorlagenbau.
Drei Dinge kann die Fassung mehr als die alte in network.sh:
- hwaddress festgenagelt. Eine Bruecke waehlt sonst die kleinste MAC
ihrer Ports; bei einem Port ist das dieselbe, aber 'ist dieselbe' und
'bleibt dieselbe' sind zweierlei.
- Zusatzrouten des Anbieters wandern mit. Ausgelassen bleiben die
Kernel-Route zum eigenen Subnetz und die Link-Route zum Gateway —
beide entstehen von selbst, und ein gescheitertes 'up' nimmt bei
ifreload die ganze Strophe mit.
- IPv6, aber nur statisch und global. SLAAC/DHCPv6 werden bewusst nicht
nachgebaut (forwarding=1 laesst den Kernel RAs ohne accept_ra=2
verwerfen) — dafuer gibt es eine Zeile ins Protokoll statt eines
stillen Verlusts.
Geprueft gegen echte sh: Hetzner /32 routed mit pointopoint, netcup
Subnetz ohne, DHCP ohne Adresse, statisches IPv6 mit fe80::1, SLAAC
faellt weg, Zusatzrouten kommen mit.
network.sh hat sein eigenes build_bridge abgegeben; ein Test haelt jetzt
jede der sieben Brueckenfunktionen auf genau einer Stelle im Repo fest.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Bruecken-Erkennung stand in network.sh, geschrieben fuer den
stillgelegten Rettungssystem-Weg, und borgte sich vier Dinge von
woanders: detect_primary_interface aus proxmox.sh, log, http_get und
CLUPILOT_PROBE_URL aus clupilot-bootstrap.sh.
Der Debian-Weg laedt die Bibliothek EINZELN auf den Host und faehrt sie
dort — geborgte Helfer waeren dann nicht da. Also allein lauffaehig, mit
log und http_get unter einem command-v-Schutz, damit der Bootstrap seine
eigenen behaelt.
Neu und aus dem laufenden Zustand abgeleitet:
- interface_is_physical — bridge_ports auf einem Bond oder einer
bestehenden Bridge ist falsch, und aus der Ferne nicht reparierbar.
- address_is_dynamic — der Kernel markiert eine geleaste Adresse, das
steht bei jedem Anbieter gleich da. Die Datei des Anbieters ist nur
noch das Zweitsignal.
Geprueft gegen eine echte sh mit aufgezeichneten ip-Ausgaben, plus die
Zusicherung, dass es detect_primary_interface im Repo genau einmal gibt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher las StorageAllowance sie bei jeder Anzeige neu aus config. Solange es
eine Groesse gab, war das folgenlos — beim Zuschnitt von 100 GB auf 20 GB waere
es der stille Verlust von 80 GB je gekauftem Block gewesen, bei einem Kunden,
der bereits Daten darin liegen hat.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Punkte des Besitzers, und der dritte hat den eigentlichen Fehler
freigelegt.
Die Verlaufslinien sahen komisch aus, weil sie auf ihr EIGENES Minimum und
Maximum skalierten. Ein Host mit 0,2 % CPU, der zwischen 0,1 und 0,3 schwankt,
zeichnete damit einen Seismographen über die volle Höhe — und widersprach flach
der Zahl direkt daneben. Dieselbe Regel stand im Chart.js-Entwurf schon
ausformuliert ("eine automatisch skalierte Achse lässt 3 % wie eine Wand
aussehen") und ging beim Umstieg auf die Kacheln verloren.
x-ui.spark bekommt deshalb `min`/`max`. Ohne Angabe bleibt alles wie bisher —
das ist, was jeder bestehende Aufrufer übergibt. Die Prozent-Kacheln geben
0 und 100 mit, die Netz-Kacheln nur die 0, weil MiB/s keine natürliche
Obergrenze haben. Ein untätiger Host zeichnet jetzt vier ruhige Linien statt
zweier wogender und zweier flacher — vorher wirkte CPU (0,2 %) belebter als RAM
(3,6 %), also genau verkehrt herum.
Dazu: Werte außerhalb der Grenzen werden geklemmt statt aus dem Kasten
gezeichnet, die Fläche ist ein Verlauf statt einer harten Kante, und die
Linien sind mit 120×40 statt 80×32 lesbar.
Ich hatte max=100 zwischendurch selbst verworfen, weil es "zu tot" aussah — auf
einem Bild, auf dem die Füllung wegen eines CSS-Fehlers gar nicht gezeichnet
wurde. Ein Vergleich mit einem kaputten Bild. Der Fehler: `.spark path
{ fill: none }` schlägt als CSS-Regel das Präsentationsattribut fill="url(#…)".
`fill: none` gehört an die Linie, nicht an jeden Pfad.
Die Ausstattung ist wieder einzeilig. Sechs Felder, sechs Spalten — und die
Bau-Kennung steht nur noch im Titel: als eigene Zeile zwang sie die ganze Tafel
in eine zweite Reihe, für eine Zeichenkette, die fast niemand liest.
Und die Wartezeit: die Seite stößt beim Öffnen eine Sammlung an, wenn noch
keine Messwerte da sind, statt bis zum nächsten minütlichen Lauf leer zu
bleiben. Ein leerer Kasten liest sich als "kaputt", nicht als "gleich". Der Job
ist ShouldBeUnique, zwei geöffnete Seiten reihen also keine zwei ein.
Codex-Befund dazu (P2): das galt auch für Hosts, die der Sammler ohnehin
überspringt — ein Host mitten in der Übernahme hat keinen Token, und seine
Seite hätte die ganze Flotte abgeklappert und die eigenen Kacheln trotzdem leer
gelassen. Wer gesammelt wird, steht jetzt einmal am Modell (Host::collectable),
gelesen von Job und Seite. Zwei Fassungen dieser Frage waren genau der Grund.
Geprüft: 2307 Tests grün, Pint sauber, Codex ohne Befund. Der Test für den
Maßstab prüft jetzt auch die echten Aufrufstellen und nicht nur das Bauteil —
gegengeprobt durch Entfernen von max=100, dann fällt er. Im Browser beide
Fälle angesehen: ruhiger Host vier flache Linien, ausgelasteter Host lesbare
Form, null Konsolenfehler.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Nach der Vorlage des Besitzers. Zustand, Speicher und die gemeinsame Kurve
standen nebeneinander: zwei davon halb leer, die dritte überfüllt, und die
gemeinsame Kurve brauchte eine Legende, um zu sagen, welche Linie welche ist.
Eine Reihe je Kachel löst alle drei Beschwerden auf einmal. Die Beschriftung
der Kachel benennt die Reihe, also braucht es keine Legende mehr. Die Höhen
sind durch das Raster gleich statt zufällig. Und der leere Platz ist mit
Messwerten gefüllt, die es ohnehin schon gab: Proxmox' Aufzeichnung liefert
Netzdurchsatz in beide Richtungen mit, ungefragt.
Sechs Kacheln: CPU-Auslastung, RAM-Auslastung, Speicher (als Ring), Eingehend,
Ausgehend, Zustand. Gebaut mit x-ui.metric, x-ui.spark und x-ui.ring — die
gab es alle schon, und der Kopfkommentar von x-ui.metric sagt selbst "exactly
as the approved template draws it". Nichts daneben neu gebaut.
Die öffentliche IP steht jetzt auf der Seite. Sie stand vorher NUR klein unter
der Überschrift — die Adresse, unter der der Host wirklich erreichbar ist, war
in der Detailseite nirgends ein Feld. Sie führt jetzt die Ausstattungs-Tafel
an, und die Reserve-Eingabe ist mit dorthin gezogen: die Kacheln zeigen, was
gemessen wurde, die Tafel, was eingestellt ist. Ein Eingabefeld zwischen
Messwerten sähe aus, als ließe sich eine Messung ändern.
Alle vier Verlaufslinien tragen denselben Ton. Die Regel steht im Bauteil
selbst — "muted where the figure is observed, accent where it can be acted on"
—, und hier ist keine Zahl anzufassen. Vier verschiedene Töne nebeneinander
behaupten einen Unterschied, den es nicht gibt.
Zwei Funde aus der Prüfung
--------------------------
- x-ui.spark warf fehlende Messwerte per array_filter heraus und verband die
Nachbarn. Zwei Fehler auf einmal: die Linie behauptete eine Messung, die es
nicht gab, und alles danach rutschte nach links — eine Stunde mit zwei Lücken
zeichnete sich als achtundfünfzig Minuten. Die x-Lage kommt jetzt aus dem
Platz in der URSPRÜNGLICHEN Reihe, und zusammenhängende Messwerte werden als
eigene Züge gezeichnet. Eine saubere Reihe ergibt genau einen Zug und
dasselbe Bild wie vorher, was alle bisherigen Aufrufer liefern.
- Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus v1.3.91 kennt
netin/netout nicht, und die Host-Seite wäre 55 Sekunden lang an einem
fehlenden Schlüssel gestorben — genau in der Minute, in der jemand nachsieht,
ob das Update durch ist. Der Schlüssel heißt jetzt host-load:v2:<id> und
wandert mit der Form mit.
Und einer, den kein Prüfer gemeldet hat: beim Zerlegen in Züge stand im
Flächenpfad ein `L` unmittelbar vor einem `M`. Gültig gelesen, nicht
gezeichnet — die Füllung verschwand still. Aufgefallen ist es beim Ansehen der
Seite, nicht durch eine Meldung; jetzt prüft ein Test, dass jeder Flächenpfad
mit M anfängt, mit Z endet und keinen Befehl direkt hinter einem anderen trägt.
x-ui.chart behält seinen update-on-Weg, obwohl diese Seite ihn nicht mehr
benutzt: er ist eine geprüfte Fähigkeit des gemeinsamen Bauteils, und der
darunterliegende Fix (Instanz aus dem reaktiven Alpine-Objekt) gilt für jeden
Chart.
Geprüft: 2291 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit
eingespielten Messwerten: sechs Kacheln, Lücke als echte Aussparung in Linie
UND Fläche, Leerzustand zeigt "—" statt einer Null, null Konsolenfehler über
einen vollen Poll-Zyklus.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Karte "Rechenleistung" zeigte keine Leistung. 12 Kerne und 63 GB sind die
Ausstattung des Blechs und ändern sich nie — sie standen aber im selben
Kartenraster wie "Zustand" und "Speicher", die beide leben. Wer die Seite
öffnete, um zu sehen, wie es dem Host geht, las dort eine Zahl, die das nie
sagen konnte.
An ihrer Stelle steht jetzt die Last: CPU und RAM als Stundenkurve, beide in
Prozent auf EINER Achse, dazu die aktuellen Werte als beschriftete Zahlen.
Die Geschichte kommt aus Proxmox' eigener Aufzeichnung
(/nodes/{node}/rrddata), nicht aus einem eigenen Sampler. Ein Sampler hieße
neue Tabelle, minütlicher Job, Aufräum-Job und ~1440 Zeilen je Host und Tag —
um weniger genau nachzubauen, was ohnehin auf der Platte liegt. Die RRD ist ab
der ersten Sekunde gefüllt, auch für die Stunde vor dem ersten Hinsehen, und
kann nicht von dem abweichen, was Proxmox' eigene Oberfläche zeigt.
Eine Lücke bleibt eine Lücke: ein Punkt ohne Messwert wird null, nie 0, und
spanGaps steht auf false. Dieselbe Regel wie in instance_metrics. Antwortet der
Host gar nicht, sagt die Tafel das in einem Satz, statt eine ruhige Stunde zu
zeichnen — auf dem Testhost live bestätigt.
Der Fehler, den das ans Licht gebracht hat
------------------------------------------
x-ui.chart steht überall unter wire:ignore, sonst zerstört Livewire das Canvas.
Ein Poll erreicht den Chart also nie. Dafür bekam das Bauteil ein optionales
update-on: es hört auf ein Fenster-Ereignis und tauscht die Daten IM
bestehenden Chart.js-Objekt.
Das lief nicht. Die Zahlen neben der Kurve wanderten, die Kurve nicht, und
chart.update() starb still im Legenden-Plugin:
TypeError: Cannot set properties of undefined (setting 'fullSize')
Grund: die Chart.js-Instanz lag als Eigenschaft im Alpine-Objekt und wurde
damit reaktiv umhüllt. Chart.js' Plugin-Innenleben überlebt das Proxy nicht.
Gemessen statt geschlossen: dieselbe Instanz wirft über das Proxy und läuft
über Alpine.raw(). Sie liegt jetzt in der Closure.
Aufgefallen ist es nie, weil bis zum ersten Live-Chart kein einziger Chart in
diesem Repo je update() gerufen hat — konstruieren und Erstzeichnen gehen durch
die Hülle noch. tests/Feature/ChartLiveUpdateTest.php hält die Regel fest,
damit der nächste Live-Chart nicht denselben Nachmittag kostet.
Der Rest
--------
- Version lesbar: "Proxmox VE 9.2.6" statt pve-manager/9.2.6/7f8d…, mitten in
der Bau-Kennung abgeschnitten. Die Kennung steht klein darunter. Eine
unerwartete Form wird unverändert durchgereicht statt verschluckt.
- Vier Kleinkarten (Mgmt-IP, Node, Version, Instanzen) sind eine
Ausstattungs-Tafel geworden. Die Instanzen-Anzahl steht in der Überschrift
der Liste, die sie ohnehin zeigt.
- Der Übernahme-Fortschritt klappt zu, sobald sie durch ist. Fünfzehn
abgehakte Schritte sind auf einem laufenden Host kein Dauerinhalt —
aufklappbar über <details>, ohne JavaScript.
- PlanVersion::requiredTemplateVmids() ersetzt die dritte Kopie derselben
Fensterlogik.
- BuildVmTemplate sagt nicht mehr "this takes 10–20 minutes". Das war eine
Schätzung vor dem ersten Lauf; gemessen waren es unter zwei. Eine Konsole,
die falsch vorhersagt, erzieht dazu, sie zu ignorieren.
Geprüft: 2281 Tests grün, Pint sauber, Codex ohne Befund. Die Farbwahl gegen
den Validator gerechnet (ΔE 28,3 protan / 39,2 normal; der Akzent liegt unter
3:1 gegen die Fläche, deshalb tragen beide Reihen sichtbare Beschriftung). Im
Browser: null Konsolenfehler über einen vollen Poll-Zyklus, und die Kurve
wandert auf dem echten Poll ohne Neuladen — mit eingespielten Messwerten
belegt, weil die Testhosts in TEST-NET liegen und nie antworten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der letzte Handgriff in der Host-Übernahme fällt weg. VerifyVmTemplate meldete
bisher nur, dass eine Vorlage fehlt, weil niemand entschieden hatte, was in die
goldene Vorlage gehört. Entschieden ist es längst und steht in
deploy/bootstrap/lib/template.sh — der neue Schritt BuildVmTemplate lädt genau
diese Datei auf den Host und führt sie dort aus, statt ihre Prüfungen ein
zweites Mal in PHP zu haben.
Er läuft abgekoppelt und wird abgefragt: Abbild laden und drei
virt-customize-Läufe brauchen zehn bis zwanzig Minuten, ein einzelner
SSH-Aufruf liefe gegen den Befehlszeitablauf von 2000 s. "Läuft noch" heißt
dabei, dass der Prozess lebt (kill -0 gegen die hinterlegte PID) — in der
Statusdatei steht "running" auch dann noch, wenn niemand mehr da ist, der sie
ändert.
Fünf Fehler, die dabei aufgefallen sind und Geld gekostet hätten:
- qm importdisk hängte die Platte unter ${storage}:vm-9000-disk-0 ein. Der Name
gilt nur bei Block-Ablagen; auf einer Verzeichnis-Ablage heißt sie
local:9000/vm-9000-disk-0.qcow2 — also genau auf dem per Debian aufgesetzten
Proxmox, um das es hier geht. Jetzt qm set --import-from, und Proxmox
benennt selbst.
- growpart war nie installiert. GrowGuestFilesystem ruft es auf, und es lief
bisher, weil Debians Cloud-Abbild es zufällig mitbringt. Fiele es heraus,
läge jedes gekaufte Kontingent über einem Dateisystem, das nie gewachsen ist.
Jetzt ausdrücklich eingebaut und als vierte Falle nachgewiesen.
- local nimmt ab Werk keine Platten an. Ohne das stirbt nicht nur der Bau,
RegisterCapacity meldet danach Kapazität 0: ein Host, der fertig aussieht und
nie einen Kunden tragen kann. ensure_image_storage greift nur ein, wenn keine
Ablage Platten annimmt, hängt images an die vorhandene Liste an statt sie zu
ersetzen, und schreibt über pvesm set statt in die pmxcfs-Datei.
- Ein abgebrochener Download blieb unter dem Zielnamen liegen und wäre beim
nächsten Lauf ungeprüft weiterbenutzt worden. Jetzt .part, umbenannt erst
nach geprüfter Summe.
- VerifyVmTemplate und VmTemplateCheck fragten nur, ob VMID 9000 existiert. Ein
abgebrochener Bau hinterlässt eine gewöhnliche VM mit dieser Nummer, und
beide sagten dazu "passt" — der Fehler kam beim ersten bezahlten Klon zurück.
Jetzt template: 1.
isTemplate() stellt zwei Anfragen, weil die falsche Antwort hier etwas
zerstört: false heißt "Vorlage fehlt", und der Bau fängt mit qm destroy --purge
an. Proxmox beantwortet die Konfiguration einer nicht vorhandenen VM mit 500 —
demselben Code wie einen Knoten in Not. Die VM-Liste klärt deshalb die
Abwesenheit, alles darunter wirft und landet im Wiederholungs-Zweig.
Aufgeben beendet erst die Prozessgruppe, dann räumt es auf, und gebaut wird nur
die Fehlliste: create_proxmox_template räumt eine VMID weg, bevor es sie
anlegt, also hätte "alles Verlangte" eine gesunde zweite Vorlage auf dem Weg
zerstört.
Geprüft: 2267 Tests grün, Pint sauber, sh -n über alle drei Shell-Dateien, die
storage.cfg-Auswertung gegen eine echte Beispieldatei durchgespielt, und jeder
Befehl, den der Schritt absetzt, geht durch sh -n — keine andere Prüfung führt
diese Shell je aus. Drei Codex-Runden (R15), alle Befunde behoben.
Nicht geprüft: nichts davon lief je gegen echte Hardware. Die erste Übernahme
auf einem Proxmox-Host ist die Abnahme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Übernahme des ersten PVE-9-Hosts blieb in CreateAutomationToken stehen:
`pveum role modify` lehnt die GANZE Privilegienliste ab, sobald ein einziger
Name darin unbekannt ist — "invalid privilege 'VM.Monitor'". Die Liste stammt
aus der PVE-8-Zeit.
Ersatzlos gestrichen, nicht ersetzt: VM.Monitor gab Zugriff auf den
QEMU-Monitor, und den benutzt CluPilot nirgends. Geprüft, nicht vermutet — die
einzigen Treffer auf "monitor" im Provisioning betreffen Uptime Kuma. Auf PVE 8
fehlt damit ein Recht, das dort ohnehin niemand gebraucht hat; eine Liste passt
weiterhin auf beide Fassungen.
Dazu ein Test, der die angeforderten Namen gegen die gültigen hält — abgelesen
aus der Administrator-Rolle eines echten PVE 9. Die Liste ist die Verabredung
zwischen CluPilot und jedem Host, den es je übernimmt; sie hier zu prüfen ist
billiger als der Abbruch auf einer Maschine, die schon halb eingerichtet ist.
2245 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
„could not converge the Proxmox automation role privileges", fünfmal
hintereinander. Die Meldung sagte, WAS scheiterte, und verschwieg das Einzige,
was weiterhilft: was `pveum` dazu geschrieben hat. Der Betreiber musste sich
auf den Host melden und den Befehl von Hand nachstellen, um eine Zeile zu
lesen, die daneben schon dastand.
Die Fehlerausgabe von `pveum role modify` steht jetzt in der Meldung — erste
Zeile, gekürzt: `pveum` stellt den Grund voran und breitet danach seine
Aufrufhilfe aus, und die wäre ein Bildschirm Text in einer Ereigniszeile.
2244 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Übernahme brach in "WireGuard einrichten" ab: fünf Wiederholungen,
"WireGuard handshake not up yet". Geprüft wurde mit `ping -c1 -W2 <hub-ip>`.
Das fragte drei Dinge auf einmal und nannte nur eines: ob der Handshake steht,
ob ICMP durchkommt — und ob es `ping` auf der Maschine überhaupt GIBT.
Auf Hetzners `Debian-trixie-latest-amd64-base` gibt es das nicht. `iputils-ping`
ist im Image nicht dabei, und PrepareBaseSystem installiert
`curl gnupg ifupdown2 chrony`. Der Schritt scheiterte damit über einem Tunnel,
der stehen konnte. Auf dem alten Proxmox-Image war ping dabei, deshalb lief es
dort durch — dieselbe Pipeline, anderes Grundsystem.
Gefragt wird jetzt WireGuard selbst: `date +%s; wg show wg0 latest-handshakes`.
Die Uhr des HOSTS kommt in derselben Antwort mit, weil `latest-handshakes` eine
absolute Zeit ausgibt und ein Vergleich gegen UNSERE Uhr eine Zeitverschiebung
zwischen zwei Maschinen als Tunnelzustand läse. `date` ist in coreutils und
überall da.
Codex, zwei Runden:
- P1: `> 0` hiesse "hat jemals". WireGuard behält den Zeitstempel unbegrenzt,
also meldete ein Wiederholungslauf über einem toten Tunnel "steht", und die
Schritte danach wählten die Tunneladresse für SSH. Jetzt muss der Handshake
frisch sein (180 s) und vom KONFIGURIERTEN Hub kommen — ein fremder Peer auf
wg0 ist kein Beweis dafür, dass wir erreichbar sind.
- P1: Der neue Host-Versatz (.100) liess die Vergabe in einem Subnetz kleiner
als /26 "erschöpft" melden, obwohl unten alles frei war. Der Versatz ist eine
Bevorzugung, keine Bedingung: zweiter Durchgang von vorn.
Ausserdem, wie gewünscht: Hosts bekommen ihre Tunneladresse ab .100
(CLUPILOT_WG_HOST_OFFSET), Personen zählen weiter von unten. Fortlaufend
vergeben landete der erste Host zwischen zwei Notebooks, und wer eine Adresse
in einem Protokoll las, konnte nicht sagen, ob dahinter ein Mensch oder eine
Maschine steht.
2243 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Personen und Hosts standen gemischt untereinander, nach Verbindungszustand
sortiert — ein Host-Zugang konnte also zwischen zwei Mitarbeitern stehen.
Beide sind Peers im selben Netz und haben dieselben Messwerte, aber es sind
zwei verschiedene Fragen: "wer von uns ist im Netz" und "welche Maschinen
hängen dran". Wer die eine stellt, liest die Antworten der anderen als
Rauschen.
Jetzt zwei Gruppen mit Überschrift und Anzahl. Eine Gruppe ohne Einträge wird
gar nicht erst gezeichnet — auf einer frischen Installation stünde sonst
"Hosts" über einer Lücke, bevor je einer angelegt wurde.
Getrennt wird nach der geladenen host-Beziehung, nicht nach `kind`: die
Plakette in der Zeile fragt dasselbe, und wer einen "Host-Zugang" liest, soll
ihn auch unter den Hosts finden. Ein adoptierter Peer (kind=system) mit
host_id gehört zu den Hosts, obwohl seine Art etwas anderes sagt.
Die Zeile selbst ist nach resources/views/components/admin/vpn-peer-row.blade.php
gewandert. Sie zweimal hinzuschreiben hätte geheissen, sie ab dem nächsten
Knopf an zwei Stellen zu pflegen — und die zweite fällt erst auf, wenn jemand
einen Host-Zugang sucht und ihn anders aussehen sieht als seinen eigenen.
Die Plakette bleibt trotz der Überschrift daneben: eine Zeile wandert beim
Suchen aus ihrer Überschrift heraus, und dann steht sie ohne sie da.
Codex: keine Befunde. 2237 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unter jedem Schritt stand `dashboard.step_state.done` statt eines Wortes —
im Adminbereich UND im Kundenportal, wo derselbe Stepper jemanden durch die
Einrichtung seiner Cloud begleitet.
Die Schlüssel gab es in keiner der beiden Sprachdateien. Die Paritätsprüfung
aus R16 konnte das nicht finden: sie vergleicht Deutsch mit Englisch, und die
beiden waren sich einig. Der Aufruf ist zusätzlich zusammengesetzt
(`__('dashboard.step_state.'.$state)`), also findet ihn auch keine Suche nach
festen Zeichenketten. Der neue Test rendert deshalb das Bauteil und prüft alle
vier Zustände in beiden Sprachen.
Dazu ein Test, der heute Nacht hochgegangen ist: PortalInvoicesTest verbot die
Zeichenkette '01.08.2026' — das Datum aus einer alten Attrappe. Heute ist der
1. August 2026, und die echten Rechnungen des Tests tragen das
Ausstellungsdatum von heute. Eine Zusicherung, die ein Datum verbietet,
verbietet auch den Tag, an dem es echt wird. Was die Attrappe ausmachte, war
ihr Satz, und den prüft der Nachbartest über 'Nächste Abbuchung' — stabil,
weil er nirgends sonst entsteht.
2235 Tests grün.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>