diff --git a/app/Livewire/Docker/Index.php b/app/Livewire/Docker/Index.php new file mode 100644 index 0000000..ebabcca --- /dev/null +++ b/app/Livewire/Docker/Index.php @@ -0,0 +1,106 @@ +> */ + 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()); + } +} diff --git a/app/Livewire/Modals/ContainerLogs.php b/app/Livewire/Modals/ContainerLogs.php new file mode 100644 index 0000000..bfaf10f --- /dev/null +++ b/app/Livewire/Modals/ContainerLogs.php @@ -0,0 +1,64 @@ +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'); + } +} diff --git a/app/Services/DockerService.php b/app/Services/DockerService.php new file mode 100644 index 0000000..4695d3b --- /dev/null +++ b/app/Services/DockerService.php @@ -0,0 +1,117 @@ +fleet->runPrivileged($server, 'command -v docker >/dev/null 2>&1 && echo yes || echo no'); + + return trim($res['output']) === 'yes'; + } + + /** + * @return array + */ + 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 + */ + 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.'); + } + } +} diff --git a/lang/de/audit.php b/lang/de/audit.php index d7ee025..bb92dda 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -57,6 +57,7 @@ return [ 'fail2ban.configure' => 'fail2ban konfiguriert', 'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt', 'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt', + 'docker.action' => 'Container-Aktion', 'file.edit' => 'Datei bearbeitet', 'firewall.rule_add' => 'Firewall-Regel hinzugefügt', 'firewall.rule_delete' => 'Firewall-Regel gelöscht', diff --git a/lang/de/docker.php b/lang/de/docker.php new file mode 100644 index 0000000..3518b62 --- /dev/null +++ b/lang/de/docker.php @@ -0,0 +1,26 @@ + '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.', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php index dc71e54..bd9e010 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -22,6 +22,7 @@ return [ 'nav_terminal' => 'Terminal', 'nav_settings' => 'Einstellungen', 'nav_system' => 'System', + 'nav_docker' => 'Docker', 'nav_alerts' => 'Alarme', 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', diff --git a/lang/en/audit.php b/lang/en/audit.php index 3b51256..1e53e2d 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -57,6 +57,7 @@ return [ 'fail2ban.configure' => 'fail2ban configured', 'fail2ban.ignoreip_add' => 'fail2ban: exception added', 'fail2ban.ignoreip_remove' => 'fail2ban: exception removed', + 'docker.action' => 'Container action', 'file.edit' => 'File edited', 'firewall.rule_add' => 'Firewall rule added', 'firewall.rule_delete' => 'Firewall rule deleted', diff --git a/lang/en/docker.php b/lang/en/docker.php new file mode 100644 index 0000000..401bde9 --- /dev/null +++ b/lang/en/docker.php @@ -0,0 +1,26 @@ + '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.', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 6c3a040..a7e4c51 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -22,6 +22,7 @@ return [ 'nav_terminal' => 'Terminal', 'nav_settings' => 'Settings', 'nav_system' => 'System', + 'nav_docker' => 'Docker', 'nav_alerts' => 'Alerts', 'nav_threats' => 'Threats', 'nav_versions' => 'Version', diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 8a4a5ca..f14ea74 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -13,6 +13,7 @@ 'x' => '', 'dashboard' => '', 'server' => '', + 'box' => '', 'cpu' => '', 'folder' => '', 'audit' => '', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 1928653..294cbba 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -46,6 +46,7 @@ {{ __('shell.nav_dashboard') }} {{ __('shell.nav_servers') }} {{ __('shell.nav_services') }} + {{ __('shell.nav_docker') }} {{ __('shell.nav_files') }} {{ __('shell.nav_audit') }} @can('manage-network') diff --git a/resources/views/livewire/docker/index.blade.php b/resources/views/livewire/docker/index.blade.php new file mode 100644 index 0000000..f1ff9b1 --- /dev/null +++ b/resources/views/livewire/docker/index.blade.php @@ -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 + +
+ {{-- Header --}} +
+
+

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

+

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

+
+ {{ $connected ? __('docker.connected') : __('docker.disconnected') }} +
+ + + @if (! $ready) + {{-- lazy skeleton --}} +
+ @for ($i = 0; $i < 3; $i++) +
+ @endfor +
+ @elseif (! $connected) +
+

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

+

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

+
+ @elseif (empty($containers)) +
+

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

+

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

+
+ @else +
+ @foreach ($containers as $c) +
+ +
+

{{ $c['name'] }}

+

{{ $c['image'] }}@if ($c['ports']) · {{ $c['ports'] }}@endif

+
+ + +
+ @can('operate') + {{ __('docker.logs') }} + @if ($c['state'] === 'running') + {{ __('docker.restart') }} + {{ __('docker.stop') }} + @else + {{ __('docker.start') }} + @endif + @endcan +
+
+ @endforeach +
+ @endif +
+
diff --git a/resources/views/livewire/modals/container-logs.blade.php b/resources/views/livewire/modals/container-logs.blade.php new file mode 100644 index 0000000..77a2f52 --- /dev/null +++ b/resources/views/livewire/modals/container-logs.blade.php @@ -0,0 +1,26 @@ +
+
+ + + +
+

{{ $ref }}

+

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

+
+
+ + @if (! $loaded) +
+ @elseif ($error) +
+ +

{{ $error }}

+
+ @else +
{{ $logs !== '' ? $logs : __('docker.logs_empty') }}
+ @endif + +
+ {{ __('common.close') }} +
+
diff --git a/routes/web.php b/routes/web.php index 5e2c6d2..15f14c1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Livewire\Alerts; use App\Livewire\Audit; use App\Livewire\Auth; use App\Livewire\Dashboard; +use App\Livewire\Docker; use App\Livewire\Files; use App\Livewire\Help; use App\Livewire\Release; @@ -222,6 +223,7 @@ Route::middleware('auth')->group(function () { })->name('servers.history'); 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('/audit', Audit\Index::class)->name('audit.index'); diff --git a/tests/Feature/DockerComponentTest.php b/tests/Feature/DockerComponentTest.php new file mode 100644 index 0000000..eb85dbf --- /dev/null +++ b/tests/Feature/DockerComponentTest.php @@ -0,0 +1,144 @@ +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'); + } +} diff --git a/tests/Feature/DockerServiceTest.php b/tests/Feature/DockerServiceTest.php new file mode 100644 index 0000000..c3d1e5a --- /dev/null +++ b/tests/Feature/DockerServiceTest.php @@ -0,0 +1,124 @@ + '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())); + } +}