# 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 } } }