From 19fc6e1f348657077fbd6a717cfd024d56e28ead Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 22:26:20 +0200 Subject: [PATCH] docs(spec): external reverse-proxy TLS mode (Zoraxy in front) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-14-external-proxy-tls-design.md | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-14-external-proxy-tls-design.md diff --git a/docs/superpowers/specs/2026-06-14-external-proxy-tls-design.md b/docs/superpowers/specs/2026-06-14-external-proxy-tls-design.md new file mode 100644 index 0000000..f3162cb --- /dev/null +++ b/docs/superpowers/specs/2026-06-14-external-proxy-tls-design.md @@ -0,0 +1,82 @@ +# 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`).