# test-filius.hub.mdg-hamburg.de and filius.hub.mdg-hamburg.de → server F # # A **site snippet** for the `conf.d` of the external openresty, like the # dashboard's `proxy.conf` and Scratch's `deploy/proxy/scratch.conf`: it goes # **into** the existing `http{}` and declares nothing in the main context # except two `upstream`s and two `map`s. # # Two environments, one machine # ----------------------------- # Test and production are two Docker stacks on the same machine (the LXC # container of server F), each with its own database, its own secrets and # its own port: # # test-filius.hub.mdg-hamburg.de → :8081 (filius_test) # filius.hub.mdg-hamburg.de → :8080 (filius_production) # # Each stack has **one** entrance, the port of its F process. F delivers # everything on it: the editor's page and bundle, `/__auth`, `/svc/*`, # `/blobs`, `/healthz` and the two WebSockets `/sync` and `/sim`. This vhost # is therefore a pass-through and splits nothing up — except where the # proxy's own defaults would get in the way (WebSockets, upload size). # # The two environments are one `server` block: the `map` below picks the # stack by the name the browser asked for. Two blocks that only differ in # one line drift apart; whoever later changes a limit in one forgets the # other, and the test environment no longer tests what production runs. # # Which dashboard talks to which F: `test-dashboard` to `test-filius`, # `dashboard` to `filius` — each with the ticket secret and service key of # its own stack. A ticket from the test dashboard is refused in production # (wrong secret) and that is intended. # # ── The one line every installation touches ─────────────────────────── # # `filius-lxc.invalid` (twice, in the two `upstream`s) is the address of the # LXC container of server F, which does not exist yet. The placeholder is # chosen so that it **cannot** work: `.invalid` never resolves, and # `openresty -t` stops with "host not found in upstream". Whoever puts the # file in place without replacing it finds out before the reload, not from # a user. # # The Docker stacks have to publish the ports on an address this openresty # reaches, e.g. in the stack's compose file (F's own port stays 8080 in # both containers, `FILIUS_PORT`): # # test: ports: ["
:8081:8080"] # production: ports: ["
:8080:8080"] # # Check from the openresty: # # curl -s http://
:8081/healthz # curl -s http://
:8080/healthz # # ── What the stacks need in their environment ───────────────────────── # # `FILIUS_ORIGINS` is the origin of the page the editor runs in, and that is # this host: F refuses a WebSocket and a blob upload from any other origin. # # test: FILIUS_ORIGINS=https://test-filius.hub.mdg-hamburg.de # production: FILIUS_ORIGINS=https://filius.hub.mdg-hamburg.de # # No `FILIUS_INSECURE_COOKIE`: TLS ends here, and the browser reaches F only # via https. The cookie carries `Secure` and `SameSite=Lax`; it reaches F in # the dashboard's frame because `*.hub.mdg-hamburg.de` is the same site as # the dashboard. # # ── What must **not** stand here ────────────────────────────────────── # # No `add_header`, above all no `X-Frame-Options`: the editor lives in a # frame of the dashboard, and the header would turn it into a white box. # (An `add_header X-Frame-Options` in the `http{}` of this openresty would do # the same to this host.) # # No CORS headers. F answers CORS for `/blobs` itself, against its # `FILIUS_ORIGINS`; a second `Access-Control-Allow-Origin` from here makes # the browser refuse the response. upstream filius_test { server filius-lxc.invalid:8081; } upstream filius_production { server filius-lxc.invalid:8080; } # Which stack a host goes to. `$host` is the name without port, in lower # case. The `server_name` below lets only these two names in, so the # `default` never decides anything; it is there so that no request ends in a # `proxy_pass` to nothing. map $host $filius_upstream { default filius_production; test-filius.hub.mdg-hamburg.de filius_test; filius.hub.mdg-hamburg.de filius_production; } # Own name instead of `$connection_upgrade`: this `conf.d` holds further # snippets, the openresty already ships `$connection_upgrade`, and two `map`s # of the same name are a start-up error in nginx. map $http_upgrade $filius_connection_upgrade { default upgrade; '' close; } server { listen 80; server_name test-filius.hub.mdg-hamburg.de filius.hub.mdg-hamburg.de; return 301 https://$host$request_uri; } server { listen 443 ssl; server_name test-filius.hub.mdg-hamburg.de filius.hub.mdg-hamburg.de; http2 on; # Wildcard for *.hub.mdg-hamburg.de; both names are one label under # `hub`, as the wildcard requires. ssl_certificate /etc/ssl/certs/hub.combined; ssl_certificate_key /etc/ssl/private/hub.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; # One file for both environments, without a variable in the path: such a # path is opened by the worker per request, not by the master at start, # and the worker may not write in `/var/log/nginx`. Which environment a # line belongs to is told by each stack's own log (`docker compose logs`). access_log /var/log/nginx/filius-access.log; error_log /var/log/nginx/filius-error.log; # ── The two WebSockets: /sync and /sim ─────────────────────────── # # `/sync` is the shared document (Hocuspocus), `/sim?project=…` the # simulation room. Both stay open as long as the project is open. # # * `Upgrade`/`Connection`: without them nginx does not pass the # handshake on. # * 3600 s: the limit applies between two bytes. F pings every 30 s, so # an open connection is never silent that long — but a connection # that is cut after the default 60 s reconnects with a complete # sign-in and a reload of the document, and everyone sees the others # blink. # * `Origin` passes through unchanged (nginx forwards every request # header); F checks it against `FILIUS_ORIGINS`. location ~ ^/(sync|sim)$ { proxy_pass http://$filius_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $filius_connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_buffering off; proxy_connect_timeout 10s; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } # ── Large files: /blobs ────────────────────────────────────────── # # Pictures and other binary files from 16 KB on go here as one request # each, up to 16 MiB (`BLOB_MAX_BYTES` in `packages/server/src/blobs.ts`). # The limit here is **above** F's on purpose: then F answers a file that # is too large itself, with `413 tooLarge` and its CORS headers, and the # editor can say so. nginx's own 413 has no CORS headers; the browser # hands the editor only "network error", and nobody looks for the cause # in an nginx default on another machine. location /blobs { proxy_pass http://$filius_upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; client_max_body_size 20m; proxy_connect_timeout 10s; proxy_read_timeout 120s; proxy_send_timeout 120s; } # ── The dashboard's service route: /svc/ ───────────────────────── # # Guarded by `FILIUS_SERVICE_KEY`; the dashboard reaches F under this # name, so if this proxy is down, creating, importing and deleting # projects is down too. A FILIUS file the dashboard imports comes here # whole, with its pictures (`/svc/projects//import`, F takes up to # 64 MiB, `IMPORT_BYTES`); nginx' default of 1 MB would refuse a class's # project with a few screenshots in it. F limits everything else under # `/svc/` to 64 KiB itself. The limit is again above F's, so that F # answers a file too large itself (see `/blobs`). location /svc/ { proxy_pass http://$filius_upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; client_max_body_size 70m; proxy_connect_timeout 10s; proxy_read_timeout 120s; proxy_send_timeout 120s; } # ── Everything else ────────────────────────────────────────────── # # The editor's page and bundle, `/__auth` (ticket in, cookie out) and # `/healthz`; nginx's default of 1 MB for a body stays. location / { proxy_pass http://$filius_upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; proxy_connect_timeout 10s; proxy_read_timeout 120s; proxy_send_timeout 120s; } } # ── When something does not work ────────────────────────────────────── # # | Symptom | Cause | # |---|---| # | `openresty -t`: host not found in upstream "filius-lxc.invalid…" | The placeholder is still there, see above. | # | 502 on one host only | That stack is not running or does not publish its port. `curl http://
:8081/healthz` (test) or `:8080` (production) from the openresty. | # | Test host shows production's projects (or the other way round) | The two ports are swapped in the `upstream`s or in the compose files. | # | Editor opens, but "connection lost" right away | `/sync` or `/sim` gets no `Upgrade`, or `FILIUS_ORIGINS` does not name this host (F logs `sync.foreignOrigin`). | # | Sign-in in the dashboard's frame fails silently | The stack runs with `FILIUS_INSECURE_COOKIE=1`, or the dashboard of the other environment issued the ticket. | # | Large picture: "upload failed" without a reason | `client_max_body_size` in `/blobs` is below 16 MiB. | # | Importing a FILIUS file fails with 413 (an HTML page, not F's JSON) | `client_max_body_size` in `/svc/` is below 64 MiB. | # | Editor in the dashboard stays a white box | Something sets `X-Frame-Options` for this host. `curl -sI https://filius.hub.mdg-hamburg.de/` |