diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d72f10..afb4834 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,26 +1,61 @@ # Changelog -Alle nennenswerten Änderungen an Clusev. Format angelehnt an +Alle nennenswerten Änderungen an Clusev. Format nach [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. +[SemVer](https://semver.org). -## 0.1.0-dev — Unreleased +**Release-Prozess:** Feature-Commits sammeln sich auf dem Branch. Ist ein Stand +reif, wird er nach `main` gemerged, als `vX.Y.Z` getaggt und hier unter einer +eigenen Versionsüberschrift dokumentiert. Nutzer beziehen ausschließlich +getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Builds. -### 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) +## [Unreleased] -### 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) +_Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ -### 2026-06-11 -- chore · Projekt-Bootstrap: Dockerisiertes Laravel 13 + Livewire 3 + Tailwind 4 + Reverb, Regelwerk (rules.md) + Projektkontext (CLAUDE.md) +## [0.1.0] - 2026-06-13 + +Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Panel-Härtung als ein Release. + +### Hinzugefügt +- Dashboard mit Live-Sparklines und poll-getriebenem Dual-Chart (KPIs + Tabelle). +- Server-Index + Detailseite, Dienste (systemd: Start/Stop/Restart + Journal), + Datei-Manager (SFTP: Up-/Download, Editor), Audit-Log. +- SSH-Schicht über phpseclib (exec + SFTP) mit Host-Key-Pinning (TOFU) und + Multi-Server-Switcher samt Fleet-Kontext. +- Echte Metriken (CPU/RAM/Disk/Load) inkl. Absolutwerten über Reverb. +- Authentifizierung + 2FA (TOTP) mit erzwungenem Onboarding. +- R5-Bestätigungsmodals (wire-elements/modal) für destruktive Aktionen. +- System-Seite mit Domain & TLS (Caddy Auto-TLS) und Release-Kanal. +- Echte Versions-Seite, die Version, Build-Hash und dieses Änderungsprotokoll liest. +- Deploy: `install.sh` + Caddy (Auto-TLS) + Produktions-Härtung. +- Selbst-gehostete Fonts (kein CDN), Split-Brand Login/2FA. + +### Geändert +- Dienst-Steuerung läuft über das hinterlegte Sudo-Passwort (`sudo -S`) statt nur + passwortlosem sudo — Start/Stop/Restart + Journal funktionieren live. +- Lazy-Loading der Seiten (Skeletons), normalisierte Buttons + Spinner, + Detailseiten- und Einstellungs-Redesign, Schlüsselerzeugung. +- Vollständige Regel-Auditierung (R1–R14), neue Regel R14 (lokale Fonts). + +### Behoben +- Datei-Editor: Klick löste einen Alpine-Fehler aus (`@js()` kompiliert nicht in + Komponenten-Attributen) — auf Index-Lookups umgestellt. +- Adversarial Review der SSH-Schicht (Escaping, Parser, Host-Key-Pinning). +- 500 durch `@disabled` auf ``; Modal über dem Backdrop; poll-getriebene + Metriken (Chart + Gauges) statt fiktiver Fleet-Daten; 2FA-Setup-QR als ``. +- fail2ban/apt-Härtung schlug fehl, weil der 12s-SSH-Timeout den Paket-Install + abbrach — langer Timeout für apt-Aktionen. + +### Sicherheit +- Server-Härtung aus dem Dashboard (Root-/Passwort-Login, fail2ban, UFW, + Auto-Updates) mit Befehls-Vorschau, Bestätigung und Aussperr-Schutz. +- Verwaltung hinterlegter SSH-Zugänge (Name, Sperren, Löschen); der Vault + verweigert gesperrte Zugänge. Dienst-Steuerung über hinterlegtes Sudo-Passwort. +- Panel-Selbst-Härtung: Security-Header + umgebungsbewusste CSP, 2FA-Brute-Force- + Drossel, Session-Cookie-Härtung. +- Release-/Versionierungsmodell: echte semantische Versionen + Git-Tags, Changelog + pro Version, Kanäle `stable`/`beta` (kein nutzer-seitiges `dev`). + +[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.1.0...HEAD +[0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0 diff --git a/CLAUDE.md b/CLAUDE.md index f21ff06..9348eaa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,5 +252,6 @@ docker compose -f docker-compose.prod.yml exec app php artisan migrate --force 9. **Reuse** the token set + Blade component kit (R10). 10. **Verify in a browser (R12)**: every touched route loads at **HTTP 200** with **zero console errors** (loaded state of lazy pages, not the skeleton) — a green `Livewire::test` is not enough; - build + 3 breakpoints; then `git status` for stray - secrets, commit on the feature branch. + build + 3 breakpoints; then `git status` for stray secrets, commit on the feature branch. +11. **Codex review (R15)**: run **`/codex:review`** over the change; it must report **no errors and + no security issues**. Fix + re-run until clean. The task is not done until Codex passes. diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index 7d77ebd..753219a 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -19,18 +19,17 @@ use Livewire\Component; * to review. The app NEVER reloads Caddy itself (separate container) — it only * stages the config and shows the reload command. * - * Release-Kanal: a stable|beta|dev choice persisted as a Setting (default from + * Release-Kanal: a stable|beta choice persisted as a Setting (default from * config('clusev.channel')), audited on change. */ #[Layout('layouts.app')] #[Title('System — Clusev')] class Index extends Component { - /** Valid release channels with their German descriptions. */ + /** Valid user-facing release channels with their German descriptions. */ public const CHANNELS = [ - 'stable' => 'Nur getestete, freigegebene Releases. Empfohlen für den Produktivbetrieb.', - 'beta' => 'Vorabversionen zum Testen neuer Funktionen. Kann instabil sein.', - 'dev' => 'Entwicklungs-Builds direkt aus dem Haupt-Branch. Nur für Entwicklung.', + 'stable' => 'Nur getestete, vom Maintainer freigegebene Releases (getaggte Versionen). Empfohlen für den Produktivbetrieb.', + 'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.', ]; public string $domain = ''; @@ -43,7 +42,9 @@ class Index extends Component { $this->domain = (string) ($deployment->domain() ?? ''); $this->email = (string) ($deployment->email() ?? ''); - $this->channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable'; + $channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable'; + // Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable. + $this->channel = array_key_exists($channel, self::CHANNELS) ? $channel : 'stable'; } /** Persist domain + email, then (re)render the Caddy site config for review. */ @@ -124,6 +125,7 @@ class Index extends Component 'hasTls' => $this->domain !== '', 'caddyConfig' => $deployment->renderCaddyConfig(), 'reloadHint' => $deployment->reloadHint(), + 'whyNotEnv' => $deployment->whyNotEnv(), 'channels' => self::CHANNELS, ]); } diff --git a/app/Livewire/Versions/Index.php b/app/Livewire/Versions/Index.php index 00b369a..44b3e8a 100644 --- a/app/Livewire/Versions/Index.php +++ b/app/Livewire/Versions/Index.php @@ -8,28 +8,106 @@ 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. + * Version & releases page. Everything shown here is real: the installed version + * comes from config, the latest published release is the newest git TAG read from + * .git at runtime, and the release history is the project's own CHANGELOG.md parsed + * BY VERSION. There is no in-app updater (Clusev ships as a Docker image) and no + * fake release server — the update check honestly compares the installed version + * against the newest tag in the configured channel. */ #[Layout('layouts.app')] #[Title('Version — Clusev')] class Index extends Component { + /** Only these channels are ever offered to users — there is no 'dev' channel. */ + private const CHANNELS = ['stable', 'beta']; + public ?string $lastChecked = null; - /** Honest "check": there is no release server yet — report the real status. */ + public ?string $updateStatus = null; + + /** 'current' | 'update' | null — drives the banner tone. */ + public ?string $updateState = null; + + /** + * Honest update check: compare the installed version against the newest release + * tag visible in the channel. No external server, no stars, no CVE feed. + */ public function checkUpdates(): void { $this->lastChecked = now()->format('H:i'); - $channel = Setting::get('release_channel', config('clusev.channel')); - $repository = config('clusev.repository'); + $channel = $this->channel(); + $installed = (string) config('clusev.version'); + $latest = $this->latestTag(); - $this->dispatch('notify', message: $repository - ? 'Kanal: '.$channel.' — Quelle: '.$repository - : 'Entwicklungsversion — kein Release-Kanal konfiguriert.'); + if ($latest === null) { + $this->updateState = 'current'; + $this->updateStatus = 'Noch kein Release getaggt — installierte Version v'.$installed.' (Kanal: '.$channel.').'; + } elseif ($this->isNewer($latest, $installed)) { + $this->updateState = 'update'; + $this->updateStatus = 'Update verfügbar: v'.$latest.' (Kanal: '.$channel.').'; + } else { + $this->updateState = 'current'; + $this->updateStatus = 'Aktuell — v'.$installed.' (Kanal: '.$channel.').'; + } + } + + /** Resolve the configured channel, clamped to the user-facing set. */ + private function channel(): string + { + $channel = (string) (Setting::get('release_channel', config('clusev.channel')) ?? 'stable'); + + return in_array($channel, self::CHANNELS, true) ? $channel : 'stable'; + } + + /** + * Newest release tag from .git, without needing the git binary. Reads loose + * refs under refs/tags plus packed-refs, normalises a leading "v", and sorts + * by semantic version. Returns null when nothing is tagged yet. + */ + private function latestTag(): ?string + { + $root = base_path('.git'); + $tags = []; + + $dir = $root.'/refs/tags'; + if (is_dir($dir)) { + foreach (scandir($dir) ?: [] as $entry) { + if ($entry !== '.' && $entry !== '..' && is_file($dir.'/'.$entry)) { + $tags[] = $entry; + } + } + } + + foreach (preg_split('/\R/', (string) @file_get_contents($root.'/packed-refs')) as $line) { + if (preg_match('#\srefs/tags/(\S+)$#', trim($line), $m)) { + $tags[] = $m[1]; + } + } + + // Keep only vX.Y.Z / X.Y.Z style tags, normalise off the leading "v". + $versions = []; + foreach (array_unique($tags) as $tag) { + $v = ltrim($tag, 'vV'); + if (preg_match('/^\d+\.\d+\.\d+/', $v)) { + $versions[] = $v; + } + } + + if ($versions === []) { + return null; + } + + usort($versions, fn (string $a, string $b): int => version_compare($b, $a)); + + return $versions[0]; + } + + /** Is the given tag a newer semantic version than the installed one? */ + private function isNewer(string $tag, string $installed): bool + { + return version_compare(ltrim($tag, 'vV'), ltrim($installed, 'vV'), '>'); } /** Resolve the current commit from .git without needing the git binary. */ @@ -39,13 +117,13 @@ class Index extends Component $head = @file_get_contents($root.'/HEAD'); if ($head === false) { - return ['sha' => null, 'branch' => config('clusev.channel')]; + return ['sha' => null, 'branch' => $this->channel()]; } $head = trim($head); if (! str_starts_with($head, 'ref: ')) { - return ['sha' => substr($head, 0, 7), 'branch' => config('clusev.channel')]; + return ['sha' => substr($head, 0, 7), 'branch' => $this->channel()]; } $ref = substr($head, 5); @@ -66,51 +144,90 @@ class Index extends Component } /** - * Parse CHANGELOG.md into dated groups for the timeline. + * Parse CHANGELOG.md BY VERSION into release nodes. Each node carries its + * version, optional date, and the changes grouped by Keep-a-Changelog + * subsection (Hinzugefügt / Geändert / Behoben / Sicherheit / …), newest + * first (Unreleased on top). * - * @return array}> + * @return array>}> */ - private function changelog(): array + private function releases(): array { $path = base_path('CHANGELOG.md'); if (! is_file($path)) { return []; } - $groups = []; - $date = null; + $releases = []; + $current = null; + $group = null; foreach (preg_split('/\R/', (string) file_get_contents($path)) as $line) { - if (preg_match('/^###\s+(.+)$/', $line, $m)) { - $date = trim($m[1]); - $groups[$date] = []; + // Version header: "## [0.1.0] - 2026-06-13" or "## [Unreleased]" + if (preg_match('/^##\s+\[([^\]]+)\](?:\s*-\s*(.+))?\s*$/', $line, $m)) { + if ($current !== null) { + $releases[] = $current; + } + $version = trim($m[1]); + $unreleased = strcasecmp($version, 'Unreleased') === 0; + $current = [ + 'version' => $version, + 'date' => isset($m[2]) ? trim($m[2]) : null, + 'unreleased' => $unreleased, + 'groups' => [], + ]; + $group = null; continue; } - if ($date !== null && preg_match('/^-\s*(\w+)\s*·\s*(.+)$/u', trim($line), $m)) { - $groups[$date][] = ['type' => strtolower($m[1]), 'text' => trim($m[2])]; + + if ($current === null) { + continue; + } + + // Subsection header: "### Hinzugefügt" + if (preg_match('/^###\s+(.+?)\s*$/', $line, $m)) { + $group = trim($m[1]); + $current['groups'][$group] ??= []; + + continue; + } + + // Bullet line under the active subsection (handles wrapped continuations). + if ($group !== null && preg_match('/^\s*-\s+(.+?)\s*$/', $line, $m)) { + $current['groups'][$group][] = trim($m[1]); + + continue; + } + if ($group !== null && $current['groups'][$group] !== [] + && preg_match('/^\s{2,}(\S.*)$/', $line, $m)) { + // Continuation of the previous bullet (Keep-a-Changelog wrapping). + $last = array_key_last($current['groups'][$group]); + $current['groups'][$group][$last] .= ' '.trim($m[1]); } } - $out = []; - foreach ($groups as $d => $items) { - if ($items !== []) { - $out[] = ['date' => $d, 'items' => $items]; - } + if ($current !== null) { + $releases[] = $current; } - return $out; + // Drop empty nodes (e.g. a bare Unreleased) and link-reference tails. + return array_values(array_filter($releases, fn (array $r): bool => $r['groups'] !== [])); } public function render() { + $releases = $this->releases(); + $latestTag = $this->latestTag(); + return view('livewire.versions.index', [ 'version' => config('clusev.version'), - 'channel' => Setting::get('release_channel', config('clusev.channel')), + 'channel' => $this->channel(), 'repository' => config('clusev.repository'), 'license' => config('clusev.license'), 'build' => $this->build(), - 'groups' => $this->changelog(), + 'releases' => $releases, + 'latestTag' => $latestTag, ]); } } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..a84620b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,10 @@ namespace App\Providers; +use App\Models\Setting; +use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; +use Throwable; class AppServiceProvider extends ServiceProvider { @@ -19,6 +22,40 @@ class AppServiceProvider extends ServiceProvider */ public function boot(): void { - // + $this->applyDashboardDomain(); + } + + /** + * Keep Laravel's URL generation in sync with the dashboard-set panel domain + * WITHOUT ever touching .env. + * + * The panel domain lives in the DATABASE (Settings: `dashboard_domain`), not in + * .env — and the app never rewrites .env. So at runtime, if a domain is set, we + * override config('app.url') to `https://` so that signed URLs, absolute + * links, password-reset mails, etc. resolve against the operator-configured host. + * + * Guarded hard: during `artisan migrate`, fresh installs, or any moment before the + * `settings` table exists (or the DB is unreachable), this must be a silent no-op so + * console commands and migrations never crash. Hence the Schema::hasTable() probe + * plus a defensive try/catch around the DB read. + */ + private function applyDashboardDomain(): void + { + try { + if (! Schema::hasTable('settings')) { + return; + } + + $domain = Setting::get('dashboard_domain'); + + if ($domain === null || $domain === '') { + return; + } + + config(['app.url' => 'https://'.$domain]); + } catch (Throwable) { + // No DB / table yet (install, migrate, CI). Leave config('app.url') as-is — + // never fail boot over a settings lookup. + } } } diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index 7fab980..d47e04d 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -18,6 +18,12 @@ use App\Models\Setting; * container. Writing the file is the most the app can do; picking it up is an infra * step ('caddy reload --config /config/clusev.caddy' inside the caddy container, or a * stack recreate). renderCaddyConfig()/writeCaddyConfig() never touch the live proxy. + * + * .env IS NEVER WRITTEN. This service only persists Settings (DB) and writes a + * standalone Caddy site block to storage/app/caddy/clusev.caddy. It does not read, + * edit, or rewrite the application's .env in any way — the panel domain is a database + * value, so a change to it never mutates .env. See whyNotEnv() for the operator-facing + * explanation surfaced in the dashboard. */ class DeploymentService { @@ -131,4 +137,16 @@ class DeploymentService { return 'caddy reload --config /config/'.self::CONFIG_FILENAME; } + + /** + * German operator explanation: the panel domain lives in the DATABASE (Settings), + * NOT in .env — and the app never overwrites .env. Surfaced in the dashboard to + * clear up the worry that every domain change rewrites .env. Changes take effect + * after a Caddy reload (the proxy runs in its own container). + */ + public function whyNotEnv(): string + { + return 'Die Domain liegt in der Datenbank, nicht in der .env — die .env wird nie ' + .'überschrieben. Änderungen greifen nach einem Caddy-Reload (`caddy reload`).'; + } } diff --git a/app/Services/FleetService.php b/app/Services/FleetService.php index ea92274..fa0d91f 100644 --- a/app/Services/FleetService.php +++ b/app/Services/FleetService.php @@ -27,17 +27,18 @@ class FleetService public function __construct(private readonly CredentialVault $vault) {} - private function client(Server $server): SshClient + private function client(Server $server, int $timeout = 12): SshClient { - return (new SshClient($this->vault, timeout: 12))->connect($server); + return (new SshClient($this->vault, timeout: $timeout))->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 `. + * fed to `sudo -S` over stdin — the PLAINTEXT password never appears in any + * argv; only its base64 is transiently in the remote `sh -c` argv (reversible + * only by a co-located local user during the sub-second exec). Key/other auth + * falls back to passwordless `sudo -n`. Prefix this string, then `priv `. */ private function sudoFn(Server $server): string { @@ -61,10 +62,10 @@ class FleetService * * @return array{ok: bool, output: string} */ - public function runPrivileged(Server $server, string $command): array + public function runPrivileged(Server $server, string $command, int $timeout = 60): array { $b64 = base64_encode($command); - $ssh = $this->client($server); + $ssh = $this->client($server, $timeout); try { [$out, $code] = $ssh->run( @@ -83,10 +84,10 @@ class FleetService * * @return array{ok: bool, output: string} */ - public function runPlain(Server $server, string $command): array + public function runPlain(Server $server, string $command, int $timeout = 60): array { $b64 = base64_encode($command); - $ssh = $this->client($server); + $ssh = $this->client($server, $timeout); try { [$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s '.$b64.' | base64 -d)" 2>&1'); diff --git a/app/Services/HardeningService.php b/app/Services/HardeningService.php index 0f4624b..cdff944 100644 --- a/app/Services/HardeningService.php +++ b/app/Services/HardeningService.php @@ -126,7 +126,8 @@ class HardeningService { $preview = $this->preview($server, 'fail2ban'); $cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y fail2ban && systemctl enable --now fail2ban'; - $res = $this->fleet->runPrivileged($server, $cmd); + // apt download+install can take a minute — long timeout (12s default kills it). + $res = $this->fleet->runPrivileged($server, $cmd, 600); return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; } @@ -138,7 +139,8 @@ class HardeningService $cmd = 'DEBIAN_FRONTEND=noninteractive apt-get install -y unattended-upgrades' .' && systemctl enable --now unattended-upgrades' .' && dpkg-reconfigure -f noninteractive unattended-upgrades'; - $res = $this->fleet->runPrivileged($server, $cmd); + // apt download+install can take a minute — long timeout (12s default kills it). + $res = $this->fleet->runPrivileged($server, $cmd, 600); return ['ok' => $res['ok'], 'output' => $res['output'], 'preview' => $preview]; } diff --git a/config/clusev.php b/config/clusev.php index 31c36d3..b53750a 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -1,11 +1,12 @@ '0.1.0-dev', + 'version' => '0.1.0', - 'channel' => 'dev', + // Default user channel. Only 'stable' and 'beta' are ever offered to users. + 'channel' => 'stable', // Real project home (self-hosted Gitea) + license. Open-core, AGPL. 'repository' => 'https://git.bave.dev/boban/clusev', diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index 6de03a6..aa77e47 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -26,6 +26,16 @@ {{-- Domain & TLS --}} + {{-- DB-not-.env clarification: the panel domain is a database value; .env is never rewritten. --}} +
+ +
+

Die Domain wird in der Datenbank gespeichert, nicht in der .env.

+

{{ $whyNotEnv }}

+

Die .env wird vom Panel nie überschrieben. Laravel übernimmt die neue Adresse sofort (Laufzeit-Override von app.url); die Auslieferung greift nach dem Caddy-Reload unten.

+
+
+ {{-- Current TLS state --}} @if ($hasTls)
diff --git a/resources/views/livewire/versions/index.blade.php b/resources/views/livewire/versions/index.blade.php index 3724a7c..75ed950 100644 --- a/resources/views/livewire/versions/index.blade.php +++ b/resources/views/livewire/versions/index.blade.php @@ -1,8 +1,18 @@ @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); + // Map a Keep-a-Changelog subsection to a badge tone. + $tone = fn (string $g): string => [ + 'Hinzugefügt' => 'accent', + 'Geändert' => 'cyan', + 'Behoben' => 'neutral', + 'Sicherheit' => 'accent', + 'Entfernt' => 'neutral', + 'Veraltet' => 'neutral', + ][$g] ?? 'neutral'; + $repoHost = parse_url($repository, PHP_URL_HOST); $repoPath = trim((string) parse_url($repository, PHP_URL_PATH), '/'); + + $isUpdate = $updateState === 'update'; @endphp
@@ -19,15 +29,34 @@
- {{-- Honest status banner — no fake "update available" --}} -
- + {{-- Honest status banner — installed version vs. newest release tag in the channel --}} +
$isUpdate, + 'border-line' => ! $isUpdate, + ])> + $isUpdate, + 'border-accent/25 bg-accent/10 text-accent-text' => ! $isUpdate, + ])> + +

Clusev v{{ $version }} - Entwicklung + Kanal: {{ $channel }} + @if ($latestTag) + neuestes Release v{{ $latestTag }} + @endif +

+

+ @if ($updateStatus) + {{ $updateStatus }} + @else + Installierte Version. „Nach Updates suchen" vergleicht sie ehrlich mit dem neuesten getaggten Release im Kanal. Aktualisierung läuft über den Deploy (Image ziehen + migrieren). + @endif

-

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

@if ($lastChecked) geprüft {{ $lastChecked }} @@ -35,25 +64,47 @@
- {{-- Changelog timeline --}} - + {{-- Release history BY VERSION --}} +
- @forelse ($groups as $g) + @forelse ($releases as $rel)
$loop->first, 'bg-ink-4' => ! $loop->first])> @unless ($loop->last)@endunless
-

{{ $g['date'] }}

-
    - @foreach ($g['items'] as $it) -
  • - {{ $label($it['type']) }} - {{ $it['text'] }} -
  • + {{-- Version + date header --}} +
    + @if ($rel['unreleased']) + Unreleased + in Arbeit + @else + v{{ $rel['version'] }} + @if ($rel['date']) + {{ $rel['date'] }} + @endif + @endif +
    + + {{-- Grouped changes --}} +
    + @foreach ($rel['groups'] as $group => $items) +
    +
    + {{ $group }} +
    +
      + @foreach ($items as $text) +
    • + + {{ $text }} +
    • + @endforeach +
    +
    @endforeach -
+
@empty @@ -74,6 +125,7 @@
Build
{{ $build['sha'] ?? '—' }}
Branch
{{ $build['branch'] }}
Kanal
{{ $channel }}
+
Neuestes Release
{{ $latestTag ? 'v'.$latestTag : '—' }}
Lizenz
{{ $license }}
@@ -81,7 +133,7 @@
-

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

+

Updates kommen über getaggte Releases im 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
diff --git a/rules.md b/rules.md index f8811af..9bf2fa1 100644 --- a/rules.md +++ b/rules.md @@ -24,6 +24,7 @@ - [ ] **R12** — every touched page loads in a **browser** at **HTTP 200** with **zero console errors** (the **loaded** state of `wire:init` pages, not just the skeleton). - [ ] **R13** — route **paths + names are English** (`/settings`, not `/einstellungen`); German only in the visible label. - [ ] **R14** — fonts are **self-hosted locally** (`resources/fonts/*.woff2` bundled by Vite + `@font-face` in `app.css`) — **no Google Fonts / CDN `` or `@import`**. +- [ ] **R15** — **Codex reviewed the change** (`/codex:review`) and reported **no errors and no security issues** — a task is NOT done until Codex passes. - [ ] **Secrets** — `git status` shows **no** `.env*`, token, key, or home-dir dotfile staged. --- @@ -426,6 +427,31 @@ app** — `.woff2` files in `public/fonts/`, declared via `@font-face` in `resou --- +## R15 — Codex must review every change before it is "done" + +**Why.** A second, independent reviewer catches what the author misses — logic bugs, regressions, +and especially **security holes** in a control plane that holds the keys to a whole fleet. Self-review +is not enough; an external pass is mandatory before any task is considered complete. + +**Rule.** After implementing and self-verifying (R12 etc.), run **`/codex:review`** over the change. +Codex must report **no errors and no security vulnerabilities**. If it flags anything, fix it and +re-run until Codex is clean. Only then is the task done. Reviewing for **security** specifically — +SSH/command injection, credential handling, authz, lock-out safety, CSP — is part of every pass. + +✅ **Correct** +```text +implement → self-check (lint + R12 browser) → /codex:review → 0 findings → done → commit + ↑__________________│ (loop until clean) +``` + +❌ **Forbidden** +```text +"tests pass, shipping it" without an independent Codex review. +Marking a task done while Codex still reports an error or a security issue. +``` + +--- + ## Appendix — Secret hygiene The project lives in `/home/nexxo/clusev` (its own git repo). The Gitea push token lives in