# Rendered by install.sh → /etc/oxpulse-partner-edge/Caddyfile # Placeholders: test.example, api-abc.test.example # # Traffic split on partner edge: # / (SPA) → xray-client:3080 (backend renders branded index.html) # /_app/immutable → xray-client:3080 BUT cached 1 year at Caddy # /api/* → xray-client:3080 with X-Forwarded-Host header # /ws/* → xray-client:3080 (WebSocket upgrade preserved by Caddy) # api-abc.test.example.test.example TLS passthrough → coturn:5349 # # caddy-l4 TURNS SNI mux: listener_wrappers peeks TLS ClientHello BEFORE Caddy # HTTP app sees it. Matching SNI → raw TCP to coturn (coturn terminates own TLS). # Any other SNI → falls through to HTTP app. { # Global options # Caddy 2.11 tightened the default admin `origins` whitelist; container # healthcheck (`wget http://127.0.0.1:2019/config/`) started getting 403 # "host not allowed" even though production traffic was fine. Explicit # origins list fixes the healthcheck without exposing admin beyond loopback. admin localhost:2019 { origins localhost 127.0.0.1 } email admin@test.example # M2b.2: DB-IP country lookup — DISABLED 2026-05-16. # The aksdb fork registered maxmind_geolocation as a global option; # porech v1.0.3 (current build) only exposes it as an HTTP matcher. # Caddy v2.11.2 rejects this block as "unrecognized global option". # X-Geo-Country header injections below now emit an empty string for # {vars.maxmind_country_code}; Rust upstream falls back to its own chain. # Follow-up: re-implement geo lookup using porech matcher form OR # a different plugin that supports a global db_path declaration. # NOTE: listener_wrappers MUST be at global servers{} scope — not in a # site-level snippet (Phase 2 PoC confirmed). layer4 applies to the listener # itself, not to per-site handlers. servers { # H3/QUIC disabled — ТСПУ entropy heuristic target (R1 Layer 0). protocols h1 h2 listener_wrappers { layer4 { @turns tls sni api-abc.test.example.test.example route @turns { # coturn runs in network_mode: host and binds 5349 on every # host interface. From this Caddy container's perspective # 127.0.0.1 is its own loopback (NOT the host) — wrong dest. # host.docker.internal is provided by docker-compose # extra_hosts: [host.docker.internal:host-gateway] and # resolves to the bridge gateway, which IS a host interface # coturn binds to. caddy-l4 then forwards the raw TLS TCP # stream; coturn terminates its own TLS using the # Caddy-issued cert mounted read-only. proxy tcp/host.docker.internal:5349 } } tls } } # Phase 1: structured JSON access logs to stdout. # Docker JSON driver collects to journald. Fields including request.host, # request.uri, resp_status, duration_ms, upstream_status, # upstream_duration_ms, upstream_address are emitted automatically # by caddy when format=json. log { format json level INFO } } # Phase 2: tunnel_upstream snippet — collapses the 4 repeated reverse_proxy # blocks (api, ws, events, SPA fallback) into a single authoritative definition. # {args[0]} receives the path pattern from `import tunnel_upstream /api/*`. # A second snippet (tunnel_upstream_default) handles the no-path catch-all SPA # fallback where there is no route argument. (tunnel_upstream) { reverse_proxy {args[0]} xray-client:3080 host.docker.internal:18443 { lb_policy first lb_try_duration 5s lb_try_interval 250ms health_uri /api/health health_interval 10s health_timeout 3s health_status 2xx health_passes 2 health_fails 3 header_up X-Forwarded-Host test.example header_up X-Forwarded-Proto https header_up Host oxpulse.chat header_up X-Geo-Country {vars.maxmind_country_code} } } (tunnel_upstream_default) { reverse_proxy xray-client:3080 host.docker.internal:18443 { lb_policy first lb_try_duration 5s lb_try_interval 250ms health_uri /api/health health_interval 10s health_timeout 3s health_status 2xx health_passes 2 health_fails 3 header_up X-Forwarded-Host test.example header_up X-Forwarded-Proto https header_up Host oxpulse.chat header_up X-Geo-Country {vars.maxmind_country_code} } } test.example { encode gzip zstd header { Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" Referrer-Policy "no-referrer" X-Frame-Options "DENY" -Server -Via -Alt-Svc } # Active-probing defense removed 2026-04-20: the @probe matcher + # cover decoy combination interacted badly with Service Worker # precache (SW install fetches '/' with mode='cors' → matched as # probe → SW cached cover as the "/" response) and with Arc's # aggressive storage handling. Net effect was breaking legitimate # first visits instead of hiding the service from scanners. If we # need DPI defense again, do it at a different layer (fail2ban / # WAF rule on edge IP by UA fingerprint) so it never touches the # SPA contract. cover.html is still shipped for backwards-compat # with /etc/oxpulse-partner-edge/cover bind mount but unreachable. # Relay API — JWT-authenticated cascade relay endpoint. # Called by the signaling server for multi-region room bridging. # Routes directly to SFU relay port (not through tunnel). handle /relay/* { reverse_proxy host.docker.internal:8912 } # Phase 7 M4.A5 — client-facing SFU WebSocket endpoint. # Browsers connect here with a room JWT in Sec-WebSocket-Protocol. # The SFU container runs network_mode: host, so 8920 is reachable via # the bridge gateway alias `host.docker.internal` (see extra_hosts in # docker-compose.yml.tpl). Caddy auto-handles the WS upgrade for any # reverse_proxy upstream (Connection/Upgrade headers preserved). # `handle` (not `handle_path`) keeps the full /sfu/ws/{room_id} path # so the SFU's axum router matches. # Port 8920 chosen because 8911 is squatted on krolik (San Jose). handle /sfu/ws/* { reverse_proxy host.docker.internal:8920 } # Every GET / just serves the SPA. handle { # Cache SvelteKit hashed assets for a year (immutable by filename hash). @immutable path_regexp /_app/immutable/.* header @immutable Cache-Control "public, max-age=31536000, immutable" # API — preserve partner domain so backend branding resolver picks right config. import tunnel_upstream /api/* # WebSocket — Caddy auto-upgrades on Upgrade: websocket. import tunnel_upstream /ws/* # Event telemetry. import tunnel_upstream /events/* # SPA fallback — everything else goes through the tunnel so backend can # inject partner branding into index.html before shipping to browser. import tunnel_upstream_default } } # Stub vhost — Caddy issues + renews cert for TURNS subdomain via ACME # HTTP-01 on :80 (Caddy still owns :80 unmultiplexed). The cert is written # to the caddy-data volume and bind-mounted read-only into coturn. # Actual :443 traffic for this SNI is routed by caddy-l4 above → coturn # before this handler ever sees it — so this respond only fires for # ACME-challenge + any stray request that bypassed the l4 mux. api-abc.test.example.test.example { tls { issuer acme { # CRITICAL: once l4 routes :443 for this SNI to coturn, Caddy can # no longer answer TLS-ALPN-01 (which would come in on :443). # Force HTTP-01 which uses :80 (Caddy still owns :80). # Silent failure if missing: cert renewal stops after 90 days. # Source: scratch/B-certs-client-security.md §1.1 disable_tlsalpn_challenge } } respond 421 } # ============================================================================= # Phase 1 canary site -- 127.0.0.1:9080 ONLY, never publicly exposed. # Provides 4 diagnostic endpoints for healthcheck.sh and operators. # DO NOT add to any public-facing listener or firewall rule. # ============================================================================= http://127.0.0.1:9080 { # /canary/tunnel -- probe xray-client via /health path. # 2xx = tunnel reachable; non-2xx = upstream sick but container up. handle /canary/tunnel { reverse_proxy xray-client:3080/health { transport http { dial_timeout 2s response_header_timeout 2s } } } # /canary/upstream -- bypass tunnel health path, hit xray-client directly. # If this fails too, the xray-client container itself is down. handle /canary/upstream { reverse_proxy xray-client:3080 { transport http { dial_timeout 2s response_header_timeout 2s } } } # /canary/config-hash -- sha256 of the rendered Caddyfile. # install.sh writes CADDYFILE_SHA to install.env; healthcheck.sh check 15 # compares these to detect operator drift edits. handle /canary/config-hash { respond "__CADDYFILE_SHA__" 200 } # /canary/route-table -- static route manifest. # Placeholder for Phase 5 introspection; used now to confirm canary is live. handle /canary/route-table { respond `{"routes":["tunnel","sfu","relay","branding"]}` 200 } } # ============================================================================= # Phase 3: operator override slot — conf.d/*.caddy # # Files placed in /etc/oxpulse-partner-edge/conf.d/ are loaded AFTER the # rendered Caddyfile. They survive install.sh / update.sh / upgrade.sh reruns # because those scripts never touch files inside conf.d/. # # Use cases: # - Per-tenant vhosts (e.g. cheburator.bot + www.cheburator.bot static sites) # - Hand-crafted route additions (webhook proxies, emergency overrides) # - Emergency patches that must survive the next scheduled upgrade # # File naming convention: -.caddy # Example: cheburator-vhosts.caddy # # Empty directory: Caddy emits a warning but exits 0 (validated 2026-05-15). # Do NOT place secrets in conf.d/ files — they are world-readable by default. # See docs/runbooks/conf-d.md for full guidance. # ============================================================================= import /etc/oxpulse-partner-edge/conf.d/*.caddy