diff --git a/app/Http/Middleware/PanelScheme.php b/app/Http/Middleware/PanelScheme.php index c287d86..cd987ec 100644 --- a/app/Http/Middleware/PanelScheme.php +++ b/app/Http/Middleware/PanelScheme.php @@ -68,7 +68,11 @@ class PanelScheme // 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()) { + // + // In external-proxy mode the upstream terminates TLS and owns HTTP->HTTPS; this Caddy is + // HTTP-only, so the app must NOT bounce to https://domain:443 (unreachable). The forwarded + // scheme still drives cookie-Secure/HSTS above. + if (! $this->deployment->externalTls() && ! $request->isSecure()) { return redirect()->to('https://'.$domain.$request->getRequestUri(), 301); } diff --git a/app/Services/DeploymentService.php b/app/Services/DeploymentService.php index 7981688..7d249e2 100644 --- a/app/Services/DeploymentService.php +++ b/app/Services/DeploymentService.php @@ -163,6 +163,22 @@ class DeploymentService return $this->configuredDomain(); } + /** True when an upstream reverse proxy terminates TLS (the panel serves HTTP; Caddy issues no certs). */ + public function externalTls(): bool + { + try { + return Setting::get('tls_mode', 'caddy') === 'external'; + } catch (Throwable) { + return false; + } + } + + /** Persist the TLS mode (only the two valid values; anything else falls back to caddy). */ + public function setTlsMode(string $mode): void + { + Setting::put('tls_mode', $mode === 'external' ? 'external' : 'caddy'); + } + /** True once an ACTIVE domain is configured — i.e. automatic TLS is in effect. */ public function hasTls(): bool { diff --git a/routes/web.php b/routes/web.php index 0692d6e..137a50c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -36,7 +36,7 @@ Route::get('/_caddy/ask', function (Request $request, DeploymentService $deploym $asked = strtolower(trim((string) $request->query('domain'))); $domain = $deployment->domain(); - abort_unless($domain !== null && $asked !== '' && $asked === $domain, 403); + abort_unless($domain !== null && $asked !== '' && $asked === $domain && ! $deployment->externalTls(), 403); return response('ok', 200); })->name('caddy.ask'); diff --git a/tests/Feature/ExternalTlsModeTest.php b/tests/Feature/ExternalTlsModeTest.php new file mode 100644 index 0000000..3c880d0 --- /dev/null +++ b/tests/Feature/ExternalTlsModeTest.php @@ -0,0 +1,102 @@ +handle($request, fn () => new Response('ok')); + } + + public function test_external_tls_flag_reflects_the_setting(): void + { + $deployment = app(DeploymentService::class); + $this->assertFalse($deployment->externalTls(), 'default is caddy'); + + $deployment->setTlsMode('external'); + $this->assertTrue(app(DeploymentService::class)->externalTls()); + + $deployment->setTlsMode('garbage'); // invalid → caddy + $this->assertFalse(app(DeploymentService::class)->externalTls()); + } + + public function test_caddy_ask_denies_in_external_mode_but_approves_in_caddy_mode(): void + { + Setting::put('panel_domain', 'panel.example.com'); + + // caddy mode: the configured domain is approved + $this->get('/_caddy/ask?domain=panel.example.com')->assertOk(); + + // external mode: even the configured domain is denied (no ACME) + app(DeploymentService::class)->setTlsMode('external'); + $this->get('/_caddy/ask?domain=panel.example.com')->assertForbidden(); + } + + public function test_panel_scheme_skips_the_https_redirect_in_external_mode(): void + { + Setting::put('panel_domain', 'panel.example.com'); + app(DeploymentService::class)->setTlsMode('external'); + + $request = Request::create('http://panel.example.com/dashboard'); + $response = $this->runPanelScheme($request); + + $this->assertSame(200, $response->getStatusCode(), 'external mode must NOT redirect to https'); + $this->assertSame('ok', $response->getContent()); + } + + public function test_panel_scheme_still_forces_https_in_caddy_mode(): void + { + Setting::put('panel_domain', 'panel.example.com'); // caddy mode (default) + + $request = Request::create('http://panel.example.com/dashboard'); + $response = $this->runPanelScheme($request); + + $this->assertSame(301, $response->getStatusCode()); + $this->assertStringStartsWith('https://panel.example.com', $response->headers->get('Location')); + } + + public function test_panel_scheme_still_refuses_a_foreign_host_in_external_mode(): void + { + Setting::put('panel_domain', 'panel.example.com'); + app(DeploymentService::class)->setTlsMode('external'); + + $this->expectException(HttpException::class); + $this->runPanelScheme(Request::create('http://evil.example.com/dashboard')); + } +}