# 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`).
