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
parent
99a183bcc7
commit
8ef907d429
|
|
@ -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:..).
|
||||
|
|
|
|||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 --}}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue