Metadata-Version: 2.4
Name: splent_feature_nginx
Version: 1.5.0
License-Expression: LGPL-3.0-or-later
Requires-Python: >=3.13
Description-Content-Type: text/markdown
License-File: COPYING
License-File: COPYING.LESSER
Requires-Dist: splent_framework>=1.21.0
Dynamic: license-file

# splent_feature_nginx

Nginx reverse proxy for SPLENT products (config archetype). It ships no Python
behaviour of its own: it brings an nginx container that sits in front of the
product's web service and answers on the product's HTTP and HTTPS ports.

## What it provides

- Docker:
  - `docker/docker-compose.dev.yml`, nginx in front of the development server.
  - `docker/docker-compose.prod.yml`, built from `docker/Dockerfile.prod`, with
    the `certbot_webroot` and `letsencrypt` volumes for TLS certificates.
  Both join `splent_network`, where they reach the product as
  `NGINX_UPSTREAM_HOST:NGINX_UPSTREAM_PORT`.
- Server templates in `docker/nginx/templates/`, one per environment, rendered
  by the nginx image at start. Both do the same things:
  - The web container is resolved per request through Docker's DNS
    (`resolver 127.0.0.11 valid=10s`, `proxy_pass` through a variable), so
    nginx starts before the web container exists, answers 502 while it is
    away and follows it when it is recreated with another address.
  - Only `NGINX_SERVER_NAME` reaches the app. A `default_server` takes every
    other Host, and requests without one: production closes the connection
    (444), development answers 421 with a sentence saying what to add.
  - The site's other names, `NGINX_REDIRECT_SERVER_NAMES` (the bare domain
    of a `www.` site), get a 301 to the first name of `NGINX_SERVER_NAME`,
    the canonical one, path and query included, and never reach the app.
  - The app is told the host as `X-Forwarded-Host: $host`, the name the
    request matched a server by. A trusted terminator chooses the scheme
    and names the client, but not the host, so the app only ever hears a
    name of `NGINX_SERVER_NAME`.
  - A server on `127.0.0.1:8080`, inside the container only, answers
    `/healthz` with 200, which the compose healthcheck asks.
  - The requests that publish to Zenodo or build a dataset's ZIP get
    `NGINX_LONG_REQUEST_TIMEOUT` (600 s), everything else 120 s.
  - `/login`, `/signup`, `/reset/forgot` and `/captcha/*` are rate limited
    per client address (`NGINX_AUTH_RATE_LIMIT`, `NGINX_AUTH_RATE_BURST`),
    with 429 above the limit.
  - Every request gets an id, `$request_id` or the one a trusted proxy sent,
    which goes to the app and back to the browser as `X-Request-ID`.
  - The access log uses the `splent` format: the combined layout with the
    tokens of the reset, confirmation and sign-up links, the secret query
    parameters (`token`, `api_key`, `code`, `state`, ...) and the same in the
    Referer replaced by `***`, then the request id, the peer and the times.
    nginx's error log has no format, so the request line of a request nginx
    itself failed (the app down, a 429) is still written there as it came.
  `tests/unit/test_server_templates.py` renders both as the image does and
  checks each of these.
- `docker/nginx/locations/`, where a product drops extra `location` blocks.
  `protected-files.conf.example` shows how to serve files the app has
  authorised through `X-Accel-Redirect`.
- An entrypoint script, `15-splent-trusted-proxies.sh`, that trusts
  `X-Forwarded-Proto`, `X-Request-ID` and the client in `X-Forwarded-For`
  only from `NGINX_TRUSTED_PROXY_CIDRS`, and another,
  `16-splent-redirects.sh`, that writes the redirect server for
  `NGINX_REDIRECT_SERVER_NAMES` and stops the container on a name that is
  not a host.
- An `nginx` entry in the Administration section of the menu registry, for
  administrators only. It opens the site through the proxy, at
  `NGINX_SERVER_NAME` (its first name that is a host) on
  `NGINX_HTTP_HOST_PORT`, in a new tab. It needs `splent_framework>=1.21.0`.
- For one more minor, the same link in the signed-in sidebar slot
  (`layout.authenticated_sidebar`), at `localhost` as before, for the shells
  that still draw slots instead of the registry.

## Configuration

| Env var | Default | Purpose |
| --- | --- | --- |
| `NGINX_UPSTREAM_HOST` | `__PRODUCT___web` | The product's web container, resolved to the product name by `product:env --merge` and `product:build`. |
| `NGINX_UPSTREAM_PORT` | `5000` | Port the web container listens on. |
| `NGINX_HTTP_HOST_PORT` | `80` | Host port for HTTP. Gets the product's port offset. |
| `NGINX_HTTPS_HOST_PORT` | `443` | Host port for HTTPS. Gets the product's port offset. |
| `NGINX_SERVER_NAME` | `localhost`, asked for in production | `server_name` of the site, several names separated by spaces. The first is the canonical one. Any other Host is refused. |
| `NGINX_REDIRECT_SERVER_NAMES` | empty | Other names of the site, separated by spaces, redirected for good to the first name of `NGINX_SERVER_NAME`. |
| `NGINX_TRUSTED_PROXY_CIDRS` | empty | Peers allowed to set the forwarded headers and name the client. Empty trusts nobody. Set it only behind a TLS terminator on the host. |
| `NGINX_BIND_HOST` | `0.0.0.0` | Host interface the ports are published on. `127.0.0.1` behind a TLS terminator on the host. |
| `NGINX_AUTH_RATE_LIMIT` | `60r/m` | Sustained rate per client address on the account forms. |
| `NGINX_AUTH_RATE_BURST` | `30` | Requests above that rate served at once before the 429s. |
| `NGINX_LONG_REQUEST_TIMEOUT` | `600s` | Read timeout of the Zenodo publications and the ZIP downloads. Keep `GUNICORN_TIMEOUT` a little above it. |

`docker/.env.prod.example` is the production copy of `docker/.env.example`:
it asks for `NGINX_SERVER_NAME`, since `localhost` there would refuse the
real site, and marks the rest as the operator's.

## Behind a TLS terminator

The server listens on HTTP only, so HTTPS comes from a terminator on the
host (nginx, Caddy, Traefik) that forwards to `NGINX_HTTP_HOST_PORT`. Then:

1. `NGINX_BIND_HOST=127.0.0.1`, so the terminator is the only way in.
2. `NGINX_TRUSTED_PROXY_CIDRS=172.16.0.0/12`, the docker range the
   terminator arrives from (the `peer=` field of the access log shows the
   address; add `192.168.65.0/24` on Docker Desktop).
3. The terminator keeps the Host it received and sets `X-Forwarded-For`,
   `X-Forwarded-Proto` and, when it has one, `X-Request-ID`. Whatever
   `X-Forwarded-Host` it sends is not passed on: nginx forwards the Host
   the request matched.
4. The app keeps `SPLENT_TRUSTED_PROXIES=1`. nginx is the only hop it
   talks to: nginx decides whether to believe the terminator and forwards
   one `X-Forwarded-For` (the client), one `X-Forwarded-Proto` and one
   `X-Forwarded-Host`. With 2 the app looks for two values in each, finds
   one, and ignores them: `http://` links, cookies without `Secure`, no
   HSTS.

When nginx is the edge, leave the first two as they are and keep
`SPLENT_TRUSTED_PROXIES=1` as well.

## One canonical host

A site reached by more than one name should answer by one, so that the
links it mails, the addresses a search engine keeps and the OAuth callbacks
all name the same host. Put the canonical name in `NGINX_SERVER_NAME` and
the others in `NGINX_REDIRECT_SERVER_NAMES`:

```
NGINX_SERVER_NAME=www.hub.example.org
NGINX_REDIRECT_SERVER_NAMES=hub.example.org
```

The app then only ever sees `www.hub.example.org`. Behind nginx it should
refuse any other Host too (Flask's `TRUSTED_HOSTS`) and build the links it
makes outside a request with `SERVER_NAME`, which is the product's to set.

## Use it in a product

```bash
splent feature:add splent_feature_nginx
```
