diff --git a/app/Livewire/Posture/Index.php b/app/Livewire/Posture/Index.php
new file mode 100644
index 0000000..db0136f
--- /dev/null
+++ b/app/Livewire/Posture/Index.php
@@ -0,0 +1,63 @@
+> server uuid → score result (or ['error' => msg]) */
+ public array $scores = [];
+
+ public bool $ready = false;
+
+ public function mount(): void
+ {
+ abort_unless(Auth::user()?->can('operate'), 403);
+ }
+
+ public function title(): string
+ {
+ return __('posture.title');
+ }
+
+ public function scan(PostureService $posture): void
+ {
+ abort_unless(Auth::user()?->can('operate'), 403);
+
+ $this->scores = [];
+ foreach (Server::withActiveCredential()->orderBy('name')->get() as $server) {
+ try {
+ $this->scores[$server->uuid] = $posture->score($server);
+ } catch (Throwable $e) {
+ $this->scores[$server->uuid] = ['error' => $e->getMessage()];
+ }
+ }
+
+ $this->ready = true;
+ }
+
+ public function render(): View
+ {
+ $scored = array_filter($this->scores, fn ($s) => isset($s['score']));
+ $avg = $scored !== [] ? (int) round(array_sum(array_column($scored, 'score')) / count($scored)) : null;
+
+ return view('livewire.posture.index', [
+ 'servers' => Server::withActiveCredential()->orderBy('name')->get(['uuid', 'name']),
+ 'average' => $avg,
+ ])->title($this->title());
+ }
+}
diff --git a/app/Services/PostureService.php b/app/Services/PostureService.php
new file mode 100644
index 0000000..10b0932
--- /dev/null
+++ b/app/Services/PostureService.php
@@ -0,0 +1,68 @@
+}
+ */
+ public function score(Server $server): array
+ {
+ $items = $this->hardening->state($server); // throws if the host can't be read
+
+ $checks = [];
+ $passed = 0;
+ $applicable = 0;
+
+ foreach ($items as $it) {
+ // A check counts toward the score only when the OS supports it AND it's a real security
+ // gate (not the neutral auto-updates preference).
+ $counts = $it['supported'] && ! $it['neutral'];
+ if ($counts) {
+ $applicable++;
+ if ($it['secure']) {
+ $passed++;
+ }
+ }
+
+ $checks[] = [
+ 'key' => $it['key'],
+ 'label' => $it['label'],
+ 'detail' => $it['detail'],
+ 'secure' => (bool) $it['secure'],
+ 'supported' => (bool) $it['supported'],
+ 'neutral' => (bool) $it['neutral'],
+ 'counts' => $counts,
+ ];
+ }
+
+ $score = $applicable > 0 ? (int) round($passed / $applicable * 100) : 100;
+
+ return [
+ 'score' => $score,
+ 'rating' => $this->rating($score),
+ 'passed' => $passed,
+ 'applicable' => $applicable,
+ 'checks' => $checks,
+ ];
+ }
+
+ /** strong ≥90 · fair ≥60 · weak below — mapped to the status triad in the UI. */
+ private function rating(int $score): string
+ {
+ return $score >= 90 ? 'strong' : ($score >= 60 ? 'fair' : 'weak');
+ }
+}
diff --git a/lang/de/posture.php b/lang/de/posture.php
new file mode 100644
index 0000000..5569db2
--- /dev/null
+++ b/lang/de/posture.php
@@ -0,0 +1,29 @@
+ 'Sicherheit',
+ 'title' => 'Sicherheitslage',
+ 'subtitle' => 'Härtungs-Score je Server über die Flotte',
+
+ 'scan_hint' => 'Prüfe Server …',
+ 'fleet_avg' => 'Flotten-Schnitt',
+ 'no_servers_title' => 'Keine Server',
+ 'no_servers' => 'Keine Server mit aktivem Credential zum Prüfen.',
+
+ // Rating
+ 'rating_strong' => 'Stark',
+ 'rating_fair' => 'Mittel',
+ 'rating_weak' => 'Schwach',
+
+ // Per server
+ 'passed_of' => ':passed/:applicable Prüfungen bestanden',
+ 'view_server' => 'Server öffnen',
+ 'scan_error' => 'Prüfung fehlgeschlagen',
+
+ // Checks
+ 'check_secure' => 'OK',
+ 'check_open' => 'Offen',
+ 'check_neutral' => 'Info',
+ 'check_unsupported' => 'n. v.',
+ 'remediation_hint' => 'Offene Punkte auf der Server-Detailseite härten.',
+];
diff --git a/lang/de/shell.php b/lang/de/shell.php
index 1920085..1163b4d 100644
--- a/lang/de/shell.php
+++ b/lang/de/shell.php
@@ -25,6 +25,7 @@ return [
'nav_docker' => 'Docker',
'nav_commands' => 'Befehle',
'nav_alerts' => 'Alarme',
+ 'nav_posture' => 'Sicherheitslage',
'nav_threats' => 'Bedrohungen',
'nav_versions' => 'Version',
'nav_help' => 'Hilfe',
diff --git a/lang/en/posture.php b/lang/en/posture.php
new file mode 100644
index 0000000..20371dc
--- /dev/null
+++ b/lang/en/posture.php
@@ -0,0 +1,29 @@
+ 'Security',
+ 'title' => 'Security posture',
+ 'subtitle' => 'Per-server hardening score across the fleet',
+
+ 'scan_hint' => 'Scanning servers …',
+ 'fleet_avg' => 'Fleet average',
+ 'no_servers_title' => 'No servers',
+ 'no_servers' => 'No servers with an active credential to scan.',
+
+ // Rating
+ 'rating_strong' => 'Strong',
+ 'rating_fair' => 'Fair',
+ 'rating_weak' => 'Weak',
+
+ // Per server
+ 'passed_of' => ':passed/:applicable checks passed',
+ 'view_server' => 'Open server',
+ 'scan_error' => 'Scan failed',
+
+ // Checks
+ 'check_secure' => 'OK',
+ 'check_open' => 'Open',
+ 'check_neutral' => 'Info',
+ 'check_unsupported' => 'n/a',
+ 'remediation_hint' => 'Harden the open items on the server details page.',
+];
diff --git a/lang/en/shell.php b/lang/en/shell.php
index 999f8e6..632e687 100644
--- a/lang/en/shell.php
+++ b/lang/en/shell.php
@@ -25,6 +25,7 @@ return [
'nav_docker' => 'Docker',
'nav_commands' => 'Commands',
'nav_alerts' => 'Alerts',
+ 'nav_posture' => 'Security posture',
'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 5bd3d82..8407d8d 100644
--- a/resources/views/components/sidebar.blade.php
+++ b/resources/views/components/sidebar.blade.php
@@ -60,6 +60,9 @@
{{ __('shell.group_account') }}
{{ __('shell.nav_settings') }}
{{ __('shell.nav_system') }}
+ @can('operate')
+ {{ __('shell.nav_posture') }}
+ @endcan
@can('manage-panel')
{{ __('shell.nav_alerts') }}
{{ __('shell.nav_threats') }}
diff --git a/resources/views/livewire/posture/index.blade.php b/resources/views/livewire/posture/index.blade.php
new file mode 100644
index 0000000..991c6aa
--- /dev/null
+++ b/resources/views/livewire/posture/index.blade.php
@@ -0,0 +1,76 @@
+@php
+ $ratingStatus = ['strong' => 'online', 'fair' => 'warning', 'weak' => 'offline'];
+ $scoreColor = fn (int $s) => $s >= 90 ? 'text-online' : ($s >= 60 ? 'text-warning' : 'text-offline');
+@endphp
+
+
+ {{-- Header --}}
+
+
+
{{ __('posture.eyebrow') }}
+
{{ __('posture.title') }}
+
{{ __('posture.subtitle') }}
+
+ @if ($ready && ! is_null($average))
+
+
{{ __('posture.fleet_avg') }}
+
{{ $average }}/100
+
+ @endif
+
+
+ @if ($servers->isEmpty())
+
+
+
{{ __('posture.no_servers_title') }}
+
{{ __('posture.no_servers') }}
+
+
+ @elseif (! $ready)
+
+ @foreach ($servers as $s)
+
+ @endforeach
+
{{ __('posture.scan_hint') }}
+
+ @else
+
+ @foreach ($servers as $s)
+ @php($r = $scores[$s->uuid] ?? null)
+
+ @if (! $r || isset($r['error']))
+
+
+ {{ $s->name }}
+ {{ __('posture.scan_error') }}
+
+ @else
+
+
+
{{ $r['score'] }}
+
{{ __('posture.rating_'.$r['rating']) }}
+
+
+
{{ $s->name }}
+
{{ __('posture.passed_of', ['passed' => $r['passed'], 'applicable' => $r['applicable']]) }}
+
+ @foreach ($r['checks'] as $c)
+ @if ($c['counts'])
+ $c['secure'],
+ 'border-offline/20 bg-offline/10 text-offline' => ! $c['secure'],
+ ])>
+ {{ $c['label'] }}
+
+ @endif
+ @endforeach
+
+
+
+ @endif
+
+ @endforeach
+
+ @endif
+
diff --git a/routes/web.php b/routes/web.php
index f807840..8d9918a 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\Posture;
use App\Livewire\Release;
use App\Livewire\Servers;
use App\Livewire\Services;
@@ -237,6 +238,7 @@ Route::middleware('auth')->group(function () {
Route::get('/settings', Settings\Index::class)->name('settings');
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('/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/PostureComponentTest.php b/tests/Feature/PostureComponentTest.php
new file mode 100644
index 0000000..994e2c5
--- /dev/null
+++ b/tests/Feature/PostureComponentTest.php
@@ -0,0 +1,91 @@
+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_posture(): void
+ {
+ $this->actingAs($this->viewer())->get('/posture')->assertForbidden();
+ }
+
+ public function test_operator_can_open_posture(): void
+ {
+ $this->actingAs($this->operator())->get('/posture')->assertOk();
+ }
+
+ public function test_scan_scores_each_active_credential_server(): void
+ {
+ $this->actingAs($this->operator());
+ $this->server('web1', '10.0.0.1');
+
+ $posture = Mockery::mock(PostureService::class);
+ $posture->shouldReceive('score')->once()->andReturn([
+ 'score' => 75, 'rating' => 'fair', 'passed' => 3, 'applicable' => 4, 'checks' => [],
+ ]);
+ app()->instance(PostureService::class, $posture);
+
+ Livewire::test(Index::class)
+ ->call('scan')
+ ->assertOk()
+ ->assertSet('ready', true)
+ ->assertSee('75')
+ ->assertSee(__('posture.rating_fair'));
+ }
+
+ public function test_a_server_that_cannot_be_scanned_shows_an_error_row_not_a_crash(): void
+ {
+ $this->actingAs($this->operator());
+ $this->server('down', '10.0.0.9');
+
+ $posture = Mockery::mock(PostureService::class);
+ $posture->shouldReceive('score')->once()->andThrow(new \RuntimeException('ssh unreachable'));
+ app()->instance(PostureService::class, $posture);
+
+ Livewire::test(Index::class)
+ ->call('scan')
+ ->assertOk()
+ ->assertSet('ready', true)
+ ->assertSee(__('posture.scan_error'));
+ }
+}
diff --git a/tests/Feature/PostureServiceTest.php b/tests/Feature/PostureServiceTest.php
new file mode 100644
index 0000000..f84ba4e
--- /dev/null
+++ b/tests/Feature/PostureServiceTest.php
@@ -0,0 +1,91 @@
+ 'box', 'ip' => '10.0.0.1', 'ssh_port' => 22, 'status' => 'online']);
+ }
+
+ /** @param array> $state */
+ private function scoreWith(array $state): array
+ {
+ $hardening = Mockery::mock(HardeningService::class);
+ $hardening->shouldReceive('state')->andReturn($state);
+
+ return (new PostureService($hardening))->score($this->server());
+ }
+
+ private function item(string $key, bool $secure, bool $supported = true, bool $neutral = false): array
+ {
+ return ['key' => $key, 'label' => $key, 'detail' => '', 'featureOn' => false, 'secure' => $secure, 'supported' => $supported, 'reason' => $supported ? null : 'x', 'neutral' => $neutral];
+ }
+
+ public function test_score_is_the_ratio_of_passing_applicable_gates(): void
+ {
+ $r = $this->scoreWith([
+ $this->item('ssh_root', true),
+ $this->item('ssh_password', true),
+ $this->item('fail2ban', true),
+ $this->item('firewall', false), // 3 of 4 secure
+ ]);
+
+ $this->assertSame(75, $r['score']);
+ $this->assertSame(3, $r['passed']);
+ $this->assertSame(4, $r['applicable']);
+ $this->assertSame('fair', $r['rating']);
+ }
+
+ public function test_all_secure_is_a_strong_hundred(): void
+ {
+ $r = $this->scoreWith([$this->item('a', true), $this->item('b', true)]);
+
+ $this->assertSame(100, $r['score']);
+ $this->assertSame('strong', $r['rating']);
+ }
+
+ public function test_neutral_and_unsupported_items_do_not_count(): void
+ {
+ $r = $this->scoreWith([
+ $this->item('ssh_root', true), // counts, secure
+ $this->item('unattended', false, neutral: true), // neutral → excluded
+ $this->item('firewall', false, supported: false), // unsupported → excluded
+ ]);
+
+ $this->assertSame(1, $r['applicable']); // only ssh_root counts
+ $this->assertSame(100, $r['score']); // 1/1 secure
+ }
+
+ public function test_all_failing_is_weak(): void
+ {
+ $r = $this->scoreWith([$this->item('a', false), $this->item('b', false)]);
+
+ $this->assertSame(0, $r['score']);
+ $this->assertSame('weak', $r['rating']);
+ }
+
+ public function test_no_applicable_checks_defaults_to_a_full_score(): void
+ {
+ $r = $this->scoreWith([$this->item('unattended', false, neutral: true)]);
+
+ $this->assertSame(0, $r['applicable']);
+ $this->assertSame(100, $r['score']); // nothing to fail → not penalised
+ }
+}