feat(tls): external reverse-proxy mode core — no ACME + no forced HTTPS redirect (host check kept)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>feat/v1-foundation
parent
19fc6e1f34
commit
99a183bcc7
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Middleware\PanelScheme;
|
||||
use App\Models\Setting;
|
||||
use App\Services\DeploymentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\Response as SymfonyResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExternalTlsModeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Remove the active-domain snapshot so domain() falls back to the DB Setting
|
||||
// (configuredDomain()), letting tests control the domain via Setting::put().
|
||||
$snapshot = storage_path('framework/active-domain');
|
||||
if (is_file($snapshot)) {
|
||||
unlink($snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
// Restore an empty snapshot (bare-IP) after each test so the dev stack is
|
||||
// unaffected; the entrypoint will overwrite it on next container start anyway.
|
||||
file_put_contents(storage_path('framework/active-domain'), '');
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
private function runPanelScheme(Request $request): SymfonyResponse
|
||||
{
|
||||
$mw = new PanelScheme(app(DeploymentService::class));
|
||||
|
||||
return $mw->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'));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue