# Caddy reverse proxy for the FreeSocks Control Plane (beta).
#
# Serves the built SPA (/srv/dist, baked into the web image) and routes the API
# surface to the Convex HTTP-actions port (:3211) over the compose network.
# SITE_ADDRESS + ACME_EMAIL come from the compose env (.env.beta). Caddy
# auto-provisions + renews the TLS cert via Let's Encrypt on :443.
#
# Bind-mounted by docker-compose.beta.yml, so header tweaks can be applied with
# `docker compose -f docker-compose.beta.yml exec web caddy reload --config /etc/caddy/Caddyfile`
# without rebuilding the SPA image. See docs/beta-deploy.md.
{
	# Let's Encrypt contact (expiry notices). Override via the ACME_EMAIL env.
	email {$ACME_EMAIL:ops@freesocks.org}

	# Privacy default: NO access logs. FCP serves users in censored regions and
	# never persists a client IP at rest (the app HMAC-buckets it, never logs it —
	# see convex/lib/http.ts + docs/privacy.md). Caddy would still see the real
	# client IP, so we discard the access log EXPLICITLY (a null sink) rather than
	# leaving it off "by omission" — the intent is now tamper-evident, and a
	# future edit that wants logging has to consciously remove this. Caddy's own
	# runtime/error log (no per-request client IPs) still goes to stderr.
	# Privacy default: NO access logs. FCP serves users in censored regions and
	# never persists a client IP at rest (the app HMAC-buckets it, never logs it —
	# see convex/lib/http.ts + docs/privacy.md). Caddy would still see the real
	# client IP, so we discard the access log EXPLICITLY (a null sink) rather than
	# leaving it off "by omission" — the intent is now tamper-evident, and a
	# future edit that wants logging has to consciously remove this.
	# IMPORTANT: this must NOT be a global `log default { output discard }` —
	# in Caddy the default logger IS the runtime logger, so discarding it
	# globally also silences startup + ACME logs (and made deploy debugging
	# blind). Access logs are discarded per-site below; runtime logs keep
	# going to stderr (docker logs).
	log default {
		output stderr
	}

	# Client-IP trust (see the @api block + docs/beta-deploy.md §"Client IP").
	# Default is a TEST-NET-1 sentinel matching NOTHING → Caddy overwrites XFF with
	# the immediate peer (topology (a): Caddy is the public edge, TRUSTED_PROXY=true
	# / TRUSTED_PROXY_HOPS=1 on the backend). When ANYTHING fronts Caddy (Pangolin /
	# CF Tunnel / ngrok / LB, topology (b)), set CADDY_TRUSTED_PROXIES to that peer's
	# IP/CIDR (e.g. the tailnet 100.64.0.0/10, or a /32): Caddy then trusts + PRESERVES
	# the fronting proxy's XFF and APPENDS the peer → a 2-entry chain, and the backend
	# reads the real client with TRUSTED_PROXY_HOPS=2.
	# NOTE: use a SINGLE CIDR here. A space-separated multi-CIDR value is NOT reliably
	# split by `{$VAR}` single-token expansion; 100.64.0.0/10 covers a whole tailnet
	# in one CIDR. `caddy validate` (CI) guards this block's syntax.
	servers {
		trusted_proxies static {$CADDY_TRUSTED_PROXIES:192.0.2.255/32}
	}
}

# Site scheme: default https (Caddy manages a public LE cert via ACME — the
# "Caddy is the public edge" topology). Set CADDY_SCHEME=http to serve plain
# HTTP on :80 with ACME disabled — the mode to use when the app is fronted by
# an authenticating edge (Cloudflare -> your reverse proxy over Tailscale),
# where TLS terminates at that edge and the proxy->host hop is already
# encrypted (WireGuard), so no public origin cert is needed (and no ACME
# rate-limit/challenge pain).
{$CADDY_SCHEME:https}://{$SITE_ADDRESS:beta.freesocks.org} {
	encode zstd gzip

	# Per-site ACCESS log discard (the privacy intent — see the header comment).
	# The global runtime logger stays on stderr so startup/ACME logs are visible.
	log {
		output discard
	}

	# --- Security headers (CDN-blinding hardening) ---------------------------
	# W1: with Cloudflare Turnstile replaced by self-hosted Cap, the member CSP
	# is near-pure 'self' — ZERO third-party origins. Inline STYLES are allowed
	# (Svelte style bindings); inline SCRIPTS are not (the FOUC theme logic is
	# the external /theme-init.js). worker-src is 'self' blob:: the PoP signing
	# worker is a same-origin module, but Cap's PoW solver is a blob: worker.
	# 'wasm-unsafe-eval' lets Cap's WebAssembly PoW solver compile (it does NOT
	# permit eval()); the WASM is a bundled same-origin asset. The /cap admin
	# dashboard is the one scope with a relaxed CSP + no COOP/COEP (see below).
	#
	# Headers IDENTICAL on every response (member SPA, API, AND the /cap
	# dashboard). The CSP and the cross-origin-isolation pair (COOP/COEP) are
	# NOT here — they differ for /cap and are set disjointly by scope below.
	# Why disjoint and not a global-set + per-/cap-override: a deferred
	# site-wide set always wins the response-write race against a deeper handle
	# override (the outer route wraps first, so it runs last), so the override
	# could never take effect. `-Server` makes this block deferred, so it still
	# applies after the upstream/file_server response.
	header {
		# CORP stops OUR responses being embedded cross-origin (harmless on the
		# top-level dashboard too). Permissions-Policy denies features we never
		# use. (browsing-topics intentionally omitted: it's a Chrome-only,
		# best-effort denial that logs an "unrecognized feature" warning in every
		# other browser — pure console noise for no real gain here.)
		Cross-Origin-Resource-Policy "same-origin"
		Permissions-Policy "accelerometer=(), camera=(), display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
		# Direct TLS termination here, so HSTS is ours to set. No includeSubDomains
		# / preload on a beta host (it would bind siblings of beta.freesocks.org).
		Strict-Transport-Security "max-age=31536000"
		X-Content-Type-Options "nosniff"
		Referrer-Policy "no-referrer"
		-Server
		# NOTE: no Content-Security-Policy-Report-Only require-trusted-types-for
		# here anymore. Svelte + the Cap widget both legitimately use string-to-
		# script/HTML sinks, so Trusted Types can never be ENFORCED without
		# breaking the app; with no report-to endpoint the report-only header
		# only spammed the console. Removed.
	}
	# Member SPA + API (everything BUT /cap): the strict pure-self CSP plus the
	# cross-origin-isolation pair. COOP/COEP live HERE, not site-wide, because
	# the /cap dashboard is a third-party app that loads cross-origin assets
	# (e.g. its logo) and must NOT run under COEP require-corp. `defer` so the
	# set lands after the file_server/proxy response (replace, no duplicate).
	@notCap not path /cap/*
	header @notCap {
		# - script-src carries a PER-REQUEST nonce ({http.request.uuid}, the same
		#   UUID templated into <meta name="csp-nonce"> in index.html). It lets
		#   ONLY the Cap captcha's instrumentation challenge run — a server-
		#   supplied, per-challenge-randomised inline script in a sandboxed
		#   <iframe srcdoc> (which inherits this policy). 'self' still governs our
		#   own (external) scripts; the nonce adds nothing an attacker can use
		#   because it is unguessable and regenerated every response.
		# - 'unsafe-eval' is REQUIRED by that same instrumentation script: its
		#   anti-emulation probes deliberately call eval()/new Function() (verified
		#   in the pinned tiago2/cap image), each wrapped in catch -> return null,
		#   so a blocked eval = no postMessage = instr_timeout after 20s. eval is
		#   only reachable from code that is ALREADY executing; initial injection
		#   stays gated by 'self' + the nonce (inline scripts remain blocked).
		# - worker-src allows blob: because @cap.js/widget spawns its proof-of-
		#   work solver as `new Worker(URL.createObjectURL(new Blob([...])))`
		#   (verified in its source); worker-src 'self' alone silently kills it
		#   and the captcha never completes.
		# - font-src data: because Vite inlines small @fontsource subsets as
		#   data: URIs in the bundled CSS (inline bytes, not a network origin).
		Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-{http.request.uuid}' 'unsafe-eval' 'wasm-unsafe-eval'; connect-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; worker-src 'self' blob:; child-src 'self' blob:"
		Cross-Origin-Opener-Policy "same-origin"
		Cross-Origin-Embedder-Policy "require-corp"
		defer
	}
	# Integrity-Policy "blocked-destinations=(script)" stays DEFERRED. Every chunk
	# now carries SRI (entry/css stamped in index.html + an import map with an
	# `integrity` section over all dynamic chunks, emitted by the Vite sriPlugin),
	# so the original blocker is closed. It is NOT enabled because a module WORKER
	# realm does not inherit the document import map, so the enforcing header would
	# require integrity for the PoP signing worker's module imports — which have no
	# integrity source in the worker realm — and BLOCK them, breaking auth. Flip it
	# only after an in-browser beta check (load /admin, trigger a sealed route, and
	# confirm the PoP worker still signs) — likely with per-worker integrity.
	# See docs/threat-model-cdn-blinding.md.

	# --- self-hosted Cap captcha (W1) -> the cap service over the compose net ---
	# The browser widget posts challenges to the SAME-ORIGIN /cap/<siteKey>/...
	# so they stay within connect-src 'self'. Strip the /cap prefix to match the
	# Cap server's routes. This ALSO serves Cap's ADMIN DASHBOARD: the deployment
	# topology puts an authenticating edge (Pangolin + CrowdSec) in front of this
	# host with the admin paths behind its auth, and the dashboard is
	# ADMIN_KEY-gated regardless.
	#
	# /cap gets its OWN, relaxed CSP (set below) because the dashboard login uses
	# a javascript: navigation that needs 'unsafe-inline' in script-src, and the
	# dashboard's widget-test page runs the same eval-probing instrumentation
	# challenge as the member SPA, so 'unsafe-eval' too. The widget's
	# challenge/redeem endpoints are JSON (CSP inert), so the relaxation
	# effectively applies to the dashboard documents alone. The member SPA keeps
	# the strict CSP — CSP is per-response, and @notCap above excludes /cap.
	@cap path /cap/*
	handle @cap {
		# /cap is the ONLY scope that sets CSP here (the site-wide block above
		# omits it, and @notCap excludes /cap), so this is the sole setter — no
		# override race. The -X-Powered-By deletion auto-defers the block so it
		# lands after Cap's response.
		# img-src allows https://trycap.dev for the dashboard's logo (operator-only
		# page, behind the fronting edge's auth); the member origin never permits
		# it. font-src data: for any inlined dashboard fonts.
		header {
			Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://trycap.dev; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
			-X-Powered-By
		}
		uri strip_prefix /cap
		reverse_proxy cap:3000 {
			# Symmetry with the backend proxy: strip any client-supplied
			# CF-Connecting-IP so a request hitting Caddy directly can't hand Cap a
			# spoofed client IP. Cap rate-limits on the compose peer (Caddy) by
			# default — do NOT set RATELIMIT_IP_HEADER to a client header.
			header_up -CF-Connecting-IP
		}
	}

	# --- API + health -> Convex HTTP actions (:3211 over the compose network) --
	@api path /api/* /healthz /readyz
	handle @api {
		# Client-IP trust — topology matrix (the backend reads the resulting XFF
		# per convex/lib/http.ts:resolveClientIp; verify live via the admin
		# self-diagnostic GET /api/v1/admin/client-ip):
		#   (a) Caddy IS the public edge: CADDY_TRUSTED_PROXIES unset (the sentinel
		#       above) → Caddy OVERWRITES X-Forwarded-For with the immediate peer, so
		#       the chain is 1 entry and TRUSTED_PROXY=true (≡ HOPS=1) reads it
		#       unspoofably.
		#   (b) Something FRONTS Caddy (Pangolin / CF Tunnel / ngrok / LB): set
		#       CADDY_TRUSTED_PROXIES to that peer (global block above) → Caddy
		#       preserves the fronting XFF + appends the peer (a 2-entry chain), and
		#       the backend reads the real client with TRUSTED_PROXY_HOPS=2. The
		#       backend right-anchors the chain, so it stays correct even if a hop
		#       appends rather than discards a client-supplied header.
		#   (c) Cloudflare CDN edge: CF_FRONTED=true on the backend + origin locked
		#       to CF-only (see docs/threat-model-cdn-blinding.md).
		# Defense in depth: strip any client-supplied Cloudflare headers before
		# they reach the backend — UNLESS the operator declares a real Cloudflare
		# edge fronts this deployment (CADDY_TRUST_CF_HEADER=true, and the origin
		# must then be locked to CF-only traffic so the headers can't be spoofed
		# by a direct hit). With the strip active (the default), the backend can
		# never see them, so CF_FRONTED, the analytics Cloudflare IP source, and
		# the analytics coarse-geo mode stay inert even if misconfigured — the
		# two layers can't disagree. The geo trio (IPCountry/Region-Code/IPCity)
		# gets the same treatment as the IP: resolveCountry (mirror tiering) and
		# the analytics relay consume the first two, and the vouched-or-absent
		# invariant should cover every CF-derived value alike.
		@untrustedCfHeader `{env.CADDY_TRUST_CF_HEADER} != "true"`
		request_header @untrustedCfHeader -CF-Connecting-IP
		request_header @untrustedCfHeader -CF-IPCountry
		request_header @untrustedCfHeader -CF-Region-Code
		request_header @untrustedCfHeader -CF-IPCity
		reverse_proxy backend:3211
	}

	# Never serve source maps from the public root (defense in depth; the prod
	# build emits none, but block the path regardless).
	@maps path *.map
	respond @maps 404

	# --- everything else -> the built SPA, history-API fallback to index.html --
	handle {
		root * /srv/dist
		# Vite emits content-hashed files under /assets/, so they are immutable;
		# the HTML entry must revalidate so a new deploy is picked up at once.
		@assets path /assets/*
		@html not path /assets/*
		header @assets Cache-Control "public, max-age=31536000, immutable"
		header @html Cache-Control "no-cache"
		try_files {path} /index.html
		# Expand {{placeholder "http.request.uuid"}} in index.html to the same
		# per-request UUID the CSP `script-src 'nonce-…'` header carries, so the
		# <meta name="csp-nonce"> matches the enforcing policy. Scoped to
		# text/html only: hashed JS/font/image assets are served verbatim.
		templates {
			mime text/html
		}
		# Expand {{placeholder "http.request.host"}} in robots.txt + sitemap.xml
		# so their canonical URLs track the deployment host (beta vs prod) with a
		# single committed file. Deliberately a SEPARATE, path-scoped instance
		# rather than widening the block above: template execution on all
		# text/plain would 500 on any future .txt that happens to contain `{{`.
		# (.xml MIME varies by system table — match both text/xml and
		# application/xml.)
		@seoFiles path /robots.txt /sitemap.xml
		templates @seoFiles {
			mime text/plain text/xml application/xml
		}
		file_server
	}
}

# =============================================================================
# OPTIONAL — expose the Convex dashboard + deploy API through Caddy.
# =============================================================================
# OFF by default: the dashboard (:6791) and deploy/sync API (:3210) stay bound to
# 127.0.0.1 and are reached over an SSH tunnel (docs/beta-deploy.md). To reach
# the dashboard from a browser without a tunnel, UNCOMMENT both site blocks below
# and:
#   1. Point DNS (A/AAAA) for BOTH hostnames at this host.
#   2. In .env.beta set CONVEX_DASHBOARD_ADDRESS + CONVEX_API_ADDRESS (the `web`
#      service passes them in for the interpolation below) and
#      CONVEX_DASHBOARD_DEPLOYMENT_URL = https://<CONVEX_API_ADDRESS> (so the
#      dashboard's BROWSER calls the API host). See .env.beta.example.
#   3. `docker compose ... up -d` (to apply the env), then reload Caddy.
#
# Two HOSTS, not one random path: the dashboard is a Next.js app serving assets
# from absolute paths (/_next/*), so it must sit at a host ROOT — a sub-path
# breaks it — and its browser separately calls the deploy API's own /api/* paths.
#
# !!! SECURITY — the hostname is NOT secret !!!  A non-guessable subdomain is at
# most weak defense-in-depth: the moment Caddy gets a Let's Encrypt cert for it,
# the exact name is published to public Certificate Transparency logs (crt.sh) —
# plus passive-DNS. So you MUST gate the surface with real auth, not obscurity.
# The deploy API (:3210) fronts the sync/deploy surface and accepts the admin key
# for FULL control (it is otherwise 127.0.0.1-only); unauthenticated callers reach
# only publicConfig.get, but the surface must not be open to the world.
#   - The `@gate not remote_ip …` allowlist below is the real control here:
#     restrict to your operator IPs / VPN egress. (Or use `basic_auth` over TLS,
#     or mTLS — none depend on the hostname being secret.)
#   - In PROD, front BOTH hosts with Pangolin (auth + CrowdSec) instead.
#   - To keep the NAME private too, you'd need a wildcard cert (DNS-01) + wildcard
#     A record so it never hits CT logs — defense-in-depth, never the lock.
# (Convex serves the dashboard cross-origin to the API; if it loads but won't
# connect after enabling, verify origin/CORS on your Convex version there.)
#
# {$CONVEX_DASHBOARD_ADDRESS} {
# 	encode zstd gzip
# 	header -Server
# 	# The real gate — replace with your operator IPs/CIDRs (NOT the hostname).
# 	@gate not remote_ip 203.0.113.0/24 198.51.100.7
# 	respond @gate 403
# 	reverse_proxy dashboard:6791
# }
#
# {$CONVEX_API_ADDRESS} {
# 	encode zstd gzip
# 	header -Server
# 	@gate not remote_ip 203.0.113.0/24 198.51.100.7
# 	respond @gate 403
# 	# reverse_proxy upgrades the /api/.../sync WebSocket natively. (Caddy serves
# 	# the ACME challenge before these routes, so the allowlist won't block cert
# 	# issuance.)
# 	reverse_proxy backend:3210
# }
