`, 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:'; /** * Was diesem Host gerade im Weg steht — oder null, wenn nichts im Weg steht. * * Es gibt diese Methode, weil dieselbe Frage an zwei Stellen gestellt wird * und nur eine davon eine Ausnahme gebrauchen kann. `issue()` unten muss * scheitern: es ist die letzte Instanz vor dem Ticket. Die Seite dagegen * will die Antwort VORHER wissen, um sie in ihrer eigenen Sprache * hinzuschreiben, statt sie als 500er an den Betreiber weiterzureichen. * * Zurück kommt ein Merkwort, kein Satz: die Formulierung gehört in die * Sprachdateien, nicht in eine Ausnahme. `null` heißt „nichts im Weg". */ public static function blocker(Host $host): ?string { // Ohne Schlüssel kann die Brücke sich nicht anmelden, und der Betreiber // sähe nur einen Vorspann, der nie weicht. if (blank((string) app(SecretVault::class)->get('ssh.private_key'))) { return 'no_key'; } // Derselbe Gedanke, 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)) { return 'not_ready'; } return null; } public static function issue(Host $host, Operator $for): string { // Die Prüfung steht in `blocker()`; hier bleibt nur, sie ernst zu // nehmen. Der Wortlaut ist der von früher — er steht in Logs und in // den Tests aus Aufgabe 1. match (self::blocker($host)) { 'no_key' => throw new RuntimeException('Kein SSH-Schlüssel hinterlegt — ohne ihn kann keine Terminalsitzung entstehen.'), 'not_ready' => throw new RuntimeException("Host {$host->name} hat keine Tunneladresse oder keinen geprüften Fingerabdruck hinterlegt — ohne beides kein Ticket."), default => null, }; $key = (string) app(SecretVault::class)->get('ssh.private_key'); $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); } }