80 Commits (53f1293f0905510e10be2282fe3629180ffdbb0a)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
5fe33553ad |
Zertifikats-Spec auf Weg B umgeschrieben, Registereintrag gestrichen
tests / pest (push) Waiting to run
Details
tests / assets (push) Waiting to run
Details
tests / release (push) Blocked by required conditions
Details
Die Spec fuer die Host-Konsole (docs/superpowers/specs/2026-08-01-host- konsole-zertifikat-design.md) war auf einer eigenen DNS-Zone und Proxmox' Alias-Modus fuer DNS-01 gebaut. Hetzner kennt keine Subzonen (docs.hetzner. com/networking/dns/faq/zones/, Artikel NE-7597D: "Subzones are not supported") -- damit entfallen eigene Zone, CNAME je Host, Alias-Modus und der pro Host verteilte DNS-Token ersatzlos. Der Grund bleibt in der Spec stehen, statt geloescht zu werden, damit niemand denselben Weg in einem halben Jahr erneut vorschlaegt. Weg B, wie im Register vorgegeben: CluPilot stellt zentral aus, DNS-01 ueber den vorhandenen kontoweiten Hetzner-Token, Zertifikat per SSH (RemoteShell::putFile + `pvenode cert set --force --restart`, geprueft gegen die Proxmox-Dokumentation) auf den Host, Erneuerung als geplanter Job auf der Bereitstellungs-Warteschlange (dieselbe Grenze wie SyncVpnPeers -- nur queue-provisioning erreicht einen Host ueber den Tunnel). Dazu ein Vergleich mit der Kundeninstanz (ConfigureDnsAndTls, HTTP-01) und eine genaue Bestandsaufnahme der Bereitschaftsseite: sie kennt heute kein Zertifikat, weder fuer Hosts noch, trotz ersten Anscheins, uebertragbar fuer die Plattform -- CertificateSweep/ProxyHost misst nur oeffentlich erreichbare Namen und laeuft im falschen Container fuer einen Host-FQDN. Im Code bestaetigt und in der Spec vermerkt: RrsetId::zone() ist heute fest auf die Kundenzone verdrahtet, ein Host-FQDN liegt aber in der Plattformzone -- das ist Bauarbeit, keine offene Entscheidung. Offen bleibt nur, welches Werkzeug das ACME-Protokoll auf CluPilot-Seite spricht (keine Bibliothek/kein Tool dafuer im Repo) und ob Plattform- und Kundenzone im selben Hetzner-Projekt liegen -- beides als offene Fragen benannt, keine davon blockiert den Rest des Ablaufs. Registereintrag in OpenWork.php gestrichen: die Spec beschreibt keinen toten Weg mehr, und genau das war der einzige Punkt, den der Eintrag festhielt. Getestet: php artisan test --filter=OpenWork, 8 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
fd216be623 |
Der Hostnamen-Zähler übersteht jetzt das Löschen eines Rechenzentrums
Der Zähler lag auf der Rechenzentrums-ZEILE (next_host_number). Ein leeres Rechenzentrum liess sich löschen - bewusst so entschieden, was nichts mehr hält, soll entfernbar bleiben -, aber die Zeile nahm den Zähler mit. Wer denselben Code neu anlegte, bekam eine frische Zeile mit dem Schema-Default 1, und der nächste Host hiess wieder <code>-01, obwohl dieser Name schon in alten Protokollen, Sicherungen und DNS-Zwischenspeichern auf eine ANDERE Maschine zeigt. Der Zähler zieht deshalb in eine eigene Tabelle host_name_sequences um, geführt über den rohen Code statt über die id der Rechenzentrums-Zeile. ConfirmDeleteDatacenter bleibt unangetastet: das Löschen war nie das Problem, nur was es mitriss. Die Migration überträgt den Bestand (fsn/hel) vor dem Löschen der alten Spalte und ist gegen echtes MariaDB in beide Richtungen geprüft (hoch, Werte kontrolliert, zurück, wieder hoch). Neuer Test in HostNamingTest stellt den ganzen Bruch nach: Rechenzentrum anlegen, Host vergeben, Host entfernen, über den echten Bestätigungsdialog löschen, mit demselben Code neu anlegen - der nächste Name bleibt fortlaufend statt wieder bei 01 zu beginnen. Gegen den unveränderten Code lief er rot (HostName::preview lieferte nbg-01 statt nbg-02). Registereintrag "Ein Zähler kann durch Löschen eines Rechenzentrums zurückfallen" gestrichen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
507636f38f |
Die abgebuchte Domain wird jetzt wirklich von der Maschine genommen
tests / pest (push) Waiting to run
Details
tests / assets (push) Waiting to run
Details
tests / release (push) Blocked by required conditions
Details
Der Registereintrag nannte den falschen Grund: das Deaktivieren startet sehr wohl eine Provisionierung. CustomDomainAccess::deactivate() ruft seit Langem ReapplyInstanceAddress, das legt einen Lauf der `address`-Pipeline an und schickt AdvanceRunJob auf die provisioning-Warteschlange; ConfigureNextcloud loescht dort trusted_domains 2 und ConfigureDnsAndTls schreibt den Router ohne den Namen neu. Das ist gebaut und geprueft. Der Schaden war trotzdem echt, nur eine Tuer weiter. Erreicht wurde deactivate() allein ueber PlanChange::settleCustomDomain, also ueber den Paketwechsel. Der zweite und haeufigere Weg, auf dem das Recht endet — der Kunde bucht das Modul in der Abrechnung ab, clupilot:end-cancelled-addons haelt den Termin am Ende des bezahlten Zeitraums — ging an dieser Stelle vorbei: BookAddon::cancel() lieferte Speicher nach und sprach mit Stripe, fragte aber niemanden nach der Adresse. Die Domain verschwand aus jeder Ansicht und blieb auf der Maschine stehen. BookAddon::cancel() fragt jetzt CustomDomainAccess::enforce() — die ganze Regel, nicht den Modulschluessel: wer von Team auf Business aufgestuft hat und sein altes Modul loswird, behaelt die Domain, weil das Paket sie selbst traegt. Und der Anstoss darf die Entscheidung nicht kippen. deactivate() faengt jetzt einen Fehlschlag der Nachfuehrung ab und schreibt ihn als Fehler ins Log: die Wahrheit steht in der Datenbank, die Maschine zieht nach, und eine Kuendigung haengt nicht daran, ob ein fremder Host gerade antwortet. Die Gegenrichtung brauchte nichts: der Entzug loescht die Domain-Spalte, also traegt der Kunde sie nach der Neubuchung neu ein und weist sie neu nach — und genau dort haengt seit jeher der Lauf, der sie wieder ausliefert. Ein Test haelt das fest, damit es keine Einbahnstrasse wird. Registereintrag gestrichen. Rot gesehen: ohne den settleCustomDomain-Aufruf fallen drei der vier neuen Tests; ohne das try/catch faellt der vierte. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
29f13275f8 |
Das Register behauptete etwas Falsches: der Plan-Wechsel IST gebaut
"Ein Plan-Wechsel wird nirgends angewendet" stimmt seit laengerem nicht mehr. ApplyPlanChange faehrt die plan-change-Pipeline, aufgerufen vom OrderObserver bei einer Aufstufung und von clupilot:apply-due-plan-changes bei einer Abstufung zum Laufzeitende. settleCustomDomain() hat sehr wohl einen Aufrufer (ApplyPlanChange:256), und sieben Testdateien mit 43 Pruefungen decken den Weg. Aufgeschrieben, weil der Kopfkommentar dieser Datei genau das ausschliesst: "Ein Punkt verschwindet, wenn die Arbeit im selben Commit fertig wird, der ihn streicht — und damit kann die Liste nicht behaupten, etwas sei offen, das es laengst nicht mehr ist." Genau das ist passiert. Wer eine Liste fuehrt, deren einziger Zweck Ehrlichkeit ist, muss sie mit der Arbeit streichen, nicht danach. Gepruefte Restliste: neun Punkte. Zwei davon (zweiter Sicherungsort, Office Pro) haengen an Infrastruktur, die es noch nicht gibt; einer (Hostnamen- Abnahme) an der echten Anlage; einer (Support-Mail) an einem SMTP-Konto, das der Betreiber anlegen muss. |
|
|
|
cfbe339df0 |
Die Seitenleiste fragt zuerst, ob etwas auf dich wartet
Achtundzwanzig Eintraege in sieben Gruppen, und „System" war der Platz fuer
alles, was sonst nirgends hinpasste: Mail-Einrichtung neben einem
Rechtsdokument, neben den persoenlichen Kontoeinstellungen, neben der
Mitarbeiterverwaltung — und ganz unten die Seite, die sagt, was liegt. Der
Betreiber hat es so beschrieben: „offene Punkte ist der letzte Punkt, dann
Rolle drueber und Einstellungen wieder drueber".
Zwei Regeln ordnen es jetzt.
Was man einmal einrichtet, verlaesst die Leiste: neun Seiten liegen als
Kacheln hinter EINEM Eintrag (admin.setup), gruppiert nach dem, was sie
einrichten. Keine dieser Seiten wurde angefasst — sie behalten Route,
Berechtigung und Inhalt, es aendert sich nur der Weg dorthin. Damit ist der
Umbau rueckholbar.
Und die drei Seiten, auf denen etwas WARTET, stehen ganz oben, mit einer Zahl
daneben: das ist die Frage, mit der man eine Konsole oeffnet. Stoerungen sind
aus „Betrieb" nach oben gezogen und Zahlungsprobleme aus „Geld" — umgezogen,
nicht verdoppelt. Bei null faellt die Plakette weg, der Eintrag bleibt: eine
Seite, die verschwindet, sobald nichts offen ist, ist genau dann nicht
erreichbar, wenn man nachsehen will, ob wirklich nichts offen ist.
Zwanzig Eintraege statt achtundzwanzig, jede Seite genau einmal.
Zwei Dinge, die beim Verschieben kaputtgegangen waeren:
* Die neun verschobenen Seiten standen nicht mehr in console(). Damit war auf
ihnen KEIN Eintrag markiert (Codex R15, P2) und currentLabel() lieferte
null — die Brotkrume haette dort nur noch „Konsole" gesagt. Zwei Stellen,
eine Wurzel: die Kachelliste ist nach Navigation::setup() gewandert, wo
beide sie lesen, und isCurrent() haelt die Tuer markiert, solange man
dahinter steht.
* Die Versionszeile im Fuss stand als toter Text da, waehrend die Seite mit
dem Aktualisierungsknopf in die Einrichtung gezogen war. Gemeldet vom
Betreiber: „man sieht es nicht, ohne genau hinzuklicken." Sie ist jetzt der
Weg dorthin — und sagt in der Akzentfarbe, wenn etwas wartet. Wartet
nichts, bleibt sie grau: eine Zeile, die immer ruft, ruft nie.
Die drei Zahlen liegen fuer eine Minute im Zwischenspeicher. Diese Leiste
rendert auf JEDER Konsolenseite; ohne das waeren es vier Abfragen je
Seitenaufruf — eine Abgabe, die man erst sucht, wenn die Konsole zaeh ist.
Achtzehn Zusicherungen, darunter die, auf die es ankommt: keine der
achtundzwanzig Seiten von vorher ist verlorengegangen. Die Liste steht im Test
ausgeschrieben und nicht aus der Repository-Geschichte gelesen — ein Test, der
sich seine Erwartung aus demselben Repository holt, das er prueft, prueft
nichts.
Entwurf: docs/superpowers/specs/2026-08-04-konsolen-seitenleiste-design.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
58b82aa8a2 |
commandExpandingEnv zurueckgenommen — sie schuetzte vor nichts
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> |
|
|
|
0c8b7813b4 |
Kommentare korrigiert nach Codex-Befund
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> |
|
|
|
4feaba1bb1 |
Eine occ-Befehlsform, deren Wert erst im Container eingesetzt wird
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> |
|
|
|
7087ef6a2f |
Die Bereitschaftsseite meldet eine Spur, die steht
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> |
|
|
|
1886bf2076 |
Sicht auf die Spuren, und ein Schalter, der die Drossel abstellt
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> |
|
|
|
552be46881 |
Was aus dem Gast kommt, ist keine Adresse, bis es geprueft ist
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>
|
|
|
|
51a97019a8 |
Sperren sehen und aufheben: Portalseite fuer den Inhaber, Abschnitte in der Konsole
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> |
|
|
|
d3407ff613 |
Melder: gescheiterte Anmeldungen lesen, zaehlen, sperren
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. |
|
|
|
c99911ca55 |
Sicherheitsdurchsicht: Passwort-Logins am Host zu, und die Konsole sagt selbst, wo sie offensteht
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> |
|
|
|
d7bb0f2e63 | Offene Punkte: nach Dringlichkeit gruppiert, in der Formensprache der Bereitschaftsseite | |
|
|
07c51474d3 | Update drehte sich im Kreis: es ersetzte sich selbst mitten im Lauf; dazu eine Seite fuer offene Punkte | |
|
|
c0f83ab957 | Seitenleiste neu geordnet: Betrieb trug zwoelf Eintraege aus drei Themen | |
|
|
d4f534fbbb |
Interne Pakete gehören nicht nach Stripe
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> |
|
|
|
4cd848eead |
Fix-Runde 2: Warnung fuer nicht reparierte Hosts, case-sichere Vorabpruefung, wiederholbare Migration
Vier Befunde aus dem Abschluss-Review ueber d62a2c8/c815aee/11ba7ee: - Die Migration renamt nur Hosts mit dns_name; pve-fsn-1/pve-hel-1 blieben unrepariert und stumm. Sie werden jetzt gesammelt und gemeldet (Log + Konsole), ohne die Migration abzubrechen - diese Hosts laufen weiter. - Die Vorabpruefung verglich in PHP byteweise, die Spalte liegt auf utf8mb4_unicode_ci. Umgestellt auf GROUP BY/HAVING in SQL, damit dieselbe Kollation entscheidet, die spaeter den Unique-Index baut. - Ein Fehlschlag nach der Vorabpruefung liess einen zweiten Anlauf sofort an "Duplicate column name" sterben. Die beiden betroffenen Schema-Schritte stehen jetzt hinter Schema::hasColumn(), macht den Kommentar darueber wahr. - Seeder (DatabaseSeeder, DemoCustomerSeeder) sind auf pve-*-Namen sitzen geblieben, weil sie dns_name nie benutzt hatten. Auf fsn-01/hel-01 umgestellt, next_host_number entsprechend vorbelegt. Dazu vier Kleinigkeiten: ein Test nagelte den falschen Config-Schluessel fest (dns.zone statt platform_zone), HostName::claim() erzwingt jetzt wirklich eine Transaktion statt es nur zu verlangen, down() vergisst nicht mehr den Settings-Cache, und der Kommentar ueber HostName::free() nennt jetzt ehrlich die Einschraenkung auf einen einzelnen Thread. Alle vier Migrationslaeufe (Vorabpruefung-Kollision, Meldung fuer unreparierte Hosts, Fehlschlag-und-erneuter-Anlauf, Rueckbau mit Cache-Invalidierung) gegen echtes MariaDB auf einer Scratch-Datenbank geprueft, um die parallele Billing-Session nicht zu beruehren. Voller Testlauf: 2395 bestanden. Bericht mit allen Befehlen und Ausgaben unter .superpowers/sdd/2026-08-01-hostname-vergabe/final-fix-report.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d62a2c8ff8 |
Hostnamen vergibt CluPilot: ein Name statt zweier, und der Zaehler ueberlebt das Loeschen
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>
|
|
|
|
665a0c4e08 |
Release v1.3.91 — die Host-Seite zeigt Last statt Ausstattung
tests / pest (push) Failing after 9m29s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
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>
|
|
|
|
b9fb401823 |
Release v1.3.85 — Handshake-Prüfung hing an einem Programm, das es nicht gibt
tests / pest (push) Failing after 8m54s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
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> |
|
|
|
8ab50f5650 |
Release v1.3.81 — Host anlegen scheiterte am WireGuard-Peer
tests / pest (push) Failing after 9m3s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Das Anlegen eines Hosts endete in der Konsole mit einem 500, bevor der Betreiber den Einmal-Code je zu sehen bekam. Ursache war nicht das Anlegen, sondern der Tunnel-Peer: HostEnrolment::issueWithKeys() rief `wg set wg0` selbst auf — im Web-Request, also im `app`-Container. Der hat weder NET_ADMIN noch /dev/net/tun; wg0 lebt im Provisioning-Container. Die Antwort war "Unable to modify interface: Operation not permitted". Der Peer geht jetzt als ApplyHostVpnPeer auf die Provisioning-Queue — genau dorthin, wo ApplyVpnPeer es für die VPN-Zugänge längst richtig macht, mit derselben `wireguard:hub`-Sperre und demselben Grundsatz: der Sollzustand kommt beim Ausführen aus der Zeile, nicht aus dem beim Einreihen festgehaltenen Wert. Der abgelöste Schlüssel reist als Wert mit, weil in der Zeile zu diesem Zeitpunkt schon der neue steht. Aufgefallen ist es nie, weil die Testsuite den Hub gegen FakeWireguardHub tauscht — der bestehende Test blieb grün, während der echte Weg seit jeher fehlschlug. Die zwei neuen Tests prüfen deshalb den WEG statt des Ergebnisses: in der Anfrage bleibt der Hub unberührt, und der Auftrag liegt auf der Provisioning-Queue. Codex: 0 Fehler, 0 Sicherheitsbefunde. Dazu sein P2 — /.claude/ stand weder im Index noch in .gitignore, ein `git add -A` hätte 153 MB als verschachteltes Repo eingebettet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7f7b654fcb | Zahlungsprobleme: geplatzte Kaeufe aufzeichnen, beide Arten an einer Stelle | |
|
|
93e848d3f9 | Rollen und Rechte in der Konsole zuweisbar | |
|
|
0944de7cfa | Passwortrichtlinie einstellbar, doppelte Anmelde-Mail behoben | |
|
|
19ed6461cf | Bereitschaft: das Absender-Postfach wird gefragt, ob es senden KANN | |
|
|
200df30b9d |
Hetzner-Cloud-DNS, Bereitschaftsseite aus sich heraus behebbar
Die alte Hetzner-DNS-API ist abgeschaltet (301 auf die Weboberflaeche).
Jede Bereitstellung starb in ConfigureDnsAndTls, nachdem der Kunde bezahlt
hatte. HttpHetznerDnsClient und DnsTokenCheck sprechen jetzt die Cloud-API:
Bearer statt Auth-API-Token, RRSets ueber {name}/{typ} statt Record-IDs, der
Zonen-Lookup entfaellt. Gegen das Live-Konto gemessen, nicht geraten.
Drei Fallen, die am Konto gemessen wurden: TXT-Werte muessen in
Anfuehrungszeichen (sonst 422, was als read_only gemeldet worden waere), ein
Name mit Zonensuffix wird STILL angenommen (201), und der Fake gab andere IDs
aus als der echte Client -- zwoelf Tests waren gruen ueber einem Abbau, der im
Betrieb geworfen haette.
Bereitschaftspunkte, die nur eine Shell beheben konnte, sind jetzt bedienbar:
SSH-Schluesselpaar erzeugen (Ed25519 ueber phpseclib, privater Teil direkt in
den Tresor), Stripe-Katalog abgleichen (Warteschlange, Trockenlauf, Ausgabe
wortgleich), Mailzustellung als Schalter statt MAIL_MAILER, Neustart der
Arbeiterprozesse. Stripe hat jetzt alle drei Werte in der Konsole: Secret Key,
Signatur-Secret (Tresor, je Modus getrennt) und Publishable Key (Klartext).
Codex-Review (R15), zwei P1 behoben: der Signaturschluessel faellt nicht mehr
vom Live- in den Testplatz, und eine Record-ID aus der alten API macht einen
Host nicht mehr unloeschbar -- was adressierbar ist, wandelt eine Migration um,
der Rest wird beim Loeschen laut uebergangen statt geworfen.
2109 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
272cfe45e8 |
Keep IP addresses out of a certificate overview
127.0.0.1 was in the list on the live server, marked as console, showing "no valid certificate: Connection refused" in red. It came from ADMIN_HOSTS, where a bare IP is deliberately allowed — it is the way back into the console when a name does not resolve. My filter only asked for a dot, and 127.0.0.1 has three. Let's Encrypt does not issue for IP addresses, so that row was permanently red and nobody could ever fix it. A red line that cannot be acted on is worse than no line: it teaches the reader to skip the colour, which is the one thing the overview needs them not to do. Two checks now, not one: the address test, and a last label that is not numeric. A TLD is never a number, and that also catches the forms FILTER_VALIDATE_IP lets through. The row already in the database goes away on the next sync, because otherwise my mistake would sit on every installation that has already updated. Vanished config rows are handled by what they carry: one that never had a certificate was a mistake and is deleted; one that HAS a live certificate becomes a manual entry instead, so nothing with a running expiry disappears without the operator deciding. That is a change of mind from the previous commit, which said config rows are never removed — this case showed the cost of that rule where the row should never have existed. 2043 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cfaea7dc82 |
Fill the hostname page from the installation, not from a blank form
The page was empty, and that was a design fault rather than a missing button. I built a register that starts blank — while the installation already serves half a dozen names whose certificates were exactly what the operator wanted to see. An overview you have to populate first does not answer "what do I have". So the names are derived now, from the same configuration routes/web.php builds its domain bindings from: SITE_HOSTS, APP_HOST, FILES_HOST, ADMIN_HOSTS. If a name is in the environment, the application answers on it, and then it belongs in this list without anybody typing it a second time. Opening the page syncs them; the list is never empty again. Syncing and measuring are deliberately separate. The sync costs nothing and runs on page load. The measurement goes out over the network and runs on the button or on a schedule — doing it on page load would mean waiting through half a dozen TLS handshakes, and one of them is always the name that currently does not resolve. Which is the other half of what was missing: there was no overview because nothing measured unless somebody pressed a button. A daily run at 04:17 fills it, because the question that matters is not "is it valid right now" but "is renewal running" — a certificate expiring in forty days is fine, the same one at twenty means something has been broken for a week. Only a measurement taken while nobody is looking can tell those apart. The page now opens with four counts — total, valid, expiring soon, without a certificate — and says when it last measured. A name that comes from the environment is marked as such and cannot be removed here: it would come back at the next sync, and a button that does nothing is worse than no button, because it gets believed once. 2040 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
60c501e40d |
Put the page in the navigation, and stop files. handing out the portal
Two faults, both mine, both reported from the live server.
The page had a route and no navigation entry. A page reachable only by typing
its URL does not exist as far as the operator is concerned, and "Betrieb →
Hostnamen und Zertifikate" was exactly as findable as I had made it: not at all.
It sits under Betrieb rather than System because what is set there decides
whether an address ANSWERS — that is operations, not configuration.
And calling files.… without a path redirected to app.… The host-bound group only
claimed /bootstrap.tar.gz and /{file}; a bare / matches neither, so the request
fell through to the host-agnostic routes and landed on the portal. Two holes,
because / and a multi-segment path miss the placeholder for different reasons,
and both are closed now.
404 rather than a redirect, and that is the point rather than a detail. The
redirect told anybody who tried the name where the portal lives and that both
sit on the same machine — the one thing every other hostname in routes/web.php
is careful not to say. An address with nothing to offer has nothing to tell
either.
2037 tests pass, assets build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
156de12c8c |
Give the downloads a hostname of their own, with two halves
A domain that exists only to serve one installer is not worth a certificate. One that also carries the AGB, the AV and the TOM is — those addresses go into contracts and onto invoices and have to still resolve in three years, which an address that moves with the next rebuild of the portal cannot promise. That reason is what changed the answer. files.… over cdn./storage./archiv.: a CDN is an edge network and this is not one, so the name would be a lie the day a real CDN goes in front of it. "storage" reads like object storage or customer data, and a customer seeing it will wonder whether their files live there. "archiv" says superseded, which the terms currently in force are not — and R13 keeps paths and names English anyway. Two halves on that host, with opposite rules, and that is the whole point of giving it its own name: Public — storage/app/files/public/, served to anyone, indexable, because somebody looking for the terms should find them. Versioned filenames: agb-2026-01.pdf, never agb.pdf, so a contract signed in January cannot come to point at conditions written in July. The rule is written where somebody will look for it rather than enforced, because a upload that rejects a filename helps nobody. Private — the installer, and it is not a file in that directory at all: it is built from deploy/bootstrap on demand. The gate is the one-time enrolment code that is ALREADY in the pasted line, resolved without being consumed, because the code is still needed for every progress report and for the registration at the end. No second secret: a dedicated download token would never expire, would sit in shell histories forever, and would travel in the same line as the WireGuard private key — protecting the least sensitive thing with the exposure of the most sensitive one. 404 rather than 403 on a bad code, and noindex on the response. Path traversal is answered before it starts: basename() only, no directory tree under public/ by design, and dotfiles refused. The test walks ../../.env three different ways. Empty FILES_HOST keeps the archive on the portal host exactly as before, so nothing breaks in the window between setting the variable and the DNS record existing. The hostname had to move into phpunit.xml rather than a config()->set(): routes are bound at boot, so a test that sets it afterwards is setting it too late. 2025 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ba4996f316 |
Keep the platform on .com and the customers on .cloud
The owner caught me putting the archive on clupilot.cloud. That is the customer
zone, and looking into it turned up the same confusion already sitting in the
code — in the one place that matters most.
RegisterHostDns says "fsn-01.node.clupilot.com" in its own docblock. The
validation comment in Datacenters says it. ServicesTest writes it out verbatim.
The step itself built the name from config('provisioning.dns.zone') — the
CUSTOMER zone — so on this installation a host was actually called
fsn-01.node.clupilot.cloud. Three places asserting one thing and the code doing
another.
OfficialDomains explains why that matters and is worth not weakening: two
registrable domains by design, the company's for site, portal and console, the
instance zone for customer workloads. A Nextcloud is third-party software that
strangers sign into, and on the same registrable domain as the portal it shares
cookie scope with it. A host name in that zone does not break the separation,
but it puts it in question, and the next slip is more expensive.
So there is now a platform_zone, derived from APP_URL when unset, and
RegisterHostDns uses it.
My own archiveUrl was broken for a second reason. It fell back to url() when
APP_HOST is empty — and APP_HOST is empty on most installations, because empty
means "the portal answers on any hostname" and that is the default. Called from
the console, url() would have produced the CONSOLE hostname, and the line would
have 404'd on a machine that is not allowed to reach the admin area at all. It
takes the host from APP_URL now.
The script no longer guesses its own name. It used reverse DNS, then the tunnel
address, then a hard-coded clupilot.net — a third domain that appears nowhere
else in this project and was simply invented. PrepareBaseSystem has the same
invention. A guessed name does not stay guessed: it ends up in /etc/hostname, in
/etc/hosts, in every log line and in every certificate request the machine ever
makes. CluPilot knows the name because it just assigned it, so it passes --fqdn
and the script refuses without it. The value now also survives the reboot in the
arguments file, which it would not have.
The ACME contact moved to .com for the same reason it was wrong: the operator
does not live in the customer zone.
Open, and NOT decided here: the owner also wants the host to get a public DNS
record and a certificate on the .com name. RegisterHostDns deliberately writes
host names only into the tunnel's dnsmasq, and says why — publishing them hands
every scanner the internal subnet and roughly how many hosts sit behind it.
Nothing in the current design needs a public certificate for a host's own name;
Traefik serves customer domains, not this one. Reversing that is a security
decision and belongs to the owner, not to this commit.
1992 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
903ebdd2b2 |
Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the vertical slice that closes that: a one-time code, the archive the rescue system fetches, and the page that says what to do with both. The command carries EVERYTHING the script needs before the tunnel exists, because there is nothing to fetch — that is the whole point of spec §5. Which means CluPilot generates the WireGuard keypair and admits the peer at the hub before the machine has ever booted, and hands the private half over in the line. It is worthless within minutes: task 9 of the script replaces it with one generated on the machine. Shown exactly once. The database holds only the code's hash and never the private key, so leaving the page does not bring it back — it mints a new code, which invalidates the old one. That is deliberate: a glance at somebody's screen should be worth nothing an hour later. Which is also why save() no longer redirects. Sending the operator to the host detail page sends them away from the only value they need, and an existing test asserted that redirect — it now asserts the opposite, with the reason written next to it. SHA-256 rather than bcrypt for the code, and the reason is not speed. Both endpoints have to FIND the host by the code; with bcrypt that means trying every row. The code is 32 characters of CSPRNG output, so it has the entropy that stretching exists to manufacture. resolve() and claim() are separate because progress reports arrive BEFORE registration. If reporting consumed the code, a host could never register after its first message. The archive URL is always the public hostname. The console runs under admin.…, but this line executes on a machine that must not reach the admin area — it is locked down for exactly that reason — so route() from the console would emit a hostname that 404s on a server only reachable through the provider's console. The page warns about missing tunnel settings BEFORE the host is created, not after. An empty hub key produces a line that looks clean, copies fine, runs, and ends in a tunnel that never handshakes — discovered on the machine, after somebody has already paid for it. The three steps lead with the rescue system, because that is the one nobody knows by heart, and it says enabling is not the same as booting into it — the script refuses a running production machine, which is what a half-done switch looks like from the inside. 1986 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
880b5f1998 |
Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.
Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.
billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.
ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.
HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.
HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.
StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.
1966 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
786318b6d4 |
Never tell anyone to delete a catalogue their contracts bill on
The refusal added in the previous commit had a state it read wrongly, and
the wrong reading was destructive.
record() sat at the END of handle() and in no try/finally, while
createProduct() and ensure() throw uncaught. A run that died after the
first object left hasStoredObjects() true and recorded() null. The same
state arises with no failure at all: CheckoutController → PlanPrices::
ensure() and BookAddon → SyncStripeAddonItems → AddonPrices::ensure()
mint missing prices and never call record().
In that state the next run — in the SAME mode — refused with the foreign
account message and its "Clear plan_families.stripe_product_id,
plan_prices.stripe_price_id and the … registers first", and
billing.catalogue_synced blocked with the same text. The objects were
from the account in force. The right move was to resume, which is what
the idempotency keys exist for; instead an operator was handed a delete
instruction for a catalogue live contracts are billed on.
Two changes:
1. record() moves ahead of the creation loop, right behind the refusal.
There it is already proved that either nothing is stored or what is
stored belongs to the active account, so the moment carries the
claim just as well — and a run that dies part-way can no longer
leave a state that contradicts itself.
2. "Origin never recorded" gets its own sentence and its own cure,
separate from "established: other account".
StripeCatalogueMode::matchesActiveMode() becomes
belongsToAnotherMode(), which is only true where the other account
is fact. The check still blocks — the origin cannot be proved — but
it says "run the sync again", and it names no register to empty.
Red first:
⨯ it takes up a catalogue whose origin was never written down
Failed asserting that 1 matches expected 0.
⨯ it leaves no half-built catalogue that contradicts itself when a run dies
Failed asserting that null is identical to an object of class "App\Support\OperatingMode".
⨯ it tells an unrecorded origin apart from a foreign account
The two states are held apart by assertion, not by wording: only the
foreign-account sentence may name stripe_plan_prices, and the unrecorded
one must name stripe:sync-catalogue instead. The existing foreign-account
test keeps its teeth.
Full suite: 1817 passed, 6366 assertions.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
26cf1a72a0 |
Remember which Stripe account the catalogue was built in
The mode switches the credentials. It does not switch what was made from them: a Price id and a Product id belong to the account that issued them, and test and live are two accounts. Every credential got two slots on this branch; the ids derived from them sit in single-valued columns. So the planned sequence of this installation ended in the exact false green this page exists to rule out — sync in test, store the live key, switch, and billing.catalogue_synced went on reporting satisfied because it only asked whether the column was filled. "Bereit für Livebetrieb", and the first real order got "No such price". Detection, not repair. Stripe does not put the account in the id — prod_… and price_… look the same in both, only KEYS carry _test_/_live_ — so the origin cannot be read back out of a stored id, and asking Stripe is out: this page reaches nothing over the network on a page load. What is left is to write it down at sync time, which is what App\Support\StripeCatalogueMode does. One setting for the whole catalogue is only honest because stripe:sync-catalogue now REFUSES a run into a catalogue that belongs to the other account. Without that, the run would skip every row that already carries an id, answer "already in step", and record an account it never touched — the same lie one level down. The registers count as stored objects too: inStep() takes a registered row as proof on its own for the reverse-charge half. The `breaks` sentence says what happens (checkout fails, no order) and what actually helps, including the awkward half: re-running the sync is not enough, the pointers and both registers have to be cleared first. The slot migration backfills the one case it can prove: whatever is at Stripe was made with the one key this installation has ever stored, so it belongs to the account that key opens. Otherwise a long-synced catalogue would read as "origin unknown" and the page would demand a re-sync nobody needs. Red first: ⨯ it does not call the catalogue synced when its ids belong to the other account ⨯ it says the sale is refused and a fresh sync is needed, not that a column is empty ⨯ it records the mode its objects were created in ⨯ it refuses to work into a catalogue that belongs to the other account ⨯ it syncs into the new account once the stale ids are cleared Full suite: 1814 passed, 6357 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
823eeaf413 |
Refuse a key that does not belong to the slot it sits in
put('stripe.secret', 'sk_live_REALMONEY') in test mode was accepted,
get() handed it out, billing.stripe_secret reported satisfied, and the
badge above it said "Testbetrieb". The strict rule closed the automatic
route into that state (no fallback); the typed one stayed open.
The prefix decides it without touching the network, and that rule now
lives in ONE place — OperatingMode::ofStripeKey() — called by the three
that were answering it separately: the slot migration (unchanged verdict,
`?? Live` for a value it cannot place), StripeCheck's `live` flag
(unchanged verdict, null stays false), and the readiness check, which
never asked at all. A key it cannot place is not reported as a
contradiction: this check only says what it can prove.
The two directions get their own `breaks` sentence, because the
consequences are opposite — real money moving while the console says
test, versus no money moving while the order looks paid.
The "Prüfen" button no longer contradicts the check either: the page
rendered only ok/reason, so a live key in the test slot answered
"Geprüft: in Ordnung". It now names the account the key belongs to.
Red first:
⨯ it refuses a live key sitting in the test slot
⨯ it refuses a test key sitting in the live slot
⨯ it says what the wrong key does, not that a field is empty
Guard tests (ConfirmInModal, ModalHeight, IconLayout, DisplayTimezone)
run with the blade change: green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
955f1b874f |
Deliver the processing agreement, and hold the proof it was accepted
Art. 28(3) GDPR wants a contract wherever personal data is processed on somebody else's behalf, which is the whole of what this product does. "In writing" there includes electronic form (Art. 28(9)), so a document the customer can read plus a recorded acceptance is enough — no signature on paper. The website already promises "AV-Vertrag inklusive", which means it has to be obtainable without asking us for it. It was not obtainable at all. **The text is never this application's.** An operator uploads the document their lawyer wrote, names the version, and publishes it; the measures ride along as a second file, because they are an annex to the agreement and "which measures applied when this customer accepted" has to have one answer. Inventing the text here would have been worse than having none. **Uploading and publishing are two acts.** Acceptance is per version, so publishing leaves every customer who accepted the previous one outstanding again — correct, and far too expensive to trigger by dropping a file on a form. It goes through a confirmation modal that says exactly that (R23). **The customer's side** is a card in the contract tab: read the agreement, read the measures, one press to conclude it. What that press records is what makes it evidence rather than a flag — the version, the moment, the address it came from, and the login that pressed. Pressing twice is one agreement (unique index, not a check somebody can forget), and a superseded acceptance is kept rather than overwritten: it was true when it was made, and the history is the point. Nothing renders until a version is in force. A card offering an agreement that does not exist is worse than the silence. The files live on the private disk and are served through routes that check who is asking — an agreement is not a public asset, and a guessable URL to one would be a list of who our customers are. The customer route takes no version parameter: which document applies is ours to say. `dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps the platform running does not thereby decide what every customer is asked to agree to — and a capability written under `web` since the 2026-07-29 move lands in a guard nothing authenticates against, which is how this one first shipped answering 403 to a role that visibly had it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
49d528dbea |
Fix round: point at the real tab, hide the button nobody can press, prove the quiet page load
Fixes four Important findings from the review of Task 12.
Corrected nine check `tab` values at the source (BillingChecks was already
correct; OnboardingChecks/ProvisioningChecks/DeliveryChecks all carried the
pre-redesign 'integrations' value, which is not a member of
Integrations::TABS): four onboarding checks now point at 'platform' or 'env'
depending on where their field actually is, five provisioning/delivery
checks point at 'services'. checkUrl()'s match-block resolver — praised as
correct for the six checks that point at a genuinely different admin page —
is unchanged; its `default` arm is now a pure safety net, not a route any
check actually relies on.
Wrapped the "Prüfen" button in @can('secrets.manage'): mount() admits
hosts.manage OR secrets.manage, but runCheck() requires secrets.manage alone,
so an Admin-role operator could see a button that 403s on press.
Fixed a docblock on HEARTBEAT_KEYS that asserted a staleness threshold was
kept in sync with OperationChecks::STALE_AFTER_MINUTES — that constant is a
key/settings-name map with no threshold of its own, and no such
synchronisation exists.
Three new tests, each confirmed red against a deliberately reintroduced
version of the bug it covers before being confirmed green: every check's tab
value against where its field actually lives, the run-check button hidden
from an operator who cannot press it, and Http::assertNothingSent() after a
plain page load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
e83b17ec37 |
Put every missing field on one page before a customer pays
Admin\Readiness renders App\Support\Readiness::byGroup() end to end: state, label, breaks sentence and a fix link for every check, a button on the four that have to ask a real service (DnsTokenCheck, WireguardEndpointCheck, VmTemplateCheck, StripeCheck) and only ever run on demand, and one headline line — ready for the operator's current mode, or how many blocking items are still open. Wired into the nav and into Overview's own notice list beside the same gate as Admin\Integrations, since both halves it configures come back together on this page. Fixed two check keys the guard test caught for real: onboarding.ssh_key and delivery.inbound_password did not textually contain the vault key they check (ssh.private_key, inbound_mail.password), which is exactly the kind of silent gap this whole task exists to catch. Renamed to onboarding.ssh_private_key and delivery.inbound_mail_password, with the two existing test files that referenced the old keys updated to match. Also fixed the guard test's own assertion: Pest's toContain() is variadic, so a second string argument is a second required substring, not a failure message (the same trap tests/Feature/ModalHeightTest.php already documents about itself) — it failed on every entry, including ones already covered. Replaced with str_contains()+toBeTrue(), which keeps the check and the message both. Two pre-existing Overview tests assumed a bare install has nothing to report; it now correctly always starts with a readiness gap, so both were narrowed to what they actually test — no invented data, no invented incident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c354ad1463 |
Make mails readable on a phone, and let an operator look at one
Three things the owner reported, and one sentence that promised something
this product does not do.
**The mails had a fixed width.** components/mail/layout.blade.php set
width="600" and width:600px, so every phone showed a 600px canvas in a
390px window and one had to scroll sideways to read a sentence. Now the
table is width="100%" with max-width:600px — fluid, with 600 as a ceiling
rather than a floor. A mail cannot fix this with a media query (Outlook
renders with Word and drops <style> blocks), which is why the whole file
is inline attributes and why the shrinking has to be built in. Side
padding went 40px -> 24px in the layout and in all nine mail views:
40 each side leaves 278px of a 390px screen to read in.
**There was no way to look at a mail.** An invoice mail needs an invoice,
a maintenance announcement needs a window, and "register an account to
see whether the confirmation reads well" is not a workflow — which is how
the fixed width survived for months. App\Services\Mail\MailPreviews
builds all nine from sample data, every record with make() and never
create(), so a preview leaves no invoice, order or window behind and
draws no number from a series. Two ways to see one: in a tab as the
document itself, or sent to the signed-in operator's own address. There
is no recipient field — that would be a form for mailing strangers.
sendNow(), not send(): every mailable here implements ShouldQueue, and
send() defers to the queue for those, which would have put a failure in a
worker's log while the button reported success to somebody waiting for
the mail. The test caught that.
**The booking page had no shell.** Order declared layouts.portal — the
bare shell the sign-in pages use, no navigation and no padding — so the
page sat flush against the top and bottom of the window with no way back
to anything. layouts.portal-app, like every other portal page.
**The consent box said something untrue.** Its refusal read "otherwise we
can only build your cloud once the withdrawal period has ended", which
describes a path that does not exist: nothing here delivers fourteen days
later, and an unticked box simply means no order. Said plainly now, and
said on the page before somebody runs into the refusal.
Also: GrantedPlanPortalTest asserted assertDontSee('179'), which matches
any id containing those digits — and ids climb through a suite run
because a rolled-back transaction does not reset an auto-increment
counter. It passed alone and failed in company. Asserts on '179,00' now,
which is what a leaked price would actually look like.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
494a3a817e |
Fix round: guard the parse, bound the future, name the real tab
Covers the queue assignment the second heartbeat exists to prove, stops an unreadable heartbeat value from taking the whole readiness page down with it, rejects a heartbeat dated into the future, and points both checks at the tab that actually restarts the scheduler and provisioning worker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6ca3e6ef4a |
Notice when nobody is picking up the queue
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0f4226c488 |
Fix round: read the mailer against NON_DELIVERING, not against 'log' alone
delivery.mailer_not_log now reads MailboxTransport::NON_DELIVERING (made public) instead of a narrower `!== 'log'`, so 'array' — this suite's own MAIL_MAILER — and an unset default no longer pass as ready when nothing would actually be delivered. provisioning.monitoring_token's severity now follows provisioning.monitoring.required the same way RegisterMonitoring itself does, instead of asserting a fixed 'warning' the pipeline does not always honour. Two breaks sentences (dns_zone, mail_templates) now name a concrete failure mechanism instead of a correct-but-vague description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
325cee50ca |
Check the machines, the DNS and the post as well
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b1e5bf8023 |
Fix round: match the actual selection IssueInvoice and sync use
Two of the five billing checks measured something close to, but not
the same as, what actually gates the flow they report on:
- billing.invoice_series counted distinct kinds without filtering on
`active`, but IssueInvoice draws its series with
where('kind', ...)->where('active', true) (IssueInvoice.php:373,
:444) — a deactivated row is invisible to it exactly like a missing
one. An installation with a switched-off 'invoice' series was
reported ready while no invoice could draw a number: false-green.
- billing.catalogue_synced counted every PlanPrice with a null
stripe_price_id, but stripe:sync-catalogue only ever mirrors
PUBLISHED versions (SyncStripeCatalogue's own header comment). A
draft's unsynced price is an ordinary state, not a gap; the old
query kept this check permanently unsatisfied on any installation
that had ever drafted a next version — an alarm always on and
therefore never read.
Both queries now mirror the condition the real gate uses instead of a
similar-looking one of their own. Two tests added that were red before
the fix: an inactive required series that must not read as ready, and
an unpublished draft's unsynced price that must not raise an alarm.
Also strengthens the weakest breaks sentence: invoice_series_breaks
named the mechanism ("a document cannot draw a number") rather than
the consequence, unlike its four siblings. It now says what the
customer and operator actually experience — a running cloud with no
invoice, the failure only logged — matching company_details_breaks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d9c0ad0e4a |
Say what breaks, not just which field is empty
Task 7: the readiness collector (App\Support\Readiness) and its first group, billing (App\Support\Readiness\BillingChecks). Reports missing prerequisites — the active mode's Stripe key, its webhook signing secret, complete company details, an invoice series per document kind, a fully Stripe-synced catalogue — without enforcing anything itself; the real locks (IssueInvoice, the checkout's Stripe-key guard) stay where they are. The webhook check uses App\Support\StripeWebhookSecret::current() rather than querying config() by mode a second time, and the company check calls CompanyProfile::missingForInvoicing() rather than duplicating its list — two sources for one question is how they drift apart. Added lang/de/readiness.php and lang/en/readiness.php with every `readiness.*` key this group uses, each `_breaks` sentence naming the actual downstream failure rather than repeating the field name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a1e3484fba |
Verify webhooks against the secret of the mode we are in
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3cf17e5cb0 |
Give the installation a test mode and a live mode
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |