Ein Terminal auf den CluPilot-Server selbst

Eigene Faehigkeit, eigener Schluessel, eigene Tuer. Die Bruecke bleibt
unveraendert: sie kennt keine Hosts, nur Tickets.

Den Weg zum Wirt traegt die root-eigene Haelfte des Updaters — sie holt
sich die oeffentliche Haelfte selbst, prueft sie hart und schreibt die
Optionsliste, die das Dienstkonto nicht bestimmen darf. Kein sudo-Weg
dorthin: einen Root-Schluessel eintraegt ein Mensch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 10:06:50 +02:00
parent c9fef59983
commit 9fa45e869f
33 changed files with 1483 additions and 172 deletions

View File

@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Services\Terminal\ServerIdentity;
use Illuminate\Console\Command;
/**
* Die öffentliche Hälfte der Serverkennung, eine Zeile, sonst nichts.
*
* Gerufen von `/usr/local/sbin/clupilot-host-step install-server-terminal`
* der root-eigenen Hälfte auf dem Wirt, die daraus einen Eintrag in
* `/root/.ssh/authorized_keys` macht. Nach demselben Muster wie
* `apply-proxy-hosts`: der privilegierte Schritt HOLT sich den Wert selbst,
* statt ihn vom Dienstkonto gereicht zu bekommen, und prüft ihn dann hart.
*
* Nur die öffentliche Hälfte. Ausgabe eines Kommandos wandert in Protokolle
* und Terminalverläufe; was hier herauskommt, stünde ohnehin in jeder
* authorized_keys.
*
* Kein Erzeugen auf Zuruf: gibt es keine Kennung, endet das Kommando mit 3 und
* einem Satz, der sagt, wo sie entsteht. Ein Kommando, das im Vorbeigehen einen
* Schlüssel mintet, täte es unter niemandes Namen und der Tresor hält fest,
* WER einen Wert gesetzt hat.
*/
class ServerTerminalKey extends Command
{
protected $signature = 'clupilot:server-terminal-key';
protected $description = 'Print the public half of the server terminal SSH identity';
public function handle(): int
{
$publicKey = ServerIdentity::publicKey();
if ($publicKey === null) {
$this->getOutput()->writeln(
'<error>Für den Server ist keine SSH-Kennung erzeugt. In der Konsole unter Einstellungen → Terminal zum Server anlegen.</error>',
);
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;
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Admin;
use LivewireUI\Modal\ModalComponent;
/**
* Rückfrage, bevor eine NEUE Serverkennung die bestehende ersetzt (R23).
*
* Keine Formsache. Die öffentliche Hälfte der bisherigen Kennung steht auf dem
* Wirt in `/root/.ssh/authorized_keys`, und von hier aus wird sie nicht
* angefasst das tut allein `install-server-terminal`, die root-eigene Hälfte
* auf der Maschine. Zwischen dem Erzeugen und dem nächsten
* `sudo bash deploy/install-agent.sh` geht das Server-Terminal nicht auf.
*
* Mutiert nichts selbst: bestätigen schickt ein Ereignis zurück an
* Admin\Settings, dessen `generateServerKey()` seine eigene Prüfung behält.
*/
class ConfirmGenerateServerKey extends ModalComponent
{
public function mount(): void
{
$this->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');
}
}

View File

@ -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),

View File

@ -0,0 +1,84 @@
<?php
namespace App\Livewire\Admin;
use App\Http\Middleware\RestrictConsoleNetwork;
use App\Models\Operator;
use App\Services\Terminal\ServerTicket;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Eine Root-Sitzung auf dem CluPilot-Server selbst, im eigenen Fenster.
*
* Dieselbe Bühne wie beim Host-Terminal, dieselbe Brücke, dasselbe Ticket
* und eine Tür, die enger ist, weil dahinter mehr liegt. Ein Host-Terminal
* reicht auf eine Maschine; dieses reicht auf Datenbank, Tresor, DNS-Token und
* jede Kundeninstanz.
*
* DIE REIHENFOLGE IN mount() IST DIE FUNKTION:
*
* 1. `server.terminal` eine eigene Fähigkeit, nicht `hosts.manage`.
* 2. Das Netz. Und zwar OHNE `RestrictConsoleNetwork::isRestricted()` zu
* fragen: dieser Schalter darf die Konsole öffnen, aber nicht diese Tür.
* Wer ihn aus gutem Grund umlegt ausgesperrt, muss von unterwegs ran ,
* öffnete sonst still auch eine Root-Schale ins Herz der Anlage.
* 3. Erst dann die Frage, ob überhaupt eine Sitzung entstehen kann.
*
* Und wie beim Host-Terminal: DIESE SEITE WIRFT NIE. Ein Terminalfenster, das
* mit Laravels Fehlerseite aufgeht Klassenname, Dateipfad, Zeilennummer ,
* ist ein Bruch mitten im Produkt.
*
* Kein Routenparameter, also auch nicht die Falle, die den Host-Ableger eine
* Umbenennung gekostet hat (Livewires ImplicitRouteBinding). Hier gibt es
* nichts nachzuschlagen: der Server ist der, auf dem das hier läuft.
*/
#[Layout('layouts.bare')]
class ServerTerminal extends Component
{
public string $ticket = '';
/**
* Warum hier kein Terminal entstehen kann ein Merkwort, oder leer, wenn
* nichts im Weg steht.
*/
public string $problem = '';
public function mount(): void
{
$this->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'));
}
}

View File

@ -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,

View File

@ -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

View File

@ -0,0 +1,75 @@
<?php
namespace App\Services\Terminal;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Services\Ssh\Keypair;
use phpseclib3\Crypt\PublicKeyLoader;
use Throwable;
/**
* Die SSH-Kennung, mit der das Terminal auf den CluPilot-Server selbst kommt.
*
* Bewusst eine eigene, nicht der Flottenschlüssel: dessen öffentliche Hälfte
* liegt auf JEDEM übernommenen Host in `/root/.ssh/authorized_keys`. Ein
* Schlüssel für beides hieße, dass ein Host, der einmal in falsche Hände gerät,
* dieselbe Kennung trägt wie die Tür zur Steuerungsebene.
*
* Nur die private Hälfte wird aufbewahrt. Die öffentliche wird daraus
* abgeleitet, wann immer sie gebraucht wird zwei gespeicherte Hälften können
* auseinanderlaufen, eine abgeleitete nie.
*/
final class ServerIdentity
{
/** Der Tresor-Eintrag. Steht hier, nicht verstreut bei den Aufrufern. */
public const VAULT_KEY = 'server.private_key';
public static function exists(): bool
{
return filled(app(SecretVault::class)->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;
}
}
}

View File

@ -0,0 +1,87 @@
<?php
namespace App\Services\Terminal;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Throwable;
/**
* Was der Wirt über sich selbst hinterlassen hat, als er eingerichtet wurde.
*
* Zwei Werte, und keiner von beiden darf aus dem Code kommen:
*
* - **Die Adresse**, unter der der Container den Wirt erreicht das Gateway
* des compose-Netzes. Auf dieser Installation 172.18.0.1, auf der nächsten
* eine andere. Ein fester Wert im Code wäre auf jeder zweiten Maschine falsch.
* - **Der Fingerabdruck** des Hostschlüssels, in CluPilots Form gebildet
* (`SHA256:` base64 über `"<algorithmus> <base64-blob>"`, 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,
);
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace App\Services\Terminal;
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use Illuminate\Support\Facades\Log;
use RuntimeException;
/**
* Die Eintrittskarte für eine Root-Sitzung auf dem CluPilot-Server selbst.
*
* Dieselbe Form wie {@see TerminalTicket}, dieselbe Brücke, dieselben dreißig
* Sekunden nur eine andere Herkunft der vier Felder: Adresse und
* Fingerabdruck aus der Einrichtungsdatei des Wirts, Benutzer `root`, und der
* Schlüssel aus einem EIGENEN Tresor-Eintrag.
*
* `host_uuid` trägt den festen Wert `server`. Die Brücke schreibt dieses Feld
* in ihre Log-Zeile; ein leeres Feld hätte sie unlesbar gemacht, ein neues Feld
* hätte sie geändert. So bleibt sie unverändert und das Log bleibt lesbar.
*
* WAS HIER NICHT GEPRÜFT WIRD: von welcher Adresse aus jemand fragt. Das ist
* eine Eigenschaft der Anfrage, nicht der Maschine, und steht deshalb in der
* Seite (App\Livewire\Admin\ServerTerminal) vor dem einzigen Aufruf von
* issue(), den es gibt.
*/
final class ServerTicket
{
/**
* Was gerade im Weg steht oder null, wenn nichts im Weg steht.
*
* Es gibt diese Methode aus demselben Grund wie beim Host-Terminal: die
* Seite will die Antwort VORHER wissen, um sie in ihrer eigenen Sprache
* hinzuschreiben, statt sie als 500er weiterzureichen. Zurück kommt ein
* Merkwort, kein Satz.
*/
public static function blocker(): ?string
{
if (! ServerIdentity::exists()) {
return 'no_key';
}
// Ohne die Einrichtung fehlen Adresse UND Fingerabdruck. Beide kommen
// aus derselben Datei und werden von derselben Zeile geschrieben, also
// gibt es auch nur ein Merkwort dafür — und genau eine Anleitung.
if (ServerTerminalSetup::read() === null) {
return 'not_installed';
}
return null;
}
public static function issue(Operator $for): string
{
match (self::blocker()) {
'no_key' => 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,
]);
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Services\Terminal;
use Illuminate\Support\Facades\Redis;
/**
* Wo eine Eintrittskarte liegt, wie lange, und warum genau so.
*
* Herausgezogen, als ein zweiter Ausstellungsweg dazukam (das Terminal zum
* CluPilot-Server selbst). Was hier steht, ist die Verabredung mit einem
* Container, der dieses Repo nicht kennt sie zweimal hinzuschreiben hieße,
* darauf zu wetten, dass beide Abschriften gemeinsam altern. Wer die
* Ausstellung ändert, ändert sie hier einmal für beide.
*
* ROHES REDIS, NICHT DIE Cache-FASSADE zwei Gründe, beide gemessen:
*
* 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 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.
*
* DER SCHLÜSSEL, VOLLSTÄNDIG für den Container, der dagegen schreibt:
* `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. 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<string, mixed> $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<string, mixed>|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);
}
}

View File

@ -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',

View File

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
use Spatie\Permission\PermissionRegistrar;
/**
* `server.terminal` eine Root-Schale auf dem CluPilot-Server selbst öffnen.
*
* NICHT `hosts.manage`, obwohl das Host-Terminal daran hängt. Ein Host-Terminal
* reicht auf eine Maschine; dieses reicht auf alles Datenbank, Tresor,
* DNS-Token, jede Kundeninstanz. Wer es hat, hat die Plattform. Dieselbe
* Fähigkeit für beides hieße, dass jeder, der einen Host neu starten darf,
* nebenbei die Steuerungsebene bekommt.
*
* NUR Owner, anders als bei den meisten Fähigkeiten hier. Weitergeben kann sie
* trotzdem nur er selbst: der Rollen-Editor liegt hinter `staff.manage`, und
* das hat heute allein die Owner-Rolle. Sie steht damit im Editor, ist also
* delegierbar, wenn der Betreiber das ausdrücklich will und niemand sonst
* kann es für ihn entscheiden.
*
* Guard `operator`: jede Fähigkeit dieser Anwendung liegt dort seit 2026-07-29
* (siehe move_rbac_to_operator_guard) eine unter `web` angelegte träfe still
* überhaupt niemanden.
*/
return new class extends Migration
{
public function up(): void
{
app(PermissionRegistrar::class)->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();
}
};

View File

@ -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 "<algorithmus> <base64-blob>",
# 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 <<EOF
[Unit]
Description=CluPilot update agent

View File

@ -70,7 +70,7 @@ STATE_FILE="storage/app/deployed-commit"
# fixed by deploy/install-agent.sh; this script only asks. Raising HOST_STEP_NEEDS
# here is what makes an update tell the operator to run the installer again.
HOST_STEP=/usr/local/sbin/clupilot-host-step
HOST_STEP_NEEDS=2
HOST_STEP_NEEDS=3
# Which step is running, for the console to show. Written as a KEY, not as the
# sentence below it: the console is translated and this script is not, so an
# English line here would surface untranslated in the interface. A run that dies

View File

@ -74,6 +74,22 @@ return [
'console_add' => '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.',

View File

@ -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',

View File

@ -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.',

76
lang/de/server.php Normal file
View File

@ -0,0 +1,76 @@
<?php
return [
'terminal' => [
'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.',
],
],
],
];

View File

@ -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.',

View File

@ -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',

View File

@ -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.',

64
lang/en/server.php Normal file
View File

@ -0,0 +1,64 @@
<?php
return [
'terminal' => [
'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.',
],
],
],
];

View File

@ -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. --}}
<div class="relative flex h-screen w-screen flex-col overflow-hidden bg-ink"
data-terminal
data-ticket="{{ $ticket }}">
{{-- 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. --}}
<div data-terminal-screen wire:ignore class="flex-1 overflow-hidden"></div>
<div data-terminal-stage
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-6 bg-ink px-6 text-center"
data-stage-messages="{{ json_encode($messages, JSON_UNESCAPED_UNICODE) }}">
{{-- 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. --}}
<pre data-stage-mark
class="select-none font-mono text-[7px] leading-none text-accent sm:text-[13px]"
role="img" aria-label="CluPilot"> ██████ ██ ██ ██ ████████ ██ ██ ██████ ████████
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██ ██ ██ ██ ████████ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██████ ████████ ██████ ██ ██ ████████ ██████ ██ </pre>
{{-- 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. --}}
<div class="max-w-sm space-y-2" aria-live="polite">
<p data-stage-title class="text-sm font-semibold text-bg">{{ $title }}</p>
<p data-stage-note class="text-xs leading-relaxed text-bg opacity-70">{{ $note }}</p>
</div>
{{-- 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. --}}
<div data-stage-retry @class(['hidden' => ! $retry])>
<x-ui.button data-stage-retry-button variant="secondary" size="sm">
{{ $retryLabel }}
</x-ui.button>
</div>
</div>
</div>

View File

@ -0,0 +1,17 @@
<div class="rounded-lg bg-surface p-6">
<div class="flex items-start gap-3">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning">
<x-ui.icon name="alert-triangle" class="size-5" />
</span>
<div class="min-w-0">
<h3 class="text-base font-semibold text-ink">{{ __('admin_settings.server_terminal_generate_title') }}</h3>
<p class="mt-1 text-sm text-muted">{{ __('admin_settings.server_terminal_generate_body') }}</p>
</div>
</div>
<div class="mt-6 flex justify-end gap-3">
<x-ui.button variant="secondary" x-on:click="Livewire.dispatch('closeModal')">{{ __('common.cancel') }}</x-ui.button>
<x-ui.button variant="primary" wire:click="confirm" wire:loading.attr="disabled">
{{ __('admin_settings.server_terminal_generate_confirm') }}
</x-ui.button>
</div>
</div>

View File

@ -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
<div class="relative flex h-screen w-screen flex-col overflow-hidden bg-ink"
data-terminal
data-ticket="{{ $ticket }}">
{{-- 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. --}}
<div data-terminal-screen wire:ignore class="flex-1 overflow-hidden"></div>
<div data-terminal-stage
class="absolute inset-0 z-10 flex flex-col items-center justify-center gap-6 bg-ink px-6 text-center"
data-stage-messages="{{ json_encode($messages, JSON_UNESCAPED_UNICODE) }}">
{{-- 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. --}}
<pre data-stage-mark
class="select-none font-mono text-[7px] leading-none text-accent sm:text-[13px]"
role="img" aria-label="CluPilot"> ██████ ██ ██ ██ ████████ ██ ██ ██████ ████████
██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██ ██ ██ ██ ████████ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██ ██ ██ ██ ██
██████ ████████ ██████ ██ ██ ████████ ██████ ██ </pre>
{{-- 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. --}}
<div class="max-w-sm space-y-2" aria-live="polite">
<p data-stage-title class="text-sm font-semibold text-bg">{{ $stageTitle }}</p>
<p data-stage-note class="text-xs leading-relaxed text-bg opacity-70">{{ $stageNote }}</p>
</div>
{{-- 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. --}}
<div data-stage-retry @class(['hidden' => $problem !== 'unknown'])>
<x-ui.button data-stage-retry-button variant="secondary" size="sm">
{{ __('hosts.terminal.stage.retry') }}
</x-ui.button>
</div>
</div>
</div>
{{-- Schirm, Bühne und Wortmarke stehen seit dem zweiten Terminalfenster in
<x-admin.terminal-screen>. 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. --}}
<x-admin.terminal-screen
:ticket="$ticket"
:title="$stageTitle"
:note="$stageNote"
:messages="$messages"
:retry="$problem === 'unknown'"
:retry-label="__('hosts.terminal.stage.retry')" />

View File

@ -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. --}}
<x-admin.terminal-screen
:ticket="$ticket"
:title="$stageTitle"
:note="$stageNote"
:messages="$messages"
:retry="$problem === 'unknown'"
:retry-label="__('server.terminal.stage.retry')" />

View File

@ -436,6 +436,77 @@
</div>
<p class="mt-1.5 text-xs text-muted">{{ __('admin_settings.console_ip_hint') }}</p>
</form>
{{-- 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. --}}
<p class="mt-3 flex items-start gap-2 rounded-lg border border-line bg-surface-2 px-3 py-2 text-xs text-muted">
<x-ui.icon name="lock" class="size-4 shrink-0 text-muted" />
<span>{{ __('admin_settings.console_terminal_note') }}</span>
</p>
</div>
</div>
@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)
<div class="rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="font-semibold text-ink">{{ __('admin_settings.server_terminal_title') }}</h2>
<p class="mt-1 max-w-xl text-sm text-muted">{{ __('admin_settings.server_terminal_body') }}</p>
@if ($serverTerminalProblem === '' && $serverTerminalInstalledAt)
<p class="mt-2 text-xs text-muted">
{{ __('admin_settings.server_terminal_installed', ['when' => \Illuminate\Support\Carbon::parse($serverTerminalInstalledAt)->local()->isoFormat('LLL')]) }}
</p>
@endif
</div>
{{-- 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)
<x-ui.button variant="secondary" size="sm"
:href="route('admin.server.terminal')"
target="_blank" rel="noopener" :title="__('server.terminal.hint')">
<x-ui.icon name="activity" class="size-4" />{{ __('server.terminal.open') }}
</x-ui.button>
@else
<x-ui.button variant="secondary" size="sm" disabled>
<x-ui.icon name="activity" class="size-4" />{{ __('server.terminal.open') }}
</x-ui.button>
@endif
</div>
<div class="mt-5 space-y-3 border-t border-line pt-4">
@if (! $serverTerminalOnNetwork)
<p class="text-sm text-warning">{{ __('admin_settings.server_terminal_off_network', ['ip' => $viewerIp]) }}</p>
@endif
@if ($serverTerminalProblem === 'no_key')
<p class="text-sm text-muted">{{ __('admin_settings.server_terminal_no_key') }}</p>
@elseif ($serverTerminalProblem === 'not_installed')
<p class="text-sm text-muted">{{ __('admin_settings.server_terminal_not_installed') }}</p>
<p class="rounded-lg border border-line-strong bg-surface-2 px-3 py-2 font-mono text-sm text-body">sudo bash deploy/install-agent.sh</p>
@endif
<div class="flex flex-wrap items-center gap-3">
<x-ui.button variant="{{ $serverTerminalProblem === 'no_key' ? 'primary' : 'secondary' }}" size="sm"
x-on:click="$dispatch('openModal', { component: 'admin.confirm-generate-server-key' })">
<x-ui.icon name="shield" class="size-4" />
{{ $serverTerminalProblem === 'no_key' ? __('admin_settings.server_terminal_generate') : __('admin_settings.server_terminal_regenerate') }}
</x-ui.button>
<span class="text-xs text-muted">{{ __('admin_settings.server_terminal_key_hint') }}</span>
</div>
</div>
</div>
@endif

View File

@ -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.

View File

@ -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)

View File

@ -0,0 +1,168 @@
<?php
use App\Models\Operator;
use App\Services\Secrets\SecretVault;
use App\Services\Terminal\ServerIdentity;
use App\Services\Terminal\ServerTerminalSetup;
use App\Services\Terminal\ServerTicket;
use App\Services\Terminal\TicketStore;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Redis;
/**
* Die Einrichtung, wie `install-server-terminal` sie auf dem Wirt hinterlässt.
*
* Echte Datei statt einer vorgetäuschten: der Weg vom Skript zur Seite führt
* durch JSON und durch zwei Prüfungen, und beide sind genau die Stellen, an
* denen sich später etwas verschiebt.
*/
function writeServerSetup(array $overrides = []): void
{
File::ensureDirectoryExists(dirname(ServerTerminalSetup::path()));
File::put(ServerTerminalSetup::path(), json_encode(array_merge([
'address' => '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');
});

View File

@ -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);

View File

@ -0,0 +1,79 @@
<?php
use Illuminate\Support\Facades\File;
/**
* Die root-eigene Hälfte, als Text gelesen.
*
* Ausführen lässt sie sich hier nicht sie will einen Wirt mit sshd, einer
* laufenden Anlage und einem Auschecken unter /opt/clupilot. Was ein Test
* trotzdem festhalten kann, sind die Entscheidungen, die in ihr stecken und
* die man beim nächsten Anfassen versehentlich zurücknimmt.
*
* Nachbar, nicht Ersatz: tests/Feature/HostStepTest.php prüft den Helfer als
* Ganzes dass er root gehört, dass sudoers je eine feste Kommandozeile nennt,
* dass der Vertrag zu update.sh passt. Hier steht nur, was DIESEN Schritt
* ausmacht.
*/
function installAgent(): string
{
return File::get(base_path('deploy/install-agent.sh'));
}
it('erhoeht den Vertrag, damit Bestandsserver ueberhaupt erfahren, dass es den Schritt gibt', function () {
// Ein neuer Schritt nuetzt auf einem Server nichts, der noch den alten
// Helfer traegt — und still nutzlos ist die Fehlerart, fuer die update.sh
// seine Vertragspruefung hat.
expect(installAgent())->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 <<EOF')->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 "<algorithmus> <base64-blob>" — 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"');
});