feat(docker): container management over SSH (feature 3/8)

Manage the containers running ON a fleet server, agentlessly — the integrated Portainer.
List containers + state, view logs, start/stop/restart, all via `docker` over SSH.

- DockerService (over FleetService::runPrivileged → base64 transport, shell-injection-safe):
  containers() parses `docker ps -a --format '{{json .}}'`; containerAction() with an op allow-list
  (start|stop|restart|pause|unpause); logs() with a clamped tail; composeStacks() read-only. Every
  container ref is validated `^[a-zA-Z0-9][\w.-]*$` before interpolation — blocks shell metacharacters
  AND a leading-dash argument injection (mirrors serviceAction). A missing/errored docker → [] (never
  throws to the UI).
- Docker\Index page (route /docker, sidebar "Docker" with a new box icon): lazy container list for the
  active server, open to any role. Container actions are `operate`-gated (abort_unless per method) and
  audited (docker.action). Reading LOGS is also operate-gated (container output can leak secrets —
  consistent with gating file CONTENT reads); the list/state stays open. ContainerLogs modal re-gates
  the read itself.
- lang/{de,en}/docker.php + audit.php docker.action + shell.nav_docker (de/en parity). No emoji.

Codex hardening applied: logs output is byte-capped (head -c 262144) AND mb_scrub'd before it reaches
a Livewire property (invalid UTF-8 / a huge line can't break the JSON snapshot); the ref regex uses
\A…\z anchors so a trailing newline can't slip past.

16 new tests: service (json parse, empty-on-unavailable, valid-op command, reject unknown-op / shell-
metachars / leading-dash / trailing-newline ref, logs clamp+byte-cap, available probe), component
(viewer browses the list; viewer 403 on action AND on logs open + modal read; operator/admin act +
audited; logs modal tail). 685 tests green, Pint, lang parity, Codex-reviewed (fixes applied).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 20:23:56 +02:00
parent 0472b3531a
commit 35b7c15598
16 changed files with 707 additions and 0 deletions

View File

@ -0,0 +1,106 @@
<?php
namespace App\Livewire\Docker;
use App\Livewire\Concerns\WithFleetContext;
use App\Models\AuditEvent;
use App\Services\DockerService;
use Illuminate\Contracts\View\View;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use Livewire\Attributes\Layout;
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.
*/
#[Layout('layouts.app')]
class Index extends Component
{
use WithFleetContext;
/** @var array<int, array<string, string>> */
public array $containers = [];
public bool $connected = false;
public bool $ready = false;
public function title(): string
{
return __('docker.title');
}
public function load(DockerService $docker): void
{
$this->containers = [];
$this->connected = false;
$active = $this->activeServer();
if ($active && $active->credential_exists) {
try {
$this->containers = $docker->containers($active);
$this->connected = true;
} catch (Throwable) {
$this->connected = false;
}
}
$this->ready = true;
}
public function action(string $id, string $op, DockerService $docker): void
{
abort_unless(Auth::user()?->can('operate'), 403);
$active = $this->activeServer();
if (! $active || ! $active->credential_exists) {
return;
}
try {
$res = $docker->containerAction($active, $id, $op);
AuditEvent::create([
'user_id' => Auth::id(),
'server_id' => $active->id,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'docker.action',
'target' => "{$op} {$id} · {$active->name}",
'ip' => request()->ip(),
]);
$this->dispatch('notify', message: $res['ok']
? __('docker.action_ok', ['op' => $op, 'ref' => $id])
: __('docker.action_failed', ['error' => $res['output']]));
} catch (InvalidArgumentException) {
$this->dispatch('notify', message: __('docker.invalid_ref'));
}
$this->load($docker);
}
/** Open the read-only logs modal for a container (the ref is re-validated in DockerService). */
public function viewLogs(string $id): void
{
// Container logs can contain secrets (env dumps, tokens in traces), so reading them is an
// 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) {
return;
}
$this->dispatch('openModal', component: 'modals.container-logs', arguments: [
'serverId' => $active->id,
'ref' => $id,
]);
}
public function render(): View
{
return view('livewire.docker.index')->title($this->title());
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Server;
use App\Services\DockerService;
use Illuminate\Support\Facades\Auth;
use InvalidArgumentException;
use LivewireUI\Modal\ModalComponent;
use Throwable;
/** Read-only container log tail. Loads lazily; the container ref is re-validated in DockerService. */
class ContainerLogs extends ModalComponent
{
public int $serverId;
public string $ref;
public string $logs = '';
public bool $loaded = false;
public ?string $error = null;
public function mount(int $serverId, string $ref): void
{
$this->serverId = $serverId;
$this->ref = $ref;
}
public static function modalMaxWidth(): string
{
return 'lg';
}
public function load(DockerService $docker): void
{
// 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);
if (! $server) {
$this->error = __('common.server_not_found');
$this->loaded = true;
return;
}
try {
$this->logs = $docker->logs($server, $this->ref, 200);
} catch (InvalidArgumentException) {
$this->error = __('docker.invalid_ref');
} catch (Throwable $e) {
$this->error = $e->getMessage();
}
$this->loaded = true;
}
public function render()
{
return view('livewire.modals.container-logs');
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Services;
use App\Models\Server;
use InvalidArgumentException;
/**
* Manage the containers running ON a fleet server, agentlessly over SSH. Every docker call goes
* through FleetService::runPrivileged (base64 transport shell-injection-safe); every container
* id/name is validated before interpolation so a crafted name can neither inject a command nor a
* leading-dash argument. Mirrors the FleetService::serviceAction discipline.
*/
class DockerService
{
public const ACTIONS = ['start', 'stop', 'restart', 'pause', 'unpause'];
public function __construct(private FleetService $fleet) {}
public function available(Server $server): bool
{
$res = $this->fleet->runPrivileged($server, 'command -v docker >/dev/null 2>&1 && echo yes || echo no');
return trim($res['output']) === 'yes';
}
/**
* @return array<int, array{id:string,name:string,image:string,state:string,status:string,ports:string}>
*/
public function containers(Server $server): array
{
$res = $this->fleet->runPrivileged($server, "docker ps -a --format '{{json .}}'");
if (! $res['ok']) {
return []; // docker missing / daemon down / permission → empty, never throw to the UI
}
$out = [];
foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$row = json_decode($line, true);
if (! is_array($row)) {
continue;
}
$out[] = [
'id' => (string) ($row['ID'] ?? ''),
'name' => (string) ($row['Names'] ?? ''),
'image' => (string) ($row['Image'] ?? ''),
'state' => strtolower((string) ($row['State'] ?? '')),
'status' => (string) ($row['Status'] ?? ''),
'ports' => (string) ($row['Ports'] ?? ''),
];
}
return $out;
}
public function containerAction(Server $server, string $id, string $op): array
{
if (! in_array($op, self::ACTIONS, true)) {
throw new InvalidArgumentException('Unbekannte Aktion.');
}
$this->assertValidRef($id);
$res = $this->fleet->runPrivileged($server, "docker {$op} {$id}");
return ['ok' => $res['ok'], 'output' => trim($res['output'])];
}
public function logs(Server $server, string $id, int $lines = 200): string
{
$this->assertValidRef($id);
$lines = max(1, min(2000, $lines));
// Cap by BYTES too (not just lines): one huge line or a binary-ish log could otherwise bloat
// the response or, via invalid UTF-8, break Livewire's JSON snapshot. head -c bounds it, and
// mb_scrub drops any invalid byte sequence before it reaches a Livewire public property.
$res = $this->fleet->runPrivileged($server, "docker logs --tail {$lines} {$id} 2>&1 | head -c 262144");
return mb_scrub(trim($res['output']), 'UTF-8');
}
/**
* @return array<int, array{name:string,status:string}>
*/
public function composeStacks(Server $server): array
{
$res = $this->fleet->runPrivileged($server, 'docker compose ls --format json 2>/dev/null');
if (! $res['ok']) {
return [];
}
$rows = json_decode(trim($res['output']), true);
if (! is_array($rows)) {
return [];
}
return array_map(fn ($r) => [
'name' => (string) ($r['Name'] ?? ''),
'status' => (string) ($r['Status'] ?? ''),
], array_values(array_filter($rows, 'is_array')));
}
/**
* A container id or name hex id or a docker name. MUST start alphanumeric (blocks a leading
* dash = argument injection) and contain only [\w.-] (no shell metacharacters).
*/
private function assertValidRef(string $ref): void
{
// \A ... \z (not ^...$): \z anchors the ABSOLUTE end so a trailing newline can't sneak past.
if (! preg_match('/\A[a-zA-Z0-9][\w.-]*\z/', $ref)) {
throw new InvalidArgumentException('Ungueltige Container-Referenz.');
}
}
}

View File

@ -57,6 +57,7 @@ return [
'fail2ban.configure' => 'fail2ban konfiguriert', 'fail2ban.configure' => 'fail2ban konfiguriert',
'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt', 'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt',
'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt', 'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt',
'docker.action' => 'Container-Aktion',
'file.edit' => 'Datei bearbeitet', 'file.edit' => 'Datei bearbeitet',
'firewall.rule_add' => 'Firewall-Regel hinzugefügt', 'firewall.rule_add' => 'Firewall-Regel hinzugefügt',
'firewall.rule_delete' => 'Firewall-Regel gelöscht', 'firewall.rule_delete' => 'Firewall-Regel gelöscht',

26
lang/de/docker.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
'eyebrow' => 'Flotte',
'title' => 'Docker',
'containers_title' => 'Container',
'connected' => 'Verbunden',
'disconnected' => 'Getrennt',
'unavailable_title' => 'Docker nicht verfügbar',
'unavailable_hint' => 'Kein Docker auf diesem Server, Daemon aus, oder keine Berechtigung.',
'empty_title' => 'Keine Container',
'empty_hint' => 'Auf diesem Server laufen keine Container.',
'logs' => 'Logs',
'logs_subtitle' => 'letzte 200 Zeilen (nur lesen)',
'logs_empty' => 'Keine Log-Ausgabe.',
'start' => 'Start',
'stop' => 'Stopp',
'restart' => 'Neustart',
'action_ok' => 'Aktion „:op“ auf :ref ausgeführt.',
'action_failed' => 'Aktion fehlgeschlagen: :error',
'invalid_ref' => 'Ungültige Container-Referenz.',
];

View File

@ -22,6 +22,7 @@ return [
'nav_terminal' => 'Terminal', 'nav_terminal' => 'Terminal',
'nav_settings' => 'Einstellungen', 'nav_settings' => 'Einstellungen',
'nav_system' => 'System', 'nav_system' => 'System',
'nav_docker' => 'Docker',
'nav_alerts' => 'Alarme', 'nav_alerts' => 'Alarme',
'nav_threats' => 'Bedrohungen', 'nav_threats' => 'Bedrohungen',
'nav_versions' => 'Version', 'nav_versions' => 'Version',

View File

@ -57,6 +57,7 @@ return [
'fail2ban.configure' => 'fail2ban configured', 'fail2ban.configure' => 'fail2ban configured',
'fail2ban.ignoreip_add' => 'fail2ban: exception added', 'fail2ban.ignoreip_add' => 'fail2ban: exception added',
'fail2ban.ignoreip_remove' => 'fail2ban: exception removed', 'fail2ban.ignoreip_remove' => 'fail2ban: exception removed',
'docker.action' => 'Container action',
'file.edit' => 'File edited', 'file.edit' => 'File edited',
'firewall.rule_add' => 'Firewall rule added', 'firewall.rule_add' => 'Firewall rule added',
'firewall.rule_delete' => 'Firewall rule deleted', 'firewall.rule_delete' => 'Firewall rule deleted',

26
lang/en/docker.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
'eyebrow' => 'Fleet',
'title' => 'Docker',
'containers_title' => 'Containers',
'connected' => 'Connected',
'disconnected' => 'Disconnected',
'unavailable_title' => 'Docker unavailable',
'unavailable_hint' => 'No Docker on this server, the daemon is down, or no permission.',
'empty_title' => 'No containers',
'empty_hint' => 'No containers are running on this server.',
'logs' => 'Logs',
'logs_subtitle' => 'last 200 lines (read-only)',
'logs_empty' => 'No log output.',
'start' => 'Start',
'stop' => 'Stop',
'restart' => 'Restart',
'action_ok' => 'Action “:op” run on :ref.',
'action_failed' => 'Action failed: :error',
'invalid_ref' => 'Invalid container reference.',
];

View File

@ -22,6 +22,7 @@ return [
'nav_terminal' => 'Terminal', 'nav_terminal' => 'Terminal',
'nav_settings' => 'Settings', 'nav_settings' => 'Settings',
'nav_system' => 'System', 'nav_system' => 'System',
'nav_docker' => 'Docker',
'nav_alerts' => 'Alerts', 'nav_alerts' => 'Alerts',
'nav_threats' => 'Threats', 'nav_threats' => 'Threats',
'nav_versions' => 'Version', 'nav_versions' => 'Version',

View File

@ -13,6 +13,7 @@
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>', 'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>', 'dashboard' => '<rect width="7" height="9" x="3" y="3" rx="1"/><rect width="7" height="5" x="14" y="3" rx="1"/><rect width="7" height="9" x="14" y="12" rx="1"/><rect width="7" height="5" x="3" y="16" rx="1"/>',
'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>', 'server' => '<rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/>',
'box' => '<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/>',
'cpu' => '<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>', 'cpu' => '<rect width="16" height="16" x="4" y="4" rx="2"/><rect width="6" height="6" x="9" y="9" rx="1"/><path d="M15 2v2"/><path d="M15 20v2"/><path d="M2 15h2"/><path d="M2 9h2"/><path d="M20 15h2"/><path d="M20 9h2"/><path d="M9 2v2"/><path d="M9 20v2"/>',
'folder' => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>', 'folder' => '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>', 'audit' => '<path d="M15 12h-5"/><path d="M15 8h-5"/><path d="M19 17V5a2 2 0 0 0-2-2H4"/><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2a1 1 0 0 0 1 1h3"/>',

View File

@ -46,6 +46,7 @@
<x-nav-item icon="dashboard" href="/" :active="request()->is('/')" data-tour="dashboard">{{ __('shell.nav_dashboard') }}</x-nav-item> <x-nav-item icon="dashboard" href="/" :active="request()->is('/')" data-tour="dashboard">{{ __('shell.nav_dashboard') }}</x-nav-item>
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')" data-tour="servers">{{ __('shell.nav_servers') }}</x-nav-item> <x-nav-item icon="server" href="/servers" :active="request()->is('servers*')" data-tour="servers">{{ __('shell.nav_servers') }}</x-nav-item>
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item> <x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
<x-nav-item icon="box" href="/docker" :active="request()->is('docker*')">{{ __('shell.nav_docker') }}</x-nav-item>
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item> <x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item> <x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
@can('manage-network') @can('manage-network')

View File

@ -0,0 +1,66 @@
@php
// container state -> status-pill status
$pill = fn (string $s) => match (true) {
$s === 'running' => 'online',
$s === 'paused' => 'warning',
in_array($s, ['created', 'restarting'], true) => 'pending',
default => 'offline', // exited / dead / removing
};
@endphp
<div class="space-y-4" @if (! $ready) wire:init="load" @endif>
{{-- Header --}}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('docker.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('docker.title') }}</h2>
</div>
<x-status-pill :status="$connected ? 'online' : 'offline'">{{ $connected ? __('docker.connected') : __('docker.disconnected') }}</x-status-pill>
</div>
<x-panel :title="__('docker.containers_title')" :subtitle="$this->activeServer()?->name" :padded="false">
@if (! $ready)
{{-- lazy skeleton --}}
<div class="space-y-2 p-4 sm:p-5">
@for ($i = 0; $i < 3; $i++)
<div class="h-12 animate-pulse rounded-md bg-raised/50"></div>
@endfor
</div>
@elseif (! $connected)
<div class="px-4 py-10 text-center sm:px-5">
<p class="text-sm text-ink-2">{{ __('docker.unavailable_title') }}</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.unavailable_hint') }}</p>
</div>
@elseif (empty($containers))
<div class="px-4 py-10 text-center sm:px-5">
<p class="text-sm text-ink-2">{{ __('docker.empty_title') }}</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">{{ __('docker.empty_hint') }}</p>
</div>
@else
<div class="divide-y divide-line">
@foreach ($containers as $c)
<div wire:key="ctr-{{ $c['id'] }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<x-status-dot :status="$pill($c['state'])" :ping="$c['state'] === 'running'" class="h-2.5 w-2.5" />
<div class="min-w-0 flex-1">
<p class="truncate font-mono text-sm text-ink">{{ $c['name'] }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $c['image'] }}@if ($c['ports']) · {{ $c['ports'] }}@endif</p>
</div>
<x-status-pill :status="$pill($c['state'])" class="hidden sm:inline-flex">{{ $c['state'] ?: '—' }}</x-status-pill>
<div class="flex flex-wrap items-center gap-1.5 pl-5 sm:pl-0">
@can('operate')
<x-btn variant="secondary" wire:click="viewLogs('{{ $c['id'] }}')">{{ __('docker.logs') }}</x-btn>
@if ($c['state'] === 'running')
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'restart')" wire:loading.attr="disabled">{{ __('docker.restart') }}</x-btn>
<x-btn variant="danger-soft" wire:click="action('{{ $c['id'] }}', 'stop')" wire:loading.attr="disabled">{{ __('docker.stop') }}</x-btn>
@else
<x-btn variant="secondary" wire:click="action('{{ $c['id'] }}', 'start')" wire:loading.attr="disabled">{{ __('docker.start') }}</x-btn>
@endif
@endcan
</div>
</div>
@endforeach
</div>
@endif
</x-panel>
</div>

View File

@ -0,0 +1,26 @@
<div wire:init="load">
<div class="mb-4 flex items-center gap-2.5">
<span class="grid h-9 w-9 shrink-0 place-items-center rounded-md border border-line bg-inset text-ink-2">
<x-icon name="box" class="h-4 w-4" />
</span>
<div class="min-w-0 flex-1">
<h2 class="truncate font-display text-base font-semibold text-ink" title="{{ $ref }}">{{ $ref }}</h2>
<p class="font-mono text-[11px] text-ink-3">{{ __('docker.logs_subtitle') }}</p>
</div>
</div>
@if (! $loaded)
<div class="h-64 animate-pulse rounded-md bg-raised/50"></div>
@elseif ($error)
<div class="flex items-center gap-2.5 rounded-md border border-offline/25 bg-offline/10 px-3.5 py-3">
<x-icon name="alert" class="h-4 w-4 shrink-0 text-offline" />
<p class="text-sm text-ink-2">{{ $error }}</p>
</div>
@else
<pre class="max-h-[60vh] overflow-auto rounded-md border border-line bg-void p-3 font-mono text-[11px] leading-relaxed text-ink-2">{{ $logs !== '' ? $logs : __('docker.logs_empty') }}</pre>
@endif
<div class="mt-4 flex justify-end">
<x-btn variant="secondary" wire:click="$dispatch('closeModal')">{{ __('common.close') }}</x-btn>
</div>
</div>

View File

@ -9,6 +9,7 @@ use App\Livewire\Alerts;
use App\Livewire\Audit; use App\Livewire\Audit;
use App\Livewire\Auth; use App\Livewire\Auth;
use App\Livewire\Dashboard; use App\Livewire\Dashboard;
use App\Livewire\Docker;
use App\Livewire\Files; use App\Livewire\Files;
use App\Livewire\Help; use App\Livewire\Help;
use App\Livewire\Release; use App\Livewire\Release;
@ -222,6 +223,7 @@ Route::middleware('auth')->group(function () {
})->name('servers.history'); })->name('servers.history');
Route::get('/services', Services\Index::class)->name('services.index'); Route::get('/services', Services\Index::class)->name('services.index');
Route::get('/docker', Docker\Index::class)->name('docker');
Route::get('/files', Files\Index::class)->name('files.index'); Route::get('/files', Files\Index::class)->name('files.index');
Route::get('/audit', Audit\Index::class)->name('audit.index'); Route::get('/audit', Audit\Index::class)->name('audit.index');

View File

@ -0,0 +1,144 @@
<?php
namespace Tests\Feature;
use App\Livewire\Docker\Index;
use App\Livewire\Modals\ContainerLogs;
use App\Models\Server;
use App\Models\User;
use App\Services\DockerService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class DockerComponentTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->create(['must_change_password' => false]);
}
private function operator(): User
{
return User::factory()->operator()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
private function activeServer(): Server
{
$server = Server::create(['name' => 'box', 'ip' => '10.0.0.2', 'ssh_port' => 22, 'status' => 'online']);
$server->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
session(['active_server_id' => $server->id]);
return $server;
}
private function stubDocker(array $containers = []): DockerService
{
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('containers')->andReturn($containers);
app()->instance(DockerService::class, $docker);
return $docker;
}
public function test_viewer_can_browse_the_container_list(): void
{
$this->actingAs($this->viewer());
$this->activeServer();
$this->stubDocker([['id' => 'abc', 'name' => 'web', 'image' => 'nginx', 'state' => 'running', 'status' => 'Up', 'ports' => '80/tcp']]);
Livewire::test(Index::class)
->call('load')
->assertOk()
->assertSet('connected', true)
->assertSee('web');
}
public function test_viewer_cannot_run_a_container_action(): void
{
$this->actingAs($this->viewer());
$this->activeServer();
$docker = $this->stubDocker();
$docker->shouldReceive('containerAction')->never(); // guard fires first
Livewire::test(Index::class)
->call('action', 'web', 'stop')
->assertForbidden();
}
public function test_operator_can_run_a_container_action_and_it_is_audited(): void
{
$this->actingAs($this->operator());
$server = $this->activeServer();
$docker = $this->stubDocker();
$docker->shouldReceive('containerAction')->once()
->with(Mockery::type(Server::class), 'web', 'restart')
->andReturn(['ok' => true, 'output' => 'web']);
Livewire::test(Index::class)
->call('action', 'web', 'restart')
->assertOk();
$this->assertDatabaseHas('audit_events', ['action' => 'docker.action', 'server_id' => $server->id]);
}
public function test_admin_can_run_a_container_action(): void
{
$this->actingAs($this->admin());
$this->activeServer();
$docker = $this->stubDocker();
$docker->shouldReceive('containerAction')->once()->andReturn(['ok' => true, 'output' => '']);
Livewire::test(Index::class)->call('action', 'db', 'start')->assertOk();
}
public function test_viewer_cannot_open_logs(): void
{
$this->actingAs($this->viewer());
$this->activeServer();
$this->stubDocker();
Livewire::test(Index::class)->call('viewLogs', 'web')->assertForbidden();
}
public function test_viewer_cannot_read_logs_in_the_modal(): void
{
$this->actingAs($this->viewer());
$server = $this->activeServer();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('logs')->never(); // guard fires before the read
app()->instance(DockerService::class, $docker);
Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web'])
->call('load')
->assertForbidden();
}
public function test_logs_modal_loads_a_container_tail(): void
{
$this->actingAs($this->admin());
$server = $this->activeServer();
$docker = Mockery::mock(DockerService::class);
$docker->shouldReceive('logs')->once()->with(Mockery::type(Server::class), 'web', 200)->andReturn('log line 1');
app()->instance(DockerService::class, $docker);
Livewire::test(ContainerLogs::class, ['serverId' => $server->id, 'ref' => 'web'])
->call('load')
->assertOk()
->assertSet('logs', 'log line 1');
}
}

View File

@ -0,0 +1,124 @@
<?php
namespace Tests\Feature;
use App\Models\Server;
use App\Services\DockerService;
use App\Services\FleetService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use InvalidArgumentException;
use Mockery;
use Tests\TestCase;
class DockerServiceTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function server(): Server
{
return Server::create(['name' => 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
}
/** DockerService over a mocked FleetService::runPrivileged; returns [$docker, $fleet]. */
private function make(): array
{
$fleet = Mockery::mock(FleetService::class);
return [new DockerService($fleet), $fleet];
}
public function test_containers_parses_the_docker_ps_json(): void
{
[$docker, $fleet] = $this->make();
$json = json_encode(['ID' => 'abc123', 'Names' => 'web', 'Image' => 'nginx', 'State' => 'running', 'Status' => 'Up 2h', 'Ports' => '80/tcp'])."\n"
.json_encode(['ID' => 'def456', 'Names' => 'db', 'Image' => 'mariadb', 'State' => 'exited', 'Status' => 'Exited (0)', 'Ports' => '']);
$fleet->shouldReceive('runPrivileged')->once()->andReturn(['ok' => true, 'output' => $json]);
$rows = $docker->containers($this->server());
$this->assertCount(2, $rows);
$this->assertSame('web', $rows[0]['name']);
$this->assertSame('running', $rows[0]['state']);
$this->assertSame('exited', $rows[1]['state']);
}
public function test_containers_returns_empty_when_docker_is_unavailable(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->andReturn(['ok' => false, 'output' => 'docker: command not found']);
$this->assertSame([], $docker->containers($this->server()));
}
public function test_container_action_runs_the_right_command_for_a_valid_ref(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->once()
->with(Mockery::type(Server::class), 'docker restart web1')
->andReturn(['ok' => true, 'output' => 'web1']);
$res = $docker->containerAction($this->server(), 'web1', 'restart');
$this->assertTrue($res['ok']);
}
public function test_container_action_rejects_an_unknown_op(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->never();
$this->expectException(InvalidArgumentException::class);
$docker->containerAction($this->server(), 'web', 'rm -rf');
}
public function test_container_action_rejects_a_ref_with_shell_metacharacters(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->never();
$this->expectException(InvalidArgumentException::class);
$docker->containerAction($this->server(), 'web; rm -rf /', 'stop');
}
public function test_container_action_rejects_a_leading_dash_ref(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->never();
$this->expectException(InvalidArgumentException::class);
$docker->containerAction($this->server(), '--help', 'stop'); // arg injection
}
public function test_logs_clamps_the_line_count_and_validates_the_ref(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->once()
->with(Mockery::type(Server::class), 'docker logs --tail 2000 web 2>&1 | head -c 262144')
->andReturn(['ok' => true, 'output' => 'log line']);
$this->assertSame('log line', $docker->logs($this->server(), 'web', 99999)); // clamped to 2000, byte-capped
}
public function test_container_action_rejects_a_ref_with_a_trailing_newline(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->never();
$this->expectException(InvalidArgumentException::class);
$docker->containerAction($this->server(), "web\n", 'stop'); // \z anchor blocks the trailing newline
}
public function test_available_reflects_the_probe(): void
{
[$docker, $fleet] = $this->make();
$fleet->shouldReceive('runPrivileged')->andReturn(['ok' => true, 'output' => 'yes']);
$this->assertTrue($docker->available($this->server()));
}
}