clusev/docker/caddy/Caddyfile

102 lines
4.2 KiB
Caddyfile

# Clusev reverse proxy — the panel domain is changeable FROM THE DASHBOARD.
#
# TLS is issued ON DEMAND: on the first HTTPS handshake for a host, Caddy asks the app
# (/_caddy/ask) whether to issue a certificate, and the app approves ONLY the domain
# currently configured in the dashboard (DB) / .env. So the operator changes the domain
# in the UI, points DNS at this host, restarts the stack, and Caddy gets the cert for the
# new domain automatically — no Caddyfile edit, no SITE_ADDRESS.
#
# HTTP is ALWAYS served (bare-IP access + the operator's recovery path); the app issues
# the HTTPS redirect for the configured domain (PanelScheme), and never for the raw IP.
# Reverb (Pusher protocol) rides the same address over /app/* (WS) and /apps/* (event
# API), proxied to reverb:8080 — no extra exposed port, no mixed content.
{
# ACME contact for Let's Encrypt (set by install.sh; defaults to admin@localhost).
email {$ACME_EMAIL}
# Only mint a certificate for the host the app approves — prevents cert-issuance
# abuse from arbitrary domains pointed at this IP.
on_demand_tls {
ask http://app:80/_caddy/ask
}
# Keep serving plain HTTP (still issue certs on demand): the APP decides the
# HTTP->HTTPS redirect — only for the configured domain, never for the raw IP —
# so bare-IP HTTP always stays reachable as the operator's recovery path.
auto_https disable_redirects
# Honor X-Forwarded-* from a trusted upstream proxy (external-TLS mode). Defaults to
# 127.0.0.1/32 (trust nothing external); set TRUSTED_PROXY_CIDR to the upstream proxy's
# EXACT address (a /32, or its tightest CIDR). SECURITY: never a broad range and never
# 0.0.0.0/0 — a too-wide value lets any client forge X-Forwarded-For, which then drives
# request()->ip() in the app and defeats the IP-keyed login/2FA throttles + the honeypot
# brute-force ban (an attacker can spoof a victim's IP to get it banned, or a whitelisted
# IP to evade the ban entirely).
servers {
trusted_proxies static {$TRUSTED_PROXY_CIDR:127.0.0.1/32}
}
}
# Snippet: the shared proxy + hardening, reused by both the HTTP and HTTPS sites.
(clusev_proxy) {
@reverb path /app/* /apps/*
reverse_proxy @reverb reverb:8080
# Terminal sidecar websocket (xterm.js ↔ PTY); Caddy handles the WS upgrade automatically.
@terminal path /terminal/ws
reverse_proxy @terminal terminal:3000
# The internal terminal-resolve endpoint hands out DECRYPTED SSH credentials. It is for the sidecar
# only, which reaches it over the private network (app:80, bypassing Caddy). Refuse it at the public
# edge so a leaked sidecar secret can't be replayed from the internet (defence in depth).
@internal path /_internal/*
respond @internal 404
reverse_proxy app:80
encode zstd gzip
header {
# conservative hardening; HSTS is asserted by the app only over HTTPS
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
}
# HTTPS for any host the app approves (the configured panel domain); cert obtained
# on demand on the first handshake.
https:// {
tls {
on_demand
}
import clusev_proxy
}
# Plain HTTP: always served — bare-IP access and the recovery path. Also carries the
# ACME HTTP-01 challenge. The app redirects regular requests to HTTPS for the configured
# domain (PanelScheme); the raw IP stays on HTTP.
http:// {
# Reverb (/app WS, /apps events) bypasses the app, so enforce transport here: on a
# HOSTNAME force the WS/event paths onto HTTPS, so broadcasts (incl. the public metrics
# channel) are never carried in cleartext and a stale/wrong host is refused by on-demand
# TLS. On a bare IP there is no TLS, so plaintext there is expected and left alone.
@reverb_cleartext {
path /app/* /apps/*
not header_regexp Host ^(\d{1,3}\.){3}\d{1,3}(:\d+)?$
# External-TLS mode: an upstream proxy already terminated TLS and forwards over HTTP with
# X-Forwarded-Proto: https. The connection is secure end-to-end, so do NOT bounce the
# WS/event paths to https here — this Caddy serves no cert for the domain in that mode, so
# the redirect would just break the WebSocket. Only genuine cleartext is redirected.
not header X-Forwarded-Proto https
}
handle @reverb_cleartext {
redir https://{host}{uri}
}
handle {
import clusev_proxy
}
}