UI polish + honest service-action reporting

Command palette: rebuild the nav list from a role-gated table so every
navigable page (Docker, Terminal, WireGuard, Commands, Security posture,
Updates, Certificates, Uptime, Alerts, Threats) appears with a role-gated
"g <key>" chord. The help panel is derived from the resulting list, so a
gated-out page has neither an entry nor a live shortcut (no 403 dead-end).
New chords k/t/w/c/p/u/r/a resolve via nav.find; no collision with the
hardcoded CMDK_GO map (d/s/i/f/l/e/y/v).

Scrollbars: add a .no-scrollbar utility and apply it to the server-detail
tab strip and the sidebar nav, so macOS "always show scroll bars" no longer
paints a permanent track where nothing overflows.

Threats KPIs: replace the orphaned 5-box row with a 4-box even grid of the
count metrics + a full-width Top-Attacker spotlight card (an IP, not a count).

serviceAction: a start/restart can EXIT 0 (systemd accepted the request) yet
the unit dies on launch (e.g. monit crashing on a bad config) -> is-active
reports failed. Verify the real post-action state and, when the unit did not
come up, report failure WITH the reason (systemctl status tail) so the
operator sees why instead of a false "gestartet". Harden all three systemctl
calls against argument injection: unit must start alphanumeric, passed after
`--` and escapeshellarg'd (Codex-reviewed, clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-06 22:15:57 +02:00
parent 40383261bd
commit b7e44ca3c2
6 changed files with 104 additions and 37 deletions

View File

@ -606,18 +606,47 @@ class FleetService
if (! in_array($op, ['start', 'stop', 'restart'], true)) { if (! in_array($op, ['start', 'stop', 'restart'], true)) {
throw new InvalidArgumentException('Unbekannte Aktion.'); throw new InvalidArgumentException('Unbekannte Aktion.');
} }
if (! preg_match('/^[\w@.:-]+$/', $unit)) { // Unit MUST start alphanumeric (blocks a leading-dash value that systemctl would parse as an
// option, e.g. `-H host` / `--signal=`) — mirrors the DockerService::ref() discipline. Every
// systemctl call below ALSO passes the unit after `--` (end-of-options) and escapeshellarg'd,
// so a hostile unit can never become a flag even if the pattern were ever loosened.
if (! preg_match('/\A[a-zA-Z0-9][\w@.:-]*\z/', $unit)) {
throw new InvalidArgumentException('Ungueltige Unit.'); throw new InvalidArgumentException('Ungueltige Unit.');
} }
$safe = escapeshellarg($unit);
$ssh = $this->client($server); $ssh = $this->client($server);
try { try {
[$out, $code] = $ssh->run( [$out, $code] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} {$unit} 2>&1" 'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} -- {$safe} 2>&1"
); );
$out = trim($out);
$ok = $code === 0;
return ['ok' => $code === 0, 'output' => trim($out)]; // A `start`/`restart` can EXIT 0 (systemd accepted the request) yet the unit dies on
// launch → is-active=failed a moment later (e.g. monit crashing on a bad config). Verify
// the real post-action state; if it never came up, report failure WITH the reason (last
// journal lines) so the operator sees *why* — not a false "gestartet". (stop = the unit
// going inactive IS success, so skip the check there.)
if ($ok && $op !== 'stop') {
[$state] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl is-active -- {$safe} 2>&1"
);
$state = trim($state);
if ($state !== 'active') {
$ok = false;
[$why] = $ssh->run(
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl status --no-pager --lines=12 -- {$safe} 2>&1 || true"
);
$detail = trim($why);
$out = trim(($out !== '' ? $out."\n" : '')
.$unit.': '.$state.' ('.$op.')'
.($detail !== '' ? "\n".$detail : ''));
}
}
return ['ok' => $ok, 'output' => $out];
} finally { } finally {
$ssh->disconnect(); $ssh->disconnect();
} }

View File

@ -169,6 +169,16 @@
.tabular { .tabular {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* Keep scroll capability but hide the track used on the sidebar nav + the server-detail tab
strip so macOS "always show scroll bars" doesn't paint a permanent bar where nothing overflows. */
.no-scrollbar {
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* old Edge */
}
.no-scrollbar::-webkit-scrollbar {
display: none; /* Chromium / WebKit (macOS) */
}
} }
/* Touch devices: no zoom-on-focus /* Touch devices: no zoom-on-focus

View File

@ -4,38 +4,54 @@
Strg/-K opens it; "g" + a key navigates; "/" focuses the page search; "?" shows help. Strg/-K opens it; "g" + a key navigates; "/" focuses the page search; "?" shows help.
--}} --}}
@php @php
// Every navigable page (mirrors the 3-group sidebar), each ROLE-GATED so the palette + its
// "g <key>" shortcut only ever offer pages the user can actually open — go() resolves a chord
// via this list, so a gated-out page has neither an entry nor a live shortcut (no 403 dead-end).
// [ lang key, href, chord letter (null = search-only), required ability (null = always) ]
$u = auth()->user();
$navDefs = [
// Fleet
['shell.nav_dashboard', '/', 'd', null],
['shell.nav_servers', '/servers', 's', null],
['shell.nav_services', '/services', 'i', null],
['shell.nav_commands', '/commands', 'c', 'operate'],
['shell.nav_files', '/files', 'f', null],
['shell.nav_audit', '/audit', 'l', null],
// Host
['shell.nav_docker', '/docker', 'k', 'manage-fleet'],
['shell.nav_terminal', '/terminal', 't', 'manage-fleet'],
['shell.nav_wireguard', '/wireguard', 'w', 'manage-network'],
['shell.nav_system', '/system', 'y', null],
['shell.nav_posture', '/posture', 'p', 'operate'],
['shell.nav_patch', '/patch', 'u', 'operate'],
['shell.nav_certs', '/certs', 'r', 'operate'],
['shell.nav_health', '/health', null, 'operate'],
// Account
['shell.nav_settings', '/settings', 'e', null],
['shell.nav_alerts', '/alerts', 'a', 'manage-panel'],
['shell.nav_threats', '/threats', null, 'manage-panel'],
['shell.nav_versions', '/versions', 'v', null],
['shell.nav_help', config('clusev.docs_url'), 'h', null],
];
$nav = [];
foreach ($navDefs as [$key, $href, $chord, $gate]) {
if ($gate !== null && ! ($u?->can($gate) ?? false)) {
continue;
}
$nav[] = ['label' => __($key), 'href' => $href, 'hint' => $chord ? 'g '.$chord : ''];
}
// Help panel: the fixed utility keys + one "g <key>" row per shortcut the user actually has.
$chords = [ $chords = [
['Strg / ⌘ K', __('shell.cmdk_open')], ['Strg / ⌘ K', __('shell.cmdk_open')],
['/', __('shell.cmdk_focus_search')], ['/', __('shell.cmdk_focus_search')],
['?', __('shell.cmdk_show_help')], ['?', __('shell.cmdk_show_help')],
['g d', __('shell.nav_dashboard')],
['g s', __('shell.nav_servers')],
['g i', __('shell.nav_services')],
['g f', __('shell.nav_files')],
['g l', __('shell.nav_audit')],
['g e', __('shell.nav_settings')],
['g y', __('shell.nav_system')],
['g v', __('shell.nav_versions')],
['g h', __('shell.nav_help')],
]; ];
foreach ($nav as $n) {
$nav = [ if ($n['hint'] !== '') {
['label' => __('shell.nav_dashboard'), 'href' => '/', 'hint' => 'g d'], $chords[] = [str_replace('g ', 'g ', $n['hint']), $n['label']];
['label' => __('shell.nav_servers'), 'href' => '/servers', 'hint' => 'g s'], }
['label' => __('shell.nav_services'), 'href' => '/services', 'hint' => 'g i'],
['label' => __('shell.nav_files'), 'href' => '/files', 'hint' => 'g f'],
['label' => __('shell.nav_audit'), 'href' => '/audit', 'hint' => 'g l'],
['label' => __('shell.nav_settings'), 'href' => '/settings', 'hint' => 'g e'],
['label' => __('shell.nav_system'), 'href' => '/system', 'hint' => 'g y'],
['label' => __('shell.nav_versions'), 'href' => '/versions', 'hint' => 'g v'],
['label' => __('shell.nav_help'), 'href' => config('clusev.docs_url'), 'hint' => 'g h'],
];
// Terminal is admin-only (manage-fleet) — add its "g t" chord + palette entry ONLY for admins,
// so lower roles neither see it nor dead-end on the now-gated /terminal route.
if (auth()->user()?->can('manage-fleet')) {
$chords[] = ['g t', __('shell.nav_terminal')];
$nav[] = ['label' => __('shell.nav_terminal'), 'href' => '/terminal', 'hint' => 'g t'];
} }
$actions = [ $actions = [

View File

@ -7,7 +7,7 @@
['key' => 'terminal', 'label' => __('servers.tab_terminal'), 'icon' => 'terminal', 'show' => (bool) auth()->user()?->can('operate')], ['key' => 'terminal', 'label' => __('servers.tab_terminal'), 'icon' => 'terminal', 'show' => (bool) auth()->user()?->can('operate')],
], fn ($t) => $t['show']); ], fn ($t) => $t['show']);
@endphp @endphp
<div class="flex items-center gap-1 overflow-x-auto border-b border-line" role="tablist" aria-label="{{ __('servers.tabs_aria') }}"> <div class="flex items-center gap-1 overflow-x-auto no-scrollbar border-b border-line" role="tablist" aria-label="{{ __('servers.tabs_aria') }}">
@foreach ($tabs as $t) @foreach ($tabs as $t)
<button type="button" wire:click="$set('tab', '{{ $t['key'] }}')" role="tab" <button type="button" wire:click="$set('tab', '{{ $t['key'] }}')" role="tab"
aria-selected="{{ $tab === $t['key'] ? 'true' : 'false' }}" aria-selected="{{ $tab === $t['key'] ? 'true' : 'false' }}"

View File

@ -31,7 +31,7 @@
</div> </div>
{{-- Nav (navBadges polls /shell/badges.json and updates the alert/threat/update chips live) --}} {{-- Nav (navBadges polls /shell/badges.json and updates the alert/threat/update chips live) --}}
<nav class="flex-1 space-y-1 overflow-y-auto p-3" x-data="navBadges"> <nav class="flex-1 space-y-1 overflow-y-auto no-scrollbar p-3" x-data="navBadges">
{{-- Flotte: per-server / fleet operations --}} {{-- Flotte: per-server / fleet operations --}}
<p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_fleet') }}</p> <p class="px-3 pb-1 pt-2 font-mono text-[10px] uppercase tracking-widest text-ink-4">{{ __('shell.group_fleet') }}</p>
<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>

View File

@ -14,6 +14,7 @@
</div> </div>
{{-- KPI strip: auto-fit login attempts first (the "someone tried to log in" signal). --}} {{-- KPI strip: auto-fit login attempts first (the "someone tried to log in" signal). --}}
{{-- Four homogeneous count metrics (even 2-/4-up grid, no orphan). --}}
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
<x-kpi <x-kpi
:label="__('threats.kpi_login_attempts')" :label="__('threats.kpi_login_attempts')"
@ -35,11 +36,22 @@
:value="$honeytoken_trips" :value="$honeytoken_trips"
:tone="$honeytoken_trips > 0 ? 'offline' : 'ink'" :tone="$honeytoken_trips > 0 ? 'offline' : 'ink'"
icon="alert" /> icon="alert" />
<x-kpi </div>
:label="__('threats.kpi_top_ip')"
:value="$top_ip ?? '—'" {{-- Top attacker is a single IP (not a count), so a full-width spotlight reads far better than a
:tone="$top_ip ? 'warning' : 'ink'" lone fifth KPI orphaned on its own grid row. --}}
icon="activity" /> <div class="flex items-center gap-3 rounded-lg border border-line bg-surface p-4 shadow-panel">
<span @class([
'grid h-9 w-9 shrink-0 place-items-center rounded-md',
'border border-warning/25 bg-warning/10 text-warning' => (bool) $top_ip,
'bg-raised text-ink-4' => ! $top_ip,
])>
<x-icon name="activity" class="h-4 w-4" />
</span>
<div class="min-w-0">
<p class="font-mono text-[11px] uppercase tracking-wider text-ink-3">{{ __('threats.kpi_top_ip') }}</p>
<p @class(['tabular mt-0.5 truncate font-mono text-xl font-semibold', 'text-warning' => (bool) $top_ip, 'text-ink-3' => ! $top_ip])>{{ $top_ip ?? '—' }}</p>
</div>
</div> </div>
{{-- Threat feed --}} {{-- Threat feed --}}