docker/Caddyfile
Rama main · réplica de lectura del repositorio original.
# Edge for MDMesh. Serves the SPA and reverse-proxies the API/files/wake-socket to the server.
# SITE_ADDRESS is set by the setup wizard:
# own-domain → "mdm.example.com" (Caddy auto-provisions + renews a Let's Encrypt cert)
# cloudflare → ":80" (Cloudflare terminates TLS; cloudflared connects to this internally)
{
email "{$ACME_EMAIL}"
# Who Caddy believes about a client's address ({client_ip}, which the server records as a device's public IP).
# Set per listener, so each hosting mode gets its own rule. Strict mode reads a header list from the right, so an
# address a client prepends never counts.
# :443 own domain. Devices connect to Caddy directly, so nothing they send is believed: only a peer in
# TRUSTED_PROXIES may set X-Forwarded-For. The compose files pass it from .env; empty there (the default)
# trusts no peer, and unset, loopback only, which no Docker peer is.
# :80 set by docker/edge-peers.sh (the image's entrypoint), by hosting mode. Cloudflare tunnel: cloudflared's
# CF-Connecting-IP, from a private peer. Anything else (the dev stack, a proxy of your own in front):
# TRUSTED_PROXIES and X-Forwarded-For, as on :443. The defaults here are that second rule.
# Own-domain's :80 only redirects to HTTPS, so the :80 rule never applies to its traffic.
servers :443 {
trusted_proxies static {$TRUSTED_PROXIES:127.0.0.1/32 ::1/128}
trusted_proxies_strict
}
servers :80 {
trusted_proxies static {$HTTP_TRUSTED_PEERS:127.0.0.1/32 ::1/128}
client_ip_headers {$HTTP_CLIENT_IP_HEADER:X-Forwarded-For}
trusted_proxies_strict
}
}
# Every reverse_proxy to the server imports this. The server reads the client's address from X-Forwarded-For (and
# X-Real-IP, in code inherited from Headwind), so send exactly one value, Caddy's {client_ip}, and drop a client-sent
# CF-Connecting-IP. header_up runs after reverse_proxy's own X-Forwarded-For handling, so it wins.
(client_address) {
header_up X-Forwarded-For {client_ip}
header_up X-Real-IP {client_ip}
header_up -CF-Connecting-IP
}
# Browser security headers, on every response the edge sends, the error fallback's included (handle_errors imports
# this too). No MIME sniffing. No framing: nothing MDMesh serves is meant to be framed (the console, its QR view and
# the recovery page are top-level pages, and the agent has no WebView). No Referer to other sites; the map's tile
# images send just the origin, which OpenStreetMap requires (web/src/ui/LocationMap.tsx). `defer` applies them
# after the upstream's own headers, so these are the values the browser gets.
# HSTS only where the browser is on HTTPS: Caddy's own TLS (own domain), or a proxy in front that says so in
# X-Forwarded-Proto (Cloudflare tunnel, your own proxy). A browser ignores HSTS received over plain HTTP, so a forged
# X-Forwarded-Proto gains nothing, and HTTP-only (LAN) installs are unaffected. Six months, this host only, no preload.
# Importers define @https (a site's named matchers are visible in its handle_errors, and may be defined only once).
(security_headers) {
header {
defer
X-Content-Type-Options nosniff
X-Frame-Options DENY
Content-Security-Policy "frame-ancestors 'none'"
Referrer-Policy same-origin
}
header @https {
defer
Strict-Transport-Security "max-age=15552000"
}
}
{$SITE_ADDRESS} {
encode gzip
@https expression `{http.request.scheme} == "https" || {http.request.header.X-Forwarded-Proto} == "https"`
import security_headers
# A server path that the server's routing would read differently from its security filters: a ";" path
# parameter, or an encoded ";", "/" or "\" (or a raw "\"), anywhere in the path of /rest, /files or /agent (the
# query string is not looked at). Refused here with 400, before it is proxied; the server refuses these too
# (AmbiguousPathFilter), for installs without this edge. {http.request.uri} is the path as sent, still escaped.
@ambiguous {
path_regexp ^(?i)/(rest|files|agent)([/;\\]|$)
expression `{http.request.uri}.matches("^(?i)[^?]*(;|%3b|%2f|%5c|\\\\)")`
}
handle @ambiguous {
respond 400
}
# Canonical agent-APK URL: /files/agent.apk works on BOTH deployment shapes (native serves a
# real file from Tomcat; here it's the supervisor's sha256-verified release mirror — the same
# upstream as /update/agent.apk, which stays below for back-compat). The exact-path matcher
# outranks the /files/* → server route (Caddy sorts handles longest-path-first), so this must
# stay more specific than @backend.
handle /files/agent.apk {
rewrite * /update/agent.apk
reverse_proxy supervisor:9000
}
# API, uploaded files, and the agent wake WebSocket → the Tomcat server. reverse_proxy
# upgrades WebSockets automatically, so /agent/ws works without extra config.
@backend path /rest/* /files/* /agent/ws/*
handle @backend {
reverse_proxy server:8080 {
import client_address
}
}
# Updater status + recovery page → the decoupled supervisor (always up, independent of the
# server), so "update available" + recovery stay reachable even mid-update.
handle /update/* {
reverse_proxy supervisor:9000
}
handle /recovery* {
reverse_proxy supervisor:9000
}
# Health probes for uptime monitors. Both answer 200 "ok" or 503, never the SPA's catch-all 200:
# /healthz the edge AND the API server answer (the probe apply.sh/rollback.sh use to call
# an update healthy: GET /rest/public/name, no DB round-trip, no auth)
# /healthz/supervisor the updater/recovery supervisor answers (update checks, recovery page, and the
# /files/agent.apk mirror that new enrollments download)
# Each also answers with a trailing slash (exact paths only, nothing below them), so /healthz/ cannot fall through
# to the SPA's 200. `rewrite` inside reverse_proxy changes only the upstream request, so handle_errors below still
# sees the probe path.
@healthz path /healthz /healthz/
@healthzSupervisor path /healthz/supervisor /healthz/supervisor/
handle @healthz {
reverse_proxy server:8080 {
import client_address
rewrite /rest/public/name
@up status 2xx
handle_response @up {
respond "ok" 200
}
handle_response {
respond "server unavailable" 503
}
}
}
handle @healthzSupervisor {
reverse_proxy supervisor:9000 {
rewrite /healthz
@up status 2xx
handle_response @up {
respond "ok" 200
}
handle_response {
respond "supervisor unavailable" 503
}
}
}
# Everything else is the single-page app.
handle {
root * /srv
try_files {path} /index.html
file_server
}
# If the server (API) is down/erroring, fall back to the supervisor's recovery page. Not for the health
# probes: proxied as-is, /healthz would reach the supervisor's own /healthz and call a dead server "ok".
handle_errors {
import security_headers
handle @healthz {
respond "server unavailable" 503
}
handle @healthzSupervisor {
respond "supervisor unavailable" 503
}
handle {
reverse_proxy supervisor:9000
}
}
}