127 lines
6.2 KiB
PHP
127 lines
6.2 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Terminal;
|
|
|
|
use App\Models\Host;
|
|
use App\Models\Operator;
|
|
use App\Services\Secrets\SecretVault;
|
|
use Illuminate\Support\Facades\Redis;
|
|
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.
|
|
*
|
|
* ROHES REDIS, NICHT DIE Cache-FASSADE — zwei Gründe, aus dem Fix-Review:
|
|
*
|
|
* 1. `Cache::put()` läuft über `Illuminate\Cache\RedisStore`, und die
|
|
* serialisiert jeden Wert mit PHP `serialize()`, solange kein `serializer`
|
|
* in `config('database.php')` gesetzt ist (hier: keiner). Aus dem JSON
|
|
* unten würde in Redis ein `s:412:"{...}";` — eine PHP-Hülle, die der
|
|
* Python-Container aus Aufgabe 3 nicht kennt und nicht raten kann.
|
|
* 2. `Cache::pull()` ist `get()` dann `forget()`, zwei getrennte Runden.
|
|
* Zwei gleichzeitige Einlösungen bekämen beide den Root-Schlüssel, bevor
|
|
* die zweite merkt, dass er weg ist. Redis' `GETDEL` ist eine einzige,
|
|
* atomare Runde: die zweite Einlösung sieht den fehlenden Schlüssel, statt
|
|
* ihn noch zu bekommen — das eigene Fenster des Betreibers, dem das Ticket
|
|
* abgenommen wurde, scheitert dann sichtbar, statt dass beide Fenster
|
|
* still nebeneinander laufen.
|
|
*
|
|
* DER SCHLÜSSEL, VOLLSTÄNDIG — für Aufgabe 3, die dagegen schreibt, ohne
|
|
* dieses Repo zu kennen: `self::PREFIX.$ticket`, also
|
|
* `terminal:ticket:<64 Hex-Zeichen>`, 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);
|
|
}
|
|
}
|