222 Commits (37e191424674ed63f151e795ac0ac3b55ebddf4d)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
37e1914246 |
Merge branch 'claude/nice-moser-521659'
# Conflicts: # VERSION # deploy/install-agent.sh # deploy/update.sh |
|
|
|
4debc79883 |
Eine hängende Sperre ist jetzt ein Knopf, kein SSH-Zugang
Am 4. August 2026 hielt ein einziger Aufruf — `timeout 45 docker compose exec` ohne `--kill-after` — die Sperre stundenlang, und jeder folgende Lauf wurde übersprungen. Behoben ist die Ursache in v1.6.5; was fehlte, war der Griff für das nächste Mal. Er lag per SSH auf dem Wirt. Die Konsole kann ihn nicht selbst führen: sie ist www-data in einem Behälter, der Sperrenhalter ist ein Prozess auf dem WIRT. Also bittet sie den Agenten, und der ruft den root-eigenen Helfer — dasselbe Muster wie `apply-proxy-hosts`, mit eigener sudoers-Zeile. Die Bitte liegt in einer EIGENEN Datei, und das ist keine Geschmacksfrage: der Agent nimmt die Sperre in seinen ersten Zeilen, lange bevor er den Postkasten ansieht. Ein blockierter Lauf steigt vorher aus — eine Entsperr-Bitte im Postkasten erreichte ihn also genau dann nie, wenn sie gebraucht wird. Dazu nimmt der Postkasten eine Bitte zur Zeit an, und die dreißig Minuten, in denen dort eine wartende Update-Anfrage liegt, sind die, in denen jemand entsperren will. Der Dienstbenutzer sagt „gib die Sperre frei", nicht „töte 1234": welcher Prozess das ist, sucht der Helfer selbst. Dürfte der Anrufer die Nummer liefern, wäre die Freigabe das Recht, jeden beliebigen Prozess als root zu beenden. Beendet werden alle, die die Sperrdatei OFFEN halten — nicht nur der, der das flock genommen hat. Ein flock hängt an der offenen Dateibeschreibung, und die wird vererbt: stirbt der Agent, sein hängendes `docker compose exec` aber nicht, bleibt die Sperre gehalten. Genau das war der Vorfall. Verschont bleiben PID 1 und der Anrufer samt Vorfahren — im Betrieb hält der blockierte Agent die Datei selbst offen und ist zugleich der, der anruft. Erst SIGTERM, fünf Sekunden, dann SIGKILL. Eine Frist ohne Nachdruck ist keine Frist. Bestätigt wird im Modal (R23), das VORHER nennt, was es beendet — PID, Laufzeit und Kommandozeile aus dem Lebenszeichen. Eigene Berechtigung `deployment.unblock`, nicht `site.manage`: wer aktualisieren darf, darf damit nicht automatisch in einen laufenden Vorgang hineingreifen. Der Helfer-Vertrag steigt auf 3, und update.sh verlangt ihn. Ohne das wäre der Knopf auf einem Wirt, der install-agent.sh seither nicht mehr gefahren hat, ein Knopf, der still nichts tut — und die Konsole sagt es jetzt, statt den Betreiber dorthin zurückzuschicken, wo er ohne sie schon war. Geprüft wird ausgeführt, nicht begutachtet: der Helfer läuft im Test gegen eine echte Sperrdatei mit echten Prozessen daran. Das hat gleich einen Fehler gefunden — der Helfer erbt den Deskriptor und hielt seine eigenen Kommandosubstitutionen für Halter, sodass die Runde nie zum Ende gekommen wäre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9fa45e869f |
Ein Terminal auf den CluPilot-Server selbst
Eigene Faehigkeit, eigener Schluessel, eigene Tuer. Die Bruecke bleibt unveraendert: sie kennt keine Hosts, nur Tickets. Den Weg zum Wirt traegt die root-eigene Haelfte des Updaters — sie holt sich die oeffentliche Haelfte selbst, prueft sie hart und schreibt die Optionsliste, die das Dienstkonto nicht bestimmen darf. Kein sudo-Weg dorthin: einen Root-Schluessel eintraegt ein Mensch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6a5609cfc7 |
Ein einzelner übersprungener Lauf ist kein Ausfall
Die Konsole meldete „kommt seit HH:MM nicht an die Arbeit", sobald der Agent EINMAL an der Sperre vorbeilief. Gemessen wurde das bei einem Überholen von neun Sekunden: Zeitgeber und Wächter laufen beide minütlich, einer nimmt die Sperre, der andere geht weg. Das ist Betrieb, kein Fehler — und der Besitzer hat daraufhin eine Stunde lang eine gesunde Anlage auseinandergenommen. Der Kopfkommentar an der Stelle kannte den Unterschied längst („ließe sich von einem gesunden Überholen zweier Läufe nicht unterscheiden"). Die Dauer wurde mitgeführt, nur gegen nichts verglichen. Der Agent zählt die Serie jetzt mit (`skips` im Lebenszeichen), die Konsole macht ab zwei Läufen eine Meldung daraus. Gezählt wird in LÄUFEN, nicht in Minuten: wie oft der Zeitgeber wirklich auslöst, steht in der systemd-Unit auf dem Wirt, die diese Anwendung nicht sehen kann. Die Schwelle gilt für den ganzen Zustand, nicht nur für den Satz. Nur die Meldung zu unterdrücken hätte den Fehlalarm gegen einen stilleren getauscht: `blocked_since` blendet auch „N Aktualisierungen zurück" und die Zielversion aus, und die wären beim ersten übersprungenen Lauf verschwunden, ohne dass irgendwo stünde warum. Geprüft wird der Zähler am echten Skript, nicht an einer Nachbildung seiner Logik: der Zweig liegt vor allem Teuren, also läuft der Agent im Test gegen eine gehaltene Sperre und steigt aus, bevor `git fetch` oder `docker compose` in die Nähe kommen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e83b1d886a |
Eine Warnung, bevor die eigene Cloud den Kunden aussperrt
Ab dem Laufzeitende zieht EndInstanceService die Adresse ein — der Kunde kommt
ab dem Moment nicht mehr an seine Nextcloud. Wer selbst etwas herunterladen
will, muss es VORHER tun, und das wusste bisher niemand.
Die Auswahl ist bewusst "hoechstens sieben Tage" und nicht "genau sieben Tage":
ein Gleichheitsvergleich verfehlt jede Instanz, die zwischen zwei Laeufen
durchrutscht, und der Preis dafuer waere, dass jemand ausgesperrt wird, ohne es
gewusst zu haben.
Zusaetzlich zum Zettel: lang/{de,en}/mail_pace.php bekommen einen Eintrag fuer
ServiceEndingSoonMail, weil MailPacePageTest fuer jede Klasse in MailLane::all()
einen Anzeigenamen verlangt — ohne ihn waere die Suite rot.
|
|
|
|
7edd88b71c |
Ein Zugang zur Konsole, dessen Passwort niemand kennt
Bisher erzeugte Settings::inviteStaff() beim Einladen eines Betreiber-Mitarbeiters ein Passwort und zeigte es dem Inhaber einmal auf dem Bildschirm, damit er es "sicher weitergibt" -- fuer einen Zugang zur Konsole, die die ganze Flotte verwaltet. Der Kommentar dort nannte sich selbst eine Attrappe. Jetzt geht eine Einladung ohne Passwort hinaus: der Eingeladene bekommt eine Mail mit einem Link und vergibt sein Passwort selbst, ueber App\Livewire\Auth\OperatorSetPassword. Niemand -- auch der Inhaber nicht -- kennt je ein fremdes Passwort. Dafuer brauchte es einen eigenen Weg, den es fuer Betreiber noch nicht gab (R21: Konsole und Portal teilen keine Identitaet). Ein eigener Broker mit eigener Tabelle (operator_password_reset_tokens, config/auth.php), eine eigene Route in der Gast-Gruppe der Konsole (admin.invitation), und Operator::sendPasswordResetNotification() wirft jetzt, statt Laravels Vorgabe zu nutzen, die auf die Portalseite verlinkt haette. Der Einladungslink gilt 72 Stunden -- lang genug fuer ein Wochenende, kurz genug, dass ein altes Postfach nicht auf Dauer einen Schluessel zur Konsole haelt. Zwei-Faktor bleibt unberuehrt: die neue Seite meldet niemanden automatisch an, der Eingeladene durchlaeuft danach die normale Anmeldung samt ihrer bestehenden Zwei-Faktor-Pruefung. Die Route liegt hinter denselben Netz- und Hostwaechtern wie der Rest der Konsole (RestrictAdminHost, RestrictConsoleNetwork), ohne Sonderfall. OperatorInvitationMail reiht sich in den Versandtakt ein (MailLane::LOCKED, wie ResetPasswordMail und VerifyEmailMail -- ein Mensch wartet gerade) und ist damit in der Vorschau- und Versandtakt-Uebersicht der Konsole sichtbar, statt lautlos in die gedrosselte Spur zu fallen. Zwei Mutationsproben gegen die tragende Zusicherung durchgefuehrt (Bericht: .superpowers/sdd/betreiber-einladung-report.md): die erste (durch die Vorgaengersitzung) hielt fest, dass drei Zusicherungen fallen wuerden, waere das Konto sofort anmeldbar; die zweite -- das Passwort testweise wieder auf die Seite gebracht -- faellt exakt an der dafuer gebauten reflektierenden Pruefung ueber alle oeffentlichen Eigenschaften der Seite. Suite: 2771 passed (9683 assertions). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d1755f5921 |
Der Update-Agent sagt, dass er lebt — auch wenn er nicht arbeiten kann
Gemeldet: „seit 1.5.1 muss ich nach jedem Update install-agent.sh fahren,
sonst kommt kein Update mehr". Die Konsole meldete „Der Update-Dienst auf dem
Server laeuft nicht" und schickte genau dorthin.
Er lief. Das Journal zeigt zwei Tage lang lueckenlos 57-59 Laeufe je Stunde.
Aber ueber zweiundachtzig Minuten hinweg startete und endete jeder Lauf in
DERSELBEN Sekunde, waehrend ein arbeitender Lauf zwei braucht: sie stiegen
alle sofort wieder aus, an der Sperre eines anderen Vorgangs — `flock -n 9 ||
exit 0`. Lautlos. Kein Journal-Eintrag (systemd sieht einen sauberen Lauf),
keine Zeile in der Statusdatei, nichts in der Konsole.
Und die Statusdatei war die einzige Lebendmeldung, die es gab. Sie wird erst
nach rund 190 Zeilen geschrieben — nach dem `git fetch` und nach einem
`docker compose exec`, beide ohne Zeitgrenze und beide unter der Sperre. Ein
Lauf, der davor aussteigt, hinterlaesst nichts, und nach zwanzig Minuten
schliesst die Konsole daraus, der Dienst sei tot. Sie schloss falsch, und die
Handlungsanweisung dazu aendert an einer gehaltenen Sperre nichts.
Drei Aenderungen:
* Ein Lebenszeichen (agent-alive.json) als ERSTES bei jedem Lauf, vor allem,
was blockieren kann. Zwei Zustaende: `running` heisst "ich habe die Sperre
und arbeite", `blocked` heisst "ich bin ausgestiegen" — mit `since` (seit
wann ununterbrochen) und `held_by` (wer, per fuser und ps).
* Zeitgrenzen: 45 Sekunden um den docker-exec, 120 um den git fetch. Ohne
sie wartet ein Abruf gegen eine tote Verbindung, bis das Betriebssystem
ihn nach vielen Minuten aufgibt — und haelt dabei die Sperre.
* Die Konsole liest das Lebenszeichen statt der Statusdatei. Der Unterschied
ist der Punkt: Status heisst "zuletzt ERFOLGREICH nachgesehen",
Lebenszeichen heisst "zuletzt ueberhaupt gelaufen". Ein blockierter Agent
gilt als lebendig, aber seine Zahlen zaehlen nicht mehr als aktuell —
`behind` und `target_release` fallen auf "unbekannt", statt eine Stunde
alte Auskunft als frisch auszugeben.
Statt "laeuft nicht" steht dort jetzt "laeuft, kommt aber seit HH:MM nicht an
die Arbeit", mit dem Prozess darunter.
Ein Agent von VOR dieser Aenderung schreibt die Datei nicht — fuer den gilt
weiter die alte Regel. Ihn dafuer fuer tot zu erklaeren waere derselbe Fehler
mit umgekehrtem Vorzeichen; ein Test haelt das fest.
Widerlegt und damit ausgeschlossen: Besitzrechte (Wirt, .env und Behaelter
fuehren alle 1001), das Ausführbar-Bit (100755 im Repo), systemds
Startdrosselung (seit v1.1.0 abgeschaltet), ein zwischengespeicherter Zustand
im Panel (es liest bei jedem Aufruf frisch) und eine Luecke im Timer (keine).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
9f2a45c7bf |
Fuenf geparkte Kleinigkeiten nach der Mitarbeiterverwaltung
tests / pest (push) Waiting to run
Details
tests / assets (push) Waiting to run
Details
tests / release (push) Blocked by required conditions
Details
Drei eigene Saetze statt einer geteilten Meldung an den drei Inhaber-Wachen (sperren, Rolle aendern, entfernen) — jede nennt jetzt den Grund: der Inhaber-Sitz ist der Zugang, mit dem der Kunde seine eigene Cloud verwaltet. Der Kommentar zum Auffangnetz in NextcloudUsers::applyRole() behauptete einen engeren Schutz, als &&/|| linksassoziativ tatsaechlich bilden — korrigiert statt geklammert, weil Klammern die gerade erst gebaute || true-Erkennung in FakeProxmoxClient gebrochen haetten, fuer einen in der Praxis folgenlosen Fall. Beide Fakes (FakeProxmoxClient, FakeRemoteShell) verweisen jetzt im Kopfkommentar aufeinander: sie behandeln ein angehaengtes || true unterschiedlich, und wer nur den einen kennt, soll das an der Klasse lesen koennen. Die Restnaht beim nachgeschickten Sperren (SyncSeatToNextcloud::invite()) bleibt Code wie er ist — Begruendung als Kommentar an der Stelle: die Naht ist real, aber folgenlos (kein nc_synced_at, kein Wiederholen-Knopf, Weg zurueck bleibt die Vordertuer). Und ein kurzer Satz an der restore-Aktion in retry(), der die schon vorhandene, aber weit oben stehende Begruendung buendelt: kein Feld fuer ein faelliges enable, weil das die naechste Behauptung ueber eine Cloud waere, in die das Portal nicht sehen kann. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f62304a245 |
Jemanden aus einer Gruppe zu nehmen, in der er nicht ist, ist kein Fehler
K3. `mitarbeiter` und `nur-lesen` legt niemand an — sie entstehen erst, wenn `user:add --group=…` sie zum ersten Mal braucht. `occ group:removeuser` legt nichts an und beendet mit Fehlercode, wenn die Gruppe fehlt, und run() verundet alle Exitcodes. Auf einer frischen Instanz scheiterte damit die erste Rollenaenderung dauerhaft: "fehlgeschlagen — Die Cloud hat die Aenderung nicht angenommen", und Wiederholen waehlte wieder role, also wieder denselben Fehlschlag. `2>/dev/null || true` hinter dem Entfernen, mit derselben Begruendung wie in HostFirewall::releaseMany(): nicht in der Gruppe zu sein IST der gewuenschte Endzustand. Nur fuers Entfernen — ein gescheitertes group:adduser bleibt ein Fehlschlag, denn wer in keiner Gruppe landet, sieht in seiner neuen Cloud nichts. Warum die Suite das nie sah: FakeProxmoxClient liess jeden nicht verskripteten Befehl gelingen, und die Pruefungen belegten die erzeugte Befehlsmenge, nie die Antwort des Gasts. Der Fake beachtet jetzt ein abschliessendes `|| true` — das ist eine Aussage der Shell, nicht des Aufrufers, und ein Fake, der trotzdem einen Fehlercode zurueckgaebe, liesse einen Test beweisen, dass ein Befehl scheitert, den keine echte Shell je scheitern laesst. Damit haelt die Pruefung den Fake ausdruecklich auf Fehlercode und sieht applyRole() trotzdem true liefern. Dazu ein Testkommentar, der den falschen Schutz benannte: bei owner/admin auf denselben Gruppennamen traegt `if ($gruppe !== $ziel)`, nicht array_unique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
1bc1e972d6 |
Der Griff, mit dem ein Sitz in der Nextcloud wirksam wird
Anlegen, einladen, Gruppe, sperren, freigeben. Keine Methode wirft — ein nicht erreichbarer Gast gibt false zurueck, statt den Arbeiter mitzureissen, auf dem die bezahlte Bereitstellung laeuft. Zwei Fallen sind hier eingebaut statt umgangen: user:disable allein laesst Sitzungen fuenf Minuten weiterleben (deshalb auth-tokens:delete daneben), und ein Konto mit eigenem Speicherplatz folgt der Paketvorgabe nicht mehr (deshalb --delete beim Verlassen von readonly, kein Ueberschreiben). |
|
|
|
d7d116e9a5 |
Jede Mail faehrt wieder ueber ihren eigenen Mailer
Mail::to(...) loest den Standard-Mailer auf, und dessen queue() schreibt danach `$view->mailer($this->name)`. Damit war der im Konstruktor gewaehlte Mailer (cp_mail_<typ>) durch 'smtp' ersetzt, bevor der Auftrag ueberhaupt gebaut war. Mit dem Mailer fiel alles weg, was an ihm haengt: MailboxTransport, die Postfachsuche samt passendem Absender — und der Notschalter aus App\Support\MailDelivery, der genau dort sitzt. Eine abgeschaltete Zustellung schaltete deshalb nichts ab, und die Mails meldeten sich mit den Zugangsdaten aus der Konfiguration an, waehrend der Absender aus der Mail kam: „553 Sender address rejected". Das lief hier im Minutentakt. Zwei Stellen hatten es schon richtig — MaintenanceNotifier und MailPreview, beide mit der Erklaerung an der Zeile. Neun andere nicht. Alle beginnen jetzt mit Mail::mailer($mail->mailer), sodass das Ueberschreiben denselben Wert zurueckschreibt. Die Attrappen in drei Tests zielten auf to() und trafen damit nicht mehr den Einstieg; drei von ihnen bestanden ohnehin nur zufaellig, weil ein Aufruf auf null ebenfalls warf. Sie zielen jetzt auf mailer(). Erzwungen durch tests/Feature/MailDispatchTest.php: Mail::to( ist in app/ verboten, und ein Gegentest haelt das Framework-Verhalten fest, damit die Regel fallen darf, wenn Laravel sie eines Tages unnoetig macht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
cacdca0a18 |
Passwort-Leck-Test verschaerft, mail_smtpauth an authenticates gekoppelt
Der Passwort-Test prüfte nur die Abwesenheit des Schlüssels mail_smtppassword — ein Leck unter anderem Namen wäre durchgerutscht. Prüft jetzt zusätzlich, dass der Passwort-WERT in keinem values()-Eintrag steckt. mail_smtpauth stand fest auf 'true', obwohl Mailbox::isConfigured() ein Postfach ohne Anmeldung ausdrücklich zulässt. Nextcloud hätte sich dann mit leerem Passwort anzumelden versucht — jeder Versand wäre still gescheitert. Richtet sich jetzt nach $box->authenticates, mit Test für den false-Fall. |
|
|
|
da9e71bc88 |
Welche Mailwerte eine Kundeninstanz bekommt — und wann gar keine
Server aus app_settings, Zugangsdaten aus dem Postfach, an einer Stelle zusammengelegt. Fehlt eines von beiden, wird NICHTS geschrieben: eine halb eingetragene Mailkonfiguration laesst Nextcloud bei jedem Versand still scheitern. mailboxFor() ist die Naht, an der spaeter ein Konto je Kunde haengt. |
|
|
|
c78866e360 |
fix(security): Aufheben einer Sperre sagt die Wahrheit und wird nachgeholt
tests / pest (push) Has been cancelled
Details
tests / assets (push) Has been cancelled
Details
tests / release (push) Has been cancelled
Details
Mein eigener Befund "die Zeile erneuert sich nach dem Aufheben nicht" war falsch — eine Pruefung ueber den ganzen Weg (Modal schickt das Ereignis, Seite faengt es) zeigt, dass die Zeile sehr wohl in den Verlauf wandert. Dabei fiel der echte Fehler auf, der daneben lag: HostFirewall::release() schluckt jeden Fehlschlag und meldet ihn nur ins Log. Beide Aufrufer verwarfen den Rueckgabewert und meldeten in jedem Fall "Sperre aufgehoben." War der Host im Moment des Aufhebens nicht erreichbar, stand der Datensatz auf aufgehoben und die Regel noch drin: Portal und Konsole zeigten "Aufgehoben", waehrend die Adresse weiter ausgesperrt blieb — bis zum Ablauf der urspruenglichen Sperrzeit, ohne dass es jemand sagen konnte. - release() gibt zurueck, ob die Firewall schon nachgezogen hat; alle drei Stellen (Portal, Host-Ansicht, Kunden-Ansicht) sagen es, wenn nicht. - releaseMany() als Gegenstueck zu blockMany(): eine SSH-Sitzung statt einer je Adresse. - ScanForIntrusions gleicht jetzt in BEIDE Richtungen ab. Bisher trug er nur ein; nichts nahm je einen haengengebliebenen Eintrag wieder heraus. Eine Adresse, die eine ANDERE aktive Sperre desselben Hosts noch traegt, bleibt stehen. - Jeder Loeschbefehl traegt `2>/dev/null || true`: nft scheitert am Loeschen eines Elements, das es nicht gibt, und weg ist genau das Ziel. Ohne das meldete der Abgleich bei jedem Lauf einen Fehlschlag. 8 neue Pruefungen, Suite 2625 gruen. |
|
|
|
f40b762413 |
Zwei Empfaengerkreise, und eine Wegwahl, die auch das Konto verschiebt
Fix-Welle nach dem Gesamt-Review, Punkte 4 und 5. Beide sitzen in denselben
Dateien, deshalb ein Commit.
DIE HOST-MELDUNG WAR WORTWOERTLICH DIE KUNDENMAIL (Punkt 4).
notifyHostManagers() verschickte dieselbe SecurityBlockMail. Deren Text ist fuer
den Kunden geschrieben ("bei Ihrer Cloud", "…koennen Sie die Sperre auf der
Sicherheitsuebersicht aufheben"), der Knopf zeigte auf portal.security — wo ein
Betreiber kein Konto hat und Host-Sperren nie erscheinen —, welcher Host
betroffen ist stand nirgends, und die Anrede blieb leer, weil $block->instance
null ist.
Die Mail kann jetzt beide Faelle. Unterscheidungsmerkmal in der Ansicht ist
$hostName: gesetzt heisst Host-Sperre, null heisst Instanz-Sperre. Ein zweites
Flag daneben waere eine zweite Wahrheit.
- Betreff nennt den Host.
- Eigener Textblock (mail_heading_host, mail_preheader_host, mail_intro_host mit
"fehlgeschlagene SSH-Anmeldungen am Host :host"), eigene Tabellenzeile
field_host, eigener Hinweis mail_note_host.
- Der Knopf zeigt auf admin.hosts.show, also auf die Host-Detailseite der
Konsole, wo die Host-Sperren mit ihrem Aufheben-Knopf stehen.
- Keine Anrede im Host-Fall — sie war vorher leer, und der Empfaengerkreis ist
ohnehin mehrere Personen.
- Der Fehlalarm-Satz ist ein anderer und sagt, was ein Betreiber wissen will:
bestehende Verbindungen bleiben unberuehrt, die Regel steht unter
"established,related accept".
R19: die zwei Kommentare, die fuer Host-Sperren Unwahres behaupteten, sind
richtiggestellt — der Kopfkommentar von SecurityBlockMail und der
MailCatalogue-Eintrag. Beide beschreiben jetzt zwei Empfaengerkreise. Ein
Kommentar mit falscher Begruendung haelt den Naechsten vom Nachsehen ab.
DIE WEGWAHL VERSCHOB DEN ABSENDER, NICHT DAS KONTO (Punkt 5).
SecurityBlockMail nagelte mailer('cp_system') fest und holte das Postfach ueber
die Wegwahl. MailboxTransport loeste weiter ueber den ZWECK auf und
authentifizierte mit dessen Zugangsdaten. Legte der Betreiber security-block auf
info@ — der ausdrueckliche Anlass fuer Abschnitt 3a der Spec —, ging die Mail
mit From: info@ durch die SMTP-Sitzung von no-reply@. Viele Server weisen das ab
("553 5.7.1 Sender address rejected: not owned by user"), und dann verschwindet
ausgerechnet die Mail, die er gerade umgelegt hat. Betraf ALLE Mailarten.
Die Wegwahl bestimmt jetzt auch den Mailer. Die zweitbeste Loesung — die Auswahl
in der Konsole auf Postfaecher zu begrenzen, deren Konto benutzt wird — war
nicht noetig.
- config/mail.php erzeugt AUS MailCatalogue::all() je Mailart einen Mailer
cp_mail_<mailart> mit transport, purpose und mail_key. Erzeugt statt
aufgelistet, weil der Katalog ausdruecklich die EINE Liste ist; eine zweite
hier waere die, die beim siebzehnten Mail vergessen wird. Genauso statisch wie
die fuenf Zweck-Mailer: ein reines Array, keine Datenbank, auch unter
config:cache unveraendert.
- MailboxTransport nimmt den mail_key entgegen und loest damit ueber DIESELBE
MailRoute::purposeOrMailbox() auf, die auch der Umschlag fragt. Ohne
Schluessel entscheidet wie bisher allein der Zweck — jeder Mailer, der keinen
hat, verhaelt sich unveraendert.
- Jede Mailart mit Schluessel benennt ihren Mailer ueber
MailCatalogue::mailer(). Dessen Rueckfall auf cp_<zweck> fuer einen
unbekannten Schluessel ist nicht Zierde: DunningNoticeMail baut seinen
Schluessel aus der Mahnstufe, und ohne den Rueckfall waere eine Stufe
ausserhalb 0-3 ein "Mailer [...] is not defined" und damit ein Totalausfall
statt eines falschen Absenders.
- Betrifft alle Mailarten: 13 Mailables, die CloudReady-Notification und die
cloud-ready-Vorschau. ContactRequestMail bleibt auf cp_support — es hat keinen
Schluessel im Katalog, weil es keine eigene Mailart ist.
MailSenderOwnershipTest ist mitgezogen und dabei STRENGER geworden. Die alte
Fassung verglich "From-Zweck == Mailer-Zweck"; jetzt prueft sie, dass der
Katalog dem Zweck zustimmt, den der Umschlag verlangt. Katalog-Drift war bis
hierher nur als Warnung im Docblock von MailCatalogue festgehalten und durch
nichts erzwungen. Ein Literal, das selbst kein Schluessel ist, wird als PRAEFIX
behandelt (der Mahnlauf) — dann muessen alle Schluessel dahinter zustimmen, und
es muss mindestens einen geben.
MailPreviewTest fragte den Zweck bisher, indem es 'cp_' vom Mailer-Namen
abschnitt. Das war eine Zusicherung ueber das Namensschema, nicht ueber die
Zusage; sie liest den Zweck jetzt aus der Konfiguration des Mailers selbst.
tests/Feature/Mail/SenderAddressTest.php gehoert inhaltlich zur parallel
laufenden Sitzung. Vier Zusicherungen darin pruefen genau den Mailer-Namen, den
dieser Commit aendert, also sind sie mitgezogen — die Datei war dabei sauber,
es steckt keine fremde unfertige Arbeit in diesem Commit.
Committet mit ausdruecklicher Dateiangabe am Zeilenende, weil eine parallele
Sitzung an derselben Ablage arbeitet und der Index fremde Arbeit enthalten kann.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
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>
|
|
|
|
4db1957f06 |
Drei Spuren, und welche Mail in welche gehoert
Die Trennung laeuft zwischen dringend und nicht dringend, nicht zwischen Massenversand und Einzelmail: eine Ausfallmeldung geht an alle UND eilt. Die sieben Mails, auf die jemand wartet, sind gesperrt statt nur voreingestellt — auch eine von Hand geschriebene Einstellung verschiebt sie nicht. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
412d67955a |
Fix-Runde 2: Drossel-Marke erst nach Erfolg, Kommentar richtiggestellt
Die Marke stand bisher VOR dem Versandversuch - scheiterte das Einreihen, schwieg das Subjekt fuer den Rest der Stunde, obwohl null statt einer Mail angekommen war. Jetzt steht sie in notifyInstanceOwner() erst nach dem erfolgreichen queue() im try-Block, in notifyHostManagers() erst nach mindestens einem geglueckten Einreihen (Merker ueber die Schleife, da ein einzelnes abgelehntes Postfach weder die uebrigen Betreiber noch die Marke fuer alle blockieren darf). Kein Sturm-Risiko: Sperren entstehen ohnehin nur ab der Zehner-Schwelle, nicht bei jedem Fehlversuch. Die Kommentare behaupteten außerdem, das try/catch finge Zustellungsfehler ab - tatsaechlich faengt es nur, was beim EINREIHEN schiefgeht (synchron, vor der Warteschlange); ein Zustellungsfehler passiert spaeter im Warteschlangen-Arbeiter und steht in dessen Protokoll. Beide Docblocks richtiggestellt. Neuer Testfall haengt einen Wrapper vor die gefakte Mail-Fassade, dessen erster to()-Aufruf wirft und ab dem zweiten an die echte Fake-Instanz durchreicht - MailFake::queue() selbst kann einen Fehlschlag nicht simulieren, weil es den Mailable nur ablegt und dabei nie wirft. Als Gegenprobe testweise auf den alten Code zurueckgesetzt: Testfall lief rot mit der erwarteten Meldung, Datei danach byte-identisch wiederhergestellt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
17a07d1a68 |
Fix-Runde 1: Host-Sperren gehen an jeden Betreiber mit hosts.manage
Koordinator-Entscheidung statt neuer Einstellung: der Empfaengerkreis ist per Definition richtig, weil es genau die Menschen sind, die eine Host-Sperre in der Konsole ueberhaupt aufheben duerfen, und er pflegt sich bei jedem Rollenwechsel von selbst mit. notifyHostManagers() nutzt Spaties eigenen Operator::permission()-Scope (dieselbe Pruefung wie authorize() an anderer Stelle, nur als Mengenabfrage) - kein Empfaenger heisst keine Mail, kein Fehler, keine Drossel-Markierung; jede Adresse einzeln in ihrem eigenen try/catch, damit ein abgelehntes Postfach nicht die uebrigen Betreiber um ihre Meldung bringt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2d979052d0 |
Benachrichtigung ueber eine gesperrte Adresse, hoechstens eine je Stunde
SecurityBlockMail geht bei einer Instanz-Sperre an die Kontoadresse des Inhabers, aus dem SYSTEM-Postfach wie NewDeviceSignInMail. Die Drossel sitzt in BlockAddress::notifyInstanceOwner() ueber Settings (kein neues Feld fuer etwas, das nach einer Stunde niemanden mehr interessiert) und wird VOR dem Versandversuch gesetzt. Ein Throwable beim Verschicken wird gemeldet und verschluckt: die Sperre steht schon, bevor ueberhaupt versucht wird zu verschicken, und ein kaputtes Postfach darf sie nicht rueckgaengig machen. Host-Sperren verschicken bewusst noch keine Mail: kein Muster im Repo, wie eine Betreiber-Meldung ihren Empfaenger findet (siehe Bericht). Route 'portal.security' minimal angelegt (Aufgabe 6 baut die echte Seite) - auf einem eigenen Pfad, weil sie sich mit der oeffentlichen /security-Seite sonst lautlos gegenseitig ueberschreiben, sobald Portal und Website ohne eigene Domain laufen (RouteCollection indiziert ueber Methode+Domain+URI, nicht ueber den Namen). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ec1f13807e |
Fix-Runde 2: ein werfender Gast reisst den Lauf nicht mehr mit
guestExec() wirft bei einer echten HTTP-Antwort (->throw()), nicht nur bei einem sauberen Fehlercode — ein abgeschalteter Gast oder ein noch nicht gestarteter Agent liess das ungefangen durch scanInstances() nach oben laufen und beendete handle(), bevor scanHosts() und vor allem reapplyActiveBlocks() dran waren. Eine unbeteiligte Sperre wurde dadurch jede Minute erneut nicht wiedereingetragen. FailedLoginReader::fromInstance() faengt Throwable jetzt genauso wie fromHost() es schon tat; ScanForIntrusions::scanInstances() umschliesst zusaetzlich jede Instanz einzeln, nach dem Muster von PingHosts. Dazu ein Testfall mit einem werfenden und einem lauten Gast nebeneinander, und Carbon::setTestNow() jetzt in finally, damit ein abgebrochener Testfall die Uhr nicht fuer die Folgetests eingefroren laesst. |
|
|
|
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. |
|
|
|
fc3ff3cb28 |
Gesperrte Adressen: Datensatz, Verdopplung und die Liste, die nie gesperrt wird
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. |
|
|
|
77a4c3d990 |
Sperrliste in der Host-Firewall, unter der Regel fuer bestehende Verbindungen
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. |
|
|
|
5dcfee9957 |
Wegwahl je Mailart: welche Mail aus welchem Postfach geht
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> |
|
|
|
e3c781f067 |
Der Tunnel bekommt einen eigenen Container
Bisher wohnte wg0 im Container des Provisionierungs-Arbeiters, also im Abbild `clupilot-app`. Das wird bei fast jeder Freigabe neu gebaut — und ein neu gebauter Container bekommt eine neue Adresse im Compose-Netz, womit die Weiterleitung fuer UDP 51820 neu geschrieben wird und JEDE bestehende WireGuard-Sitzung abreisst. Der Tunnel hing damit am Veroeffentlichungstakt der Anwendung, und zusaetzlich daran, dass ein PHP-Prozess nicht abstuerzt. Jetzt gehoert der Netz-Namensraum einem eigenen Container `vpn-hub` mit eigenem, winzigem Abbild (Alpine plus wireguard-tools), das sich fast nie aendert. Provisionierungs-Arbeiter, Terminal-Bruecke, interner DNS und internes Gateway steigen dort ein, statt einer von ihnen den Namensraum zu besitzen. NACHGEMESSEN, nicht angenommen: App-Abbild neu gebaut, Arbeiter, Bruecke und Gateway per --force-recreate neu erzeugt — der Hub blieb Container 92e928cf53b0, wg0 und beide Zugaenge unangetastet, und nginx erreichte die Bruecke weiter (HTTP 426). Genau der Vorgang, der bisher jedes Mal alles abgerissen hat. Nachgezogen: - nginx spricht die Bruecke unter `vpn-hub:8082` an — dem Namen des Namensraum-Eigentuemers; ein Mitbewohner hat keinen eigenen DNS-Eintrag. - update.sh baut vpn-hub mit und haengt Nachbar-Neustarts und den conntrack-Griff an die Frage, ob der Hub WIRKLICH neu gebaut wurde. - update-agent.sh startet den Arbeiter nicht mehr neu, sondern signalisiert ihm. Diese Stelle laeuft unbeaufsichtigt hinter dem Knopf „Dienste neu starten" — wer den drueckt, rechnet nicht damit, sich selbst auszusperren. - Vier Meldungen in der Konsole rieten dem Betreiber, genau den Befehl von Hand auszufuehren, der ihm den Tunnel abreisst. Auch die sind korrigiert. - rescue-tunnel.sh und das Runbook zeigen auf den neuen Besitzer. Eine Kleinigkeit unterwegs, die ich falsch angekuendigt hatte: `[[ … ]] && x=true` bricht unter `set -e` NICHT ab — bash nimmt die linke Seite einer &&-Liste ausdruecklich aus. Nachgeprueft; die if-Form bleibt trotzdem, aus Lesbarkeit, und der Kommentar sagt jetzt den wahren Grund. 2509 Tests gruen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f64a564c40 |
Terminal: keine Fehlerseite mehr, ein Knopf auf jeder Zeile, eine Buehne statt roter Zeilen
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>
|
|
|
|
714b044ff1 |
Fix-Runde 1: Ticket ueber rohes Redis (GETDEL, reines JSON) statt Cache-Fassade
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.
|
|
|
|
830af24b6c | Terminal: das Ticket, einmalig und dreissig Sekunden gueltig | |
|
|
6314bb60fb |
Schalter je Paket, ehrliche Statusanzeige, Reste der alten Leiter
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> |
|
|
|
f8d0c2c353 | Merge branch 'claude/angry-golick-187172' into feat/neue-pakete | |
|
|
ce7d0426cb |
Fix-Runde: preferredDatacenter() blind fuer Reservierung, Loeschen hob sie auf
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> |
|
|
|
4ad1828f27 |
Ein reservierter Host gehoert seinem Kunden
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> |
|
|
|
3b5dee8f0d |
Ein Ausweg wird nur angeboten, wenn er auch buchbar ist
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> |
|
|
|
c3c05ff9f8 |
Interne Pakete verschwinden aus dem Laden und bleiben in der Konsole
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> |
|
|
|
afa6183903 |
Zusatzspeicher: 20 GB fuer 15 Euro, hoechstens drei, nicht bei Enterprise
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> |
|
|
|
1f32a2d5b3 |
Hoechstens drei Bloecke, geprueft in der Buchung
Die Ansicht durfte 50 in den Warenkorb legen, die Aktion pruefte nichts. Der Deckel liegt kaufmaennisch dort, wo Aufsteigen billiger wird als Stapeln, und gehoert deshalb dorthin, wo gebucht wird — gezaehlt ueber alle laufenden Buchungen, denn drei Bestellungen a einem Block sind drei Bloecke. StorageAllowanceTest schrieb die alte Notbremse (50) als erwartete Zahl fest; angepasst auf die jetzt engere kaufmaennische Grenze (3), wie im Aufgabenblatt fuer DowngradeTest vorgezeichnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2ab6a92fd8 |
Die Packungsgroesse steht auf der Buchung, nicht in der Konfiguration
Bisher las StorageAllowance sie bei jeder Anzeige neu aus config. Solange es eine Groesse gab, war das folgenlos — beim Zuschnitt von 100 GB auf 20 GB waere es der stille Verlust von 80 GB je gekauftem Block gewesen, bei einem Kunden, der bereits Daten darin liegen hat. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
dc35e5310f |
Release v1.3.93 — die Seite konnte den Host gar nicht fragen
tests / pest (push) Failing after 9m23s
Details
tests / assets (push) Successful in 28s
Details
tests / release (push) Has been skipped
Details
Auf echter Hardware blieb jede Messkachel leer, während der Host sichtbar online war und vor zwei Minuten geantwortet hatte. Kein Zufall und kein Aussetzer: ein Entwurfsfehler. Nur der queue-provisioning-Container hängt im WireGuard-Netz — NET_ADMIN, /dev/net/tun, das wireguard-Volume. Der app-Container, der die Konsole rendert, hat überhaupt keine Route zu 10.66.0.x. Ich hatte den Proxmox-Aufruf in render() gelegt, also in den einen Container, der die Management-Adresse nicht erreichen kann. Ein Aufruf von dort konnte nie etwas anderes sein als eine Zeitüberschreitung. PingHosts schreibt dieselbe Regel seit Langem in seinen Kopf: "runs on the provisioning queue, which is where the Proxmox credentials are usable." Ich habe sie gelesen und nicht angewendet. Verschlimmert hat es mein eigenes catch (Throwable): der Grund wurde verschluckt, und die Kachel sagte "keine Messwerte" — ununterscheidbar davon, dass der Host schweigt. Auf dem Testhost fiel nichts auf, weil TEST-NET ohnehin nie antwortet. Jetzt zwei Hälften: - HostLoadSeries::collect() holt und legt ab, aus Jobs\CollectHostLoad auf der provisioning-Warteschlange, minütlich — der Takt, in dem Proxmox einen frischen Messwert schreibt. Ein Fehlschlag wird protokolliert, mit Host, Node und Grund. - HostLoadSeries::forHost() liest nur aus dem Zwischenspeicher und öffnet nie eine Verbindung. Ein Test hält das mit Http::assertNothingSent() fest. Der Eintrag lebt fünf Minuten bei minütlichem Sammeln: länger als der Takt, damit ein ausgefallener Lauf keine Seite leert, die eine Sekunde vorher in Ordnung war — und kurz genug, dass ein stehengebliebener Sammler die Zahlen mitnimmt, statt eine alte Stunde als aktuell stehenzulassen. Der Sammler ist ShouldBeUnique (Codex-Befund, P1). Die provisioning-Warteschlange ist DIESELBE, auf der Kunden-Bereitstellung läuft; ein stiller Host kostet den vollen HTTP-Zeitablauf, und ohne diese Sperre stauten sich minütlich neue Läufe hinter dem alten und verzögerten bezahlte Arbeit. Dasselbe Mittel, das CollectInstanceTraffic nebenan schon benutzt. Dazu: der Zustands-Punkt war mit 62 px so groß wie der Speicher-Ring nebenan. Eine gefüllte Scheibe wiegt optisch weit mehr als ein dünner Ring und erschlug die Kachel — jetzt 32 px. Und ein Test, der aus Versehen recht behielt: die Kachel-Prüfung verglich mit "50", was auch die 500 GB in der Instanzenliste darunter trifft. Sie prüft jetzt Zahlen, die sonst nirgends auf der Seite vorkommen. Noch offen, nicht hier angefasst: VmTemplateCheck fragt die Proxmox-API ebenfalls aus dem app-Container heraus, von der Bereitschaftsseite aus. Selber Fehler, Bestand, eigener Punkt. Geprüft: 2299 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit eingespielten Messwerten: sechs gefüllte Kacheln, Zustands-Scheibe in Proportion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
589489a9fc |
Release v1.3.92 — sechs Kacheln statt dreier ungleicher Kästen
tests / pest (push) Failing after 10m15s
Details
tests / assets (push) Successful in 28s
Details
tests / release (push) Has been skipped
Details
Nach der Vorlage des Besitzers. Zustand, Speicher und die gemeinsame Kurve standen nebeneinander: zwei davon halb leer, die dritte überfüllt, und die gemeinsame Kurve brauchte eine Legende, um zu sagen, welche Linie welche ist. Eine Reihe je Kachel löst alle drei Beschwerden auf einmal. Die Beschriftung der Kachel benennt die Reihe, also braucht es keine Legende mehr. Die Höhen sind durch das Raster gleich statt zufällig. Und der leere Platz ist mit Messwerten gefüllt, die es ohnehin schon gab: Proxmox' Aufzeichnung liefert Netzdurchsatz in beide Richtungen mit, ungefragt. Sechs Kacheln: CPU-Auslastung, RAM-Auslastung, Speicher (als Ring), Eingehend, Ausgehend, Zustand. Gebaut mit x-ui.metric, x-ui.spark und x-ui.ring — die gab es alle schon, und der Kopfkommentar von x-ui.metric sagt selbst "exactly as the approved template draws it". Nichts daneben neu gebaut. Die öffentliche IP steht jetzt auf der Seite. Sie stand vorher NUR klein unter der Überschrift — die Adresse, unter der der Host wirklich erreichbar ist, war in der Detailseite nirgends ein Feld. Sie führt jetzt die Ausstattungs-Tafel an, und die Reserve-Eingabe ist mit dorthin gezogen: die Kacheln zeigen, was gemessen wurde, die Tafel, was eingestellt ist. Ein Eingabefeld zwischen Messwerten sähe aus, als ließe sich eine Messung ändern. Alle vier Verlaufslinien tragen denselben Ton. Die Regel steht im Bauteil selbst — "muted where the figure is observed, accent where it can be acted on" —, und hier ist keine Zahl anzufassen. Vier verschiedene Töne nebeneinander behaupten einen Unterschied, den es nicht gibt. Zwei Funde aus der Prüfung -------------------------- - x-ui.spark warf fehlende Messwerte per array_filter heraus und verband die Nachbarn. Zwei Fehler auf einmal: die Linie behauptete eine Messung, die es nicht gab, und alles danach rutschte nach links — eine Stunde mit zwei Lücken zeichnete sich als achtundfünfzig Minuten. Die x-Lage kommt jetzt aus dem Platz in der URSPRÜNGLICHEN Reihe, und zusammenhängende Messwerte werden als eigene Züge gezeichnet. Eine saubere Reihe ergibt genau einen Zug und dasselbe Bild wie vorher, was alle bisherigen Aufrufer liefern. - Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus v1.3.91 kennt netin/netout nicht, und die Host-Seite wäre 55 Sekunden lang an einem fehlenden Schlüssel gestorben — genau in der Minute, in der jemand nachsieht, ob das Update durch ist. Der Schlüssel heißt jetzt host-load:v2:<id> und wandert mit der Form mit. Und einer, den kein Prüfer gemeldet hat: beim Zerlegen in Züge stand im Flächenpfad ein `L` unmittelbar vor einem `M`. Gültig gelesen, nicht gezeichnet — die Füllung verschwand still. Aufgefallen ist es beim Ansehen der Seite, nicht durch eine Meldung; jetzt prüft ein Test, dass jeder Flächenpfad mit M anfängt, mit Z endet und keinen Befehl direkt hinter einem anderen trägt. x-ui.chart behält seinen update-on-Weg, obwohl diese Seite ihn nicht mehr benutzt: er ist eine geprüfte Fähigkeit des gemeinsamen Bauteils, und der darunterliegende Fix (Instanz aus dem reaktiven Alpine-Objekt) gilt für jeden Chart. Geprüft: 2291 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit eingespielten Messwerten: sechs Kacheln, Lücke als echte Aussparung in Linie UND Fläche, Leerzustand zeigt "—" statt einer Null, null Konsolenfehler über einen vollen Poll-Zyklus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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>
|
|
|
|
db125781ed |
Release v1.3.88 — die Vorlage baut sich selbst
tests / pest (push) Failing after 9m17s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Der letzte Handgriff in der Host-Übernahme fällt weg. VerifyVmTemplate meldete
bisher nur, dass eine Vorlage fehlt, weil niemand entschieden hatte, was in die
goldene Vorlage gehört. Entschieden ist es längst und steht in
deploy/bootstrap/lib/template.sh — der neue Schritt BuildVmTemplate lädt genau
diese Datei auf den Host und führt sie dort aus, statt ihre Prüfungen ein
zweites Mal in PHP zu haben.
Er läuft abgekoppelt und wird abgefragt: Abbild laden und drei
virt-customize-Läufe brauchen zehn bis zwanzig Minuten, ein einzelner
SSH-Aufruf liefe gegen den Befehlszeitablauf von 2000 s. "Läuft noch" heißt
dabei, dass der Prozess lebt (kill -0 gegen die hinterlegte PID) — in der
Statusdatei steht "running" auch dann noch, wenn niemand mehr da ist, der sie
ändert.
Fünf Fehler, die dabei aufgefallen sind und Geld gekostet hätten:
- qm importdisk hängte die Platte unter ${storage}:vm-9000-disk-0 ein. Der Name
gilt nur bei Block-Ablagen; auf einer Verzeichnis-Ablage heißt sie
local:9000/vm-9000-disk-0.qcow2 — also genau auf dem per Debian aufgesetzten
Proxmox, um das es hier geht. Jetzt qm set --import-from, und Proxmox
benennt selbst.
- growpart war nie installiert. GrowGuestFilesystem ruft es auf, und es lief
bisher, weil Debians Cloud-Abbild es zufällig mitbringt. Fiele es heraus,
läge jedes gekaufte Kontingent über einem Dateisystem, das nie gewachsen ist.
Jetzt ausdrücklich eingebaut und als vierte Falle nachgewiesen.
- local nimmt ab Werk keine Platten an. Ohne das stirbt nicht nur der Bau,
RegisterCapacity meldet danach Kapazität 0: ein Host, der fertig aussieht und
nie einen Kunden tragen kann. ensure_image_storage greift nur ein, wenn keine
Ablage Platten annimmt, hängt images an die vorhandene Liste an statt sie zu
ersetzen, und schreibt über pvesm set statt in die pmxcfs-Datei.
- Ein abgebrochener Download blieb unter dem Zielnamen liegen und wäre beim
nächsten Lauf ungeprüft weiterbenutzt worden. Jetzt .part, umbenannt erst
nach geprüfter Summe.
- VerifyVmTemplate und VmTemplateCheck fragten nur, ob VMID 9000 existiert. Ein
abgebrochener Bau hinterlässt eine gewöhnliche VM mit dieser Nummer, und
beide sagten dazu "passt" — der Fehler kam beim ersten bezahlten Klon zurück.
Jetzt template: 1.
isTemplate() stellt zwei Anfragen, weil die falsche Antwort hier etwas
zerstört: false heißt "Vorlage fehlt", und der Bau fängt mit qm destroy --purge
an. Proxmox beantwortet die Konfiguration einer nicht vorhandenen VM mit 500 —
demselben Code wie einen Knoten in Not. Die VM-Liste klärt deshalb die
Abwesenheit, alles darunter wirft und landet im Wiederholungs-Zweig.
Aufgeben beendet erst die Prozessgruppe, dann räumt es auf, und gebaut wird nur
die Fehlliste: create_proxmox_template räumt eine VMID weg, bevor es sie
anlegt, also hätte "alles Verlangte" eine gesunde zweite Vorlage auf dem Weg
zerstört.
Geprüft: 2267 Tests grün, Pint sauber, sh -n über alle drei Shell-Dateien, die
storage.cfg-Auswertung gegen eine echte Beispieldatei durchgespielt, und jeder
Befehl, den der Schritt absetzt, geht durch sh -n — keine andere Prüfung führt
diese Shell je aus. Drei Codex-Runden (R15), alle Befunde behoben.
Nicht geprüft: nichts davon lief je gegen echte Hardware. Die erste Übernahme
auf einem Proxmox-Host ist die Abnahme.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
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> |
|
|
|
b15fcc53a5 | Fix-Runde: Reichweite der Sperre, keine doppelte Gebuehr, verlorene Nachricht | |
|
|
a64d41639b | Mahnwesen: die sechs Kundenmails | |
|
|
9899152d95 | Mahnwesen: Tageslauf mit Gebuehrenrechnungen | |
|
|
9304382f62 | Mahnwesen: Fall, Zeitplan und Gebuehrenstufen | |
|
|
f91726d078 | Testergebnis erscheint am Feld statt am Seitenende; DNS-Token bekommt einen Testknopf | |
|
|
d6fd760a8b | Offene Rechnungen bis zum Ende blaettern (Codex P2) | |
|
|
19ed6461cf | Bereitschaft: das Absender-Postfach wird gefragt, ob es senden KANN |