CluPilotCloud/docs/superpowers/plans/2026-08-02-terminal-contain...

34 KiB

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/wsterminal: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

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

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
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostTerminalTest.php

Erwartet: PASS, alle fünf.

  • Schritt 6: Committen
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:

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

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

{{-- 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:

/*
 * 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:

// 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):

'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:

@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
docker compose exec -u 1000:1000 -e npm_config_cache=/tmp/npm-cache -T app npm run build
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
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:
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
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
# 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
"""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:

grep -n "hostKeyFingerprint" -A 8 app/Services/Ssh/PhpseclibRemoteShell.php
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.

  # 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 /:

    # 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:

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
docker compose build terminal
docker compose up -d terminal
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
docker compose exec -u 1000:1000 -T app php artisan test
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.