From 941950584ccf27abcb8f53693d4960aaeeef5ac5 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 2 Aug 2026 18:18:22 +0200 Subject: [PATCH] Terminal: eigenes Fenster mit Vorspann, Knopf in Liste und Detailseite Aufgabe 2: alles, was der Betreiber sieht, noch ohne Container dahinter. Der Knopf bleibt fuer einen Host ohne Tunneladresse oder Fingerabdruck absichtlich unsichtbar, statt in eine unbehandelte RuntimeException aus Aufgabe 1 zu fuehren. Co-Authored-By: Claude Opus 5 --- app/Livewire/Admin/HostTerminal.php | 67 +++++++++++++++++++ lang/de/hosts.php | 6 ++ lang/en/hosts.php | 6 ++ package-lock.json | 17 +++++ package.json | 2 + resources/js/terminal.js | 54 +++++++++++++++ resources/views/layouts/bare.blade.php | 18 +++++ .../livewire/admin/host-detail.blade.php | 15 +++++ .../livewire/admin/host-terminal.blade.php | 24 +++++++ .../views/livewire/admin/hosts.blade.php | 23 +++++++ routes/admin.php | 4 ++ tests/Feature/Admin/HostTerminalTest.php | 53 +++++++++++++++ vite.config.js | 6 +- 13 files changed, 294 insertions(+), 1 deletion(-) create mode 100644 app/Livewire/Admin/HostTerminal.php create mode 100644 resources/js/terminal.js create mode 100644 resources/views/layouts/bare.blade.php create mode 100644 resources/views/livewire/admin/host-terminal.blade.php diff --git a/app/Livewire/Admin/HostTerminal.php b/app/Livewire/Admin/HostTerminal.php new file mode 100644 index 0000000..2df6530 --- /dev/null +++ b/app/Livewire/Admin/HostTerminal.php @@ -0,0 +1,67 @@ + …])` — bleibt + * unverändert, das betrifft nur, wie diese Klasse den Wert intern hält). + */ +#[Layout('layouts.bare')] +class HostTerminal extends Component +{ + public Host $record; + + public string $ticket = ''; + + public function mount(string $host): void + { + $this->authorize('hosts.manage'); + + $this->record = Host::query()->where('uuid', $host)->firstOrFail(); + + /** @var Operator $operator */ + $operator = auth('operator')->user(); + + $this->ticket = TerminalTicket::issue($this->record, $operator); + } + + public function render() + { + // 'host' ist der Name, den die Ansicht erwartet — die Eigenschaft + // heißt aus dem oben erklärten Grund anders. + return view('livewire.admin.host-terminal', ['host' => $this->record]) + ->title($this->record->name.' — Terminal'); + } +} diff --git a/lang/de/hosts.php b/lang/de/hosts.php index 63d494e..73a148b 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -134,6 +134,12 @@ return [ 'retry' => 'Erneut versuchen', 'error_title' => 'Onboarding fehlgeschlagen', + 'terminal' => [ + 'open' => 'Terminal', + 'connecting' => 'Verbinde mit :host …', + 'hint' => 'Öffnet ein eigenes Fenster mit einer Root-Sitzung auf diesem Host.', + ], + 'remove' => 'Host entfernen', 'remove_title' => 'Host entfernen?', 'remove_body' => 'Entfernt nur den CluPilot-Eintrag von :name. Der physische Server wird NICHT gelöscht oder zurückgesetzt.', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index dd2c82d..e2e5d0f 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -134,6 +134,12 @@ return [ 'retry' => 'Retry', 'error_title' => 'Onboarding failed', + 'terminal' => [ + 'open' => 'Terminal', + 'connecting' => 'Connecting to :host …', + 'hint' => 'Opens a separate window with a root session on this host.', + ], + 'remove' => 'Remove host', 'remove_title' => 'Remove host?', 'remove_body' => 'Removes only the CluPilot record for :name. The physical server is NOT deleted or wiped.', diff --git a/package-lock.json b/package-lock.json index ab416a7..d1a46f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-serif": "^5.3.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "alpinejs": "^3.15.12", "chart.js": "^4.5.1", "laravel-echo": "^2.4.0", @@ -498,6 +500,21 @@ "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==", "license": "MIT" }, + "node_modules/@xterm/addon-fit": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", + "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", + "license": "MIT" + }, + "node_modules/@xterm/xterm": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", + "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==", + "license": "MIT", + "workspaces": [ + "addons/*" + ] + }, "node_modules/alpinejs": { "version": "3.15.12", "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.12.tgz", diff --git a/package.json b/package.json index 74e728a..ab55197 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", "@fontsource/ibm-plex-serif": "^5.3.0", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "alpinejs": "^3.15.12", "chart.js": "^4.5.1", "laravel-echo": "^2.4.0", diff --git a/resources/js/terminal.js b/resources/js/terminal.js new file mode 100644 index 0000000..a08602d --- /dev/null +++ b/resources/js/terminal.js @@ -0,0 +1,54 @@ +/* + * 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. + */ +import { Terminal } from '@xterm/xterm' +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 screen = root.querySelector('[data-terminal-screen]') + + const term = new Terminal({ convertEol: true, fontFamily: 'ui-monospace, monospace', fontSize: 13 }) + const fit = new FitAddon() + term.loadAddon(fit) + + const scheme = location.protocol === 'https:' ? 'wss' : 'ws' + const socket = new WebSocket(`${scheme}://${location.host}/terminal/ws?t=${encodeURIComponent(root.dataset.ticket)}`) + socket.binaryType = 'arraybuffer' + + // Der Vorspann weicht beim ERSTEN BYTE, nicht bei `onopen`. + let opened = false + const reveal = () => { + if (opened) return + opened = true + splash.classList.add('hidden') + screen.classList.remove('hidden') + term.open(screen) + fit.fit() + } + + socket.onmessage = (event) => { + reveal() + term.write(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') + } + + socket.onerror = () => { + reveal() + term.write('\r\n\x1b[31m— Verbindung 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 new file mode 100644 index 0000000..54e7a58 --- /dev/null +++ b/resources/views/layouts/bare.blade.php @@ -0,0 +1,18 @@ + + + + + {{-- Eigener Einstiegspunkt, nicht app.js: siehe resources/js/terminal.js. --}} + @vite('resources/js/terminal.js') + +{{-- + Das schlichte Layout — für Seiten, die kein Konsolen-Layout wollen. + + Kein Kopf, keine Seitenleiste, keine Statusleiste: das Terminal bekommt die + volle Fläche eines eigenen Fensters. `layouts/admin.blade.php` bringt all + das mit; hier fehlt es mit Absicht. +--}} + + {{ $slot }} + + diff --git a/resources/views/livewire/admin/host-detail.blade.php b/resources/views/livewire/admin/host-detail.blade.php index cfa76b1..4ee74a5 100644 --- a/resources/views/livewire/admin/host-detail.blade.php +++ b/resources/views/livewire/admin/host-detail.blade.php @@ -29,6 +29,21 @@ {{ $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. --}} + @can('hosts.manage') + @if ($host->wg_ip && $host->ssh_host_key) + + + {{ __('hosts.terminal.open') }} + + + @endif + @endcan {{-- Die Bedingung steht am Bauteil, nicht hier: `issue()` prüft dieselbe, und zwei Fassungen liefen auseinander. --}} @if (\App\Livewire\Admin\ReissueTakeover::eligible($host)) diff --git a/resources/views/livewire/admin/host-terminal.blade.php b/resources/views/livewire/admin/host-terminal.blade.php new file mode 100644 index 0000000..32f70e0 --- /dev/null +++ b/resources/views/livewire/admin/host-terminal.blade.php @@ -0,0 +1,24 @@ +{{-- 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. --}} +
+ +
+ {{-- 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]) }}

+
+ + +
diff --git a/resources/views/livewire/admin/hosts.blade.php b/resources/views/livewire/admin/hosts.blade.php index 3767ed2..0ded973 100644 --- a/resources/views/livewire/admin/hosts.blade.php +++ b/resources/views/livewire/admin/hosts.blade.php @@ -81,6 +81,12 @@ {{ __('hosts.col.instances') }} {{ __('hosts.col.capacity') }} {{ __('hosts.col.status') }} + {{-- 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. --}} + @@ -143,6 +149,23 @@ @endif {{ __('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. --}} + @can('hosts.manage') + @if ($host->wg_ip && $host->ssh_host_key) + + + {{ __('hosts.terminal.open') }} + + + @endif + @endcan + @endforeach diff --git a/routes/admin.php b/routes/admin.php index 76289f8..c8cbb56 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -35,6 +35,10 @@ Route::get('/instances', Admin\Instances::class)->name('instances'); Route::get('/hosts', Admin\Hosts::class)->name('hosts'); Route::get('/hosts/create', Admin\HostCreate::class)->name('hosts.create'); Route::get('/hosts/{host}', Admin\HostDetail::class)->name('hosts.show'); +// Das Terminal öffnet sich in einem eigenen Fenster und hat deshalb keinen +// Navigationseintrag — es wird nur aus der Liste und der Detailseite heraus +// aufgerufen. `hosts.manage` prüft das Bauteil selbst in mount(). +Route::get('/hosts/{host}/terminal', Admin\HostTerminal::class)->name('hosts.terminal'); Route::get('/datacenters', Admin\Datacenters::class)->name('datacenters'); // Hostnamen und Zertifikate. Eigene Seite und nicht unter Einstellungen: was diff --git a/tests/Feature/Admin/HostTerminalTest.php b/tests/Feature/Admin/HostTerminalTest.php index 9194ebe..6c919fa 100644 --- a/tests/Feature/Admin/HostTerminalTest.php +++ b/tests/Feature/Admin/HostTerminalTest.php @@ -135,3 +135,56 @@ it('gibt kein Ticket ohne geprüften Fingerabdruck aus', function () { expect(fn () => TerminalTicket::issue($host, Operator::factory()->role('Owner')->create())) ->toThrow(RuntimeException::class, "Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."); }); + +// ── Aufgabe 2: die Seite und die Knöpfe ───────────────────────────────────── + +it('lässt niemanden ohne hosts.manage an ein Terminal', function () { + // Mit Fingerabdruck: sonst schlüge der Aufruf schon an issue() selbst + // fehl (siehe Test oben), nicht an der Berechtigungsprüfung — und dieser + // Test soll genau LETZTERE belegen. + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + + // „Read-only" darf die Konsole betreten und sonst nichts. + $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator') + ->get(route('admin.hosts.terminal', ['host' => $host->uuid])) + ->assertForbidden(); +}); + +it('zeigt den Vorspann und reicht das Ticket weiter, aber niemals den Schlüssel', function () { + $host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']); + + $html = $this->actingAs(admin(), 'operator') + ->get(route('admin.hosts.terminal', ['host' => $host->uuid])) + ->assertOk() + ->getContent(); + + // Der Vorspann steht da, bevor irgendetwas verbindet. + expect($html)->toContain('CluPilot') + // 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']); + + $erlaubt = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent(); + expect($erlaubt)->toContain(__('hosts.terminal.open')); + + $this->actingAs(Operator::factory()->role('Read-only')->create(), 'operator'); + $verwehrt = $this->get(route('admin.hosts'))->getContent(); + expect($verwehrt)->not->toContain(__('hosts.terminal.open')); +}); + +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(); + + $html = $this->actingAs(admin(), 'operator')->get(route('admin.hosts'))->getContent(); + + expect($html)->not->toContain(__('hosts.terminal.open')); +}); diff --git a/vite.config.js b/vite.config.js index 8ecb276..1066bb1 100644 --- a/vite.config.js +++ b/vite.config.js @@ -17,7 +17,11 @@ export default defineConfig({ // site.js ist ein eigener Einstiegspunkt, kein Teil von app.js: es // startet Alpine, und auf Konsolen- und Portalseiten tut das schon // Livewire. Zwei Starts zerlegen beide. Siehe resources/js/site.js. - input: ['resources/css/app.css', 'resources/js/app.js', '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. + input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/site.js', 'resources/js/terminal.js'], refresh: true, }), ],