morphit/ops/bunkerweb/frontend/nginx.conf

189 lines
9.2 KiB
Nginx Configuration File

# Morphit frontend nginx — serves the built SvelteKit static site and
# reverse-proxies the API paths to the relay + indexer running on the
# HOST. Runs BEHIND BunkerWeb inside the bunkerweb_net Docker network,
# on plain :80.
#
# BunkerWeb (see ../docker-compose.yml + ../bunkerweb.env.example) is
# the public entry point: it terminates TLS, runs the OWASP-CRS WAF,
# rate-limits, sets the real client IP in X-Forwarded-For, and adds the
# security headers (HSTS/CSP/X-Frame-Options/...). It then proxies
# EVERY path to this container (REVERSE_PROXY_HOST=http://frontend:80).
# So this config intentionally does none of that — no TLS, no security
# headers. Its only jobs are static-file serving + path routing.
#
# This mirrors the routing in ops/nginx/web.conf (the bare-metal
# single-host vhost) MINUS the TLS + security-header blocks that
# BunkerWeb owns here. Keep the two in sync when either changes.
#
# UPSTREAMS — host.docker.internal resolves to the host via
# `extra_hosts: ["host.docker.internal:host-gateway"]` in
# docker-compose.yml, so the relay + indexer stay on the host (NOT in a
# container). Ports are the morphit defaults: relay 8080, indexer 8081
# (MORPHIT_RELAY_LISTEN_PORT / MORPHIT_INDEXER_LISTEN_PORT). Change
# them here if you bind the services to non-default ports.
#
# IMPORTANT — the relay + indexer must listen on an address the
# Docker bridge can reach (NOT 127.0.0.1 only): bind them to the
# host-gateway address or 0.0.0.0 and firewall those ports so only
# the bridge can reach them (the UFW rules in
# ops/ansible/roles/hardening do this). A loopback-only bind is
# unreachable from this container and the proxy_pass returns 502.
#
# REAL CLIENT IP — BunkerWeb already put the real client as the LEFTMOST
# X-Forwarded-For entry. These blocks APPEND to the chain via
# $proxy_add_x_forwarded_for (so XFF becomes "client, bunkerweb-ip"),
# which keeps the client leftmost — the relay reads XFF[0]. Do NOT
# prepend or set X-Real-IP to $remote_addr here: $remote_addr is
# BunkerWeb's container IP, not the client, and overwriting would break
# the per-IP signup rate limiting. The relay's
# MORPHIT_RELAY_TRUSTED_PROXY_IPS must cover this Docker network's CIDR
# (172.20.0.0/16) — that single CIDR covers BOTH the BunkerWeb and the
# frontend containers, since the relay sees this container as the
# immediate peer.
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Tell browsers text responses are UTF-8. Without this, a plain-text
# file like /canary.txt (which contains em-dashes, bullets, box-
# drawing) is served with no charset, so browsers fall back to
# windows-1252 and render mojibake (─ → "─", — → "—"). nginx's
# default charset_types already covers text/plain, text/html, etc.,
# so this one line fixes every text asset. (cp432)
charset utf-8;
# Serve the SvelteKit build's pre-compressed .gz siblings when the
# client accepts gzip (zero CPU — the compression happened at build
# time). BunkerWeb still does the client-facing gzip/brotli at the
# edge, so this only saves the frontend->BunkerWeb hop, but it's
# free. A plain `gzip on` covers proxied JSON responses too.
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_static on;
# Never expose dotfiles / editor leftovers if one ever lands in the
# build dir. 404 (not 403) gives no signal about whether the path
# exists. Mirrors ops/nginx/web.conf.
location ~ /\.(?!well-known) {
return 404;
}
location ~* \.(env|git|sql|bak|old|orig|swp|tmp)$ {
return 404;
}
# ─── Relay — fund-spending endpoints ──────────────────────────
# The frontend calls same-origin /relay/v1/* (e.g.
# /relay/v1/account/create); strip the /relay prefix so the relay
# process sees bare /v1/* (mirrors web.conf's
# `rewrite ^/relay/(.*)$ /$1`). The longer /relay/ prefix means
# /relay/v1/* never falls into the /v1/ block below.
location /relay/ {
rewrite ^/relay/(.*)$ /$1 break;
proxy_pass http://host.docker.internal:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 64k;
}
# ─── Indexer — read-only public API (/v1/*), served as-is ─────
location /v1/ {
proxy_pass http://host.docker.internal:8081;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# Operator-only per-source price-feed health is gated on
# X-Morphit-Local-Health (sent by the local ops-cli over the
# bridge). Clear any client-supplied value so a public caller
# can never forge it and read the per-source block.
proxy_set_header X-Morphit-Local-Health "";
client_max_body_size 4k;
}
# ─── SSE live-update streams (orderbook / chat / instances) ───
# The indexer also sends `X-Accel-Buffering: no` on every stream,
# which every nginx hop (this container + BunkerWeb) honors, so
# events arrive live through both proxies. This block is the
# belt-and-suspenders version: buffering off + a long read timeout.
# A regex location beats the /v1/ prefix, so .../stream paths land
# here ahead of the plain /v1/ block above.
location ~ ^/v1/.*/stream$ {
proxy_pass http://host.docker.internal:8081;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_buffering off;
proxy_read_timeout 1h;
}
# ─── Indexer RSS/Atom orderbook feeds (/rss/*) ────────────────
location /rss/ {
proxy_pass http://host.docker.internal:8081;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
}
# ─── MCP — read-only AI-agent orderbook surface (/mcp) ────────
# The host MCP runs the HTTP transport on port 8124. It is NOT
# virtual-hosted; it uses the Host header only for DNS-rebinding
# defense, whose default allowlist includes the loopback form — so
# present `Host: 127.0.0.1:8124` upstream (forwarding $host would be
# rejected 403). Responses can be long-running for federation
# queries, hence the longer read timeout.
location /mcp {
proxy_pass http://host.docker.internal:8124;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host 127.0.0.1:8124;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
client_max_body_size 256k;
proxy_read_timeout 120s;
}
# ─── Update surface — never cache ─────────────────────────────
# /service-worker.js drives update detection: the browser refetches it
# on every update check (the app registers the worker with
# updateViaCache:'none'), and a byte-changed worker is what raises the
# in-app "Load it now" snackbar after a deploy. verify.json carries
# this build's version + asset-hash manifest, read by the app's
# update-version poll and the "About this instance" auto-verify. If
# EITHER is served stale — e.g. from BunkerWeb's own edge cache, or any
# CDN in front — the client never learns a new build is live and the
# update prompt never appears. (This is the "no update snackbar on
# mobile OR PC" bug: ops/nginx/web.conf carried this no-cache block but
# THIS file did not, and the two configs drifted out of sync.) Mark
# both no-cache so BunkerWeb and the browser always revalidate. No
# security headers here — BunkerWeb owns those at the edge; this server
# block sets none to inherit, so the add_header reset footgun that
# web.conf has to work around does not apply here.
location = /service-worker.js {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
location = /verify.json {
add_header Cache-Control "no-cache" always;
try_files $uri =404;
}
# ─── SvelteKit SPA fallback ───────────────────────────────────
# Longest-prefix match means the proxy blocks above win for their
# paths; every other request falls here and returns the SPA entry
# so client-side routing handles the URL.
location / {
try_files $uri $uri.html $uri/index.html /index.html;
}
}