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
parent
a64bd39c6c
commit
0b2dffe0de
73
CHANGELOG.md
73
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 `<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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,26 +64,48 @@
|
|||
</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)
|
||||
{{-- 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">
|
||||
<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>
|
||||
<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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@empty
|
||||
<p class="font-mono text-[11px] text-ink-3">Kein Änderungsprotokoll gefunden.</p>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
26
rules.md
26
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 `<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
|
||||
|
|
|
|||
Loading…
Reference in New Issue