Code sync, security fixes
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
# Deployment — relata.dixon.cx
|
||||
|
||||
Relata runs on `server.dixon.cx` as a Docker Compose stack managed by systemd, behind the
|
||||
existing nginx.
|
||||
|
||||
`relata.dixon.cx` is **grey-clouded** in Cloudflare — DNS only, no proxy — so requests reach
|
||||
this server directly. That was a deliberate change: the zone's managed challenge blocks
|
||||
non-browser clients, which made API tokens unusable, and Cloudflare's 100-second proxy timeout
|
||||
would have cut off answer generation. The cost is that the origin address is public.
|
||||
|
||||
```
|
||||
client ──► nginx :443 ──► 127.0.0.1:8477 ──► app container
|
||||
├── mongodb (compose network only)
|
||||
├── qdrant (compose network only)
|
||||
└── ollama (host, via bridge proxy)
|
||||
```
|
||||
|
||||
## Files this deployment owns
|
||||
|
||||
| Path | What |
|
||||
|---|---|
|
||||
| `deploy/docker-compose.prod.yml` | The stack. Committed; contains no secrets. |
|
||||
| `/etc/relata/relata.env` | Site configuration **and secrets**. Mode 600, root. Not in the repository. |
|
||||
| `/etc/systemd/system/relata.service` | Brings the stack up at boot. |
|
||||
| `/etc/systemd/system/relata-ollama-proxy.{socket,service}` | Bridges the host's Ollama onto the Docker network. |
|
||||
| `/etc/systemd/system/ollama.service.d/keep-alive.conf` | Keeps models resident for 30 minutes. |
|
||||
| `/etc/nginx/sites-available/relata.dixon.cx.conf` | TLS termination and proxying. |
|
||||
| `/etc/letsencrypt/live/relata.dixon.cx/` | Certificate, renewed by the existing certbot timer. |
|
||||
|
||||
## Everyday operations
|
||||
|
||||
```bash
|
||||
sudo systemctl status relata # is it up
|
||||
sudo systemctl restart relata # restart the stack
|
||||
sudo systemctl stop relata # stop it
|
||||
docker compose -f deploy/docker-compose.prod.yml logs -f app # follow the app log
|
||||
curl -s http://127.0.0.1:8477/health/ready | jq . # dependency status
|
||||
```
|
||||
|
||||
**Note:** `/etc/relata/relata.env` is root-readable only, so `docker compose` must be run as
|
||||
root (or through systemd). Running it as your own user fails with a permission error on the
|
||||
env file — that is the file permissions working, not a fault.
|
||||
|
||||
## Deploying a new version
|
||||
|
||||
```bash
|
||||
cd /home/andy/src/relata
|
||||
make check # do not deploy something that does not pass
|
||||
|
||||
cd deploy
|
||||
RELATA_VERSION=1.0.2 sudo -E docker compose -f docker-compose.prod.yml build app
|
||||
sudo docker tag relata:1.0.2 relata:latest # systemd deploys the `latest` tag
|
||||
sudo systemctl restart relata
|
||||
```
|
||||
|
||||
Tagging both a version and `latest` means `latest` is always a tag you can name, so a rollback
|
||||
is `docker tag relata:1.0.1 relata:latest && sudo systemctl restart relata`.
|
||||
|
||||
## Why the pieces are as they are
|
||||
|
||||
**MongoDB and Qdrant publish no ports.** They are reachable only over the compose network.
|
||||
The development compose file publishes them for tooling; production has no reason to, and this
|
||||
host has no firewall, so anything published is a decision to publish.
|
||||
|
||||
**Ollama is the host's, not a container.** The host already runs one with the models pulled; a
|
||||
second copy would duplicate several gigabytes of weights and, on a CPU-only host, would not be
|
||||
any faster — the bottleneck is the CPU, not the process boundary. The host binds it to
|
||||
`127.0.0.1`, which containers cannot reach, so `relata-ollama-proxy.socket` listens on the
|
||||
Docker bridge address (`172.17.0.1:11434`) and forwards to it.
|
||||
|
||||
The obvious alternative — `OLLAMA_HOST=0.0.0.0` — would have been one line, and on a host with
|
||||
no firewall it publishes an unauthenticated model API to the internet. The socket proxy exposes
|
||||
it to containers on the bridge and nothing else. That is still slightly wider than Relata alone:
|
||||
any container on this host can reach Ollama through it.
|
||||
|
||||
**Ollama runs as a systemd service, and did not used to.** It was previously an `ollama serve`
|
||||
process started by hand inside a tmux session, owned by `andy` and orphaned to init. It worked,
|
||||
but it would not have survived a reboot — and Relata is enabled at boot and depends on it, so a
|
||||
restart would have brought the site up with no embedding and no analysis.
|
||||
|
||||
It is now `ollama.service`, enabled, with the 6.8GB model store moved from `/home/andy/.ollama`
|
||||
to `/usr/share/ollama/.ollama` where the service's own user can read it. `relata.service`
|
||||
declares `After=ollama.service`.
|
||||
|
||||
**Models stay resident for 30 minutes** (`/etc/systemd/system/ollama.service.d/keep-alive.conf`).
|
||||
Measured on this host: an answer takes **47s cold and 17s warm**, and almost all of the
|
||||
difference is loading 5.2GB of `llama3.1:8b`. The default of 5 minutes expired between one
|
||||
question and the next, so nearly every answer paid the load. The two models total ~5.8GB
|
||||
against 31GB of RAM.
|
||||
|
||||
**The Cloudflare real-IP snippet is deliberately NOT included.** With the hostname grey-clouded,
|
||||
`$remote_addr` is already the true client. That snippet trusts `CF-Connecting-IP` from any
|
||||
Cloudflare address — with no Cloudflare proxy actually in front, anyone willing to route a
|
||||
request through a Cloudflare Worker could then set their own address and give themselves a
|
||||
fresh login-rate-limit bucket.
|
||||
|
||||
**If this hostname is ever put back behind the orange cloud, re-add the include.** Without it
|
||||
every request would appear to come from a Cloudflare edge node and rate limiting would bucket
|
||||
the whole internet together.
|
||||
|
||||
**nginx adds no security headers.** Relata sets its own CSP, X-Frame-Options,
|
||||
X-Content-Type-Options and Referrer-Policy on every response. `add_header` in nginx appends
|
||||
rather than replaces, and two conflicting X-Frame-Options headers are worse than one.
|
||||
|
||||
**Two locations have long timeouts.** `GET /search` and `POST /api/v1/search/answer` run a
|
||||
language model inside the request. Everything else gets 120s.
|
||||
|
||||
## Known constraints
|
||||
|
||||
### Answer generation is slow, because this host has no GPU
|
||||
|
||||
8 cores, 31GB RAM, CPU-only inference. Measured with `llama3.1:8b`:
|
||||
|
||||
| | Time |
|
||||
|---|---|
|
||||
| Answer, model warm | ~17s |
|
||||
| Answer, model cold | ~47s |
|
||||
| Full ingestion pipeline, one short note | ~120s |
|
||||
|
||||
Ingestion is asynchronous — jobs run in the background and nobody waits — so 120s is a
|
||||
non-issue. Answer generation is synchronous and is what a person actually feels.
|
||||
|
||||
A second Ollama container would not change any of this: same CPU, same weights, plus several
|
||||
gigabytes of duplicated model store. What would:
|
||||
|
||||
1. A GPU. That is the whole of the gap.
|
||||
2. `RELATA_ANALYSIS_MODEL=llama3.2:3b` — several times faster, noticeably worse, and the
|
||||
safeguards still hold (an uncited answer is downgraded, a schema-echo answer is rejected).
|
||||
3. `RELATA_SEARCH_ANSWER_MAX_CHUNKS=4` to send the model less material.
|
||||
|
||||
### Cloudflare (resolved, but worth knowing)
|
||||
|
||||
Both of the following applied while `relata.dixon.cx` was proxied, and are the reason it is now
|
||||
grey-clouded. They return the moment the orange cloud goes back on:
|
||||
|
||||
- **Non-browser clients are challenged.** Requests without a browser get `403` with
|
||||
`cf-mitigated: challenge` at the edge. This is zone-wide on `dixon.cx` — `grafana.dixon.cx`
|
||||
and `intel.dixon.cx` behave the same way; `blackwall.cx` has an exception. API tokens are
|
||||
unusable through it.
|
||||
- **A 100-second proxy timeout** on non-enterprise plans, which cannot be raised from the
|
||||
origin, returns 524 for a slow answer even though nginx and Relata would both have waited.
|
||||
|
||||
If you want the proxy back for its other benefits, both are fixable from the Cloudflare
|
||||
dashboard: a **WAF → Custom rules → Skip** matching
|
||||
`(http.host eq "relata.dixon.cx" and starts_with(http.request.uri.path, "/api/v1/"))` skipping
|
||||
*Managed Challenge* and *Bot Fight Mode*, and either keeping answers under 100s (see above) or
|
||||
accepting that they will be cut off. Re-add `snippets/cloudflare-realip.conf` to the site at the
|
||||
same time.
|
||||
|
||||
### Everything else
|
||||
|
||||
- **First sign-in provisions a private workspace.** Each Blackwall identity gets its own, and
|
||||
relationship discovery never crosses between them. See
|
||||
[ADR 8](../docs/decisions/0008-federated-identity-private-workspaces.md).
|
||||
- **Local password login is off.** Set `RELATA_LOCAL_LOGIN_ENABLED=true` in
|
||||
`/etc/relata/relata.env` and restart to open the break-glass form if Blackwall is ever
|
||||
unreachable. `relata createadmin` works regardless.
|
||||
- **Backups.** `scripts/backup.sh` covers MongoDB and Qdrant. The compose volumes are
|
||||
`relata-prod_mongo_data`, `relata-prod_qdrant_data` and `relata-prod_uploads`.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Relata — production stack for relata.dixon.cx
|
||||
#
|
||||
# Differences from the development compose file, and why:
|
||||
#
|
||||
# - MongoDB and Qdrant publish NO ports. They are reachable only over the
|
||||
# compose network, so nothing on the host or the LAN can talk to them.
|
||||
# The development file publishes them for tooling; production has no reason
|
||||
# to.
|
||||
# - There is no Ollama container. The host already runs one on
|
||||
# 127.0.0.1:11434 with the models pulled, and a second copy would duplicate
|
||||
# several gigabytes of weights for no benefit.
|
||||
# - The application listens on loopback only. nginx is the only thing that
|
||||
# reaches it, and TLS terminates there.
|
||||
# - Secrets come from an env_file outside the repository. Nothing in this
|
||||
# file is sensitive, so it is safe to commit.
|
||||
#
|
||||
# Managed by systemd: relata.service. See deploy/README.md.
|
||||
|
||||
name: relata-prod
|
||||
|
||||
services:
|
||||
mongodb:
|
||||
image: mongo:7
|
||||
restart: unless-stopped
|
||||
command: ["--bind_ip_all"]
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
- mongo_config:/data/configdb
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging: &logging
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "5"
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:v1.12.4
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
healthcheck:
|
||||
# The Qdrant image has no curl or wget; its own binary can answer.
|
||||
test: ["CMD", "/qdrant/qdrant", "--version"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
logging: *logging
|
||||
|
||||
app:
|
||||
image: relata:${RELATA_VERSION:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VERSION: ${RELATA_VERSION:-latest}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
mongodb:
|
||||
condition: service_healthy
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
# Loopback only. nginx proxies to this; nothing else can reach it.
|
||||
- "127.0.0.1:${RELATA_PUBLISH_PORT:-8477}:8080"
|
||||
env_file:
|
||||
# Secrets and site configuration. Outside the repository, mode 600.
|
||||
- /etc/relata/relata.env
|
||||
environment:
|
||||
# Inside the container Relata must listen on all interfaces for the
|
||||
# published port to reach it. The port itself is bound to 127.0.0.1
|
||||
# above, so this does not expose anything.
|
||||
RELATA_HTTP_ADDR: "0.0.0.0:8080"
|
||||
RELATA_ALLOW_EXTERNAL_BIND: "true"
|
||||
RELATA_MONGO_URI: "mongodb://mongodb:27017"
|
||||
RELATA_QDRANT_URL: "http://qdrant:6333"
|
||||
# The host's Ollama, reached through the gateway address below.
|
||||
RELATA_OLLAMA_BASE_URL: "http://host.docker.internal:11434"
|
||||
RELATA_UPLOAD_DIR: "/data/uploads"
|
||||
RELATA_LOG_JSON: "true"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
volumes:
|
||||
- uploads:/data/uploads
|
||||
# The image ships a read-only root filesystem; these are the only writable
|
||||
# paths, and neither is executable.
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,mode=1777,noexec,nosuid,nodev
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/local/bin/relata", "version"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
logging: *logging
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
mongo_config:
|
||||
qdrant_data:
|
||||
uploads:
|
||||
Reference in New Issue
Block a user