Zwei nftables-Mengen (clupilot_blocked/clupilot_blocked6, beide mit
flags timeout) im erzeugten Regelwerk, die Drop-Regel dafuer sitzt
absichtlich unter ct state established,related accept — wer drin ist,
bleibt drin, gesperrt wird nur, was neu anklopft. HostFirewall::block()/
release() tragen eine Adresse mit Ablaufzeit ein bzw. nehmen sie heraus,
ueber die WireGuard-Adresse des Hosts, und geben false statt zu werfen,
wenn der Host nicht erreichbar ist, damit eine spaetere Wiedereintrage-
Aufgabe die Sperre einfach nochmal versuchen kann.
MailCatalogue haelt die eine Liste aller sechzehn Mailarten (Schluessel,
Beschriftung, Vorgabe-Zweck), aus MailPreviews herausgezogen, damit es
nur noch eine Stelle gibt, die beim naechsten Mailtyp vergessen werden
kann. MailRoute sitzt darueber: ein Eintrag ist eine Ausnahme fuer GENAU
diese eine Mailart, keine zweite Zuordnungsebene — ohne Eintrag oder bei
abgeschaltetem Zielpostfach faellt sie unveraendert auf den Zweck
zurueck, den MailboxResolver schon kennt.
SendsFromMailbox bekommt dafuer einen optionalen $mailKey; alle
bestehenden Aufrufer (inklusive ContactRequestMail, das mailboxAddresses
selbst zusammensetzt) bleiben bei null und damit beim alten Verhalten.
Jede Mailklasse und die CloudReady-Benachrichtigung nennen jetzt ihren
Katalog-Schluessel. Die Konsole bekommt eine vierte Karte unter der
Zweck-Zuordnung: eine Zeile je Mailart, ein <select> mit den aktiven
Postfaechern und "wie der Zweck (...)" als Vorgabe.
Der wichtigste Test schickt eine Mail ohne jeden Eintrag und prueft,
dass sie exakt beim bisherigen Postfach landet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
InstanceFactory kennt keinen active()-Zustand — die Tests im Plan haetten nicht
einmal kompiliert. Status und vmid stehen jetzt ausgeschrieben.
Und eine Zusicherung in Aufgabe 1 war leer: sie prueft, dass der Nachbar NICHT
auf 'info' zeigt, ohne ein Postfach fuer Abrechnung anzulegen — der Resolver
liefert dann null, und null ist nun einmal nicht 'info'. Genau die Sorte Test,
die gruen bleibt, waehrend die Sache kaputt ist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reihenfolge nach Abhaengigkeit: Wegwahl je Mailart (steht fuer sich), dann die
Sperrliste in der Firewall, darauf der Datensatz mit Verdopplung und
Ausnahmeliste, darauf die Melder, dann die Mail, zuletzt die Ansichten.
Zwei Zusagen haengen an genau einer Zeile und bekommen deshalb einen Test, der
das ERGEBNIS prueft und nicht den Quelltext: die Sperrregel muss unter
'ct state established,related accept' stehen (sonst fliegt jeder mitten aus
seiner Sitzung), und beim Wiedereintragen nach einem Neustart muss die
RESTlaufzeit gesetzt werden (sonst verlaengert sich eine Sperre bei jedem
Neustart des Hosts).
Beim Selbstdurchgang gegen die Spec fielen zwei fehlende Pruefungen auf und sind
nachgetragen: Versuche ueber zwei Fenster verteilt duerfen nicht sperren, und
eine gescheiterte Mail darf die Sperre nicht mitnehmen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ich hatte 'ueber den bestehenden SSH-Weg' geschrieben. Das stimmt fuer Hosts und
ist fuer Kundeninstanzen falsch: jeder Befehl im Gast laeuft im Produkt ueber
guestExec, also den QEMU-Gastagenten via Proxmox-API — so macht es jeder
occ-Aufruf. Falsch beschrieben haette der Plan den Melder auf einen Weg gebaut,
den es fuer Gaeste gar nicht gibt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Betreiber will die Meldungen dieses Systems aus einem eigenen Postfach
schicken, ohne dass jede andere Mail mitwandert. Heute kennt die Zuordnung nur
fuenf Zwecke — alles mit demselben Zweck geht aus demselben Postfach.
Dazu kommt eine Ebene UEBER den Zwecken, kein Ersatz: je Mailart ein Postfach,
Vorgabe 'wie der Zweck'. Ohne Eintrag aendert sich nichts. Der Katalog der 16
Mailarten existiert schon fuer die Vorschau und wird dabei zur EINEN Liste,
damit es keine zweite gibt, die man vergessen kann.
Ausdruecklich NICHT darin: Postfaecher anlegen und loeschen. Das ist ein eigenes
Projekt — ein Postfach zu loeschen, auf das noch drei Mailarten zeigen, darf
nicht still geschehen. Die fuenf vorhandenen genuegen fuer dieses System.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Entworfen mit dem Betreiber. Vier Entscheidungen stehen fest: gesperrt wird die
ADRESSE des Angreifers (nicht das Konto, nicht die Instanz), ab 10 Fehlversuchen
in 10 Minuten, fuer eine Stunde mit Verdopplung bei Wiederholung, und aufgehoben
wird von dem, den es angeht — der Inhaber im Portal, der Betreiber in der
Konsole.
Der Kern des Entwurfs ist eine nftables-Menge mit Ablaufzeit, deren Regel UNTER
'ct state established,related accept' steht. Damit ist 'wer drin ist, bleibt
drin' eine Eigenschaft des Netzes und kein Versprechen in unserem Code. Und die
Stunde laeuft im Kernel ab — eine Sperre kann nicht liegenbleiben, weil eine
Warteschlange klemmt.
Ehrlich benannt: auf einem fertig uebernommenen Host gibt es kein oeffentliches
SSH mehr, der Host-Melder greift also enger als sein Name klingt. Und eine
SSH-Verbindung je Instanz und Minute traegt heute, aber nicht bei hundert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DER FUND. Auf einem uebernommenen Host blieb die Anmeldung als root MIT PASSWORT
erlaubt. Port 22 steht waehrend der ganzen Uebernahme offen im Internet — die
Host-Firewall macht ihn erst als vorletzter von sechzehn Schritten zu. Dazwischen
lag ein Fenster von Stunden, in dem jede Maschine der Welt Root-Passwoerter
durchprobieren durfte. Und wer spaeter das Notfallskript benutzt, reisst es
wieder auf.
EstablishSshTrust schreibt jetzt /etc/ssh/sshd_config.d/99-clupilot.conf und
verbietet Passwort-Anmeldung. Der Zeitpunkt ist genau richtig gewaehlt: eine
Zeile darueber hat sich `keyLogin()` erfolgreich MIT DEM SCHLUESSEL angemeldet —
wir wissen also, dass der Weg hinein steht, bevor wir den anderen zumachen.
`reload` statt `restart`, und `sshd -t` davor. Ein Fehlschlag bricht die
Uebernahme NICHT ab: eine Haertung, die einen ganzen Aufbau scheitern laesst,
wird beim naechsten Mal weggelassen.
DIE KONSOLE SAGT ES JETZT SELBST. Neue Pruefgruppe „Sicherheit" auf der
Bereitschaftsseite, drei Punkte, alle drei aus dieser Durchsicht:
- Ist die Konsole ueberhaupt eingeschraenkt? (blockierend)
- Steht in TRUSTED_RANGES nur, was dort hingehoert? Alles andere wurde von Hand
in die .env geschrieben und erscheint in der Oberflaeche als „nicht
entfernbar" — beim naechsten Anschlusswechsel ein Aussperren.
- Haengen APP_PORT/REVERB_HOST_PORT auf der Schleife? Docker traegt
veroeffentlichte Ports VOR der Firewall ein: ein Dienst auf 0.0.0.0 ist von
aussen erreichbar, auch wenn ufw zu aussieht — und wer ihn direkt anspricht,
geht am Reverse Proxy vorbei, an dessen Zugangsliste und an TLS.
Diese Entwicklungsmaschine meldet prompt zwei davon. Genau dafuer ist die Gruppe
da: eine fehlende Einrichtung faellt beim ersten Versuch auf, eine offene Tuer
nie — bis sie jemand benutzt.
WAS DIE DURCHSICHT SONST ERGAB, und was in Ordnung ist: TrustProxies traut nur
privaten Bereichen und ausdruecklich NICHT X-Forwarded-Host, eine gefaelschte
Herkunftsadresse greift also nicht. Zwei-Faktor ist erzwungen, nicht optional.
Die Anmeldung bremst nach fuenf Versuchen. Geheimnisse liegen mit eigenem
Schluessel verschluesselt. Die Host-Firewall laesst 22 und 8006 nur aus dem
Tunnel. Der Terminal-Pfad umgeht die Netzsperre bewusst — sein Riegel ist das
Einmal-Ticket, dreissig Sekunden, an Host und Betreiber gebunden.
2523 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DER WAECHTER. Ein abgebrochenes Update liess den Stapel halb unten liegen, den
Tunnel weg und die Seite eine Stunde lang mit 500 antworten — bis jemand von
Hand nachsah. Das wartet nie wieder auf einen Menschen.
deploy/watchdog.sh laeuft jede Minute als systemd-Dienst AUF DEM WIRT, nicht in
einem Container: ein Waechter im Container braeuchte den Docker-Socket
hineingereicht (Root auf dem Wirt fuer jeden, der je hineinkommt) und waere
genau dann tot, wenn man ihn braucht. Er nimmt dieselbe Sperre wie der
Update-Agent und fasst waehrend eines Updates nichts an.
Er kennt vier Fehlerbilder, alle vier heute wirklich passiert, und fuer jedes
genau einen Griff: fehlende Dienste starten; Container, die einander nicht mehr
finden, NEU ERZEUGEN (ein Neustart hilft da nicht); wg0 hochziehen; einen seit
ueber einer halben Stunde haengenden Wartungsmodus beenden. Was er nicht kennt,
protokolliert er und laesst es in Ruhe. `maintenance-hold` ist die Handbremse.
Beides nachgemessen: Dienst gestoppt -> wieder da; wg0 abgeraeumt -> wieder oben.
DREI LUECKEN, DIE DERSELBE VORFALL AUFGEDECKT HAT:
1. `phase()` machte `mkdir -p` ohne Fangnetz. Gehoerte storage/ nach einem
frueheren Fehltritt root, starb das Update mit `set -e` an seiner ERSTEN
Zeile, ohne eine einzige Ausgabe. Von aussen sah das aus wie
"haengengeblieben"; in Wahrheit war es nach einer Millisekunde vorbei.
2. Ein zweiter Aufruf meldete "Already up to date" und tat NICHTS — der Checkout
stand ja schon auf dem Ziel. Der Commit ist die falsche Frage; jetzt wird der
Zustand gefragt: laeuft jeder Dienst, ist der Wartungsmodus aus.
3. Nach einer Netz-Umstellung reicht `up -d` nicht: nur neu gestartete Container
haengen weiter am alten Netz, alle laufen, und trotzdem loest kein Name mehr
auf. Jetzt `--force-recreate`.
DIE VIER PUNKTE AUS DEM BETRIEB:
- Provisioning zeigte 15/16 in der Liste und "16 von 16" in der Karte daneben,
bei Status "Fertig" und 100 %. Die Liste rechnete `current_step + 1` — "dieser
Schritt laeuft gerade" —, und bei einem fertigen Lauf laeuft keiner mehr.
- Mahngebuehren standen in CENT im Feld. Wer eine Gebuehr eintraegt, denkt in
Euro und tippt "5"; daraus wurden fuenf Cent, ohne Widerspruch. Jetzt Euro im
Feld, Cent in der Datenbank, gerundet statt abgeschnitten ((int)(19.99*100)
ist 1998). Und Fristen und Geld stehen in zwei eigenen Bloecken mit eigener
Ueberschrift, die Einheit im Feld statt in der Beschriftung.
- Die Bueroadresse stand in der Konsole neben dem Management-Netz mit dem
Vermerk "nicht entfernbar" — sie kommt aber aus TRUSTED_RANGES in der .env und
ist sehr wohl aenderbar. Beim naechsten Umzug des Anschlusses waere das ein
Aussperren gewesen. Strukturell sind nur zwei Eintraege; alles andere steht
jetzt als das da, was es ist, mit dem Weg heraus.
- Ein Host-Zugang hiess weiter "pve-fns-1", waehrend der Host laengst "fsn-01"
hiess. Die Zeile zeigt jetzt den Namen des Hosts, nicht den einmal
gespeicherten — damit traegt sie jede kuenftige Umbenennung von selbst.
Und die Frage "wofuer brauche ich Uptime Kuma": es ist benutzt — RegisterMonitoring
legt fuer jede Kunden-Instanz eine Ueberwachung an, SyncMonitoringStatus holt den
Stand, und ein Ausfall steht auf der Uebersicht. Ohne Kunden-Instanzen gibt es
nichts zu sehen, was wie "unbenutzt" aussieht. Das steht jetzt in den
Einstellungen, statt dort nur "API-Token und wo die Bruecke erreichbar ist".
2522 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Die Wurzel des ganzen Uebels, endlich an der Wurzel: Docker traegt den
veroeffentlichten UDP-Port als Weiterleitung auf die Container-Adresse ein, und
der Kernel merkt sich jeden laufenden Strom samt dieser Adresse. Bekam der
Container beim Neubau eine andere — und er bekam die naechste freie —, zeigten
alle gemerkten Stroeme ins Leere. Und sie verfallen nicht, weil WireGuards
Lebenszeichen sie alle 25 Sekunden auffrischt.
Deshalb kam ein Telefon nach Aus/Ein sofort zurueck und ein Host mit festem
Quellport ueberhaupt nicht.
Jetzt hat vpn-hub eine feste Adresse (172.18.0.240, ueber CLUPILOT_VPN_HUB_IP
aenderbar) in einem Netz mit erklaertem Subnetz. Nach einem Neubau entsteht exakt
dieselbe Weiterleitung, die gemerkten Stroeme bleiben gueltig, und es gibt nichts
mehr aufzuraeumen. Nachgemessen: 172.18.0.240 vor und nach
`up -d --force-recreate vpn-hub`.
UND DIE UMSTELLUNG SELBST, die mich beim Bauen fast den Stapel gekostet haette:
Docker kann ein bestehendes Netz nicht umdefinieren. Es muss neu angelegt werden,
und das scheitert, solange auch nur EIN Container daranhaengt — `up -d` bricht
dann mit "network … has active endpoints" ab und laesst den Stapel halb unten
stehen. Genau so hier passiert, mit einem Container, der gar nicht zum Stapel
gehoerte. Das Deployment vergleicht deshalb vorher das erklaerte Subnetz mit dem
tatsaechlichen und faehrt bei Abweichung EINMAL geordnet herunter, statt darueber
zu stolpern. Danach stimmen die Werte ueberein und der Block tut nie wieder etwas.
Das conntrack-Aufraeumen bleibt trotzdem drin: als Rueckfahrkarte fuer Server auf
aelterem Stand und fuer den Fall, dass jemand das Subnetz aendert.
2510 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bisher wohnte wg0 im Container des Provisionierungs-Arbeiters, also im Abbild
`clupilot-app`. Das wird bei fast jeder Freigabe neu gebaut — und ein neu
gebauter Container bekommt eine neue Adresse im Compose-Netz, womit die
Weiterleitung fuer UDP 51820 neu geschrieben wird und JEDE bestehende
WireGuard-Sitzung abreisst. Der Tunnel hing damit am Veroeffentlichungstakt der
Anwendung, und zusaetzlich daran, dass ein PHP-Prozess nicht abstuerzt.
Jetzt gehoert der Netz-Namensraum einem eigenen Container `vpn-hub` mit eigenem,
winzigem Abbild (Alpine plus wireguard-tools), das sich fast nie aendert.
Provisionierungs-Arbeiter, Terminal-Bruecke, interner DNS und internes Gateway
steigen dort ein, statt einer von ihnen den Namensraum zu besitzen.
NACHGEMESSEN, nicht angenommen: App-Abbild neu gebaut, Arbeiter, Bruecke und
Gateway per --force-recreate neu erzeugt — der Hub blieb Container 92e928cf53b0,
wg0 und beide Zugaenge unangetastet, und nginx erreichte die Bruecke weiter
(HTTP 426). Genau der Vorgang, der bisher jedes Mal alles abgerissen hat.
Nachgezogen:
- nginx spricht die Bruecke unter `vpn-hub:8082` an — dem Namen des
Namensraum-Eigentuemers; ein Mitbewohner hat keinen eigenen DNS-Eintrag.
- update.sh baut vpn-hub mit und haengt Nachbar-Neustarts und den
conntrack-Griff an die Frage, ob der Hub WIRKLICH neu gebaut wurde.
- update-agent.sh startet den Arbeiter nicht mehr neu, sondern signalisiert ihm.
Diese Stelle laeuft unbeaufsichtigt hinter dem Knopf „Dienste neu starten" —
wer den drueckt, rechnet nicht damit, sich selbst auszusperren.
- Vier Meldungen in der Konsole rieten dem Betreiber, genau den Befehl von Hand
auszufuehren, der ihm den Tunnel abreisst. Auch die sind korrigiert.
- rescue-tunnel.sh und das Runbook zeigen auf den neuen Besitzer.
Eine Kleinigkeit unterwegs, die ich falsch angekuendigt hatte: `[[ … ]] && x=true`
bricht unter `set -e` NICHT ab — bash nimmt die linke Seite einer &&-Liste
ausdruecklich aus. Nachgeprueft; die if-Form bleibt trotzdem, aus Lesbarkeit, und
der Kommentar sagt jetzt den wahren Grund.
2509 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deploy/rescue-tunnel.sh stellt der Reihe nach fest, wo es klemmt, sagt es in
klaren Worten und behebt, was sich vom Server aus beheben laesst: Container
starten, wg0 hochziehen, gemerkte UDP-Stroeme wegraeumen. Und es sagt, wenn der
Server noch vor v1.4.4 steht, wo der Tunnel noch am Warteschlangen-Arbeiter hing.
Bewusst nichts Zerstoererisches — kein Neubau, kein Neustart des
Tunnel-Containers. Genau der waere die Ursache und nicht die Loesung.
Der Grund fuer das Skript: im Ernstfall soll niemand eine Befehlsfolge aus einem
Runbook abtippen. Ein Befehl, und was danach noch zu tun bleibt, steht am Ende
der Ausgabe.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Rettungsweg war bisher muendliche Ueberlieferung, verteilt ueber einen
Chatverlauf. Jetzt aufgeschrieben, mit der einen Regel darueber: kein Weg zurueck
darf durch den Tunnel fuehren, der gerade kaputt ist.
Vier Wege hinein (SSH auf den Server, Anbieterkonsole, das Notfallskript auf dem
Host, eine offline abgelegte WireGuard-Konfiguration), ein Entscheidungsbaum zum
Eingrenzen, und je Fall die Befehle. Dazu vier Dinge, die man EINMAL einrichtet,
solange nichts brennt — darunter der sudoers-Eintrag, ohne den der conntrack-
Griff aus v1.4.6 nur als Warnung im Protokoll landet statt zu laufen.
Und ein Abschnitt darueber, was seit v1.4.4/v1.4.6 nicht mehr passieren sollte:
wer es trotzdem sieht, hat einen Server auf altem Stand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Betreiber hat es dreimal hintereinander erlebt: Update gefahren, VPN weg.
Die Ursache dafuer, dass es JEDES Mal passierte, stand hier:
docker compose restart queue queue-provisioning scheduler reverb
In dessen Netz-Namensraum lebt wg0. `restart` baut den Namensraum neu auf, also
riss jede WireGuard-Sitzung ab — die des Betreibers am Telefon wie die jedes
Hosts. Fuer ein Update, das nur PHP-Code aendert, ist das ein absurder Preis.
Seit der Arbeiter dort in einer Schleife laeuft (v1.4.4), geht es billiger:
`queue:restart` setzt ein Signal, der Arbeiter beendet sich nach dem laufenden
Auftrag, die Schleife startet ihn mit dem neuen Code neu. Der Container bleibt
stehen, wg0 bleibt oben, niemand merkt etwas.
Und fuer den Fall, dass `up -d` ihn doch neu baut (neues Abbild, geaenderte
Konfiguration): das Skript merkt sich die Container-ID vorher und nachher. Hat
sie sich geaendert, raeumt es die gemerkten UDP-Stroeme selbst weg —
`sudo -n conntrack -D -p udp --dport <port>`, und wenn es das nicht darf, steht
der Befehl als Warnung im Protokoll statt gar nichts.
Dieser Handgriff war bisher muendliche Ueberlieferung. Ohne ihn zeigen die
gemerkten Stroeme weiter auf den alten Container, und sie verfallen nicht:
WireGuard schickt alle 25 Sekunden ein Lebenszeichen und haelt den kaputten
Eintrag am Leben. Genau deshalb kam ein Telefon nach Aus- und Einschalten sofort
zurueck (neuer Quellport) und ein Host mit festem Port ueberhaupt nicht.
Ein Test haelt beides fest: queue-provisioning darf nicht in der Neustart-Liste
stehen, und der conntrack-Griff muss im Skript bleiben.
2509 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Dinge, die dem Betreiber am fertigen Bild aufgefallen sind.
DIE WORTMARKE. Sechs Zellen pro Buchstabe liessen sie hoch und schmal wirken;
acht treffen die Proportion. Damit ist die Zeichnung 65 statt 51 Zellen breit,
und bei 13px rund 500px — auf dem Schirm eine Wortmarke, kein Etikett. Am
Telefon 7px und damit rund 270px, was neben den Raendern hineinpasst.
DIE HOSTZEILE. „Online" und „Verbunden" standen 3px versetzt, obwohl beide
Zellen gleich hoch sind (18,6px, gemessen). Der Grund: ein `inline-flex` holt
sich seine Grundlinie vom ERSTEN Kind. Links ist das ein 8px-Punkt, rechts ein
16px-Icon; beide sitzen in ihrer Zeile mittig, ihre Unterkanten liegen deshalb
4px auseinander, und genau darauf setzt der Browser die Grundlinie der ganzen
Zelle. Mit `align-middle` richtet sich der Kasten nach seiner Mitte statt nach
dem ersten Kind. Nachgemessen: vorher 361,3 gegen 358,5, jetzt beide 361,6 —
Versatz 0px.
Beides im Browser gemessen, nicht geschaetzt. 2508 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Betreiber sah einen losen Klotz mitten im Wort. Ursache war der Bogen des
`P` in der uebernommenen Figlet-Zeichnung: eine Zelle in der zweiten Zeile, unter
der in der ersten nichts stand. Figlet-Schriften sind fuer echte
Terminalschriften gezeichnet und tragen in IBM Plex Mono nicht.
Jetzt selbst gezeichnet: jeder Buchstabe sechs Zellen breit (das `I` zwei), eine
Zelle Abstand, und keine Zelle ohne Nachbarn darueber. Nicht von Hand abgezaehlt,
sondern aus einer Buchstabentabelle gesetzt — von Hand hatte ich mich prompt
verzaehlt und die Buchstaben liefen ineinander, was im Browser sofort zu sehen
war.
Groesser als vorher (15px statt 11px, am Telefon 10px): die neue Zeichnung ist
51 Zellen breit statt 62, bei gleicher Schriftgroesse wirkte sie deshalb
kleiner.
Zwischendurch stand hier „CluPilot Cloud" mit grauem zweiten Wort wie im Logo.
Auf Wunsch des Betreibers wieder raus — nur die Wortmarke.
2508 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Zwei Ursachen, eine davon habe ich mit v1.4.2 selbst gelegt.
1. DER NETZ-ALIAS. Damit in docker/nginx/default.conf `terminal:8082` stehen
konnte statt des Namens des Nachbarcontainers, bekam queue-provisioning einen
Eintrag unter `networks: aliases:`. Das ist Teil seiner NETZKONFIGURATION —
und Compose baut einen Container neu, sobald die sich aendert. Neu gebaut
heisst neue Adresse im Compose-Netz, heisst neu geschriebene
Weiterleitungsregeln fuer den veroeffentlichten UDP-Port 51820. Auf dem liegt
jede bestehende WireGuard-Sitzung.
Der Preis fuer einen sprechenderen Namen in einer Konfigurationszeile war
also ein Abriss saemtlicher Tunnel beim Ausrollen — der des Betreibers am
Telefon wie der jedes Hosts. Alias entfernt, nginx spricht die Bruecke unter
`queue-provisioning:8082` an. Nachgemessen: danach laesst `docker compose
up -d` den Tunnel-Container unangetastet, und nginx erreicht die Bruecke
weiterhin (426 statt 502).
2. DER ARBEITER ALS PROZESS 1. wg0 stand im Namensraum von queue-provisioning,
und dessen Prozess 1 war `exec php artisan queue:work provisioning`. Endete
der Arbeiter, endete der Container — und mit ihm der Namensraum und jede
Sitzung darin. Ein Arbeiter endet oefter, als man denkt: `--timeout=2100`
beendet ihn bei einem langen Provisionierungs-Schritt, ein fataler Fehler
beendet ihn, ein Speicherlimit beendet ihn, `queue:restart` beendet ihn
absichtlich. Aus jedem dieser vier Faelle wurde bisher "das VPN ist
unzuverlaessig".
Der Rumpf liegt jetzt in docker/provisioning-worker.sh: wg0 einmal hochziehen,
danach den Arbeiter in einer Schleife halten, SIGTERM sauber weiterreichen.
Nachgemessen: Arbeiter getoetet -> Container hat NULL Neustarts, wg0 steht
weiter, Arbeiter ist von allein wieder da.
Beide Male bleibt der Netz-Namensraum stehen. Damit verschwindet nebenbei auch
der Grund, aus dem update.sh die Bruecke hinterher neu starten musste — die
Zeile bleibt trotzdem, sie kostet nichts und traegt den Fall, dass der Container
doch einmal neu gebaut wird.
NICHT GETAGGT, mit Absicht: diese Aenderung fasst genau das an, was den Zugang
des Betreibers traegt. Sie gehoert ausgerollt, wenn jemand davorsitzt und die
Konsole des Anbieters als Rueckfahrkarte hat — nicht unbeaufsichtigt vom
Update-Agenten, waehrend der Betreiber unterwegs ist.
2508 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Betreiber oeffnete ein Terminal und las "Keine Verbindung — laeuft der
Terminal-Dienst, und steht der Tunnel?". Das war keine Diagnose, das war eine
Rueckfrage an den, der gerade unterwegs ist und nicht nachsehen kann.
Der Grund ist eine Eigenart des Protokolls: scheitert ein WebSocket schon am
Handschlag, bekommt die Seite laut Norm KEINEN HTTP-Status — `event.code` ist
1006, sonst nichts. Das ist Absicht (sonst waere ein Socket ein Portscanner) und
macht ausgerechnet die Unterscheidung unmoeglich, auf die es hier ankommt: laeuft
die Bruecke nicht, oder ist die Leitung weg? Beides sah gleich aus.
Eine gewoehnliche Anfrage an dieselbe Stelle darf den Status sehr wohl sehen.
Scheitert der Socket, ohne dass je ein Byte kam, fragt das Fenster deshalb einmal
nach und liest die Antwort:
502/503/504 nginx erreicht die Bruecke nicht -> "Der Terminal-Dienst laeuft
nicht", mit dem Befehl, der ihn zurueckholt, und dem Hinweis auf
den geteilten Netz-Namensraum
404 oeffentlicher Hostname -> "Auf diesem Namen gibt es kein Terminal"
sonst die Bruecke lebt, die Sitzung ist an etwas anderem gescheitert;
"Keine Verbindung" bleibt stehen
gar nichts die Anfrage kam nicht einmal los -> die Leitung ist wirklich weg
Alle drei Faelle nachgemessen, nicht angenommen: Bruecke laeuft -> 426,
oeffentlicher Name -> 404, `docker compose stop terminal` -> 502. Und danach mit
gestoppter Bruecke im Browser angesehen, im selben Zustand, in dem der Betreiber
gerade steht.
Dazu ein Test, der jeden Schluessel abdeckt, den terminal.js an showStage()
uebergeben kann — ein fehlender schriebe "undefined" ins Fenster, und zwar
ausgerechnet in dem Moment, in dem etwas kaputt ist.
2507 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aus dem Gesamt-Review, und der erste Befund ist der, der still zugeschlagen
haette.
`terminal` lebt im Netz-Namensraum von `queue-provisioning`, und ein Prozess
bleibt in dem Namensraum, in dem er gestartet ist. `update.sh` startet den Hub
neu — danach lauscht die Bruecke in einem, den es nicht mehr gibt. Nichts meldet
dabei einen Fehler: `docker compose ps` sagt weiter "healthy", weil die
Lebendpruefung ueber Loopback INNERHALB des verwaisten Namensraums laeuft. Nach
aussen antwortet nginx mit 502, und der Betreiber liest "Keine Verbindung —
laeuft der Terminal-Dienst?", waehrend der Dienst behauptet, es gehe ihm gut.
Genau dieselbe Falle, die zwei Bloecke tiefer schon fuer vpn-dns/vpn-gateway
behandelt ist; die Bruecke fehlte in der Behandlung.
Nachgemessen statt geglaubt: Hub neu gestartet -> Docker sagt "healthy", curl aus
dem Namensraum bekommt gar keine Antwort. Nach `restart terminal`: 200.
Und ein zweiter Ausrollfehler daneben: gebaut wurde nur `app`. `docker compose
up -d` baut nur Images, die es noch GAR NICHT gibt — beim ersten Ausrollen faellt
das nicht auf, danach nie wieder. Eine Aenderung an docker/terminal/ saehe
ausgeliefert aus, und es liefe das alte Image.
Ausserdem:
- Die Meldung zu 4502 zaehlte zwei Ursachen auf, der Code deckt fuenf. Die
Bruecke schickt 4502 fuer JEDE gescheiterte Anmeldung, auch fuer einen
abgewiesenen Schluessel — und das ist der wahrscheinlichste Fall, wenn ein Host
neu aufgesetzt wurde. "antwortet nicht" war dort schlicht falsch: die Maschine
hat geantwortet und abgelehnt. Titel und Text legen sich nicht mehr fest.
- R19: der Kommentar an der Kopfzeile der Spalte nannte "Berechtigung,
Betriebsbereitschaft" als Grund, warum der Knopf nicht ueberall steht.
Letzteres entscheidet seit dem Entsperren nichts mehr, und zwanzig Zeilen
tiefer begruendete der Kommentar am Knopf ausfuehrlich das Gegenteil.
- Der Kommentar am Retry-Knopf erklaerte die Reihenfolge von .hidden gegen
.inline-flex fuer zu unsicher, waehrend die Buehne dreissig Zeilen hoeher genau
darauf baut. Tailwind gibt .hidden als letzte Display-Klasse aus; der wahre
Grund fuer den Wrapper ist, dass die Klassen des Knopfes aus einem geteilten
Bauteil kommen.
- REDIS_URL stand fest auf Datenbank 1, waehrend PHP REDIS_CACHE_DB liest. Wer
die anfasst, legt auf der einen Seite ab, wo die andere nicht sucht.
2507 Tests gruen, compose config und bash -n sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drei Dinge, die beim ersten Hinsehen im Betrieb auffielen.
1. Der Knopf war weg. Er verschwand, wenn dem Host die Tunneladresse oder der
Fingerabdruck fehlte — mit der Begruendung, ein Knopf, der verlaesslich in
eine Ausnahme laeuft, sei schlechter als gar keiner. Das stimmte, solange die
Seite dahinter mit Laravels Fehlerseite aufging. Jetzt steht er auf jeder
Zeile: ein fehlender Knopf sah aus wie "hier gibt es kein Terminal" statt
"hier noch nicht, und zwar deshalb".
2. Die Seite ging mit einem Stacktrace auf. `TerminalTicket::issue()` warf,
niemand fing es, und wer den Knopf drueckte, bekam Klassenname, Dateipfad,
Zeilennummer und Quelltextauszug in einem Fenster des eigenen Produkts.
`blocker()` beantwortet die Frage jetzt VOR dem Ausstellen und gibt ein
Merkwort zurueck, keinen Satz — die Formulierung gehoert in die
Sprachdateien. `mount()` wirft nicht mehr, mit Fangzaun fuer das, womit
niemand gerechnet hat.
3. Der Abbruch war die einzige ungestaltete Stelle im Produkt: eine rote
ANSI-Zeile mitten in der eigenen Ausgabe. Der Vorspann und der Schirm waren
Geschwister, von denen abwechselnd eines `hidden` trug — das trug genau
einmal, beim Aufbau, und fuer alles danach fehlte die Rueckfahrkarte. Die
Buehne liegt jetzt UEBER dem Terminal und kann dreimal auftreten: beim
Verbinden, beim Ende, beim Abbruch. Die Sitzung darunter bleibt stehen.
Welcher Text, entscheidet der Schliesscode der Bruecke (4401 Ticket, 4502
kein SSH); dazu ein Knopf, der neu laedt, weil ein Ticket dreissig Sekunden
gilt und genau einmal.
Beim Hinsehen gefunden, nicht beim Testen:
- Die Schriftgrafik war unlesbar. Die Figlet-Zeichnung setzt darauf, dass der
Unterstrich einer Zeile den Strich der naechsten beruehrt; in IBM Plex Mono
sitzt er tiefer. Eng verschmierte das Wort, weit zerfiel es. Vollbloecke
fuellen ihre Zelle und stapeln in jeder Schrift.
- Dunkelrot auf Fast-Schwarz hatte kaum Kontrast. Die Wortmarke bleibt jetzt
immer in der Akzentfarbe — sie ist keine Statuslampe, was los ist, sagt die
Zeile darunter.
- Auf dem Schirm stand ":host antwortet nicht". Der Name war an die Erklaerung
uebergeben, an die Ueberschrift nicht. Ein Test mit
`toContain(__('...title'))` haette das nie gefunden — er verglich ":host" mit
":host". Der neue prueft das Ergebnis.
Nachgewiesen: Knopf oeffnet ein NEUES Tab (die Liste bleibt stehen), Ticket
ausgestellt, Socket verbunden, Bruecke kommt nicht auf den Host, schliesst 4502,
Buehne kommt mit "pve-fsn-1 antwortet nicht" und Knopf zurueck, Knopf laedt
wirklich neu und holt ein frisches 64-Zeichen-Ticket. Der Fingerabdruck dafuer
war geliehen und ist wieder entfernt.
2507 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Der Beiwagen nach dem Vorbild von kuma-bridge: Python, ein Zweck, kein
Laravel. Er steht im Netz-Namensraum von queue-provisioning, weil dort wg0
lebt und nur von dort ein Host ueberhaupt erreichbar ist.
Drei Punkte, die das Review von Aufgabe 2 offen gelassen hat, sind hier
entschieden:
- Das Ticket reist als Sec-WebSocket-Protocol, nicht in der Adresszeile.
Die Adresse eines Upgrade-Antrags schreibt jeder Reverse Proxy mit, und
auf der Strecke stehen zwei, von denen nur einer aus diesem Repo
konfiguriert wird.
- /terminal/ws bleibt an der Wurzel, unabhaengig von AdminArea::prefix():
nginx kann die Konfiguration der Anwendung nicht lesen, der Ort muss ein
fester Text sein. In beiden Dateien steht die Begruendung.
- Die Bruecke sendet ausschliesslich Binaerrahmen.
Abweichungen vom Plan, jeweils gemessen statt vermutet:
- Der Schluessel in Redis traegt REDIS_PREFIX (clupilot-database-), nicht
CACHE_PREFIX: TerminalTicket schreibt ueber Redis::connection('cache'),
und phpredis stellt die Praefix-Option dieser Verbindung voran.
- Port 8082 statt 8081: im selben Namensraum lauscht der VPN-Gateway schon
auf VPN_HEALTH_PORT, und der zweite Zuhoerer auf einem Port stirbt.
- SSH ueber paramiko.Transport statt SSHClient. SSHClient prueft gegen
known_hosts, die dieser Container nicht hat und aus einem Fingerabdruck
auch nicht bilden kann; RejectPolicy verbaende nie, AutoAddPolicy
meldete sich mit einem Root-Schluessel an, bevor irgendetwas geprueft
ist. Der Transport erlaubt die richtige Reihenfolge: Handschlag,
Fingerabdruck, dann erst Anmeldung.
- Der Fingerabdruck wird gebildet wie in PhpseclibRemoteShell, nicht wie
bei OpenSSH: gehasht wird "<algorithmus> <base64-blob>", nicht der Blob.
- proxy_pass ueber eine Variable mit resolver, damit nginx nicht beim
Start scheitert, wenn die Bruecke gerade nicht laeuft.
- /terminal/ws antwortet auf einem oeffentlichen Hostnamen mit 404,
dieselbe Regel wie /admin eine Zeile darueber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Der Vorspann-Test prüft jetzt data-terminal-splash und die Ticketform
(64 Hexzeichen) selbst, statt sich auf ein aria-label zu verlassen, das
nur wegen des Tests existiert.
- Neuer Test: Berechtigung vor Nachschlagen — eine erfundene UUID meldet
403, nie 404.
- Der Knopf-Sichtbarkeitstest sichert jetzt auf die konkrete Terminal-Route
zu statt auf das nackte Wort "Terminal" irgendwo auf der Seite.
- Drei Kommentare (terminal.js, bare.blade.php, vite.config.js) behaupteten,
die Seite liefe ohne Livewire/Chart.js — sie ist aber eine Vollseiten-
Livewire-Komponente und zieht app.js über <x-shell.head> ohnehin mit.
Kommentare korrigiert: eigener Einstiegspunkt, damit der Terminalcode
nicht in app.js landet, nicht weil die Seite ohne Livewire liefe.
- terminal.js: ein fehlgeschlagener Socket feuert error UND danach close;
onclose schweigt jetzt, wenn nie ein Byte ankam, statt "Verbindung
beendet" hinter "Verbindung nicht möglich" zu schreiben.
- terminal.js: Textrahmen landen jetzt als String im Terminal statt als
leeres Uint8Array.
- data-host wird jetzt gelesen und steht in den Verbindungsmeldungen.
- wire:ignore auf dem Terminalschirm, bevor die Komponente ihre erste
Aktion bekommt und xterms DOM beim nächsten Render löscht.
- hosts.blade.php/host-detail.blade.php: der Terminal-Knopf trägt sein
href jetzt selbst (x-ui.button :href), statt in einem <a> zu stecken —
interaktiver Inhalt in einem Link war ungültiges HTML.
Suite: 2502 bestanden (vorher 2501 + ein neuer Test).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Aufgabe 2: alles, was der Betreiber sieht, noch ohne Container dahinter.
Der Knopf bleibt fuer einen Host ohne Tunneladresse oder Fingerabdruck
absichtlich unsichtbar, statt in eine unbehandelte RuntimeException aus
Aufgabe 1 zu fuehren.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cache::put() serialisierte den JSON-Inhalt zusaetzlich mit PHP serialize()
(kein 'serializer' konfiguriert), und Cache::pull() war get()+forget() in
zwei Runden statt atomar. issue()/redeem() sprechen jetzt direkt ueber
Redis::connection('cache') (setex/getdel), der volle Schluessel inkl.
REDIS_PREFIX steht im Kopfkommentar fuer Aufgabe 3. issue() weist ausserdem
Hosts ohne wg_ip oder ohne ssh_host_key zurueck, statt die Pruefung an einen
noch nicht existierenden Container zu delegieren.