diff --git a/README.md b/README.md index c8f92b7..0032095 100644 --- a/README.md +++ b/README.md @@ -51,5 +51,19 @@ des Editors, durch Kommas getrennt); `FILIUS_PORT` ist 8080, wenn nicht gesetzt. Fehlt eine davon, startet er nicht. Ohne HTTPS (Entwicklerrechner) setzt `FILIUS_INSECURE_COOKIE=1` den Keks ohne `Secure`. +## Betrieb + +Zwei Docker-Stacks auf Server F, je mit eigener Datenbank, eigenem +`FILIUS_TICKET_SECRET` und `FILIUS_SERVICE_KEY`: + +| Name | Port | gehört zu | +| --- | --- | --- | +| `test-filius.hub.mdg-hamburg.de` | 8081 | `test-dashboard` | +| `filius.hub.mdg-hamburg.de` | 8080 | `dashboard` | + +`deploy/proxy.conf` ist der Eintrag für den externen OpenResty (in dessen +`conf.d`); vorher die Adresse von F für `filius-lxc.invalid` einsetzen. +`FILIUS_ORIGINS` ist jeweils der eigene Name mit `https://`. + Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter i18n-Schlüsseln (Deutsch/Englisch). diff --git a/deploy/proxy.conf b/deploy/proxy.conf new file mode 100644 index 0000000..d2ef077 --- /dev/null +++ b/deploy/proxy.conf @@ -0,0 +1,222 @@ +# 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; + } + + # ── Everything else ────────────────────────────────────────────── + # + # The editor's page and bundle, `/__auth` (ticket in, cookie out), + # `/svc/*` (the dashboard's service route, guarded by + # `FILIUS_SERVICE_KEY`; the dashboard reaches F under this name, so if + # this proxy is down, creating and deleting projects is down too) and + # `/healthz`. F limits the bodies of `/svc/*` to 64 KiB itself, so + # nginx's default of 1 MB 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. | +# | Editor in the dashboard stays a white box | Something sets `X-Frame-Options` for this host. `curl -sI https://filius.hub.mdg-hamburg.de/` |