diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8d72f10 --- /dev/null +++ b/CHANGELOG.md @@ -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 (R1–R14), 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) diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 49261c2..28b3534 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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 { diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php new file mode 100644 index 0000000..fda413d --- /dev/null +++ b/app/Livewire/Versions/Index.php @@ -0,0 +1,109 @@ +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}> + */ + 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(), + ]); + } +} diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index 2d30b72..72b2f8c 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -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 `. + */ + 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)]; diff --git a/config/clusev.php b/config/clusev.php new file mode 100644 index 0000000..31c36d3 --- /dev/null +++ b/config/clusev.php @@ -0,0 +1,14 @@ + '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', +]; diff --git a/resources/views/components/icon.blade.php b/resources/views/components/icon.blade.php index 331fdaf..decfe1f 100644 --- a/resources/views/components/icon.blade.php +++ b/resources/views/components/icon.blade.php @@ -4,6 +4,8 @@ $paths = [ 'menu' => '', 'chevron-left' => '', + 'tag' => '', + 'git-branch' => '', 'x' => '', 'dashboard' => '', 'server' => '', diff --git a/resources/views/components/sidebar.blade.php b/resources/views/components/sidebar.blade.php index d665ae8..7c4c8a5 100644 --- a/resources/views/components/sidebar.blade.php +++ b/resources/views/components/sidebar.blade.php @@ -32,6 +32,7 @@

Konto

Einstellungen + Version {{-- User --}} diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php index a022a48..03b29f8 100644 --- a/resources/views/layouts/auth.blade.php +++ b/resources/views/layouts/auth.blade.php @@ -42,14 +42,14 @@ - ssh · control-plane + clusev · sicherheitsmodell -
-

$ clusev connect 10.10.90.0/24

-

24 Hosts erreichbar · Host-Keys gepinnt

-

$ fleet status --live

-

cpu 31% · mem 48% · load 0.86 · 0 Alarme

-

$

+
+

ssh agentenlos · exec + SFTP über phpseclib

+

key Host-Keys via TOFU gepinnt

+

2fa erzwungen für jeden Login

+

log jede Aktion im Audit-Log

+

core Open Core · AGPL-lizenziert

diff --git a/resources/views/livewire/dashboard.blade.php b/resources/views/livewire/dashboard.blade.php index b37d076..c17135f 100644 --- a/resources/views/livewire/dashboard.blade.php +++ b/resources/views/livewire/dashboard.blade.php @@ -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) { diff --git a/resources/views/livewire/files/index.blade.php b/resources/views/livewire/files/index.blade.php index 3808a99..3d69a8e 100644 --- a/resources/views/livewire/files/index.blade.php +++ b/resources/views/livewire/files/index.blade.php @@ -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 '—'; diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php new file mode 100644 index 0000000..3724a7c --- /dev/null +++ b/resources/views/livewire/versions/index.blade.php @@ -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 + +
+ {{-- Header --}} +
+
+

Version

+

Version & Releases

+
+ + + + Nach Updates suchen + +
+ + {{-- Honest status banner — no fake "update available" --}} +
+ +
+

+ Clusev v{{ $version }} + Entwicklung +

+

Aktueller Entwicklungsstand — noch kein getaggtes Release, kein Release-Kanal konfiguriert. Aktualisierung läuft über den Deploy (Image ziehen + migrieren).

+
+ @if ($lastChecked) + geprüft {{ $lastChecked }} + @endif +
+ +
+ {{-- Changelog timeline --}} + +
+ @forelse ($groups as $g) +
+
+ $loop->first, 'bg-ink-4' => ! $loop->first])> + @unless ($loop->last)@endunless +
+
+

{{ $g['date'] }}

+
    + @foreach ($g['items'] as $it) +
  • + {{ $label($it['type']) }} + {{ $it['text'] }} +
  • + @endforeach +
+
+
+ @empty +

Kein Änderungsprotokoll gefunden.

+ @endforelse +
+
+ + {{-- Aside --}} +
+ +
+
+ v{{ $version }} + läuft +
+
+
Build
{{ $build['sha'] ?? '—' }}
+
Branch
{{ $build['branch'] }}
+
Kanal
{{ $channel }}
+
Lizenz
{{ $license }}
+
+
+
+ + +
+

Updates kommen über den Deploy — neues Image ziehen und Migrationen anwenden:

+
docker compose -f docker-compose.prod.yml pull
+docker compose -f docker-compose.prod.yml up -d
+artisan migrate --force
+
+
+ + +
+ + + + {{ $repoPath }} + {{ $repoHost }} + + + +

Open Core · {{ $license }}

+
+
+
+
+
diff --git a/routes/web.php b/routes/web.php index 59240cb..68e918e 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); }); });