diff --git a/app/Livewire/Patch/Index.php b/app/Livewire/Patch/Index.php new file mode 100644 index 0000000..e3b129b --- /dev/null +++ b/app/Livewire/Patch/Index.php @@ -0,0 +1,130 @@ + uuid → counts */ + public array $counts = []; + + /** @var array uuid → last patch result */ + public array $results = []; + + public bool $ready = false; + + public function mount(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + public function title(): string + { + return __('patch.title'); + } + + private function gate(): void + { + abort_unless(Auth::user()?->can('operate'), 403); + } + + public function scan(MaintenanceService $maint): void + { + $this->gate(); + $this->counts = []; + + foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) { + try { + $this->counts[$server->uuid] = $maint->updateCounts($server); + } catch (Throwable $e) { + $this->counts[$server->uuid] = ['error' => $e->getMessage()]; + } + } + + $this->ready = true; + } + + public function patch(string $uuid): void + { + $this->gate(); + $server = Server::withActiveCredential()->where('uuid', $uuid)->firstOrFail(); + $c = $this->counts[$uuid] ?? []; + $pending = $c['pending'] ?? '?'; + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('patch.confirm_heading'), + 'body' => __('patch.confirm_body', ['server' => $server->name, 'count' => $pending]), + 'confirmLabel' => __('patch.apply'), + 'danger' => true, + 'icon' => 'rotate', + 'notify' => '', // the handler reports the outcome + 'token' => ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id), + ], + ); + } + + #[On('patchApply')] + public function applyPatch(string $confirmToken, MaintenanceService $maint): void + { + $this->gate(); + + try { + $payload = ConfirmToken::consume($confirmToken, 'patchApply'); + } catch (InvalidConfirmToken) { + return; // forged / replayed — the modal already audited patch.apply + } + + $server = Server::where('uuid', $payload['params']['uuid'])->first(); + if (! $server) { + return; + } + + $res = $maint->applyUpgrades($server); + $res['output'] = mb_scrub($res['output'], 'UTF-8'); // remote output into a Livewire prop → scrub invalid bytes + $this->results[$server->uuid] = $res; + + // Refresh this server's counts so the badge reflects the post-patch state. + try { + $this->counts[$server->uuid] = $maint->updateCounts($server); + } catch (Throwable) { + // keep the old counts if the re-scan fails + } + + $this->dispatch('notify', message: $res['ok'] + ? __('patch.patched', ['server' => $server->name]) + : __('patch.patch_failed', ['error' => $res['output']])); + } + + public function render(): View + { + // Fleet roll-up (only over successfully-scanned servers). + $scored = array_filter($this->counts, fn ($c) => isset($c['pending']) && $c['pending'] !== null); + $totalPending = array_sum(array_map(fn ($c) => $c['pending'], $scored)); + $totalSecurity = array_sum(array_map(fn ($c) => $c['security'] ?? 0, $scored)); + + return view('livewire.patch.index', [ + 'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']), + 'totalPending' => (int) $totalPending, + 'totalSecurity' => (int) $totalSecurity, + ])->title($this->title()); + } +} diff --git a/app/Services/MaintenanceService.php b/app/Services/MaintenanceService.php index 989452c..084c4c0 100644 --- a/app/Services/MaintenanceService.php +++ b/app/Services/MaintenanceService.php @@ -47,6 +47,36 @@ class MaintenanceService return preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null; } + /** + * Pending update count AND its security subset in one round-trip. Either value is NULL when the + * query couldn't run (unsupported manager / errored) so the caller shows "unbekannt" rather than + * a misleading 0. Security is best-effort per family; it is always ≤ pending. + * + * @return array{pending: ?int, security: ?int} + */ + public function updateCounts(Server $server): array + { + $os = $this->detector->detect($server); + if (! $os->managesPackages()) { + return ['pending' => null, 'security' => null]; + } + + $res = $this->fleet->runPlain($server, PackageManager::for($os)->countsScript()); + if (! str_contains($res['output'], 'CLUSEV_OK')) { + return ['pending' => null, 'security' => null]; + } + + $pending = preg_match('/CLUSEV_PENDING=(\d+)/', $res['output'], $m) ? (int) $m[1] : null; + $security = preg_match('/CLUSEV_SECURITY=(\d+)/', $res['output'], $s) ? (int) $s[1] : null; + + // Security can never exceed total pending (guards a noisy grep). + if ($pending !== null && $security !== null) { + $security = min($security, $pending); + } + + return ['pending' => $pending, 'security' => $security]; + } + /** * Refresh the index and apply all upgrades as root, using the host's package * manager. Long-running, so a 900s timeout. Output trimmed to the last ~400 diff --git a/app/Support/Confirm/ConfirmToken.php b/app/Support/Confirm/ConfirmToken.php index e77dafa..f6bbf89 100644 --- a/app/Support/Confirm/ConfirmToken.php +++ b/app/Support/Confirm/ConfirmToken.php @@ -70,6 +70,7 @@ class ConfirmToken 'alertRuleDeleted', 'commandRun', 'runbookDeleted', + 'patchApply', ]; /** How long an issued confirm stays valid (seconds) — generous for a human click. */ diff --git a/app/Support/Os/PackageManager.php b/app/Support/Os/PackageManager.php index 554d6e5..d039e48 100644 --- a/app/Support/Os/PackageManager.php +++ b/app/Support/Os/PackageManager.php @@ -51,6 +51,29 @@ final class PackageManager }; } + /** + * Like pendingScript, but ALSO emits `CLUSEV_SECURITY=` — the subset of pending upgrades that + * come from a security source. One round-trip for both counts. Best-effort per family (the apt + * path is solid; dnf/zypper security queries degrade to 0 when the metadata isn't present). + */ + public function countsScript(): string + { + return match ($this->manager) { + 'apt' => 'out=$(apt-get -s upgrade 2>/dev/null) && echo CLUSEV_OK; ' + ."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -c '^Inst ')\"; " + ."echo \"CLUSEV_SECURITY=\$(printf '%s\\n' \"\$out\" | grep '^Inst ' | grep -ic 'securit')\"", + 'dnf' => 'out=$('.$this->dnf().' -q check-update 2>/dev/null); rc=$?; ' + .'if [ "$rc" = 0 ] || [ "$rc" = 100 ]; then echo CLUSEV_OK; fi; ' + ."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^[[:graph:]]+[[:space:]]+[[:graph:]]+[[:space:]]+[[:graph:]]+')\"; " + .'echo "CLUSEV_SECURITY=$('.$this->dnf().' -q --security check-update 2>/dev/null | grep -cE \'^[[:graph:]]+[[:space:]]+[[:graph:]]+[[:space:]]+[[:graph:]]+\')"', + 'zypper' => 'out=$(zypper --non-interactive list-updates 2>/dev/null); rc=$?; ' + .'[ "$rc" = 0 ] && echo CLUSEV_OK; ' + ."echo \"CLUSEV_PENDING=\$(printf '%s\\n' \"\$out\" | grep -cE '^v \\|')\"; " + .'echo "CLUSEV_SECURITY=$(zypper --non-interactive list-patches --category security 2>/dev/null | grep -cE \'^[[:alnum:]]\')"', + default => 'echo CLUSEV_PENDING=0; echo CLUSEV_SECURITY=0', + }; + } + /** Refresh index + apply all upgrades (privileged, long-running). */ public function applyScript(): string { diff --git a/lang/de/audit.php b/lang/de/audit.php index 250e85d..9470093 100644 --- a/lang/de/audit.php +++ b/lang/de/audit.php @@ -70,6 +70,7 @@ return [ 'group.members' => 'Gruppen-Zuordnung geändert', 'mail.configure' => 'E-Mail (SMTP) konfiguriert', 'password.change' => 'Passwort geändert', + 'patch.apply' => 'Updates eingespielt', 'password.reset' => 'Passwort zurückgesetzt', 'profile.update' => 'Profil aktualisiert', // Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone diff --git a/lang/de/patch.php b/lang/de/patch.php new file mode 100644 index 0000000..7e03f34 --- /dev/null +++ b/lang/de/patch.php @@ -0,0 +1,27 @@ + 'Wartung', + 'title' => 'Updates', + 'subtitle' => 'Ausstehende Paket-Updates je Server + Sicherheits-Updates', + + 'scan_hint' => 'Prüfe Updates …', + 'total_pending' => 'Ausstehend', + 'total_security' => 'Sicherheit', + 'no_servers_title' => 'Keine Server', + 'no_servers' => 'Keine Server mit aktivem Credential.', + + 'up_to_date' => 'Aktuell', + 'pending_label' => ':count Update(s)', + 'security_label' => ':count Sicherheit', + 'unknown' => 'unbekannt', + 'scan_error' => 'Prüfung fehlgeschlagen', + + 'apply' => 'Patchen', + 'patched' => ':server gepatcht.', + 'patch_failed' => 'Patch fehlgeschlagen: :error', + 'result_title' => 'Ergebnis', + + 'confirm_heading' => 'Updates einspielen', + 'confirm_body' => 'Alle Updates auf :server einspielen (:count ausstehend)?', +]; diff --git a/lang/de/shell.php b/lang/de/shell.php index 1163b4d..a95603d 100644 --- a/lang/de/shell.php +++ b/lang/de/shell.php @@ -26,6 +26,7 @@ return [ 'nav_commands' => 'Befehle', 'nav_alerts' => 'Alarme', 'nav_posture' => 'Sicherheitslage', + 'nav_patch' => 'Updates', 'nav_threats' => 'Bedrohungen', 'nav_versions' => 'Version', 'nav_help' => 'Hilfe', diff --git a/lang/en/audit.php b/lang/en/audit.php index 2eb6a58..f24b874 100644 --- a/lang/en/audit.php +++ b/lang/en/audit.php @@ -70,6 +70,7 @@ return [ 'group.members' => 'Group membership changed', 'mail.configure' => 'E-mail (SMTP) configured', 'password.change' => 'Password changed', + 'patch.apply' => 'Updates applied', 'password.reset' => 'Password reset', 'profile.update' => 'Profile updated', // Neutral, WAF/IDS-style labels — deliberately do NOT reveal the deception layer to anyone diff --git a/lang/en/patch.php b/lang/en/patch.php new file mode 100644 index 0000000..91f759c --- /dev/null +++ b/lang/en/patch.php @@ -0,0 +1,27 @@ + 'Maintenance', + 'title' => 'Updates', + 'subtitle' => 'Pending package updates per server + security updates', + + 'scan_hint' => 'Scanning updates …', + 'total_pending' => 'Pending', + 'total_security' => 'Security', + 'no_servers_title' => 'No servers', + 'no_servers' => 'No servers with an active credential.', + + 'up_to_date' => 'Up to date', + 'pending_label' => ':count update(s)', + 'security_label' => ':count security', + 'unknown' => 'unknown', + 'scan_error' => 'Scan failed', + + 'apply' => 'Patch', + 'patched' => ':server patched.', + 'patch_failed' => 'Patch failed: :error', + 'result_title' => 'Result', + + 'confirm_heading' => 'Apply updates', + 'confirm_body' => 'Apply all updates on :server (:count pending)?', +]; diff --git a/lang/en/shell.php b/lang/en/shell.php index 632e687..6b18bb4 100644 --- a/lang/en/shell.php +++ b/lang/en/shell.php @@ -26,6 +26,7 @@ return [ 'nav_commands' => 'Commands', 'nav_alerts' => 'Alerts', 'nav_posture' => 'Security posture', + 'nav_patch' => 'Updates', 'nav_threats' => 'Threats', 'nav_versions' => 'Version', 'nav_help' => 'Help', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index 8407d8d..b381e4f 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -62,6 +62,7 @@ {{ __('shell.nav_system') }} @can('operate') {{ __('shell.nav_posture') }} + {{ __('shell.nav_patch') }} @endcan @can('manage-panel') {{ __('shell.nav_alerts') }} diff --git a/resources/views/livewire/patch/index.blade.php b/resources/views/livewire/patch/index.blade.php new file mode 100644 index 0000000..0dfea9c --- /dev/null +++ b/resources/views/livewire/patch/index.blade.php @@ -0,0 +1,70 @@ +
+ {{-- Header --}} +
+

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

+

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

+

{{ __('patch.subtitle') }}

+
+ + {{-- Fleet KPIs --}} + @if ($ready) +
+ + +
+ @endif + + @if ($servers->isEmpty()) + +
+

{{ __('patch.no_servers_title') }}

+

{{ __('patch.no_servers') }}

+
+
+ @elseif (! $ready) +
+ @foreach ($servers as $s) +
+ @endforeach +

{{ __('patch.scan_hint') }}

+
+ @else + +
+ @foreach ($servers as $s) + @php($c = $counts[$s->uuid] ?? null) + @php($result = $results[$s->uuid] ?? null) +
+
+ + + @if (! $c || isset($c['error'])) + {{ __('patch.scan_error') }} + @elseif (is_null($c['pending'])) + {{ __('patch.unknown') }} + @elseif ($c['pending'] === 0) + {{ __('patch.up_to_date') }} + @else +
+ {{ __('patch.pending_label', ['count' => $c['pending']]) }} + @if (($c['security'] ?? 0) > 0) + {{ __('patch.security_label', ['count' => $c['security']]) }} + @endif + @can('operate') + {{ __('patch.apply') }} + @endcan +
+ @endif +
+ + @if ($result) +
{{ $result['output'] }}
+ @endif +
+ @endforeach +
+
+ @endif +
diff --git a/routes/web.php b/routes/web.php index 8d9918a..4b58d99 100644 --- a/routes/web.php +++ b/routes/web.php @@ -13,6 +13,7 @@ use App\Livewire\Dashboard; use App\Livewire\Docker; use App\Livewire\Files; use App\Livewire\Help; +use App\Livewire\Patch; use App\Livewire\Posture; use App\Livewire\Release; use App\Livewire\Servers; @@ -239,6 +240,7 @@ Route::middleware('auth')->group(function () { Route::get('/versions', Versions\Index::class)->name('versions'); Route::get('/system', System\Index::class)->name('system'); Route::get('/posture', Posture\Index::class)->middleware('can:operate')->name('posture'); + Route::get('/patch', Patch\Index::class)->middleware('can:operate')->name('patch'); Route::get('/help', Help\Index::class)->name('help'); Route::get('/wireguard', Wireguard\Index::class)->middleware('can:manage-network')->name('wireguard'); Route::get('/terminal', Terminal\Index::class)->name('terminal'); diff --git a/tests/Feature/MaintenanceUpdateCountsTest.php b/tests/Feature/MaintenanceUpdateCountsTest.php new file mode 100644 index 0000000..d21a919 --- /dev/null +++ b/tests/Feature/MaintenanceUpdateCountsTest.php @@ -0,0 +1,81 @@ + 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']); + } + + private function service(string $runPlainOutput, OsProfile $os): MaintenanceService + { + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPlain')->andReturn(['ok' => true, 'output' => $runPlainOutput]); + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($os); + + return new MaintenanceService($fleet, $detector); + } + + private function apt(): OsProfile + { + return new OsProfile('debian', 'debian', 'Debian 13', 'apt', 'ufw', 'systemd', ['apt-get']); + } + + public function test_parses_pending_and_security_counts(): void + { + $counts = $this->service("CLUSEV_OK\nCLUSEV_PENDING=12\nCLUSEV_SECURITY=3", $this->apt())->updateCounts($this->server()); + + $this->assertSame(12, $counts['pending']); + $this->assertSame(3, $counts['security']); + } + + public function test_security_is_clamped_to_pending(): void + { + // A noisy grep could over-count security; it can never exceed the total. + $counts = $this->service("CLUSEV_OK\nCLUSEV_PENDING=2\nCLUSEV_SECURITY=9", $this->apt())->updateCounts($this->server()); + + $this->assertSame(2, $counts['pending']); + $this->assertSame(2, $counts['security']); + } + + public function test_missing_sentinel_yields_unknown(): void + { + $counts = $this->service('some error, no sentinel', $this->apt())->updateCounts($this->server()); + + $this->assertNull($counts['pending']); + $this->assertNull($counts['security']); + } + + public function test_unsupported_manager_yields_unknown(): void + { + $alpine = new OsProfile('alpine', 'alpine', 'Alpine', 'apk', 'none', 'openrc', []); + $fleet = Mockery::mock(FleetService::class); + $fleet->shouldReceive('runPlain')->never(); // never even probes an unsupported manager + $detector = Mockery::mock(OsDetector::class); + $detector->shouldReceive('detect')->andReturn($alpine); + + $counts = (new MaintenanceService($fleet, $detector))->updateCounts($this->server()); + + $this->assertNull($counts['pending']); + } +} diff --git a/tests/Feature/PatchComponentTest.php b/tests/Feature/PatchComponentTest.php new file mode 100644 index 0000000..c6a33f2 --- /dev/null +++ b/tests/Feature/PatchComponentTest.php @@ -0,0 +1,123 @@ +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 server(string $name = 'box', string $ip = '10.0.0.1'): Server + { + $s = Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']); + $s->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']); + + return $s; + } + + public function test_viewer_cannot_open_patch(): void + { + $this->actingAs($this->viewer())->get('/patch')->assertForbidden(); + } + + public function test_operator_can_open_patch(): void + { + $this->actingAs($this->operator())->get('/patch')->assertOk(); + } + + public function test_scan_shows_pending_and_security_counts(): void + { + $this->actingAs($this->operator()); + $this->server('web1'); + + $maint = Mockery::mock(MaintenanceService::class); + $maint->shouldReceive('updateCounts')->once()->andReturn(['pending' => 12, 'security' => 3]); + app()->instance(MaintenanceService::class, $maint); + + Livewire::test(Index::class) + ->call('scan') + ->assertOk() + ->assertSet('ready', true) + ->assertSee('12') + ->assertViewHas('totalSecurity', 3); + } + + public function test_scan_survives_an_unreachable_server(): void + { + $this->actingAs($this->operator()); + $this->server('down'); + + $maint = Mockery::mock(MaintenanceService::class); + $maint->shouldReceive('updateCounts')->once()->andThrow(new \RuntimeException('ssh fail')); + app()->instance(MaintenanceService::class, $maint); + + Livewire::test(Index::class)->call('scan')->assertSet('ready', true)->assertSee(__('patch.scan_error')); + } + + public function test_patch_opens_a_confirm(): void + { + $this->actingAs($this->operator()); + $server = $this->server(); + + Livewire::test(Index::class)->call('patch', $server->uuid)->assertDispatched('openModal'); + } + + public function test_apply_patch_runs_upgrades_over_the_sealed_server(): void + { + $this->actingAs($this->operator()); + $server = $this->server(); + + $maint = Mockery::mock(MaintenanceService::class); + $maint->shouldReceive('applyUpgrades')->once()->with(Mockery::on(fn ($s) => $s->id === $server->id))->andReturn(['ok' => true, 'output' => 'upgraded 12 packages']); + $maint->shouldReceive('updateCounts')->andReturn(['pending' => 0, 'security' => 0]); // post-patch re-scan + app()->instance(MaintenanceService::class, $maint); + + $token = ConfirmToken::issue('patchApply', ['uuid' => $server->uuid], 'patch.apply', $server->name, $server->id); + ConfirmToken::confirm($token); + + Livewire::test(Index::class) + ->call('applyPatch', $token) + ->assertOk() + ->assertSet("results.{$server->uuid}.output", 'upgraded 12 packages'); + } + + public function test_a_forged_patch_token_runs_nothing(): void + { + $this->actingAs($this->operator()); + $maint = Mockery::mock(MaintenanceService::class); + $maint->shouldReceive('applyUpgrades')->never(); + app()->instance(MaintenanceService::class, $maint); + + Livewire::test(Index::class)->call('applyPatch', 'not-a-token')->assertSet('results', []); + } +}