diff --git a/.env.example b/.env.example index b85dd76..69e2510 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,20 @@ # Host port serving the API + web dashboard. #API_PORT=8001 +# Built-in HTTPS for a server with a public IP: point a domain at it, open +# ports 80 and 443, and uncomment both lines. Caddy then gets a certificate on +# its own and serves everything at https://DOMAIN (README "Exposing the +# server"). If you keep a docker-compose.override.yml, add it to the list. +#COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml +#DOMAIN=loopback.example.com + +# Behind your OWN reverse proxy: the address or CIDR it connects from, so the +# server takes the client address from X-Forwarded-For (per-client login rate +# limit, real addresses in the audit trail). Not needed with the Caddy file +# above, which sets it. Never "*" while port 8001 is reachable by anything +# but the proxy: clients could then forge their address. +#FORWARDED_ALLOW_IPS= + # The AI-coach MCP endpoint, served at /mcp on the same port (README "MCP # Server"). Set to false to serve no coach endpoint at all. #MCP_ENABLED=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 32491a3..9d80e2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,20 @@ tagged `X.Y.Z` and `X.Y` to GHCR (see README "Releases & upgrading"). The running server reports its version at `/api/health` and on the admin System screen. +## [Unreleased] + +### Added + +- **Built-in HTTPS for a server with a domain.** Two lines in `.env` + (`COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml` and `DOMAIN=`) + add a Caddy container that obtains and renews a Let's Encrypt certificate + and serves the dashboard, API and coach at `https://`. It also + closes plain-HTTP port 8001 to everything but the host. +- **`FORWARDED_ALLOW_IPS`** passes through to the server, for installs behind + their own reverse proxy. Without it every proxied client appears as the + proxy: the login rate limit is one bucket shared by everyone and the audit + trail shows the proxy's address on every event. The Caddy file sets it. + ## [0.1.17] — 2026-11-12 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 37a5bdb..d6c6a9d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -318,4 +318,23 @@ docker compose up -d --build # Deploy changes docker compose logs -f backend # Check logs ``` +**Built-in HTTPS (`deploy/https/caddy.yml`, unreleased after 0.1.17):** an add-on compose +file enabled with `COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml` + `DOMAIN=` in +`.env` (override files with `!override`, same pattern as `deploy/demo`; not compose +profiles, because a profile cannot change the `app` service's ports). Things worth not +re-deriving: +- **Caddy does NOT share the app's network namespace** (`network_mode: service:app`), + tempting as it is (the API would see a loopback peer and trust `X-Forwarded-For` with no + config). Tested: when the app container restarts on its own — crash, `docker restart` — + the sidecar is left in a dead namespace and the site stays down until someone restarts + the sidecar; a plain `docker compose up -d` does not fix it. Any future sidecar + (Tailscale, cloudflared) has the same problem. +- **Client addresses come from uvicorn's proxy-header handling, not app code**: + `client_ip()` and slowapi both read `request.client.host`, which uvicorn rewrites from + `X-Forwarded-For` only for peers in `FORWARDED_ALLOW_IPS` (default: loopback only). The + Caddy file sets `*`, which is sound only because the same file makes port 8001 + loopback-only; the two settings must stay together. +- A Tailscale Funnel add-on file was drafted but not shipped: it could not be tested + without a tailnet auth key. + This deployment is exposed via Tailscale Funnel (HTTPS on :8443, proxying the API on :8001) for iPhone app access. diff --git a/README.md b/README.md index dc8ed2f..a61756e 100644 --- a/README.md +++ b/README.md @@ -106,13 +106,29 @@ docker compose -f docker-compose.demo.yml down -v ## Exposing the server -The iOS app and dashboard just need to reach port 8001 — over HTTPS, or over HTTP on a network you trust. Options, safest first: +The iOS app syncs in the background, so the server has to be reachable from wherever the phone is — over HTTPS, or over HTTP on a network you trust. Dashboard, API and coach (`/mcp`) all share the one port, so whichever option you pick covers all three. -- **Tailscale (recommended):** install Tailscale on the server and each phone; clients use `http://:8001` inside the tailnet and nothing is exposed to the internet. If the app must also work for people outside your tailnet, [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) can publish the port (e.g. `tailscale funnel --bg --https 8443 8001`). -- **Reverse proxy:** put Caddy / nginx / Traefik with a real certificate in front and forward to `127.0.0.1:8001`. -- **LAN only:** fine for trying it out, but the app syncs in the background, so a phone that leaves the house needs one of the options above. +### Built-in HTTPS (a server with a public IP and a domain) -If the server ends up publicly reachable: login is rate-limited (5/min/IP), there is no registration endpoint (the first-run setup endpoint closes permanently once the admin account exists), and the admin System screen shows an auth audit trail — but public exposure still means **strong passwords on every account**. +Point a domain's DNS at the server, make sure ports 80 and 443 are open, and add two lines to `.env`: + +```ini +COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml +DOMAIN=loopback.example.com +``` + +Then `docker compose up -d`. This adds a [Caddy](https://caddyserver.com) container that obtains and renews a Let's Encrypt certificate by itself and serves everything at `https://`. It also closes plain-HTTP port 8001 to everything but the host itself, and makes the server see each client's real address, so the login rate limit applies per client and the audit trail shows who connected. + +`COMPOSE_FILE` replaces Compose's automatic file discovery: if you keep a `docker-compose.override.yml`, add it to the list (`docker-compose.yml:docker-compose.override.yml:deploy/https/caddy.yml`). On Windows the separator is `;`. + +### Other ways + +- **Tailscale:** install Tailscale on the server and each phone; clients use `http://:8001` inside the tailnet and nothing is exposed to the internet. If the app must also work for people outside your tailnet, [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) can publish the port (e.g. `tailscale funnel --bg --https 8443 8001`). Needs no domain and no open port, so it suits a home server. +- **Cloudflare Tunnel:** also works from behind a home router, with a domain on Cloudflare. Be aware that Cloudflare terminates the TLS connection, so it can read the health data passing through; with Caddy or Tailscale it is only ever decrypted on your own machine. [`deploy/demo/docker-compose.public.yml`](deploy/demo/docker-compose.public.yml) shows the pattern. +- **Your own reverse proxy:** put nginx / Traefik / Caddy with a real certificate in front and forward to `127.0.0.1:8001`. Set `FORWARDED_ALLOW_IPS` in `.env` to the address the proxy connects from, or every client shares one rate limit (see [Configuration](#configuration)). +- **LAN only:** fine for trying it out, but a phone that leaves the house stops syncing. + +If the server ends up publicly reachable: login is rate-limited (5/min per client address), there is no registration endpoint (the first-run setup endpoint closes permanently once the admin account exists), and the admin System screen shows an auth audit trail — but public exposure still means **strong passwords on every account**. ## Backups @@ -166,6 +182,8 @@ Everything is configured through environment variables in the root `.env` (read | `POSTGRES_PASSWORD` / `POSTGRES_USER` / `POSTGRES_DB` | `training-api` | Database credentials (not published outside the compose network; also fed to the app as `DATABASE_URL`) | | `API_PORT` | `8001` | Host port for the API + dashboard | | `MCP_ENABLED` | `true` | Serve the coach MCP at `/mcp` on the same port (see [MCP Server](#mcp-server)). `false` removes the endpoint | +| `FORWARDED_ALLOW_IPS` | *(unset)* | Behind a reverse proxy: the address(es) or CIDR the proxy connects from, whose `X-Forwarded-For` the server trusts for the client address (login rate limit, audit trail). Unset, only loopback is trusted and every proxied client looks like the proxy. `deploy/https/caddy.yml` sets this for you. Never use `*` while port 8001 is reachable by anything but the proxy | +| `DOMAIN` | *(unset)* | Hostname for built-in HTTPS, used with `deploy/https/caddy.yml` (see [Exposing the server](#exposing-the-server)) | | `BACKUP_HOST_DIR` | `./backups` | Host directory the server writes its `pg_dump` backups to (and reports freshness from) | | `BACKUP_ENABLED` | `true` | Server-managed backups: nightly dump, catch-up after downtime, pre-migration dump. `false` = bring your own (see [Backups](#backups)) | | `BACKUP_TIME` | `03:30` | Nightly backup time, container-local (set `TZ` to make it your local time) | diff --git a/backend/app/rate_limit.py b/backend/app/rate_limit.py index 3f3890a..2744a7e 100644 --- a/backend/app/rate_limit.py +++ b/backend/app/rate_limit.py @@ -3,9 +3,12 @@ Lives in its own module so routes and ``main`` can import the same limiter instance without a circular import. -Note: behind a reverse proxy (Tailscale Funnel) ``get_remote_address`` sees the -proxy's address, so the login limit is effectively shared across clients. That -is acceptable as a brute-force backstop for a self-hosted household instance. +The key is the client address as uvicorn reports it. Behind a reverse proxy +that is the proxy's own address — one bucket shared by every client, so a +single client hammering the login locks everyone out — unless the proxy's +address is listed in FORWARDED_ALLOW_IPS, in which case uvicorn takes the +client from X-Forwarded-For. deploy/https/caddy.yml sets that up; see README +"Exposing the server". """ from slowapi import Limiter diff --git a/deploy/https/caddy.yml b/deploy/https/caddy.yml new file mode 100644 index 0000000..167f7ad --- /dev/null +++ b/deploy/https/caddy.yml @@ -0,0 +1,48 @@ +# Built-in HTTPS with a real certificate — layered on top of docker-compose.yml. +# +# For a server with a public IP (a VPS) and a domain whose DNS points at it. +# Caddy obtains and renews a Let's Encrypt certificate on its own; ports 80 +# and 443 must be reachable from the internet. Enable it from the root .env: +# +# COMPOSE_FILE=docker-compose.yml:deploy/https/caddy.yml +# DOMAIN=loopback.example.com +# +# then `docker compose up -d`. Dashboard, API and coach (/mcp) are then at +# https://. See README "Exposing the server". + +services: + app: + # Plain HTTP stays on the host's loopback only — handy for a local health + # check, closed to the internet. Everything else arrives through Caddy. + ports: !override + - "127.0.0.1:${API_PORT:-8001}:8001" + environment: + # Take the client address from X-Forwarded-For, so the login rate limit + # and the audit trail see each client instead of one shared proxy + # address. "*" trusts the header from any peer, which is sound ONLY + # because of the loopback-only port above: the peers left are Caddy + # (which sets the header from the real connection and discards any a + # client sent) and processes on this host. Publish 8001 more widely and + # anyone can forge their address — keep these two settings together. + - FORWARDED_ALLOW_IPS=* + + caddy: + image: caddy:2 + container_name: caddy__training-api + command: caddy reverse-proxy --from ${DOMAIN:?set DOMAIN in .env to the hostname that points at this server} --to app:8001 + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + # Certificates and the ACME account. Losing this volume just means a + # fresh certificate, but repeated loss runs into Let's Encrypt limits. + - caddy_data:/data + - caddy_config:/config + depends_on: + - app + restart: unless-stopped + +volumes: + caddy_data: + caddy_config: diff --git a/docker-compose.yml b/docker-compose.yml index 959d4da..1c84360 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,10 @@ services: # Coach MCP at /mcp on this same port (on by default). Passes through # only when set in the root .env. - MCP_ENABLED + # Behind a reverse proxy: whose X-Forwarded-For to trust for the client + # address (see README "Configuration"). Passes through only when set; + # deploy/https/caddy.yml sets it itself. + - FORWARDED_ALLOW_IPS volumes: # Backup dir: the server writes its own pg_dump files here (nightly + # pre-migration + on demand from the System screen, which also reports