fix(tls): reverb WebSocket + honest TLS status behind an external proxy

- reverbClient() now returns wss://<domain>:443 when the active domain is being
  served (the HTTPS front door), instead of deriving wss://host:80 from a request
  that looks like plain HTTP:80 behind an external TLS proxy. Bare-IP recovery
  stays plain ws.
- Caddy no longer redirects the /app/* /apps/* WS paths to https when the upstream
  already terminated TLS (X-Forwarded-Proto: https) — that redirect broke the
  WebSocket in external-proxy mode.
- The 'TLS active' status card no longer claims Let's Encrypt issuance in
  external-proxy mode; it states TLS is provided by the upstream proxy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation v0.9.18
boban 2026-06-19 20:49:25 +02:00
parent 4bcdafbeff
commit 92969eb0dc
8 changed files with 62 additions and 3 deletions

View File

@ -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._ _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://<domain>: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 ## [0.9.17] - 2026-06-19
### Behoben ### Behoben

View File

@ -310,8 +310,19 @@ class DeploymentService
{ {
$key = config('broadcasting.connections.reverb.key'); $key = config('broadcasting.connections.reverb.key');
$request = request(); $request = request();
$domain = $this->domain();
if ($request !== null) { 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 [ return [
'key' => $key, 'key' => $key,
'host' => $request->getHost(), 'host' => $request->getHost(),

View File

@ -2,7 +2,7 @@
return [ return [
// First tagged release is v0.1.0 (semantic, not -dev). // 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 // 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 // image ships no .git); the Versions page prefers them and falls back to a live .git read in

View File

@ -70,6 +70,11 @@ http:// {
@reverb_cleartext { @reverb_cleartext {
path /app/* /apps/* path /app/* /apps/*
not header_regexp Host ^(\d{1,3}\.){3}\d{1,3}(:\d+)?$ 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 { handle @reverb_cleartext {
redir https://{host}{uri} redir https://{host}{uri}

View File

@ -19,6 +19,8 @@ return [
'tls_active_title' => 'TLS aktiv — Zertifikat automatisch über Let\'s Encrypt', 'tls_active_title' => 'TLS aktiv — Zertifikat automatisch über Let\'s Encrypt',
'tls_active_body_pre' => 'Erreichbar unter', '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_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_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.', '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.',

View File

@ -19,6 +19,8 @@ return [
'tls_active_title' => 'TLS active — certificate issued automatically via Let\'s Encrypt', 'tls_active_title' => 'TLS active — certificate issued automatically via Let\'s Encrypt',
'tls_active_body_pre' => 'Reachable at', '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_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_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.', '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.',

View File

@ -174,10 +174,10 @@
<div class="flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3"> <div class="flex items-start gap-3 rounded-md border border-online/25 bg-online/5 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-online" /> <x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-online" />
<div class="min-w-0"> <div class="min-w-0">
<p class="text-sm text-ink">{{ __('system.tls_active_title') }}</p> <p class="text-sm text-ink">{{ $tlsMode === 'external' ? __('system.tls_active_external_title') : __('system.tls_active_title') }}</p>
<p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.tls_active_body_pre') }} <p class="mt-0.5 font-mono text-[11px] leading-relaxed text-ink-3">{{ __('system.tls_active_body_pre') }}
<a href="{{ $panelUrl }}" class="break-all text-accent-text hover:underline">{{ $panelUrl }}</a> · <a href="{{ $panelUrl }}" class="break-all text-accent-text hover:underline">{{ $panelUrl }}</a> ·
{{ __('system.tls_active_body_post') }}</p> {{ $tlsMode === 'external' ? __('system.tls_active_external_post') : __('system.tls_active_body_post') }}</p>
</div> </div>
</div> </div>
@else @else

View File

@ -100,6 +100,31 @@ class ExternalTlsModeTest extends TestCase
$this->runPanelScheme(Request::create('http://evil.example.com/dashboard')); $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 public function test_external_mode_forces_https_url_generation_for_the_active_domain(): void
{ {
Setting::put('panel_domain', 'panel.example.com'); Setting::put('panel_domain', 'panel.example.com');