diff --git a/CHANGELOG.md b/CHANGELOG.md index b476146..4063277 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,20 @@ 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.9.18] - 2026-06-19 + +### Behoben +- **Realtime (WebSocket) hinter einem externen TLS-Proxy lief nicht.** Der Echo/Reverb-Endpunkt + wurde aus dem (hinter dem Proxy als `http:80` erscheinenden) Request abgeleitet → `wss://host:80`, + was scheiterte. Bei aktiver Domain wird der Reverb-Client jetzt fest auf **`wss://:443`** + (die HTTPS-Vordertür) gesetzt; der Bare-IP-Recovery-Pfad bleibt einfaches `ws`. Zusätzlich leitet + Clusevs Caddy die WS-Pfade (`/app/*`, `/apps/*`) **nicht mehr auf https um, wenn der Upstream + bereits TLS terminiert hat** (`X-Forwarded-Proto: https`) — sonst brach der Redirect die + WebSocket-Verbindung im Externer-Proxy-Modus. +- **TLS-Status zeigte im Externer-Proxy-Modus fälschlich „Let's Encrypt".** Die „TLS aktiv"-Karte + meldet jetzt korrekt „TLS aktiv — über externen Reverse-Proxy; das Panel stellt kein Zertifikat + aus" statt einer automatischen Let's-Encrypt-Ausstellung, die in diesem Modus nicht stattfindet. + ## [0.9.17] - 2026-06-19 ### Behoben diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index d1c89d9..cafe3ec 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -310,8 +310,19 @@ class DeploymentService { $key = config('broadcasting.connections.reverb.key'); $request = request(); + $domain = $this->domain(); if ($request !== null) { + // Served over the active domain → the browser reached the panel through the HTTPS + // front door (Caddy or an external TLS proxy on 443), which tunnels /app/* + /apps/* + // to reverb. Connect Reverb the SAME way — wss on 443 — derived from the active + // domain, NOT the request scheme/port: behind an external TLS proxy those look like + // plain HTTP:80 here, which would (wrongly) produce wss://host:80 and fail. + if ($domain !== null && strtolower($request->getHost()) === $domain) { + return ['key' => $key, 'host' => $domain, 'port' => 443, 'scheme' => 'https']; + } + + // Bare IP / recovery path → exactly what the request used (plain ws over the IP). return [ 'key' => $key, 'host' => $request->getHost(), diff --git a/config/clusev.php b/config/clusev.php index 90a8f71..c026c52 100644 --- a/config/clusev.php +++ b/config/clusev.php @@ -2,7 +2,7 @@ return [ // First tagged release is v0.1.0 (semantic, not -dev). - 'version' => '0.9.17', + 'version' => '0.9.18', // Deployed commit + branch. install.sh bakes these into .env from the host's .git (the prod // image ships no .git); the Versions page prefers them and falls back to a live .git read in diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index 94f5855..8364880 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -70,6 +70,11 @@ http:// { @reverb_cleartext { path /app/* /apps/* not header_regexp Host ^(\d{1,3}\.){3}\d{1,3}(:\d+)?$ + # External-TLS mode: an upstream proxy already terminated TLS and forwards over HTTP with + # X-Forwarded-Proto: https. The connection is secure end-to-end, so do NOT bounce the + # WS/event paths to https here — this Caddy serves no cert for the domain in that mode, so + # the redirect would just break the WebSocket. Only genuine cleartext is redirected. + not header X-Forwarded-Proto https } handle @reverb_cleartext { redir https://{host}{uri} diff --git a/lang/de/system.php b/lang/de/system.php index 09c1509..6beaca1 100644 --- a/lang/de/system.php +++ b/lang/de/system.php @@ -19,6 +19,8 @@ return [ 'tls_active_title' => 'TLS aktiv — Zertifikat automatisch über Let\'s Encrypt', 'tls_active_body_pre' => 'Erreichbar unter', 'tls_active_body_post' => 'wird automatisch ausgestellt und erneuert. Voraussetzung: die Domain zeigt per DNS auf diesen Server.', + 'tls_active_external_title' => 'TLS aktiv — über externen Reverse-Proxy', + 'tls_active_external_post' => 'TLS stellt der vorgelagerte Proxy bereit; das Panel selbst stellt kein Zertifikat aus.', '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.', diff --git a/lang/en/system.php b/lang/en/system.php index fa7af2c..33cc62e 100644 --- a/lang/en/system.php +++ b/lang/en/system.php @@ -19,6 +19,8 @@ return [ 'tls_active_title' => 'TLS active — certificate issued automatically via Let\'s Encrypt', 'tls_active_body_pre' => 'Reachable at', 'tls_active_body_post' => 'issued and renewed automatically. Requirement: the domain points to this server via DNS.', + 'tls_active_external_title' => 'TLS active — via external reverse proxy', + 'tls_active_external_post' => 'TLS is provided by the upstream proxy; the panel itself issues no certificate.', '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.', diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index 80b02e8..4d7b35c 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -174,10 +174,10 @@
-

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

+

{{ $tlsMode === 'external' ? __('system.tls_active_external_title') : __('system.tls_active_title') }}

{{ __('system.tls_active_body_pre') }} {{ $panelUrl }} · - {{ __('system.tls_active_body_post') }}

+ {{ $tlsMode === 'external' ? __('system.tls_active_external_post') : __('system.tls_active_body_post') }}

@else diff --git a/tests/Feature/ExternalTlsModeTest.php b/tests/Feature/ExternalTlsModeTest.php index 1ef3bd2..97852d5 100644 --- a/tests/Feature/ExternalTlsModeTest.php +++ b/tests/Feature/ExternalTlsModeTest.php @@ -100,6 +100,31 @@ class ExternalTlsModeTest extends TestCase $this->runPanelScheme(Request::create('http://evil.example.com/dashboard')); } + public function test_reverb_client_uses_wss_on_443_for_the_active_domain(): void + { + Setting::put('panel_domain', 'panel.example.com'); + app(DeploymentService::class)->setTlsMode('external'); + + // External proxy terminates TLS; the request arrives here as plain HTTP on :80. + app()->instance('request', Request::create('http://panel.example.com/dashboard')); + + $rc = app(DeploymentService::class)->reverbClient(); + $this->assertSame('panel.example.com', $rc['host']); + $this->assertSame(443, $rc['port'], 'WS must use the HTTPS front door (443), not the internal :80'); + $this->assertSame('https', $rc['scheme'], 'WS must be wss on the active domain'); + } + + public function test_reverb_client_stays_plain_ws_on_the_bare_ip_recovery_path(): void + { + Setting::put('panel_domain', 'panel.example.com'); + + app()->instance('request', Request::create('http://10.0.0.5/dashboard')); + + $rc = app(DeploymentService::class)->reverbClient(); + $this->assertSame('10.0.0.5', $rc['host']); + $this->assertSame('http', $rc['scheme'], 'the bare-IP recovery path has no TLS'); + } + public function test_external_mode_forces_https_url_generation_for_the_active_domain(): void { Setting::put('panel_domain', 'panel.example.com');