Umsetzungsplan: Terminal-Container in drei Aufgaben
parent
632d3213a4
commit
8a99f83556
|
|
@ -0,0 +1,892 @@
|
|||
# Terminal-Container — Umsetzungsplan
|
||||
|
||||
> **Für ausführende Agenten:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`. Die Schritte benutzen Checkbox-Syntax (`- [ ]`).
|
||||
|
||||
**Ziel:** Ein Knopf neben jedem Host öffnet ein eigenes Fenster mit einem xterm.js-Terminal, das per SSH auf diesem Host als `root` angemeldet ist.
|
||||
|
||||
**Architektur:** Die App vergibt ein einmaliges Ticket (Redis, 30 s) und öffnet ein eigenes Fenster. Ein neuer Beiwagen-Container `terminal` (Python, `websockets` + `paramiko`) löst das Ticket ein, baut SSH auf und verbindet PTY und WebSocket. nginx im `app`-Container reicht genau einen Pfad durch. Der Browser sieht nie den Schlüssel.
|
||||
|
||||
**Tech-Stack:** Laravel 13.8, Livewire 3, Redis, Python 3.12 (`websockets`, `paramiko`), xterm.js, nginx, Docker Compose.
|
||||
|
||||
## Verbindliche Rahmenbedingungen
|
||||
|
||||
- **Spec:** `docs/superpowers/specs/2026-08-02-terminal-container-design.md`. Bei Abweichung gewinnt die Spec.
|
||||
- **Der Browser sieht nie:** privaten Schlüssel, Tunneladresse, Benutzernamen. Nur das undurchsichtige Ticket.
|
||||
- **Ticket:** 32 Byte Zufall, **30 Sekunden**, **einmalig** — Einlösen heißt Löschen im selben Zug.
|
||||
- **Berechtigung:** `hosts.manage`, geprüft an der **Aktion**, nicht nur an der Ansicht. Kein neues Recht.
|
||||
- **Ziel der Verbindung:** `hosts.wg_ip` (Tunneladresse), Benutzer `root`, Schlüssel aus `SecretVault::get('ssh.private_key')`, Fingerabdruck `hosts.ssh_host_key` geprüft.
|
||||
- **Der `app`-Container erreicht keinen Host.** Der neue Container muss wie `queue-provisioning` im Tunnel stehen (`network_mode`/Tunnel-Anbindung von `queue-provisioning` übernehmen).
|
||||
- **Der Vorspann weicht erst, wenn Daten fließen** — nicht, wenn der Socket offen ist.
|
||||
- **Leerlauf:** 15 Minuten ohne Ein-/Ausgabe beendet die Sitzung. Socket zu = SSH zu.
|
||||
- **Kommentare und Nutzertexte auf Deutsch**, im Ton der umliegenden Dateien: sie erklären *warum*.
|
||||
- **R18** (Icon `size-4` in Knöpfen, einzeilig), **R22** (Aufwand nach Aufgabe).
|
||||
- **Testlauf:** `docker compose exec -u 1000:1000 -T app php artisan test …` aus `/home/nexxo/clupilot`. Der `-T`-Schalter ist Pflicht.
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Zuständigkeit |
|
||||
|---|---|
|
||||
| `app/Services/Terminal/TerminalTicket.php` **(neu)** | Ticket ausstellen und (für Tests) einlösen. Die einzige Stelle, die weiß, was im Ticket steht. |
|
||||
| `app/Livewire/Admin/HostTerminal.php` **(neu)** | Die Seite im eigenen Fenster: löst den Host auf, prüft `hosts.manage`, reicht das Ticket an die Ansicht. |
|
||||
| `resources/views/livewire/admin/host-terminal.blade.php` **(neu)** | ASCII-Vorspann + xterm.js-Einbettung. |
|
||||
| `resources/js/terminal.js` **(neu)** | Eigener Vite-Einstiegspunkt: xterm.js, WebSocket, Umschalten vom Vorspann. |
|
||||
| `docker/terminal/Dockerfile` **(neu)** | Python 3.12, `websockets`, `paramiko`. |
|
||||
| `docker/terminal/bridge.py` **(neu)** | Ticket einlösen, SSH aufbauen, PTY ⇄ WebSocket. |
|
||||
| `docker/terminal/requirements.txt` **(neu)** | Zwei Zeilen. |
|
||||
| `docker-compose.yml` | Dienst `terminal`, im Tunnel. |
|
||||
| `docker/nginx/default.conf` | `location /terminal/ws` → `terminal:8081`. |
|
||||
| `routes/admin.php` | Route `hosts/{host}/terminal`. |
|
||||
| `app/Support/Navigation.php` | *nicht* — die Seite hat keinen Navigationseintrag, sie wird nur aus Liste/Detailseite geöffnet. |
|
||||
| `resources/views/livewire/admin/hosts.blade.php` | Knopf je Zeile. |
|
||||
| `resources/views/livewire/admin/host-detail.blade.php` | Knopf im Kopf. |
|
||||
| `lang/de/hosts.php`, `lang/en/hosts.php` | Beschriftungen. |
|
||||
| `tests/Feature/Admin/HostTerminalTest.php` **(neu)** | Die sechs Zusicherungen aus der Spec. |
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 1: Das Ticket
|
||||
|
||||
Der Kern. Ohne ihn ist alles andere Anzeige.
|
||||
|
||||
**Dateien:**
|
||||
- Neu: `app/Services/Terminal/TerminalTicket.php`
|
||||
- Neu: `tests/Feature/Admin/HostTerminalTest.php`
|
||||
|
||||
**Schnittstellen:**
|
||||
- Erzeugt: `TerminalTicket::issue(Host $host, Operator $for): string` — gibt den undurchsichtigen Schlüssel zurück
|
||||
- Erzeugt: `TerminalTicket::redeem(string $ticket): ?array` — liest **und löscht**; `null`, wenn abgelaufen oder schon benutzt
|
||||
- Erzeugt: `TerminalTicket::TTL_SECONDS = 30`
|
||||
|
||||
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
Neue Datei `tests/Feature/Admin/HostTerminalTest.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Terminal\TerminalTicket;
|
||||
|
||||
beforeEach(function () {
|
||||
// Der Schlüssel, den das Ticket mitgeben soll. Ohne ihn stünde im Ticket
|
||||
// ein leerer Wert, und der Test bewiese nichts.
|
||||
app(SecretVault::class)->put('ssh.private_key', "-----BEGIN OPENSSH PRIVATE KEY-----\nTEST\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create());
|
||||
});
|
||||
|
||||
it('trägt alles, was die Brücke braucht — und nichts davon im Klartext an den Browser', function () {
|
||||
$host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
|
||||
$operator = Operator::factory()->role('Owner')->create();
|
||||
|
||||
$ticket = TerminalTicket::issue($host, $operator);
|
||||
|
||||
// Undurchsichtig: keine Adresse, kein Name, nichts Ratbares.
|
||||
expect($ticket)->toMatch('/^[a-f0-9]{64}$/')
|
||||
->and($ticket)->not->toContain($host->wg_ip)
|
||||
->and($ticket)->not->toContain($host->name);
|
||||
|
||||
$payload = TerminalTicket::redeem($ticket);
|
||||
|
||||
expect($payload['host_uuid'])->toBe($host->uuid)
|
||||
->and($payload['operator_id'])->toBe($operator->id)
|
||||
// Die TUNNELADRESSE, nie die öffentliche: der Container steht im
|
||||
// Tunnel, und die öffentliche IP wäre der Weg, den SecureHostFirewall
|
||||
// ausdrücklich zumacht.
|
||||
->and($payload['ip'])->toBe($host->wg_ip)
|
||||
->and($payload['user'])->toBe('root')
|
||||
->and($payload['fingerprint'])->toBe('SHA256:abc')
|
||||
->and($payload['private_key'])->toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||
});
|
||||
|
||||
it('trägt genau eine Sitzung', function () {
|
||||
$ticket = TerminalTicket::issue(
|
||||
Host::factory()->active()->create(),
|
||||
Operator::factory()->role('Owner')->create(),
|
||||
);
|
||||
|
||||
expect(TerminalTicket::redeem($ticket))->not->toBeNull()
|
||||
// Die zweite Einlösung läuft ins Leere. Das Löschen beim Lesen IST die
|
||||
// Regel — ein Ticket, das zweimal trägt, ist ein Nachschlüssel.
|
||||
->and(TerminalTicket::redeem($ticket))->toBeNull();
|
||||
});
|
||||
|
||||
it('trägt nach dreißig Sekunden nicht mehr', function () {
|
||||
$ticket = TerminalTicket::issue(
|
||||
Host::factory()->active()->create(),
|
||||
Operator::factory()->role('Owner')->create(),
|
||||
);
|
||||
|
||||
$this->travel(TerminalTicket::TTL_SECONDS + 1)->seconds();
|
||||
|
||||
expect(TerminalTicket::redeem($ticket))->toBeNull();
|
||||
});
|
||||
|
||||
it('öffnet mit dem Ticket für einen Host keine Sitzung auf einem anderen', function () {
|
||||
$a = Host::factory()->active()->create();
|
||||
$b = Host::factory()->active()->create();
|
||||
$operator = Operator::factory()->role('Owner')->create();
|
||||
|
||||
$payload = TerminalTicket::redeem(TerminalTicket::issue($a, $operator));
|
||||
|
||||
expect($payload['ip'])->toBe($a->wg_ip)
|
||||
->and($payload['ip'])->not->toBe($b->wg_ip);
|
||||
});
|
||||
|
||||
it('gibt kein Ticket ohne hinterlegten Schlüssel aus', function () {
|
||||
// Ein Ticket ohne Schlüssel führt zu einem Fenster, das aufgeht und nie
|
||||
// verbindet — der Fehler gehört hierher, nicht in den Container.
|
||||
app(SecretVault::class)->forget('ssh.private_key');
|
||||
|
||||
expect(fn () => TerminalTicket::issue(
|
||||
Host::factory()->active()->create(),
|
||||
Operator::factory()->role('Owner')->create(),
|
||||
))->toThrow(RuntimeException::class);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Schritt 2: Testlauf, der scheitern muss**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostTerminalTest.php
|
||||
```
|
||||
|
||||
Erwartet: FAIL — `Class "App\Services\Terminal\TerminalTicket" not found`.
|
||||
|
||||
- [ ] **Schritt 3: Prüfen, ob `SecretVault` ein `forget()` hat**
|
||||
|
||||
```bash
|
||||
grep -n "public function forget" app/Services/Secrets/SecretVault.php
|
||||
```
|
||||
|
||||
Gibt es keines, den letzten Test stattdessen mit `put('ssh.private_key', '', …)` schreiben und in `issue()` auf `blank()` prüfen. Die Zusicherung bleibt dieselbe: kein Schlüssel, kein Ticket.
|
||||
|
||||
- [ ] **Schritt 4: `TerminalTicket` anlegen**
|
||||
|
||||
Neue Datei `app/Services/Terminal/TerminalTicket.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Services\Terminal;
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Die Eintrittskarte für eine Terminalsitzung — und die einzige Stelle, die
|
||||
* weiß, was darin steht.
|
||||
*
|
||||
* Der Browser bekommt nur diesen Schlüssel: zweiunddreißig Byte Zufall, sonst
|
||||
* nichts. Alles, was die Brücke wirklich braucht — Tunneladresse, Benutzer,
|
||||
* privater Schlüssel, gepinnter Fingerabdruck — liegt serverseitig daneben und
|
||||
* wird vom Container gelesen, nie vom Browser mitgegeben. Ein Ticket, das die
|
||||
* Verbindungsdaten selbst trüge, stünde in der Adresszeile, im Verlauf und in
|
||||
* jedem Protokoll dazwischen.
|
||||
*
|
||||
* Dreißig Sekunden, weil ein Ticket nur den Weg vom Klick zum offenen Fenster
|
||||
* überbrücken muss. Und genau eine Einlösung: `redeem()` löscht im selben Zug,
|
||||
* was es liest — ein Ticket, das zweimal trägt, ist ein Nachschlüssel.
|
||||
*/
|
||||
final class TerminalTicket
|
||||
{
|
||||
public const TTL_SECONDS = 30;
|
||||
|
||||
private const PREFIX = 'terminal:ticket:';
|
||||
|
||||
public static function issue(Host $host, Operator $for): string
|
||||
{
|
||||
$key = (string) app(SecretVault::class)->get('ssh.private_key');
|
||||
|
||||
// Lieber hier scheitern als ein Fenster, das aufgeht und schweigt: ohne
|
||||
// Schlüssel kann die Brücke sich nicht anmelden, und der Betreiber sähe
|
||||
// nur einen Vorspann, der nie weicht.
|
||||
if (blank($key)) {
|
||||
throw new RuntimeException('Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.');
|
||||
}
|
||||
|
||||
$ticket = bin2hex(random_bytes(32));
|
||||
|
||||
// ALS JSON, nicht als PHP-Array. Laravel legt einen Cache-Wert sonst
|
||||
// PHP-serialisiert ab, und der Container, der ihn liest, ist Python —
|
||||
// der kann damit nichts anfangen. Das Format ist hier eine
|
||||
// Schnittstelle zwischen zwei Sprachen, keine interne Ablage.
|
||||
Cache::put(self::PREFIX.$ticket, json_encode([
|
||||
'operator_id' => $for->id,
|
||||
'host_uuid' => $host->uuid,
|
||||
// Die Tunneladresse. Die öffentliche IP wäre der Weg, den
|
||||
// SecureHostFirewall ausdrücklich zumacht.
|
||||
'ip' => $host->wg_ip,
|
||||
'user' => 'root',
|
||||
'private_key' => $key,
|
||||
'fingerprint' => $host->ssh_host_key,
|
||||
], JSON_THROW_ON_ERROR), self::TTL_SECONDS);
|
||||
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest das Ticket und löscht es im selben Zug.
|
||||
*
|
||||
* @return array{operator_id: int, host_uuid: string, ip: ?string, user: string, private_key: string, fingerprint: ?string}|null
|
||||
*/
|
||||
public static function redeem(string $ticket): ?array
|
||||
{
|
||||
$raw = Cache::pull(self::PREFIX.$ticket);
|
||||
|
||||
return $raw === null ? null : json_decode((string) $raw, true, flags: JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Schritt 5: Testlauf**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostTerminalTest.php
|
||||
```
|
||||
|
||||
Erwartet: PASS, alle fünf.
|
||||
|
||||
- [ ] **Schritt 6: Committen**
|
||||
|
||||
```bash
|
||||
git add app/Services/Terminal/TerminalTicket.php tests/Feature/Admin/HostTerminalTest.php && git commit -m "Terminal: das Ticket, einmalig und dreissig Sekunden gueltig"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 2: Die Seite und die Knöpfe
|
||||
|
||||
Alles, was der Betreiber sieht — noch ohne Container dahinter. Am Ende dieser Aufgabe geht das Fenster auf, der Vorspann läuft, und die Verbindung scheitert sichtbar. Das ist Absicht: so ist der Vorspann geprüft, bevor es etwas zu verbinden gibt.
|
||||
|
||||
**Dateien:**
|
||||
- Neu: `app/Livewire/Admin/HostTerminal.php`
|
||||
- Neu: `resources/views/livewire/admin/host-terminal.blade.php`
|
||||
- Neu: `resources/js/terminal.js`
|
||||
- Ändern: `routes/admin.php`, `vite.config.js`, `package.json` (xterm)
|
||||
- Ändern: `resources/views/livewire/admin/hosts.blade.php`, `host-detail.blade.php`
|
||||
- Ändern: `lang/de/hosts.php`, `lang/en/hosts.php`
|
||||
- Test: `tests/Feature/Admin/HostTerminalTest.php` (erweitern)
|
||||
|
||||
**Schnittstellen:**
|
||||
- Verbraucht: `TerminalTicket::issue(Host, Operator): string` aus Aufgabe 1
|
||||
- Erzeugt: Route `admin.hosts.terminal` mit Parameter `host` (UUID)
|
||||
|
||||
- [ ] **Schritt 1: Die fehlschlagenden Tests schreiben**
|
||||
|
||||
Ans Ende von `tests/Feature/Admin/HostTerminalTest.php` anfügen:
|
||||
|
||||
```php
|
||||
it('lässt niemanden ohne hosts.manage an ein Terminal', function () {
|
||||
$host = Host::factory()->active()->create();
|
||||
|
||||
// „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();
|
||||
|
||||
$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();
|
||||
|
||||
$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'));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Schritt 2: Testlauf, der scheitern muss**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostTerminalTest.php
|
||||
```
|
||||
|
||||
Erwartet: FAIL — Route `admin.hosts.terminal` existiert nicht.
|
||||
|
||||
- [ ] **Schritt 3: xterm.js als Abhängigkeit**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -e npm_config_cache=/tmp/npm-cache -T app npm install @xterm/xterm @xterm/addon-fit
|
||||
```
|
||||
|
||||
**Wichtig:** `package.json` und `package-lock.json` ändern sich damit — das nächste Deployment fährt `npm ci`. Das ist der Schritt, der zuletzt an einem root-eigenen Zwischenspeicher scheiterte; die Reparatur dafür ist seit `v1.3.98` drin, aber der Lauf gehört beobachtet.
|
||||
|
||||
- [ ] **Schritt 4: Das Bauteil anlegen**
|
||||
|
||||
Neue Datei `app/Livewire/Admin/HostTerminal.php`:
|
||||
|
||||
```php
|
||||
<?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.
|
||||
*/
|
||||
#[Layout('layouts.bare')]
|
||||
class HostTerminal extends Component
|
||||
{
|
||||
public Host $host;
|
||||
|
||||
public string $ticket = '';
|
||||
|
||||
public function mount(string $host): void
|
||||
{
|
||||
$this->authorize('hosts.manage');
|
||||
|
||||
$this->host = Host::query()->where('uuid', $host)->firstOrFail();
|
||||
|
||||
/** @var Operator $operator */
|
||||
$operator = auth('operator')->user();
|
||||
|
||||
$this->ticket = TerminalTicket::issue($this->host, $operator);
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.host-terminal')->title($this->host->name.' — Terminal');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Schritt 5: Prüfen, ob es ein schlichtes Layout gibt**
|
||||
|
||||
```bash
|
||||
ls resources/views/layouts/ resources/views/components/layouts/ 2>/dev/null
|
||||
```
|
||||
|
||||
Gibt es kein `bare`, eines anlegen: `<!DOCTYPE html>`, `<x-shell.head :title="$title ?? 'Terminal'" />`, `@vite('resources/js/terminal.js')`, `<body class="bg-ink">{{ $slot }}</body>`. Nichts weiter — kein Kopf, keine Leiste.
|
||||
|
||||
- [ ] **Schritt 6: Die Ansicht mit dem Vorspann**
|
||||
|
||||
Neue Datei `resources/views/livewire/admin/host-terminal.blade.php`:
|
||||
|
||||
```blade
|
||||
{{-- 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">
|
||||
<pre class="font-mono text-[11px] leading-[1.15] text-accent select-none" aria-hidden="true">
|
||||
___ _ ___ _ _ _
|
||||
/ __| |_ _| _ \(_) |___| |_
|
||||
| (__| | || | _/| | / _ \ _|
|
||||
\___|_|\_,_|_| |_|_\___/\__|
|
||||
</pre>
|
||||
<p class="sr-only">{{ __('hosts.terminal.connecting', ['host' => $host->name]) }}</p>
|
||||
</div>
|
||||
|
||||
<div data-terminal-screen class="hidden flex-1"></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
- [ ] **Schritt 7: Der Einstiegspunkt**
|
||||
|
||||
Neue Datei `resources/js/terminal.js`:
|
||||
|
||||
```js
|
||||
/*
|
||||
* 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())
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Schritt 8: Vite-Einstiegspunkt und Route**
|
||||
|
||||
`vite.config.js` — `'resources/js/terminal.js'` in `input` aufnehmen.
|
||||
|
||||
`routes/admin.php`, neben den anderen Host-Routen:
|
||||
|
||||
```php
|
||||
// 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');
|
||||
```
|
||||
|
||||
- [ ] **Schritt 9: Die Knöpfe und die Texte**
|
||||
|
||||
`lang/de/hosts.php`, im Block `terminal` (neu anlegen):
|
||||
|
||||
```php
|
||||
'terminal' => [
|
||||
'open' => 'Terminal',
|
||||
'connecting' => 'Verbinde mit :host …',
|
||||
'hint' => 'Öffnet ein eigenes Fenster mit einer Root-Sitzung auf diesem Host.',
|
||||
],
|
||||
```
|
||||
|
||||
`lang/en/hosts.php` entsprechend: `'Terminal'`, `'Connecting to :host …'`, `'Opens a separate window with a root session on this host.'`
|
||||
|
||||
In `resources/views/livewire/admin/hosts.blade.php` je Zeile, in der Aktionsspalte:
|
||||
|
||||
```blade
|
||||
@can('hosts.manage')
|
||||
<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-slot:icon><x-ui.icon name="terminal" class="size-4" /></x-slot:icon>
|
||||
{{ __('hosts.terminal.open') }}
|
||||
</x-ui.button>
|
||||
</a>
|
||||
@endcan
|
||||
```
|
||||
|
||||
In `host-detail.blade.php` derselbe Block in die Kopfzeile, neben „In Wartung".
|
||||
|
||||
Gibt es kein Icon `terminal`, in `resources/views/components/ui/icon.blade.php` nachsehen, welche Namen existieren, und ein passendes nehmen (`activity` als Rückfall). **R18:** `size-4`, einzeilig.
|
||||
|
||||
- [ ] **Schritt 10: Bauen und Testlauf**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -e npm_config_cache=/tmp/npm-cache -T app npm run build
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostTerminalTest.php
|
||||
```
|
||||
|
||||
Erwartet: PASS, alle acht.
|
||||
|
||||
- [ ] **Schritt 11: Mit eigenen Augen ansehen**
|
||||
|
||||
Konsole öffnen, Host-Liste, Terminal-Knopf drücken. Erwartet: ein eigenes Fenster, der ASCII-Schriftzug, und danach „Verbindung nicht möglich" — es gibt noch keinen Container. **Genau das ist das Ziel dieser Aufgabe.**
|
||||
|
||||
- [ ] **Schritt 12: Committen**
|
||||
|
||||
```bash
|
||||
git add app/Livewire/Admin/HostTerminal.php resources/views/livewire/admin/host-terminal.blade.php resources/js/terminal.js vite.config.js package.json package-lock.json routes/admin.php resources/views/livewire/admin/hosts.blade.php resources/views/livewire/admin/host-detail.blade.php lang/de/hosts.php lang/en/hosts.php tests/Feature/Admin/HostTerminalTest.php && git commit -m "Terminal: eigenes Fenster mit Vorspann, Knopf in Liste und Detailseite"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 3: Die Brücke
|
||||
|
||||
Der Container. Ab hier verbindet es wirklich.
|
||||
|
||||
**Dateien:**
|
||||
- Neu: `docker/terminal/Dockerfile`, `docker/terminal/requirements.txt`, `docker/terminal/bridge.py`
|
||||
- Ändern: `docker-compose.yml`, `docker/nginx/default.conf`
|
||||
- Test: `tests/Feature/DeploymentRunsAsTheAppUserTest.php` (erweitern)
|
||||
|
||||
**Schnittstellen:**
|
||||
- Verbraucht: Redis-Schlüssel `terminal:ticket:<ticket>` aus Aufgabe 1 — **Achtung:** Laravel legt Cache-Schlüssel mit einem Präfix ab. Vor dem Schreiben von `bridge.py` den echten Schlüssel nachsehen:
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan tinker --execute="echo config('cache.prefix');"
|
||||
```
|
||||
|
||||
Der Container muss `<prefix>terminal:ticket:<ticket>` lesen. Steht das Präfix nicht fest, es dem Container per Umgebungsvariable mitgeben.
|
||||
|
||||
- [ ] **Schritt 1: Das Cache-Präfix und das Serialisierungsformat feststellen**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan tinker --execute="
|
||||
\$t = App\Services\Terminal\TerminalTicket::issue(App\Models\Host::factory()->active()->create(), App\Models\Operator::factory()->create());
|
||||
echo 'ticket: '.\$t.PHP_EOL;
|
||||
echo 'prefix: '.config('cache.prefix').PHP_EOL;
|
||||
echo 'roh in redis: '.substr((string) Illuminate\Support\Facades\Redis::get(config('cache.prefix').'terminal:ticket:'.\$t), 0, 120).PHP_EOL;
|
||||
"
|
||||
```
|
||||
|
||||
Aufgabe 1 legt den Inhalt bereits **als JSON** ab — genau aus diesem Grund. Was hier festzustellen ist, ist nur zweierlei: das **Cache-Präfix** (es geht als `CACHE_PREFIX` an den Container) und ob Laravel den JSON-String seinerseits noch serialisiert einpackt. Zeigt die Ausgabe `s:123:"{...}"` statt `{...}`, muss `bridge.py` die PHP-Serialisierung der äußeren Schicht abstreifen — dann steht der JSON-Inhalt zwischen den Anführungszeichen. Das ist in `redeem_payload()` in Schritt 4 berücksichtigt.
|
||||
|
||||
- [ ] **Schritt 2: `requirements.txt`**
|
||||
|
||||
```
|
||||
websockets==13.1
|
||||
paramiko==3.5.0
|
||||
redis==5.2.1
|
||||
```
|
||||
|
||||
- [ ] **Schritt 3: `Dockerfile`**
|
||||
|
||||
```dockerfile
|
||||
# CluPilot Terminal-Bruecke: loest ein Ticket ein, baut SSH auf und verbindet
|
||||
# PTY und WebSocket. Sonst nichts — kein Laravel, keine Datenbank.
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY bridge.py .
|
||||
|
||||
EXPOSE 8081
|
||||
CMD ["python", "bridge.py"]
|
||||
```
|
||||
|
||||
- [ ] **Schritt 4: `bridge.py`**
|
||||
|
||||
```python
|
||||
"""CluPilot Terminal-Bruecke.
|
||||
|
||||
Loest ein einmaliges Ticket ein, baut damit eine SSH-Sitzung auf und verbindet
|
||||
deren PTY mit einem WebSocket. Mehr tut dieser Dienst nicht: keine Datenbank,
|
||||
kein Laravel, keine eigene Anmeldung.
|
||||
|
||||
Das Ticket ist die einzige Tuer. Es lebt dreissig Sekunden, traegt genau eine
|
||||
Sitzung, und `getdel` loescht es im selben Zug, in dem es gelesen wird.
|
||||
"""
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
import paramiko
|
||||
import redis.asyncio as redis
|
||||
import websockets
|
||||
|
||||
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/1")
|
||||
CACHE_PREFIX = os.environ.get("CACHE_PREFIX", "")
|
||||
IDLE_SECONDS = int(os.environ.get("IDLE_SECONDS", "900"))
|
||||
PORT = int(os.environ.get("PORT", "8081"))
|
||||
|
||||
|
||||
async def redeem_payload(ticket: str) -> dict | None:
|
||||
"""Liest das Ticket und loescht es im selben Zug."""
|
||||
client = redis.from_url(REDIS_URL)
|
||||
try:
|
||||
raw = await client.getdel(f"{CACHE_PREFIX}terminal:ticket:{ticket}")
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
if not raw:
|
||||
return None
|
||||
|
||||
text = raw.decode() if isinstance(raw, bytes) else raw
|
||||
|
||||
# Laravel kann den JSON-String seinerseits PHP-serialisiert einpacken:
|
||||
# s:123:"{...}". Dann steht der Inhalt zwischen den Anfuehrungszeichen.
|
||||
if text.startswith("s:"):
|
||||
text = text[text.index('"') + 1:text.rindex('"')]
|
||||
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def connect(payload: dict) -> paramiko.SSHClient:
|
||||
key = paramiko.RSAKey.from_private_key(io.StringIO(payload["private_key"]))
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
# Kein AutoAddPolicy: der Fingerabdruck ist seit EstablishSshTrust gepinnt.
|
||||
# Blind zu vertrauen waere genau die Luecke, die das Pinnen schliesst — und
|
||||
# dieser Container erreicht jeden Host als root.
|
||||
client.set_missing_host_key_policy(paramiko.RejectPolicy())
|
||||
|
||||
client.connect(
|
||||
hostname=payload["ip"],
|
||||
username=payload["user"],
|
||||
pkey=key,
|
||||
timeout=10,
|
||||
look_for_keys=False,
|
||||
allow_agent=False,
|
||||
)
|
||||
|
||||
# Nachtraegliche Pruefung des Fingerabdrucks: paramiko haelt den Schluessel
|
||||
# der Gegenstelle bereit, und CluPilot kennt den, der beim Uebernehmen
|
||||
# gepinnt wurde. Weichen sie ab, ist das nicht dieselbe Maschine.
|
||||
expected = (payload.get("fingerprint") or "").strip()
|
||||
if expected:
|
||||
import base64
|
||||
import hashlib
|
||||
|
||||
remote = client.get_transport().get_remote_server_key()
|
||||
digest = base64.b64encode(hashlib.sha256(remote.asbytes()).digest()).decode().rstrip("=")
|
||||
if f"SHA256:{digest}" != expected:
|
||||
client.close()
|
||||
raise RuntimeError("Fingerabdruck weicht ab")
|
||||
|
||||
return client
|
||||
|
||||
|
||||
async def serve(websocket):
|
||||
params = urllib.parse.parse_qs(urllib.parse.urlparse(websocket.request.path).query)
|
||||
ticket = (params.get("t") or [""])[0]
|
||||
|
||||
payload = await redeem_payload(ticket) if ticket else None
|
||||
|
||||
if payload is None:
|
||||
# Kein Grund, keine Meldung mit Inhalt: ein unbekanntes Ticket darf
|
||||
# nicht verraten, ob es je eines gab.
|
||||
await websocket.close(code=4401)
|
||||
return
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
try:
|
||||
client = await loop.run_in_executor(None, connect, payload)
|
||||
except Exception:
|
||||
await websocket.close(code=4502)
|
||||
return
|
||||
|
||||
channel = client.invoke_shell(term="xterm-256color")
|
||||
channel.settimeout(0.0)
|
||||
|
||||
# Ein gemeinsamer Zeitstempel fuer beide Richtungen: Leerlauf heisst, dass
|
||||
# WEDER getippt noch ausgegeben wurde.
|
||||
last = loop.time()
|
||||
|
||||
async def to_ssh():
|
||||
nonlocal last
|
||||
async for message in websocket:
|
||||
last = loop.time()
|
||||
data = message.encode() if isinstance(message, str) else message
|
||||
await loop.run_in_executor(None, channel.sendall, data)
|
||||
|
||||
async def to_browser():
|
||||
nonlocal last
|
||||
while not channel.closed:
|
||||
await asyncio.sleep(0.02)
|
||||
if channel.recv_ready():
|
||||
last = loop.time()
|
||||
await websocket.send(channel.recv(32768))
|
||||
|
||||
async def idle_watch():
|
||||
while True:
|
||||
await asyncio.sleep(5)
|
||||
if loop.time() - last > IDLE_SECONDS:
|
||||
return
|
||||
|
||||
tasks = [asyncio.create_task(t()) for t in (to_ssh, to_browser, idle_watch)]
|
||||
|
||||
try:
|
||||
# Was zuerst endet, beendet alles: das Fenster zu, die Sitzung tot oder
|
||||
# der Leerlauf abgelaufen.
|
||||
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
||||
finally:
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
channel.close()
|
||||
client.close()
|
||||
await websocket.close()
|
||||
|
||||
|
||||
async def main():
|
||||
async with websockets.serve(serve, "0.0.0.0", PORT, ping_interval=20):
|
||||
await asyncio.Future()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
Der Import `urllib.parse` gehört mit zu den Importen oben.
|
||||
|
||||
- [ ] **Schritt 5: Den Fingerabdruck gegen die Wirklichkeit prüfen**
|
||||
|
||||
Die Berechnung oben nimmt an, dass `hosts.ssh_host_key` als `SHA256:<base64>` gespeichert ist — so schreibt es `PhpseclibRemoteShell::hostKeyFingerprint()`. **Nachsehen, bevor gebaut wird:**
|
||||
|
||||
```bash
|
||||
grep -n "hostKeyFingerprint" -A 8 app/Services/Ssh/PhpseclibRemoteShell.php
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan tinker --execute="echo App\Models\Host::query()->whereNotNull('ssh_host_key')->value('ssh_host_key');"
|
||||
```
|
||||
|
||||
Hat das Format eine andere Gestalt, die Berechnung in `connect()` daran anpassen. **Nicht die Prüfung weglassen** — sie ist der einzige Schutz davor, dass dieser Container mit einem Root-Schlüssel eine fremde Maschine anmeldet.
|
||||
|
||||
- [ ] **Schritt 6: In `docker-compose.yml` eintragen**
|
||||
|
||||
Die Tunnel-Anbindung **von `queue-provisioning` abschreiben** — der `app`-Container erreicht keinen Host, dieser muss es.
|
||||
|
||||
```yaml
|
||||
# Terminal-Bruecke: SSH auf einen Host, als WebSocket. Steht im Tunnel wie
|
||||
# queue-provisioning, weil der app-Container keinen Host erreicht.
|
||||
terminal:
|
||||
build:
|
||||
context: ./docker/terminal
|
||||
image: clupilot-terminal:dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_URL: "redis://redis:6379/1"
|
||||
CACHE_PREFIX: "${CACHE_PREFIX:-}"
|
||||
IDLE_SECONDS: "900"
|
||||
depends_on:
|
||||
- redis
|
||||
```
|
||||
|
||||
Kein `ports:` — erreichbar nur über nginx im `app`-Container.
|
||||
|
||||
- [ ] **Schritt 7: nginx durchreichen**
|
||||
|
||||
In `docker/nginx/default.conf`, **vor** `location /`:
|
||||
|
||||
```nginx
|
||||
# Der einzige Pfad zur Terminal-Bruecke. Bewusst eng: kein Praefix-Match
|
||||
# auf /terminal, sondern genau dieser eine Ort.
|
||||
#
|
||||
# ACHTUNG: dieser Pfad laeuft NICHT durch PHP, also greift
|
||||
# RestrictConsoleNetwork hier nicht. Der Riegel ist allein das Ticket —
|
||||
# einmalig, dreissig Sekunden, an einen Host und einen Betreiber gebunden.
|
||||
# So steht es auch in der Spec.
|
||||
location = /terminal/ws {
|
||||
proxy_pass http://terminal:8081;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 900s;
|
||||
proxy_send_timeout 900s;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Schritt 8: Den Test für die Rahmenbedingungen erweitern**
|
||||
|
||||
An `tests/Feature/DeploymentRunsAsTheAppUserTest.php` anfügen:
|
||||
|
||||
```php
|
||||
it('reicht den Terminal-Pfad durch, ohne ihn weiter zu oeffnen als noetig', function () {
|
||||
$nginx = File::get(base_path('docker/nginx/default.conf'));
|
||||
|
||||
// Genau dieser eine Ort, kein Praefix: ein `location /terminal` machte
|
||||
// jeden Unterpfad zum Weg in den Container.
|
||||
expect($nginx)->toContain('location = /terminal/ws')
|
||||
->and($nginx)->toContain('proxy_pass http://terminal:8081')
|
||||
->and($nginx)->toContain('Upgrade $http_upgrade');
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Schritt 9: Bauen und starten**
|
||||
|
||||
```bash
|
||||
docker compose build terminal
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d terminal
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose logs terminal --tail 20
|
||||
```
|
||||
|
||||
- [ ] **Schritt 10: Von Hand prüfen — das ist der eigentliche Beweis**
|
||||
|
||||
1. Terminal-Knopf drücken → Fenster geht auf, Vorspann läuft, danach **eine Eingabeaufforderung**.
|
||||
2. `hostname` tippen → der Name des Hosts kommt zurück.
|
||||
3. Fenster schließen → `docker compose logs terminal` zeigt das Ende der Sitzung.
|
||||
4. Dieselbe Adresse **noch einmal** aufrufen, mit demselben Ticket in der Zeile → keine Sitzung. Das Ticket war einmalig.
|
||||
5. Ein Betreiber mit Rolle `Read-only` bekommt beim Aufruf der Terminalseite **403**.
|
||||
|
||||
Punkt 4 und 5 sind die zwei, die die Sicherheit tragen. Gehen sie nicht, ist die Aufgabe nicht fertig.
|
||||
|
||||
- [ ] **Schritt 11: Vollständiger Testlauf und Committen**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan test
|
||||
```
|
||||
|
||||
```bash
|
||||
git add docker/terminal docker-compose.yml docker/nginx/default.conf tests/Feature/DeploymentRunsAsTheAppUserTest.php && git commit -m "Terminal-Bruecke: ein Container, eine Aufgabe, ein Ticket"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nach dem Bauen
|
||||
|
||||
- [ ] **Den offenen Punkt streichen, der jetzt keiner mehr ist** — falls die Terminalarbeit einen Eintrag in `App\Support\OpenWork` erledigt.
|
||||
- [ ] **Einen neuen eintragen:** das Terminal auf einer **Kunden-Instanz** (Zustand `planned`), mit dem Hinweis, dass es dieselbe Ticket-Mechanik ist, aber eine andere Berechtigung braucht — dort liegen Kundendaten.
|
||||
- [ ] **Freigabe schneiden.** `package.json` hat sich geändert, `npm ci` läuft also beim Deployment — der Schritt, der zuletzt scheiterte. Den Lauf beobachten.
|
||||
|
||||
## Was dieser Plan bewusst NICHT tut
|
||||
|
||||
- **Keine Aufzeichnung der Sitzung.** Wer den Knopf drücken darf, hat die Maschine; das steht so in der Spec und wird hier nicht heimlich abgeschwächt.
|
||||
- **Kein Terminal auf Kunden-Instanzen.** Ausdrücklich der nächste Schritt, nicht dieser.
|
||||
- **Keine IP-Freigabeliste für den WebSocket.** Sie greift dort nicht, und eine Attrappe wäre schlimmer als ihr Fehlen — die Spec benennt das offen.
|
||||
Loading…
Reference in New Issue