83 lines
5.4 KiB
Markdown
83 lines
5.4 KiB
Markdown
# 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`).
|