# PiCloud dev Caddyfile. # # Routing topology: # /healthz, /version → picloud (liveness + version, k8s-friendly) # /api/v1/admin/* → picloud (manager control plane) # /api/v1/execute/{id} → picloud (orchestrator ID-based bypass) # /api/* → 404 (unsupported / sunset API version) # /admin/* → dashboard SPA (mounted with paths.base=/admin) # everything else → picloud (orchestrator route table; user paths) # # The "everything else → picloud" rule is what makes user-defined paths # like /greet possible. picloud's matcher answers them via the routes # table, or returns 404 with a precise JSON error. # # When v2 of the API ships, add `handle /api/v2/admin/* { ... }` etc. # alongside the v1 handles, before the catch-all `/api/*` 404. # # Audit 2026-06-11 (H-A1/2/3, M07-06/07/08/09): defense-in-depth headers. # CSP / X-Frame-Options / Permissions-Policy / no-store land on the # dashboard SPA and admin API; nosniff + Referrer-Policy land everywhere. # User-route responses (catch-all `handle`) deliberately get NO CSP — # user scripts own their own response headers by design. # `?` operator = "default if missing" so a downstream response (e.g. the # file-download endpoint with its own restrictive CSP) wins. { auto_https off admin off log { output stdout format console } } :80 { # Audit 2026-06-11 (H-1) — hard request-body ceiling at the proxy. # Without this, Caddy forwards bodies up to its multi-GB default to # picloud. 12 MB sits just above the orchestrator's 10 MiB user-route # read so legitimate large invokes pass; admin / email handlers are # bounded far tighter by their own Axum extractor limits. request_body { max_size 12MB } # Baseline headers applied to every response. header { X-Content-Type-Options "nosniff" Referrer-Policy "no-referrer" } handle /healthz { reverse_proxy picloud:8080 } handle /version { reverse_proxy picloud:8080 } handle /api/v1/admin/* { header { ?Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'" ?X-Frame-Options "DENY" ?Cache-Control "no-store" ?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()" } reverse_proxy picloud:8080 } handle /api/v1/execute/* { reverse_proxy picloud:8080 } handle /api/* { respond 404 { body "{\"error\":\"no such API version — see /version for supported routes\"}" close } } # Dashboard SPA at /admin. Its internal Caddy serves files from /srv # (root); we don't strip the prefix because SvelteKit was built with # paths.base = '/admin' so the bundle's URLs already include it. handle /admin/* { header { ?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" ?X-Frame-Options "DENY" ?Cache-Control "no-store" ?Permissions-Policy "geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=()" } reverse_proxy dashboard:80 } handle /admin { # Bare /admin (no trailing slash) — let SvelteKit's SPA handle it. header { ?Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" ?X-Frame-Options "DENY" } reverse_proxy dashboard:80 } # Everything else → picloud's user-route fallback. If no route # matches, picloud responds 404 with a JSON body. handle { reverse_proxy picloud:8080 } log { output stdout format console } }