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 <noreply@anthropic.com>
main
nexxo 2026-08-02 18:18:22 +02:00
parent 714b044ff1
commit 941950584c
13 changed files with 294 additions and 1 deletions

View File

@ -0,0 +1,67 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Host;
use App\Models\Operator;
use App\Services\Terminal\TerminalTicket;
use Livewire\Attributes\Layout;
use Livewire\Component;
/**
* Die Seite im eigenen Fenster.
*
* Sie hat bewusst kein Layout der Konsole: hier steht ein Terminal, das die
* volle Fläche bekommt, und eine Seitenleiste daneben wäre ein Rahmen um eine
* Sache, die keinen braucht.
*
* Das Ticket entsteht beim Aufruf und lebt dreißig Sekunden also genau so
* lange, wie das Fenster zum Verbinden braucht. Wer die Seite offen liegen
* lässt und später neu lädt, bekommt ein frisches.
*
* Berechtigung ZUERST, Host-Suche DANACH: wer `hosts.manage` nicht hat, soll
* auch nicht per Antwortzeit oder 404-gegen-403 lernen können, ob eine UUID zu
* einem echten Host gehört.
*
* Die Eigenschaft heißt bewusst NICHT `$host` wie der Routenparameter, obwohl
* das näherliegend gewesen wäre und die Ansicht genau diesen Namen erwartet
* (siehe render() unten, das ihn dort wieder einsetzt). Livewires eigene
* `ImplicitRouteBinding` löst JEDE öffentliche, typisierte Eigenschaft aUTOMATISCH
* gegen einen gleichnamigen Routenparameter auf unabhängig vom `mount()`
* -Rumpf. Mit `public Host $host` UND einem Parameter `{host}` in der Route
* bekam `mount(string $host)` beim Testlauf nicht die UUID, sondern ein
* bereits (und ungeprüft!) aufgelöstes `Host`-Objekt, von PHP wegen der
* Typangabe `string` über `__toString()` in JSON verwandelt und genau die
* Prüf-Reihenfolge oben ausgehebelt, weil die Auflösung dann VOR jedem
* `authorize()`-Aufruf passiert. Ein anderer Name für die Eigenschaft nimmt
* dem Mechanismus die Angriffsfläche, ohne die Route umzubenennen (die
* Schnittstelle `route('admin.hosts.terminal', ['host' => …])` 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');
}
}

View File

@ -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.',

View File

@ -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.',

17
package-lock.json generated
View File

@ -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",

View File

@ -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",

54
resources/js/terminal.js Normal file
View File

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

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<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. --}}
@vite('resources/js/terminal.js')
</head>
{{--
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.
--}}
<body class="bg-ink">
{{ $slot }}
</body>
</html>

View File

@ -29,6 +29,21 @@
{{ $host->status === 'active' ? __('hosts.detail.drain') : __('hosts.detail.activate') }}
</x-ui.button>
@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)
<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>
@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))

View File

@ -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. --}}
<div class="flex h-screen w-screen flex-col bg-ink"
data-terminal
data-ticket="{{ $ticket }}"
data-host="{{ $host->name }}">
<div data-terminal-splash class="flex flex-1 items-center justify-center">
{{-- 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. --}}
<pre class="font-mono text-[11px] leading-[1.15] text-accent select-none" role="img" aria-label="CluPilot">
___ _ ___ _ _ _
/ __| |_ _| _ \(_) |___| |_
| (__| | || | _/| | / _ \ _|
\___|_|\_,_|_| |_|_\___/\__|
</pre>
<p class="sr-only">{{ __('hosts.terminal.connecting', ['host' => $host->name]) }}</p>
</div>
<div data-terminal-screen class="hidden flex-1"></div>
</div>

View File

@ -81,6 +81,12 @@
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.instances') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.capacity') }}</th>
<th class="px-4 py-3 font-semibold">{{ __('hosts.col.status') }}</th>
{{-- 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. --}}
<th class="px-4 py-3 font-semibold"></th>
</tr>
</thead>
<tbody>
@ -143,6 +149,23 @@
@endif
</td>
<td class="px-4 py-3"><x-ui.badge :status="$badge">{{ __('hosts.status.'.$host->status) }}</x-ui.badge></td>
<td class="px-4 py-3 text-right">
{{-- 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)
<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>
@endif
@endcan
</td>
</tr>
@endforeach
</tbody>

View File

@ -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

View File

@ -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'));
});

View File

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