feat(versions)+fix: password-sudo service control, honest login, real versions page

- Service control now works with a sudo-PASSWORD credential (not only NOPASSWD):
  FleetService::sudoFn() defines a remote priv() that feeds the vaulted password to
  `sudo -S` over stdin (base64 over the encrypted channel, never on the argv) when not
  root, else falls back to sudo -n. Verified LIVE on 10.10.90.162: cron restart/stop/
  start executed for real (ActiveEnterTimestamp advanced to now, is-active=inactive
  after stop, restarted clean), journal reads real entries via sudo.
- Login brand panel: removed fabricated telemetry — fake "24 Hosts erreichbar",
  "cpu 31% mem 48% load 0.86", "clusev connect 10.10.90.0/24". Replaced with true
  capability lines (agentless SSH/phpseclib, TOFU host-key pinning, 2FA, audit log,
  AGPL). Only real claims now (per "nur eintragen was auch wirklich geht").
- New Version & Releases page (/versions, EN route per R13) with REAL data only:
  version from config/clusev.php, build SHA + branch read from .git at runtime,
  changelog parsed from a real CHANGELOG.md, real Gitea repo + AGPL license, honest
  update path (deploy commands) — NO fake updater / stars / forks / CVEs / "update
  available v2.5.0". Sidebar nav + tag/git-branch icons added.
- Dummy-data sweep (12-auditor workflow + adversarial verify): 1 confirmed finding —
  removed the unused sine/cosine series() fake-sparkline generator in Dashboard.php;
  also fixed a stale "static seed" chart comment and a stale "mock listing" comment;
  derived the versions repo label from config (DRY). 5 false positives dismissed.

Verified (R12): /login + /versions + all 7 routes 200 / 0 console errors; login
fake-metrics gone + honest lines + fonts load; versions shows real version/build/
changelog/repo with zero fabricated stats.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-13 01:43:48 +02:00
parent aa7a7b1db8
commit 875678d0cd
12 changed files with 293 additions and 24 deletions

26
CHANGELOG.md Normal file
View File

@ -0,0 +1,26 @@
# Changelog
Alle nennenswerten Änderungen an Clusev. Format angelehnt an
[Keep a Changelog](https://keepachangelog.com), Versionierung nach
[SemVer](https://semver.org). Es gibt noch kein getaggtes Release —
der Stand ist die laufende v1-Foundation-Entwicklung.
## 0.1.0-dev — Unreleased
### 2026-06-13
- fix · Datei-Editor: Klick löste einen Alpine-Fehler aus (`@js()` kompiliert nicht in Komponenten-Attributen) — auf Index-Lookups umgestellt
- fix · Dienst-Steuerung über das hinterlegte Sudo-Passwort (`sudo -S`) statt nur passwortloses sudo — start/stop/restart + Journal funktionieren live
- feat · Selbst-gehostete Fonts (kein CDN), Split-Brand Login/2FA, diese Versions-Seite
- chore · Vollständige Regel-Auditierung (R1R14), neue Regel R14 (lokale Fonts)
### 2026-06-12
- feat · Dashboard mit Live-Sparklines und Dual-Chart (poll-getrieben)
- feat · Server-Index + Detailseite, Dienste (systemd), Datei-Manager (SFTP), Audit-Log
- feat · SSH-Schicht über phpseclib (exec + SFTP), Host-Key-Pinning (TOFU), Multi-Server-Switcher
- feat · Echte Metriken (CPU/RAM/Disk/Load) inkl. Absolutwerten; Datei-Up-/Download/Editor
- feat · Authentifizierung + 2FA (TOTP) mit erzwungenem Onboarding, R5-Bestätigungsmodals
- feat · Deploy: install.sh + Caddy (Auto-TLS) + Produktions-Härtung
- fix · Adversarial Review der SSH-Schicht (Escaping, Parser, Host-Key)
### 2026-06-11
- chore · Projekt-Bootstrap: Dockerisiertes Laravel 13 + Livewire 3 + Tailwind 4 + Reverb, Regelwerk (rules.md) + Projektkontext (CLAUDE.md)

View File

@ -30,18 +30,6 @@ class Dashboard extends Component
$this->ready = true;
}
/** Deterministic wavy series — fallback sparkline for metrics without history. */
private function series(float $base, int $n = 40, float $amp = 9): array
{
$out = [];
for ($i = 0; $i < $n; $i++) {
$v = $base + sin($i / 3.0) * $amp + sin($i / 6.5) * ($amp * 0.55) + cos($i / 2.0) * 2;
$out[] = (int) max(2, min(98, round($v)));
}
return $out;
}
/** Pad a real history series to a minimum length for a stable sparkline. */
private function pad(array $values, float|int $current, int $min = 16): array
{

View File

@ -0,0 +1,109 @@
<?php
namespace App\Livewire\Versions;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
/**
* Version & releases page. Everything shown here is real: the version comes from
* config, the build hash is read from .git at runtime, and the changelog is the
* project's own CHANGELOG.md. There is no in-app updater (Clusev ships as a Docker
* image) the page states the actual update path instead of faking one.
*/
#[Layout('layouts.app')]
#[Title('Version — Clusev')]
class Index extends Component
{
public ?string $lastChecked = null;
/** Honest "check": there is no release server yet — report the real status. */
public function checkUpdates(): void
{
$this->lastChecked = now()->format('H:i');
$this->dispatch('notify', message: 'Entwicklungsversion — kein Release-Kanal konfiguriert.');
}
/** Resolve the current commit from .git without needing the git binary. */
private function build(): array
{
$root = base_path('.git');
$head = @file_get_contents($root.'/HEAD');
if ($head === false) {
return ['sha' => null, 'branch' => config('clusev.channel')];
}
$head = trim($head);
if (! str_starts_with($head, 'ref: ')) {
return ['sha' => substr($head, 0, 7), 'branch' => config('clusev.channel')];
}
$ref = substr($head, 5);
$branch = preg_replace('#^refs/heads/#', '', $ref);
$sha = @file_get_contents($root.'/'.$ref);
if ($sha === false) {
// packed-refs fallback
foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) {
if (str_ends_with(trim($line), ' '.$ref)) {
$sha = explode(' ', trim($line))[0];
break;
}
}
}
return ['sha' => $sha ? substr(trim($sha), 0, 7) : null, 'branch' => $branch];
}
/**
* Parse CHANGELOG.md into dated groups for the timeline.
*
* @return array<int, array{date:string, items:array<int, array{type:string, text:string}>}>
*/
private function changelog(): array
{
$path = base_path('CHANGELOG.md');
if (! is_file($path)) {
return [];
}
$groups = [];
$date = null;
foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) {
if (preg_match('/^###\s+(.+)$/', $line, $m)) {
$date = trim($m[1]);
$groups[$date] = [];
continue;
}
if ($date !== null && preg_match('/^-\s*(\w+)\s*·\s*(.+)$/u', trim($line), $m)) {
$groups[$date][] = ['type' => strtolower($m[1]), 'text' => trim($m[2])];
}
}
$out = [];
foreach ($groups as $d => $items) {
if ($items !== []) {
$out[] = ['date' => $d, 'items' => $items];
}
}
return $out;
}
public function render()
{
return view('livewire.versions.index', [
'version' => config('clusev.version'),
'channel' => config('clusev.channel'),
'repository' => config('clusev.repository'),
'license' => config('clusev.license'),
'build' => $this->build(),
'groups' => $this->changelog(),
]);
}
}

View File

@ -32,6 +32,28 @@ class FleetService
return (new SshClient($this->vault, timeout: 12))->connect($server);
}
/**
* A remote `priv()` shell function: runs its arguments directly when already
* root, otherwise via sudo. For a password credential the stored password is
* fed to `sudo -S` over stdin (base64-encoded, so it never lands in the remote
* process argv / `ps`); key/other auth falls back to passwordless `sudo -n`.
* Prefix this to a command string, then invoke `priv <cmd>`.
*/
private function sudoFn(Server $server): string
{
$cred = $server->credential;
$password = ($cred && $cred->auth_type === 'password') ? (string) $cred->secret : '';
if ($password !== '') {
$b64 = base64_encode($password."\n");
$branch = 'echo '.$b64.' | base64 -d | sudo -S -p \'\' "$@"';
} else {
$branch = 'sudo -n "$@"';
}
return 'priv() { if [ "$(id -u)" = 0 ]; then "$@"; else '.$branch.'; fi; }; ';
}
/** Split a marker-delimited compound output into [section => body]. */
private function sections(string $out): array
{
@ -223,7 +245,7 @@ class FleetService
$cmd = 'export LC_ALL=C; '
.'echo '.self::MARK.'units===; systemctl list-units --type=service --all --plain --no-legend --no-pager; '
.'echo '.self::MARK.'files===; systemctl list-unit-files --type=service --no-legend --no-pager; '
.'echo '.self::MARK.'journal===; if [ "$(id -u)" = 0 ]; then SUDO=""; else SUDO="sudo -n"; fi; $SUDO journalctl -n '.(int) $journalLines.' --no-pager -o short-iso 2>&1';
.'echo '.self::MARK.'journal===; '.$this->sudoFn($server).'priv journalctl -n '.(int) $journalLines.' --no-pager -o short-iso 2>&1';
try {
$s = $this->sections($ssh->exec($cmd));
@ -466,8 +488,7 @@ class FleetService
try {
[$out, $code] = $ssh->run(
'export LC_ALL=C; if [ "$(id -u)" = 0 ]; then SUDO=""; else SUDO="sudo -n"; fi; '
."\$SUDO systemctl {$op} {$unit} 2>&1"
'export LC_ALL=C; '.$this->sudoFn($server)."priv systemctl {$op} {$unit} 2>&1"
);
return ['ok' => $code === 0, 'output' => trim($out)];

14
config/clusev.php Normal file
View File

@ -0,0 +1,14 @@
<?php
return [
// Pre-release development build — no tagged release yet. The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '0.1.0-dev',
'channel' => 'dev',
// Real project home (self-hosted Gitea) + license. Open-core, AGPL.
'repository' => 'https://git.bave.dev/boban/clusev',
'license' => 'AGPL-3.0',
];

View File

@ -4,6 +4,8 @@
$paths = [
'menu' => '<path d="M4 12h16"/><path d="M4 6h16"/><path d="M4 18h16"/>',
'chevron-left' => '<path d="m15 18-6-6 6-6"/>',
'tag' => '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
'git-branch' => '<line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/>',
'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"/>',
'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"/>',

View File

@ -32,6 +32,7 @@
<p class="px-3 pb-1 pt-3 font-mono text-[10px] uppercase tracking-widest text-ink-4">Konto</p>
<x-nav-item icon="settings" href="/settings" :active="request()->is('settings*')">Einstellungen</x-nav-item>
<x-nav-item icon="tag" href="/versions" :active="request()->is('versions*')">Version</x-nav-item>
</nav>
{{-- User --}}

View File

@ -42,14 +42,14 @@
<span class="h-2 w-2 rounded-full bg-warning/50"></span>
<span class="h-2 w-2 rounded-full bg-online/50"></span>
</span>
<span class="font-mono text-[10px] text-ink-4">ssh · control-plane</span>
<span class="font-mono text-[10px] text-ink-4">clusev · sicherheitsmodell</span>
</div>
<div class="space-y-1.5 p-4 font-mono text-xs leading-relaxed">
<p class="text-ink-2"><span class="text-ink-4">$</span> clusev connect <span class="text-accent-text">10.10.90.0/24</span></p>
<p class="text-online">24 Hosts erreichbar · Host-Keys gepinnt</p>
<p class="text-ink-2"><span class="text-ink-4">$</span> fleet status <span class="text-accent-text">--live</span></p>
<p class="text-ink-3">cpu 31% · mem 48% · load 0.86 · 0 Alarme</p>
<p class="flex items-center text-ink-2"><span class="text-ink-4">$</span><span class="ml-2 inline-block h-3.5 w-[7px] animate-pulse bg-accent"></span></p>
<div class="space-y-2.5 p-4 font-mono text-xs leading-relaxed">
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">ssh</span> agentenlos · exec + SFTP über phpseclib</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">key</span> Host-Keys via TOFU gepinnt</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">2fa</span> erzwungen für jeden Login</p>
<p class="flex items-center gap-2.5 text-ink-2"><span class="w-8 shrink-0 text-accent-text">log</span> jede Aktion im Audit-Log</p>
<p class="flex items-center gap-2.5 text-ink-3"><span class="w-8 shrink-0 text-cyan">core</span> Open Core · AGPL-lizenziert</p>
</div>
</div>
</div>

View File

@ -1,7 +1,7 @@
@php
$svcLabel = ['online' => 'aktiv', 'warning' => 'degradiert', 'offline' => 'gestoppt'];
// dual-series chart paths (CPU + MEM) — static seed; live wiring via Reverb is a follow-up
// Build SVG polyline paths from the real CPU/MEM history series (cache-backed, polled).
$chart = function (array $s) {
$w = 300; $h = 100; $pad = 6; $n = count($s); $pts = [];
foreach ($s as $i => $v) {

View File

@ -2,7 +2,7 @@
$dirs = collect($entries)->where('type', 'dir')->count();
$files = collect($entries)->where('type', 'file')->count();
// Human-readable byte sizes for the mock listing (real values come from SFTP later).
// Human-readable byte sizes for the live SFTP listing.
$fmtSize = function (?int $bytes): string {
if ($bytes === null) {
return '—';

View File

@ -0,0 +1,106 @@
@php
$tone = fn (string $t): string => ['feat' => 'accent', 'fix' => 'cyan', 'security' => 'accent'][$t] ?? 'neutral';
$label = fn (string $t): string => ['feat' => 'Feature', 'fix' => 'Fix', 'chore' => 'Wartung', 'security' => 'Sicherheit', 'perf' => 'Tempo', 'style' => 'Design'][$t] ?? ucfirst($t);
$repoHost = parse_url($repository, PHP_URL_HOST);
$repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/');
@endphp
<div class="space-y-5">
{{-- 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">Version</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">Version &amp; Releases</h2>
</div>
<x-btn variant="secondary" wire:click="checkUpdates" wire:target="checkUpdates" wire:loading.attr="disabled">
<svg wire:loading wire:target="checkUpdates" class="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true"><path d="M21 12a9 9 0 1 1-6.219-8.56" stroke-linecap="round" /></svg>
<x-icon name="rotate" class="h-3.5 w-3.5" wire:loading.remove wire:target="checkUpdates" />
Nach Updates suchen
</x-btn>
</div>
{{-- Honest status banner no fake "update available" --}}
<div class="flex flex-wrap items-center gap-4 rounded-xl border border-line bg-surface p-5 shadow-panel">
<span class="grid h-12 w-12 shrink-0 place-items-center rounded-xl border border-accent/25 bg-accent/10 text-accent-text"><x-icon name="tag" class="h-6 w-6" /></span>
<div class="min-w-0 flex-1">
<p class="flex flex-wrap items-center gap-2 font-display text-base font-semibold text-ink">
Clusev <span class="font-mono text-accent-text">v{{ $version }}</span>
<x-badge tone="neutral">Entwicklung</x-badge>
</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">Aktueller Entwicklungsstand noch kein getaggtes Release, kein Release-Kanal konfiguriert. Aktualisierung läuft über den Deploy (Image ziehen + migrieren).</p>
</div>
@if ($lastChecked)
<span class="shrink-0 font-mono text-[11px] text-ink-4">geprüft {{ $lastChecked }}</span>
@endif
</div>
<div class="grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_300px]">
{{-- Changelog timeline --}}
<x-panel title="Änderungsprotokoll" :subtitle="count($groups) . ' Tage'" :padded="false">
<div class="px-4 py-4 sm:px-5">
@forelse ($groups as $g)
<div class="flex gap-4 pb-6 last:pb-0">
<div class="flex flex-col items-center">
<span @class(['mt-1 h-2.5 w-2.5 shrink-0 rounded-full', 'bg-accent' => $loop->first, 'bg-ink-4' => ! $loop->first])></span>
@unless ($loop->last)<span class="mt-1 w-px flex-1 bg-line"></span>@endunless
</div>
<div class="-mt-0.5 min-w-0 flex-1">
<p class="font-mono text-xs text-ink-3">{{ $g['date'] }}</p>
<ul class="mt-2 space-y-2">
@foreach ($g['items'] as $it)
<li class="flex items-start gap-2.5">
<x-badge :tone="$tone($it['type'])" class="mt-0.5 shrink-0">{{ $label($it['type']) }}</x-badge>
<span class="text-sm leading-relaxed text-ink-2">{{ $it['text'] }}</span>
</li>
@endforeach
</ul>
</div>
</div>
@empty
<p class="font-mono text-[11px] text-ink-3">Kein Änderungsprotokoll gefunden.</p>
@endforelse
</div>
</x-panel>
{{-- Aside --}}
<div class="space-y-5">
<x-panel title="Installiert" :padded="false">
<div class="space-y-3 px-4 py-4 sm:px-5">
<div class="flex items-baseline gap-2">
<span class="font-display text-2xl font-bold text-ink">v{{ $version }}</span>
<span class="inline-flex items-center gap-1.5 font-mono text-[11px] text-online"><x-status-dot status="online" />läuft</span>
</div>
<dl class="space-y-2 font-mono text-[11px]">
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Build</dt><dd class="text-ink-2">{{ $build['sha'] ?? '—' }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Branch</dt><dd class="truncate text-ink-2">{{ $build['branch'] }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Kanal</dt><dd class="text-ink-2">{{ $channel }}</dd></div>
<div class="flex items-center justify-between gap-3"><dt class="text-ink-4">Lizenz</dt><dd class="text-ink-2">{{ $license }}</dd></div>
</dl>
</div>
</x-panel>
<x-panel title="Aktualisierung" :padded="false">
<div class="space-y-3 px-4 py-4 sm:px-5">
<p class="font-mono text-[11px] leading-relaxed text-ink-3">Updates kommen über den Deploy neues Image ziehen und Migrationen anwenden:</p>
<pre class="overflow-x-auto rounded-md border border-line bg-void px-3 py-2.5 font-mono text-[11px] leading-relaxed text-ink-2">docker compose -f docker-compose.prod.yml pull
docker compose -f docker-compose.prod.yml up -d
artisan migrate --force</pre>
</div>
</x-panel>
<x-panel title="Projekt" :padded="false">
<div class="space-y-3 px-4 py-4 sm:px-5">
<a href="{{ $repository }}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-3 rounded-md border border-line bg-inset px-3 py-2.5 transition-colors hover:border-accent/30">
<span class="grid h-8 w-8 shrink-0 place-items-center rounded-md bg-raised text-ink-3"><x-icon name="git-branch" class="h-4 w-4" /></span>
<span class="min-w-0 flex-1">
<span class="block truncate font-mono text-xs text-ink">{{ $repoPath }}</span>
<span class="block truncate font-mono text-[11px] text-ink-3">{{ $repoHost }}</span>
</span>
<x-icon name="chevron-left" class="h-4 w-4 shrink-0 rotate-180 text-ink-4" />
</a>
<p class="flex items-center gap-2 font-mono text-[11px] text-ink-3"><x-icon name="shield" class="h-3.5 w-3.5 shrink-0 text-cyan" /> Open Core · {{ $license }}</p>
</div>
</x-panel>
</div>
</div>
</div>

View File

@ -8,6 +8,7 @@ use App\Livewire\Files;
use App\Livewire\Servers;
use App\Livewire\Services;
use App\Livewire\Settings;
use App\Livewire\Versions;
use Illuminate\Support\Facades\Auth as AuthFacade;
use Illuminate\Support\Facades\Route;
@ -41,5 +42,6 @@ Route::middleware('auth')->group(function () {
Route::get('/audit', Audit\Index::class)->name('audit.index');
Route::get('/settings', Settings\Index::class)->name('settings');
Route::get('/versions', Versions\Index::class)->name('versions');
});
});