diff --git a/.env.example b/.env.example index 28dffd8..2c45323 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,9 @@ IMAGE_TAG=latest APP_DOMAIN= APP_SCHEME=http ACME_EMAIL= +# External reverse-proxy TLS: set to the upstream proxy's address/CIDR so Caddy trusts its +# X-Forwarded-Proto. Leave as 127.0.0.1/32 (trust nothing) when Caddy terminates TLS itself. +TRUSTED_PROXY_CIDR=127.0.0.1/32 # Derived from APP_DOMAIN by install.sh (":${APP_PORT}" vs "https://${APP_DOMAIN}"): SITE_ADDRESS=:80 # Prod image: locally-built tag, or a pinned GHCR digest (ghcr.io//clusev@sha256:..). diff --git a/app/Livewire/System/Index.php b/app/Livewire/System/Index.php index a9705a3..4066f1f 100644 --- a/app/Livewire/System/Index.php +++ b/app/Livewire/System/Index.php @@ -26,6 +26,9 @@ class Index extends Component /** Valid user-facing release channels (descriptions are localized at render time). */ public const CHANNELS = ['stable', 'beta']; + /** Valid TLS termination modes. */ + public const TLS_MODES = ['caddy', 'external']; + /** Effective panel domain (DB override or install-time APP_DOMAIN); '' = bare IP. */ public string $domain = ''; @@ -37,6 +40,8 @@ class Index extends Component public string $channel = 'stable'; + public string $tlsMode = 'caddy'; + /** Channel key => localized description, built per-request for the view. */ private function channelDescriptions(): array { @@ -56,6 +61,7 @@ class Index extends Component $channel = Setting::get('release_channel', config('clusev.channel')) ?? 'stable'; // Clamp to a valid user channel — a stale/legacy value (e.g. 'dev') falls back to stable. $this->channel = in_array($channel, self::CHANNELS, true) ? $channel : 'stable'; + $this->tlsMode = $deployment->externalTls() ? 'external' : 'caddy'; } /** @return array> */ @@ -150,6 +156,45 @@ class Index extends Component $this->channel = $channel; } + /** TLS-mode changes are audited (confirmation via the shared modal). */ + public function confirmTlsMode(string $mode): void + { + if (! in_array($mode, self::TLS_MODES, true) || $mode === $this->tlsMode) { + return; + } + + $this->dispatch('openModal', + component: 'modals.confirm-action', + arguments: [ + 'heading' => __('system.change_tls_heading'), + 'body' => $mode === 'external' + ? __('system.change_tls_body_external') + : __('system.change_tls_body_caddy'), + 'confirmLabel' => __('system.change_tls_confirm'), + 'danger' => true, + 'icon' => 'shield', + 'auditAction' => 'system.settings_updated', + 'auditTarget' => 'tls_mode='.$mode, + 'event' => 'tlsModeChanged', + 'params' => ['mode' => $mode], + 'notify' => '', + ], + ); + } + + #[On('tlsModeChanged')] + public function applyTlsMode(string $mode, DeploymentService $deployment): void + { + if (! in_array($mode, self::TLS_MODES, true)) { + return; + } + + $deployment->setTlsMode($mode); + $this->tlsMode = $mode; + $this->restartPending = true; + $this->dispatch('notify', message: __('system.tls_saved_notify')); + } + public function render(DeploymentService $deployment) { return view('livewire.system.index', [ diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 73e697c..118b9b9 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -68,6 +68,9 @@ services: # Domain is dashboard-driven via on-demand TLS (Caddy asks app:/_caddy/ask), so # no SITE_ADDRESS is needed. Only the ACME contact comes from .env. ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}" + # External reverse-proxy TLS: set to the upstream proxy's CIDR so Caddy trusts + # its X-Forwarded-Proto. Leave as 127.0.0.1/32 when Caddy terminates TLS itself. + TRUSTED_PROXY_CIDR: "${TRUSTED_PROXY_CIDR:-127.0.0.1/32}" volumes: - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data # ACME account + certs — MUST persist across recreate diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile index a18b16b..94f5855 100644 --- a/docker/caddy/Caddyfile +++ b/docker/caddy/Caddyfile @@ -24,6 +24,12 @@ # HTTP->HTTPS redirect — only for the configured domain, never for the raw IP — # so bare-IP HTTP always stays reachable as the operator's recovery path. auto_https disable_redirects + + # Honor X-Forwarded-* from a trusted upstream proxy (external-TLS mode). Defaults to + # 127.0.0.1/32 (trust nothing external); set TRUSTED_PROXY_CIDR to the proxy's address. + servers { + trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32} + } } # Snippet: the shared proxy + hardening, reused by both the HTTP and HTTPS sites. diff --git a/lang/de/system.php b/lang/de/system.php index ebd60e7..e783b53 100644 --- a/lang/de/system.php +++ b/lang/de/system.php @@ -54,6 +54,19 @@ return [ 'change_channel_confirm' => 'Wechseln', 'change_channel_notify' => 'Release-Kanal auf „:channel“ gesetzt.', + // TLS-mode selector + 'tls_mode_title' => 'TLS-Terminierung', + 'tls_mode_caddy' => 'Caddy (Let\'s Encrypt, automatisch)', + 'tls_mode_caddy_desc' => 'Das Panel holt selbst ein Zertifikat für die Domain.', + 'tls_mode_external' => 'Externer Reverse-Proxy', + 'tls_mode_external_desc' => 'Ein Proxy davor (z. B. Zoraxy) terminiert TLS; das Panel liefert HTTP.', + 'tls_external_hint' => 'Externer Proxy: setze TRUSTED_PROXY_CIDR auf die Adresse deines Proxys und schütze den HTTP-Port per Firewall, sodass er nur vom Proxy erreichbar ist. Caddy holt dann kein Zertifikat.', + 'change_tls_heading' => 'TLS-Modus ändern', + 'change_tls_body_external' => 'Auf „Externer Reverse-Proxy" umstellen? Caddy holt kein Zertifikat mehr; TLS muss der Proxy davor liefern. Gilt nach Neustart des Stacks.', + 'change_tls_body_caddy' => 'Auf „Caddy (Let\'s Encrypt)" umstellen? Das Panel holt wieder selbst ein Zertifikat. Gilt nach Neustart des Stacks.', + 'change_tls_confirm' => 'Umstellen', + 'tls_saved_notify' => 'TLS-Modus gespeichert — Stack neu starten, um zu übernehmen.', + // Page title 'title' => 'System — Clusev', ]; diff --git a/lang/en/system.php b/lang/en/system.php index 64bf382..d9a96f9 100644 --- a/lang/en/system.php +++ b/lang/en/system.php @@ -54,6 +54,19 @@ return [ 'change_channel_confirm' => 'Switch', 'change_channel_notify' => 'Release channel set to “:channel”.', + // TLS-mode selector + 'tls_mode_title' => 'TLS termination', + 'tls_mode_caddy' => 'Caddy (Let\'s Encrypt, automatic)', + 'tls_mode_caddy_desc' => 'The panel fetches its own certificate for the domain.', + 'tls_mode_external' => 'External reverse proxy', + 'tls_mode_external_desc' => 'A proxy in front (e.g. Zoraxy) terminates TLS; the panel serves HTTP.', + 'tls_external_hint' => 'External proxy: set TRUSTED_PROXY_CIDR to your proxy\'s address and firewall the HTTP port so it is only reachable from the upstream proxy. Caddy will not fetch a certificate.', + 'change_tls_heading' => 'Change TLS mode', + 'change_tls_body_external' => 'Switch to "External reverse proxy"? Caddy will no longer fetch a certificate; TLS must be provided by the proxy in front. Takes effect after a stack restart.', + 'change_tls_body_caddy' => 'Switch to "Caddy (Let\'s Encrypt)"? The panel will fetch its own certificate again. Takes effect after a stack restart.', + 'change_tls_confirm' => 'Switch', + 'tls_saved_notify' => 'TLS mode saved — restart the stack to apply.', + // Page title 'title' => 'System — Clusev', ]; diff --git a/resources/views/livewire/system/index.blade.php b/resources/views/livewire/system/index.blade.php index 7145e94..0a63188 100644 --- a/resources/views/livewire/system/index.blade.php +++ b/resources/views/livewire/system/index.blade.php @@ -88,6 +88,59 @@ + + {{-- TLS-mode selector --}} +
+

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

+ + {{-- Segmented control (mirrors the channel selector) --}} +
+ + +
+ + {{-- Per-mode description --}} +
+ +
+

+ @if ($tlsMode === 'caddy') + {{ __('system.tls_mode_caddy_desc') }} + @else + {{ __('system.tls_mode_external_desc') }} + @endif +

+
+
+ + {{-- External-proxy hint (warning tone) --}} + @if ($tlsMode === 'external') +
+ +

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

+
+ @endif +
{{-- Release-Kanal --}} diff --git a/tests/Feature/TlsModeToggleTest.php b/tests/Feature/TlsModeToggleTest.php new file mode 100644 index 0000000..e8e2aff --- /dev/null +++ b/tests/Feature/TlsModeToggleTest.php @@ -0,0 +1,38 @@ +actingAs(User::factory()->create(['must_change_password' => false])); + + Livewire::test(Index::class) + ->call('applyTlsMode', 'external', app(DeploymentService::class)) + ->assertSet('tlsMode', 'external') + ->assertSet('restartPending', true); + + $this->assertTrue(app(DeploymentService::class)->externalTls()); + } + + public function test_invalid_mode_is_ignored(): void + { + $this->actingAs(User::factory()->create(['must_change_password' => false])); + + Livewire::test(Index::class) + ->call('applyTlsMode', 'garbage', app(DeploymentService::class)) + ->assertSet('tlsMode', 'caddy'); + + $this->assertFalse(app(DeploymentService::class)->externalTls()); + } +}