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>