clusev/docs/superpowers/specs/2026-06-14-external-proxy-t...

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'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.phpexternalTls(), 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/Caddyfiletrusted_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).