Terminal Fix-Runde 1: Ticket-Zusicherung im Test, ehrliche Kommentare, ein Ereignis eine Meldung

- 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>
main
nexxo 2026-08-02 18:32:58 +02:00
parent 941950584c
commit 4e52c35f24
7 changed files with 84 additions and 29 deletions

View File

@ -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
* `<x-shell.head>`, 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())

View File

@ -2,7 +2,10 @@
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="h-full">
<head>
<x-shell.head :title="$title ?? 'Terminal'" />
{{-- 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.
<x-shell.head> 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')
</head>
{{--

View File

@ -35,13 +35,15 @@
als gar keiner. --}}
@can('hosts.manage')
@if ($host->wg_ip && $host->ssh_host_key)
<a href="{{ route('admin.hosts.terminal', ['host' => $host->uuid]) }}"
target="_blank" rel="noopener"
title="{{ __('hosts.terminal.hint') }}">
<x-ui.button variant="secondary" size="sm">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
</a>
{{-- href direkt am Knopf, nicht als <a> 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). --}}
<x-ui.button variant="secondary" size="sm"
:href="route('admin.hosts.terminal', ['host' => $host->uuid])"
target="_blank" rel="noopener" :title="__('hosts.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
@endif
@endcan
{{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft

View File

@ -20,5 +20,9 @@
<p class="sr-only">{{ __('hosts.terminal.connecting', ['host' => $host->name]) }}</p>
</div>
<div data-terminal-screen class="hidden flex-1"></div>
{{-- 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. --}}
<div data-terminal-screen wire:ignore class="hidden flex-1"></div>
</div>

View File

@ -156,13 +156,15 @@
verlässlich auslöst, ist schlechter als gar keiner. --}}
@can('hosts.manage')
@if ($host->wg_ip && $host->ssh_host_key)
<a href="{{ route('admin.hosts.terminal', ['host' => $host->uuid]) }}"
target="_blank" rel="noopener"
title="{{ __('hosts.terminal.hint') }}">
<x-ui.button variant="secondary" size="sm">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
</a>
{{-- href direkt am Knopf, nicht als <a> 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). --}}
<x-ui.button variant="secondary" size="sm"
:href="route('admin.hosts.terminal', ['host' => $host->uuid])"
target="_blank" rel="noopener" :title="__('hosts.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('hosts.terminal.open') }}
</x-ui.button>
@endif
@endcan
</td>

View File

@ -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 () {

View File

@ -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,
}),