# External reverse-proxy TLS mode (Zoraxy in front) — Design **Date:** 2026-06-14 · **Branch:** `feat/v1-foundation` · **Status:** approved (build as 0.8.0) Today Clusev's Caddy obtains a Let's Encrypt certificate **on demand** for the configured panel domain (`/_caddy/ask` approves only that domain) and `PanelScheme` forces HTTPS for it. That fails or is unwanted when the operator runs an **upstream reverse proxy (e.g. Zoraxy)** that already terminates TLS — or for a purely local/internal domain. This feature adds a **TLS mode** where Caddy does **not** issue certs and the panel is served over HTTP for the upstream proxy to wrap. ## Operator decisions (locked) - Mode: **external reverse-proxy** (not self-signed). - Topology: **Zoraxy terminates TLS → forwards to Clusev's existing Caddy HTTP port** (keeps the Reverb/WebSocket routing `/app`, `/apps`). Caddy is configured to **honor the upstream's `X-Forwarded-Proto`** so the app sees the real scheme. ## 1. Setting + DeploymentService - New Setting key `tls_mode` ∈ {`caddy` (default), `external`}, via the existing `Setting` model. - `DeploymentService::externalTls(): bool` = `Setting::get('tls_mode', 'caddy') === 'external'`. - `DeploymentService::setTlsMode(string $mode): void` (validates the enum, persists, busts cache). - `hasTls()` stays as-is for the `caddy` path; in `external` mode TLS is asserted by the upstream, so HSTS/secure-cookie decisions follow the **real request scheme** (`$request->isSecure()` via the honored `X-Forwarded-Proto`), not a static flag. ## 2. Caddy `/_caddy/ask` — no ACME in external mode The `caddy.ask` route currently approves cert issuance for the configured domain. In `external` mode it must **deny every host** (HTTP 403 / non-`ok` body) so Caddy never attempts ACME — the upstream proxy owns the certificate. In `caddy` mode the behaviour is unchanged (approve the active domain only). ## 3. PanelScheme — defer HTTPS to the upstream in external mode With a domain set and `external` mode: - Keep the **host enforcement** (only the configured domain by hostname; the literal server IP stays the plain-HTTP recovery path; `_caddy/*` + health exempt). - **Skip the app-level `redirect → https://domain:443`** — the upstream proxy performs HTTP→HTTPS, and Caddy here is HTTP-only, so a 443 redirect would be wrong/unreachable. - Cookie `Secure` + HSTS still follow `$request->isSecure()`, which now reflects the upstream's `X-Forwarded-Proto` (honored by Caddy — see §4) and Laravel's `trustProxies(at: '*')`. In `caddy` mode `PanelScheme` is unchanged (still forces HTTPS for the active domain). ## 4. Caddyfile — honor the upstream X-Forwarded-Proto (env-gated) Caddy's `reverse_proxy` overwrites `X-Forwarded-Proto` with the scheme **it** received (HTTP from Zoraxy), losing the original `https`. To recover it, add a global `servers { trusted_proxies static {$TRUSTED_PROXY_CIDR} }` block so Caddy trusts the upstream's `X-Forwarded-*` (incl. Proto) from that CIDR. New env `TRUSTED_PROXY_CIDR` (default **empty** → no trust, current behaviour). The operator sets it to Zoraxy's address/range and restarts the stack when using external mode. (The feature still *functions* without it — the panel works over HTTP behind Zoraxy — but the `Secure` cookie flag + HSTS are only correct once it's set.) ## 5. UI — System → Domain & TLS In `App\Livewire\System\Index` (the domain editor), add a **TLS-Modus** control beside the domain: `Caddy (Let's Encrypt, automatisch)` vs `Externer Reverse-Proxy (TLS davor)`. Saving persists `tls_mode` and — like a domain change — surfaces the **"restart required"** notice (and, for external mode, a hint to set `TRUSTED_PROXY_CIDR` to the proxy's address). Audited via the shared confirm modal, mirroring `confirmDomain`. ## Files - `app/Models/Setting.php` — n/a (generic key/value). - `app/Services/DeploymentService.php` — `externalTls()`, `setTlsMode()`. - the `caddy.ask` closure in `routes/web.php` — deny in external mode. - `app/Http/Middleware/PanelScheme.php` — skip the forced redirect in external mode. - `app/Livewire/System/Index.php` + its view — the TLS-mode control + restart/CIDR hint. - `docker/caddy/Caddyfile` — `trusted_proxies static {$TRUSTED_PROXY_CIDR}` global option. - `.env.example` / docker-compose — document `TRUSTED_PROXY_CIDR`. - `lang/{de,en}/system.php` — the mode labels, restart + CIDR hints, audit copy. ## Testing - `externalTls()` reflects the setting; `setTlsMode()` validates the enum. - `caddy.ask`: external mode → deny (non-ok) for the configured domain; caddy mode → approve it. - `PanelScheme`: external mode + domain + HTTP request → **no** redirect (200/pass), host check still refuses a foreign host, bare-IP still served; caddy mode unchanged (still 301→https). - System UI: switching mode persists `tls_mode`, shows the restart notice; DE+EN; R12 (200, no console errors) at the 3 breakpoints; Codex clean. ## Safety invariants - Never lock the operator out: the bare-IP HTTP recovery path stays reachable in **both** modes. - External mode must not silently leave ACME on (cert-issuance spam) — `/_caddy/ask` denies. - Honoring `X-Forwarded-Proto` is gated by `TRUSTED_PROXY_CIDR`; with it empty, no header from an untrusted client is ever trusted (no scheme spoofing). ## Out of scope - Self-signed certificates for bare-IP (operator chose external-proxy mode only). - Auto-detecting the proxy IP (operator sets `TRUSTED_PROXY_CIDR`).