5.4 KiB
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'sX-Forwarded-Protoso the app sees the real scheme.
1. Setting + DeploymentService
- New Setting key
tls_mode∈ {caddy(default),external}, via the existingSettingmodel. 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 thecaddypath; inexternalmode TLS is asserted by the upstream, so HSTS/secure-cookie decisions follow the real request scheme ($request->isSecure()via the honoredX-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'sX-Forwarded-Proto(honored by Caddy — see §4) and Laravel'strustProxies(at: '*'). IncaddymodePanelSchemeis 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.askclosure inroutes/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 — documentTRUSTED_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/askdenies. - Honoring
X-Forwarded-Protois gated byTRUSTED_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).