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
parent
c9fef59983
commit
9fa45e869f
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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')" />
|
||||
|
|
|
|||
|
|
@ -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')" />
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
});
|
||||
Loading…
Reference in New Issue