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
parent
4bcdafbeff
commit
92969eb0dc
14
CHANGELOG.md
14
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://<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
|
||||
|
||||
### Behoben
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
||||
|
|
|
|||
|
|
@ -174,10 +174,10 @@
|
|||
<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" />
|
||||
<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') }}
|
||||
<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>
|
||||
@else
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in New Issue