feat(terminal): Clusev-host SSH login tile + server search; drop the inline hint

The "Clusev host" terminal was a shell inside the sidecar container (node@clusev, no
sudo, not the host). It is now a REAL SSH login into the host machine, like any fleet
server — so it shows the real root@<host> prompt with full rights.

- HostCredential (encrypted singleton) holds the host SSH login; a HostShell modal lets
  the operator set host/port/user + password|key. The clear-text secret is never echoed
  back; on edit a blank secret keeps the stored one, and switching auth method requires a
  fresh secret (no password silently reused as a key).
- open('host') mints a kind=host token only when a login is configured, else opens the
  setup modal; the internal resolve endpoint returns a server-shaped SSH spec (decrypted,
  internal-net only) for kind=host, or 404 when unconfigured.
- compose: extra_hosts host.docker.internal:host-gateway so the sidecar reaches the host
  sshd; Caddy now 404s /_internal/* at the public edge (the sidecar uses app:80 directly).
- rail gains a debounced search box past 5 servers; removed the now-obvious PTY hint line.

Browser-verified (R12): host tile shows "not configured" + gear → modal → SSH login as
root runs commands, zero console errors; search filters; resolve returns the host spec.
15 terminal tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-25 19:46:55 +02:00
parent 707b031bd2
commit 83626352e2
13 changed files with 560 additions and 35 deletions

View File

@ -0,0 +1,146 @@
<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use LivewireUI\Modal\ModalComponent;
/**
* Form modal: configure the SSH login for the "Clusev host" terminal. Stores a single
* HostCredential (encrypted) so the terminal sidecar can SSH into the actual host instead of
* opening a shell in its own container. On edit, leaving the secret blank keeps the stored one.
* The clear-text secret is never echoed back into the form.
*/
class HostShell extends ModalComponent
{
public string $host = 'host.docker.internal';
public int $port = 22;
public string $username = 'root';
public string $authType = 'password';
public string $secret = '';
public string $passphrase = '';
/** True when a HostCredential already exists — then a blank secret keeps the stored one. */
public bool $configured = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
$hc = HostCredential::current();
if ($hc !== null) {
$this->configured = true;
$this->host = $hc->host;
$this->port = $hc->port;
$this->username = $hc->username;
$this->authType = $hc->auth_type;
// secret/passphrase stay blank — never reveal the stored login back into the form.
}
}
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'host' => ['required', 'string', 'max:255'],
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'username' => ['required', 'string', 'max:120'],
'authType' => ['required', Rule::in(['password', 'key'])],
// On first setup the secret is required; when editing an existing login a blank
// secret means "keep the stored one".
'secret' => [$this->configured ? 'nullable' : 'required', 'string'],
'passphrase' => ['nullable', 'string'],
];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(): array
{
return [
'host' => __('terminal.host_field_host'),
'port' => __('terminal.host_field_port'),
'username' => __('terminal.host_field_user'),
'authType' => __('terminal.host_field_auth'),
'secret' => $this->authType === 'key' ? __('terminal.host_field_key') : __('terminal.host_field_password'),
];
}
public function save(): void
{
$data = $this->validate();
$existing = HostCredential::current();
// Switching the auth method (password ↔ key) requires a fresh secret: keeping the stored one
// would feed e.g. an old password into key auth (or vice versa) and silently break the login.
if ($this->secret === '' && $existing !== null && $this->authType !== $existing->auth_type) {
throw ValidationException::withMessages([
'secret' => __('terminal.host_secret_required_for_auth_change'),
]);
}
$hc = $existing ?? new HostCredential;
$hc->host = trim($data['host']);
$hc->port = (int) $data['port'];
$hc->username = trim($data['username']);
$hc->auth_type = $data['authType'] === 'key' ? 'key' : 'password';
// Only overwrite the secret when a new one was entered (blank = keep the stored one).
if ($this->secret !== '') {
$hc->secret = $this->secret;
$hc->passphrase = $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null;
}
$hc->save();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.configure',
'target' => $hc->username.'@'.$hc->host.':'.$hc->port,
'ip' => request()->ip(),
]);
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_saved'));
$this->closeModal();
}
public function remove(): void
{
$hc = HostCredential::current();
if ($hc !== null) {
$target = $hc->username.'@'.$hc->host.':'.$hc->port;
HostCredential::query()->delete(); // singleton — clear any/all rows, not just the first
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.remove',
'target' => $target,
'ip' => request()->ip(),
]);
}
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_removed'));
$this->closeModal();
}
public function render()
{
return view('livewire.modals.host-shell');
}
}

View File

@ -2,11 +2,13 @@
namespace App\Livewire\Terminal;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\TerminalSession;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
/**
@ -18,9 +20,15 @@ use Livewire\Component;
#[Layout('layouts.app')]
class Index extends Component
{
/** Show the server search box once the fleet is large enough to warrant filtering. */
private const SEARCH_THRESHOLD = 5;
/** 'host' or a server uuid — drives the active-target highlight in the rail. */
public string $activeKey = '';
/** Live filter over the server rail (only shown past SEARCH_THRESHOLD servers). */
public string $search = '';
public function title(): string
{
return __('terminal.title');
@ -33,7 +41,15 @@ class Index extends Component
$label = __('terminal.host_label');
$this->activeKey = 'host';
if ($kind !== 'host') {
if ($kind === 'host') {
// The host terminal is a real SSH login into the Clusev machine. With no login configured
// yet, open the setup form instead of minting a dead session.
if (HostCredential::current() === null) {
$this->configureHost();
return;
}
} else {
$kind = 'server';
$server = Server::where('uuid', $uuid)->first();
if ($server === null) {
@ -57,10 +73,36 @@ class Index extends Component
$this->dispatch('terminal-open', token: $token, title: $label);
}
/** Open the modal that stores the Clusev host SSH login. */
public function configureHost(): void
{
$this->dispatch('openModal', component: 'modals.host-shell');
}
/** The host-login modal saved/removed — re-render so the tile reflects the new state. */
#[On('hostShellSaved')]
public function hostShellSaved(): void
{
// No state to set; the empty handler simply triggers a re-render.
}
public function render()
{
$serverCount = Server::count();
$servers = Server::query()
->when($this->search !== '', fn ($q) => $q->where('name', 'like', '%'.$this->search.'%'))
->orderBy('name')
->get(['uuid', 'name', 'status']);
$host = HostCredential::current();
return view('livewire.terminal.index', [
'servers' => Server::orderBy('name')->get(['uuid', 'name', 'status']),
'servers' => $servers,
'serverCount' => $serverCount,
'showSearch' => $serverCount > self::SEARCH_THRESHOLD,
'hostConfigured' => $host !== null,
'hostTarget' => $host !== null ? $host->username.'@'.$host->host : null,
])->title($this->title());
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
/**
* The SSH login for the "Clusev host" terminal (a singleton one host). Stored encrypted; only
* ever read over the internal network by the terminal resolve endpoint, never sent to the browser.
* When no row exists the host terminal is "not configured" and the tile opens the setup form.
*/
class HostCredential extends Model
{
protected $guarded = [];
protected $casts = [
'port' => 'integer',
'secret' => 'encrypted',
'passphrase' => 'encrypted',
];
/** The single configured host login, or null when the operator hasn't set one up yet. */
public static function current(): ?self
{
return static::query()->orderBy('id')->first();
}
}

View File

@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Holds the SSH login for the "Clusev host" terminal a single row (the host is one machine).
* Unlike a fleet server, the host is not in the `servers` table; the operator stores its root
* (or any) login here so the terminal sidecar can SSH into the actual host instead of opening a
* shell inside its own container. The secret/passphrase are encrypted at rest (model casts).
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('host_credentials', function (Blueprint $table) {
$table->id();
// Reachable from the terminal sidecar container; defaults to the docker host gateway.
$table->string('host')->default('host.docker.internal');
$table->unsignedSmallInteger('port')->default(22);
$table->string('username')->default('root');
$table->string('auth_type')->default('password'); // 'password' | 'key'
$table->text('secret'); // encrypted (password or private key)
$table->text('passphrase')->nullable(); // encrypted (key passphrase)
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('host_credentials');
}
};

View File

@ -88,8 +88,10 @@ services:
context: ./docker/terminal
image: "${CLUSEV_TERMINAL_IMAGE:-clusev-terminal:prod}"
restart: unless-stopped
# Clean hostname so the "Clusev host" terminal prompt reads node@clusev, not the container hash.
hostname: clusev
# Reach the Docker host's sshd from the sidecar so the "Clusev host" terminal can SSH into the
# real machine (host.docker.internal → the host gateway).
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
TERMINAL_SIDECAR_SECRET: "${TERMINAL_SIDECAR_SECRET:-}"
APP_INTERNAL_URL: "http://app:80"

View File

@ -103,8 +103,10 @@ services:
context: ./docker/terminal
image: clusev-terminal:dev
restart: unless-stopped
# Clean hostname so the "Clusev host" terminal prompt reads node@clusev, not the container hash.
hostname: clusev
# Reach the Docker host's sshd from the sidecar so the "Clusev host" terminal can SSH into the
# real machine (host.docker.internal → the host gateway).
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
TERMINAL_SIDECAR_SECRET: "${TERMINAL_SIDECAR_SECRET:-}"
APP_INTERNAL_URL: "http://app:80"

View File

@ -41,6 +41,12 @@
@terminal path /terminal/ws
reverse_proxy @terminal terminal:3000
# The internal terminal-resolve endpoint hands out DECRYPTED SSH credentials. It is for the sidecar
# only, which reaches it over the private network (app:80, bypassing Caddy). Refuse it at the public
# edge so a leaked sidecar secret can't be replayed from the internet (defence in depth).
@internal path /_internal/*
respond @internal 404
reverse_proxy app:80
encode zstd gzip

View File

@ -8,9 +8,12 @@ return [
'targets_title' => 'Ziele',
'targets_subtitle' => 'Wähle eine Sitzung',
'host_label' => 'Clusev',
'host_sub' => 'Lokale Shell',
'host_label' => 'Clusev-Host',
'host_not_configured' => 'Nicht eingerichtet',
'host_configure' => 'Host-Login einrichten',
'servers_heading' => 'Server',
'search_placeholder' => 'Server suchen…',
'no_match' => 'Keine Treffer.',
'no_servers' => 'Noch keine Server angelegt.',
'no_target' => 'Kein Ziel gewählt',
'pick_target' => 'Ziel links wählen, um eine Sitzung zu starten.',
@ -22,5 +25,23 @@ return [
'status_error' => 'Fehler',
'clear' => 'Leeren',
'hint' => 'Echte PTY — dieselben Befehle wie im Terminal, inkl. Autovervollständigung (Tab / Shift-Tab). Eingaben gehen direkt an die Shell.',
// Host-Login-Einrichtung (Clusev-Host-Terminal)
'host_setup_title' => 'Clusev-Host-Login',
'host_setup_subtitle' => 'SSH-Zugang zum Clusev-Host. Damit wird das Terminal ein echtes Login auf der Maschine — kein Container.',
'host_field_host' => 'Host / IP',
'host_field_port' => 'Port',
'host_field_user' => 'Benutzer',
'host_field_auth' => 'Authentifizierung',
'host_auth_password' => 'Passwort',
'host_auth_key' => 'SSH-Key',
'host_field_password' => 'Passwort',
'host_field_key' => 'Privater SSH-Key',
'host_field_passphrase' => 'Passphrase (optional)',
'host_secret_keep' => 'Leer lassen, um das gespeicherte Passwort zu behalten',
'host_secret_required_for_auth_change' => 'Bei Wechsel der Authentifizierungsmethode muss das Login neu eingegeben werden.',
'host_security_note' => 'Dieses Login gibt dem Web-Terminal eine Shell auf dem Host. Verschlüsselt gespeichert, nur über das interne Netz genutzt.',
'host_save' => 'Speichern',
'host_remove' => 'Entfernen',
'host_saved' => 'Host-Login gespeichert.',
'host_removed' => 'Host-Login entfernt.',
];

View File

@ -8,9 +8,12 @@ return [
'targets_title' => 'Targets',
'targets_subtitle' => 'Pick a session',
'host_label' => 'Clusev',
'host_sub' => 'Local shell',
'host_label' => 'Clusev host',
'host_not_configured' => 'Not configured',
'host_configure' => 'Set up host login',
'servers_heading' => 'Servers',
'search_placeholder' => 'Search servers…',
'no_match' => 'No matches.',
'no_servers' => 'No servers added yet.',
'no_target' => 'No target selected',
'pick_target' => 'Pick a target on the left to start a session.',
@ -22,5 +25,23 @@ return [
'status_error' => 'Error',
'clear' => 'Clear',
'hint' => 'A real PTY — the same commands as your terminal, including autocompletion (Tab / Shift-Tab). Input goes straight to the shell.',
// Host login setup (Clusev host terminal)
'host_setup_title' => 'Clusev host login',
'host_setup_subtitle' => 'SSH access to the Clusev host. This makes the terminal a real login on the machine — not a container.',
'host_field_host' => 'Host / IP',
'host_field_port' => 'Port',
'host_field_user' => 'User',
'host_field_auth' => 'Authentication',
'host_auth_password' => 'Password',
'host_auth_key' => 'SSH key',
'host_field_password' => 'Password',
'host_field_key' => 'Private SSH key',
'host_field_passphrase' => 'Passphrase (optional)',
'host_secret_keep' => 'Leave blank to keep the stored password',
'host_secret_required_for_auth_change' => 'Switching the authentication method requires entering the login again.',
'host_security_note' => 'This login gives the web terminal a shell on the host. Stored encrypted, used only over the internal network.',
'host_save' => 'Save',
'host_remove' => 'Remove',
'host_saved' => 'Host login saved.',
'host_removed' => 'Host login removed.',
];

View File

@ -0,0 +1,88 @@
<div class="p-5 sm:p-6">
<div class="flex items-start gap-3.5">
<span class="grid h-10 w-10 shrink-0 place-items-center rounded-md border border-accent/30 bg-accent/10 text-accent-text">
<x-icon name="command" class="h-5 w-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-display text-base font-semibold text-ink">{{ __('terminal.host_setup_title') }}</h2>
<p class="mt-1 text-sm leading-relaxed text-ink-2">{{ __('terminal.host_setup_subtitle') }}</p>
</div>
</div>
<div class="mt-4 space-y-3">
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div class="sm:col-span-2">
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('terminal.host_field_host') }}</label>
<input wire:model="host" type="text" placeholder="host.docker.internal"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('host')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('terminal.host_field_port') }}</label>
<input wire:model="port" type="number" min="1" max="65535" placeholder="22"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('port')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('terminal.host_field_user') }}</label>
<input wire:model="username" type="text" placeholder="root"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@error('username')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('terminal.host_field_auth') }}</label>
<select wire:model.live="authType"
class="h-9 w-full rounded-md border border-line bg-inset px-3 text-sm text-ink focus:border-accent/40 focus:outline-none">
<option value="password">{{ __('terminal.host_auth_password') }}</option>
<option value="key">{{ __('terminal.host_auth_key') }}</option>
</select>
</div>
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ $authType === 'key' ? __('terminal.host_field_key') : __('terminal.host_field_password') }}</label>
@if ($authType === 'key')
<textarea wire:model="secret" rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
class="w-full resize-none rounded-md border border-line bg-inset px-3 py-2.5 font-mono text-xs text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none"></textarea>
@else
<input wire:model="secret" type="password" placeholder="{{ $configured ? __('terminal.host_secret_keep') : '••••••••' }}"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
@endif
@error('secret')<p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p>@enderror
</div>
@if ($authType === 'key')
<div>
<label class="mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('terminal.host_field_passphrase') }}</label>
<input wire:model="passphrase" type="password" placeholder="••••••••"
class="h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
</div>
@endif
<p class="flex items-start gap-2 rounded-md border border-warning/25 bg-warning/5 px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-3">
<x-icon name="alert" class="mt-px h-3.5 w-3.5 shrink-0 text-warning" />
{{ __('terminal.host_security_note') }}
</p>
</div>
<div class="mt-6 flex items-center justify-between gap-2">
<div>
@if ($configured)
<x-btn variant="ghost" wire:click="remove" wire:target="remove" wire:loading.attr="disabled" class="text-offline hover:text-offline">
<x-icon name="trash" class="h-3.5 w-3.5" />
{{ __('terminal.host_remove') }}
</x-btn>
@endif
</div>
<div class="flex items-center gap-2">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.cancel') }}</x-btn>
<x-btn variant="primary" wire:click="save" wire:target="save" wire:loading.attr="disabled">
<x-icon name="save" class="h-3.5 w-3.5" wire:loading.remove wire:target="save" />
<svg wire:loading wire:target="save" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
{{ __('terminal.host_save') }}
</x-btn>
</div>
</div>
</div>

View File

@ -10,24 +10,38 @@
{{-- Target rail --}}
<x-panel :title="__('terminal.targets_title')" :subtitle="__('terminal.targets_subtitle')" :padded="false">
<div class="space-y-1 p-2">
{{-- Clusev host --}}
<button type="button" wire:click="open('host')" wire:loading.attr="disabled"
@class([
'flex w-full items-center gap-3 rounded-md border px-3 py-2.5 text-left transition-colors',
'border-accent/30 bg-accent/10' => $activeKey === 'host',
'border-transparent hover:border-line hover:bg-raised/60' => $activeKey !== 'host',
])>
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent">
<x-icon name="command" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1">
<span @class(['block truncate font-mono text-sm', 'text-accent-text' => $activeKey === 'host', 'text-ink' => $activeKey !== 'host'])>{{ __('terminal.host_label') }}</span>
<span class="block truncate font-mono text-[11px] text-ink-4">{{ __('terminal.host_sub') }}</span>
</span>
</button>
{{-- Clusev host a real SSH login into the host machine (configured by the operator). --}}
<div @class([
'flex items-center rounded-md border transition-colors',
'border-accent/30 bg-accent/10' => $activeKey === 'host',
'border-transparent hover:border-line hover:bg-raised/60' => $activeKey !== 'host',
])>
<button type="button" wire:click="open('host')" wire:loading.attr="disabled"
class="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-md border border-accent/25 bg-accent/10 text-accent">
<x-icon name="command" class="h-4 w-4" />
</span>
<span class="min-w-0 flex-1">
<span @class(['block truncate font-mono text-sm', 'text-accent-text' => $activeKey === 'host', 'text-ink' => $activeKey !== 'host'])>{{ __('terminal.host_label') }}</span>
<span @class(['block truncate font-mono text-[11px]', 'text-ink-3' => $hostConfigured, 'text-warning' => ! $hostConfigured])>{{ $hostConfigured ? $hostTarget : __('terminal.host_not_configured') }}</span>
</span>
</button>
<button type="button" wire:click="configureHost" title="{{ __('terminal.host_configure') }}"
class="mr-1.5 grid h-7 w-7 shrink-0 place-items-center rounded-md text-ink-4 transition-colors hover:bg-raised hover:text-ink-2">
<x-icon name="settings" class="h-3.5 w-3.5" />
</button>
</div>
<div class="my-1.5 px-3 font-mono text-[10px] uppercase tracking-[0.18em] text-ink-4">{{ __('terminal.servers_heading') }}</div>
@if ($showSearch)
<div class="relative px-1 pb-1">
<x-icon name="search" class="pointer-events-none absolute left-3.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-4" />
<input wire:model.live.debounce.250ms="search" type="text" placeholder="{{ __('terminal.search_placeholder') }}"
class="h-8 w-full rounded-md border border-line bg-inset pl-8 pr-3 font-mono text-[12px] text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none" />
</div>
@endif
@forelse ($servers as $s)
<button type="button" wire:key="term-{{ $s->uuid }}" wire:click="open('server', '{{ $s->uuid }}')" wire:loading.attr="disabled"
@class([
@ -39,7 +53,7 @@
<span @class(['min-w-0 flex-1 truncate font-mono text-sm', 'text-accent-text' => $activeKey === $s->uuid, 'text-ink' => $activeKey !== $s->uuid])>{{ $s->name }}</span>
</button>
@empty
<p class="px-3 py-4 font-mono text-[11px] text-ink-4">{{ __('terminal.no_servers') }}</p>
<p class="px-3 py-4 font-mono text-[11px] text-ink-4">{{ $search !== '' ? __('terminal.no_match') : __('terminal.no_servers') }}</p>
@endforelse
</div>
</x-panel>
@ -75,6 +89,4 @@
</div>
</div>
</div>
<p class="font-mono text-[11px] text-ink-4">{{ __('terminal.hint') }}</p>
</div>

View File

@ -16,6 +16,7 @@ use App\Livewire\System;
use App\Livewire\Terminal;
use App\Livewire\Versions;
use App\Livewire\Wireguard;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\TerminalSession;
use App\Services\DeploymentService;
@ -81,8 +82,22 @@ Route::post('/_internal/terminal/resolve', function (Request $request) {
abort_if($burned !== 1, 404);
$session = TerminalSession::where('token', $token)->firstOrFail();
// The "Clusev host" terminal is a real SSH login into the host machine, configured by the operator
// (HostCredential). Hand the sidecar a server-shaped SSH spec — the decrypted login goes only over
// the internal network, never to the browser. No configured login → 404 (the UI gates this anyway).
if ($session->kind === 'host') {
return response()->json(['kind' => 'host']);
$hc = HostCredential::current();
abort_if($hc === null, 404);
return response()->json([
'kind' => 'server',
'host' => $hc->host,
'port' => $hc->port ?: 22,
'username' => $hc->username,
'auth_type' => $hc->auth_type,
'secret' => $hc->secret, // decrypted by the model cast; internal net only
'passphrase' => $hc->passphrase,
]);
}
$server = $session->server()->with('credential')->first();

View File

@ -2,7 +2,9 @@
namespace Tests\Feature;
use App\Livewire\Modals\HostShell;
use App\Livewire\Terminal\Index;
use App\Models\HostCredential;
use App\Models\Server;
use App\Models\SshCredential;
use App\Models\TerminalSession;
@ -30,6 +32,14 @@ class TerminalTest extends TestCase
return $s;
}
private function hostCredential(): HostCredential
{
return HostCredential::create([
'host' => 'host.docker.internal', 'port' => 2200, 'username' => 'root',
'auth_type' => 'password', 'secret' => 'r00t-pw',
]);
}
private function token(string $kind, ?int $serverId = null, ?\DateTimeInterface $expires = null): string
{
$token = 'tok-'.bin2hex(random_bytes(6));
@ -53,8 +63,10 @@ class TerminalTest extends TestCase
// ── token minting (Livewire) ──────────────────────────────────────────────
public function test_open_host_mints_a_single_use_host_session(): void
public function test_open_host_mints_a_single_use_host_session_when_configured(): void
{
$this->hostCredential();
Livewire::test(Index::class)->call('open', 'host')->assertSet('activeKey', 'host');
$s = TerminalSession::first();
@ -65,6 +77,15 @@ class TerminalTest extends TestCase
$this->assertTrue($s->expires_at->isFuture());
}
public function test_open_host_without_a_login_opens_the_setup_modal_and_mints_nothing(): void
{
Livewire::test(Index::class)
->call('open', 'host')
->assertDispatched('openModal', component: 'modals.host-shell');
$this->assertSame(0, TerminalSession::count());
}
public function test_open_server_mints_a_session_for_that_server(): void
{
$server = $this->server();
@ -88,12 +109,28 @@ class TerminalTest extends TestCase
$this->resolve($token, 'wrong-secret')->assertForbidden();
}
public function test_resolve_returns_host_kind_and_burns_the_token(): void
public function test_resolve_returns_the_host_ssh_spec_and_burns_the_token(): void
{
$this->hostCredential();
$token = $this->token('host');
$this->resolve($token)->assertOk()->assertExactJson([
'kind' => 'server', // the sidecar SSHes into the host exactly like a fleet server
'host' => 'host.docker.internal',
'port' => 2200,
'username' => 'root',
'auth_type' => 'password',
'secret' => 'r00t-pw', // decrypted by the model cast, only over the internal net
'passphrase' => null,
]);
// single use → second resolve fails
$this->resolve($token)->assertNotFound();
}
public function test_resolve_host_404s_when_no_host_login_is_configured(): void
{
$token = $this->token('host');
$this->resolve($token)->assertOk()->assertExactJson(['kind' => 'host']);
// single use → second resolve fails
$this->resolve($token)->assertNotFound();
}
@ -135,4 +172,76 @@ class TerminalTest extends TestCase
$this->server();
$this->get(route('terminal'))->assertOk()->assertSee(__('terminal.heading'));
}
// ── host-login modal + server search ───────────────────────────────────────
public function test_host_shell_modal_saves_the_singleton(): void
{
Livewire::test(HostShell::class)
->set('host', '10.0.0.1')
->set('port', 22)
->set('username', 'root')
->set('authType', 'password')
->set('secret', 'hunter2pw')
->call('save')
->assertDispatched('hostShellSaved');
$hc = HostCredential::current();
$this->assertNotNull($hc);
$this->assertSame('10.0.0.1', $hc->host);
$this->assertSame('root', $hc->username);
$this->assertSame('hunter2pw', $hc->secret); // round-trips through the encrypted cast
}
public function test_host_shell_edit_keeps_the_stored_secret_when_left_blank(): void
{
$this->hostCredential(); // secret 'r00t-pw'
Livewire::test(HostShell::class)
->assertSet('configured', true)
->set('username', 'admin') // change something, leave secret blank
->call('save');
$hc = HostCredential::current();
$this->assertSame('admin', $hc->username);
$this->assertSame('r00t-pw', $hc->secret); // unchanged
}
public function test_host_shell_requires_a_new_secret_when_switching_auth_method(): void
{
$this->hostCredential(); // auth_type 'password', secret 'r00t-pw'
Livewire::test(HostShell::class)
->set('authType', 'key') // switch method but leave the secret blank
->set('secret', '')
->call('save')
->assertHasErrors('secret');
// The stored login is untouched — no password silently reused as a key.
$hc = HostCredential::current();
$this->assertSame('password', $hc->auth_type);
$this->assertSame('r00t-pw', $hc->secret);
}
public function test_search_filters_the_server_rail(): void
{
Server::create(['name' => 'alpha-web', 'ip' => '10.0.0.1', 'status' => 'online']);
Server::create(['name' => 'beta-db', 'ip' => '10.0.0.2', 'status' => 'online']);
Livewire::test(Index::class)
->set('search', 'alpha')
->assertSee('alpha-web')
->assertDontSee('beta-db');
}
public function test_search_box_appears_only_past_the_threshold(): void
{
$this->get(route('terminal'))->assertDontSee(__('terminal.search_placeholder'));
foreach (range(1, 6) as $i) {
Server::create(['name' => 'srv-'.$i, 'ip' => '10.0.0.'.$i, 'status' => 'online']);
}
$this->get(route('terminal'))->assertSee(__('terminal.search_placeholder'));
}
}