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
parent
941950584c
commit
4e52c35f24
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
{{--
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
|
|
|
|||
Loading…
Reference in New Issue