From a44e29426a77dadff0972169812eb07170a32799 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 01:38:16 +0200 Subject: [PATCH] feat(domain): change the panel domain from the dashboard (v0.4.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The panel domain is editable on the System page again — safely this time. It is stored in the DB (Setting `panel_domain`, overriding install-time APP_DOMAIN) and APPLIED ON RESTART, never mid-session: a snapshot file (storage/framework/active-domain) is frozen at container start by the entrypoint (`clusev:snapshot-domain`), so saving a new domain only takes effect after `docker compose ... restart`. How it works - DeploymentService: pending (configuredDomain, DB) vs active (domain(), snapshot file); setDomain() persists; restartPending() drives the UI notice. Snapshot reads the DB DIRECTLY (cache-independent) and retries until the settings table is readable; if it never is, it freezes the env fallback so the active domain is always a FIXED value (never a live one that could shift after startup). - AppServiceProvider: derives app.url from the active domain at boot; pins server->Reverb publishing to the internal reverb:8080 (domain/cert-independent). - Caddy: on-demand TLS gated by /_caddy/ask (issues a cert only for the configured domain); HTTP always served for bare-IP recovery; /app,/apps forced to HTTPS for any hostname (plaintext only on a bare IP). - Reverb client endpoint is derived from the live request and rides the same front door (/app tunnel — Caddy in prod, nginx in dev), so realtime follows a domain change with no JS rebuild and no stale .env value. - System page: domain form + R5 confirm + "restart required" notice with the exact command; DE/EN strings (R16). Anti-lockout / security - session.secure + the HTTPS redirect follow the real request scheme; bare-IP HTTP is always a recovery path. trustProxies('*') only in production (dev can't be tricked into faking HTTPS via X-Forwarded-*). When a domain is active only that domain (HTTPS) and the literal server IP (HTTP) serve the panel; any other/stale host is refused (404), and IP-recovery redirects stay on the IP. Bump 0.3.0 -> 0.4.0; CHANGELOG. Follow-up tracked: make the public `metrics` broadcast channel private (wire broadcasting auth). Verified: Pint clean; npm build; Caddyfile validates; R12 all routes 200 + 0 console errors; Echo connects via the unified /app tunnel; domain set/clear + restart-gating + /_caddy/ask (200 active / 403 other) + host-enforcement matrix all confirmed in dev; Codex review iterated to no actionable in-scope findings; 14-agent adversarial lockout/security review (real trustProxies finding fixed). Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + CHANGELOG.md | 32 +++ app/Console/Commands/SnapshotDomain.php | 55 +++++ app/Http/Middleware/PanelScheme.php | 77 +++++++ app/Livewire/System/Index.php | 87 +++++++- app/Providers/AppServiceProvider.php | 33 ++- app/Services/DeploymentService.php | 202 ++++++++++++++++-- bootstrap/app.php | 21 +- config/clusev.php | 2 +- docker-compose.prod.yml | 4 +- docker/caddy/Caddyfile | 68 ++++-- docker/entrypoint.sh | 9 + docker/nginx/nginx.conf | 16 ++ lang/de/system.php | 20 +- lang/en/system.php | 20 +- resources/js/app.js | 17 +- resources/views/layouts/app.blade.php | 14 ++ .../views/livewire/system/index.blade.php | 64 ++++-- routes/web.php | 13 ++ 19 files changed, 671 insertions(+), 84 deletions(-) create mode 100644 app/Console/Commands/SnapshotDomain.php create mode 100644 app/Http/Middleware/PanelScheme.php diff --git a/.gitignore b/.gitignore index 669d4fd..70ad099 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ /public/hot /public/storage /storage/*.key +/storage/framework/active-domain /storage/pail /vendor diff --git a/CHANGELOG.md b/CHANGELOG.md index f0021d3..8c373ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,38 @@ 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.4.0] - 2026-06-14 + +Die Panel-Domain ist wieder aus dem Dashboard änderbar — diesmal sicher: gespeichert wird in der Datenbank, angewendet beim Neustart, das Zertifikat holt Caddy automatisch. + +### Hinzugefügt +- **Domain aus dem Dashboard ändern.** Auf der System-Seite lässt sich die Panel-Domain + eintragen oder leeren. Gespeichert wird als Einstellung (`panel_domain`, übersteuert die + Installations-Domain `APP_DOMAIN`); nach einem Stack-Neustart leiten sich URL, der + WebSocket-Endpunkt (Reverb), das TLS-Zertifikat und die Cookie-Sicherheit automatisch davon + ab. Kein Terminal-Editieren mehr — nur „speichern und neu starten". +- **Automatisches TLS per On-Demand (Caddy).** Caddy stellt das Zertifikat beim ersten + HTTPS-Zugriff aus und fragt vorher die App (`/_caddy/ask`), ob für genau diese Domain + ausgestellt werden darf — so kann keine fremde, auf die IP gezeigte Domain Zertifikate + erzwingen. Voraussetzung bleibt: die Domain zeigt per DNS auf den Server. + +### Geändert +- Der Reverb-Client (Browser) bezieht seinen Endpunkt jetzt zur Laufzeit aus der aktuellen + Anfrage und läuft über dieselbe Adresse wie das Panel (Proxy/Reverse-Tunnel `/app/*`, + `/apps/*` — Caddy in Produktion, nginx in Dev). So folgt Realtime jeder Domain-Änderung + ohne Neu-Bauen der Assets und ohne separat geöffneten Port. +- Server-seitiges Event-Publishing läuft intern direkt zum `reverb`-Dienst, ist damit + unabhängig von der öffentlichen Domain/dem Zertifikat. + +### Sicherheit +- **Kein Aussperren.** Sicherer-Cookie-Schalter und HTTPS-Redirect richten sich nach der + tatsächlichen Anfrage (hinter Caddy via `X-Forwarded-*`, das nur in Produktion vertraut + wird). Der Zugriff per **IP über HTTP bleibt immer als Rückfallweg erreichbar**, falls + eine neue Domain (DNS/Zertifikat) noch nicht bereit ist. +- **Nur die aktive Domain liefert das Panel aus.** Bei gesetzter Domain wird das Panel per + Hostname ausschließlich über genau diese Domain (HTTPS) bedient; eine alte/fremde, auf den + Server gezeigte Domain wird abgewiesen (404) — auch wenn Caddy ihr Zertifikat noch hält. + ## [0.3.0] - 2026-06-13 Zweisprachige Oberfläche: die gesamte UI gibt es jetzt auf Deutsch und Englisch, frei umschaltbar pro Konto. diff --git a/app/Console/Commands/SnapshotDomain.php b/app/Console/Commands/SnapshotDomain.php new file mode 100644 index 0000000..91a284d --- /dev/null +++ b/app/Console/Commands/SnapshotDomain.php @@ -0,0 +1,55 @@ +option('tries')); + + for ($i = 1; $i <= $tries; $i++) { + if ($deployment->snapshotActiveDomain()) { + $this->info('Active domain: '.($deployment->domain() ?? '(bare IP / HTTP)')); + + return self::SUCCESS; + } + + if ($i < $tries) { + sleep(1); + } + } + + // Settings never became readable in time. Keep any existing snapshot (last-known-good, + // frozen); only if none exists, freeze the install-time env fallback — so the active + // domain is always a FIXED snapshot and never falls back to a live value that could + // change once the DB/migrations catch up (which would break the restart boundary). + if ($deployment->hasSnapshot()) { + $this->warn('Settings not reachable; kept the existing active-domain snapshot.'); + } else { + $deployment->snapshotFallback(); + $this->warn('Settings not reachable; froze the install-time domain (dashboard override applies on the next ready restart).'); + } + + return self::SUCCESS; + } +} diff --git a/app/Http/Middleware/PanelScheme.php b/app/Http/Middleware/PanelScheme.php new file mode 100644 index 0000000..c287d86 --- /dev/null +++ b/app/Http/Middleware/PanelScheme.php @@ -0,0 +1,77 @@ + $request->isSecure()]); + + $domain = $this->deployment->domain(); + + // Bare-IP mode: no TLS, no host restriction. Keep app.url on the current request + // so URLs don't point at a domain that was removed from service. + if ($domain === null) { + config(['app.url' => $request->getSchemeAndHttpHost()]); + + return $next($request); + } + + // Internal endpoints must answer on any host/scheme (Caddy's on-demand TLS ask is + // a plain-HTTP call with an internal Host; the health check likewise). + if ($request->is('_caddy/*', 'up')) { + return $next($request); + } + + $host = strtolower($request->getHost()); + + // The literal server IP is the plaintext recovery path — always served over HTTP. + // Pin app.url to the IP request too, so login/onboarding redirects (absolute + // route() URLs) stay on the reachable IP instead of bouncing to the (possibly + // unreachable) domain that AppServiceProvider set as app.url. + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + config(['app.url' => $request->getSchemeAndHttpHost()]); + + return $next($request); + } + + // Only the active domain may serve the panel by hostname. + if ($host !== $domain) { + abort(404); + } + + // Force HTTPS for the active domain — on the default TLS port (443). Build the URL + // explicitly so a non-standard HTTP APP_PORT is never carried into the https:// URL + // (Caddy serves TLS only on 443; https://domain:8080 would be unreachable). + if (! $request->isSecure()) { + return redirect()->to('https://'.$domain.$request->getRequestUri(), 301); + } + + return $next($request); + } +} diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index c727b65..a9705a3 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -11,10 +11,11 @@ use Livewire\Component; /** * System settings — dashboard-configurable Domain + TLS and the release channel. * - * 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. + * Domain & TLS: the panel domain is EDITABLE here. Saving persists it (Setting + * `panel_domain`, overriding the install-time APP_DOMAIN) and asks the operator to + * restart the stack; on restart everything re-derives from it (app.url, the Reverb + * client endpoint, Caddy's on-demand cert, the secure-cookie flag). Nothing switches + * mid-session — that is what makes it safe. Bare-IP/HTTP access stays a recovery path. * * Release-Kanal: a stable|beta choice persisted as a Setting (default from * config('clusev.channel')), audited on change. @@ -25,6 +26,17 @@ class Index extends Component /** Valid user-facing release channels (descriptions are localized at render time). */ public const CHANNELS = ['stable', 'beta']; + /** Effective panel domain (DB override or install-time APP_DOMAIN); '' = bare IP. */ + public string $domain = ''; + + /** The domain form field (FQDN, or empty to clear the override). */ + public string $domainInput = ''; + + /** True after a save — shows the "restart the stack to apply" notice. */ + public bool $restartPending = false; + + public string $channel = 'stable'; + /** Channel key => localized description, built per-request for the view. */ private function channelDescriptions(): array { @@ -34,19 +46,75 @@ class Index extends Component ]; } - /** Install-time panel domain (read-only display). */ - public string $domain = ''; - - public string $channel = 'stable'; - public function mount(DeploymentService $deployment): void { + // Active = what the stack currently serves (snapshot); the form edits the PENDING + // (configured) domain. They differ when a saved change awaits a restart. $this->domain = (string) ($deployment->domain() ?? ''); + $this->domainInput = (string) ($deployment->configuredDomain() ?? ''); + $this->restartPending = $deployment->restartPending(); $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 = in_array($channel, self::CHANNELS, true) ? $channel : 'stable'; } + /** @return array> */ + private function domainRules(): array + { + // Empty clears the override (revert to install-time APP_DOMAIN / bare IP). + return ['domainInput' => ['nullable', 'string', 'max:253', 'regex:'.DeploymentService::DOMAIN_REGEX]]; + } + + /** Validate, then confirm the domain change via the shared modal (audited there). */ + public function confirmDomain(DeploymentService $deployment): void + { + $this->validate($this->domainRules(), [ + 'domainInput.regex' => __('system.domain_invalid'), + 'domainInput.max' => __('system.domain_invalid'), + ]); + + $new = strtolower(trim($this->domainInput)); + + if ($new === (string) ($deployment->configuredDomain() ?? '')) { + return; // no change vs the already-configured (pending) domain + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('system.change_domain_heading'), + 'body' => $new !== '' + ? __('system.change_domain_body', ['domain' => $new]) + : __('system.clear_domain_body'), + 'confirmLabel' => __('system.change_domain_confirm'), + 'danger' => true, + 'icon' => 'globe', + 'auditAction' => 'system.settings_updated', + 'auditTarget' => 'panel_domain='.($new !== '' ? $new : '(cleared)'), + 'event' => 'domainChanged', + 'params' => ['domain' => $new], + 'notify' => '', // defer: we surface the restart notice instead of a toast + ], + ); + } + + #[On('domainChanged')] + public function applyDomain(string $domain, DeploymentService $deployment): void + { + $domain = strtolower(trim($domain)); + + // Defensive re-validation (the value round-trips through the modal event). + if ($domain !== '' && ! preg_match(DeploymentService::DOMAIN_REGEX, $domain)) { + return; + } + + $pending = $deployment->setDomain($domain); + // The active (served) domain stays frozen until restart; only the pending changes. + $this->domainInput = (string) ($pending ?? ''); + $this->restartPending = $deployment->restartPending(); + $this->dispatch('notify', message: __('system.domain_saved_notify')); + } + /** Release channel changes are audited (confirmation via the shared modal). */ public function confirmChannel(string $channel): void { @@ -87,6 +155,7 @@ class Index extends Component return view('livewire.system.index', [ 'hasTls' => $this->domain !== '', 'panelUrl' => $deployment->panelUrl(), + 'isOverridden' => $deployment->domainIsOverridden(), 'channels' => $this->channelDescriptions(), ])->title(__('system.title')); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 8120694..28b9831 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,28 +2,41 @@ namespace App\Providers; +use App\Services\DeploymentService; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider { - /** - * Register any application services. - */ public function register(): void { // } /** - * Bootstrap any application services. + * Apply the effective panel domain (dashboard override in DB, else install-time + * APP_DOMAIN) to runtime config at boot. boot() runs every request, so a domain + * change takes effect on the next request after the operator restarts the stack — + * nothing is switched mid-session, which is what makes it safe. * - * 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. + * - Server -> Reverb publishing is pinned to the INTERNAL reverb service (both dev + * and prod compose name it `reverb`), so realtime keeps working right after a + * domain change and never depends on the new public cert. The browser endpoint is + * injected by the layout from DeploymentService::reverbClient(). + * - app.url -> https:// so absolute URL generation matches how the panel is + * served. With no domain (bare IP / dev) the env-derived app.url is left as is. */ - public function boot(): void + public function boot(DeploymentService $deployment): void { - // + config([ + 'broadcasting.connections.reverb.options.host' => 'reverb', + 'broadcasting.connections.reverb.options.port' => 8080, + 'broadcasting.connections.reverb.options.scheme' => 'http', + 'broadcasting.connections.reverb.options.useTLS' => false, + ]); + + $domain = $deployment->domain(); + if ($domain !== null) { + config(['app.url' => 'https://'.$domain]); + } } } diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index d4435a0..7981688 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -2,34 +2,212 @@ namespace App\Services; +use App\Models\Setting; +use Illuminate\Support\Facades\DB; +use Throwable; + /** - * 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. + * The panel's domain + TLS state, with a clear split between the PENDING domain + * (changeable from the dashboard) and the ACTIVE domain (what the running stack + * actually serves, frozen until a restart). + * + * - PENDING (configuredDomain): a dashboard override stored as Setting `panel_domain` + * wins over the install-time APP_DOMAIN. A panel_domain ROW — even empty — is an + * explicit choice (empty = bare IP); only the ABSENCE of the row falls back to + * APP_DOMAIN. + * - ACTIVE (domain): read from a snapshot file written ONCE at container start + * (entrypoint -> `clusev:snapshot-domain`). So saving a new domain does NOT change + * how the panel is served until the operator restarts the stack — nothing switches + * mid-session, which is what makes it safe. Falls back to the pending value when no + * snapshot exists (dev / first run / tests). + * + * Everything serving-related (app.url, Caddy's /_caddy/ask, the Reverb client endpoint, + * the HTTPS redirect/secure-cookie) keys off the ACTIVE domain; bare-IP/HTTP access + * always stays a recovery path. */ class DeploymentService { - public function domain(): ?string - { - $domain = config('clusev.domain'); + /** FQDN matcher (labels 1-63 chars, no scheme/port/path, TLD >= 2 alpha). */ + public const DOMAIN_REGEX = '/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i'; - return ($domain !== null && $domain !== '') ? strtolower(trim((string) $domain)) : null; + /** Snapshot of the active domain, written at container start (relative to storage/). */ + private const ACTIVE_FILE = 'framework/active-domain'; + + /** + * The PENDING domain: dashboard override (Setting `panel_domain`) if a row exists + * (empty row = explicit bare IP), otherwise the install-time APP_DOMAIN. This is + * what will become active after the next restart. null = bare IP / plain HTTP. + */ + public function configuredDomain(): ?string + { + try { + $db = Setting::get('panel_domain'); // null = no row; '' = explicit bare IP; 'x' = domain + } catch (Throwable) { + // Settings table not migrated yet (install/migrate) -> env fallback. + $db = null; + } + + $source = $db !== null ? $db : config('clusev.domain'); + + return ($source !== null && trim((string) $source) !== '') + ? strtolower(trim((string) $source)) + : null; } - /** True once a domain is configured — i.e. automatic TLS is in effect. */ + /** + * The ACTIVE domain the running stack serves — frozen at container start. Falls back + * to the pending value when no snapshot exists (dev / first run / tests). + */ + public function domain(): ?string + { + $path = storage_path(self::ACTIVE_FILE); + + if (is_file($path)) { + $value = trim((string) @file_get_contents($path)); + + return $value !== '' ? strtolower($value) : null; + } + + return $this->configuredDomain(); + } + + /** True once an active-domain snapshot file exists. */ + public function hasSnapshot(): bool + { + return is_file(storage_path(self::ACTIVE_FILE)); + } + + /** + * Snapshot the configured domain as the active one. Called (with retries) by the + * entrypoint at container start, so a domain change applies on restart and never + * mid-session. + * + * Reads the override DIRECTLY from the database (not the Redis-cached Setting::get), so + * it never freezes a stale env fallback when only the cache is down. Returns FALSE when + * the setting can't be read authoritatively yet — DB unreachable OR the settings table + * not migrated (fresh install) — so the caller retries and only then freezes the env + * fallback. This guarantees the active domain is always a FIXED snapshot, never a live + * value that could change when the DB/migrations catch up. Never throws. + */ + public function snapshotActiveDomain(): bool + { + try { + // null = no override row; '' = explicit bare IP; 'x' = domain. + $db = DB::table('settings')->where('key', 'panel_domain')->value('value'); + } catch (Throwable) { + return false; // DB down OR settings table not migrated yet — not authoritative + } + + $source = $db !== null ? $db : config('clusev.domain'); + + return $this->writeSnapshot($this->normalizeDomain($source)); + } + + /** + * Freeze the install-time domain (env) as the active one when the settings are + * unreachable at boot, so serving stays FIXED (the restart boundary holds) instead of + * falling back to a live value. A dashboard override (DB-only) then applies on the next + * restart where the database/migrations are ready. + */ + public function snapshotFallback(): bool + { + return $this->writeSnapshot($this->normalizeDomain(config('clusev.domain'))); + } + + /** Lowercased/trimmed domain, or null when empty (bare IP). */ + private function normalizeDomain(mixed $value): ?string + { + return ($value !== null && trim((string) $value) !== '') + ? strtolower(trim((string) $value)) + : null; + } + + private function writeSnapshot(?string $domain): bool + { + try { + $dir = storage_path('framework'); + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + + return @file_put_contents(storage_path(self::ACTIVE_FILE), (string) $domain) !== false; + } catch (Throwable) { + return false; + } + } + + /** A restart is needed when the pending domain differs from the active one. */ + public function restartPending(): bool + { + return $this->configuredDomain() !== $this->domain(); + } + + /** True when the panel domain is a dashboard override (a panel_domain row exists). */ + public function domainIsOverridden(): bool + { + try { + return Setting::get('panel_domain') !== null; + } catch (Throwable) { + return false; + } + } + + /** + * Persist a new panel domain. Pass null/'' to choose bare IP (an explicit override + * that also disables the install-time APP_DOMAIN). Returns the pending domain. + */ + public function setDomain(?string $domain): ?string + { + Setting::put('panel_domain', $domain !== null ? strtolower(trim($domain)) : ''); + + return $this->configuredDomain(); + } + + /** True once an ACTIVE domain is configured — i.e. automatic TLS is in effect. */ public function hasTls(): bool { return $this->domain() !== null; } - /** The HTTPS URL the panel is reachable at once a domain is configured. */ + /** The HTTPS URL the panel is reachable at for the ACTIVE domain. */ public function panelUrl(): ?string { $domain = $this->domain(); return $domain !== null ? 'https://'.$domain : null; } + + /** + * Browser-facing Reverb (Echo) endpoint. Derived from the CURRENT request, because the + * browser always reaches Reverb through the SAME front door it loaded the panel from: + * both Caddy (prod) and nginx (dev) tunnel /app/* + /apps/* to the internal reverb:8080. + * So the endpoint is always the live request's host+port+scheme — never a stale .env + * REVERB_* value (which would break realtime after a domain change/clear) and needing no + * JS rebuild. Falls back to config/reverb.php only when there is no request context. + * + * @return array{key: ?string, host: ?string, port: int, scheme: string} + */ + public function reverbClient(): array + { + $key = config('broadcasting.connections.reverb.key'); + $request = request(); + + if ($request !== null) { + return [ + 'key' => $key, + 'host' => $request->getHost(), + 'port' => $request->getPort(), + 'scheme' => $request->isSecure() ? 'https' : 'http', + ]; + } + + $opt = config('reverb.apps.apps.0.options', []); + + return [ + 'key' => $key, + 'host' => $opt['host'] ?? '127.0.0.1', + 'port' => (int) ($opt['port'] ?? 80), + 'scheme' => $opt['scheme'] ?? 'http', + ]; + } } diff --git a/bootstrap/app.php b/bootstrap/app.php index b1d0a33..242b6fa 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { - $middleware->web(append: [ - SetLocale::class, - SecurityHeaders::class, - ]); + // Trust Caddy's X-Forwarded-* ONLY in production, where the app has no host port + // (docker-compose.prod.yml exposes app:80 on the internal net only) so Caddy is + // the sole peer. In dev the app port is published on the host, so trusting forged + // proxy headers would let a LAN peer fake HTTPS (forcing a Secure cookie over + // plain HTTP and breaking the bare-IP recovery login). + if (($_SERVER['APP_ENV'] ?? getenv('APP_ENV') ?: 'local') === 'production') { + $middleware->trustProxies(at: '*'); + } + + // PanelScheme first (scheme/host enforcement before auth), so an HTTP request to + // the active domain is sent straight to HTTPS and a wrong host is refused before + // any auth redirect. + $middleware->web( + prepend: [PanelScheme::class], + append: [SetLocale::class, SecurityHeaders::class], + ); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/config/clusev.php b/config/clusev.php index dfd2ca2..1996d49 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -3,7 +3,7 @@ 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.3.0', + 'version' => '0.4.0', // Default user channel. Only 'stable' and 'beta' are ever offered to users. 'channel' => 'stable', diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 089e99a..e7285b2 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -60,8 +60,8 @@ services: - "443:443" - "443:443/udp" # HTTP/3 environment: - SITE_ADDRESS: "${SITE_ADDRESS:-:80}" - # never empty: bare-IP mode has no cert, so this address is unused + # Domain is dashboard-driven via on-demand TLS (Caddy asks app:/_caddy/ask), so + # no SITE_ADDRESS is needed. Only the ACME contact comes from .env. ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}" volumes: - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index ffcbaaf..a18b16b 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -1,21 +1,33 @@ -# Clusev reverse proxy — one template, both modes (driven by $SITE_ADDRESS). +# Clusev reverse proxy — the panel domain is changeable FROM THE DASHBOARD. # -# SITE_ADDRESS=:80 -> plain HTTP on the bare IP, no cert -# SITE_ADDRESS=https://clusev.host.tld -> auto Let's Encrypt, :443, auto-redirect +# TLS is issued ON DEMAND: on the first HTTPS handshake for a host, Caddy asks the app +# (/_caddy/ask) whether to issue a certificate, and the app approves ONLY the domain +# currently configured in the dashboard (DB) / .env. So the operator changes the domain +# in the UI, points DNS at this host, restarts the stack, and Caddy gets the cert for the +# new domain automatically — no Caddyfile edit, no SITE_ADDRESS. # -# 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. - +# HTTP is ALWAYS served (bare-IP access + the operator's recovery path); the app issues +# the HTTPS redirect for the configured domain (PanelScheme), and never for the raw IP. +# 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 - # in bare-IP mode, where no cert is issued so the address is simply unused. + # ACME contact for Let's Encrypt (set by install.sh; defaults to admin@localhost). email {$ACME_EMAIL} + + # Only mint a certificate for the host the app approves — prevents cert-issuance + # abuse from arbitrary domains pointed at this IP. + on_demand_tls { + ask http://app:80/_caddy/ask + } + + # Keep serving plain HTTP (still issue certs on demand): the APP decides the + # HTTP->HTTPS redirect — only for the configured domain, never for the raw IP — + # so bare-IP HTTP always stays reachable as the operator's recovery path. + auto_https disable_redirects } -{$SITE_ADDRESS} { +# Snippet: the shared proxy + hardening, reused by both the HTTP and HTTPS sites. +(clusev_proxy) { @reverb path /app/* /apps/* reverse_proxy @reverb reverb:8080 @@ -24,10 +36,40 @@ encode zstd gzip header { - # conservative hardening; HSTS only meaningful under HTTPS + # conservative hardening; HSTS is asserted by the app only over HTTPS X-Content-Type-Options nosniff X-Frame-Options DENY Referrer-Policy strict-origin-when-cross-origin -Server } } + +# HTTPS for any host the app approves (the configured panel domain); cert obtained +# on demand on the first handshake. +https:// { + tls { + on_demand + } + import clusev_proxy +} + +# Plain HTTP: always served — bare-IP access and the recovery path. Also carries the +# ACME HTTP-01 challenge. The app redirects regular requests to HTTPS for the configured +# domain (PanelScheme); the raw IP stays on HTTP. +http:// { + # Reverb (/app WS, /apps events) bypasses the app, so enforce transport here: on a + # HOSTNAME force the WS/event paths onto HTTPS, so broadcasts (incl. the public metrics + # channel) are never carried in cleartext and a stale/wrong host is refused by on-demand + # TLS. On a bare IP there is no TLS, so plaintext there is expected and left alone. + @reverb_cleartext { + path /app/* /apps/* + not header_regexp Host ^(\d{1,3}\.){3}\d{1,3}(:\d+)?$ + } + handle @reverb_cleartext { + redir https://{host}{uri} + } + + handle { + import clusev_proxy + } +} diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index df09d2f..ff015c2 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -18,4 +18,13 @@ if [ "$(id -u)" = "0" ]; then chown -R app:app storage bootstrap/cache 2>/dev/null || true fi +# Freeze the configured panel domain as the active one — ONLY in the web/app container +# (its CMD is supervisord). The queue/reverb workers share this entrypoint and, in dev, +# the same bind-mounted storage; snapshotting from a worker restart would apply a pending +# domain without an app restart, breaking the "applies on restart" boundary. Best-effort: +# a failure here must never block startup. +if [ "$1" = "supervisord" ]; then + php artisan clusev:snapshot-domain >/dev/null 2>&1 || true +fi + exec "$@" diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf index 0d6ed76..3648424 100644 --- a/docker/nginx/nginx.conf +++ b/docker/nginx/nginx.conf @@ -25,6 +25,22 @@ http { index index.php; charset utf-8; + # Reverb (Pusher protocol) over the SAME origin — mirrors Caddy in prod so the + # browser reaches Reverb through this front door (/app/* = WS, /apps/* = events). + # A resolver + variable defers DNS to request time, so nginx still starts if the + # reverb container is momentarily unresolvable. + location ~ ^/(app|apps)/ { + resolver 127.0.0.11 valid=10s; + set $reverb_upstream reverb:8080; + proxy_pass http://$reverb_upstream; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "Upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_read_timeout 600s; + } + location / { try_files $uri $uri/ /index.php?$query_string; } diff --git a/lang/de/system.php b/lang/de/system.php index d4d2f4d..ebd60e7 100644 --- a/lang/de/system.php +++ b/lang/de/system.php @@ -18,13 +18,25 @@ return [ 'tls_bare_title' => 'Bare-IP-Modus: Klartext-HTTP', 'tls_bare_body' => 'Ohne Domain läuft das Panel inkl. 2FA und Audit über unverschlüsseltes HTTP. Domain eintragen — TLS wird dann automatisch eingerichtet, ohne weitere Schritte.', - // Read-only domain facts + // Domain form + status 'domain' => 'Domain', - 'domain_via_ip' => '— (Zugriff per IP)', + 'domain_placeholder' => 'panel.example.com (leer = Zugriff per IP)', + 'domain_help' => 'Die Domain muss per DNS auf diesen Server zeigen. Nach dem Speichern den Stack neu starten — TLS-Zertifikat, URL, WebSocket und Cookie-Sicherheit richten sich automatisch ein. Leer lassen für Zugriff per IP über HTTP.', + 'domain_invalid' => 'Bitte eine gültige Domain eingeben (z. B. panel.example.com) — ohne http://, Port oder Pfad.', 'access_address' => 'Zugriffsadresse', 'access_bare_ip' => 'HTTP (Bare-IP)', - 'domain_note_pre' => 'Die Panel-Domain wird bei der Installation gesetzt und bleibt mit URL, WebSocket und Cookie-Sicherheit konsistent. Zum Ändern', - 'domain_note_post' => 'mit der neuen Domain erneut ausführen — TLS richtet sich danach automatisch ein.', + + // Change-domain confirmation modal + 'change_domain_heading' => 'Domain ändern', + 'change_domain_body' => 'Die Panel-Domain wird auf „:domain“ gesetzt. Nach einem Neustart des Stacks ist das Panel unter https://:domain erreichbar; das Zertifikat wird automatisch ausgestellt. Voraussetzung: DNS zeigt bereits auf diesen Server.', + 'clear_domain_body' => 'Die Domain wird entfernt. Nach einem Neustart ist das Panel per IP über unverschlüsseltes HTTP erreichbar (kein TLS). Eine Domain kann jederzeit wieder eingetragen werden.', + 'change_domain_confirm' => 'Ändern', + 'domain_saved_notify' => 'Domain gespeichert — Stack neu starten, um sie anzuwenden.', + + // Restart-required notice + 'restart_title' => 'Neustart erforderlich', + 'restart_body' => 'Die Domain ist gespeichert. Starte den Stack auf dem Host neu, damit sie übernommen wird:', + 'restart_lockout_hint' => 'Nach dem Neustart eventuell neu anmelden. Falls die neue Domain (DNS/Zertifikat) noch nicht erreichbar ist, bleibt das Panel über http:// als Rückfallweg erreichbar.', // Release channel panel 'channel_title' => 'Release-Kanal', diff --git a/lang/en/system.php b/lang/en/system.php index bd540c7..64bf382 100644 --- a/lang/en/system.php +++ b/lang/en/system.php @@ -18,13 +18,25 @@ return [ 'tls_bare_title' => 'Bare-IP mode: plaintext HTTP', 'tls_bare_body' => 'Without a domain the panel — including 2FA and audit — runs over unencrypted HTTP. Enter a domain and TLS is then set up automatically, with no further steps.', - // Read-only domain facts + // Domain form + status 'domain' => 'Domain', - 'domain_via_ip' => '— (access via IP)', + 'domain_placeholder' => 'panel.example.com (empty = access via IP)', + 'domain_help' => 'The domain must point to this server via DNS. After saving, restart the stack — the TLS certificate, URL, WebSocket and cookie security set themselves up automatically. Leave empty for access via IP over HTTP.', + 'domain_invalid' => 'Please enter a valid domain (e.g. panel.example.com) — without http://, port or path.', 'access_address' => 'Access address', 'access_bare_ip' => 'HTTP (bare IP)', - 'domain_note_pre' => 'The panel domain is set at installation and stays consistent with URL, WebSocket and cookie security. To change it, re-run', - 'domain_note_post' => 'with the new domain — TLS then sets itself up automatically.', + + // Change-domain confirmation modal + 'change_domain_heading' => 'Change domain', + 'change_domain_body' => 'The panel domain will be set to “:domain”. After a stack restart the panel is reachable at https://:domain; the certificate is issued automatically. Requirement: DNS already points to this server.', + 'clear_domain_body' => 'The domain will be removed. After a restart the panel is reachable via IP over unencrypted HTTP (no TLS). You can set a domain again at any time.', + 'change_domain_confirm' => 'Change', + 'domain_saved_notify' => 'Domain saved — restart the stack to apply it.', + + // Restart-required notice + 'restart_title' => 'Restart required', + 'restart_body' => 'The domain is saved. Restart the stack on the host to apply it:', + 'restart_lockout_hint' => 'You may need to sign in again after the restart. If the new domain (DNS/certificate) is not reachable yet, the panel stays available at http:// as a fallback.', // Release channel panel 'channel_title' => 'Release channel', diff --git a/resources/js/app.js b/resources/js/app.js index 1360f52..e35ed6e 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -3,14 +3,19 @@ import Pusher from 'pusher-js'; window.Pusher = Pusher; -// Reverb (Pusher protocol). Host/port/scheme are env-driven (derived from APP_DOMAIN later). +// Reverb (Pusher protocol). The endpoint is injected at runtime by the layout +// (window.__clusev.reverb), derived from the effective panel domain — so changing the +// domain from the dashboard needs no JS rebuild. Falls back to Vite env for HMR/dev. +const rc = (typeof window !== 'undefined' && window.__clusev && window.__clusev.reverb) || {}; +const reverbScheme = rc.scheme ?? import.meta.env.VITE_REVERB_SCHEME ?? 'https'; +const reverbPort = Number(rc.port ?? import.meta.env.VITE_REVERB_PORT ?? (reverbScheme === 'https' ? 443 : 80)); window.Echo = new Echo({ broadcaster: 'reverb', - key: import.meta.env.VITE_REVERB_APP_KEY, - wsHost: import.meta.env.VITE_REVERB_HOST, - wsPort: Number(import.meta.env.VITE_REVERB_PORT ?? 80), - wssPort: Number(import.meta.env.VITE_REVERB_PORT ?? 443), - forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https', + key: rc.key ?? import.meta.env.VITE_REVERB_APP_KEY, + wsHost: rc.host ?? import.meta.env.VITE_REVERB_HOST, + wsPort: reverbPort, + wssPort: reverbPort, + forceTLS: reverbScheme === 'https', enabledTransports: ['ws', 'wss'], }); diff --git a/resources/views/layouts/app.blade.php b/resources/views/layouts/app.blade.php index 04c091c..49fc126 100644 --- a/resources/views/layouts/app.blade.php +++ b/resources/views/layouts/app.blade.php @@ -1,10 +1,24 @@ +@inject('deployment', 'App\Services\DeploymentService') +@php + // Reverb (Echo) endpoint derived at RUNTIME from the effective domain, so changing + // the panel domain from the dashboard needs no JS rebuild. Read by resources/js/app.js. + $reverb = $deployment->reverbClient(); +@endphp {{ $title ?? 'Clusev' }} + @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index ec27646..9a0be47 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -41,27 +41,53 @@ @endif - {{-- Read-only: the panel domain is an install-time decision (kept consistent - with URL/WebSocket/cookie security); TLS provisions itself automatically. --}} -
-
-
{{ __('system.domain') }}
-
{{ $domain !== '' ? $domain : __('system.domain_via_ip') }}
+ {{-- Restart-required notice (shown after a successful save) --}} + @if ($restartPending) +
+ +
+

{{ __('system.restart_title') }}

+

{{ __('system.restart_body') }}

+ docker compose -f docker-compose.prod.yml restart +

{{ __('system.restart_lockout_hint') }}

+
-
-
{{ __('system.access_address') }}
-
- @if ($panelUrl) - {{ $panelUrl }} - @else - {{ __('system.access_bare_ip') }} - @endif -
+ @endif + + {{-- Editable panel domain. Saving persists it and asks for a restart; on restart + app URL, the Reverb endpoint, Caddy's cert and the secure cookie re-derive from it. --}} +
+
+ +
+ + + {{ __('common.save') }} + +
+ @error('domainInput') +

{{ $message }}

+ @enderror +

{{ __('system.domain_help') }}

-
-

- {{ __('system.domain_note_pre') }} install.sh {{ __('system.domain_note_post') }} -

+ + {{-- Current access address (read-only status) --}} +
+
+
{{ __('system.access_address') }}
+
+ @if ($panelUrl) + {{ $panelUrl }} + @else + {{ __('system.access_bare_ip') }} + @endif +
+
+
+ {{-- Release-Kanal --}} diff --git a/routes/web.php b/routes/web.php index cf531f7..1d458ef 100644 --- a/routes/web.php +++ b/routes/web.php @@ -11,6 +11,7 @@ use App\Livewire\Services; use App\Livewire\Settings; use App\Livewire\System; use App\Livewire\Versions; +use App\Services\DeploymentService; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth as AuthFacade; use Illuminate\Support\Facades\Route; @@ -28,6 +29,18 @@ Route::get('/locale/{locale}', function (Request $request, string $locale) { return redirect()->back(); })->name('locale.set'); +// Caddy on-demand TLS gate: Caddy calls this before issuing a certificate for a host. +// Approve ONLY the configured panel domain, so Caddy can never be tricked into +// requesting certs for arbitrary domains pointed at this IP (rate-limit abuse). +Route::get('/_caddy/ask', function (Request $request, DeploymentService $deployment) { + $asked = strtolower(trim((string) $request->query('domain'))); + $domain = $deployment->domain(); + + abort_unless($domain !== null && $asked !== '' && $asked === $domain, 403); + + return response('ok', 200); +})->name('caddy.ask'); + Route::middleware('guest')->group(function () { Route::get('/login', Auth\Login::class)->name('login'); Route::get('/two-factor-challenge', Auth\TwoFactorChallenge::class)->name('two-factor.challenge');