75 lines
2.8 KiB
PHP
75 lines
2.8 KiB
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);
|
|
}
|
|
}
|