From c34ced0c0e48cd25dc3f5a0d1956c31c96e2f894 Mon Sep 17 00:00:00 2001 From: boban Date: Sat, 13 Jun 2026 21:17:26 +0200 Subject: [PATCH] fix(tls/ui): fully automatic TLS (status-only) + uniform hardening buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per operator feedback ("das soll automatisch sein, die Caddy-Config muss man nicht sehen, nur 'SSL steht'"): - TLS is fully automatic via Caddy (auto-issue + auto-renew + HTTP→HTTPS). The System page now shows only the STATUS (domain + access URL) — the generated Caddy config and the manual `caddy reload` command are gone, and the domain/email edit form is removed. - The panel domain is an INSTALL-TIME value (APP_DOMAIN → config('clusev.domain')), so it stays consistent with APP_URL, the Reverb (wss) endpoint and cookie security — none of which can be re-derived at runtime (Caddy's site address + the browser's Reverb host are fixed at install/build, and .env is never rewritten). DeploymentService is now read-only. - Removed AppServiceProvider's runtime app.url override (it let a stale DB value shadow the install-time URL). Migration drops the inert empty dashboard_domain/dashboard_email rows (a real configured value is preserved as history; re-apply via install.sh — see CHANGELOG). - Hardening checklist: all toggle buttons share one style (secondary/bordered) instead of varying with state (R10). Bump 0.2.0 -> 0.2.1 + CHANGELOG. Pint clean; Codex reviewed (the remaining note is a deliberate install-time-domain tradeoff — a runtime/DB domain cannot be served by the templated Caddy without a false "TLS active" status); R12 — system/server-show/dashboard HTTP 200, 0 console errors, raw config + edit form gone, buttons uniform. Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 3 +- CHANGELOG.md | 24 +++- app/Livewire/System/Index.php | 54 +------ app/Providers/AppServiceProvider.php | 44 +----- app/Services/DeploymentService.php | 134 ++---------------- config/clusev.php | 6 +- ..._020000_drop_dashboard_domain_settings.php | 33 +++++ docker/caddy/Caddyfile | 7 +- .../views/livewire/servers/show.blade.php | 4 +- .../views/livewire/system/index.blade.php | 72 ++++------ 10 files changed, 122 insertions(+), 259 deletions(-) create mode 100644 database/migrations/2026_06_13_020000_drop_dashboard_domain_settings.php diff --git a/.env.example b/.env.example index ffde51e..28dffd8 100644 --- a/.env.example +++ b/.env.example @@ -75,7 +75,8 @@ IMAGE_TAG=latest # ── Reverse proxy / TLS (prod — written by install.sh) ─────────────── # Single knob: APP_DOMAIN empty -> Caddy serves plain HTTP on APP_PORT (bare-IP). -# APP_DOMAIN set -> Caddy gets a Let's Encrypt cert + forces HTTPS. +# APP_DOMAIN set -> Caddy auto-issues a Let's Encrypt cert + forces HTTPS. +# Changing the panel domain later = re-run install.sh with the new domain. APP_DOMAIN= APP_SCHEME=http ACME_EMAIL= diff --git a/CHANGELOG.md b/CHANGELOG.md index adefa71..48d7e5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,27 @@ getaggte Releases (Kanal `stable`, optional `beta`) — niemals Entwicklungs-Bui _Keine offenen Änderungen — der nächste Stand wird hier gesammelt und als `vX.Y.Z` getaggt._ +## [0.2.1] - 2026-06-13 + +Verfeinerungen an 0.2.0: vollautomatisches TLS ohne sichtbare Konfiguration und einheitliche Buttons. + +### Geändert +- **TLS ist vollautomatisch.** Caddy stellt das Zertifikat aus, erneuert es und leitet + HTTP→HTTPS um — ohne manuelle Schritte. Die System-Seite zeigt nur noch den **Status** + (Domain, Zugriffsadresse) statt der erzeugten Caddy-Konfiguration und des + `caddy reload`-Befehls. Die Panel-Domain ist eine **Installations-Entscheidung** + (`APP_DOMAIN`) und bleibt damit konsistent mit URL, WebSocket (wss) und Cookie-Sicherheit; + zum Ändern `install.sh` mit der neuen Domain erneut ausführen. +- Härtungs-Checkliste: alle Aktions-Buttons im selben Stil (einheitlich `secondary`, + mit Rahmen) statt zustandsabhängig mal mit, mal ohne Rahmen. + +### Entfernt +- Laufzeit-Override der App-URL aus der Datenbank und das dashboard-seitige Bearbeiten + von Domain/E-Mail (konnte URL/WebSocket/Cookies nicht konsistent zur Laufzeit umstellen). + Eine Migration entfernt die leeren Alt-Einträge; ein real konfigurierter Wert bleibt als + Verlauf erhalten. **Upgrade-Hinweis:** eine zuvor im Dashboard gesetzte Domain bitte einmalig + über `install.sh` setzen. + ## [0.2.0] - 2026-06-13 Mehr-Betriebssystem-Verwaltung, echte Firewall- und fail2ban-Steuerung aus dem Dashboard sowie eine Tastatur-/Befehls-Bedienung. @@ -114,7 +135,8 @@ Erste geschnittene Version — die komplette v1-Basis inklusive Server- und Pane - 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.2.0...HEAD +[Unreleased]: https://git.bave.dev/boban/clusev/compare/v0.2.1...HEAD +[0.2.1]: https://git.bave.dev/boban/clusev/compare/v0.2.0...v0.2.1 [0.2.0]: https://git.bave.dev/boban/clusev/compare/v0.1.1...v0.2.0 [0.1.1]: https://git.bave.dev/boban/clusev/compare/v0.1.0...v0.1.1 [0.1.0]: https://git.bave.dev/boban/clusev/releases/tag/v0.1.0 diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index 8c6fb38..f300e32 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -2,10 +2,8 @@ namespace App\Livewire\System; -use App\Models\AuditEvent; use App\Models\Setting; use App\Services\DeploymentService; -use Illuminate\Support\Facades\Auth; use Livewire\Attributes\Layout; use Livewire\Attributes\On; use Livewire\Attributes\Title; @@ -14,10 +12,10 @@ use Livewire\Component; /** * System settings — dashboard-configurable Domain + TLS and the release channel. * - * Domain & TLS: persists the panel domain + Let's Encrypt admin email (Settings), - * then renders the matching Caddy site block via DeploymentService for the operator - * to review. The app NEVER reloads Caddy itself (separate container) — it only - * stages the config and shows the reload command. + * Domain & TLS: READ-ONLY status. The panel domain is an install-time decision + * (APP_DOMAIN) so it stays consistent with URL/WebSocket/cookie security; Caddy then + * issues, renews and HTTP→HTTPS-redirects fully automatically. Nothing to apply or + * reload here — to change the domain, re-run install.sh. * * Release-Kanal: a stable|beta choice persisted as a Setting (default from * config('clusev.channel')), audited on change. @@ -32,47 +30,19 @@ class Index extends Component 'beta' => 'Vorabversionen kommender Releases zum Testen neuer Funktionen. Kann instabil sein. Entwicklungs-Builds werden Nutzern nie angeboten.', ]; + /** Install-time panel domain (read-only display). */ public string $domain = ''; - public string $email = ''; - public string $channel = 'stable'; public function mount(DeploymentService $deployment): void { $this->domain = (string) ($deployment->domain() ?? ''); - $this->email = (string) ($deployment->email() ?? ''); $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. */ - public function saveDomain(DeploymentService $deployment): void - { - $data = $this->validate([ - 'domain' => ['nullable', 'string', 'max:253', 'regex:/^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i'], - 'email' => ['nullable', 'email', 'max:255', 'required_with:domain'], - ], [ - 'domain.regex' => 'Bitte eine gültige Domain angeben (z. B. panel.example.com).', - 'email.required_with' => 'Bei gesetzter Domain ist eine Admin-E-Mail für Let\'s Encrypt Pflicht.', - ]); - - $deployment->setDomain($data['domain'] ?? null); - $deployment->setEmail($data['email'] ?? null); - - $this->domain = (string) ($deployment->domain() ?? ''); - $this->email = (string) ($deployment->email() ?? ''); - - $deployment->writeCaddyConfig(); - - $this->audit('system.settings_updated', $this->domain !== '' ? $this->domain : 'bare-ip'); - - $this->dispatch('notify', message: $this->domain !== '' - ? 'Domain gespeichert. Caddy-Konfiguration neu erzeugt.' - : 'Bare-IP-Modus gespeichert. Caddy-Konfiguration neu erzeugt.'); - } - /** Release channel changes are audited (confirmation via the shared modal). */ public function confirmChannel(string $channel): void { @@ -108,23 +78,11 @@ class Index extends Component $this->channel = $channel; } - private function audit(string $action, string $target): void - { - AuditEvent::create([ - 'user_id' => Auth::id(), - 'actor' => Auth::user()?->name ?? 'system', - 'action' => $action, - 'target' => $target, - 'ip' => request()->ip(), - ]); - } - public function render(DeploymentService $deployment) { return view('livewire.system.index', [ 'hasTls' => $this->domain !== '', - 'caddyConfig' => $deployment->renderCaddyConfig(), - 'reloadHint' => $deployment->reloadHint(), + 'panelUrl' => $deployment->panelUrl(), 'channels' => self::CHANNELS, ]); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a84620b..8120694 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,10 +2,7 @@ namespace App\Providers; -use App\Models\Setting; -use Illuminate\Support\Facades\Schema; use Illuminate\Support\ServiceProvider; -use Throwable; class AppServiceProvider extends ServiceProvider { @@ -19,43 +16,14 @@ class AppServiceProvider extends ServiceProvider /** * Bootstrap any application services. + * + * Note: the panel domain / app URL is an INSTALL-TIME value (APP_URL in .env, + * derived from APP_DOMAIN by install.sh), kept consistent with the Reverb endpoint, + * cookie security and Caddy's auto-TLS. It is deliberately NOT overridden at runtime + * from the database, so URL generation always matches how the panel is actually served. */ 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 e6f44c4..d4435a0 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -2,140 +2,34 @@ namespace App\Services; -use App\Models\Setting; - /** - * Renders the Caddy reverse-proxy site config for the dashboard's own domain. - * - * Clusev ships behind a Caddy container (see docker-compose.prod.yml). In prod the - * proxy address + ACME email are driven by .env (SITE_ADDRESS / ACME_EMAIL) and the - * baked docker/caddy/Caddyfile is mounted read-only. This service lets the operator - * configure the panel domain from the dashboard: it persists domain + email as - * Settings and renders the matching Caddy site block (auto Let's Encrypt TLS), so the - * operator can review and drop it into Caddy's config. - * - * IMPORTANT: the app CANNOT restart or reload Caddy — Caddy runs in a separate - * 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. + * Read-only view of the panel's domain + TLS state. The panel domain is an + * INSTALL-TIME decision (APP_DOMAIN → config('clusev.domain')) because it must stay + * consistent with APP_URL, the Reverb (wss) endpoint, secure-cookie + HSTS settings, + * and Caddy's auto-TLS — none of which can be safely re-derived at runtime. TLS itself + * is fully automatic (Caddy issues + renews + redirects HTTP→HTTPS); the dashboard only + * SHOWS the status. To change the domain, re-run install.sh with the new domain. */ class DeploymentService { - /** Where writeCaddyConfig() drops the rendered block (app-writable, host-mountable). */ - public const CONFIG_FILENAME = 'clusev.caddy'; - public function domain(): ?string { - return Setting::get('dashboard_domain'); + $domain = config('clusev.domain'); + + return ($domain !== null && $domain !== '') ? strtolower(trim((string) $domain)) : null; } - public function email(): ?string - { - return Setting::get('dashboard_email'); - } - - public function setDomain(?string $domain): void - { - Setting::put('dashboard_domain', $domain !== null && $domain !== '' ? strtolower(trim($domain)) : null); - } - - public function setEmail(?string $email): void - { - Setting::put('dashboard_email', $email !== null && $email !== '' ? trim($email) : null); - } - - /** True once a domain is configured — i.e. TLS via Let's Encrypt is in effect. */ + /** True once a domain is configured — i.e. automatic TLS is in effect. */ public function hasTls(): bool { - $domain = $this->domain(); - - return $domain !== null && $domain !== ''; + return $this->domain() !== null; } - /** - * Render the Caddy site block for the configured domain, mirroring the shape of - * docker/caddy/Caddyfile: a global `email` block + a site address that triggers - * automatic Let's Encrypt TLS, with the same Reverb (/app/*, /apps/*) and app - * upstreams and the same conservative hardening header set. - * - * With a domain set: `https://` → auto-TLS on :443, HTTP auto-redirect. - * Without a domain: `:80` → plain HTTP on the bare IP (no cert issued). - */ - public function renderCaddyConfig(): string + /** The HTTPS URL the panel is reachable at once a domain is configured. */ + public function panelUrl(): ?string { $domain = $this->domain(); - $email = $this->email(); - $siteAddress = $domain ? 'https://'.$domain : ':80'; - // Caddy needs a non-empty ACME email even in bare-IP mode, where no cert is - // issued so the value is simply unused — mirror the compose default. - $acmeEmail = $email ?: 'admin@localhost'; - - return << -> auto Let's Encrypt, :443, HTTP auto-redirect - # :80 -> plain HTTP on the bare IP, no cert - - { - \temail {$acmeEmail} - } - - {$siteAddress} { - \t@reverb path /app/* /apps/* - \treverse_proxy @reverb reverb:8080 - - \treverse_proxy app:80 - - \tencode zstd gzip - - \theader { - \t\tX-Content-Type-Options nosniff - \t\tX-Frame-Options DENY - \t\tReferrer-Policy strict-origin-when-cross-origin - \t\t-Server - \t} - } - CADDY; - } - - /** - * Write the rendered site config into the app's storage dir. This is a staging - * file the operator can mount/copy into Caddy's /config — it does NOT reload the - * live proxy (separate container). Returns the absolute path written. - */ - public function writeCaddyConfig(): string - { - $dir = storage_path('app/caddy'); - - if (! is_dir($dir)) { - @mkdir($dir, 0775, true); - } - - $path = $dir.'/'.self::CONFIG_FILENAME; - file_put_contents($path, $this->renderCaddyConfig().PHP_EOL); - - return $path; - } - - /** Absolute path writeCaddyConfig() targets (no write performed). */ - public function configPath(): string - { - return storage_path('app/caddy/'.self::CONFIG_FILENAME); - } - - /** - * Operator note: the app cannot reload Caddy. After writing, reload is an infra - * step inside the caddy container. - */ - public function reloadHint(): string - { - return 'caddy reload --config /config/'.self::CONFIG_FILENAME; + return $domain !== null ? 'https://'.$domain : null; } } diff --git a/config/clusev.php b/config/clusev.php index 9c8c0e8..e2d5b0c 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -3,11 +3,15 @@ return [ // 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.2.0', + 'version' => '0.2.1', // Default user channel. Only 'stable' and 'beta' are ever offered to users. 'channel' => 'stable', + // Install-time panel domain (APP_DOMAIN). The dashboard Setting overrides it; this + // is the fallback so a domain set during install still gets automatic TLS. + 'domain' => env('APP_DOMAIN'), + // Real project home (self-hosted Gitea) + license. Open-core, AGPL. 'repository' => 'https://git.bave.dev/boban/clusev', diff --git a/database/migrations/2026_06_13_020000_drop_dashboard_domain_settings.php b/database/migrations/2026_06_13_020000_drop_dashboard_domain_settings.php new file mode 100644 index 0000000..df352d3 --- /dev/null +++ b/database/migrations/2026_06_13_020000_drop_dashboard_domain_settings.php @@ -0,0 +1,33 @@ +whereIn('key', ['dashboard_domain', 'dashboard_email']) + ->where(function ($q) { + $q->whereNull('value')->orWhere('value', ''); + }) + ->delete(); + } + + public function down(): void + { + // No-op: these settings are obsolete and intentionally not recreated. + } +}; diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index a80e53d..ffcbaaf 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -3,8 +3,11 @@ # SITE_ADDRESS=:80 -> plain HTTP on the bare IP, no cert # SITE_ADDRESS=https://clusev.host.tld -> auto Let's Encrypt, :443, auto-redirect # -# Reverb (Pusher protocol) rides the same address over /app/* (WS) and /apps/* -# (event API), proxied to reverb:8080 — no extra exposed port, no mixed content. +# Fully automatic: Caddy issues + renews the certificate and redirects HTTP->HTTPS with +# no script, cron or reload. The domain is the single install knob (APP_DOMAIN); to +# change it later, re-run install.sh with the new domain. Reverb (Pusher protocol) rides +# the same address over /app/* (WS) and /apps/* (event API), proxied to reverb:8080 — +# no extra exposed port, no mixed content. { # ACME_EMAIL is never empty at runtime: compose defaults it to admin@localhost diff --git a/resources/views/livewire/servers/show.blade.php b/resources/views/livewire/servers/show.blade.php index f8bf10c..4cd7980 100644 --- a/resources/views/livewire/servers/show.blade.php +++ b/resources/views/livewire/servers/show.blade.php @@ -212,8 +212,8 @@ @endif - {{-- Toggle: secondary to make secure, quiet ghost to loosen. --}} - {{ $check['featureOn'] ? 'Deaktivieren' : 'Aktivieren' }} diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index 5573d84..311a7c9 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -1,9 +1,3 @@ -@php - $field = 'h-9 w-full rounded-md border border-line bg-inset px-3 font-mono text-sm text-ink placeholder:text-ink-4 focus:border-accent/40 focus:outline-none'; - $label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3'; - $err = 'mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline'; -@endphp -
{{-- Header --}}
@@ -31,8 +25,10 @@
-

HTTPS via Let's Encrypt (Caddy)

-

Caddy stellt automatisch ein Zertifikat für {{ $domain }} aus und erneuert es.

+

TLS aktiv — Zertifikat automatisch über Let's Encrypt

+

Erreichbar unter + {{ $panelUrl }} · + wird automatisch ausgestellt und erneuert. Voraussetzung: die Domain zeigt per DNS auf diesen Server.

@else @@ -40,7 +36,7 @@

Bare-IP-Modus: Klartext-HTTP

-

Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Für den Produktivbetrieb eine Domain setzen.

+

Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen — TLS wird dann automatisch eingerichtet, ohne weitere Schritte.

@endif @@ -59,45 +55,29 @@
-
-
-
- - - @error('domain')

{{ $message }}

@enderror -

Leer lassen für Zugriff per IP über HTTP.

-
-
- - - @error('email')

{{ $message }}

@enderror -

Für Zertifikatshinweise von Let's Encrypt.

-
+ {{-- Read-only: the panel domain is an install-time decision (kept consistent + with URL/WebSocket/cookie security); TLS provisions itself automatically. --}} +
+
+
Domain
+
{{ $domain !== '' ? $domain : '— (Zugriff per IP)' }}
-
- - - Speichern - +
+
Zugriffsadresse
+
+ @if ($panelUrl) + {{ $panelUrl }} + @else + HTTP (Bare-IP) + @endif +
- - - {{-- Generated Caddy site config --}} -
-
- -

Erzeugte Caddy-Konfiguration

-
-
{{ $caddyConfig }}
-
- -
-

Caddy läuft als eigener Container und muss neu laden, um die Konfiguration zu übernehmen.

-

Die Datei wurde nach storage/app/caddy/clusev.caddy geschrieben. Im Caddy-Container ausführen:

-
{{ $reloadHint }}
-
-
-
+
+

+ Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und + Cookie-Sicherheit konsistent. Zum Ändern install.sh mit der + neuen Domain erneut ausführen — TLS richtet sich danach automatisch ein. +

{{-- Release-Kanal --}}