CluPilotCloud/app/Services/Terminal/TerminalTicket.php

101 lines
4.3 KiB
PHP

<?php
namespace App\Services\Terminal;
use App\Models\Host;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use RuntimeException;
/**
* Die Eintrittskarte für eine Terminalsitzung AUF EINEM HOST — und die einzige
* Stelle, die weiß, was für einen Host darin steht.
*
* Wo sie liegt, wie lange und in welcher Form, steht seit dem zweiten
* Ausstellungsweg in {@see TicketStore}. Hier bleibt allein die Frage, die nur
* ein Host stellt: welche Tunneladresse, welcher Fingerabdruck, und wann es
* beides noch gar nicht gibt.
*/
final class TerminalTicket
{
/**
* Steht weiter hier, weil Aufrufer und Tests danach fragen — der Wert
* selbst gehört dem Speicher, nicht diesem Weg.
*/
public const TTL_SECONDS = TicketStore::TTL_SECONDS;
/**
* 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');
return TicketStore::issue([
'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,
]);
}
/**
* `ip` und `fingerprint` sind hier nicht optional: issue() weist 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
{
/** @var array{operator_id: int, host_uuid: string, ip: string, user: string, private_key: string, fingerprint: string}|null */
return TicketStore::redeem($ticket);
}
}