diff --git a/docs/superpowers/plans/2026-08-02-terminal-container.md b/docs/superpowers/plans/2026-08-02-terminal-container.md new file mode 100644 index 0000000..f4012e0 --- /dev/null +++ b/docs/superpowers/plans/2026-08-02-terminal-container.md @@ -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 +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 +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 +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: ``, ``, `@vite('resources/js/terminal.js')`, `{{ $slot }}`. 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. --}} +
+ +
+ +

{{ __('hosts.terminal.connecting', ['host' => $host->name]) }}

+
+ + +
+``` + +- [ ] **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') + + + + {{ __('hosts.terminal.open') }} + + +@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:` 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 `terminal: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:` 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.