diff --git a/app/Livewire/Admin/HostTerminal.php b/app/Livewire/Admin/HostTerminal.php index 2df6530..8c7ea20 100644 --- a/app/Livewire/Admin/HostTerminal.php +++ b/app/Livewire/Admin/HostTerminal.php @@ -45,6 +45,12 @@ class HostTerminal extends Component public string $ticket = ''; + /** + * Warum hier kein Terminal entstehen kann — ein Merkwort aus + * `TerminalTicket::blocker()`, oder leer, wenn nichts im Weg steht. + */ + public string $problem = ''; + public function mount(string $host): void { $this->authorize('hosts.manage'); @@ -54,7 +60,28 @@ class HostTerminal extends Component /** @var Operator $operator */ $operator = auth('operator')->user(); - $this->ticket = TerminalTicket::issue($this->record, $operator); + // Diese Seite wirft nicht. Ein Terminalfenster, das mit Laravels + // Fehlerseite aufgeht — Klassenname, Dateipfad, Zeilennummer, + // Quelltextauszug —, ist ein Bruch mitten im Produkt: der Betreiber hat + // einen Knopf gedrückt und bekommt einen Stacktrace. Was schiefgehen + // kann, steht in `blocker()` und wird unten in der Sprache dieses + // Fensters hingeschrieben. + $this->problem = TerminalTicket::blocker($this->record) ?? ''; + + if ($this->problem !== '') { + return; + } + + // Der Fangzaun dahinter: `blocker()` kennt die Gründe, mit denen wir + // rechnen. Reißt `issue()` aus einem, mit dem niemand gerechnet hat + // (Redis weg, Tresor kaputt), ist die Antwort dieselbe — eine gestaltete + // Seite statt einer Fehlerseite —, nur ohne Versprechen, woran es lag. + try { + $this->ticket = TerminalTicket::issue($this->record, $operator); + } catch (\Throwable $e) { + report($e); + $this->problem = 'unknown'; + } } public function render() diff --git a/app/Services/Terminal/TerminalTicket.php b/app/Services/Terminal/TerminalTicket.php index 9bad12f..cf4b63d 100644 --- a/app/Services/Terminal/TerminalTicket.php +++ b/app/Services/Terminal/TerminalTicket.php @@ -62,32 +62,56 @@ final class TerminalTicket private const PREFIX = 'terminal:ticket:'; + /** + * Was diesem Host gerade im Weg steht — oder null, wenn nichts im Weg steht. + * + * Es gibt diese Methode, weil dieselbe Frage an zwei Stellen gestellt wird + * und nur eine davon eine Ausnahme gebrauchen kann. `issue()` unten muss + * scheitern: es ist die letzte Instanz vor dem Ticket. Die Seite dagegen + * will die Antwort VORHER wissen, um sie in ihrer eigenen Sprache + * hinzuschreiben, statt sie als 500er an den Betreiber weiterzureichen. + * + * Zurück kommt ein Merkwort, kein Satz: die Formulierung gehört in die + * Sprachdateien, nicht in eine Ausnahme. `null` heißt „nichts im Weg". + */ + public static function blocker(Host $host): ?string + { + // Ohne Schlüssel kann die Brücke sich nicht anmelden, und der Betreiber + // sähe nur einen Vorspann, der nie weicht. + if (blank((string) app(SecretVault::class)->get('ssh.private_key'))) { + return 'no_key'; + } + + // Derselbe Gedanke, für die beiden anderen Felder, ohne die keine + // sichere Sitzung entstehen kann. Eine fehlende Tunneladresse ergibt + // bloß ein Fenster, das nie verbindet — ärgerlich, aber harmlos. Ein + // fehlender Fingerabdruck ist das Gegenteil: er übergäbe die Prüfung an + // Code, der noch gar nicht existiert, und dessen naheliegendste Fassung + // ("kein Fingerabdruck im Ticket → nicht prüfen") eine ungepinnte + // Root-SSH-Sitzung im Tunnel wäre — genau das, was die Spec mit + // „Fingerabdruck geprüft" ausdrücklich ausschließt. Die Prüfung gehört + // hierher, wo der Host ohnehin schon feststeht, nicht in einen + // Container, der dem Ticket nur noch glauben kann. + if (blank($host->wg_ip) || blank($host->ssh_host_key)) { + return 'not_ready'; + } + + return null; + } + public static function issue(Host $host, Operator $for): string { + // Die Prüfung steht in `blocker()`; hier bleibt nur, sie ernst zu + // nehmen. Der Wortlaut ist der von früher — er steht in Logs und in + // den Tests aus Aufgabe 1. + match (self::blocker($host)) { + 'no_key' => throw new RuntimeException('Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.'), + 'not_ready' => throw new RuntimeException("Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."), + default => null, + }; + $key = (string) app(SecretVault::class)->get('ssh.private_key'); - // Lieber hier scheitern als ein Fenster, das aufgeht und schweigt: ohne - // Schlüssel kann die Brücke sich nicht anmelden, und der Betreiber sähe - // nur einen Vorspann, der nie weicht. - if (blank($key)) { - throw new RuntimeException('Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.'); - } - - // Derselbe Gedanke wie beim Schlüssel oben, für die beiden anderen - // Felder, ohne die keine sichere Sitzung entstehen kann. Eine - // fehlende Tunneladresse ergibt bloß ein Fenster, das nie verbindet — - // ärgerlich, aber harmlos. Ein fehlender Fingerabdruck ist das - // Gegenteil: er übergäbe die Prüfung an Code, der noch gar nicht - // existiert, und dessen naheliegendste Fassung ("kein Fingerabdruck - // im Ticket → nicht prüfen") eine ungepinnte Root-SSH-Sitzung im - // Tunnel wäre — genau das, was die Spec mit „Fingerabdruck geprüft" - // ausdrücklich ausschließt. Die Prüfung gehört hierher, wo der Host - // ohnehin schon feststeht, nicht in einen Container, der dem Ticket - // nur noch glauben kann. - if (blank($host->wg_ip) || blank($host->ssh_host_key)) { - throw new RuntimeException("Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."); - } - $ticket = bin2hex(random_bytes(32)); Redis::connection('cache')->setex(self::PREFIX.$ticket, self::TTL_SECONDS, json_encode([ diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 73a148b..1ef7e09 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -136,8 +136,52 @@ return [ 'terminal' => [ 'open' => 'Terminal', - 'connecting' => 'Verbinde mit :host …', 'hint' => 'Öffnet ein eigenes Fenster mit einer Root-Sitzung auf diesem Host.', + + // Was auf der Bühne steht — dem Bild, das über dem Terminal liegt. + // Beim Verbinden, beim Ende, beim Abbruch dasselbe Bild, anderer Text. + // Die vier unteren kennt erst der Browser; sie reisen als JSON mit. + 'stage' => [ + 'connecting' => [ + 'title' => 'Verbinde mit :host', + 'note' => 'Das Einmal-Ticket wird eingelöst und die Sitzung aufgebaut.', + ], + 'ended' => [ + 'title' => 'Sitzung beendet', + 'note' => 'Die Verbindung zu :host wurde geschlossen. Ein neuer Anlauf öffnet eine frische Sitzung.', + ], + 'lost' => [ + 'title' => 'Keine Verbindung', + 'note' => 'Zu :host kam keine Sitzung zustande. Läuft der Terminal-Dienst, und steht der Tunnel?', + ], + 'rejected' => [ + '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.', + ], + '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.', + ], + 'retry' => 'Neu verbinden', + ], + + // Gründe, die schon der Server kennt. Sie stehen sofort da, statt dass + // ein Fenster aufgeht und auf eine Verbindung wartet, die es nicht + // geben kann. + 'problem' => [ + 'no_key' => [ + 'title' => 'Kein SSH-Schlüssel hinterlegt', + 'note' => 'Ohne den Flottenschlüssel kann sich niemand an einem Host anmelden. Er wird in den Zugangsdaten hinterlegt.', + ], + 'not_ready' => [ + 'title' => 'Dieser Host ist noch nicht so weit', + 'note' => 'Es fehlt die Tunneladresse oder der geprüfte Fingerabdruck. Beides entsteht während der Übernahme — solange die nicht durch ist, kann keine sichere Sitzung entstehen.', + ], + 'unknown' => [ + 'title' => 'Das Terminal lässt sich gerade nicht öffnen', + 'note' => 'Beim Ausstellen des Tickets ist etwas schiefgegangen. Der Vorfall steht im Protokoll; ein neuer Anlauf ist einen Versuch wert.', + ], + ], ], 'remove' => 'Host entfernen', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index e2e5d0f..540c384 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -136,8 +136,52 @@ return [ 'terminal' => [ 'open' => 'Terminal', - 'connecting' => 'Connecting to :host …', 'hint' => 'Opens a separate window with a root session on this host.', + + // The stage — the panel that sits on top of the terminal. Same panel + // while connecting, when the session ends and when it breaks; only the + // words change. The lower four are only ever needed by the browser and + // travel to it as JSON. + 'stage' => [ + 'connecting' => [ + 'title' => 'Connecting to :host', + 'note' => 'Redeeming the one-time ticket and opening the session.', + ], + 'ended' => [ + 'title' => 'Session ended', + 'note' => 'The connection to :host was closed. Another attempt opens a fresh session.', + ], + 'lost' => [ + 'title' => 'No connection', + 'note' => 'No session came up on :host. Is the terminal service running, and is the tunnel up?', + ], + 'rejected' => [ + '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.', + ], + '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.', + ], + 'retry' => 'Reconnect', + ], + + // Reasons the server already knows. They appear straight away instead + // of a window that opens and waits for a connection that cannot happen. + 'problem' => [ + 'no_key' => [ + 'title' => 'No SSH key on file', + 'note' => 'Without the fleet key nobody can sign in to a host. It is stored under credentials.', + ], + 'not_ready' => [ + 'title' => 'This host is not ready yet', + 'note' => 'The tunnel address or the verified fingerprint is missing. Both appear during takeover — until that is through, no secure session can exist.', + ], + 'unknown' => [ + 'title' => 'The terminal cannot be opened right now', + 'note' => 'Something went wrong issuing the ticket. The incident is in the log; another attempt is worth a try.', + ], + ], ], 'remove' => 'Remove host', diff --git a/resources/js/terminal.js b/resources/js/terminal.js index e591547..e8fa941 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -13,13 +13,41 @@ import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' const root = document.querySelector('[data-terminal]') -if (root) { - const splash = root.querySelector('[data-terminal-splash]') +const stage = root?.querySelector('[data-terminal-stage]') + +// Der Knopf gehört zur Bühne, nicht zur Sitzung, und wird deshalb VOR der +// Weiche unten verdrahtet: bei einem Grund, den schon der Server kennt, gibt es +// gar kein Ticket — und der Knopf steht trotzdem da und muss tun. +// +// Neu laden statt neu verbinden, weil ein Ticket dreißig Sekunden gilt und genau +// einmal: derselbe Wert ein zweites Mal wäre kein zweiter Versuch. +stage?.querySelector('[data-stage-retry-button]')?.addEventListener('click', () => location.reload()) + +// Ohne Ticket gibt es nichts zu verbinden: die Seite hat dann schon +// hingeschrieben, woran es liegt (HostTerminal::mount, `$problem`), und ein +// Socket, den wir trotzdem aufmachten, überschriebe diesen Satz mit einem +// zweiten, ungenaueren. +if (root && root.dataset.ticket) { const screen = root.querySelector('[data-terminal-screen]') - // Der Name allein — nie die Tunneladresse oder der Schlüssel, die sieht - // dieses Fenster ohnehin nie. Nur in den Meldungen unten, damit klar ist, - // WELCHER Host gemeint ist, falls je mehr als ein Terminalfenster offen steht. - const host = root.dataset.host + const stageTitle = stage.querySelector('[data-stage-title]') + const stageNote = stage.querySelector('[data-stage-note]') + const stageRetry = stage.querySelector('[data-stage-retry]') + // Der Hostname steht nirgends im Skript: er ist schon in die Meldungen + // eingesetzt, die der Server mitgegeben hat. Tunneladresse und Schlüssel + // sieht dieses Fenster ohnehin nie. + const messages = JSON.parse(stage.dataset.stageMessages) + + /** + * Die Bühne zeigen. `which` ist einer der Schlüssel aus + * `data-stage-messages`; `offerRetry` entscheidet über den Knopf — er kommt + * nur, wo ein zweiter Anlauf überhaupt etwas ändern kann. + */ + const showStage = (which, offerRetry) => { + stageTitle.textContent = messages[which].title + stageNote.textContent = messages[which].note + stageRetry.classList.toggle('hidden', !offerRetry) + stage.classList.remove('hidden') + } const term = new Terminal({ convertEol: true, fontFamily: 'ui-monospace, monospace', fontSize: 13 }) const fit = new FitAddon() @@ -38,26 +66,24 @@ if (root) { const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws`, [root.dataset.ticket]) socket.binaryType = 'arraybuffer' - // Der Vorspann weicht beim ERSTEN BYTE, nicht bei `onopen`. - let opened = false + // xterm hängt sofort im Schirm, nicht erst beim ersten Byte: das Element hat + // von Anfang an seine volle Größe (die Bühne liegt darüber, nicht daneben), + // und `fit()` rechnet nur an einem Element richtig, das schon Maße hat. + term.open(screen) + fit.fit() + + // Die Bühne weicht beim ERSTEN BYTE, nicht bei `onopen`: ein stehender + // Socket sagt noch nichts darüber, ob am anderen Ende eine Sitzung entstanden + // ist, und der Unterschied fiele sonst erst auf, wenn jemand ins Leere tippt. + let received = false const reveal = () => { - if (opened) return - opened = true - splash.classList.add('hidden') - screen.classList.remove('hidden') - term.open(screen) + if (received) return + received = true + stage.classList.add('hidden') fit.fit() } - // Ob je ein Byte ankam — getrennt von `opened`, denn auch ein Verbindungsfehler - // lässt den Vorspann weichen, um die Fehlermeldung zu zeigen, ohne dass je eine - // Sitzung entstand. Ohne dieses Merkmal feuert ein fehlgeschlagener Socket - // `error` UND danach `close`, und `onclose` behauptete eine beendete Sitzung, - // die nie begonnen hat. - let received = false - socket.onmessage = (event) => { - received = true reveal() // Kommt vom Container ein Textrahmen statt eines Binärrahmens, ist // `new Uint8Array(event.data)` bei einem String ein leeres Array — @@ -72,15 +98,27 @@ if (root) { term.onData((data) => socket.readyState === WebSocket.OPEN && socket.send(data)) - socket.onclose = () => { - if (!received) return - term.write(`\r\n\x1b[31m— Verbindung zu ${host} beendet —\x1b[0m\r\n`) + // Die Schließcodes kommen aus docker/terminal/bridge.py und sind die einzige + // Auskunft, die die Brücke über den Grund gibt — absichtlich: was genau am + // Host schiefging, steht in ihrem Log, nicht in diesem Fenster. + // + // 4401 Ticket unbekannt, abgelaufen oder schon eingelöst + // 4502 kein SSH zum Host (keine Antwort, oder Fingerabdruck weicht ab) + // + // Alles andere ist ein gewöhnliches Ende: die Gegenseite hat aufgelegt. + // Das kann die Sitzung selbst sein (`exit`), der Leerlaufwächter der Brücke + // oder eine Leitung, die abgerissen ist — für den Betreiber ist der + // Unterschied derselbe: hier läuft nichts mehr, und ein neuer Anlauf hilft. + socket.onclose = (event) => { + if (event.code === 4401) return showStage('rejected', true) + if (event.code === 4502) return showStage('unreachable', true) + + showStage(received ? 'ended' : 'lost', true) } - socket.onerror = () => { - reveal() - term.write(`\r\n\x1b[31m— Verbindung zu ${host} nicht möglich —\x1b[0m\r\n`) - } + // Kein `onerror`: ein Fehler zieht immer ein `close` nach sich, und dort oben + // steht die Meldung — mit einem Code, der etwas aussagt. Zwei Handler + // schrieben zwei Sätze über denselben Vorgang, und genau das stand vorher da. - addEventListener('resize', () => opened && fit.fit()) + addEventListener('resize', () => fit.fit()) } diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index b89f718..de826e0 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -29,22 +29,22 @@ {{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }} @endif - {{-- Ohne Tunneladresse oder Fingerabdruck kann keine Sitzung entstehen — - TerminalTicket::issue() weist das mit einer RuntimeException zurück - (Aufgabe 1). Ein Knopf, der das verlässlich auslöst, ist schlechter - als gar keiner. --}} + {{-- Der Knopf steht immer da, auch wenn dem Host noch die Tunneladresse + oder der Fingerabdruck fehlt. Die Seite dahinter geht nicht mehr mit + einer Laravel-Fehlerseite auf, sondern schreibt hin, was fehlt + (`HostTerminal::mount()`, `hosts.terminal.problem.*`) — und damit ist + ein fehlender Knopf die schlechtere Auskunft von beiden. Ausführlich + steht das in hosts.blade.php an derselben Stelle. --}} @can('hosts.manage') - @if ($host->wg_ip && $host->ssh_host_key) - {{-- href direkt am Knopf, nicht als drumherum: interaktiver - Inhalt in einem Link ist ungültiges HTML, und Mittelklick/ - Statuszeile hingen sonst an einem Element, auf dem der - Zeiger nie landet (components/ui/button.blade.php Zeile 6-9). --}} - - {{ __('hosts.terminal.open') }} - - @endif + {{-- href direkt am Knopf, nicht als drumherum: interaktiver + Inhalt in einem Link ist ungültiges HTML, und Mittelklick/ + Statuszeile hingen sonst an einem Element, auf dem der + Zeiger nie landet (components/ui/button.blade.php Zeile 6-9). --}} + + {{ __('hosts.terminal.open') }} + @endcan {{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft dieselbe, und zwei Fassungen liefen auseinander. --}} diff --git a/resources/views/livewire/admin/host-terminal.blade.php b/resources/views/livewire/admin/host-terminal.blade.php index 658ffb6..e8c6ed2 100644 --- a/resources/views/livewire/admin/host-terminal.blade.php +++ b/resources/views/livewire/admin/host-terminal.blade.php @@ -1,28 +1,116 @@ -{{-- Der Vorspann steht, bis wirklich Daten fließen — nicht, bis der Socket - offen ist. Ein Socket, der steht, sagt noch nichts darüber, ob am anderen - Ende eine Sitzung entstanden ist; der Unterschied fällt sonst erst auf, - wenn jemand ins Leere tippt. --}} -
+{{-- Die Bühne liegt ÜBER dem Terminal, nicht daneben. -
- {{-- Schriftgrafik, keine Bilddatei — deshalb `role="img"` mit einem - lesbaren Namen statt `aria-hidden`: ein Screenreader soll "CluPilot" - hören, nicht die einzelnen Zeichen der Zeichnung. --}} - -

{{ __('hosts.terminal.connecting', ['host' => $host->name]) }}

-
+ Bis hierher waren es zwei Geschwister, von denen abwechselnd eines + `hidden` trug: erst der Vorspann, dann der Schirm. Das trug genau einmal — + beim Aufbau. Für alles danach fehlte die Rückfahrkarte, und was der + Betreiber beim Abbruch sah, war eine rote ANSI-Zeile mitten in der eigenen + Ausgabe: die einzige Stelle im Produkt, an der eine Meldung nicht gestaltet + war, sondern getippt. + + Als Überlagerung kann dieselbe Bühne dreimal auftreten — beim Verbinden, + beim Abbruch, beim Ende — und die Sitzung darunter bleibt stehen, statt + gelöscht zu werden. Was auf ihr steht, kommt aus den Sprachdateien; die + Meldungen, die erst der Browser kennt, reisen als JSON in + `data-stage-messages` mit, damit auch sie dort stehen und nicht im Skript. --}} +@php + // Ein Grund, den schon der Server kennt (kein Schlüssel hinterlegt, Host + // noch nicht übernommen), steht sofort auf der Bühne. Sonst beginnt sie mit + // dem Vorspann. Derselbe Bau, anderer Text, anderer Ton. + $stageTitle = $problem !== '' + ? __('hosts.terminal.problem.'.$problem.'.title') + : __('hosts.terminal.stage.connecting.title', ['host' => $host->name]); + + $stageNote = $problem !== '' + ? __('hosts.terminal.problem.'.$problem.'.note') + : __('hosts.terminal.stage.connecting.note'); + + // Was der Browser erst später braucht. Welcher Schlüssel wann gilt, steht + // in resources/js/terminal.js neben den Schließcodes der Brücke. + $messages = [ + 'ended' => [ + 'title' => __('hosts.terminal.stage.ended.title'), + 'note' => __('hosts.terminal.stage.ended.note', ['host' => $host->name]), + ], + 'lost' => [ + 'title' => __('hosts.terminal.stage.lost.title'), + 'note' => __('hosts.terminal.stage.lost.note', ['host' => $host->name]), + ], + 'rejected' => [ + 'title' => __('hosts.terminal.stage.rejected.title'), + 'note' => __('hosts.terminal.stage.rejected.note'), + ], + 'unreachable' => [ + 'title' => __('hosts.terminal.stage.unreachable.title', ['host' => $host->name]), + 'note' => __('hosts.terminal.stage.unreachable.note', ['host' => $host->name]), + ], + ]; +@endphp +
{{-- wire:ignore: xterms eigener DOM-Baum liegt in einem von Livewire verwalteten Baum. Heute hat die Komponente keine Aktion, die einen Re-Render auslöst — aber das erste `wire:`-irgendwas auf dieser Seite würde xterm sonst beim nächsten Umlauf löschen. --}} - +
+ +
+ + {{-- Schriftgrafik, keine Bilddatei — deshalb `role="img"` mit einem + lesbaren Namen statt `aria-hidden`: ein Screenreader soll "CluPilot" + hören, nicht die einzelnen Zeichen der Zeichnung. + + Vollblöcke, nicht `_ / \ |`. Die erste Fassung war eine gewöhnliche + Figlet-Zeichnung, und die funktioniert nur in einer echten + Terminalschrift: sie setzt darauf, dass der Unterstrich einer Zeile + den Strich der nächsten berührt. In IBM Plex Mono sitzt der + Unterstrich tiefer — bei enger Zeilenhöhe verschmierte das Wort, bei + weiter zerfiel es in Einzelteile. Beides im Browser durchgemessen, + beides unlesbar. `█` füllt seine Zelle ganz aus und stapelt deshalb + in jeder Schrift sauber. + + Immer Akzentfarbe, auch im Fehlerfall: Dunkelrot auf Fast-Schwarz + hatte kaum Kontrast, und eine Wortmarke, die ihre Farbe wechselt, ist + ohnehin keine Statuslampe — was los ist, sagt die Zeile darunter. + + 8px am Telefon: bei 11px ist die Zeichnung 403px breit und schöbe auf + einem schmalen Schirm die Seite zur Seite. --}} + + + {{-- aria-live: die Bühne tauscht ihren Text aus, ohne dass die Seite neu + lädt. Ohne diesen Hinweis erführe ein Screenreader vom Abbruch + nichts — für ihn stünde das Fenster weiter beim Vorspann. --}} +
+

{{ $stageTitle }}

+

{{ $stageNote }}

+
+ + {{-- Neu laden statt neu verbinden: das Ticket gilt dreißig Sekunden und + genau einmal, ein zweiter Anlauf mit demselben wäre keiner. Der + Knopf ist beim Vorspann verborgen und kommt erst, wenn ein neuer + Versuch überhaupt etwas ändern kann — bei „kein Schlüssel + hinterlegt" ändert er nichts, dort bleibt er weg. Was er tut, hängt + 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. --}} +
$problem !== 'unknown'])> + + {{ __('hosts.terminal.stage.retry') }} + +
+
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index a299347..4f7e02e 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -150,22 +150,27 @@ {{ __('hosts.status.'.$host->status) }} - {{-- Ohne Tunneladresse oder Fingerabdruck kann keine Sitzung - entstehen — TerminalTicket::issue() weist das mit einer - RuntimeException zurück (Aufgabe 1). Ein Knopf, der das - verlässlich auslöst, ist schlechter als gar keiner. --}} + {{-- Der Knopf steht auf JEDER Zeile, auch wenn dem Host noch die + Tunneladresse oder der Fingerabdruck fehlt. + + Vorher verschwand er dann, mit der Begründung, ein Knopf, der + verlässlich in eine Ausnahme läuft, sei schlechter als gar + keiner. Das stimmte, solange die Seite dahinter mit einer + Laravel-Fehlerseite aufging. Sie tut es nicht mehr — sie + schreibt hin, was fehlt (`HostTerminal::mount()`, + `hosts.terminal.problem.*`). Damit ist der fehlende Knopf die + schlechtere Auskunft von beiden: er sah aus wie „hier gibt es + kein Terminal", nicht wie „hier noch nicht, und zwar deshalb". --}} @can('hosts.manage') - @if ($host->wg_ip && $host->ssh_host_key) - {{-- href direkt am Knopf, nicht als
drumherum: interaktiver - Inhalt in einem Link ist ungültiges HTML, und Mittelklick/ - Statuszeile hingen sonst an einem Element, auf dem der - Zeiger nie landet (components/ui/button.blade.php Zeile 6-9). --}} - - {{ __('hosts.terminal.open') }} - - @endif + {{-- href direkt am Knopf, nicht als drumherum: interaktiver + Inhalt in einem Link ist ungültiges HTML, und Mittelklick/ + Statuszeile hingen sonst an einem Element, auf dem der + Zeiger nie landet (components/ui/button.blade.php Zeile 6-9). --}} + + {{ __('hosts.terminal.open') }} + @endcan diff --git a/tests/Feature/Admin/HostTerminalTest.php b/tests/Feature/Admin/HostTerminalTest.php index 3a26a2d..b91c38b 100644 --- a/tests/Feature/Admin/HostTerminalTest.php +++ b/tests/Feature/Admin/HostTerminalTest.php @@ -161,7 +161,7 @@ it('zeigt den Vorspann und reicht das Ticket weiter, aber niemals den Schlüssel // Der Vorspann steht da, bevor irgendetwas verbindet. expect($html)->toContain('CluPilot') - ->and($html)->toContain('data-terminal-splash') + ->and($html)->toContain('data-terminal-stage') // Das Ticket selbst muss im Markup landen — sonst bewiese dieser Test // trotz seines Namens nie, dass issue() etwas Brauchbares liefert. // 64 Hexzeichen: TerminalTicket::issue() aus Aufgabe 1 bildet das @@ -172,6 +172,39 @@ it('zeigt den Vorspann und reicht das Ticket weiter, aber niemals den Schlüssel ->and($html)->not->toContain($host->wg_ip); }); +it('setzt in jede Meldung, die der Browser später zeigt, den Hostnamen wirklich ein', function () { + // Gefunden beim Hinsehen, nicht beim Testen: die Überschrift zum Abbruch + // stand als „:host antwortet nicht" auf dem Schirm — der Platzhalter war + // beim Übersetzen ohne Ersetzung geblieben. + // + // Ein Test, der `toContain(__('…title'))` schriebe, hätte das nie gefunden: + // er vergliche ":host" mit ":host" und wäre grün. Deshalb prüft dieser hier + // das Ergebnis — Name drin, Doppelpunkt-Platzhalter draußen. + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + + $html = $this->actingAs(admin(), 'operator') + ->get(route('admin.hosts.terminal', ['host' => $host->uuid])) + ->getContent(); + + // Die Meldungen reisen als JSON im data-Attribut mit; im HTML stehen die + // Anführungszeichen deshalb als ". + expect($html)->toContain('data-stage-messages=') + ->and($html)->not->toContain(':host'); + + // Und der Name steht wirklich drin — sonst wäre die Zeile oben auch dann + // grün, wenn gar keine Meldung mitkäme. + // `Str::between` schnitte bis zum LETZTEN Anführungszeichen der Seite — + // gebraucht wird das erste, also das Ende des Attributs. Innerhalb des + // Attributs steht ohnehin `"`, nie ein rohes Zeichen. + $messages = json_decode(html_entity_decode( + Str::before(Str::after($html, 'data-stage-messages="'), '"'), ENT_QUOTES + ), true); + + expect($messages['unreachable']['title'])->toContain($host->name) + ->and($messages['ended']['note'])->toContain($host->name) + ->and($messages['lost']['note'])->toContain($host->name); +}); + it('bietet den Terminal-Knopf nur dem, der ihn drücken darf', function () { $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); @@ -199,15 +232,57 @@ it('prüft die Berechtigung vor dem Nachschlagen — eine erfundene UUID meldet ->assertForbidden(); }); -it('zeigt den Terminal-Knopf nicht für einen Host ohne Tunneladresse oder Fingerabdruck', function () { - // Mitten im Onboarding: die Adresse steht noch nicht, der Fingerabdruck - // auch nicht (Host::factory() ohne active()). Ein Knopf, der hier - // erscheint, führte in genau die unbehandelte Ausnahme, die issue() oben - // bewusst wirft — der Knopf bleibt deshalb weg, statt in eine Sackgasse - // zu führen. - Host::factory()->create(); +it('zeigt den Terminal-Knopf auch für einen Host, der noch nicht so weit ist', function () { + // Mitten in der Übernahme: die Tunneladresse steht noch nicht, der + // Fingerabdruck auch nicht (Host::factory() ohne active()). + // + // Der Knopf verschwand hier einmal, weil die Seite dahinter mit einer + // unbehandelten Ausnahme aufging. Das tut sie nicht mehr — sie schreibt + // hin, was fehlt. Ein fehlender Knopf sähe aus wie „hier gibt es kein + // Terminal" statt „hier noch nicht, und zwar deshalb". + $host = Host::factory()->create(); $html = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent(); - expect($html)->not->toContain(__('hosts.terminal.open')); + expect($html)->toContain(route('admin.hosts.terminal', ['host' => $host->uuid])); +}); + +it('geht bei einem Host ohne Fingerabdruck nicht mit einer Fehlerseite auf, sondern sagt warum', function () { + // Das war der eigentliche Fehler: TerminalTicket::issue() warf, niemand + // fing es, und der Betreiber bekam Laravels Fehlerseite — Klassenname, + // Dateipfad, Zeilennummer, Quelltextauszug — in einem Fenster, das er über + // einen Knopf im eigenen Produkt geöffnet hatte. + $host = Host::factory()->create(['wg_ip' => null, 'ssh_host_key' => null]); + + $html = $this->actingAs(admin(), 'operator') + ->get(route('admin.hosts.terminal', ['host' => $host->uuid])) + ->assertOk() + ->getContent(); + + expect($html)->toContain(__('hosts.terminal.problem.not_ready.title')) + // Ohne Grund kein Ticket: ein Fenster, das trotzdem eines bekäme, + // machte einen Socket auf und überschriebe den Satz oben mit einem + // zweiten, ungenaueren. + ->and($html)->toContain('data-ticket=""') + ->and($html)->not->toContain('RuntimeException'); +}); + +it('sagt auch dann warum, wenn gar kein Flottenschlüssel hinterlegt ist', function () { + // Derselbe Weg, anderer Grund — und der andere Grund ist der, der ALLE + // Hosts auf einmal betrifft. Er darf nicht als „dieser Host ist noch nicht + // so weit" erscheinen, sonst sucht jemand am falschen Ende. + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + + // SecretVault::forget() fällt auf .env zurück, deshalb auch die + // Konfiguration leeren (Muster aus OnboardingChecksTest). + app(SecretVault::class)->forget('ssh.private_key'); + config()->set('provisioning.ssh.private_key', ''); + + $html = $this->actingAs(admin(), 'operator') + ->get(route('admin.hosts.terminal', ['host' => $host->uuid])) + ->assertOk() + ->getContent(); + + expect($html)->toContain(__('hosts.terminal.problem.no_key.title')) + ->and($html)->not->toContain(__('hosts.terminal.problem.not_ready.title')); });