Commit Graph

560 Commits (aaf8697c3d4bbdca293f7ede4a3dfe20f2821a7b)

Author SHA1 Message Date
nexxo aaf8697c3d Unter jeder Spur steht, was in ihr faehrt
„Ruhig / mail-ruhig" war dieselbe Auskunft zweimal, die zweite in
Kleinschrift. Der Warteschlangenname stand da, weil ich ihn fuer nuetzlich
hielt — er ist es, aber in docker-compose.yml und in MailLane, also dort, wo
jemand ihn wirklich braucht. Auf der Seite, auf der ein Betreiber entscheidet,
was wie eilig ist, sagt er nichts.

An seiner Stelle steht jetzt, was in der Spur faehrt und warum sie so faehrt:
wer auf die direkte wartet (jemand vor einem Bildschirm), was die wichtige
traegt (Wartung, Abschaltung, Wiederanlauf), und warum die ruhige warten darf
(eine Rechnung um zwei nachts ist dieselbe Rechnung).

Nachgesehen, bevor der Name verschwand: die Bereitschaftsseite nennt die
Spuren nirgends beim Warteschlangennamen — es geht nichts verloren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:55:23 +02:00
nexxo 714f19d4d4 Der Versandtakt nennt die Mails beim Namen
Die Zuordnung zeigte class_basename(): „DormantAccountWarningMail" stand
dort, wo der Betreiber „Konto ohne Paket wird geloescht" sucht. Ein
Klassenname ist eine Auskunft ueber den Bauplan, keine ueber die Mail.

Die Namen liegen in lang/{de,en}/mail_pace.php und NICHT in MailCatalogue,
obwohl der sehr aehnlich klingt: der Katalog zaehlt Mail*arten* — vier
Mahnstufen, vier Eintraege, vier Absenderwege —, diese Seite verteilt
Mail*klassen* auf Spuren, und alle vier Mahnstufen sind EINE Klasse, die als
eine Zeile eine Spur bekommt. Zwei Listen, weil es zwei verschiedene Dinge
sind. Fehlt doch ein Name, steht der Klassenname da wie bisher statt einer
leeren Zeile — und ein Test haelt fest, dass keiner fehlt, in beiden Sprachen.

Der Warteschlangenname (mail-direkt und Geschwister) stand in der Wertspalte
zwischen Beschriftung und Eingabefeldern und las sich wie ein weiterer Wert.
Er steht jetzt als Unterzeile bei der Spur. Weg gehoert er nicht: es ist die
Zeichenkette, die der Arbeiter in docker-compose.yml abhoert.

Suite gruen, Codex ohne Befund. Der Namenstest faellt gegen die alte Fassung
— nachgemessen, nicht angenommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:52:04 +02:00
nexxo 42966b66f3 Die zweite Tuer zur selben Attrappe, und die Adresse, die dem Kundenkonto gehoert
Zwei Befunde aus dem Re-Review.

Die Mailwache fehlte an retry(). sendInvite() weist ab, wenn der Versand nicht
eingerichtet ist — der Zweig `nc_synced_at === null => 'invite'` in retry()
schickt genau dasselbe `occ user:add --generate-password --email` und tat es
nicht. Der Weg dorthin ist am ersten Tag rein durch Klicken erreichbar: /users
steht offen, solange die Instanz noch in Bereitstellung ist (customer.active
prueft den Kunden, nicht die Instanz), und ohne AKTIVE Instanz sagt
mailversandBereit() `true`. Also kein Hinweis, keine Wache, Einladung hinaus,
Auftrag scheitert an `no_instance`, Zeile rot, Wiederholen-Knopf da. Wird die
Bereitstellung fertig, ohne dass der Versand steht, legte ein Klick das Konto
an, occ beendete mit 0, und die Plakette sagte "Eingeladen". Zweiter Weg zum
selben Ende: ein einmal eingerichtetes Versandkonto wird abgeschaltet, und jede
rote Zeile ohne nc_synced_at fuehrt beim Wiederholen dorthin.

Die Wache gilt nur fuer `invite`. `disable` und `restore` verschicken nichts —
sie duerfen auch ohne Mailversand laufen, und sie sind die Rueckfahrkarte aus
einem Fehlschlag. Sie zu sperren hiesse, einen offenen Zugang offen zu lassen,
weil eine Mail nicht ginge. Die Pruefung faehrt den ganzen Weg ab, mit echtem
Auftragslauf in der Mitte.

Und der W1-Fix hatte eine Zusicherung aufgeweicht: an `status === 'invited'` war
die Adresse des INHABER-Sitzes nie aenderbar, denn er steht immer auf 'active'.
An nc_username allein wurde sie es, bis linkToInstanceAdmin() greift. Die Folge
ist kein Umbenennen — laeuft die Adresse des Inhaber-Sitzes von der
Kundenadresse weg, legt "Anlegen" mit der echten Adresse eine ZWEITE Zeile fuer
dieselbe Person an, die gegen die Platzgrenze zaehlt. Der Inhaber-Sitz ist der
eine Sitz, dessen Adresse nicht ihm gehoert, sondern dem Kundenkonto.

Die Bedingung steht jetzt einmal als adresseAenderbar() statt dreimal
abgeschrieben; auseinanderlaufen muss sie nur einmal, um eine Luecke zu sein.
Der bestehende Umbenennen-Test prueft addressEditable nicht und waere gruen
geblieben — der neue faehrt am Formular vorbei und haelt zugleich fest, dass
Umbenennen am Inhaber-Sitz erlaubt bleibt.

Beide gegen den zurueckgedrehten Fix rot gesehen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:33:48 +02:00
nexxo 40f255565b Ein Knopf, der eine Mail verspricht, die niemand verschicken kann
Die zweite Haelfte von K4. Die Wanderung legt das Versandkonto an; solange es
niemand ausgefuellt hat, bestand die Attrappe unveraendert fort — und sie ist
nicht der Randfall, sondern der Normalfall: der Mailserver dieses Produkts
entsteht gerade erst, das Postfach ist am ersten Tag garantiert leer.

Die Einladung verschickt NEXTCLOUD, nicht CluPilot — nur so entsteht das
Passwort dort, wo niemand sonst es zu sehen bekommt. Ohne eingerichteten
Versand kommt dort aber nichts heraus, und `occ user:add --generate-password
--email` gelingt trotzdem: Nextcloud legt das Konto an, versucht die
Willkommensmail, protokolliert intern einen Fehler und beendet mit 0. Der
Auftrag meldete Erfolg, die Zeile sprang auf "Eingeladen", die Meldung versprach
einen Link, an dem der Mitarbeiter sein Passwort selbst setzt — und niemand
bekam etwas.

sendInvite() fragt jetzt GuestMailConfig::for($instance)->available(): ein
reiner Blick in die Datenbank, kein Tunnel, keine Warteschlange, deshalb darf er
auf der Seite stehen. Steht der Versand nicht, geht kein Auftrag hinaus, der
Sitz bleibt unveraendert — insbesondere ohne Anmeldenamen — und die Meldung sagt,
dass nichts verschickt wurde und woran es liegt.

Drei Entscheidungen dabei:

Der Knopf bleibt stehen, der Hinweis steht ueber der Tabelle. Ihn an jeder Zeile
verschwinden zu lassen liest sich nicht als "geht hier gerade nicht", sondern als
"das kann dieses Produkt nicht" — genau die Beschwerde, die schon einmal dazu
gefuehrt hat, dass die Aktionsspalte immer gezeichnet wird. Und der Zustand ist
voruebergehend: er endet, sobald der Betrieb das Konto ausfuellt.

Die Wache steht VOR dem Ratelimit. Sonst haette ein Inhaber seine Versuche
aufgebraucht, bevor ueberhaupt einer hinausgehen konnte.

Ohne laufende Instanz greift sie gar nicht: dann scheitert der Auftrag ohnehin an
`no_instance` und die Zeile sagt das im Klartext. Diese Wache gilt dem anderen
Fall — die Cloud laeuft, nur der Versand fehlt.

Das Anlegen bleibt offen: es verspricht ausdruecklich keine Mail, und der
Hinweistext sagt das auch. Der Bereitstellungsschritt ist unangetastet und bleibt
bei "nicht eingerichtet ist etwas anderes als kaputt".

Fuenf Zusicherungen, drei davon gegen den zurueckgedrehten Fix rot gesehen; die
uebrigen zwei sind Grenzpruefungen und muessen in beide Richtungen gruen sein.
Acht Bestandspruefungen richten den Versand jetzt ueber eine eigene
Hilfsfunktion ein — zwei davon haetten sonst aus dem falschen Grund bestanden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:14:27 +02:00
nexxo 01eff483fa Das Postfach, auf dem alles steht, gibt es jetzt — leer und abgeschaltet
K4. GuestMailConfig sucht das gemeinsame Versandkonto der Kundeninstanzen unter
dem Schluessel `instance-relay`. Diesen Datensatz legte nirgends etwas an, und
die Konsole kann Postfaecher nur bearbeiten, nicht erstellen: der Betreiber
kaeme ohne Tinker gar nicht an den Start.

Daran haengt mehr als eine Einstellung. Ohne Postfach verschickt die
Kunden-Nextcloud keine Mail — aber `occ user:add --generate-password --email`
gelingt trotzdem: Nextcloud legt das Konto an, versucht die Willkommensmail,
protokolliert intern einen Fehler und beendet mit 0. Die Zeile sagte also
"Eingeladen" und die Meldung versprach einen Link, waehrend niemand eine Mail
bekam. Dieselbe Attrappe, die dieses Feature abschaffen sollte, eine Schicht
tiefer.

Die Wanderung legt die Zeile nach dem Muster der Postfach-Wanderung vom 28.07.
an: firstOrNew, mit der Adresse noreply@clupilot.cloud — inaktiv und ohne
Zugangsdaten. Ein Postfach, das ohne Zutun des Betreibers als einsatzbereit
dastuende, waere das naechste stille Versprechen; so erscheint es in der
Konsole als Zeile, die sichtbar noch etwas braucht, und isConfigured() bleibt
false. Ein bereits ausgefuelltes Postfach ruehrt ein zweiter Lauf nicht an —
sonst waere der stillste denkbare Ausfall genau ein `migrate` entfernt.

Die Zeile steht ab jetzt in jeder Testdatenbank. Die Fixtures, die sie bisher
selbst anlegten, fuellen sie aus, statt an der Eindeutigkeit des Schluessels
abzuprallen; die beiden Zusicherungen ueber die fuenf Betreiber-Postfaecher
nennen sie und halten fest, dass jede der beiden Wanderungen nur ihre eigenen
Zeilen zuruecknimmt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:57:54 +02:00
nexxo 1d0bacc650 Ein Schritt ohne Namen zeigt bei jeder Bestellung seinen Schluessel
provisioning.step.configure_instance_mail fehlte in beiden Sprachdateien,
waehrend der Schritt in der Pflicht-Pipeline steht: die Konsole zeigte bei
JEDER bezahlten Bestellung den rohen Schluessel.

Dazu die Pruefung, die danach fragt — fuer jeden Schritt jeder Pipeline, in
beiden Sprachen. Sie liest die Sprachdatei selbst und nicht trans($key, [],
'de'): Laravel faellt bei einem fehlenden deutschen Eintrag stumm auf die
Ersatzsprache zurueck, und die erste Fassung dieser Pruefung lief genau deshalb
gruen, obwohl der deutsche Eintrag geloescht war. Nachgeprueft, nicht
angenommen.

Und label() des Schrittes selbst statt eines hier nachgebauten Schluessels:
Host- und Kundenschritte liegen in verschiedenen Sprachdateien, und ein Test,
der die Zusammensetzung nachrechnet, prueft die Zusammensetzung statt den
Eintrag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:57:42 +02:00
nexxo 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>
2026-08-03 23:57:15 +02:00
nexxo f476ee6bd1 Die Adresse ist die Person, sobald es das Konto im Gast gibt
W1. `addressEditable` hing an status === 'invited' — und nichts im Bestand
setzt seats.status je von 'invited' auf 'active'. Ein eingeladener Sitz blieb
damit fuer immer 'invited' und seine Adresse dauerhaft aenderbar, entgegen dem
eigenen Kopfkommentar dieser Klasse.

Der Ablauf: einladen fuer a@x, spaeter im Modal auf b@y aendern. seats.email
wird b@y, nc_username bleibt a@x (richtig, Nextcloud kann nicht umbenennen),
und kein Befehl traegt die neue Adresse in den Gast. Danach schickt "Erneut
senden" ein user:welcome --reset-password 'a@x': das Portal fuehrt b@y als
Inhaberin des Sitzes, tatsaechlich haelt a@x den Zugang und bekommt jederzeit
einen neuen Zuruecksetzen-Link.

Massgeblich ist jetzt blank($seat->nc_username) — genau die Bedingung, die der
Kommentar ohnehin meinte: einmal gesetzt, danach unveraenderlich. Der Kommentar
ist berichtigt. Beide Stellen, das Anzeigen und die erneute Pruefung in save(),
lesen dieselbe Angabe; die hydrierte Eigenschaft bleibt ungeglaubt.

Die Adresse nachtraeglich in den Gast zu fahren ist ausdruecklich nicht Teil
dieses Durchgangs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:57:14 +02:00
nexxo 1fd5d5f8d2 Was der Auftrag ausfuehrt, ist die Absicht von jetzt, nicht die von vorhin
Drei Befunde aus dem Gesamt-Review, alle dieselbe Klasse: die Zeile im Portal
behauptet etwas, das in der Cloud des Kunden nicht gilt.

K1 — Entziehen, waehrend die Einladung noch in der Warteschlange steht. Der
Arbeiter teilt sich die Warteschlange mit der bezahlten Bereitstellung; das
Fenster ist Minuten lang. queueSync() stieg bei nc_synced_at === null aus und
kannte damit den dritten Waechter-Fall nicht: bei `pending` entsteht dort
gerade etwas, das gesperrt werden muss. Und der invite-Auftrag liest den Status
jetzt am Ende frisch aus der Datenbank nach und schiebt bei revoked/suspended
ein disable hinterher — dieselbe Begruendung wie beim schon gebauten "ein
angelegtes Konto sofort vermerken". Vorher endete der Ablauf mit
status=revoked, nc_state=synced und einem aktiven Konto in der Nextcloud, ohne
jeden Knopf, es nachzuholen.

K2 — retry() konnte ein gescheitertes Entsperren nie wiederholen: die Ableitung
kannte disable, invite und role, aber kein enable, und waehlte deshalb role.
Der Auftrag fuhr Gruppen und Quota, gelang, die Zeile sprang auf "Aktiv" — und
user:enable war nie geschickt. Woran das zu erkennen waere, steht nirgends am
Sitz; ein Feld dafuer waere die naechste Behauptung ueber die Cloud, die
irgendwann nicht mehr stimmt. Deshalb raet retry() nicht, sondern schickt an
einer offenen Zeile beides: der neue Auftrag `restore` sperrt auf UND setzt die
Rolle. suspend() bleibt bei enable, denn dort ist bekannt, was fehlt.

W2 — der Inhaber konnte sich selbst aussperren. setRole() nahm jede Rolle aus
Seat::ROLES an, also auch owner; das Auswahlfeld bietet sie nicht an, die
Livewire-Methode ist trotzdem oeffentlich erreichbar. Mit zwei Inhaber-Sitzen
griff die Zaehlung in revoke() nicht mehr, und der echte Inhaber bekam
user:disable admin samt user:auth-tokens:delete admin in seine eigene Cloud.
revoke() und setRole() weisen owner jetzt genauso ab wie suspend(), und owner
ist keine zulaessige Zielrolle mehr. Damit faellt die Zaehlung selbst weg —
eine Sperre, die man sich erst erarbeiten muss, ist keine — und mit ihr die
Meldung users.last_owner.

Sechs Pruefungen, jede einzeln gegen den zurueckgedrehten Fix rot gesehen.
Umlaute in den beruehrten Dateien nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:56:48 +02:00
nexxo 3704620bb5 Ein Knopf, der nur wieder rot zurueckkommt, und eine Zeile, die niemand einlud
Zwei Reste aus dem letzten Fix, beide dieselbe Klasse: die Zeile behauptet etwas
ueber den Gast, das dort nicht gilt.

Der Wiederholen-Knopf an einer entzogenen, nie gespiegelten Zeile lief auf
user:disable gegen einen Benutzer, den es nie gab — scheitert, dieselbe rote
Zeile, beliebig oft. retry() traegt jetzt denselben nc_synced_at-Vorbehalt wie
queueSync(), mit invite als Ausnahme, denn genau dafuer ist der Knopf da. Und
gezeichnet wird er nur noch, wo der Server ihn auch annimmt.

Nach einer Wiederaufnahme stand nc_state auf synced und status auf invited, also
zeichnete die Spalte "Eingeladen" — fuer jemanden, den niemand eingeladen hat
und dessen Konto im Gast weiter gesperrt ist. nc_state geht beim Wiederaufnehmen
zurueck auf none; nc_username und nc_synced_at bleiben, denn an letzterem haengt
das Entsperren beim folgenden Einladen. Eine Pruefung geht den ganzen Weg.
2026-08-03 22:46:32 +02:00
nexxo 02a5120b9c Der Weg zurueck geht durch die Vordertuer, und ein Fehlschlag bleibt sichtbar
Der letzte Fix hat zwei eigene Loecher gerissen, beide dieselbe Klasse wie das,
was er schliessen sollte: die Oberflaeche versicherte etwas, das im Gast nicht
eingetreten war.

Entzogen stand VOR dem Fehlschlag, also verschwanden Grund und
Wiederholen-Knopf genau dort, wo sie am noetigsten sind: scheitert das disable,
ist das Konto weiter offen, waehrend die Zeile "entzogen" sagt. Es gibt keinen
Wiederholungslauf und keinen Abgleich, und diese Seite ist die einzige, die
nc_state anzeigt. Beides steht jetzt nebeneinander.

Und die Meldung empfahl einen neuen Sitz, den die Eindeutigkeit von
(customer_id, email) unmoeglich machte. addSeat() nimmt einen entzogenen Sitz
derselben Adresse wieder auf — durch dieselbe Platzpruefung wie jeder neue,
denn das ist der Grund, warum es die Vordertuer sein muss und kein Knopf an der
Zeile. Der Auftrag entsperrt dabei, was revoke() gesperrt hat; user:welcome tut
das nicht.

retry() bekommt die owner-Wache nach, die bisher nur am Knopf davor hing. Und
der Kommentar in mount() behauptet keine Heilung mehr, die nicht stattfindet.
2026-08-03 22:32:45 +02:00
nexxo a569d2215b Entzogen bleibt entzogen, und der Inhaber laedt sich nicht selbst ein
Dass revoke() nicht mehr loescht, hat eine Kehrseite, die nirgends stand: der
Umschalter in suspend() kannte nur zwei Zustaende und machte aus einem
entzogenen Sitz beim zweiten Klick wieder einen aktiven — samt user:enable und
vorbei an der Platzgrenze, die nur beim Anlegen geprueft wird. sendInvite() bot
denselben Weg. Beide weisen 'revoked' jetzt ab, die Zeile traegt keinen
Handlungsknopf mehr, und wieder aufmachen kann man sie gar nicht: der Weg
zurueck ist ein neuer Sitz.

Der Einladen-Knopf stand auch an der Inhaber-Zeile. Ein Klick vor der fertigen
Bereitstellung haette spaeter ein zweites Konto in der Gruppe admin angelegt,
neben dem echten — und mount() haette den Sitz danach nie wieder verknuepft,
weil die Bedingung am Zustand hing statt am Anmeldenamen.

queueSync() entscheidet ueber nc_synced_at statt ueber nc_state: ein Sitz,
dessen Einladung an einem unerreichbaren Gast scheiterte, hat dort nichts, was
man sperren koennte. Damit das keine Luecke reisst, vermerkt der Auftrag ein
angelegtes Konto sofort, auch wenn die Rolle danach scheitert.
2026-08-03 22:14:19 +02:00
nexxo 4477a7699e Die Mailseite fragt drei Fragen statt eine Wand zu zeigen
Vier Karten in einer schmalen Spalte, und die letzten beiden allein
zweiundzwanzig gleich aussehende Zeilen: fuenf Zwecke, siebzehn Mailarten,
jede „Beschriftung, Auswahlfeld", darunter zweimal derselbe Speichern-Knopf.
Wer die Zuordnung EINER Mailart aendern wollte, scrollte an allem anderen
vorbei und fand am Ende eine Wand. Der Betreiber hat die Seite als unlesbar
gemeldet.

Jetzt drei Reiter nach dem Muster von Admin\Integrations, das aus demselben
Grund umgebaut wurde — geteilt nach der Frage, die man mitbringt: WOMIT
gesendet wird, WER sendet, WAS von wo rausgeht. Der offene Reiter steht in der
Adresszeile, ein Neuladen landet dort, wo man war.

Die eigentliche Wand war die Zuordnung, und zwar nicht wegen ihrer Laenge:
dass die Wegwahl je Mailart auf den Zweck zurueckfaellt, stand nur als Satz in
der leeren Auswahl — siebzehnmal derselbe Satz neben siebzehn Zeilen, die alle
gleich aussahen. Jetzt steht jede Mailart unter ihrem Zweck: oben das Postfach
des Zwecks, darunter eingerueckt, was davon abweichen darf. Die Regel ist die
Form geworden.

Die leere Auswahl nennt die Adresse, die stattdessen greift — und zwar die,
die WIRKLICH greift. Ein Codex-Befund am eigenen Umbau: ein abgeschaltetes
Postfach faellt in MailboxResolver::for() auf „System" zurueck, und
writePurposes() erlaubt genau das. Das Etikett haette eine Adresse genannt,
die nichts verschickt, an der Stelle, an der man nachsieht, um es richtig zu
machen. Es fragt deshalb dieselbe Aufloesung, die beim Senden entscheidet.

Ein Knopf statt zwei: Zwecke und Wegwahl sind fuer den Betreiber ein Vorgang.
Die Reihenfolge ist die Zusicherung — die Zwecke werden geprueft und werfen,
BEVOR eine einzige Wegwahl geschrieben ist, sonst hinterliesse eine abgelehnte
Eingabe die halbe Zuordnung. savePurposes() und saveRoutes() bleiben, was sie
waren.

Beide Warnungen stehen ueber den Reitern: sie gelten fuer die ganze Seite, und
wer nachsieht, warum keine Mail ankam, soll nicht erst den richtigen Reiter
suchen.

Vier bestehende Tests zeigen jetzt auf den Postfach-Reiter, fuenf neue kamen
dazu — darunter der Regressionstest zum Codex-Befund, der gegen die alte
Fassung faellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:06:50 +02:00
nexxo 0e3a76a7cb Aus der Attrappe wird Verwaltung
Anlegen und Einladen sind zwei Vorgaenge. Einladen schickt einen Auftrag auf
die Bereitstellungs-Warteschlange — die einzige, die einen Gast erreicht — und
der Sitz zeigt danach, was WIRKLICH passiert ist, samt Grund und
Wiederholen-Knopf. Ohne das drueckt der Inhaber wieder und wieder.

Entziehen loescht nichts mehr. Ratelimit 10 je Kunde und 3 je Sitz pro Stunde,
mit echter Restzeit in der Meldung statt stummer Verweigerung.

Eine Pruefung verbietet user:delete im ganzen app/-Verzeichnis.
2026-08-03 21:52:59 +02:00
nexxo e1052f6c4e Zwei fehlende Pruefungen aus der Nachbesserung nachgezogen
owner und admin teilen sich dieselbe Nextcloud-Gruppe (admin) — richtig
geloest durch array_unique plus Zielausschluss, aber bislang ungeprueft: ein
Rollenwechsel nach admin darf group:removeuser admin nie auslösen. Und eine
Instanz ohne Host muss false liefern, ohne einen einzigen Befehl abzusetzen.

Beide Mutationsproben im Fix-Bericht dokumentiert, inklusive einer ehrlich
vermerkten Einschraenkung: der host===null-Teil der Waechterbedingung in
run() ist fuer sich genommen nicht unabhaengig von PHPs eigener Typpruefung
beweisbar, weil ProxmoxClient::forHost() ohnehin einen TypeError wirft, den
derselbe catch(Throwable) faengt. Der vmid-Teil derselben Bedingung ist es
sehr wohl, empirisch bestaetigt.
2026-08-03 21:32:42 +02:00
nexxo 4023a0939c Der Altbestand des Registers wird geradegezogen
Was der doppelte Zuhoerer geschrieben hat, steht noch da: zweiundzwanzig
Zeilen fuer zehn Vorgaenge. Das Register beantwortet damit genau die eine
Frage falsch, fuer die es gebaut wurde — hat der Kunde das bekommen, und wie
oft.

Die Wanderung liest den Fehler rueckwaerts. Der Zuhoerer verdoppelte jeden
Versand exakt, also bleibt von jeder Gruppe gleicher Zeilen (Empfaenger,
Betreff, Mailklasse, Zeitpunkt) die aeltere Haelfte stehen. Aus vier werden
zwei — das waren zwei echte Anmeldungen in derselben Sekunde, beide
verdoppelt —, aus zwei wird eine, Einzelnes bleibt unberuehrt. Ungerade
Gruppen kann dieser Fehler nicht erzeugt haben; taucht doch eine auf, wird
aufgerundet, damit im Zweifel eine Zeile zu viel stehen bleibt.

down() ist leer, mit Begruendung: geloeschte Zeilen kommen nicht zurueck, und
eine erfundene Zeile waere schlimmer als die Luecke.

Auf dieser Installation gefahren: 22 Zeilen vorher, 11 danach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:18:26 +02:00
nexxo 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).
2026-08-03 21:16:37 +02:00
nexxo ea54387a5e Eine verschickte Mail hinterlaesst wieder eine Zeile, nicht zwei
App\Listeners\RecordSentMail hoert auf MessageSent, und Laravel meldet die
Zuhoerer in app/Listeners von selbst an. Die zusaetzliche Anmeldung von Hand
im AppServiceProvider wirkte deshalb nicht als Bekraeftigung, sondern als
zweiter Zuhoerer: jeder Versand wurde zweimal verbucht.

An einer einzelnen Mail faellt das niemandem auf — der Trockenlauf des
Versandtakts schickte fuenfzig und fand hundert Zeilen. Alle zweiundzwanzig
echten Zeilen im Register sind entsprechend Doppelungen; das sind Daten, ueber
die der Betreiber entscheidet, und sie bleiben hier unangetastet.

Die Handanmeldung ist weg, ihre Begruendung steht an ihrer Stelle. Ein Test in
MailRegisterTest nagelt eine Mail auf genau eine Zeile fest, egal wie der
Zuhoerer angemeldet ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:13:36 +02:00
nexxo 320e485215 Dauerhafte Pruefung: Wanderung und linkToInstanceAdmin() bleiben gleich
Die Gleichheit zwischen der eingefrorenen Wanderungsfassung und der
Modellmethode war bisher nur mit einer geloeschten Testdatei belegt - gut
fuer heute, nicht fuer morgen. Nach dem Muster von HostTokenKeyMigrationTest
laedt der neue Test die echte Wanderungsdatei per require() und haelt sie
gegen linkToInstanceAdmin(): einmal die entschluesselte Falle direkt
geprueft (nc_username darf nie Chiffretext werden), einmal beide Wege bei
derselben Ausgangslage gegeneinander.
2026-08-03 21:07:19 +02:00
nexxo 80974108b1 Wanderung friert das Nachziehen selbst ein, statt die Modellmethode zu rufen
up() rief bisher Seat::linkToInstanceAdmin() auf. Ein migrate:fresh auf
frischer Datenbank haette damit die dann aktuelle Fassung dieser Methode
mitgefahren, nicht die von heute. Die Nachbefuellung ist jetzt eine eigene,
dokumentiert duplizierte Fassung ueber den Query Builder.

nc_admin_ref ist am Modell verschluesselt gecastet - roher Spaltenzugriff
haette Chiffretext statt Klartext geliefert. Crypt::decryptString() macht
denselben Aufruf wie der Modell-Cast, ohne eine Modellklasse zu laden.

Kleinere Nachbesserung: fill() statt forceFill() in linkToInstanceAdmin()
(alle drei Felder stehen in $fillable), und die Gruppen-Zuordnungspruefung
verifiziert jetzt die konkreten Namen 'mitarbeiter'/'nur-lesen', nicht nur
"irgendein Wert".
2026-08-03 20:59:45 +02:00
nexxo 94210a7176 Ein Sitz fuehrt Absicht und Wirklichkeit getrennt
status = was der Inhaber will, nc_state = was in der Nextcloud ist. Ein
einzelnes Feld muesste luegen — und "fehlgeschlagen" liesse sich gar nicht
sagen.

Rollen werden Nextcloud-Gruppen. Der Inhaber-Sitz wird mit dem Admin-Konto
verknuepft, das die Bereitstellung laengst angelegt hat.

SeatFactory setzt nc_state jetzt explizit (wie status/locale bei Customer
und Instance): Eloquents create() liest DB-Spalten-Vorgaben nicht ins
In-Memory-Modell zurueck, sonst haette Seat::factory()->create()->nc_state
null statt 'none' geliefert.
2026-08-03 20:48:36 +02:00
nexxo 6513ccdb00 Behebt Zaehlfehler: gestartete Instanzen wurden doppelt gezaehlt
$started++ stand an zwei Stellen: nach der Uebersprungsgruendung und nach
dem Dispatch. Im echten Lauf wurde jede Instanz zweimal gezaehlt.

Entfernt die redundante Zaehlung nach dem Dispatch und ergaenzt zwei Tests,
die die Zahlenwerte in der Abschlusszusammenfassung pruefen — diese Tests
haetten den Fehler gefangen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:39:14 +02:00
nexxo 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>
2026-08-03 20:36:20 +02:00
nexxo 41554da7c0 Behebe 4 kritische Fehler in ConfigureInstanceMail
1. Übergebe $run->uuid statt $run->id an AdvanceRunJob (KRITISCH: Lauf
   wird nie gefahren, da HasUuid eine separate uuid erzeugt)
2. Benutze STATUS_PENDING statt STATUS_RUNNING beim Anlegen
3. Fuege hasRunInFlight-Schutz gegen doppelte Laeufe ein
4. Stelle Umlaute in Betreibertexten her, halte Code umlautfrei
5. Reorganisiere handle() nach RefreshHostFirewall-Muster
6. Erweitere Tests um Queue::assertPushed() fuer AdvanceRunJob mit uuid

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:33:50 +02:00
nexxo 3bd0d56077 Bestehende Instanzen bekommen den Mailversand nachgetragen
Ohne diesen Lauf bliebe die Mitarbeiterverwaltung fuer jeden Altkunden tot:
die Einladung geht von SEINER Nextcloud aus, und die kann bis heute nichts
verschicken. Ein Befehl, kein Zeitplan — dieselbe Begruendung wie bei
clupilot:refresh-host-firewall.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:25:08 +02:00
nexxo b92176ee59 Mailversand-Uebersprung wird am Lauf sichtbar, nicht nur im Log
Log::warning allein reicht nicht: bis der Mailserver steht, trifft dieser
Zweig auf JEDE einzelne Bestellung zu, und die einzige Rueckmeldung darf
nicht in einer Logdatei verschwinden, in die niemand schaut.
RegisterMonitoring macht sein Ueberspringen genau deshalb am Lauf sichtbar
(outcome: info) statt nur im Log — ConfigureInstanceMail bekommt jetzt
dieselbe Behandlung, das Log bleibt daneben fuer die Nachschau ausserhalb
der Konsole.

Zwei weitere Befunde aus der Pruefung behoben:
- Der Test "traegt jeden Wert einzeln in den Gast" prueft jetzt alle acht
  Schluessel aus GuestMailConfig::values() statt zwei — ein vergessener
  Schluessel faellt jetzt hier auf, nicht erst beim Kunden.
- `$config->problem() ?? 'mail_unavailable'` entfernt: im Zweig
  `! $config->available()` liefert problem() immer einen Grund, der
  Rueckfall konnte nie greifen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:19:01 +02:00
nexxo 3930cf12ae Mailversand darf eine bezahlte Bereitstellung nie aufhalten
ConfigureInstanceMail stand als Pflichtschritt in der customer-Pipeline und
liess den ganzen Lauf scheitern, wenn Server oder Postfach fehlten. Der
Mailserver dieses Produkts existiert noch nicht — er wird gerade erst
aufgesetzt. Jede neue bezahlte Bestellung waere damit an einer Nebenfunktion
haengengeblieben: der Kunde zahlt, die Cloud kommt nicht.

Denselben Fall hat dieses Projekt bei RegisterMonitoring schon richtig
entschieden: eine nicht erreichbare Ueberwachung blockiert die Bereitstellung
nie. Mailversand bekommt jetzt dieselbe Behandlung. Fehlt Server oder
Postfach, geht der Schritt mit advance() weiter und protokolliert den Grund
per Log::warning (Instanz-UUID + Grund) fuer den Betreiber; nachgeholt wird es
ueber clupilot:configure-instance-mail (Aufgabe 4), sobald beides steht.

Nicht eingerichtet bleibt etwas anderes als kaputt: sobald Server und
Postfach da sind, wird geschrieben wie zuvor, und ein echter Schreibfehler
(occ-Fehlercode, Gast antwortet nicht) bleibt weiterhin ein Fehlschlag ueber
CustomerStep::guest().

Der bestehende Test "schreibt GAR NICHTS, wenn der Mailserver fehlt" prueft
jetzt StepResult::ADVANCE statt FAIL und zusaetzlich, dass der Grund geloggt
wird. Ein neuer End-to-End-Test in CustomerProvisioningEndToEndTest faehrt
eine bezahlte Bestellung ohne jede Mail-Fixture vollstaendig bis "completed"
— die Zusicherung, um die es hier eigentlich geht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 20:06:34 +02:00
nexxo 499240dd45 Die Kundeninstanz lernt Mail verschicken
Bis hierher konnte sie es nicht — keine Freigabe-Benachrichtigung, kein
"Passwort vergessen", nichts. Das ist die Voraussetzung dafuer, dass die
Einladung an einen Mitarbeiter aus SEINER Nextcloud kommt und das Passwort
dort entsteht, wo niemand sonst es sieht.

Ohne Server oder Postfach wird nichts geschrieben und der Schritt scheitert
mit Grund.

Zwei bestehende Pruefungen mitgezogen: CustomerStepBaseTest erwartete eine
feste Schrittzahl (16 -> 17), und CustomerProvisioningEndToEndTest lief ohne
Mailversand-Fixtures durch die volle Pipeline und scheiterte jetzt genau dort
— beide auf dieselbe Art nachgezogen wie ApplyStorageQuota es vormacht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:59:44 +02:00
nexxo 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>
2026-08-03 19:46:44 +02:00
nexxo 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>
2026-08-03 19:32:40 +02:00
nexxo 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>
2026-08-03 19:19:54 +02:00
nexxo 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.
2026-08-03 19:15:36 +02:00
nexxo 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.
2026-08-03 19:06:04 +02:00
nexxo 96bc2c5a07 Fix-Runde 2: eine fremde Mailklasse faehrt im ruhigen Takt statt ungebremst
gedrosselteSpur() las die Spur nur aus $this->mailable->queue. Die setzt aber
allein RidesALane, und den tragen ausschliesslich unsere vierzehn Klassen. Eine
Mailklasse aus einem Paket — Fortify, ein kuenftiges Abhaengigkeitspaket —
bekam damit gar keine Drossel.

Das ist die Gegenrichtung zu der Entscheidung aus Aufgabe 1: dort faellt eine
unbekannte Klasse ausdruecklich in die ruhige Spur, weil eine zu langsam
verschickte Mail ein kleinerer Fehler ist als ein ungedrosselter Schub, den
niemand vorhergesehen hat. Steht keine Spur am Mailable, entscheidet jetzt
MailLane::for() — dieselbe Quelle, die Aufgabe 1 dafuer gebaut hat.

Schlange und Takt fallen fuer so eine Mail auseinander: sie bleibt auf default,
weil die Schlange der Trait waehlt, und faehrt trotzdem im ruhigen Takt, weil
den der Auftrag waehlt. Der Kommentar sagt, warum das kein Versehen ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 19:04:32 +02:00
nexxo 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>
2026-08-03 18:37:20 +02:00
nexxo 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>
2026-08-03 17:44:29 +02:00
nexxo c78866e360 fix(security): Aufheben einer Sperre sagt die Wahrheit und wird nachgeholt
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions 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.
2026-08-03 17:28:28 +02:00
nexxo 3a9a9d2f77 Der Arbeiter liest die drei Spuren, und die Reihenfolge ist die Prioritaet
Ohne diese Zeile laege nach den beiden vorigen Aufgaben jede Mail unverschickt
in einer Schlange, die niemand abholt — still, ohne Fehlermeldung. Code und
Compose gehoeren deshalb zusammen ausgerollt.

Diese Aenderung wirkt erst, wenn der Arbeiter-Container neu gestartet wird —
ein laufender alter Arbeiter liest weiter nur `default`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:08:04 +02:00
nexxo 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>
2026-08-03 17:00:20 +02:00
nexxo 6ebbaa82aa Ein Host, der die Sperrmengen nicht kennt, laesst sich jetzt nachziehen
Fix-Welle nach dem Gesamt-Review, zweite Haelfte von Punkt 3.

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

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

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

Also dasselbe hier:

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:00:08 +02:00
nexxo 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>
2026-08-03 17:00:00 +02:00
nexxo 0475aad7b0 Fix-Runde 1: das Zeitfenster gilt nur, wo auch gedrosselt wird
Eine zeitliche Grenze hebelt --tries nicht bloss fuer Rueckstellungen aus,
sondern ganz: Worker::markJobAsFailedIfWillExceedMaxAttempts prueft die
Versuchszahl ausdruecklich nur if (! $job->retryUntil()). Damit galten die drei
Versuche seit dem Takt NIRGENDS mehr — auch nicht auf der Direktspur und auch
nicht bei ausgeschaltetem Notschalter.

Eine Mail, die aus echtem Grund wirft (SMTP tot, PDF-Rendern kippt), wurde
dadurch sechs Stunden lang wiederholt statt nach drei Versuchen abgelegt, und
ohne Pause, weil der Arbeiter ohne --backoff laeuft. Ihr Fehler stand sechs
Stunden lang nicht in failed_jobs, und auf der Direktspur haette eine einzige
giftige Mail sechs Stunden lang die Kennwort-Zuruecksetzungen blockiert, sobald
Aufgabe 4 den Arbeiter in Prioritaetsreihenfolge lesen laesst.

retryUntil() haengt jetzt an derselben Bedingung wie die Drossel, beide lesen
gedrosselteSpur(). Vorher wird die Elternklasse gefragt: SendQueuedMailable
reicht an die Mailklasse weiter, und die Ueberschreibung nahm das still weg.

Dazu zwei kleinere Punkte aus derselben Durchsicht: der Kopfkommentar behauptete
weiter, eine middleware() auf der Mailklasse lese niemand — widerlegt am
Vendor-Code, jetzt steht der echte Grund dort. Und (int) Settings::get() ergab
auch ohne eingetipptes 0 eine Null (gespeicherte null, nichtnumerischer Wert);
eine Untergrenze steht jetzt an der Lesestelle, an der der Verlust passiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:55:50 +02:00
nexxo a713401562 Der Takt, und die Falle mit den Versuchen
SendQueuedMailable kennt keine middleware() — nachgesehen im Framework. Der Weg
fuehrt ueber einen eigenen Auftrag, den Mailable::newQueuedJob() ueber den
Container erzeugt; eine Bindung tauscht ihn fuer alle Mails aus, ohne dass eine
Absendestelle sich aendert. mail-wichtig bekommt 30 je 5 Minuten, mail-ruhig 20
je 10, mail-direkt gar keine Drossel — dort wartet gerade ein Mensch.

Der Arbeiter laeuft mit --tries=3, und eine gedrosselte Rueckstellung zaehlt
als Versuch. Ohne retryUntil() waere jede Rechnung nach dem dritten Drosseln
gescheitert statt verschickt. Der tragende Test faehrt den echten Arbeiter
gegen ein zu kleines Kontingent und belegt beides: failed_jobs bleibt leer, und
der hoechste Versuchszaehler ist vier — der Lauf hat die Linie wirklich
ueberschritten.

Die Bindung aendert den Klassennamen des eingereihten Auftrags, deshalb ziehen
zwei bestehende Zusicherungen in MailLaneRoutingTest und SenderAddressTest
nach: QueueFake legt Auftraege unter ihrem exakten Klassennamen ab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:25:25 +02:00
nexxo ec0e83e69b Waechtertest sucht rekursiv und nur echte Mailables
glob('Mail/*.php') sah nur die oberste Ebene von app/Mail. Der Ordner hat
mit Concerns/ und Transport/ schon zwei Unterordner - eine kuenftige
Mailklasse dort waere dem Waechter nie aufgefallen, und die Mail liefe
still auf MailLane::CALM statt auf eine bewusst gewaehlte Spur.

File::allFiles() sucht rekursiv; is_subclass_of(..., Mailable::class)
schraenkt danach auf echte Mailables ein, statt auf "liegt irgendwo unter
app/Mail" - ein Trait wie RidesALane selbst oder eine kuenftige
Basisklasse macht den Test damit nicht mehr faelschlich rot.

Falsifizierbarkeit erneut geprueft, diesmal an OrderConfirmationMail statt
an der zuvor benutzten VerifyEmailMail: Trait entfernt, Test rot mit genau
dieser Klasse benannt, Trait zurueckgesetzt, Test wieder gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:00:44 +02:00
nexxo 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>
2026-08-03 15:53:24 +02:00
nexxo 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>
2026-08-03 15:31:31 +02:00
nexxo 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>
2026-08-03 15:02:18 +02:00
nexxo 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>
2026-08-03 14:47:54 +02:00
nexxo 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>
2026-08-03 14:39:35 +02:00
nexxo 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.
2026-08-03 14:15:36 +02:00