290 lines
17 KiB
Text
290 lines
17 KiB
Text
# Morphit web frontend — nginx server block.
|
||
#
|
||
# This serves the SvelteKit-built static files from apps/web/build/
|
||
# AND reverse-proxies the API paths to the loopback services, so the
|
||
# whole instance lives under ONE hostname — the recommended single-host
|
||
# ("colocated") topology (docs/RUN-A-MORPHIT-NODE.md §9):
|
||
# /v1/* -> indexer (127.0.0.1:8081) read-only API + SSE streams
|
||
# /rss/* -> indexer (127.0.0.1:8081) orderbook RSS/Atom feeds
|
||
# /relay/* -> relay (127.0.0.1:8080) account-creation funding
|
||
# The frontend ships built for same-origin (MORPHIT_INDEXER_ORIGIN='',
|
||
# MORPHIT_RELAY_ORIGIN='/relay'), so it calls these as same-origin
|
||
# paths — you do NOT need separate indexer.* / relay.* DNS records or
|
||
# server blocks. (A split-subdomain topology is possible but
|
||
# discouraged; see the header of ops/nginx/indexer.conf for the extra
|
||
# steps it requires.)
|
||
#
|
||
# What this config buys you:
|
||
# 1. Strict Content-Security-Policy. Self-only — no Google
|
||
# Fonts, no CDN, no analytics, no third-party anything.
|
||
# Per Morphit's privacy-first philosophy. Defense-in-depth
|
||
# against any future XSS regression.
|
||
# 2. Other security headers (HSTS, X-Frame-Options, etc.)
|
||
# consistent with relay.conf and indexer.conf.
|
||
# 3. Long-cache for hashed bundle files (immutable), no-cache
|
||
# for HTML (so deploys are seen immediately).
|
||
# 4. /404 / 403 / 500 pages map to the same SvelteKit index so
|
||
# client-side routing sees the original URL.
|
||
|
||
server {
|
||
listen 443 ssl http2;
|
||
listen [::]:443 ssl http2;
|
||
server_name morphit.io;
|
||
|
||
root /var/www/morphit-frontend;
|
||
index index.html;
|
||
|
||
# ─── TLS ──────────────────────────────────────────────────
|
||
ssl_certificate /etc/letsencrypt/live/morphit.io/fullchain.pem;
|
||
ssl_certificate_key /etc/letsencrypt/live/morphit.io/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
ssl_prefer_server_ciphers off;
|
||
ssl_session_cache shared:SSL:10m;
|
||
ssl_session_timeout 1d;
|
||
ssl_session_tickets off;
|
||
|
||
# ─── Security headers ─────────────────────────────────────
|
||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "no-referrer" always;
|
||
add_header X-Frame-Options "DENY" always;
|
||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
|
||
|
||
# ─── Content-Security-Policy ──────────────────────────────
|
||
# Strict default — nothing loads from anywhere except this
|
||
# origin. In the single-host topology the indexer + relay are
|
||
# reached via same-origin paths (/v1/, /rss/, /relay/), so they
|
||
# need NO connect-src entry — 'self' already covers them.
|
||
# connect-src only needs the cross-origin endpoints the BROWSER
|
||
# talks to directly:
|
||
# - The Blurt RPC nodes (the six DEFAULT_RPC_ENDPOINTS from
|
||
# apps/web/src/lib/net/config.ts are pre-listed below; edit
|
||
# to match whatever RPC pool you ship).
|
||
# - Any altcoin price feed / block-explorer endpoints the
|
||
# browser fetches directly, only if you enable them.
|
||
# - Split-subdomain topology ONLY: add your indexer/relay
|
||
# subdomains here (and see ops/nginx/indexer.conf first).
|
||
#
|
||
# script-src needs more than 'self' for two reasons:
|
||
# - 'unsafe-inline': this is a static build with no meta CSP,
|
||
# so SvelteKit's inline hydration bootstrap (and the ?lang=
|
||
# preflight hint in app.html) run without per-page hashes.
|
||
# The frontend renders no user-supplied HTML, so the
|
||
# inline-script surface is minimal.
|
||
# - 'wasm-unsafe-eval' (+ 'unsafe-eval'): the keystore's
|
||
# argon2 KDF and signing run as in-browser WebAssembly,
|
||
# which CSP blocks without one of these. Non-optional —
|
||
# drop them and login / keystore unlock breaks. You may
|
||
# narrow to just 'wasm-unsafe-eval' if you confirm no
|
||
# dependency uses JS eval/Function.
|
||
# style-src 'unsafe-inline' — SvelteKit emits inline <style>
|
||
# for component-scoped styles; standard SvelteKit requirement.
|
||
# worker-src 'self' blob: — the altcha proof-of-work worker is
|
||
# built from an in-memory blob: URL; without blob: the anti-bot
|
||
# challenge on registration fails to run.
|
||
#
|
||
# frame-ancestors 'none' duplicates X-Frame-Options DENY for
|
||
# browsers that prefer the modern CSP directive.
|
||
#
|
||
# If you serve over Tor (.onion mirror), that mirror needs
|
||
# its own server block; CSP applies per-host.
|
||
add_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:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
|
||
|
||
# ─── Compression (cp165 byte budget) ──────────────────────
|
||
# The SvelteKit build emits pre-compressed `.br` and `.gz`
|
||
# alongside every `.js` / `.css` / `.svg` / `.html` file.
|
||
# WITHOUT these directives, nginx happily serves the raw
|
||
# uncompressed file and ignores the pre-compressed siblings —
|
||
# so every page load ships 4-6× more bytes than necessary.
|
||
#
|
||
# `gzip_static on` makes nginx prefer `foo.js.gz` over
|
||
# `foo.js` when the client's `Accept-Encoding` includes gzip.
|
||
# Zero CPU cost — the compression happened once at build time.
|
||
#
|
||
# `brotli_static on` requires the ngx_brotli module
|
||
# (apt: nginx-module-brotli on most distros, or build from
|
||
# https://github.com/google/ngx_brotli). Brotli compresses
|
||
# ~15-20% smaller than gzip on JS/CSS. If you don't have the
|
||
# module, comment out the `brotli_static` line — the
|
||
# `gzip_static` line keeps you in good shape on its own.
|
||
gzip_static on;
|
||
brotli_static on;
|
||
|
||
# ─── Caching strategy ─────────────────────────────────────
|
||
# SvelteKit emits hashed bundle files under /_app/immutable/.
|
||
# These names change on every deploy, so they're safe to
|
||
# cache forever. Everything else (HTML, manifests) gets a
|
||
# short cache so deploys are seen quickly.
|
||
#
|
||
# NGINX FOOTGUN: when a `location` block has any `add_header`,
|
||
# nginx completely drops inheritance of the server-level
|
||
# `add_header` directives. We re-add EVERY security header
|
||
# in every location that uses add_header at all, otherwise
|
||
# those responses ship without HSTS/CSP/X-Frame/etc.
|
||
location /_app/immutable/ {
|
||
expires 1y;
|
||
access_log off;
|
||
add_header Cache-Control "public, immutable, max-age=31536000";
|
||
# Full security-header re-emission (server-block inheritance lost).
|
||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "no-referrer" always;
|
||
add_header X-Frame-Options "DENY" always;
|
||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
|
||
add_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:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
|
||
}
|
||
|
||
# Service worker — must NOT be cached or it pins old clients.
|
||
location = /service-worker.js {
|
||
add_header Cache-Control "no-cache" always;
|
||
# Full security-header re-emission (server-block inheritance lost).
|
||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "no-referrer" always;
|
||
add_header X-Frame-Options "DENY" always;
|
||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
|
||
add_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:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
|
||
}
|
||
|
||
# verify.json — this build's version + asset-hash manifest. Must NOT
|
||
# be cached: the app's update-version poll and the "About this
|
||
# instance" auto-verify both read it, and a stale copy makes the app
|
||
# think it is already up to date (so the update snackbar never shows)
|
||
# and makes auto-verify compare against the wrong manifest. Same
|
||
# add_header inheritance footgun as above — re-emit the security
|
||
# headers. (If you gate the beta site behind HTTP Basic Auth, add
|
||
# `auth_basic off;` in THIS block so the poll + auto-verify can read
|
||
# verify.json without a credential prompt.)
|
||
location = /verify.json {
|
||
add_header Cache-Control "no-cache" always;
|
||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "no-referrer" always;
|
||
add_header X-Frame-Options "DENY" always;
|
||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
|
||
add_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:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
|
||
}
|
||
# Defense against attackers probing for .git, .env, etc.
|
||
# Even though apps/web/build/ shouldn't contain any of these,
|
||
# a misconfiguration shouldn't expose them. Returns 404
|
||
# (not 403) to give no signal about whether the path exists.
|
||
location ~ /\.(?!well-known) {
|
||
return 404;
|
||
}
|
||
location ~* \.(env|git|sql|bak|old|orig|swp|tmp)$ {
|
||
return 404;
|
||
}
|
||
|
||
# ─── API reverse-proxy (single-host topology) ────────────
|
||
# The indexer + relay run on loopback; these blocks expose them
|
||
# under the SAME hostname as the SPA, so the frontend's same-origin
|
||
# /v1/, /rss/, /relay/ calls reach them. If you run the indexer or
|
||
# relay on a different machine, replace 127.0.0.1 with its address.
|
||
# (Longest-prefix match means these win over the `location /`
|
||
# fallback below regardless of ordering.) These blocks add no
|
||
# add_header, so they inherit the server-level security headers.
|
||
|
||
# Relay — fund-spending endpoints. The frontend calls same-origin
|
||
# /relay/v1/* (e.g. /relay/v1/account/create); the rewrite strips
|
||
# the /relay prefix so the relay process sees /v1/account/create.
|
||
location /relay/ {
|
||
rewrite ^/relay/(.*)$ /$1 break;
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Connection "";
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
client_max_body_size 64k;
|
||
}
|
||
|
||
# Indexer — read-only public API. Serves /v1/* as-is, so forward
|
||
# unchanged (no rewrite). /relay/v1/* is caught by the longer
|
||
# /relay/ prefix above, so it never falls into this block.
|
||
location /v1/ {
|
||
proxy_pass http://127.0.0.1:8081;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Connection "";
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
# Operator-only per-source price-feed health (morphit-ops health)
|
||
# is gated on X-Morphit-Local-Health, which the local ops-cli
|
||
# sends 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 sends a `:keepalive` comment every 25s; proxy buffering
|
||
# MUST be off or events arrive in laggy batches instead of live.
|
||
# This regex matches the .../stream paths ahead of the /v1/ prefix.
|
||
location ~ ^/v1/.*/stream$ {
|
||
proxy_pass http://127.0.0.1:8081;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Connection "";
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_buffering off;
|
||
proxy_read_timeout 1h;
|
||
}
|
||
|
||
# RSS/Atom orderbook feeds — same loopback indexer, under /rss/*
|
||
# (footer pill + <head> links to /rss/orderbook.xml, plus the
|
||
# by-asset / by-account feeds). Forward unchanged, like /v1/.
|
||
location /rss/ {
|
||
proxy_pass http://127.0.0.1:8081;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Connection "";
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
}
|
||
|
||
# MCP — read-only AI-agent orderbook surface (/mcp), the loopback
|
||
# MCP HTTP transport on 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.
|
||
location /mcp {
|
||
proxy_pass http://127.0.0.1:8124;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Connection "";
|
||
proxy_set_header Host 127.0.0.1:8124;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
client_max_body_size 256k;
|
||
proxy_read_timeout 120s;
|
||
}
|
||
|
||
# SvelteKit SPA fallback — any unknown path returns the
|
||
# index so client-side routing handles it.
|
||
location / {
|
||
try_files $uri $uri.html $uri/index.html /index.html;
|
||
add_header Cache-Control "no-cache" always;
|
||
# Full security-header re-emission (server-block inheritance lost).
|
||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||
add_header X-Content-Type-Options "nosniff" always;
|
||
add_header Referrer-Policy "no-referrer" always;
|
||
add_header X-Frame-Options "DENY" always;
|
||
add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), interest-cohort=()" always;
|
||
add_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:; font-src 'self'; connect-src 'self' https://rpc.drakernoise.com https://blurtrpc.dagobert.uk https://rpc.blurt.blog https://rpc.beblurt.com https://rpc.blurt.one https://blurt-rpc.saboin.com; media-src 'none'; object-src 'none'; child-src 'none'; frame-src 'none'; worker-src 'self' blob:; manifest-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'self'" always;
|
||
}
|
||
|
||
# ─── Body cap at the edge ─────────────────────────────────
|
||
# Static files only — no POST surface here.
|
||
client_max_body_size 1k;
|
||
}
|
||
|
||
# HTTP → HTTPS redirect.
|
||
server {
|
||
listen 80;
|
||
listen [::]:80;
|
||
server_name morphit.io;
|
||
location / {
|
||
return 301 https://$host$request_uri;
|
||
}
|
||
}
|