`, auf der `cache`-Redis-Verbindung * (`config('database.redis.cache')`, standardmäßig Datenbank 1). phpredis * legt darüber transparent noch `REDIS_PREFIX` * (`config('database.redis.options.prefix')`, auf dieser Installation * `clupilot-database-`, aus `APP_NAME` abgeleitet) — unsichtbar für jeden * PHP-Aufruf über diese Verbindung, aber Teil des tatsächlichen Schlüssels * für jeden Client, der nicht über phpredis mit derselben Option spricht * (`redis-cli KEYS *`, ein Python-Client). Der Container in Aufgabe 3 muss * also entweder denselben `REDIS_PREFIX`-Wert voranstellen oder sich per * `KEYS terminal:ticket:*` durchsuchen lassen — er kann ihn aus dieser * Klasse allein nicht erraten, deshalb steht er hier. * * DER INHALT: reines JSON (`json_encode`/`json_decode`), kein PHP * `serialize()` — das war schon immer die Absicht (siehe unten), jetzt ist es * durch den direkten Redis-Zugriff auch tatsächlich das, was ankommt. */ 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.'); } // Derselbe Gedanke wie beim Schlüssel oben, für die beiden anderen // Felder, ohne die keine sichere Sitzung entstehen kann. Eine // fehlende Tunneladresse ergibt bloß ein Fenster, das nie verbindet — // ärgerlich, aber harmlos. Ein fehlender Fingerabdruck ist das // Gegenteil: er übergäbe die Prüfung an Code, der noch gar nicht // existiert, und dessen naheliegendste Fassung ("kein Fingerabdruck // im Ticket → nicht prüfen") eine ungepinnte Root-SSH-Sitzung im // Tunnel wäre — genau das, was die Spec mit „Fingerabdruck geprüft" // ausdrücklich ausschließt. Die Prüfung gehört hierher, wo der Host // ohnehin schon feststeht, nicht in einen Container, der dem Ticket // nur noch glauben kann. if (blank($host->wg_ip) || blank($host->ssh_host_key)) { throw new RuntimeException("Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."); } $ticket = bin2hex(random_bytes(32)); Redis::connection('cache')->setex(self::PREFIX.$ticket, self::TTL_SECONDS, 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)); return $ticket; } /** * Liest das Ticket und löscht es im selben Zug — mit Redis' eigenem * `GETDEL`, nicht mit zwei Aufrufen. Siehe Kopfkommentar: erst das macht * "genau eine Einlösung" zu einer Zusage, die auch unter zwei * gleichzeitigen Versuchen hält. * * `ip` und `fingerprint` sind hier nicht mehr optional: issue() weist seit * Fix-Runde 1 beide zurück, bevor überhaupt etwas in Redis landet. * * @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 = Redis::connection('cache')->getdel(self::PREFIX.$ticket); // phpredis meldet ein fehlendes/abgelaufenes/schon geholtes Ticket als // `false`, nicht als `null` — die Cache-Fassade glättete das vorher. return $raw === false || $raw === null ? null : json_decode((string) $raw, true, flags: JSON_THROW_ON_ERROR); } }