From 830af24b6ce40adc75c53d3e386d194799b44b05 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 2 Aug 2026 17:32:59 +0200 Subject: [PATCH] Terminal: das Ticket, einmalig und dreissig Sekunden gueltig --- app/Services/Terminal/TerminalTicket.php | 74 ++++++++++++++++++++ tests/Feature/Admin/HostTerminalTest.php | 88 ++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 app/Services/Terminal/TerminalTicket.php create mode 100644 tests/Feature/Admin/HostTerminalTest.php diff --git a/app/Services/Terminal/TerminalTicket.php b/app/Services/Terminal/TerminalTicket.php new file mode 100644 index 0000000..8405274 --- /dev/null +++ b/app/Services/Terminal/TerminalTicket.php @@ -0,0 +1,74 @@ +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); + } +} diff --git a/tests/Feature/Admin/HostTerminalTest.php b/tests/Feature/Admin/HostTerminalTest.php new file mode 100644 index 0000000..3965c8a --- /dev/null +++ b/tests/Feature/Admin/HostTerminalTest.php @@ -0,0 +1,88 @@ +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'); + + // Diese Installation hat in .env tatsächlich einen SSH-Schlüssel + // konfiguriert (CLUPILOT_SSH_PRIVATE_KEY_PATH), auf den SecretVault::get() + // zurückfällt, solange kein Tresor-Eintrag existiert (siehe + // OnboardingChecksTest, derselbe Rückfall) — direkt zurückgesetzt, damit + // der Test nicht vom Entwicklerrechner abhängt. + config()->set('provisioning.ssh.private_key', ''); + + expect(fn () => TerminalTicket::issue( + Host::factory()->active()->create(), + Operator::factory()->role('Owner')->create(), + ))->toThrow(RuntimeException::class); +});