Terminal: das Ticket, einmalig und dreissig Sekunden gueltig
parent
8a99f83556
commit
830af24b6c
|
|
@ -0,0 +1,74 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Host;
|
||||
use App\Models\Operator;
|
||||
use App\Services\Secrets\SecretVault;
|
||||
use App\Services\Terminal\TerminalTicket;
|
||||
|
||||
beforeEach(function () {
|
||||
// Der Schlüssel, den das Ticket mitgeben soll. Ohne ihn stünde im Ticket
|
||||
// ein leerer Wert, und der Test bewiese nichts.
|
||||
app(SecretVault::class)->put('ssh.private_key', "-----BEGIN OPENSSH PRIVATE KEY-----\nTEST\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create());
|
||||
});
|
||||
|
||||
it('trägt alles, was die Brücke braucht — und nichts davon im Klartext an den Browser', function () {
|
||||
$host = Host::factory()->active()->create(['ssh_host_key' => 'SHA256:abc']);
|
||||
$operator = Operator::factory()->role('Owner')->create();
|
||||
|
||||
$ticket = TerminalTicket::issue($host, $operator);
|
||||
|
||||
// Undurchsichtig: keine Adresse, kein Name, nichts Ratbares.
|
||||
expect($ticket)->toMatch('/^[a-f0-9]{64}$/')
|
||||
->and($ticket)->not->toContain($host->wg_ip)
|
||||
->and($ticket)->not->toContain($host->name);
|
||||
|
||||
$payload = TerminalTicket::redeem($ticket);
|
||||
|
||||
expect($payload['host_uuid'])->toBe($host->uuid)
|
||||
->and($payload['operator_id'])->toBe($operator->id)
|
||||
// Die TUNNELADRESSE, nie die öffentliche: der Container steht im
|
||||
// Tunnel, und die öffentliche IP wäre der Weg, den SecureHostFirewall
|
||||
// ausdrücklich zumacht.
|
||||
->and($payload['ip'])->toBe($host->wg_ip)
|
||||
->and($payload['user'])->toBe('root')
|
||||
->and($payload['fingerprint'])->toBe('SHA256:abc')
|
||||
->and($payload['private_key'])->toContain('BEGIN OPENSSH PRIVATE KEY');
|
||||
});
|
||||
|
||||
it('trägt genau eine Sitzung', function () {
|
||||
$ticket = TerminalTicket::issue(
|
||||
Host::factory()->active()->create(),
|
||||
Operator::factory()->role('Owner')->create(),
|
||||
);
|
||||
|
||||
expect(TerminalTicket::redeem($ticket))->not->toBeNull()
|
||||
// Die zweite Einlösung läuft ins Leere. Das Löschen beim Lesen IST die
|
||||
// Regel — ein Ticket, das zweimal trägt, ist ein Nachschlüssel.
|
||||
->and(TerminalTicket::redeem($ticket))->toBeNull();
|
||||
});
|
||||
|
||||
it('trägt nach dreißig Sekunden nicht mehr', function () {
|
||||
$ticket = TerminalTicket::issue(
|
||||
Host::factory()->active()->create(),
|
||||
Operator::factory()->role('Owner')->create(),
|
||||
);
|
||||
|
||||
$this->travel(TerminalTicket::TTL_SECONDS + 1)->seconds();
|
||||
|
||||
expect(TerminalTicket::redeem($ticket))->toBeNull();
|
||||
});
|
||||
|
||||
it('öffnet mit dem Ticket für einen Host keine Sitzung auf einem anderen', function () {
|
||||
$a = Host::factory()->active()->create();
|
||||
$b = Host::factory()->active()->create();
|
||||
$operator = Operator::factory()->role('Owner')->create();
|
||||
|
||||
$payload = TerminalTicket::redeem(TerminalTicket::issue($a, $operator));
|
||||
|
||||
expect($payload['ip'])->toBe($a->wg_ip)
|
||||
->and($payload['ip'])->not->toBe($b->wg_ip);
|
||||
});
|
||||
|
||||
it('gibt kein Ticket ohne hinterlegten Schlüssel aus', function () {
|
||||
// Ein Ticket ohne Schlüssel führt zu einem Fenster, das aufgeht und nie
|
||||
// verbindet — der Fehler gehört hierher, nicht in den Container.
|
||||
app(SecretVault::class)->forget('ssh.private_key');
|
||||
|
||||
// Diese Installation hat in .env tatsächlich einen SSH-Schlüssel
|
||||
// konfiguriert (CLUPILOT_SSH_PRIVATE_KEY_PATH), auf den SecretVault::get()
|
||||
// zurückfällt, solange kein Tresor-Eintrag existiert (siehe
|
||||
// OnboardingChecksTest, derselbe Rückfall) — direkt zurückgesetzt, damit
|
||||
// der Test nicht vom Entwicklerrechner abhängt.
|
||||
config()->set('provisioning.ssh.private_key', '');
|
||||
|
||||
expect(fn () => TerminalTicket::issue(
|
||||
Host::factory()->active()->create(),
|
||||
Operator::factory()->role('Owner')->create(),
|
||||
))->toThrow(RuntimeException::class);
|
||||
});
|
||||
Loading…
Reference in New Issue