From e8913943f9fb728b7fc64c47dfcd2bf1e255e3a7 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 17:15:00 +0100 Subject: [PATCH] p5-07: reference deployment (server-only compose + reverse-proxy docs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reverse proxy is assumed to live outside this project (Caddy, nginx, Traefik, whatever the operator already runs). The reference compose stands up only the server: image-pinned via RM_VERSION, named volume for operator state, localhost-bound so the proxy reaches it on loopback. docs/reverse-proxy.md covers what the proxy must forward — the X-Forwarded-* headers, Host, and Connection: upgrade for the agent WebSocket and live-log streams — plus the RM_TRUSTED_PROXY CIDR rule that gates header trust. Worked examples for Caddy, nginx (with the websocket upgrade map + 1h proxy_read_timeout for live logs), and Traefik. --- deploy/docker-compose.yml | 49 ++++++++++++++--- docs/reverse-proxy.md | 113 ++++++++++++++++++++++++++++++++++++++ tasks.md | 2 +- 3 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 docs/reverse-proxy.md diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a0a2dcc..3366ab1 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -1,21 +1,52 @@ # Reference deployment for the restic-manager control plane. -# Mirrors spec.md §10.1. Adjust image tag and RM_BASE_URL for your env. +# Mirrors spec.md §10.1 and the P5-07 reference deployment. # -# The server speaks plain HTTP. Front it with a TLS-terminating -# reverse proxy (Caddy/Traefik/nginx). RM_TRUSTED_PROXY must contain -# the proxy's IP/CIDR so X-Forwarded-* headers are honoured. +# Scope: this compose stands up the server only. TLS termination and +# the public hostname belong to a reverse proxy that lives outside +# this stack (Caddy, Traefik, nginx, HAProxy, your existing edge — +# whatever you already operate). See `docs/reverse-proxy.md` for the +# headers + CIDRs that proxy needs to forward. +# +# Architecture: +# * The server speaks plain HTTP on :8080. +# * The agent binaries + install scripts ship inside the image under +# /opt/restic-manager/dist/, so /agent/binary and /install/* +# serve out of the box without first-run staging. +# * The named volume holds *only* operator state (sqlite, +# secrets.enc, audit log, the AEAD key). Image upgrades replace +# the agents/scripts; the volume is untouched. +# * Pre-1.0 releases never publish :latest — pin to an exact +# vX.Y.Z tag and bump deliberately. +# +# Before first start: +# 1. Pick a version: export RM_VERSION=vX.Y.Z (or substitute below). +# 2. Set RM_BASE_URL to the public HTTPS URL the external proxy +# serves on. +# 3. Set RM_TRUSTED_PROXY to the IP/CIDR the proxy connects from +# (the X-Forwarded-* headers are honoured only when the immediate +# peer matches one of these). + services: restic-manager: - image: ghcr.io/dcglab/restic-manager:latest + image: gitea.dcglab.co.uk/steve/restic-manager:${RM_VERSION:?set RM_VERSION to a vX.Y.Z tag} restart: unless-stopped - # Bind to localhost only — the proxy is what the public reaches. + # Bind to localhost only — your reverse proxy reaches the server + # over loopback (or, if it runs in a separate compose / on + # another host, swap this for an internal docker network or a + # private LAN bind). ports: - "127.0.0.1:8080:8080" volumes: - - ./data:/data + - rm-data:/data environment: - RM_DATA_DIR=/data - RM_LISTEN=:8080 - - RM_BASE_URL=https://restic.lab.example + - RM_BASE_URL=${RM_BASE_URL:?set RM_BASE_URL to the public https URL} - RM_SECRET_KEY_FILE=/data/secret.key - - RM_TRUSTED_PROXY=172.16.0.0/12 + - RM_TRUSTED_PROXY=${RM_TRUSTED_PROXY:?set RM_TRUSTED_PROXY to the proxy CIDR} + # Cookies are Secure by default; keep that. Override only for + # local-HTTP smoke tests. + # - RM_COOKIE_SECURE=true + +volumes: + rm-data: diff --git a/docs/reverse-proxy.md b/docs/reverse-proxy.md new file mode 100644 index 0000000..12f8c35 --- /dev/null +++ b/docs/reverse-proxy.md @@ -0,0 +1,113 @@ +# Running behind a reverse proxy + +The restic-manager server is HTTP-only by design (see `spec.md` §11): +TLS termination, public hostname, ACME, HSTS, and edge-level rate +limiting all belong to a reverse proxy that you already operate +outside this project. The reference compose in `deploy/docker-compose.yml` +stands up *only* the server; this page covers what your proxy needs +to do to make the rest of it work. + +## What the proxy must forward + +The server reads four headers when (and only when) the immediate peer +matches `RM_TRUSTED_PROXY`: + +| Header | Value | Why | +|---------------------|----------------------------------------------------------|-----| +| `X-Forwarded-For` | The original client IP (single value, or comma chain) | Rate-limit keys, audit log entries, and OIDC redirect-URI checks all use the real client IP. | +| `X-Forwarded-Proto` | `https` | The server emits absolute URLs (e.g. OIDC redirect URIs) using this. | +| `Host` | The public hostname clients use | Cookies are scoped to this; `RM_BASE_URL` must match. | +| `Connection`/`Upgrade` | Pass through unchanged | The agent connects on `/ws/agent` and the live-log viewer connects on `/api/jobs/{id}/stream` — both are WebSockets and need `Upgrade: websocket` to survive the hop. | + +Set `RM_TRUSTED_PROXY` to the CIDR (or comma-separated list of CIDRs) +the proxy connects from. Anything outside that range has its +`X-Forwarded-*` headers ignored, so a stray request that bypasses the +proxy can't spoof the client IP. + +## Example: Caddy + +```caddyfile +restic.example.com { + # Caddy's default reverse_proxy preserves Host, sets + # X-Forwarded-For/Proto, and passes Connection: upgrade through, + # so a single directive covers HTTP + WebSocket. + reverse_proxy 127.0.0.1:8080 + + encode zstd gzip +} +``` + +`RM_TRUSTED_PROXY=127.0.0.1/32` if Caddy and the server share the +host; the docker-bridge CIDR (commonly `172.16.0.0/12`) if Caddy +runs in another container on the default bridge network. + +## Example: nginx + +```nginx +server { + listen 443 ssl http2; + server_name restic.example.com; + + ssl_certificate /etc/ssl/restic.example.com.fullchain.pem; + ssl_certificate_key /etc/ssl/restic.example.com.key.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + + # WebSocket support — agent + live-log endpoints need this. + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Trusted-proxy headers. + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # Live job logs are long-running streams. Bump read timeouts + # so nginx doesn't drop them mid-backup. + proxy_read_timeout 1h; + proxy_send_timeout 1h; + } +} + +# Standard websocket upgrade map (define once at the http {} level). +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +``` + +`RM_TRUSTED_PROXY` for the same-host case: `127.0.0.1/32`. + +## Example: Traefik (label-based) + +```yaml +labels: + - "traefik.enable=true" + - "traefik.http.routers.restic-manager.rule=Host(`restic.example.com`)" + - "traefik.http.routers.restic-manager.entrypoints=websecure" + - "traefik.http.routers.restic-manager.tls.certresolver=letsencrypt" + - "traefik.http.services.restic-manager.loadbalancer.server.port=8080" +``` + +Traefik handles `X-Forwarded-*` and `Connection: upgrade` by default. +`RM_TRUSTED_PROXY` should be the docker network the Traefik container +shares with the server (commonly `172.16.0.0/12` for the default +bridge, or whatever your overlay network's CIDR is). + +## Sanity-checking the wiring + +After bringing the stack up: + +1. `curl -fsS https://restic.example.com/healthz` — should return 200. +2. The login page should report HTTPS in the address bar; cookies + set after login should carry the `Secure` flag. +3. Check the server log for the `config resolved` line: + `trusted_proxies` must include the IP/CIDR your proxy actually + connects from. +4. Enrol a test agent — the WebSocket handshake hitting `/ws/agent` + confirms `Upgrade` is being forwarded correctly. + +If any of those fail, the proxy is the first place to look — the +server itself is intentionally minimal. diff --git a/tasks.md b/tasks.md index 904e593..8e64ef6 100644 --- a/tasks.md +++ b/tasks.md @@ -332,7 +332,7 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [ ] **P5-04** (S) Demo screenshots / short Loom walkthrough in README - [ ] **P5-05** (S) `SECURITY.md` with disclosure process - [ ] **P5-06** (M) End-to-end test suite in CI (Playwright vs. compose stack with sibling Linux agent) -- [ ] **P5-07** (S) Reference deployment: `docker-compose.yml` + Caddyfile snippet showing the TLS-terminating reverse proxy in front of the HTTP-only server (also demonstrates `RM_TRUSTED_PROXY`) +- [x] **P5-07** (S) Reference deployment landed alongside P5-03. `deploy/docker-compose.yml` stands up *only* the server (image-pinned via `RM_VERSION`, named volume for operator state, bound to localhost) — TLS termination is left to whichever reverse proxy the operator already runs. `docs/reverse-proxy.md` documents the headers + WebSocket pass-through the proxy must forward, the `RM_TRUSTED_PROXY` CIDR rule, and worked examples for Caddy, nginx, and Traefik. ### Phase 5 acceptance