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
parent
707b031bd2
commit
83626352e2
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue