The edge had no limit_req/limit_conn at all, so a single client could flood the Next.js backend and the Nitro client with unbounded parallel requests. Traefik's logs already showed this: bursts of gamedata icon requests answered with 429. Add limit_req (30r/s, burst 60, nodelay) and limit_conn (30) zones keyed on the real client IP, applied at server scope so both cached assets and proxied API routes share one budget. The burst is deliberately generous because the Nitro client fetches gamedata and icons in bursts when loading a room.
472 lines
19 KiB
Plaintext
472 lines
19 KiB
Plaintext
# ─── EpicNabbo CMS — nginx site config ───
|
|
# Source of truth: deployment/proxy/nginx-cms.conf in the EpicNext-Cms repo.
|
|
# Installed at /etc/nginx/sites-available/cms.conf by scripts/nginx-sync.sh.
|
|
#
|
|
# Ingeladen binnen http{} uit /etc/nginx/sites-enabled/*.conf.
|
|
#
|
|
# PRINCIPE — één eigenaar per URL-klasse:
|
|
# * Alleen nginx (dit bestand) mag Cache-Control toevoegen voor routes die
|
|
# een publieke, gedeelde cache toestaan.
|
|
# * Alles wat de app zelf (src/proxy.ts) als no-store stuurt, blijft no-store.
|
|
# * Er is GEEN byte-cache meer (geen proxy_cache_*): de app deed ooit zelf
|
|
# al single-flight/stale-while-revalidate in src/lib/cache.ts. Daarmee is
|
|
# "dubbele cache" (nginx HIT naast de app) structureel onmogelijk.
|
|
# * De headers die hieronder staan zijn de enige Cache-Control die een
|
|
# client/CDN te zien krijgt; er wordt nooit een tweede toegevoegd.
|
|
|
|
# ─── Maps (moeten op http level staan) ───
|
|
|
|
map $request_method $cors_headers {
|
|
OPTIONS 1;
|
|
default 0;
|
|
}
|
|
|
|
map $http_upgrade $connection_upgrade {
|
|
default upgrade;
|
|
'' close;
|
|
}
|
|
|
|
# ─── Cachebeleid: één plek die beslist of een antwoord gedeeld mag worden ───
|
|
#
|
|
# Waarom op nginx: Next.js overschrijft `Cache-Control` op dynamische route
|
|
# handlers (next/dist/server/send-response.js weigert een al aanwezige header
|
|
# te overschrijven) en src/proxy.ts zet die paden bovendien op no-store. Deze
|
|
# maps nemen de publieke beslissing daarom expliciet over van de app, zodat
|
|
# browser + CDN daadwerkelijk cachen — met één enkele header.
|
|
|
|
# Nooit als "publiek" aankondigen als er een sessie aan hangt. NextAuth v5
|
|
# zet `__Secure-authjs.session-token` (en `authjs.*` zonder prefix); de
|
|
# Nitro-client gebruikt een eigen cookie. Elke cookie waarvan de naam op
|
|
# session-token eindigt of met authjs. begint telt als "ingelogd", plus elk
|
|
# Authorization-header. Zo kan een persoonlijke variant nooit publiek worden.
|
|
map $http_cookie $cms_sess_cookie {
|
|
default 0;
|
|
"~*session-token=" 1;
|
|
"~*authjs\." 1;
|
|
}
|
|
|
|
map $http_authorization $cms_authz_header {
|
|
default 1;
|
|
"" 0;
|
|
}
|
|
|
|
# "1" zodra er ook maar één auth-signaal aanwezig is.
|
|
map "$cms_sess_cookie$cms_authz_header" $cms_skip_cache {
|
|
default 1;
|
|
"~^00$" 0;
|
|
}
|
|
|
|
# Cacheklasse per endpoint. De TTL's komen overeen met wat de app zelf al
|
|
# aangeeft (publicCacheControl in src/lib/api.ts) zodat de edge niets
|
|
# verscherper maakt dan de applicatie toestaat. Klasse 0 = no-store.
|
|
map $uri $cms_cc_class {
|
|
default 0;
|
|
# online count wordt door elke pagina en de SSE-stream gepolld
|
|
~^/api/online(/count)?$ 1;
|
|
# snel verouderende, maar publieke lijsten
|
|
~^/api/(photos|leaderboard|radio/current-dj|radio/points/leaderboard)$ 2;
|
|
# stabiele catalogus- en rosterdata
|
|
~^/api/(staff|teams|guilds|shop|values)(/categories|/[0-9]+)?$ 3;
|
|
}
|
|
|
|
# Eén bron van waarheid: klasse + al dan niet ingelogd. De `|`-scheiding is
|
|
# nginx' string-samenvoeging; `~^1\|0` leest "klasse 1 en niet ingelogd".
|
|
map "$cms_cc_class|$cms_skip_cache" $cms_public_cc {
|
|
default "private, no-cache, no-store, max-age=0, must-revalidate";
|
|
"~^1\|0" "public, max-age=10, s-maxage=10, stale-while-revalidate=30";
|
|
"~^2\|0" "public, max-age=60, s-maxage=60, stale-while-revalidate=180";
|
|
"~^3\|0" "public, max-age=300, s-maxage=300, stale-while-revalidate=600";
|
|
}
|
|
|
|
# ─── Mime fix ───
|
|
types {
|
|
application/json jsonc;
|
|
}
|
|
|
|
# ==========================================
|
|
# REDIRECT HTTP -> HTTPS (Poort 9444)
|
|
# ==========================================
|
|
server {
|
|
listen 9444 default_server;
|
|
listen [::]:9444 default_server;
|
|
server_name _;
|
|
|
|
location / {
|
|
return 301 https://$host$request_uri;
|
|
}
|
|
}
|
|
|
|
# ==========================================
|
|
# WEBSOCKET GAME SERVER (ws.epicnabbo.nl)
|
|
# ==========================================
|
|
server {
|
|
listen 9443 ssl;
|
|
listen [::]:9443 ssl;
|
|
server_name ws.epicnabbo.nl;
|
|
|
|
ssl_certificate /etc/ssl/epicnabbo-backend.pem;
|
|
ssl_certificate_key /etc/ssl/epicnabbo-backend.key;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
ssl_prefer_server_ciphers off;
|
|
|
|
# ─── Trusted Edge Gate ───
|
|
# Real Cloudflare edges and Traefik are the only peers trusted to supply a
|
|
# CF-Connecting-IP (see cloudflare-ips.conf). Any other peer that does is
|
|
# spoofing and is rejected before it reaches the CMS. Legitimate direct
|
|
# visitors (game client, :9443) never carry that header and pass through
|
|
# with their real peer address.
|
|
if ($cms_disallow_forwarding) {
|
|
return 403;
|
|
}
|
|
|
|
location /health {
|
|
access_log off;
|
|
return 200 "OK";
|
|
add_header Content-Type text/plain;
|
|
}
|
|
|
|
location / {
|
|
proxy_pass http://127.0.0.1:2096;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Upgrade $http_upgrade;
|
|
proxy_set_header Connection "upgrade";
|
|
proxy_set_header Host $host;
|
|
|
|
# Echt client IP (trusted peers via real_ip, directe clients = eigen peer)
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
|
|
proxy_read_timeout 86400s;
|
|
proxy_send_timeout 86400s;
|
|
}
|
|
}
|
|
|
|
# ==========================================
|
|
# MAIN HTTPS SERVER (Poort 9443)
|
|
# ==========================================
|
|
server {
|
|
listen 9443 ssl reuseport default_server;
|
|
listen [::]:9443 ssl reuseport default_server;
|
|
listen 9443 quic reuseport;
|
|
listen [::]:9443 quic reuseport;
|
|
http2 on;
|
|
|
|
server_name epicnabbo.nl www.epicnabbo.nl;
|
|
|
|
ssl_certificate /etc/ssl/epicnabbo-backend.pem;
|
|
ssl_certificate_key /etc/ssl/epicnabbo-backend.key;
|
|
ssl_protocols TLSv1.2 TLSv1.3;
|
|
ssl_prefer_server_ciphers off;
|
|
ssl_early_data on;
|
|
add_header Alt-Svc 'h3=":9443"; ma=86400' always;
|
|
|
|
index index.html;
|
|
|
|
# ─── Security Headers ───
|
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
|
add_header X-Content-Type-Options "nosniff" always;
|
|
add_header X-XSS-Protection "1; mode=block" always;
|
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
|
|
|
|
# ─── Trusted Edge Gate ───
|
|
# Real Cloudflare edges / Traefik are the only peers allowed to supply a
|
|
# CF-Connecting-IP (see cloudflare-ips.conf). Any other peer presenting one
|
|
# is spoofing (direct :9443 traffic), and is rejected before it reaches the
|
|
# CMS. Legitimate direct visitors never carry that header and pass through
|
|
# with their real peer address.
|
|
if ($cms_disallow_forwarding) {
|
|
return 403;
|
|
}
|
|
|
|
# ─── Client Limits & Timeouts ───
|
|
client_max_body_size 20m;
|
|
client_body_buffer_size 16k;
|
|
client_header_buffer_size 1k;
|
|
large_client_header_buffers 4 8k;
|
|
client_body_timeout 12s;
|
|
client_header_timeout 12s;
|
|
keepalive_timeout 30s;
|
|
send_timeout 10s;
|
|
|
|
# Abuse limits. Applied per server, not per location, so cached assets and
|
|
# proxied API routes are all covered by the same budget. nodelay keeps the
|
|
# 60-request burst responsive: allowed requests pass immediately, only the
|
|
# excess is rejected with 503 instead of being queued.
|
|
limit_req zone=cms_req_per_ip burst=60 nodelay;
|
|
limit_conn cms_conn_per_ip 30;
|
|
|
|
# Traefik health-check route herstellen
|
|
location = /health {
|
|
access_log off;
|
|
return 200 "OK";
|
|
add_header Content-Type text/plain;
|
|
}
|
|
|
|
# ─── Statische Bestanden & Assets ───
|
|
location ^~ /client/ {
|
|
alias /var/www/Octane/dist/;
|
|
try_files $uri $uri/ =404;
|
|
|
|
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
|
|
add_header Cache-Control "public, max-age=2592000";
|
|
access_log off;
|
|
add_header Cache-Tag "cms-client";
|
|
add_header Access-Control-Allow-Origin $http_origin always;
|
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
|
}
|
|
}
|
|
|
|
location ^~ /nitro-client/ {
|
|
alias /var/www/Octane/dist/;
|
|
try_files $uri $uri/ =404;
|
|
|
|
location ~* \.(js|json|css|html|wasm|ttf|woff|woff2|gif|webp|png|jpg|jpeg|svg|dat)$ {
|
|
add_header Cache-Control "public, max-age=2592000";
|
|
access_log off;
|
|
add_header Cache-Tag "cms-client";
|
|
add_header Access-Control-Allow-Origin $http_origin always;
|
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
|
if ($cors_headers) {
|
|
add_header Access-Control-Max-Age 1728000;
|
|
add_header Content-Type "text/plain; charset=utf-8";
|
|
return 204;
|
|
}
|
|
}
|
|
}
|
|
|
|
location = /gamedata { return 301 /gamedata/config/; }
|
|
location = /gamedata/ { return 301 /gamedata/config/; }
|
|
|
|
# ─── Gamedata: drie cache-klassen, want niet alles onder /gamedata/ is
|
|
# even veranderlijk.
|
|
#
|
|
# Dit pad had één regel voor de hele boom: `max-age=604800` (7 dagen). De
|
|
# Habbo-client haalt FurnitureData.json hier op, dus na een import bleef het
|
|
# client-side dagenlang de oude versie tonen — een nieuw geïmporteerd
|
|
# meubel was gewoon onzichtbaar. De purge van de `cms-gamedata`-tag
|
|
# (edge-cache.ts) raakt alleen de Cloudflare-kopie, niet de browser.
|
|
#
|
|
# 1. config/ — FurnitureData.json + de vertaalde bestanden. Verandert
|
|
# bij elke import. Kort, en `must-revalidate` sluit de
|
|
# "stuur uit de cache"-route uit zodat de client na de
|
|
# TTL een 304 vraagt in plaats van de oude body te hergebruiken.
|
|
# 2. bundled/ — nitro-bundles per sprite. De inhoud kan veranderen zonder
|
|
# dat de bestandsnaam verandert (schalen, repareren), dus
|
|
# ook revalideren, maar minder vaak: ze worden veel vaker
|
|
# opgehaald dan ze worden geschreven.
|
|
# 3. alles wat overblijft (c_images, album*, clothes, …) — content-addressed
|
|
# of per item uniek, nooit herschreven onder dezelfde naam. Blijft lang.
|
|
location ^~ /gamedata/config/ {
|
|
alias /var/www/Gamedata/config/;
|
|
add_header Cache-Control "public, max-age=300, must-revalidate";
|
|
access_log off;
|
|
add_header Cache-Tag "cms-gamedata";
|
|
|
|
add_header Access-Control-Allow-Origin $http_origin always;
|
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
|
if ($cors_headers) {
|
|
add_header Access-Control-Max-Age 1728000;
|
|
add_header Content-Type "text/plain; charset=utf-8";
|
|
return 204;
|
|
}
|
|
}
|
|
|
|
location ^~ /gamedata/bundled/ {
|
|
alias /var/www/Gamedata/bundled/;
|
|
add_header Cache-Control "public, max-age=3600, must-revalidate";
|
|
access_log off;
|
|
add_header Cache-Tag "cms-gamedata";
|
|
|
|
add_header Access-Control-Allow-Origin $http_origin always;
|
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
|
if ($cors_headers) {
|
|
add_header Access-Control-Max-Age 1728000;
|
|
add_header Content-Type "text/plain; charset=utf-8";
|
|
return 204;
|
|
}
|
|
}
|
|
|
|
location /gamedata/ {
|
|
alias /var/www/Gamedata/;
|
|
add_header Cache-Control "public, max-age=604800";
|
|
access_log off;
|
|
add_header Cache-Tag "cms-gamedata";
|
|
|
|
add_header Access-Control-Allow-Origin $http_origin always;
|
|
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
|
|
if ($cors_headers) {
|
|
add_header Access-Control-Max-Age 1728000;
|
|
add_header Content-Type "text/plain; charset=utf-8";
|
|
return 204;
|
|
}
|
|
}
|
|
|
|
location /camera/ {
|
|
alias /var/www/Camera/;
|
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
add_header Cache-Tag "cms-camera";
|
|
}
|
|
|
|
location = /favicon.ico { expires 1y; access_log off; log_not_found off; try_files $uri =404; }
|
|
location = /robots.txt { expires 1d; access_log off; log_not_found off; try_files $uri =404; }
|
|
|
|
# ─── Static Next.js Assets ───
|
|
location /_next/static/ {
|
|
proxy_pass http://cms_app;
|
|
proxy_set_header Connection "";
|
|
proxy_http_version 1.1;
|
|
# Enige eigenaar: een enkele immutable header; de app-header wordt
|
|
# altijd verwisseld zodat er nooit twee tegensprekende ontstaan
|
|
# (ook op 404's).
|
|
proxy_hide_header Cache-Control;
|
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
|
}
|
|
|
|
location /_next/data/ {
|
|
proxy_pass http://cms_app;
|
|
proxy_set_header Connection "";
|
|
proxy_http_version 1.1;
|
|
proxy_hide_header Cache-Control;
|
|
add_header Cache-Control "public, max-age=0, must-revalidate";
|
|
}
|
|
|
|
# ─── API Proxy's ───
|
|
location /api/auth/ {
|
|
proxy_pass http://cms_app;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header Connection "";
|
|
# Auth is per sessie: nooit cachen, en de app-header onderdrukken zodat
|
|
# er precies één Cache-Control overblijft.
|
|
proxy_hide_header Cache-Control;
|
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
|
}
|
|
|
|
# ─── Publieke API: één gedeelde Cache-Control, geen byte-cache ───
|
|
#
|
|
# nginx is de enige plek die hier cacheverantwoordelijkheid heeft: de app
|
|
# zet dit op no-store (Next-force) en Traefik + Cloudflare voegen niets
|
|
# toe, dus er is geen tweede laag die met deze header concurreert. De
|
|
# body zelf wordt NIET tussen-gecachet (geen proxy_cache_*): stampede-
|
|
# bescherming doet src/lib/cache.ts (in-process single-flight + Redis).
|
|
# De header zet de TTL voor browser + CDN (10/60/300s + SWR).
|
|
location ~ ^/api/(?:staff|teams|guilds|photos|leaderboard|online|online/count|shop|shop/categories|values|values/categories|values/[0-9]+|radio/current-dj|radio/points/leaderboard)$ {
|
|
proxy_pass http://cms_app;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header Connection "";
|
|
|
|
proxy_hide_header Cache-Control;
|
|
add_header Cache-Control $cms_public_cc;
|
|
# Cloudflare cache-tag: laat de edge precies deze publieke API's cachen
|
|
# (via een cache-rule) en purge alleen deze tag na een CMS-wijziging.
|
|
add_header Cache-Tag "cms-public";
|
|
}
|
|
|
|
# ─── SSE / lange streams ───
|
|
#
|
|
# Drie dingen moeten kloppen of een EventSource-stroom knapt af:
|
|
# 1. proxy_buffering off — anders houdt nginx het antwoord vast tot de
|
|
# verbinding sluit, dus de browser ziet de stream pas als een blok.
|
|
# 2. proxy_read_timeout — de default van 60s beëindigt een stroom die
|
|
# tijdens een batch-job even stilvalt, waarna de client reconnectt en
|
|
# opnieuw 504 krijgt: een reconnect-loop die de app juist belast.
|
|
# 3. send_timeout — de server-level 10s meet de pauze tussen twee writes.
|
|
# Een stream die 25s pingt, of een batch die minuten niets doet, wordt
|
|
# daar dus losgekapt. Daarom hier een eigen, ruime waarde.
|
|
location ~ ^/api/(?:online/count/stream|radio/stream|admin/import/.*|admin/studio/nitro-cleanup.*)$ {
|
|
proxy_pass http://cms_app;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header Connection "";
|
|
|
|
proxy_buffering off;
|
|
gzip off;
|
|
chunked_transfer_encoding on;
|
|
proxy_read_timeout 3600s;
|
|
proxy_send_timeout 3600s;
|
|
send_timeout 3600s;
|
|
|
|
proxy_hide_header Cache-Control;
|
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
|
# Vrijwel elke SSE-route miste dit; zonder de header blijft nginx
|
|
# alsnog bufferen, ook met proxy_buffering off.
|
|
add_header X-Accel-Buffering "no" always;
|
|
}
|
|
|
|
location /api/badges/custom {
|
|
proxy_pass http://127.0.0.1:2096;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header Connection "";
|
|
# De emulator levert zelf geen Cache-Control; zonder proxy_hide_header
|
|
# zou de app-header hier een tweede keer worden toegevoegd.
|
|
proxy_hide_header Cache-Control;
|
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
|
}
|
|
|
|
# ─── Imaging & media: de app levert de eigen Cache-Control ───
|
|
# De catch-all hieronder forceert no-store; avatars en uploads zijn
|
|
# onveranderlijk per sleutel en moeten door de browser gecachet worden.
|
|
location /api/imaging/ {
|
|
proxy_pass http://cms_app;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header Connection "";
|
|
proxy_read_timeout 30s;
|
|
}
|
|
|
|
location /api/media/ {
|
|
proxy_pass http://cms_app;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header Connection "";
|
|
}
|
|
|
|
# ─── Hoofd-routering ───
|
|
location / {
|
|
proxy_pass http://cms_app;
|
|
proxy_http_version 1.1;
|
|
proxy_set_header Host $host;
|
|
proxy_set_header X-Real-IP $remote_addr;
|
|
proxy_set_header X-Forwarded-For $remote_addr;
|
|
proxy_set_header X-Forwarded-Proto $scheme;
|
|
proxy_set_header CF-Connecting-IP "";
|
|
proxy_set_header Connection "";
|
|
# De HTML is per sessie: `auth()` in de homepage-layout stuurt
|
|
# ingelogde bezoekers door naar /me, en de CSP-nonce is per request.
|
|
# Dus nooit cachen — maar wel als één enkele, expliciete header.
|
|
# Zonder proxy_hide_header voeg je hier een tweede, tegensprekende
|
|
# Cache-Control toe aan degene die Next al meestuurt.
|
|
proxy_hide_header Cache-Control;
|
|
add_header Cache-Control "private, no-cache, no-store, max-age=0, must-revalidate" always;
|
|
}
|
|
} |