feat(tls): System TLS-mode toggle + Caddy trusted_proxies (TRUSTED_PROXY_CIDR) + firewall hint

- Add TLS_MODES const, tlsMode property, confirmTlsMode/applyTlsMode (mirroring channel toggle) to System\Index
- Segmented TLS-mode selector + external-proxy warning hint in system/index.blade.php
- DE + EN lang keys for all new strings (tls_mode_*, change_tls_*, tls_saved_notify)
- Caddyfile: servers { trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32} } in global block
- docker-compose.prod.yml caddy service: TRUSTED_PROXY_CIDR env var (safe default 127.0.0.1/32)
- .env.example: documented TRUSTED_PROXY_CIDR knob
- tests/Feature/TlsModeToggleTest.php: 2 new tests (all 7 TLS tests green)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 22:38:21 +02:00
parent 99a183bcc7
commit 8ef907d429
8 changed files with 174 additions and 0 deletions

View File

@ -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/<owner>/clusev@sha256:..).

View File

@ -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<string, array<int, string>> */
@ -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', [

View File

@ -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

View File

@ -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.

View File

@ -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',
];

View File

@ -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',
];

View File

@ -88,6 +88,59 @@
</div>
</dl>
</div>
{{-- TLS-mode selector --}}
<div class="mt-5 space-y-3">
<p class="text-sm font-medium text-ink-2">{{ __('system.tls_mode_title') }}</p>
{{-- Segmented control (mirrors the channel selector) --}}
<div role="radiogroup" aria-label="{{ __('system.tls_mode_title') }}" class="inline-flex w-full max-w-md rounded-md border border-line bg-inset p-1">
<button type="button"
role="radio"
aria-checked="{{ $tlsMode === 'caddy' ? 'true' : 'false' }}"
wire:click="confirmTlsMode('caddy')"
@class([
'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors',
'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $tlsMode === 'caddy',
'text-ink-3 hover:bg-raised hover:text-ink-2' => $tlsMode !== 'caddy',
])>
@if ($tlsMode === 'caddy')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_caddy') }}
</button>
<button type="button"
role="radio"
aria-checked="{{ $tlsMode === 'external' ? 'true' : 'false' }}"
wire:click="confirmTlsMode('external')"
@class([
'inline-flex h-9 flex-1 items-center justify-center gap-1.5 rounded font-mono text-xs uppercase tracking-wider transition-colors',
'bg-accent/15 text-accent-text shadow-[inset_0_0_0_1px_var(--color-accent)]' => $tlsMode === 'external',
'text-ink-3 hover:bg-raised hover:text-ink-2' => $tlsMode !== 'external',
])>
@if ($tlsMode === 'external')<x-icon name="shield" class="h-3.5 w-3.5" />@endif{{ __('system.tls_mode_external') }}
</button>
</div>
{{-- Per-mode description --}}
<div class="flex items-start gap-3 rounded-md border border-line bg-raised/40 p-3">
<x-icon name="shield" class="mt-0.5 h-4 w-4 shrink-0 text-accent-text" />
<div class="min-w-0">
<p class="text-sm text-ink">
@if ($tlsMode === 'caddy')
{{ __('system.tls_mode_caddy_desc') }}
@else
{{ __('system.tls_mode_external_desc') }}
@endif
</p>
</div>
</div>
{{-- External-proxy hint (warning tone) --}}
@if ($tlsMode === 'external')
<div class="flex items-start gap-3 rounded-md border border-warning/25 bg-warning/10 p-3">
<x-icon name="alert" class="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<p class="font-mono text-[11px] leading-relaxed text-ink-2">{{ __('system.tls_external_hint') }}</p>
</div>
@endif
</div>
</x-panel>
{{-- Release-Kanal --}}

View File

@ -0,0 +1,38 @@
<?php
namespace Tests\Feature;
use App\Livewire\System\Index;
use App\Models\User;
use App\Services\DeploymentService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Tests\TestCase;
class TlsModeToggleTest extends TestCase
{
use RefreshDatabase;
public function test_applying_external_mode_persists_and_flags_restart(): void
{
$this->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());
}
}