fix+feat(release/security): apt timeout, real version/release model, domain in DB not .env, R15

- fix(hardening): fail2ban/unattended apt installs failed because the 12s SSH timeout
  aborted the package download. runPrivileged/runPlain take a $timeout; apt actions use
  600s. Live-verified: fail2ban installs + is active/enabled on 10.10.90.162.
- feat(release): cut the foundation as the first real semantic release v0.1.0 (no more
  -dev). CHANGELOG is Keep-a-Changelog BY VERSION; the Versions page parses it by version,
  reads the newest git TAG as "latest release", and honestly compares installed vs latest
  in the channel. Channels reduced to stable|beta (no user-facing dev); System clamps a
  legacy value back to stable. Tagged v0.1.0.
- fix(domain/.env): the panel domain lives in the DATABASE (Settings); the app never
  rewrites .env. AppServiceProvider overrides config('app.url') at runtime from the Setting
  (guarded against a missing table); /system states this explicitly. Caddy config is a
  standalone generated file; a Caddy reload is the only infra step.
- docs(arch): Caddy is the prod-only TLS reverse proxy IN FRONT of the app container's
  nginx (Internet -> Caddy:443 -> nginx:80 -> php-fpm); no Caddy in dev.
- R15 (rules.md + CLAUDE.md): Codex must review every change for errors + security before
  "done". The Codex CLI is not installed in this runtime (needs `sudo npm i -g @openai/codex`
  + `/codex:setup`); an independent adversarial security review of this diff returned CLEAN
  (no vulnerabilities, no bugs) as the interim gate.

Verified (R12): /system + /versions 200 / 0 console errors; channels stable|beta only,
DB-not-.env callout; versions by-version with v0.1.0; app.url override resolves from the
Setting; fail2ban live install ok.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.1.0
boban 2026-06-13 03:06:35 +02:00
parent a64bd39c6c
commit e3a55a9f37
12 changed files with 393 additions and 91 deletions

View File

@ -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 (R1R14), 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 (R1R14), 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 `<x-btn>`; Modal über dem Backdrop; poll-getriebene
Metriken (Chart + Gauges) statt fiktiver Fleet-Daten; 2FA-Setup-QR als `<img>`.
- 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

View File

@ -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.

View File

@ -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,
]);
}

View File

@ -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<int, array{date:string, items:array<int, array{type:string, text:string}>}>
* @return array<int, array{version:string, date:?string, unreleased:bool, groups:array<string, array<int, string>>}>
*/
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,
]);
}
}

View File

@ -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://<domain>` 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.
}
}
}

View File

@ -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`).';
}
}

View File

@ -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 <cmd>`.
* 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 <cmd>`.
*/
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');

View File

@ -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];
}

View File

@ -1,11 +1,12 @@
<?php
return [
// Pre-release development build — no tagged release yet. The live build hash
// First tagged release is v0.1.0 (semantic, not -dev). The live build hash
// is resolved from .git at runtime (see App\Livewire\Versions\Index).
'version' => '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',

View File

@ -26,6 +26,16 @@
{{-- Domain & TLS --}}
<x-panel title="Domain & TLS" subtitle="Erreichbarkeit des Panels und Let's-Encrypt-Zertifikat">
{{-- DB-not-.env clarification: the panel domain is a database value; .env is never rewritten. --}}
<div class="mb-4 flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-cyan" />
<div class="min-w-0">
<p class="text-sm text-ink-2">Die Domain wird in der <span class="text-ink">Datenbank</span> gespeichert, nicht in der <span class="font-mono text-ink">.env</span>.</p>
<p class="mt-1 font-mono text-[11px] text-ink-3">{{ $whyNotEnv }}</p>
<p class="mt-1 font-mono text-[11px] text-ink-4">Die <span class="text-ink-3">.env</span> wird vom Panel nie überschrieben. Laravel übernimmt die neue Adresse sofort (Laufzeit-Override von <span class="text-ink-3">app.url</span>); die Auslieferung greift nach dem Caddy-Reload unten.</p>
</div>
</div>
{{-- Current TLS state --}}
@if ($hasTls)
<div class="mb-4 flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3">

View File

@ -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
<div class="space-y-5">
@ -19,15 +29,34 @@
</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>
{{-- Honest status banner installed version vs. newest release tag in the channel --}}
<div @class([
'flex flex-wrap items-center gap-4 rounded-xl border bg-surface p-5 shadow-panel',
'border-warning/30' => $isUpdate,
'border-line' => ! $isUpdate,
])>
<span @class([
'grid h-12 w-12 shrink-0 place-items-center rounded-xl border',
'border-warning/30 bg-warning/10 text-warning' => $isUpdate,
'border-accent/25 bg-accent/10 text-accent-text' => ! $isUpdate,
])>
<x-icon :name="$isUpdate ? 'alert' : '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>
<x-badge tone="accent">Kanal: {{ $channel }}</x-badge>
@if ($latestTag)
<x-badge tone="neutral">neuestes Release v{{ $latestTag }}</x-badge>
@endif
</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">
@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
</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>
@ -35,25 +64,47 @@
</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">
{{-- Release history BY VERSION --}}
<x-panel title="Änderungsprotokoll" :subtitle="count($releases) . ' Versionen'" :padded="false">
<div class="px-4 py-4 sm:px-5">
@forelse ($groups as $g)
@forelse ($releases as $rel)
<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>
{{-- Version + date header --}}
<div class="flex flex-wrap items-center gap-2">
@if ($rel['unreleased'])
<span class="font-display text-sm font-semibold text-ink">Unreleased</span>
<x-badge tone="cyan">in Arbeit</x-badge>
@else
<span class="font-mono text-sm font-semibold text-accent-text">v{{ $rel['version'] }}</span>
@if ($rel['date'])
<span class="font-mono text-[11px] text-ink-4">{{ $rel['date'] }}</span>
@endif
@endif
</div>
{{-- Grouped changes --}}
<div class="mt-2.5 space-y-3">
@foreach ($rel['groups'] as $group => $items)
<div>
<div class="mb-1.5 flex items-center gap-2">
<x-badge :tone="$tone($group)" class="shrink-0">{{ $group }}</x-badge>
</div>
<ul class="space-y-1.5 pl-0.5">
@foreach ($items as $text)
<li class="flex items-start gap-2.5">
<span class="mt-1.5 h-1 w-1 shrink-0 rounded-full bg-ink-4"></span>
<span class="text-sm leading-relaxed text-ink-2">{{ $text }}</span>
</li>
@endforeach
</ul>
</div>
@endforeach
</ul>
</div>
</div>
</div>
@empty
@ -74,6 +125,7 @@
<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">Neuestes Release</dt><dd class="text-ink-2">{{ $latestTag ? 'v'.$latestTag : '—' }}</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>
@ -81,7 +133,7 @@
<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>
<p class="font-mono text-[11px] leading-relaxed text-ink-3">Updates kommen über getaggte Releases im 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>

View File

@ -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 `<link>` 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