From c253e79c0c293a6f0c042f157dcfad40846cb3e0 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 5 Jul 2026 22:43:36 +0200 Subject: [PATCH] feat(docker): show the Clusev host's own containers (host target) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Docker page only ever targeted fleet servers, so an operator whose containers run ON the Clusev host itself (the clusev stack + anything else on that machine) saw nothing — the page has "nothing to do with the fleet" from their point of view. It now has a target switch: • Clusev host (default when a host SSH login exists) — lists the containers on the machine Clusev runs on, reached over the local Docker gateway with the existing encrypted HostCredential, the same login the host terminal uses. • the active fleet server — the previous behaviour, still available. Mechanics: - HostCredential::toServer() builds a TRANSIENT Server (never persisted) carrying the host login; DockerService/FleetService run unchanged against it. The logs modal resolves serverId 0 to this host server. - VerifiesHostKey only pins (writes ssh_host_key) for persisted fleet servers now; a transient host connection no longer spawns a junk fleet row on every request. The host is the local gateway — no network path to MITM — so skipping the pin there is safe. - The `app` service (dev + prod compose) gains the host.docker.internal:host-gateway mapping the terminal sidecar already had, so the PHP layer can reach the host's sshd. Without it the app container cannot resolve the host at all — the real reason host Docker never worked. - Absent runtime still renders the clean "not installed" state; a host target with no login yet shows a setup hint. Verified end-to-end in a browser: the page defaults to the host and lists the full running stack (clusev-app/mariadb/redis/… + others) with Logs/Restart/Stop actions. 748 tests green (5 new host-target tests). Co-Authored-By: Claude Fable 5 --- app/Livewire/Docker/Index.php | 90 +++++++++++++++---- app/Livewire/Modals/ContainerLogs.php | 6 +- app/Models/HostCredential.php | 25 ++++++ app/Support/Ssh/VerifiesHostKey.php | 10 ++- docker-compose.prod.yml | 4 + docker-compose.yml | 5 ++ lang/de/docker.php | 6 ++ lang/en/docker.php | 6 ++ .../views/livewire/docker/index.blade.php | 43 ++++++++- tests/Feature/DockerComponentTest.php | 84 +++++++++++++++++ 10 files changed, 256 insertions(+), 23 deletions(-) diff --git a/app/Livewire/Docker/Index.php b/app/Livewire/Docker/Index.php index da1fbfa..77fef84 100644 --- a/app/Livewire/Docker/Index.php +++ b/app/Livewire/Docker/Index.php @@ -4,6 +4,8 @@ namespace App\Livewire\Docker; use App\Livewire\Concerns\WithFleetContext; use App\Models\AuditEvent; +use App\Models\HostCredential; +use App\Models\Server; use App\Services\DockerService; use Illuminate\Contracts\View\View; use Illuminate\Support\Facades\Auth; @@ -13,15 +15,20 @@ use Livewire\Component; use Throwable; /** - * Container management for the active fleet server (agentless over SSH). Viewing the list is open - * to any role; container actions (start/stop/restart/pause) require `operate`, matching how systemd - * service actions are gated. Loads lazily via wire:init like Files/Services. + * Container management over SSH. Two targets: the CLUSEV HOST itself (the machine Clusev runs on, + * reached via the stored HostCredential over the local Docker gateway) and any fleet server. The + * host is the default when configured — that is where the operator's own containers live. Viewing + * the list is open to any role; container actions (start/stop/restart) require `operate`, matching + * systemd service gating. Loads lazily via wire:init like Files/Services. */ #[Layout('layouts.app')] class Index extends Component { use WithFleetContext; + /** '' = auto (host when a host login exists, else the active fleet server); or 'host' / 'server'. */ + public string $target = ''; + /** @var array> */ public array $containers = []; @@ -29,9 +36,12 @@ class Index extends Component public bool $ready = false; - /** Docker binary absent on the host (e.g. a native mail server) — an honest state, not an error. */ + /** Docker binary absent on the target (e.g. a native mail server) — an honest state, not an error. */ public bool $notInstalled = false; + /** Host target selected but no host SSH login configured yet — points the operator to setup. */ + public bool $hostNotConfigured = false; + /** The docker error (daemon down / permission / rootless), surfaced so "empty" isn't misleading. */ public ?string $error = null; @@ -40,22 +50,65 @@ class Index extends Component return __('docker.title'); } + /** Whether a Clusev-host SSH login exists (controls the target switch + the default target). */ + public function hostConfigured(): bool + { + return HostCredential::current() !== null; + } + + /** Resolve '' to a concrete target: the host when one is configured, otherwise a fleet server. */ + public function effectiveTarget(): string + { + if ($this->target === 'host' || $this->target === 'server') { + return $this->target; + } + + return $this->hostConfigured() ? 'host' : 'server'; + } + + /** The Server the current target resolves to (transient host server, or the active fleet server). */ + private function targetServer(): ?Server + { + return $this->effectiveTarget() === 'host' + ? HostCredential::current()?->toServer() + : $this->activeServer(); + } + + /** Switch between the host and the active fleet server, then reload. */ + public function setTarget(string $target, DockerService $docker): void + { + $this->target = in_array($target, ['host', 'server'], true) ? $target : ''; + $this->ready = false; + $this->load($docker); + } + public function load(DockerService $docker): void { $this->containers = []; $this->connected = false; $this->notInstalled = false; + $this->hostNotConfigured = false; $this->error = null; - $active = $this->activeServer(); - if ($active && $active->credential_exists) { + $isHost = $this->effectiveTarget() === 'host'; + $server = $this->targetServer(); + + if ($isHost && ! $server) { + $this->hostNotConfigured = true; + $this->ready = true; + + return; + } + + // Fleet servers need a stored credential; the host server always carries the host login. + if ($server && ($isHost || $server->credential_exists)) { try { - // Probe first: a host with no docker binary (native mail/web server) gets a clean + // Probe first: a target with no docker binary (native mail/web server) gets a clean // "not installed" panel instead of the raw "sh: docker: not found" shell error. - if (! $docker->available($active)) { + if (! $docker->available($server)) { $this->notInstalled = true; } else { - $this->containers = $docker->containers($active); + $this->containers = $docker->containers($server); $this->connected = true; } } catch (Throwable $e) { @@ -71,19 +124,20 @@ class Index extends Component { abort_unless(Auth::user()?->can('operate'), 403); - $active = $this->activeServer(); - if (! $active || ! $active->credential_exists) { + $isHost = $this->effectiveTarget() === 'host'; + $server = $this->targetServer(); + if (! $server || (! $isHost && ! $server->credential_exists)) { return; } try { - $res = $docker->containerAction($active, $id, $op); + $res = $docker->containerAction($server, $id, $op); AuditEvent::create([ 'user_id' => Auth::id(), - 'server_id' => $active->id, + 'server_id' => $server->id, // null for the host (transient) — the target label names it 'actor' => Auth::user()?->name ?? 'system', 'action' => 'docker.action', - 'target' => "{$op} {$id} · {$active->name}", + 'target' => "{$op} {$id} · {$server->name}", 'ip' => request()->ip(), ]); $this->dispatch('notify', message: $res['ok'] @@ -103,13 +157,15 @@ class Index extends Component // operate action — consistent with gating file CONTENT reads. The list itself stays open. abort_unless(Auth::user()?->can('operate'), 403); - $active = $this->activeServer(); - if (! $active) { + $isHost = $this->effectiveTarget() === 'host'; + $server = $this->targetServer(); + if (! $server) { return; } $this->dispatch('openModal', component: 'modals.container-logs', arguments: [ - 'serverId' => $active->id, + // 0 is the sentinel the logs modal resolves to the Clusev host (no persisted id). + 'serverId' => $isHost ? 0 : (int) $server->id, 'ref' => $id, ]); } diff --git a/app/Livewire/Modals/ContainerLogs.php b/app/Livewire/Modals/ContainerLogs.php index bfaf10f..97cd402 100644 --- a/app/Livewire/Modals/ContainerLogs.php +++ b/app/Livewire/Modals/ContainerLogs.php @@ -2,6 +2,7 @@ namespace App\Livewire\Modals; +use App\Models\HostCredential; use App\Models\Server; use App\Services\DockerService; use Illuminate\Support\Facades\Auth; @@ -38,7 +39,10 @@ class ContainerLogs extends ModalComponent // Re-gate the actual read (not just the opener): logs can leak secrets → operate only. abort_unless(Auth::user()?->can('operate'), 403); - $server = Server::find($this->serverId); + // serverId 0 = the Clusev host (transient server from the host login); otherwise a fleet server. + $server = $this->serverId === 0 + ? HostCredential::current()?->toServer() + : Server::find($this->serverId); if (! $server) { $this->error = __('common.server_not_found'); $this->loaded = true; diff --git a/app/Models/HostCredential.php b/app/Models/HostCredential.php index cbd9fad..6458fa5 100644 --- a/app/Models/HostCredential.php +++ b/app/Models/HostCredential.php @@ -28,4 +28,29 @@ class HostCredential extends Model { return static::query()->orderBy('id')->first(); } + + /** + * A TRANSIENT Server (never persisted) that points SSH tooling — FleetService, DockerService — + * at the Clusev host itself, reusing the encrypted host login. The SshCredential is likewise + * transient; its encrypted 'secret' cast round-trips the plaintext back for CredentialVault. + * verifyHostKey skips pinning a non-persisted server, so this creates no fleet row. + */ + public function toServer(): Server + { + $cred = new SshCredential(['username' => $this->username, 'auth_type' => $this->auth_type]); + $cred->secret = $this->secret; + if ($this->passphrase) { + $cred->passphrase = $this->passphrase; + } + + $server = new Server([ + 'name' => 'Clusev Host', + 'ip' => $this->host, + 'ssh_port' => $this->port, + 'status' => 'online', + ]); + $server->setRelation('credential', $cred); + + return $server; + } } diff --git a/app/Support/Ssh/VerifiesHostKey.php b/app/Support/Ssh/VerifiesHostKey.php index bfe7427..8e23948 100644 --- a/app/Support/Ssh/VerifiesHostKey.php +++ b/app/Support/Ssh/VerifiesHostKey.php @@ -32,7 +32,13 @@ trait VerifiesHostKey return; } - // trust on first use - $server->forceFill(['ssh_host_key' => $hostKey])->save(); + // Trust on first use — but ONLY pin persisted fleet servers. A transient Server (the + // "Clusev host" built on the fly from HostCredential) must never be written to the DB: + // saving it would spawn a junk fleet row on every request. The host is reached over the + // local Docker gateway (host.docker.internal), which has no network path to MITM, so + // skipping the pin there is safe. + if ($server->exists) { + $server->forceFill(['ssh_host_key' => $hostKey])->save(); + } } } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 29a8716..e20b522 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -32,6 +32,10 @@ x-image: &image security_opt: - "no-new-privileges:true" pids_limit: 1024 + # Reach the Docker host's sshd from the PHP layer so the "Clusev host" Docker view (and any other + # host-targeted SSH) can talk to the real machine — same mapping the terminal sidecar uses. + extra_hosts: + - "host.docker.internal:host-gateway" services: app: diff --git a/docker-compose.yml b/docker-compose.yml index f10ba38..e918e23 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,11 @@ x-app: &app APP_GID: "${HOST_GID:-1002}" image: clusev-app:dev restart: unless-stopped + # Reach the Docker host's sshd from the PHP layer so the "Clusev host" Docker view (and any + # other host-targeted SSH) can talk to the real machine (host.docker.internal → host gateway), + # the same mapping the terminal sidecar already uses. + extra_hosts: + - "host.docker.internal:host-gateway" volumes: - .:/var/www/html - ./run:/var/www/html/storage/app/restart-signal diff --git a/lang/de/docker.php b/lang/de/docker.php index b84d29e..1e27631 100644 --- a/lang/de/docker.php +++ b/lang/de/docker.php @@ -7,6 +7,12 @@ return [ 'connected' => 'Verbunden', 'disconnected' => 'Getrennt', + 'target_label' => 'Ziel', + 'host_target' => 'Clusev-Host', + 'server_target' => 'Server', + 'host_unconfigured_title' => 'Host-SSH nicht eingerichtet', + 'host_unconfigured_hint' => 'Richte den Host-SSH-Zugang unter Terminal (Kachel „Clusev-Host“) ein, um die Container dieses Hosts zu sehen.', + 'unavailable_title' => 'Docker nicht verfügbar', 'unavailable_hint' => 'Daemon aus oder keine Berechtigung.', 'not_installed_title' => 'Docker nicht installiert', diff --git a/lang/en/docker.php b/lang/en/docker.php index 58f7d92..4319ccb 100644 --- a/lang/en/docker.php +++ b/lang/en/docker.php @@ -7,6 +7,12 @@ return [ 'connected' => 'Connected', 'disconnected' => 'Disconnected', + 'target_label' => 'Target', + 'host_target' => 'Clusev host', + 'server_target' => 'Server', + 'host_unconfigured_title' => 'Host SSH not configured', + 'host_unconfigured_hint' => 'Set up the host SSH login under Terminal (the “Clusev host” tile) to see this host’s containers.', + 'unavailable_title' => 'Docker unavailable', 'unavailable_hint' => 'The daemon is down, or no permission.', 'not_installed_title' => 'Docker not installed', diff --git a/resources/views/livewire/docker/index.blade.php b/resources/views/livewire/docker/index.blade.php index 9ca3166..3d1b202 100644 --- a/resources/views/livewire/docker/index.blade.php +++ b/resources/views/livewire/docker/index.blade.php @@ -6,6 +6,10 @@ in_array($s, ['created', 'restarting'], true) => 'pending', default => 'offline', // exited / dead / removing }; + $hostOn = $this->hostConfigured(); + $effTarget = $this->effectiveTarget(); + $active = $this->activeServer(); + $targetName = $effTarget === 'host' ? __('docker.host_target') : $active?->name; @endphp
@@ -15,10 +19,34 @@

{{ __('docker.eyebrow') }}

{{ __('docker.title') }}

- {{ $connected ? __('docker.connected') : __('docker.disconnected') }} + +
+ {{-- Target switch: the Clusev host vs the active fleet server (only when a host login exists) --}} + @if ($hostOn) +
+ + +
+ @endif + + {{ $connected ? __('docker.connected') : __('docker.disconnected') }} +
- + @if (! $ready) {{-- lazy skeleton --}}
@@ -26,8 +54,17 @@
@endfor
+ @elseif ($hostNotConfigured) + {{-- Host target picked but no host SSH login yet — send the operator to setup. --}} +
+
+ +
+

{{ __('docker.host_unconfigured_title') }}

+

{{ __('docker.host_unconfigured_hint') }}

+
@elseif ($notInstalled) - {{-- Honest, neutral state: the host simply has no docker (not a fault). --}} + {{-- Honest, neutral state: the target simply has no docker (not a fault). --}}
diff --git a/tests/Feature/DockerComponentTest.php b/tests/Feature/DockerComponentTest.php index 3de3844..3d7e275 100644 --- a/tests/Feature/DockerComponentTest.php +++ b/tests/Feature/DockerComponentTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; use App\Livewire\Docker\Index; use App\Livewire\Modals\ContainerLogs; +use App\Models\HostCredential; use App\Models\Server; use App\Models\User; use App\Services\DockerService; @@ -56,6 +57,89 @@ class DockerComponentTest extends TestCase return $docker; } + private function hostCred(): HostCredential + { + return HostCredential::create([ + 'host' => 'host.docker.internal', 'port' => 22, + 'username' => 'root', 'auth_type' => 'password', 'secret' => 'x', + ]); + } + + public function test_defaults_to_the_clusev_host_when_a_host_login_is_configured(): void + { + // The host is where the operator's own containers live, so it is the default target. + $this->actingAs($this->viewer()); + $this->hostCred(); + $this->activeServer(); // a fleet server also exists, but the host wins the default + $this->stubDocker([['id' => 'h1', 'name' => 'clusev-app-1', 'image' => 'clusev-app:prod', 'state' => 'running', 'status' => 'Up', 'ports' => '']]); + + Livewire::test(Index::class) + ->call('load') + ->assertOk() + ->assertSet('connected', true) + ->assertSee('clusev-app-1') + ->assertSee(__('docker.host_target')); + } + + public function test_switching_to_host_without_a_login_shows_the_unconfigured_state(): void + { + $this->actingAs($this->viewer()); + $this->activeServer(); + $this->stubDocker(); + + Livewire::test(Index::class) + ->call('setTarget', 'host') + ->assertOk() + ->assertSet('hostNotConfigured', true) + ->assertSet('connected', false) + ->assertSee(__('docker.host_unconfigured_title')); + } + + public function test_can_switch_from_the_host_to_the_active_fleet_server(): void + { + $this->actingAs($this->viewer()); + $this->hostCred(); + $this->activeServer(); + $this->stubDocker([['id' => 's1', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]); + + Livewire::test(Index::class) + ->call('setTarget', 'server') + ->assertOk() + ->assertSet('connected', true) + ->assertSee('web'); + } + + public function test_operator_can_action_a_host_container_and_it_is_audited(): void + { + $this->actingAs($this->operator()); + $this->hostCred(); // default target = host + $docker = $this->stubDocker(); + $docker->shouldReceive('containerAction')->once() + ->with(Mockery::type(Server::class), 'clusev-redis-1', 'restart') + ->andReturn(['ok' => true, 'output' => '']); + + Livewire::test(Index::class) + ->call('action', 'clusev-redis-1', 'restart') + ->assertOk(); + + // Host actions carry a null server_id (transient host server); the target label names it. + $this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => null]); + } + + public function test_logs_modal_loads_a_host_container_tail(): void + { + $this->actingAs($this->admin()); + $this->hostCred(); + $docker = Mockery::mock(DockerService::class); + $docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'clusev-app-1', 200)->andReturn('host log line'); + app()->instance(DockerService::class, $docker); + + Livewire::test(ContainerLogs::class, ['serverId' => 0, 'ref' => 'clusev-app-1']) + ->call('load') + ->assertOk() + ->assertSet('logs', 'host log line'); + } + public function test_viewer_can_browse_the_container_list(): void { $this->actingAs($this->viewer());