From 4e52c35f245fe446c05d9d079cb3225301f221cc Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 2 Aug 2026 18:32:58 +0200 Subject: [PATCH] Terminal Fix-Runde 1: Ticket-Zusicherung im Test, ehrliche Kommentare, ein Ereignis eine Meldung MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 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 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 --- resources/js/terminal.js | 32 +++++++++++++++---- resources/views/layouts/bare.blade.php | 5 ++- .../livewire/admin/host-detail.blade.php | 16 ++++++---- .../livewire/admin/host-terminal.blade.php | 6 +++- .../views/livewire/admin/hosts.blade.php | 16 ++++++---- tests/Feature/Admin/HostTerminalTest.php | 29 +++++++++++++++-- vite.config.js | 9 ++++-- 7 files changed, 84 insertions(+), 29 deletions(-) diff --git a/resources/js/terminal.js b/resources/js/terminal.js index a08602d..84a4358 100644 --- a/resources/js/terminal.js +++ b/resources/js/terminal.js @@ -1,9 +1,12 @@ /* * Das Terminal im eigenen Fenster. * - * Eigener Einstiegspunkt, nicht Teil von app.js: diese Seite lädt weder - * Livewire noch Chart.js, und app.js zöge beides mit — auf einer Seite, die - * eine WebSocket-Verbindung und ein Terminal ist, sonst nichts. + * Eigener Einstiegspunkt, nicht Teil von app.js: nicht weil diese Seite ohne + * Livewire liefe — sie ist eine Vollseiten-Livewire-Komponente und lädt + * ``, das app.js über bare.blade.php ohnehin mitzieht —, + * sondern damit der Terminalcode (xterm.js) nicht in app.js landet und die + * übrigen Konsolenseiten nicht mitschleppen, und umgekehrt app.js nicht in + * dieses schlanke Fenster. */ import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' @@ -13,6 +16,10 @@ const root = document.querySelector('[data-terminal]') if (root) { const splash = root.querySelector('[data-terminal-splash]') 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 term = new Terminal({ convertEol: true, fontFamily: 'ui-monospace, monospace', fontSize: 13 }) const fit = new FitAddon() @@ -33,21 +40,32 @@ if (root) { 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() - term.write(new Uint8Array(event.data)) + // Kommt vom Container ein Textrahmen statt eines Binärrahmens, ist + // `new Uint8Array(event.data)` bei einem String ein leeres Array — + // `binaryType = 'arraybuffer'` oben regelt nur Binärrahmen. + term.write(typeof event.data === 'string' ? event.data : new Uint8Array(event.data)) } term.onData((data) => socket.readyState === WebSocket.OPEN && socket.send(data)) socket.onclose = () => { - reveal() - term.write('\r\n\x1b[31m— Verbindung beendet —\x1b[0m\r\n') + if (!received) return + term.write(`\r\n\x1b[31m— Verbindung zu ${host} beendet —\x1b[0m\r\n`) } socket.onerror = () => { reveal() - term.write('\r\n\x1b[31m— Verbindung nicht möglich —\x1b[0m\r\n') + term.write(`\r\n\x1b[31m— Verbindung zu ${host} nicht möglich —\x1b[0m\r\n`) } addEventListener('resize', () => opened && fit.fit()) diff --git a/resources/views/layouts/bare.blade.php b/resources/views/layouts/bare.blade.php index 54e7a58..d65f1b7 100644 --- a/resources/views/layouts/bare.blade.php +++ b/resources/views/layouts/bare.blade.php @@ -2,7 +2,10 @@ - {{-- Eigener Einstiegspunkt, nicht app.js: siehe resources/js/terminal.js. --}} + {{-- Eigener Einstiegspunkt, damit der Terminalcode nicht in app.js landet + (und umgekehrt) — siehe Kopfkommentar in resources/js/terminal.js. + oben zieht app.js über sein eigenes @vite trotzdem + mit; das ist unabhängig von diesem Aufruf und bleibt so. --}} @vite('resources/js/terminal.js') {{-- diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index 4ee74a5..b89f718 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -35,13 +35,15 @@ als gar keiner. --}} @can('hosts.manage') @if ($host->wg_ip && $host->ssh_host_key) - - - {{ __('hosts.terminal.open') }} - - + {{-- 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 @endcan {{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft diff --git a/resources/views/livewire/admin/host-terminal.blade.php b/resources/views/livewire/admin/host-terminal.blade.php index 32f70e0..658ffb6 100644 --- a/resources/views/livewire/admin/host-terminal.blade.php +++ b/resources/views/livewire/admin/host-terminal.blade.php @@ -20,5 +20,9 @@

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

- + {{-- 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. --}} + diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index 0ded973..a299347 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -156,13 +156,15 @@ verlässlich auslöst, ist schlechter als gar keiner. --}} @can('hosts.manage') @if ($host->wg_ip && $host->ssh_host_key) -
- - {{ __('hosts.terminal.open') }} - - + {{-- 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 @endcan diff --git a/tests/Feature/Admin/HostTerminalTest.php b/tests/Feature/Admin/HostTerminalTest.php index 6c919fa..3a26a2d 100644 --- a/tests/Feature/Admin/HostTerminalTest.php +++ b/tests/Feature/Admin/HostTerminalTest.php @@ -5,6 +5,7 @@ use App\Models\Operator; use App\Services\Secrets\SecretVault; use App\Services\Terminal\TerminalTicket; use Illuminate\Support\Facades\Redis; +use Illuminate\Support\Str; beforeEach(function () { // Der Schlüssel, den das Ticket mitgeben soll. Ohne ihn stünde im Ticket @@ -160,20 +161,42 @@ 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') + // 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 + // Ticket über bin2hex(random_bytes(32)). + ->and($html)->toMatch('/data-ticket="[0-9a-f]{64}"/') // Und nichts, was die Brücke geheim halten muss. ->and($html)->not->toContain('BEGIN OPENSSH PRIVATE KEY') ->and($html)->not->toContain($host->wg_ip); }); it('bietet den Terminal-Knopf nur dem, der ihn drücken darf', function () { - Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + + // Auf das nackte Wort „Terminal" zu prüfen wäre dieselbe Falle wie + // assertDontSee('179') — es kann durch etwas anderes auf der Seite erfüllt + // sein. Der Link zu genau diesem Host kann das nicht. + $link = route('admin.hosts.terminal', ['host' => $host->uuid]); $erlaubt = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent(); - expect($erlaubt)->toContain(__('hosts.terminal.open')); + expect($erlaubt)->toContain($link); $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator'); $verwehrt = $this->get(route('admin.hosts'))->getContent(); - expect($verwehrt)->not->toContain(__('hosts.terminal.open')); + expect($verwehrt)->not->toContain($link); +}); + +it('prüft die Berechtigung vor dem Nachschlagen — eine erfundene UUID meldet 403, nie 404', function () { + // Der Sicherheitsriegel dieser Aufgabe: authorize() steht in mount() VOR + // dem Host::query()->where('uuid', …)->firstOrFail(). Käme die Datenbank + // zuerst dran, würde eine UUID, die es gar nicht gibt, mit 404 antworten — + // und ein 404 gegen ein 403 verriete einem Operator ohne Berechtigung, + // dass die Prüfreihenfolge übersprungen wurde. + $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator') + ->get(route('admin.hosts.terminal', ['host' => (string) Str::uuid()])) + ->assertForbidden(); }); it('zeigt den Terminal-Knopf nicht für einen Host ohne Tunneladresse oder Fingerabdruck', function () { diff --git a/vite.config.js b/vite.config.js index 1066bb1..5dc4d63 100644 --- a/vite.config.js +++ b/vite.config.js @@ -18,9 +18,12 @@ export default defineConfig({ // startet Alpine, und auf Konsolen- und Portalseiten tut das schon // Livewire. Zwei Starts zerlegen beide. Siehe resources/js/site.js. // - // terminal.js ist aus demselben Grund ein eigener Einstiegspunkt: - // das Terminalfenster hat weder Livewire noch Chart.js nötig, die - // app.js sonst mitzöge. Siehe resources/js/terminal.js. + // terminal.js ist ebenfalls ein eigener Einstiegspunkt — nicht + // weil die Terminalseite ohne Livewire liefe (sie ist eine + // Vollseiten-Livewire-Komponente), sondern damit xterm.js nicht in + // app.js landet und app.js nicht in ein Fenster, das nur eine + // WebSocket-Verbindung und ein Terminal zeigt. Siehe + // resources/js/terminal.js. input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/site.js', 'resources/js/terminal.js'], refresh: true, }),