# Rendered by install.sh → /etc/oxpulse-partner-edge/Caddyfile # Placeholders: {{PARTNER_DOMAIN}}, {{TURNS_SUBDOMAIN}} # # 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) # {{TURNS_SUBDOMAIN}}.{{PARTNER_DOMAIN}} 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@{{PARTNER_DOMAIN}} # 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 # Phase 5.8: expose Prometheus metrics on localhost:2019/metrics. # Surfaces caddy_reverse_proxy_upstreams_healthy{upstream=...} for the # observability sidecar to scrape (Task 5 wires Telegram alerts off # this metric). metrics listener_wrappers { layer4 { @turns tls sni {{TURNS_SUBDOMAIN}}.{{PARTNER_DOMAIN}} 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 } } # Global log: emit Caddy internals (health checker, admin API) as JSON. # HTTP access logs are configured per-site below so we can apply field # filters there without touching the admin-api logger. log { output stdout 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]} {{AWG_MOTHERLY_IP}}:8907 xray-client:3080 {{HY2_FALLBACK_HOST}}:{{HY2_FALLBACK_PORT}} 127.0.0.1:{{NAIVE_SOCKS_PORT}} { lb_policy first lb_retries 2 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 {{PARTNER_DOMAIN}} header_up X-Forwarded-Proto https header_up Host oxpulse.chat header_up X-Geo-Country {vars.maxmind_country_code} # Phase 5.8 Task 4: propagate selected upstream as X-Channel-Tag header. # Backend uses this for request attribution; Prometheus uses it as a # label on caddy_reverse_proxy_upstreams_healthy{upstream=...} metrics. header_up X-Channel-Tag {upstream_hostport} } } (tunnel_upstream_default) { reverse_proxy {{AWG_MOTHERLY_IP}}:8907 xray-client:3080 {{HY2_FALLBACK_HOST}}:{{HY2_FALLBACK_PORT}} 127.0.0.1:{{NAIVE_SOCKS_PORT}} { lb_policy first lb_retries 2 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 {{PARTNER_DOMAIN}} header_up X-Forwarded-Proto https header_up Host oxpulse.chat header_up X-Geo-Country {vars.maxmind_country_code} # Phase 5.8 Task 4: propagate selected upstream as X-Channel-Tag header. # Backend uses this for request attribution; Prometheus uses it as a # label on caddy_reverse_proxy_upstreams_healthy{upstream=...} metrics. header_up X-Channel-Tag {upstream_hostport} } } {{PARTNER_DOMAIN}} { 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 hub (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 } # Structured JSON access log for user-facing tunnel traffic. # Emits to stdout (collected by docker json-file driver → /var/lib/docker/containers/*-json.log). # Sensitive fields deleted: Authorization header, Cookie header. # Client IPs masked: /16 IPv4 + /32 IPv6 — anti-censorship edges on partner boxes in # adversarial jurisdictions; raw visitor IPs must not persist on the box while still # retaining geo-class forensics value. # Query strings stripped from logged URIs: ?token=... and similar never persist on disk. log { output stdout format filter { wrap json fields { request>headers>Authorization delete request>headers>Cookie delete request>remote_ip ip_mask 16 32 request>client_ip ip_mask 16 32 request>uri regexp "^([^?]*)\\?.*$" "$1" } } level INFO } # Gateway-error fallback page. # Rendered entirely inline — no external assets, no redirects — # because central is unreachable when this fires. # Status code is preserved (502/503/504). # Exponential backoff retry: 4s → 8s → 16s → 32s → cap 60s (sessionStorage counter). # No meta-refresh — it cannot back off and would undercut the JS backoff. # Same-origin retry only — do NOT redirect to api.oxpulse.chat (DPI risk). handle_errors 502 503 504 { header Content-Type "text/html; charset=utf-8" respond `
Связь с сервером временно прервана.
Страница повторит попытку автоматически.
Connection to the server was interrupted. Retrying automatically.