diff --git a/app/Console/Commands/ServerTerminalKey.php b/app/Console/Commands/ServerTerminalKey.php new file mode 100644 index 0000000..2871dad --- /dev/null +++ b/app/Console/Commands/ServerTerminalKey.php @@ -0,0 +1,50 @@ +getOutput()->writeln( + 'Für den Server ist keine SSH-Kennung erzeugt. In der Konsole unter Einstellungen → Terminal zum Server anlegen.', + ); + + return 3; + } + + // Nur die Zeile, ohne Rahmen und ohne Farbe: der Aufrufer ist ein + // Skript, das sie zeichengenau weiterverarbeitet. + $this->getOutput()->writeln($publicKey); + + return self::SUCCESS; + } +} diff --git a/app/Livewire/Admin/ConfirmGenerateServerKey.php b/app/Livewire/Admin/ConfirmGenerateServerKey.php new file mode 100644 index 0000000..a717430 --- /dev/null +++ b/app/Livewire/Admin/ConfirmGenerateServerKey.php @@ -0,0 +1,37 @@ +authorize('server.terminal'); + } + + public function confirm(): void + { + $this->authorize('server.terminal'); + $this->dispatch('server-key-generate-confirmed'); + $this->closeModal(); + } + + public function render() + { + return view('livewire.admin.confirm-generate-server-key'); + } +} diff --git a/app/Livewire/Admin/Integrations.php b/app/Livewire/Admin/Integrations.php index 0f52492..b61a8e7 100644 --- a/app/Livewire/Admin/Integrations.php +++ b/app/Livewire/Admin/Integrations.php @@ -674,6 +674,12 @@ class Integrations extends Component 'mode' => OperatingMode::current(), 'restart' => $restart, 'entries' => collect(SecretVault::REGISTRY) + // Was diese Anwendung sich selbst ausstellt, gehört nicht auf + // eine Seite zum Einfügen fremder Zugangsdaten — siehe + // `internal` in der Registry. Vor dem map(), nicht danach: + // solche Einträge nennen gar keinen .env-Namen, den die Zeile + // darunter zeigen könnte. + ->reject(fn (array $meta) => $meta['internal'] ?? false) ->map(fn (array $meta, string $key) => [ 'key' => $key, 'field' => self::field($key), diff --git a/app/Livewire/Admin/ServerTerminal.php b/app/Livewire/Admin/ServerTerminal.php new file mode 100644 index 0000000..c5c9fdb --- /dev/null +++ b/app/Livewire/Admin/ServerTerminal.php @@ -0,0 +1,84 @@ +authorize('server.terminal'); + + if (! RestrictConsoleNetwork::allows((string) request()->ip())) { + $this->problem = 'off_network'; + + return; + } + + /** @var Operator $operator */ + $operator = auth('operator')->user(); + + // Der Fangzaun liegt um BEIDE Schritte, nicht nur um das Ausstellen. + // `blocker()` liest den Tresor und eine Datei — beides kann auf eine + // Weise scheitern, mit der niemand gerechnet hat, und ein Wurf dort + // wäre genau die Laravel-Fehlerseite, die dieses Fenster nie zeigen + // soll. Die Antwort ist dieselbe wie beim Ausstellen: eine gestaltete + // Seite, ohne Versprechen, woran es lag. + try { + $this->problem = ServerTicket::blocker() ?? ''; + + if ($this->problem === '') { + $this->ticket = ServerTicket::issue($operator); + } + } catch (Throwable $e) { + report($e); + $this->problem = 'unknown'; + } + } + + public function render() + { + return view('livewire.admin.server-terminal') + ->title(__('server.terminal.window_title')); + } +} diff --git a/app/Livewire/Admin/Settings.php b/app/Livewire/Admin/Settings.php index 1080a45..4cc03d7 100644 --- a/app/Livewire/Admin/Settings.php +++ b/app/Livewire/Admin/Settings.php @@ -12,6 +12,9 @@ use App\Models\VpnPeer; use App\Provisioning\Jobs\ApplyVpnPeer; use App\Services\Deployment\UpdateChannel; use App\Services\Deployment\UpdateWindow; +use App\Services\Terminal\ServerIdentity; +use App\Services\Terminal\ServerTerminalSetup; +use App\Services\Terminal\ServerTicket; use App\Support\PasswordPolicy; use App\Support\Settings as AppSettings; use Illuminate\Support\Facades\Auth; @@ -21,6 +24,7 @@ use Illuminate\Support\Facades\Password; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; +use Livewire\Attributes\On; use Livewire\Attributes\Url; use Livewire\Attributes\Validate; use Livewire\Component; @@ -541,6 +545,37 @@ class Settings extends Component $this->dispatch('notify', message: __($on ? 'admin_settings.console_locked' : 'admin_settings.console_unlocked')); } + /** + * Eine neue SSH-Kennung für das Terminal zum CluPilot-Server selbst. + * + * `server.terminal` und nichts anderes: wer diese Kennung erzeugt, bestimmt, + * mit welchem Schlüssel eine Root-Schale in die Steuerungsebene aufgeht. + * `site.manage` — die Berechtigung des Abschnitts nebenan — ist dafür die + * falsche Frage. + * + * Ersetzen sperrt aus, bis der Server neu eingerichtet ist. Deshalb die + * Rückfrage davor (Admin\ConfirmGenerateServerKey) und deshalb sagt die + * Karte danach, welche Zeile auf dem Server noch fehlt. + */ + public function generateServerKey(): void + { + $this->authorize('server.terminal'); + + /** @var Operator $operator */ + $operator = Auth::guard('operator')->user(); + + ServerIdentity::generate($operator); + + $this->dispatch('notify', message: __('admin_settings.server_terminal_generated')); + } + + /** ConfirmGenerateServerKey schickt das hierher zurück (R23) — siehe dort. */ + #[On('server-key-generate-confirmed')] + public function onGenerateServerKeyConfirmed(): void + { + $this->generateServerKey(); + } + /** Whether two-factor is compulsory for every operator on the console. */ public bool $requireTwoFactor = false; @@ -731,6 +766,15 @@ class Settings extends Component (array) config('admin_access.structural_ranges', []), )), 'viewerIp' => (string) request()->ip(), + // Das Terminal zum Server selbst. Alle vier Werte, damit die Karte + // sagen kann, WARUM der Knopf gesperrt ist — ein Knopf, der ohne + // Begründung nicht geht, schickt den Betreiber auf die Suche. + 'canServerTerminal' => $operator?->can('server.terminal') ?? false, + 'serverTerminalProblem' => ServerTicket::blocker() ?? '', + // Ohne den Schalter von RestrictConsoleNetwork gefragt: für diese + // Tür gilt die Liste immer, auch wenn die Konsole gerade offen ist. + 'serverTerminalOnNetwork' => RestrictConsoleNetwork::allows((string) request()->ip()), + 'serverTerminalInstalledAt' => ServerTerminalSetup::read()?->installedAt, 'staff' => $staff, 'roles' => Operator::operatorRoles(), 'canManageStaff' => $operator?->can('staff.manage') ?? false, diff --git a/app/Services/Secrets/SecretVault.php b/app/Services/Secrets/SecretVault.php index d489d40..196d4aa 100644 --- a/app/Services/Secrets/SecretVault.php +++ b/app/Services/Secrets/SecretVault.php @@ -141,6 +141,27 @@ final class SecretVault // editor names both, since either can be the one actually in force. 'env_key' => 'CLUPILOT_SSH_PRIVATE_KEY / CLUPILOT_SSH_PRIVATE_KEY_PATH', ], + /* + | Die Kennung für das Terminal auf den CluPilot-Server selbst. + | + | `internal`: steht NICHT auf der Zugangsdaten-Seite. Jeder andere + | Eintrag hier wird von jemand anderem ausgestellt (Stripe, Hetzner, der + | Mailhost) und kann nur eingefügt werden. Dieser wird erzeugt, und seine + | öffentliche Hälfte hat ein Skript auf dem Wirt in + | /root/.ssh/authorized_keys geschrieben. Ein Eingabefeld daneben wäre + | eine Einladung, ihn zu ersetzen — wonach das Terminal ohne sichtbaren + | Zusammenhang nicht mehr aufgeht. Erzeugt und ersetzt wird er dort, wo + | auch steht, was danach auf dem Server zu tun ist: unter Einstellungen. + | + | `strict`: kein Rückfall auf die Serverdatei. Ohne das könnte eine Zeile + | in .env bestimmen, mit welchem Schlüssel eine Root-Schale aufgeht. + */ + 'server.private_key' => [ + 'config' => 'provisioning.server_terminal.private_key', + 'label' => 'secrets.item.server_private_key', + 'internal' => true, + 'strict' => true, + ], ]; public function has(string $key): bool diff --git a/app/Services/Terminal/ServerIdentity.php b/app/Services/Terminal/ServerIdentity.php new file mode 100644 index 0000000..ad2aafd --- /dev/null +++ b/app/Services/Terminal/ServerIdentity.php @@ -0,0 +1,75 @@ +get(self::VAULT_KEY)); + } + + /** + * Erzeugt eine neue Kennung und ersetzt eine bestehende. + * + * Ersetzen sperrt aus: die alte öffentliche Hälfte steht auf dem Wirt in + * `authorized_keys` und wird von hier aus nicht angefasst. Zwischen dem + * Erzeugen und dem nächsten `install-agent.sh` erreicht das Terminal den + * Server nicht. Deshalb hängt an dieser Methode eine Rückfrage (R23), und + * deshalb sagt die Einstellungsseite danach, was noch zu tun ist. + */ + public static function generate(Operator $by): void + { + app(SecretVault::class)->put(self::VAULT_KEY, Keypair::generate()->privateKey, $by); + } + + /** + * Die öffentliche Hälfte als eine Zeile `ssh-ed25519 AAAA… clupilot`, oder + * null, wenn es keine Kennung gibt. + * + * Genau diese Zeile holt sich der root-eigene Schritt auf dem Wirt über + * `php artisan clupilot:server-terminal-key`. Sie ist öffentlich — das + * Kommando gibt nichts preis, was nicht ohnehin in einer authorized_keys + * stünde. + */ + public static function publicKey(): ?string + { + $private = (string) app(SecretVault::class)->get(self::VAULT_KEY); + + if (blank($private)) { + return null; + } + + try { + return PublicKeyLoader::load($private) + ->getPublicKey() + ->toString('OpenSSH', ['comment' => Keypair::COMMENT]); + } catch (Throwable) { + // Unlesbar gespeichert heißt „keine Kennung". Nicht werfen: die + // Aufrufer sind ein Kommando, das eine Zeile ausgeben soll, und + // eine Seite, die nie mit einer Fehlerseite aufgehen darf. + return null; + } + } +} diff --git a/app/Services/Terminal/ServerTerminalSetup.php b/app/Services/Terminal/ServerTerminalSetup.php new file mode 100644 index 0000000..0523778 --- /dev/null +++ b/app/Services/Terminal/ServerTerminalSetup.php @@ -0,0 +1,87 @@ + "`, NICHT der + * OpenSSH-Fingerabdruck — siehe docker/terminal/bridge.py). Er ist der Grund, + * warum eine Sitzung überhaupt sicher ist: ohne ihn meldet sich die Brücke + * mit einem Root-Schlüssel bei irgendwem an, der gerade auf Port 22 antwortet. + * + * Geschrieben wird die Datei von `/usr/local/sbin/clupilot-host-step + * install-server-terminal`, also von der root-eigenen Hälfte, die das + * Dienstkonto nicht anfassen kann. Gelesen wird sie hier. + */ +final readonly class ServerTerminalSetup +{ + public function __construct( + public string $address, + public string $fingerprint, + public ?string $installedAt, + ) {} + + public static function path(): string + { + return storage_path('app/deploy/server-terminal.json'); + } + + /** + * Die Einrichtung, oder null, wenn es keine brauchbare gibt. + * + * Fehlt die Datei, ist das der Normalfall vor dem ersten + * `sudo bash deploy/install-agent.sh` — dazu gibt es nichts zu sagen. Ist + * sie aber DA und trotzdem unbrauchbar, wird das protokolliert: sonst sehen + * „nie eingerichtet" und „Einrichtung kaputt" von der Seite aus gleich aus, + * und die Anleitung, die dann dort steht, hilft im zweiten Fall nicht. + */ + public static function read(): ?self + { + $path = self::path(); + + if (! File::exists($path)) { + return null; + } + + try { + $data = json_decode(File::get($path), true, flags: JSON_THROW_ON_ERROR); + } catch (Throwable $e) { + Log::warning('Die Einrichtung des Server-Terminals ist nicht lesbar.', [ + 'path' => $path, + 'error' => $e->getMessage(), + ]); + + return null; + } + + $address = is_array($data) ? (string) ($data['address'] ?? '') : ''; + $fingerprint = is_array($data) ? (string) ($data['fingerprint'] ?? '') : ''; + + // Geprüft, nicht geglaubt. Eine leere Adresse ergäbe ein Fenster, das + // nie verbindet; ein leerer Fingerabdruck ist das Gegenteil von + // harmlos — die naheliegendste Lesart davon („nichts zu prüfen") wäre + // eine ungepinnte Root-Sitzung. + if (filter_var($address, FILTER_VALIDATE_IP) === false || ! str_starts_with($fingerprint, 'SHA256:')) { + Log::warning('Die Einrichtung des Server-Terminals ist unvollständig.', ['path' => $path]); + + return null; + } + + return new self( + address: $address, + fingerprint: $fingerprint, + installedAt: ($data['installed_at'] ?? null) ? (string) $data['installed_at'] : null, + ); + } +} diff --git a/app/Services/Terminal/ServerTicket.php b/app/Services/Terminal/ServerTicket.php new file mode 100644 index 0000000..564824e --- /dev/null +++ b/app/Services/Terminal/ServerTicket.php @@ -0,0 +1,90 @@ + throw new RuntimeException('Für den Server ist keine SSH-Kennung erzeugt — ohne sie kann keine Terminalsitzung entstehen.'), + 'not_installed' => throw new RuntimeException('Der Server ist für das Terminal noch nicht eingerichtet — es fehlt storage/app/deploy/server-terminal.json.'), + default => null, + }; + + // blocker() hat die Datei eben gelesen; dass sie zwischen den beiden + // Zeilen verschwindet, wäre ein Wettlauf mit dem Einrichtungsskript. + // Die Prüfung steht trotzdem da: die Alternative wäre ein Zugriff auf + // null — also eine Fehlerseite in einem Terminalfenster. + $setup = ServerTerminalSetup::read() + ?? throw new RuntimeException('Die Einrichtung des Server-Terminals ist zwischen Prüfung und Ausstellung verschwunden.'); + + // Wer eine Root-Konsole in die Steuerungsebene aufmacht, steht mit + // Namen im Log. Die Brücke kennt nur die Nummer des Betreibers und + // nicht die Adresse, von der aus gefragt wurde — beides weiß allein + // diese Seite der Anwendung, und ohne diese Zeile wäre es nirgends + // festgehalten. Der INHALT der Sitzung wird bewusst nicht + // mitgeschrieben: er enthält zwangsläufig Passwörter und Schlüssel im + // Klartext, und ein Mitschnitt davon wäre ein zweites Geheimnislager. + Log::warning('Root-Konsole zum CluPilot-Server angefordert.', [ + 'operator' => $for->name, + 'operator_id' => $for->id, + 'from' => request()->ip(), + ]); + + return TicketStore::issue([ + 'operator_id' => $for->id, + 'host_uuid' => 'server', + 'ip' => $setup->address, + 'user' => 'root', + 'private_key' => (string) app(SecretVault::class)->get(ServerIdentity::VAULT_KEY), + 'fingerprint' => $setup->fingerprint, + ]); + } +} diff --git a/app/Services/Terminal/TerminalTicket.php b/app/Services/Terminal/TerminalTicket.php index cf4b63d..3018ebe 100644 --- a/app/Services/Terminal/TerminalTicket.php +++ b/app/Services/Terminal/TerminalTicket.php @@ -5,62 +5,24 @@ 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. + * Die Eintrittskarte für eine Terminalsitzung AUF EINEM HOST — und die einzige + * Stelle, die weiß, was für einen Host 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. + * 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 { - public const TTL_SECONDS = 30; - - private const PREFIX = 'terminal:ticket:'; + /** + * 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. @@ -112,9 +74,7 @@ final class TerminalTicket $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([ + return TicketStore::issue([ 'operator_id' => $for->id, 'host_uuid' => $host->uuid, // Die Tunneladresse. Die öffentliche IP wäre der Weg, den @@ -123,28 +83,18 @@ final class TerminalTicket '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. + * `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 { - $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); + /** @var array{operator_id: int, host_uuid: string, ip: string, user: string, private_key: string, fingerprint: string}|null */ + return TicketStore::redeem($ticket); } } diff --git a/app/Services/Terminal/TicketStore.php b/app/Services/Terminal/TicketStore.php new file mode 100644 index 0000000..d052207 --- /dev/null +++ b/app/Services/Terminal/TicketStore.php @@ -0,0 +1,93 @@ +`, 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. Der + * Container muss also denselben Wert voranstellen; er kann ihn aus dieser + * Klasse allein nicht erraten, deshalb steht er hier. + */ +final class TicketStore +{ + /** + * Dreißig Sekunden, weil ein Ticket nur den Weg vom Klick zum offenen + * Fenster überbrücken muss. Wer die Seite offen liegen lässt und später neu + * lädt, bekommt ein frisches. + */ + public const TTL_SECONDS = 30; + + private const PREFIX = 'terminal:ticket:'; + + /** + * Legt eine Sitzung ab und gibt den Schlüssel zurück, den der Browser + * bekommt: zweiunddreißig Byte Zufall, sonst nichts. + * + * Alles, was die Brücke wirklich braucht — Adresse, 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. + * + * @param array $payload + */ + public static function issue(array $payload): string + { + $ticket = bin2hex(random_bytes(32)); + + Redis::connection('cache')->setex( + self::PREFIX.$ticket, + self::TTL_SECONDS, + json_encode($payload, JSON_THROW_ON_ERROR), + ); + + return $ticket; + } + + /** + * Liest das Ticket und löscht es im selben Zug — mit Redis' eigenem + * `GETDEL`, nicht mit zwei Aufrufen. Erst das macht „genau eine Einlösung" + * zu einer Zusage, die auch unter zwei gleichzeitigen Versuchen hält. + * + * @return array|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); + } +} diff --git a/config/provisioning.php b/config/provisioning.php index 9ae8917..c306e6f 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -533,6 +533,22 @@ return [ 'command_timeout' => (int) env('CLUPILOT_SSH_COMMAND_TIMEOUT', 2000), ], + /* + | Die zweite SSH-Kennung: die, mit der das Terminal auf den CluPilot-Server + | SELBST kommt. Getrennt vom Flottenschlüssel darüber, weil dessen + | öffentliche Hälfte auf jedem übernommenen Host liegt — ein Schlüssel für + | beides machte aus zwei Türen eine. + | + | KEIN env(): dieser Schlüssel wird in der Konsole erzeugt und liegt + | verschlüsselt im Tresor. Der Eintrag steht hier, weil SecretVault::REGISTRY + | für jeden Eintrag einen Konfigurationspfad nennt; gelesen wird er nie — + | `strict` schneidet den Rückfall auf die Serverdatei ab, damit niemand die + | Kennung für eine Root-Schale über eine Zeile in .env umbiegen kann. + */ + 'server_terminal' => [ + 'private_key' => null, + ], + // Proxmox automation role/user created on each host. 'proxmox' => [ 'role_id' => 'CluPilotAutomation', diff --git a/database/migrations/2026_08_04_160000_eine_faehigkeit_fuer_das_terminal_zum_server.php b/database/migrations/2026_08_04_160000_eine_faehigkeit_fuer_das_terminal_zum_server.php new file mode 100644 index 0000000..3cc616c --- /dev/null +++ b/database/migrations/2026_08_04_160000_eine_faehigkeit_fuer_das_terminal_zum_server.php @@ -0,0 +1,45 @@ +forgetCachedPermissions(); + + Permission::findOrCreate('server.terminal', 'operator'); + Role::findOrCreate('Owner', 'operator')->givePermissionTo('server.terminal'); + + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } + + public function down(): void + { + app(PermissionRegistrar::class)->forgetCachedPermissions(); + Permission::query()->where('name', 'server.terminal')->where('guard_name', 'operator')->delete(); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + } +}; diff --git a/deploy/install-agent.sh b/deploy/install-agent.sh index da15f98..030bd9e 100755 --- a/deploy/install-agent.sh +++ b/deploy/install-agent.sh @@ -52,7 +52,7 @@ cat > "$HOST_STEP" <<'EOF' # One argument, from the list below. Nothing takes free-form input. set -euo pipefail -CONTRACT=2 +CONTRACT=3 case "${1:-}" in contract) @@ -154,6 +154,93 @@ case "${1:-}" in systemctl reload caddy ;; + install-server-terminal) + # Die Tür, durch die das Terminal auf DIESEN Server kommt. + # + # Nicht in sudoers, anders als die beiden Schritte darüber, und das ist + # Absicht: dieser Schritt schreibt einen Schlüssel nach + # /root/.ssh/authorized_keys. Stünde er dort, könnte das Dienstkonto + # sich jederzeit selbst root geben, indem es in der Anwendung eine neue + # Kennung erzeugt und dann den Schritt anstößt. So braucht es dafür + # einen Menschen mit sudo — genau einmal, und wieder bei jedem Wechsel + # der Kennung. Die Konsole schreibt die Zeile hin, wenn es so weit ist. + CHECKOUT=/opt/clupilot + [[ -d "$CHECKOUT" ]] || { echo "no checkout at $CHECKOUT" >&2; exit 3; } + command -v openssl >/dev/null 2>&1 || { echo "openssl is not installed" >&2; exit 3; } + + # ── 1. Die öffentliche Hälfte, aus der Anwendung geholt ────────────── + # Geholt, nicht gereicht: derselbe Gedanke wie bei apply-proxy-hosts. + KEY="$(cd "$CHECKOUT" && runuser -u clupilot -- docker compose exec -T -u www-data app \ + php artisan clupilot:server-terminal-key 2>/dev/null)" \ + || { echo "could not read the server terminal key from the application" >&2; exit 4; } + + # ── 2. Genau eine Zeile, und zwar eine, die wie ein Schlüssel aussieht ─ + # Das ist die Stelle, an der aus „die Anwendung nennt ihren Schlüssel" + # nicht „die Anwendung schreibt in authorized_keys" werden darf: eine + # zweite Zeile wäre ein zweiter Schlüssel, und ein Zeilenumbruch im Wert + # ist der ganze Trick. + KEY="$(printf '%s\n' "$KEY" | tr -d '\r' | grep . || true)" + [[ "$(printf '%s\n' "$KEY" | grep -c .)" -eq 1 ]] \ + || { echo "refusing: expected exactly one key line" >&2; exit 5; } + [[ "$KEY" =~ ^ssh-ed25519\ [A-Za-z0-9+/]+=*(\ [^[:space:]]+)?$ ]] \ + || { echo "refusing the key line: not a single ssh-ed25519 entry" >&2; exit 5; } + + # ── 3. Woher der Container kommt, und wie er hierher schaut ────────── + # Beides gemessen statt geraten: das Netz heißt nach dem Projekt, und + # die Adresse, unter der der Wirt aus dem Container sichtbar ist, ist + # das Gateway dieses Netzes. Auf der nächsten Installation sind beide + # anders. + CID="$(cd "$CHECKOUT" && runuser -u clupilot -- docker compose ps -q vpn-hub)" + [[ -n "$CID" ]] || { echo "vpn-hub is not running — start the stack first" >&2; exit 3; } + NET_NAME="$(docker inspect "$CID" -f '{{range $n, $c := .NetworkSettings.Networks}}{{$n}}{{end}}')" + GATEWAY="$(docker inspect "$CID" -f '{{range .NetworkSettings.Networks}}{{.Gateway}}{{end}}')" + SUBNET="$(docker network inspect "$NET_NAME" -f '{{(index .IPAM.Config 0).Subnet}}')" + [[ -n "$GATEWAY" && -n "$SUBNET" ]] || { echo "could not read the container network" >&2; exit 3; } + + # ── 4. Eintragen, mit fester Optionsliste ──────────────────────────── + # Die Optionen stehen HIER und kommen nicht aus der Anwendung: `from` + # bindet den Schlüssel an das Container-Netz, `restrict` nimmt ihm alles + # ausser einer Sitzung — kein Port-Forwarding, kein Agent, kein X11 —, + # und `pty` gibt genau das eine zurück, ohne das ein Terminal keines ist. + # + # Ersetzen statt anhängen: hinter der Markierung steht immer genau eine + # Zeile, und ein zweiter Lauf hinterlässt keinen zweiten Schlüssel. + MARK="# clupilot-server-terminal" + AK=/root/.ssh/authorized_keys + install -d -m 0700 -o root -g root /root/.ssh + [[ -f "$AK" ]] || { : > "$AK"; chmod 0600 "$AK"; } + + TMP="$(mktemp)" + awk -v mark="$MARK" 'skip { skip=0; next } $0 == mark { skip=1; next } { print }' "$AK" > "$TMP" + printf '%s\nfrom="%s",restrict,pty %s\n' "$MARK" "$SUBNET" "$KEY" >> "$TMP" + install -o root -g root -m 0600 "$TMP" "$AK" + rm -f "$TMP" + + # ── 5. Der Fingerabdruck, IN CLUPILOTS FORM ───────────────────────── + # `SHA256:` base64 über der Zeichenkette " ", + # NICHT der OpenSSH-Fingerabdruck (der hasht den Blob selbst). Wer das + # hier verwechselt, bekommt bei jedem Verbindungsversuch eine Abweichung + # — und die Versuchung, die Prüfung für kaputt zu halten. Die + # Gegenstelle steht in docker/terminal/bridge.py, fingerprints(). + # + # Ed25519, weil paramiko in genau dieser Reihenfolge aushandelt und + # ssh-ed25519 an erster Stelle steht. Fehlt er, wird nichts geraten. + HOSTKEY=/etc/ssh/ssh_host_ed25519_key.pub + [[ -f "$HOSTKEY" ]] || { echo "no ed25519 host key at $HOSTKEY" >&2; exit 3; } + FINGERPRINT="SHA256:$(printf '%s %s' "$(awk '{print $1}' "$HOSTKEY")" "$(awk '{print $2}' "$HOSTKEY")" \ + | openssl dgst -sha256 -binary | openssl base64)" + + # ── 6. Was die Anwendung davon wissen muss ────────────────────────── + # Kein Geheimnis darin: eine Adresse und ein Hash. Deshalb lesbar für + # das Dienstkonto, das den Ticket-Inhalt daraus baut. + OUT="$CHECKOUT/storage/app/deploy/server-terminal.json" + TMP="$(mktemp)" + printf '{"address":"%s","fingerprint":"%s","installed_at":"%s"}\n' \ + "$GATEWAY" "$FINGERPRINT" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$TMP" + install -o clupilot -g clupilot -m 0644 "$TMP" "$OUT" + rm -f "$TMP" + ;; + *) # Refused by name. sudoers already permits only the exact command lines # above, so this is the second of two locks, not the only one. @@ -188,6 +275,14 @@ if ! command -v rsync >/dev/null 2>&1; then || echo " ! Could not install rsync — the invoice archive cannot be collected until it is there." fi +# Und die Tür für das Terminal auf diesen Server — hier und nicht in sudoers, +# siehe die Begründung am Schritt selbst. Nicht fatal: auf einer Installation, +# in der noch keine Kennung erzeugt wurde, ist "geht nicht" die richtige +# Antwort, und die Konsole sagt dann, was zuerst zu tun ist. +echo " Installing the server terminal key (Einstellungen → Terminal zum Server)" +"$HOST_STEP" install-server-terminal \ + || echo " ! Not set up — generate the identity in the console first, then run this script again." + cat > /etc/systemd/system/clupilot-update-agent.service < 'Hinzufügen', 'console_remove' => 'Entfernen', 'console_ip_hint' => 'Einzelne Adresse oder Bereich, z. B. 203.0.113.7 oder 203.0.113.0/24.', + 'console_terminal_note' => 'Dieselben Adressen entscheiden auch, von wo aus das Terminal zum CluPilot-Server aufgeht — unabhängig davon, ob die Sperre oben ein- oder ausgeschaltet ist.', + + // Terminal zum Server selbst + 'server_terminal_title' => 'Terminal zum Server', + 'server_terminal_body' => 'Eine Root-Sitzung auf dem CluPilot-Server selbst — nicht auf einem Host. Wer sie öffnet, erreicht Datenbank, Tresor, DNS-Token und jede Kundeninstanz.', + 'server_terminal_installed' => 'Eingerichtet am :when.', + 'server_terminal_off_network' => 'Von :ip aus geht sie nicht auf. Nur aus dem Management-Netz oder von einer der Adressen oben.', + 'server_terminal_no_key' => 'Es gibt noch keine Kennung. Sie wird hier erzeugt und danach einmal auf dem Server hinterlegt.', + 'server_terminal_not_installed' => 'Die Kennung ist erzeugt, auf dem Server aber noch nicht hinterlegt. Dort einmal ausführen:', + 'server_terminal_generate' => 'Kennung erzeugen', + 'server_terminal_regenerate' => 'Kennung neu erzeugen', + 'server_terminal_key_hint' => 'Eigener Schlüssel, getrennt vom Flottenschlüssel für die Hosts.', + 'server_terminal_generated' => 'Neue Kennung erzeugt. Auf dem Server noch „sudo bash deploy/install-agent.sh" ausführen.', + 'server_terminal_generate_title' => 'Neue Kennung für das Server-Terminal?', + 'server_terminal_generate_body' => 'Die bisherige steht auf dem Server in /root/.ssh/authorized_keys und wird von hier aus nicht entfernt. Bis „sudo bash deploy/install-agent.sh" dort gelaufen ist, geht das Terminal zum Server nicht auf.', + 'server_terminal_generate_confirm' => 'Neu erzeugen', 'console_ip_invalid' => 'Das ist keine gültige IP-Adresse und kein gültiger Bereich.', 'console_ip_added' => ':ip darf die Konsole jetzt erreichen.', 'console_ip_removed' => ':ip wurde entfernt.', diff --git a/lang/de/roles.php b/lang/de/roles.php index e9d1147..7e9166b 100644 --- a/lang/de/roles.php +++ b/lang/de/roles.php @@ -27,6 +27,7 @@ return [ 'datacenters.manage' => 'Rechenzentren verwalten', 'dpa.manage' => 'Auftragsverarbeitungsverträge', 'hosts.manage' => 'Hosts anlegen, übernehmen, entfernen', + 'server.terminal' => 'Root-Sitzung auf dem CluPilot-Server selbst — Vollzugriff auf die gesamte Anlage', 'instances.adminlogin' => 'Sich in die Cloud eines Kunden einloggen', 'instances.manage' => 'Sicherheitssperren einer Kunden-Instanz sehen und aufheben', 'instances.restart' => 'Eine Kunden-Cloud neu starten', diff --git a/lang/de/secrets.php b/lang/de/secrets.php index 44b500d..bcc359f 100644 --- a/lang/de/secrets.php +++ b/lang/de/secrets.php @@ -10,6 +10,7 @@ return [ 'dns_token' => 'Hetzner-DNS-API-Token', 'monitoring_token' => 'Uptime-Kuma-API-Token', 'ssh_private_key' => 'SSH-Schlüssel (privat)', + 'server_private_key' => 'Serverschlüssel für das Terminal (privat)', ], 'ssh_private_key_hint' => 'Diese Identität wird auf jedem neuen Host hinterlegt — der empfindlichste Wert im System, er öffnet jeden Server, den CluPilot verwaltet.', diff --git a/lang/de/server.php b/lang/de/server.php new file mode 100644 index 0000000..c2c32f5 --- /dev/null +++ b/lang/de/server.php @@ -0,0 +1,76 @@ + [ + + 'window_title' => 'CluPilot-Server — Terminal', + + // Auf der Einstellungsseite, am Knopf. + 'open' => 'Terminal öffnen', + 'hint' => 'Öffnet ein eigenes Fenster mit einer Root-Sitzung auf dem CluPilot-Server.', + + // Was auf der Bühne steht — dem Bild, das über dem Terminal liegt. + // Dieselben sechs Fälle wie beim Host, weil es dieselbe Brücke ist; + // nur nennen sie hier keinen Hostnamen, sondern die eine Maschine, auf + // der diese Konsole selbst läuft. + 'stage' => [ + 'connecting' => [ + 'title' => 'Verbinde mit dem CluPilot-Server', + 'note' => 'Das Einmal-Ticket wird eingelöst und die Sitzung aufgebaut.', + ], + 'ended' => [ + 'title' => 'Sitzung beendet', + 'note' => 'Die Verbindung zum Server wurde geschlossen. Ein neuer Anlauf öffnet eine frische Sitzung.', + ], + 'lost' => [ + 'title' => 'Keine Verbindung', + 'note' => 'Es kam keine Sitzung zustande. Läuft der Terminal-Dienst?', + ], + 'rejected' => [ + 'title' => 'Ticket nicht mehr gültig', + 'note' => 'Ein Ticket gilt dreißig Sekunden und genau einmal. Dieses war abgelaufen oder schon eingelöst — ein neuer Anlauf stellt ein frisches aus.', + ], + // Absichtlich ohne Festlegung: die Brücke schickt 4502 für JEDE + // gescheiterte Anmeldung. Beim Server sind zwei Fälle so viel + // wahrscheinlicher als der Rest, dass sie hier stehen — und beide + // haben dieselbe Rückfahrkarte. + 'unreachable' => [ + 'title' => 'Keine Sitzung auf dem Server', + 'note' => 'Die Brücke kam nicht bis zur Kommandozeile. Am wahrscheinlichsten sind zwei Gründe: der Serverschlüssel steht nicht mehr in /root/.ssh/authorized_keys, oder der Server hat seine Hostschlüssel neu erzeugt und der hinterlegte Fingerabdruck passt nicht mehr. Beides räumt ein erneutes „sudo bash deploy/install-agent.sh" auf dem Server aus. Was genau es war, steht im Protokoll des Terminal-Dienstes; dieses Fenster erfährt es absichtlich nicht.', + ], + 'service_down' => [ + 'title' => 'Der Terminal-Dienst läuft nicht', + 'note' => 'Die Konsole steht, die Brücke dahinter nicht: nginx meldet für diese Stelle 502. Auf dem Server hilft „docker compose up -d terminal". Sie teilt sich den Netz-Namensraum des Tunnel-Containers und muss nach jedem Neustart des Tunnels selbst mit neu gestartet werden — sonst lauscht sie in einem Namensraum, den es nicht mehr gibt, und meldet dabei weiter „healthy".', + ], + 'not_here' => [ + 'title' => 'Auf diesem Namen gibt es kein Terminal', + 'note' => 'Die Brücke ist Werkzeug der Konsole und antwortet auf öffentlichen Hostnamen mit 404 — dieselbe Regel wie für /admin. Öffne die Konsole unter ihrem eigenen Namen.', + ], + 'retry' => 'Neu verbinden', + ], + + // Gründe, die schon der Server kennt. Sie stehen sofort da, statt dass + // ein Fenster aufgeht und auf eine Verbindung wartet, die es nicht + // geben kann. + 'problem' => [ + 'no_key' => [ + 'title' => 'Für den Server ist keine Kennung erzeugt', + 'note' => 'Das Terminal zum Server hat einen eigenen SSH-Schlüssel, getrennt vom Flottenschlüssel. Er wird unter Einstellungen erzeugt.', + ], + 'not_installed' => [ + 'title' => 'Der Server ist dafür noch nicht eingerichtet', + 'note' => 'Der Schlüssel ist erzeugt, aber auf dem Server noch nicht hinterlegt. Dort einmal „sudo bash deploy/install-agent.sh" ausführen — das trägt ihn ein und hält den Fingerabdruck des Servers fest.', + ], + 'off_network' => [ + 'title' => 'Nicht von dieser Adresse', + 'note' => 'Eine Root-Sitzung auf dem Server geht nur aus dem Management-Netz oder von einer freigegebenen Adresse auf. Wer im VPN steht, kommt hier durch — die Liste steht unter Einstellungen.', + ], + 'unknown' => [ + 'title' => 'Das Terminal lässt sich gerade nicht öffnen', + 'note' => 'Beim Ausstellen des Tickets ist etwas schiefgegangen. Der Vorfall steht im Protokoll; ein neuer Anlauf ist einen Versuch wert.', + ], + ], + ], + +]; diff --git a/lang/en/admin_settings.php b/lang/en/admin_settings.php index d7b9375..2d6313d 100644 --- a/lang/en/admin_settings.php +++ b/lang/en/admin_settings.php @@ -71,6 +71,22 @@ return [ 'console_add' => 'Add', 'console_remove' => 'Remove', 'console_ip_hint' => 'A single address or a range, e.g. 203.0.113.7 or 203.0.113.0/24.', + 'console_terminal_note' => 'The same addresses also decide where the terminal to the CluPilot server opens from — whether or not the lock above is switched on.', + + // Terminal to the server itself + 'server_terminal_title' => 'Terminal to the server', + 'server_terminal_body' => 'A root session on the CluPilot server itself — not on a host. Whoever opens it reaches the database, the vault, the DNS token and every customer instance.', + 'server_terminal_installed' => 'Set up on :when.', + 'server_terminal_off_network' => 'It does not open from :ip. Only from the management network or one of the addresses above.', + 'server_terminal_no_key' => 'There is no identity yet. It is generated here and then installed on the server once.', + 'server_terminal_not_installed' => 'The identity exists but has not been installed on the server. Run this there once:', + 'server_terminal_generate' => 'Generate identity', + 'server_terminal_regenerate' => 'Generate a new identity', + 'server_terminal_key_hint' => 'Its own key, separate from the fleet key for the hosts.', + 'server_terminal_generated' => 'New identity generated. Now run "sudo bash deploy/install-agent.sh" on the server.', + 'server_terminal_generate_title' => 'A new identity for the server terminal?', + 'server_terminal_generate_body' => 'The current one sits in /root/.ssh/authorized_keys on the server and is not removed from here. Until "sudo bash deploy/install-agent.sh" has run there, the terminal to the server will not open.', + 'server_terminal_generate_confirm' => 'Generate', 'console_ip_invalid' => 'That is not a valid IP address or range.', 'console_ip_added' => ':ip may now reach the console.', 'console_ip_removed' => ':ip removed.', diff --git a/lang/en/roles.php b/lang/en/roles.php index e68ae2c..f8ce752 100644 --- a/lang/en/roles.php +++ b/lang/en/roles.php @@ -27,6 +27,7 @@ return [ 'datacenters.manage' => 'Manage datacenters', 'dpa.manage' => 'Data processing agreements', 'hosts.manage' => 'Create, onboard and remove hosts', + 'server.terminal' => 'Root session on the CluPilot server itself — full access to the whole installation', 'instances.adminlogin' => 'Sign in to a customer cloud', 'instances.manage' => 'See and lift security blocks on a customer instance', 'instances.restart' => 'Restart a customer cloud', diff --git a/lang/en/secrets.php b/lang/en/secrets.php index 8238def..0ad4922 100644 --- a/lang/en/secrets.php +++ b/lang/en/secrets.php @@ -10,6 +10,7 @@ return [ 'dns_token' => 'Hetzner DNS API token', 'monitoring_token' => 'Uptime Kuma API token', 'ssh_private_key' => 'SSH key (private)', + 'server_private_key' => 'Server terminal key (private)', ], 'ssh_private_key_hint' => 'This identity is deployed to every fresh host — the most sensitive value in the system, it opens every server CluPilot manages.', diff --git a/lang/en/server.php b/lang/en/server.php new file mode 100644 index 0000000..340396f --- /dev/null +++ b/lang/en/server.php @@ -0,0 +1,64 @@ + [ + + 'window_title' => 'CluPilot server — terminal', + + 'open' => 'Open terminal', + 'hint' => 'Opens a separate window with a root session on the CluPilot server.', + + 'stage' => [ + 'connecting' => [ + 'title' => 'Connecting to the CluPilot server', + 'note' => 'The one-time ticket is being redeemed and the session set up.', + ], + 'ended' => [ + 'title' => 'Session ended', + 'note' => 'The connection to the server was closed. A new attempt opens a fresh session.', + ], + 'lost' => [ + 'title' => 'No connection', + 'note' => 'No session came about. Is the terminal service running?', + ], + 'rejected' => [ + 'title' => 'Ticket no longer valid', + 'note' => 'A ticket lasts thirty seconds and exactly one session. This one had expired or was already redeemed — a new attempt issues a fresh one.', + ], + 'unreachable' => [ + 'title' => 'No session on the server', + 'note' => 'The bridge did not reach a command line. Two reasons are by far the most likely: the server key is no longer in /root/.ssh/authorized_keys, or the server regenerated its host keys and the pinned fingerprint no longer matches. Running "sudo bash deploy/install-agent.sh" on the server clears up either. Which of the two it was is in the terminal service log; this window deliberately does not learn it.', + ], + 'service_down' => [ + 'title' => 'The terminal service is not running', + 'note' => 'The console is up, the bridge behind it is not: nginx answers 502 for this path. On the server, "docker compose up -d terminal" helps. It shares the tunnel container\'s network namespace and has to be restarted whenever the tunnel is — otherwise it listens in a namespace that no longer exists while still reporting "healthy".', + ], + 'not_here' => [ + 'title' => 'There is no terminal under this name', + 'note' => 'The bridge is a console tool and answers 404 on public hostnames — the same rule as /admin. Open the console under its own name.', + ], + 'retry' => 'Reconnect', + ], + + 'problem' => [ + 'no_key' => [ + 'title' => 'No identity has been generated for the server', + 'note' => 'The server terminal has its own SSH key, separate from the fleet key. It is generated under Settings.', + ], + 'not_installed' => [ + 'title' => 'The server is not set up for this yet', + 'note' => 'The key exists but has not been installed on the server. Run "sudo bash deploy/install-agent.sh" there once — that adds it and records the server\'s fingerprint.', + ], + 'off_network' => [ + 'title' => 'Not from this address', + 'note' => 'A root session on the server only opens from the management network or an allowed address. Anyone on the VPN gets through — the list is under Settings.', + ], + 'unknown' => [ + 'title' => 'The terminal cannot be opened right now', + 'note' => 'Something went wrong while issuing the ticket. The incident is in the log; a new attempt is worth a try.', + ], + ], + ], + +]; diff --git a/resources/views/components/admin/terminal-screen.blade.php b/resources/views/components/admin/terminal-screen.blade.php new file mode 100644 index 0000000..76f8b83 --- /dev/null +++ b/resources/views/components/admin/terminal-screen.blade.php @@ -0,0 +1,127 @@ +@props([ + // Das Ticket, das der Browser einlöst. Dreißig Sekunden, genau einmal. + 'ticket' => '', + // Was auf der Bühne steht, wenn die Seite aufgeht. + 'title' => '', + 'note' => '', + // Die Meldungen, die erst der Browser kennt (Schließcodes der Brücke). + 'messages' => [], + // Ob ein neuer Versuch überhaupt etwas ändern kann. + 'retry' => false, + 'retryLabel' => '', +]) +{{-- Schirm und Bühne für eine Terminalsitzung — geteilt von beiden Fenstern. + + Das gab es zuerst nur einmal, für einen Host. Als das Terminal zum + CluPilot-Server dazukam, waren zwei Wege möglich: abschreiben oder + herausziehen. Abgeschrieben wäre der Vorspann zweimal da und würde einmal + gepflegt — und was hier steht, ist zum guten Teil AM BILDSCHIRM GEMESSEN + (Zellbreiten, Schriftgrößen, welche Klasse welche schlägt). Genau solche + Zeilen überleben eine Abschrift nicht. + + Was NICHT hierher gehört: die Texte. Die kommen von der Seite, die diese + Bühne benutzt — ein Fenster spricht von einem Host, das andere vom Server, + und ein Bauteil, das beides selbst entscheidet, hätte einen Grund, ihre + Sprachdateien zu kennen. + + resources/js/terminal.js bleibt davon unberührt: es hängt an den + data-Attributen unten, nicht an einer Seite. --}} + +{{-- Die Bühne liegt ÜBER dem Terminal, nicht daneben. + + Bis hierher waren es zwei Geschwister, von denen abwechselnd eines + `hidden` trug: erst der Vorspann, dann der Schirm. Das trug genau einmal — + beim Aufbau. Für alles danach fehlte die Rückfahrkarte, und was der + Betreiber beim Abbruch sah, war eine rote ANSI-Zeile mitten in der eigenen + Ausgabe: die einzige Stelle im Produkt, an der eine Meldung nicht gestaltet + war, sondern getippt. + + Als Überlagerung kann dieselbe Bühne dreimal auftreten — beim Verbinden, + beim Abbruch, beim Ende — und die Sitzung darunter bleibt stehen, statt + gelöscht zu werden. Was auf ihr steht, kommt aus den Sprachdateien; die + Meldungen, die erst der Browser kennt, reisen als JSON in + `data-stage-messages` mit, damit auch sie dort stehen und nicht im Skript. --}} +
+ + {{-- wire:ignore: xterms eigener DOM-Baum liegt in einem von Livewire + verwalteten Baum. Heute hat keine der beiden Komponenten eine Aktion, + die einen Re-Render auslöst — aber das erste `wire:`-irgendwas auf so + einer Seite würde xterm sonst beim nächsten Umlauf löschen. --}} +
+ +
+ + {{-- Die Wortmarke als Schriftgrafik. + + `role="img"` mit lesbarem Namen statt `aria-hidden`: ein + Screenreader soll „CluPilot" hören, nicht die einzelnen Zeichen der + Zeichnung. + + Selbst gezeichnet, nicht aus einer Figlet-Schrift übernommen. Die + übernommene Fassung hatte im Bogen des `P` einen Überhang — eine + Zelle in der zweiten Zeile, unter der in der ersten nichts stand — + und der stand als loser Klotz mitten im Wort. Figlet-Schriften sind + für echte Terminalschriften gezeichnet und tragen hier nicht. Jeder + Buchstabe ist acht Zellen breit (das `I` zwei), mit einer Zelle + Abstand, und keine Zelle steht ohne Nachbarn darüber. Nicht von Hand + abgezählt, sondern aus einer Buchstabentabelle gesetzt — von Hand + hatte ich mich prompt verzählt, und die Buchstaben liefen ineinander. + + `█` füllt seine Zelle ganz aus und stapelt deshalb in jeder Schrift + sauber — anders als `_ / \ |`, die auf Berührung zwischen den Zeilen + angewiesen sind. Bei enger Zeilenhöhe verschmierte das Wort damit, + bei weiter zerfiel es. Beides im Browser durchgemessen. + + Immer Akzentfarbe, auch im Fehlerfall: Dunkelrot auf Fast-Schwarz + hatte kaum Kontrast, und eine Wortmarke, die ihre Farbe wechselt, ist + ohnehin keine Statuslampe — was los ist, sagt die Zeile darunter. + + Acht Zellen pro Buchstabe, nicht sechs: bei sechs standen sie hoch + und schmal da. Acht trifft die Proportion der Wortmarke. + + Zwei Größen: die Zeichnung ist 65 Zellen breit, und eine Zelle ist + gut 0,6 der Schriftgröße. 13px ergeben rund 500px — auf dem Schirm + eine Wortmarke, kein Etikett. Am Telefon sind es 7px und damit rund + 270px, was neben den Rändern noch hineinpasst. --}} + + + {{-- aria-live: die Bühne tauscht ihren Text aus, ohne dass die Seite neu + lädt. Ohne diesen Hinweis erführe ein Screenreader vom Abbruch + nichts — für ihn stünde das Fenster weiter beim Vorspann. --}} +
+

{{ $title }}

+

{{ $note }}

+
+ + {{-- Neu laden statt neu verbinden: das Ticket gilt dreißig Sekunden und + genau einmal, ein zweiter Anlauf mit demselben wäre keiner. Der + Knopf ist beim Vorspann verborgen und kommt erst, wenn ein neuer + Versuch überhaupt etwas ändern kann — bei „kein Schlüssel + hinterlegt" ändert er nichts, dort bleibt er weg. Was er tut, hängt + terminal.js an; ein `onclick` im Markup wäre Verhalten an der + Stelle, an der sonst nur Gestalt steht. + + `hidden` sitzt am Wrapper, nicht am Knopf. Nicht, weil es dort nicht + wirkte — Tailwind gibt `.hidden` als letzte der Display-Klassen aus + und schlägt `inline-flex` damit —, sondern weil die Klassen des + Knopfes aus einem geteilten Bauteil kommen und an dieser Stelle + niemandem gehören. Die Bühne selbst trägt `hidden` und `flex` + durchaus gemeinsam: das ist ein Element, das hier steht und hier + bleibt. --}} +
! $retry])> + + {{ $retryLabel }} + +
+
+
diff --git a/resources/views/livewire/admin/confirm-generate-server-key.blade.php b/resources/views/livewire/admin/confirm-generate-server-key.blade.php new file mode 100644 index 0000000..2e267d1 --- /dev/null +++ b/resources/views/livewire/admin/confirm-generate-server-key.blade.php @@ -0,0 +1,17 @@ +
+
+ + + +
+

{{ __('admin_settings.server_terminal_generate_title') }}

+

{{ __('admin_settings.server_terminal_generate_body') }}

+
+
+
+ {{ __('common.cancel') }} + + {{ __('admin_settings.server_terminal_generate_confirm') }} + +
+
diff --git a/resources/views/livewire/admin/host-terminal.blade.php b/resources/views/livewire/admin/host-terminal.blade.php index c9cc257..7c1e857 100644 --- a/resources/views/livewire/admin/host-terminal.blade.php +++ b/resources/views/livewire/admin/host-terminal.blade.php @@ -1,17 +1,3 @@ -{{-- Die Bühne liegt ÜBER dem Terminal, nicht daneben. - - Bis hierher waren es zwei Geschwister, von denen abwechselnd eines - `hidden` trug: erst der Vorspann, dann der Schirm. Das trug genau einmal — - beim Aufbau. Für alles danach fehlte die Rückfahrkarte, und was der - Betreiber beim Abbruch sah, war eine rote ANSI-Zeile mitten in der eigenen - Ausgabe: die einzige Stelle im Produkt, an der eine Meldung nicht gestaltet - war, sondern getippt. - - Als Überlagerung kann dieselbe Bühne dreimal auftreten — beim Verbinden, - beim Abbruch, beim Ende — und die Sitzung darunter bleibt stehen, statt - gelöscht zu werden. Was auf ihr steht, kommt aus den Sprachdateien; die - Meldungen, die erst der Browser kennt, reisen als JSON in - `data-stage-messages` mit, damit auch sie dort stehen und nicht im Skript. --}} @php // Ein Grund, den schon der Server kennt (kein Schlüssel hinterlegt, Host // noch nicht übernommen), steht sofort auf der Bühne. Sonst beginnt sie mit @@ -53,87 +39,15 @@ ], ]; @endphp -
- - {{-- wire:ignore: xterms eigener DOM-Baum liegt in einem von Livewire - verwalteten Baum. Heute hat die Komponente keine Aktion, die einen - Re-Render auslöst — aber das erste `wire:`-irgendwas auf dieser Seite - würde xterm sonst beim nächsten Umlauf löschen. --}} -
- -
- - {{-- Die Wortmarke als Schriftgrafik. - - `role="img"` mit lesbarem Namen statt `aria-hidden`: ein - Screenreader soll „CluPilot" hören, nicht die einzelnen Zeichen der - Zeichnung. - - Selbst gezeichnet, nicht aus einer Figlet-Schrift übernommen. Die - übernommene Fassung hatte im Bogen des `P` einen Überhang — eine - Zelle in der zweiten Zeile, unter der in der ersten nichts stand — - und der stand als loser Klotz mitten im Wort. Figlet-Schriften sind - für echte Terminalschriften gezeichnet und tragen hier nicht. Jeder - Buchstabe ist acht Zellen breit (das `I` zwei), mit einer Zelle - Abstand, und keine Zelle steht ohne Nachbarn darüber. Nicht von Hand - abgezählt, sondern aus einer Buchstabentabelle gesetzt — von Hand - hatte ich mich prompt verzählt, und die Buchstaben liefen ineinander. - - `█` füllt seine Zelle ganz aus und stapelt deshalb in jeder Schrift - sauber — anders als `_ / \ |`, die auf Berührung zwischen den Zeilen - angewiesen sind. Bei enger Zeilenhöhe verschmierte das Wort damit, - bei weiter zerfiel es. Beides im Browser durchgemessen. - - Immer Akzentfarbe, auch im Fehlerfall: Dunkelrot auf Fast-Schwarz - hatte kaum Kontrast, und eine Wortmarke, die ihre Farbe wechselt, ist - ohnehin keine Statuslampe — was los ist, sagt die Zeile darunter. - - Acht Zellen pro Buchstabe, nicht sechs: bei sechs standen sie hoch - und schmal da. Acht trifft die Proportion der Wortmarke. - - Zwei Größen: die Zeichnung ist 65 Zellen breit, und eine Zelle ist - gut 0,6 der Schriftgröße. 13px ergeben rund 500px — auf dem Schirm - eine Wortmarke, kein Etikett. Am Telefon sind es 7px und damit rund - 270px, was neben den Rändern noch hineinpasst. --}} - - - {{-- aria-live: die Bühne tauscht ihren Text aus, ohne dass die Seite neu - lädt. Ohne diesen Hinweis erführe ein Screenreader vom Abbruch - nichts — für ihn stünde das Fenster weiter beim Vorspann. --}} -
-

{{ $stageTitle }}

-

{{ $stageNote }}

-
- - {{-- Neu laden statt neu verbinden: das Ticket gilt dreißig Sekunden und - genau einmal, ein zweiter Anlauf mit demselben wäre keiner. Der - Knopf ist beim Vorspann verborgen und kommt erst, wenn ein neuer - Versuch überhaupt etwas ändern kann — bei „kein Schlüssel - hinterlegt" ändert er nichts, dort bleibt er weg. Was er tut, hängt - terminal.js an; ein `onclick` im Markup wäre Verhalten an der - Stelle, an der sonst nur Gestalt steht. - - `hidden` sitzt am Wrapper, nicht am Knopf. Nicht, weil es dort nicht - wirkte — Tailwind gibt `.hidden` als letzte der Display-Klassen aus - und schlägt `inline-flex` damit —, sondern weil die Klassen des - Knopfes aus einem geteilten Bauteil kommen und an dieser Stelle - niemandem gehören. Die Bühne selbst trägt `hidden` und `flex` - durchaus gemeinsam: das ist ein Element, das hier steht und hier - bleibt. --}} -
$problem !== 'unknown'])> - - {{ __('hosts.terminal.stage.retry') }} - -
-
-
+{{-- Schirm, Bühne und Wortmarke stehen seit dem zweiten Terminalfenster in + . Was hier bleibt, ist alles, was von einem HOST + handelt: seine Texte und die Frage, ob ein neuer Versuch etwas ändern kann. + Bei „kein Schlüssel hinterlegt" oder „Host noch nicht übernommen" ändert er + nichts — dort bleibt der Knopf weg. --}} + diff --git a/resources/views/livewire/admin/server-terminal.blade.php b/resources/views/livewire/admin/server-terminal.blade.php new file mode 100644 index 0000000..e6bf07f --- /dev/null +++ b/resources/views/livewire/admin/server-terminal.blade.php @@ -0,0 +1,52 @@ +@php + // Ein Grund, den schon der Server kennt, steht sofort auf der Bühne. Sonst + // beginnt sie mit dem Vorspann. + $stageTitle = $problem !== '' + ? __('server.terminal.problem.'.$problem.'.title') + : __('server.terminal.stage.connecting.title'); + + $stageNote = $problem !== '' + ? __('server.terminal.problem.'.$problem.'.note') + : __('server.terminal.stage.connecting.note'); + + // Was der Browser erst später braucht. Welcher Schlüssel wann gilt, steht + // in resources/js/terminal.js neben den Schließcodes der Brücke — dieselben + // sechs wie beim Host, weil es dieselbe Brücke ist. + $messages = [ + 'ended' => [ + 'title' => __('server.terminal.stage.ended.title'), + 'note' => __('server.terminal.stage.ended.note'), + ], + 'lost' => [ + 'title' => __('server.terminal.stage.lost.title'), + 'note' => __('server.terminal.stage.lost.note'), + ], + 'rejected' => [ + 'title' => __('server.terminal.stage.rejected.title'), + 'note' => __('server.terminal.stage.rejected.note'), + ], + 'unreachable' => [ + 'title' => __('server.terminal.stage.unreachable.title'), + 'note' => __('server.terminal.stage.unreachable.note'), + ], + 'service_down' => [ + 'title' => __('server.terminal.stage.service_down.title'), + 'note' => __('server.terminal.stage.service_down.note'), + ], + 'not_here' => [ + 'title' => __('server.terminal.stage.not_here.title'), + 'note' => __('server.terminal.stage.not_here.note'), + ], + ]; +@endphp +{{-- Der Neu-laden-Knopf nur bei `unknown`: bei „keine Kennung erzeugt", „noch + nicht eingerichtet" und „falsches Netz" ändert ein zweiter Anlauf nichts, + und ein Knopf, der nichts ändert, ist eine Einladung, ihn dreimal zu + drücken. --}} + diff --git a/resources/views/livewire/admin/settings.blade.php b/resources/views/livewire/admin/settings.blade.php index 1472bbf..71aea16 100644 --- a/resources/views/livewire/admin/settings.blade.php +++ b/resources/views/livewire/admin/settings.blade.php @@ -436,6 +436,77 @@

{{ __('admin_settings.console_ip_hint') }}

+ + {{-- Diese Liste hängt nicht nur an der Konsole. + Sie entscheidet auch, von wo aus eine + Root-Schale zum Server aufgeht — und das ist + der Preis dafür, beides an eine Liste zu + hängen: wer sie ändert, soll sehen, was daran + hängt, statt es zu erfahren, wenn das Terminal + nicht mehr aufgeht. --}} +

+ + {{ __('admin_settings.console_terminal_note') }} +

+ + + @endif + + {{-- Das Terminal zum Server selbst. Eigene Fähigkeit, eigener + Schlüssel, eigene Karte: was hier aufgeht, reicht nicht auf + eine Maschine, sondern auf alles. --}} + @if ($canServerTerminal) +
+
+
+

{{ __('admin_settings.server_terminal_title') }}

+

{{ __('admin_settings.server_terminal_body') }}

+ + @if ($serverTerminalProblem === '' && $serverTerminalInstalledAt) +

+ {{ __('admin_settings.server_terminal_installed', ['when' => \Illuminate\Support\Carbon::parse($serverTerminalInstalledAt)->local()->isoFormat('LLL')]) }} +

+ @endif +
+ + {{-- Zwei Zweige statt eines Knopfes mit gebundenem + `disabled`: eine Blade-Bindung in der + Attributliste eines Bauteils ist genau die + Falle aus R24 §3, und ein Link-Knopf, den man + nicht drücken soll, wäre trotzdem ein Link. --}} + @if ($serverTerminalProblem === '' && $serverTerminalOnNetwork) + + {{ __('server.terminal.open') }} + + @else + + {{ __('server.terminal.open') }} + + @endif +
+ +
+ @if (! $serverTerminalOnNetwork) +

{{ __('admin_settings.server_terminal_off_network', ['ip' => $viewerIp]) }}

+ @endif + + @if ($serverTerminalProblem === 'no_key') +

{{ __('admin_settings.server_terminal_no_key') }}

+ @elseif ($serverTerminalProblem === 'not_installed') +

{{ __('admin_settings.server_terminal_not_installed') }}

+

sudo bash deploy/install-agent.sh

+ @endif + +
+ + + {{ $serverTerminalProblem === 'no_key' ? __('admin_settings.server_terminal_generate') : __('admin_settings.server_terminal_regenerate') }} + + {{ __('admin_settings.server_terminal_key_hint') }} +
@endif diff --git a/routes/admin.php b/routes/admin.php index 0ffb7dc..7418cbb 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -152,6 +152,11 @@ Route::get('/readiness', Admin\Readiness::class)->name('readiness'); // verlässt und worauf nicht. Route::get('/open-work', Admin\OpenWork::class)->name('open-work'); Route::get('/settings', Admin\Settings::class)->name('settings'); +// Eine Root-Schale auf DEN CluPilot-Server, nicht auf einen Host. Eigenes +// Fenster, deshalb kein Navigationseintrag; aufgerufen wird sie aus den +// Einstellungen. Die Prüfungen stehen im Bauteil selbst und in dieser +// Reihenfolge: `server.terminal`, dann die Netzliste, dann die Einrichtung. +Route::get('/server/terminal', Admin\ServerTerminal::class)->name('server.terminal'); // Rollen und ihre Rechte. Eigene Seite statt eines Abschnitts unter // Einstellungen: eine Matrix aus sechs Rollen und einundzwanzig Rechten ist // keine Karte mehr, und wer sie öffnet, tut genau eine Sache. diff --git a/tests/Feature/Admin/RbacMoveTest.php b/tests/Feature/Admin/RbacMoveTest.php index def9f66..0bae8ec 100644 --- a/tests/Feature/Admin/RbacMoveTest.php +++ b/tests/Feature/Admin/RbacMoveTest.php @@ -13,10 +13,10 @@ it('moves every permission and role to the operator guard, leaving none behind', expect(Permission::where('guard_name', 'web')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(0) // 17 from the original seed, plus customers.grant_plan, - // instances.restart, dpa.manage and instances.manage — the later - // ones created straight onto this guard, because since this + // instances.restart, dpa.manage, instances.manage and server.terminal + // — the later ones created straight onto this guard, because since this // migration `web` is where a permission goes to match nobody at all. - ->and(Permission::where('guard_name', 'operator')->count())->toBe(21) + ->and(Permission::where('guard_name', 'operator')->count())->toBe(22) ->and(Role::where('guard_name', 'operator')->count())->toBe(6); }); @@ -159,7 +159,7 @@ it('preflights every customer conflict before mutating anything, listing all of // server, which is the whole reason this has to be checked up front. // Every capability this installation has, pushed back to `web` above to // stage the pre-migration shape — the figure grows with each new one. - expect(Permission::where('guard_name', 'web')->count())->toBe(21) + expect(Permission::where('guard_name', 'web')->count())->toBe(22) ->and(Permission::where('guard_name', 'operator')->count())->toBe(0) ->and(Role::where('guard_name', 'web')->count())->toBe(6) ->and(Role::where('guard_name', 'operator')->count())->toBe(0) diff --git a/tests/Feature/Admin/ServerTerminalTest.php b/tests/Feature/Admin/ServerTerminalTest.php new file mode 100644 index 0000000..277521a --- /dev/null +++ b/tests/Feature/Admin/ServerTerminalTest.php @@ -0,0 +1,168 @@ + '172.18.0.1', + 'fingerprint' => 'SHA256:4J0zwXJCmVzzJOtphWrdG1qADFwOf0dqcQNhh0vkbn0=', + 'installed_at' => '2026-08-04T10:00:00Z', + ], $overrides))); +} + +beforeEach(function () { + // Beide Schlüssel liegen hier, weil genau ihre Verwechslung das ist, was + // ein Test unten ausschliessen soll. + app(SecretVault::class)->put(ServerIdentity::VAULT_KEY, "-----BEGIN OPENSSH PRIVATE KEY-----\nSERVER\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create()); + app(SecretVault::class)->put('ssh.private_key', "-----BEGIN OPENSSH PRIVATE KEY-----\nFLOTTE\n-----END OPENSSH PRIVATE KEY-----", Operator::factory()->create()); +}); + +afterEach(function () { + File::delete(ServerTerminalSetup::path()); +}); + +it('lässt niemanden ohne server.terminal an eine Root-Konsole', function () { + writeServerSetup(); + + // Admin ist die zweitmächtigste Rolle und darf Hosts verwalten — also auch + // ein HOST-Terminal öffnen. Dieses hier ist eine andere Tür, und dass sie + // eine andere ist, ist der ganze Punkt dieser Fähigkeit. + $this->actingAs(Operator::factory()->role('Admin')->create(), 'operator') + ->get(route('admin.server.terminal')) + ->assertForbidden(); +}); + +it('stellt kein Ticket aus, wenn die Anfrage von ausserhalb der Liste kommt', function () { + writeServerSetup(); + + // Weder Management-Netz noch freigegebene Adresse. Der Schalter von + // RestrictConsoleNetwork bleibt dabei AUS — für diese Tür gilt die Liste + // trotzdem, und genau das hält dieser Test fest. + config(['admin_access.trusted_ranges' => ['10.66.0.0/24']]); + + $before = Redis::connection('cache')->keys('*terminal:ticket:*'); + + $this->actingAs(admin(), 'operator') + ->get(route('admin.server.terminal')) + ->assertOk() + ->assertSee(__('server.terminal.problem.off_network.title')); + + // Kein Fenster ist das eine; kein Ticket ist das, worauf es ankommt. + expect(Redis::connection('cache')->keys('*terminal:ticket:*'))->toBe($before); +}); + +it('trägt den Serverschlüssel ins Ticket, nicht den Flottenschlüssel', function () { + writeServerSetup(); + + $operator = admin(); + + $payload = TicketStore::redeem(ServerTicket::issue($operator)); + + expect($payload['private_key'])->toContain('SERVER') + ->and($payload['private_key'])->not->toContain('FLOTTE') + // Adresse und Fingerabdruck kommen aus der Einrichtungsdatei, nicht aus + // dem Code — auf der nächsten Installation sind beide anders. + ->and($payload['ip'])->toBe('172.18.0.1') + ->and($payload['fingerprint'])->toBe('SHA256:4J0zwXJCmVzzJOtphWrdG1qADFwOf0dqcQNhh0vkbn0=') + ->and($payload['user'])->toBe('root') + // Damit die Log-Zeile der Brücke lesbar bleibt, ohne sie zu ändern. + ->and($payload['host_uuid'])->toBe('server') + ->and($payload['operator_id'])->toBe($operator->id); +}); + +it('sagt statt einer Fehlerseite, dass der Server noch nicht eingerichtet ist', function () { + // Keine Datei — der Normalfall vor dem ersten install-agent.sh. + $this->actingAs(admin(), 'operator') + ->get(route('admin.server.terminal')) + ->assertOk() + ->assertSee(__('server.terminal.problem.not_installed.title')); +}); + +it('nimmt eine Einrichtung ohne Fingerabdruck nicht als Einrichtung', function () { + // Die gefährlichste Halbheit: mit Adresse, ohne Fingerabdruck. Die + // naheliegendste Lesart davon wäre „dann eben nicht prüfen" — also eine + // ungepinnte Root-Sitzung auf der Steuerungsebene. + writeServerSetup(['fingerprint' => '']); + + expect(ServerTerminalSetup::read())->toBeNull() + ->and(ServerTicket::blocker())->toBe('not_installed'); +}); + +it('nimmt auch eine Einrichtung ohne brauchbare Adresse nicht an', function () { + writeServerSetup(['address' => 'kein-hostname']); + + expect(ServerTerminalSetup::read())->toBeNull(); +}); + +it('sagt auch dann warum, wenn für den Server gar keine Kennung erzeugt ist', function () { + writeServerSetup(); + app(SecretVault::class)->forget(ServerIdentity::VAULT_KEY); + + $this->actingAs(admin(), 'operator') + ->get(route('admin.server.terminal')) + ->assertOk() + ->assertSee(__('server.terminal.problem.no_key.title')); +}); + +it('zeigt die Karte nur dem, der sie drücken darf', function () { + writeServerSetup(); + + $this->actingAs(Operator::factory()->role('Admin')->create(), 'operator') + ->get(route('admin.settings')) + ->assertOk() + ->assertDontSee(__('admin_settings.server_terminal_title')); + + $this->actingAs(admin(), 'operator') + ->get(route('admin.settings')) + ->assertOk() + ->assertSee(__('admin_settings.server_terminal_title')) + // Und der Zeitstempel geht durch ->local(), wie jede Zeit, die ein + // Mensch liest (R19). Der Wert oben ist 10:00 UTC. + ->assertSee(__('admin_settings.server_terminal_installed', [ + 'when' => Carbon::parse('2026-08-04T10:00:00Z')->local()->isoFormat('LLL'), + ])); +}); + +it('sagt auf der Karte, welche Zeile auf dem Server noch fehlt', function () { + // Kennung erzeugt, Server noch nicht eingerichtet — der Zustand zwischen + // den beiden Schritten, und der einzige, in dem die Anleitung zählt. + $this->actingAs(admin(), 'operator') + ->get(route('admin.settings')) + ->assertOk() + ->assertSee(__('admin_settings.server_terminal_not_installed')) + ->assertSee('sudo bash deploy/install-agent.sh'); +}); + +it('zeigt dem Owner den Vorspann und das Ticket, aber niemals den Schlüssel', function () { + writeServerSetup(); + + $html = $this->actingAs(admin(), 'operator') + ->get(route('admin.server.terminal')) + ->assertOk() + ->getContent(); + + expect($html)->toContain('data-terminal-stage') + ->and($html)->toMatch('/data-ticket="[0-9a-f]{64}"/') + // Und nichts, was die Brücke geheim halten muss. Die Adresse steht + // ebenfalls nicht drin: sie ist zwar keine Zugangsdatei, aber sie + // gehört zur Auskunft über den Weg und hat im Browser nichts verloren. + ->and($html)->not->toContain('BEGIN OPENSSH PRIVATE KEY') + ->and($html)->not->toContain('172.18.0.1'); +}); diff --git a/tests/Feature/ReadinessPageTest.php b/tests/Feature/ReadinessPageTest.php index cba07f7..f624438 100644 --- a/tests/Feature/ReadinessPageTest.php +++ b/tests/Feature/ReadinessPageTest.php @@ -90,7 +90,16 @@ it('refuses to run a check that is not on the list', function () { it('covers every entry in the secret registry', function () { $covered = collect(Readiness::all())->pluck('key')->implode(' '); - foreach (array_keys(SecretVault::REGISTRY) as $secret) { + foreach (SecretVault::REGISTRY as $secret => $meta) { + // Einträge, die diese Anwendung sich SELBST ausstellt, sind hier nicht + // gemeint. Der Befund von 2026-07-30 war ein Zugangsdatum, das jemand + // besorgen und einfügen muss und das niemand auf die Liste gesetzt hat + // — eine Kennung, die auf Knopfdruck entsteht, kann so nicht fehlen. + // Ob sie da ist, steht auf ihrer eigenen Karte unter Einstellungen. + if ($meta['internal'] ?? false) { + continue; + } + // `stripe.secret` → irgendein Prüfschlüssel, der `stripe_secret` enthält. $needle = str_replace('.', '_', $secret); diff --git a/tests/Feature/ServerTerminalInstallTest.php b/tests/Feature/ServerTerminalInstallTest.php new file mode 100644 index 0000000..d929179 --- /dev/null +++ b/tests/Feature/ServerTerminalInstallTest.php @@ -0,0 +1,79 @@ +toContain('CONTRACT=3') + ->and(installAgent())->toContain('install-server-terminal)'); +}); + +it('gibt dem Dienstkonto KEINEN sudo-Weg, sich selbst einen Root-Schluessel einzutragen', function () { + $script = installAgent(); + + // Der sudoers-Block nennt genau die Schritte, die das Dienstkonto anstossen + // darf. `install-server-terminal` schreibt nach /root/.ssh/authorized_keys; + // stuende er dort, koennte eine uebernommene Anwendung sich jederzeit + // selbst root geben — Kennung neu erzeugen, Schritt anstossen, fertig. + $sudoers = str($script)->after('/etc/sudoers.d/clupilot-host-step <before('EOF')->toString(); + + expect($sudoers)->toContain('ensure-rsync') + ->and($sudoers)->toContain('apply-proxy-hosts') + ->and($sudoers)->not->toContain('install-server-terminal'); +}); + +it('nimmt genau eine ssh-ed25519-Zeile an und nichts sonst', function () { + $script = installAgent(); + + // Die beiden Haelften der Pruefung: „genau eine Zeile" und „sieht aus wie + // ein Schluessel". Ohne die erste waere ein Zeilenumbruch im Wert ein + // zweiter Schluessel; ohne die zweite genuegte irgendein Text. + expect($script)->toContain('-eq 1 ]] \\') + ->and($script)->toContain('^ssh-ed25519\ [A-Za-z0-9+/]+=*'); +}); + +it('schreibt die Optionsliste selbst, statt sie sich reichen zu lassen', function () { + // `from` bindet den Schluessel an das Container-Netz, `restrict` nimmt ihm + // alles ausser einer Sitzung, `pty` gibt das eine zurueck, ohne das ein + // Terminal keines ist. Kaeme diese Liste aus der Anwendung, waere aus „die + // Anwendung nennt ihren Schluessel" ein „die Anwendung schreibt in + // authorized_keys" geworden. + expect(installAgent())->toContain('from="%s",restrict,pty %s'); +}); + +it('bildet den Fingerabdruck in CluPilots Form, nicht in der von OpenSSH', function () { + $script = installAgent(); + + // Gehasht wird die Zeichenkette " " — siehe + // fingerprints() in docker/terminal/bridge.py. `ssh-keygen -lf` gaebe den + // bekannten OpenSSH-Fingerabdruck und damit bei JEDEM Verbindungsversuch + // eine Abweichung. + expect($script)->toContain("printf '%s %s'") + ->and($script)->toContain('openssl dgst -sha256 -binary') + ->and($script)->not->toContain('ssh-keygen -lf'); +}); + +it('haelt Adresse und Fingerabdruck dort fest, wo die Anwendung sie liest', function () { + expect(installAgent())->toContain('storage/app/deploy/server-terminal.json') + ->and(installAgent())->toContain('"installed_at"'); +});