diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 89aa5e4..cc0dce2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -147,6 +147,14 @@ jobs: echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy" exit 1 fi + # /api/ws bypasses Basic auth (browsers don't reliably replay + # credentials on a WebSocket upgrade) and relies on the API token + # instead. Refuse to ship if that token is missing, or the live feed + # would be unauthenticated. + if [ -z "$CRYPTO_API_TOKEN" ]; then + echo "::error::CRYPTO_API_TOKEN must be set because /api/ws skips Basic auth; refusing to deploy" + exit 1 + fi - name: Log in to Docker Hub (optional) run: | diff --git a/AGENTS.md b/AGENTS.md index 00530e6..da104ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,6 +162,12 @@ real migration step. frontend nginx injects it via the `API_TOKEN_HEADER` header on `/api`, and on the WebSocket upgrade. `GET /api/health` is always open. When adding routes, depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists). +- `/api/ws` skips Traefik Basic auth via a dedicated higher-priority router + (`crypto-trading-ws`) because browsers don't reliably replay Basic creds on a + WS upgrade. The WS is gated by `API_TOKEN` instead (header, injected by nginx, + or `?token=` for manual clients) — see `websocket_endpoint`. The deploy job + fails closed if `CRYPTO_API_TOKEN` is unset. Keep the `/api/ws` block in + `nginx.conf` separate from `/api/` so upgrade headers are only set there. ## Backtesting diff --git a/README.md b/README.md index f2eb0de..469ea26 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,13 @@ open). The frontend nginx injects it server-side, so the browser never sees it and the dashboard keeps working unchanged. This sits *behind* the Traefik Basic auth, not instead of it. +`/api/ws` is special: browsers do not reliably replay Basic-auth credentials on +a WebSocket upgrade (which left the live feed stuck on "reconnecting"), so +Traefik serves that one path on a separate higher-priority router **without** +Basic auth. It is still gated by `API_TOKEN`, which nginx injects on the +handshake. Because of this the deploy **requires** `CRYPTO_API_TOKEN` to be set — +without it the WebSocket would be unauthenticated. + ## Risk controls The engine enforces limits on every tick and **halts + flattens** when one trips: diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 4e00249..02dcb4e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1,6 +1,8 @@ from __future__ import annotations +import pytest from fastapi.testclient import TestClient +from starlette.websockets import WebSocketDisconnect from app.main import create_app from app.market.fake import FakeMarketData @@ -143,6 +145,40 @@ def test_api_token_enforced(runtime: Runtime) -> None: assert client.get("/api/health").status_code == 200 +def test_websocket_rejects_missing_token(runtime: Runtime) -> None: + runtime.settings.api_token = "secret" + app = create_app(runtime.settings, runtime) + with ( + TestClient(app) as client, + pytest.raises(WebSocketDisconnect) as excinfo, + client.websocket_connect("/api/ws"), + ): + pass + assert excinfo.value.code == 1008 + + +def test_websocket_accepts_header_token(runtime: Runtime) -> None: + runtime.settings.api_token = "secret" + app = create_app(runtime.settings, runtime) + with ( + TestClient(app) as client, + client.websocket_connect("/api/ws", headers={"X-API-Token": "secret"}) as websocket, + ): + message = websocket.receive_json() + assert message["type"] == "status" + + +def test_websocket_accepts_query_token(runtime: Runtime) -> None: + runtime.settings.api_token = "secret" + app = create_app(runtime.settings, runtime) + with ( + TestClient(app) as client, + client.websocket_connect("/api/ws?token=secret") as websocket, + ): + message = websocket.receive_json() + assert message["type"] == "status" + + def test_websocket_sends_initial_status(runtime: Runtime) -> None: app = create_app(runtime.settings, runtime) with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket: diff --git a/docker-compose.yml b/docker-compose.yml index b565f10..309cb10 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -96,6 +96,18 @@ services: # fallback). A per-source rate limit reduces credential brute-forcing. - "traefik.http.routers.crypto-trading.middlewares=crypto-trading-auth@docker,crypto-trading-sec@docker,crypto-trading-authlimit@docker" - "traefik.http.middlewares.crypto-trading-auth.basicauth.users=${CRYPTO_FRONTEND_AUTH_USERS:?CRYPTO_FRONTEND_AUTH_USERS is required (htpasswd string, e.g. htpasswd -nbB user pass)}" + # Browsers do not reliably replay Basic-auth credentials on a WebSocket + # upgrade, which left the live feed stuck "reconnecting". Give /api/ws its + # own higher-priority router that skips Basic auth; the backend still + # requires the API token, which nginx injects server-side. When no API + # token is configured this path is unauthenticated, so only use it on a + # trusted network. + - "traefik.http.routers.crypto-trading-ws.rule=Host(`crypto.${BASE_DOMAIN:-matitos.org}`) && PathPrefix(`/api/ws`)" + - "traefik.http.routers.crypto-trading-ws.entrypoints=websecure" + - "traefik.http.routers.crypto-trading-ws.tls.certresolver=myresolvercd" + - "traefik.http.routers.crypto-trading-ws.priority=100" + - "traefik.http.routers.crypto-trading-ws.middlewares=crypto-trading-sec@docker,crypto-trading-authlimit@docker" + - "traefik.http.routers.crypto-trading-ws.service=crypto-trading" - "traefik.http.middlewares.crypto-trading-authlimit.ratelimit.average=${CRYPTO_AUTH_RATE_LIMIT_AVERAGE:-10}" - "traefik.http.middlewares.crypto-trading-authlimit.ratelimit.burst=${CRYPTO_AUTH_RATE_LIMIT_BURST:-5}" - "traefik.http.middlewares.crypto-trading-sec.headers.stsSeconds=31536000" diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 33e6c70..de73faa 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -8,7 +8,10 @@ server { try_files $uri $uri/ /index.html; } - location /api/ { + # WebSocket feed. Kept in its own block so the upgrade headers are only set + # here, and so the API token is injected server-side on the handshake (the + # browser cannot set headers on a WebSocket). + location /api/ws { proxy_pass http://backend:8000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; @@ -16,6 +19,16 @@ server { proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_set_header ${API_TOKEN_HEADER} "${API_TOKEN}"; + } + + location /api/ { + proxy_pass http://backend:8000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 3600s; # Inject the optional API token server-side so the browser never sees it # and the WebUI keeps working without any client changes. proxy_set_header ${API_TOKEN_HEADER} "${API_TOKEN}"; diff --git a/frontend/src/api.js b/frontend/src/api.js index 0c659f5..1fc8b7e 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -25,17 +25,32 @@ export const deletePreset = (id) => http.delete(`/presets/${id}`).then((r) => r. export function openSocket({ onMessage, onOpen, onClose }) { const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' + const url = `${proto}://${window.location.host}/api/ws` let socket let retryTimer let closed = false + let attempt = 0 + + const schedule = () => { + if (closed) return + // Back off from 2s up to 30s so a persistently failing socket (e.g. auth) + // does not hammer the server, while a brief blip still reconnects fast. + const delay = Math.min(2000 * 2 ** attempt, 30000) + attempt += 1 + retryTimer = setTimeout(connect, delay) + } const connect = () => { - socket = new WebSocket(`${proto}://${window.location.host}/api/ws`) - socket.onopen = () => onOpen?.() + if (closed) return + socket = new WebSocket(url) + socket.onopen = () => { + attempt = 0 + onOpen?.() + } socket.onmessage = (event) => onMessage?.(JSON.parse(event.data)) socket.onclose = () => { onClose?.() - if (!closed) retryTimer = setTimeout(connect, 2000) + schedule() } socket.onerror = () => socket.close() }