Terminal: die Bruecke ueberlebt jetzt ein Deployment

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>
main
nexxo 2026-08-02 19:41:33 +02:00
parent f64a564c40
commit 4fc1ccd3db
6 changed files with 57 additions and 17 deletions

View File

@ -440,7 +440,11 @@ after="$(git rev-parse HEAD)"
# The image is only rebuilt when its definition changed — minutes versus seconds.
if ! git diff --quiet "$base" "$after" -- docker/ 2>/dev/null; then
phase image "Rebuilding the image"
docker compose build --quiet app
# `terminal` mitbauen, nicht nur `app`: `docker compose up -d` weiter unten
# baut nur Images, die es noch GAR NICHT gibt. Beim ersten Ausrollen fällt
# das nicht auf, danach nie wieder — eine Änderung an docker/terminal/
# sähe ausgeliefert aus, und es liefe das alte Image.
docker compose build --quiet app terminal
# Recreate now, not at the end: everything below runs INSIDE this container,
# and an update that changes the PHP runtime would otherwise install and
# migrate under the old one.
@ -526,6 +530,23 @@ docker compose up -d
# keep running the code from before the update.
docker compose restart queue queue-provisioning scheduler reverb
# Und die Terminal-Brücke gleich hinterher, aus demselben Grund wie die beiden
# VPN-Dienste unten: sie lebt im Netz-Namensraum des Provisioning-Containers
# (`network_mode: "service:queue-provisioning"`), und ein Prozess bleibt in dem
# Namensraum, in dem er gestartet ist. Nach der Zeile darüber lauscht sie in
# einem, den es nicht mehr gibt.
#
# Nichts meldet dabei einen Fehler — im Gegenteil: `docker compose ps` sagt
# weiter „healthy", weil die Lebendprüfung über die Loopback-Adresse INNERHALB
# des verwaisten Namensraums läuft. Nach außen antwortet nginx mit 502, und der
# Betreiber liest „Keine Verbindung — läuft der Terminal-Dienst?", während der
# Dienst behauptet, es gehe ihm gut.
#
# Ohne Profil-Abfrage, anders als unten: `terminal` hängt an keinem Profil und
# läuft immer. Auf wg0 wartet sie nicht — sie bindet 8082 im Namensraum, nicht
# die Tunneladresse; die braucht sie erst, wenn jemand ein Ticket einlöst.
docker compose restart terminal
# AFTER the hub, always. Both VPN services live in the provisioning container's
# network namespace, and a process holds the namespace it started in — so once
# the hub is restarted they are listening inside one that no longer exists.

View File

@ -210,9 +210,11 @@ services:
restart: unless-stopped
network_mode: "service:queue-provisioning"
environment:
# Datenbank 1 — dieselbe, auf der `Redis::connection('cache')` das Ticket
# ablegt (REDIS_CACHE_DB, Vorgabe 1).
REDIS_URL: "redis://redis:6379/1"
# Dieselbe Datenbank, auf der `Redis::connection('cache')` das Ticket
# ablegt. Der Wert kam aus derselben .env-Stelle wie bei PHP, statt fest
# auf 1 zu stehen: sonst legt die eine Seite ab, wo die andere nicht sucht,
# sobald jemand REDIS_CACHE_DB anfasst — und das Ticket wäre einfach nie da.
REDIS_URL: "redis://redis:6379/${REDIS_CACHE_DB:-1}"
# phpredis stellt jedem Schluessel dieser Verbindung
# `database.redis.options.prefix` voran. Der ist aus APP_NAME abgeleitet
# (Str::slug(APP_NAME).'-database-') und fuer PHP unsichtbar, fuer jeden

View File

@ -158,9 +158,14 @@ return [
'title' => 'Ticket nicht mehr gültig',
'note' => 'Ein Ticket gilt dreißig Sekunden und genau einmal. Dieses war abgelaufen oder schon eingelöst — ein neuer Anlauf stellt ein frisches aus.',
],
// Absichtlich ohne Festlegung: die Brücke schickt 4502 für JEDE
// gescheiterte Anmeldung — Maschine still, Fingerabdruck abweichend,
// Schlüssel abgewiesen, Zeitüberschreitung. „Antwortet nicht" wäre
// im häufigsten Fall schlicht falsch: nach einem frisch aufgesetzten
// Host antwortet sie sehr wohl und lehnt ab.
'unreachable' => [
'title' => ':host antwortet nicht',
'note' => 'Die Anmeldung über den Tunnel ist gescheitert: entweder ist die Maschine nicht erreichbar, oder ihr Fingerabdruck weicht von dem hinterlegten ab. Was davon, steht im Protokoll des Terminal-Dienstes — nicht in diesem Fenster.',
'title' => 'Keine Sitzung auf :host',
'note' => 'Die Brücke kam nicht bis zur Kommandozeile: die Maschine antwortet nicht, ihr Fingerabdruck weicht vom hinterlegten ab, oder sie hat den Flottenschlüssel abgewiesen — Letzteres ist das Wahrscheinlichste, wenn der Host neu aufgesetzt wurde. Was davon, steht im Protokoll des Terminal-Dienstes; dieses Fenster erfährt es absichtlich nicht.',
],
'retry' => 'Neu verbinden',
],

View File

@ -159,9 +159,13 @@ return [
'title' => 'Ticket no longer valid',
'note' => 'A ticket lasts thirty seconds and works exactly once. This one had expired or was already redeemed — another attempt issues a fresh one.',
],
// Deliberately non-committal: the bridge sends 4502 for EVERY failed
// sign-in — machine silent, fingerprint mismatch, key refused,
// timeout. "Not answering" would be plain wrong in the likeliest
// case: after a host is rebuilt it answers and refuses.
'unreachable' => [
'title' => ':host is not answering',
'note' => 'Signing in through the tunnel failed: either the machine is unreachable, or its fingerprint differs from the one on file. Which of the two is in the terminal service log, not in this window.',
'title' => 'No session on :host',
'note' => 'The bridge never reached a command line: the machine is not answering, its fingerprint differs from the one on file, or it refused the fleet key — the last is the likeliest once a host has been rebuilt. Which of them is in the terminal service log; this window is deliberately not told.',
],
'retry' => 'Reconnect',
],

View File

@ -102,11 +102,13 @@
terminal.js an; ein `onclick` im Markup wäre Verhalten an der
Stelle, an der sonst nur Gestalt steht.
`hidden` sitzt am Wrapper, nicht am Knopf: `x-ui.button` bringt
`inline-flex` mit, und beide Klassen haben dieselbe Spezifität
wer gewinnt, entschiede dann die Reihenfolge im Stylesheet. Dieselbe
Falle wie bei `size-4`/`size-5` in R18, und sie ist hier mit einem
zusätzlichen Element billiger umgangen als nachgewiesen. --}}
`hidden` sitzt am Wrapper, nicht am Knopf. Nicht, weil es dort nicht
wirkte Tailwind gibt `.hidden` als letzte der Display-Klassen aus
und schlägt `inline-flex` damit , sondern weil die Klassen des
Knopfes aus einem geteilten Bauteil kommen und an dieser Stelle
niemandem gehören. Die Bühne selbst trägt `hidden` und `flex`
durchaus gemeinsam: das ist ein Element, das hier steht und hier
bleibt. --}}
<div data-stage-retry @class(['hidden' => $problem !== 'unknown'])>
<x-ui.button data-stage-retry-button variant="secondary" size="sm">
{{ __('hosts.terminal.stage.retry') }}

View File

@ -82,10 +82,16 @@
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.capacity') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.status') }}</th>
{{-- Leer mit Absicht: die Spalte trägt nur den
Terminal-Knopf, und der ist nicht auf jeder
Zeile da (Berechtigung, Betriebsbereitschaft).
Eine Beschriftung, die unabhängig davon immer
dastünde, wäre eine falsche Zusage. --}}
Terminal-Knopf, und der ist nicht für jeden da
(`hosts.manage`). Eine Beschriftung, die
unabhängig davon immer dastünde, wäre eine
falsche Zusage.
Hier stand einmal „Berechtigung,
Betriebsbereitschaft" — Letzteres entscheidet
seit dem Entsperren des Knopfes nichts mehr,
und zwanzig Zeilen tiefer begründet der
Kommentar am Knopf ausführlich das Gegenteil. --}}
<th class="px-4 py-3 font-semibold"></th>
</tr>
</thead>