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
parent
714b044ff1
commit
941950584c
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in New Issue