Compare commits
2
Commits
97fbd5901e
...
c9ee3e3aef
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9ee3e3aef | ||
|
|
3e19612cc0 |
No files matched your search
@@ -147,6 +147,14 @@ jobs:
|
|||||||
echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy"
|
echo "::error::CRYPTO_FRONTEND_AUTH_USERS is not set (repo/org variable or secret); refusing to deploy"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
- name: Log in to Docker Hub (optional)
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ real migration step.
|
|||||||
frontend nginx injects it via the `API_TOKEN_HEADER` header on `/api`, and on
|
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,
|
the WebSocket upgrade. `GET /api/health` is always open. When adding routes,
|
||||||
depend on `GuardedRuntimeDep`, not `RuntimeDep` (which no longer exists).
|
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
|
## Backtesting
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
and the dashboard keeps working unchanged. This sits *behind* the Traefik Basic
|
||||||
auth, not instead of it.
|
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
|
## Risk controls
|
||||||
|
|
||||||
The engine enforces limits on every tick and **halts + flattens** when one trips:
|
The engine enforces limits on every tick and **halts + flattens** when one trips:
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from starlette.websockets import WebSocketDisconnect
|
||||||
|
|
||||||
from app.main import create_app
|
from app.main import create_app
|
||||||
from app.market.fake import FakeMarketData
|
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
|
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:
|
def test_websocket_sends_initial_status(runtime: Runtime) -> None:
|
||||||
app = create_app(runtime.settings, runtime)
|
app = create_app(runtime.settings, runtime)
|
||||||
with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket:
|
with TestClient(app) as client, client.websocket_connect("/api/ws") as websocket:
|
||||||
|
|||||||
@@ -96,6 +96,18 @@ services:
|
|||||||
# fallback). A per-source rate limit reduces credential brute-forcing.
|
# 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.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)}"
|
- "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.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-authlimit.ratelimit.burst=${CRYPTO_AUTH_RATE_LIMIT_BURST:-5}"
|
||||||
- "traefik.http.middlewares.crypto-trading-sec.headers.stsSeconds=31536000"
|
- "traefik.http.middlewares.crypto-trading-sec.headers.stsSeconds=31536000"
|
||||||
|
|||||||
+14
-1
@@ -8,7 +8,10 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
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_pass http://backend:8000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
@@ -16,6 +19,16 @@ server {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_read_timeout 3600s;
|
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
|
# Inject the optional API token server-side so the browser never sees it
|
||||||
# and the WebUI keeps working without any client changes.
|
# and the WebUI keeps working without any client changes.
|
||||||
proxy_set_header ${API_TOKEN_HEADER} "${API_TOKEN}";
|
proxy_set_header ${API_TOKEN_HEADER} "${API_TOKEN}";
|
||||||
|
|||||||
+18
-3
@@ -25,17 +25,32 @@ export const deletePreset = (id) => http.delete(`/presets/${id}`).then((r) => r.
|
|||||||
|
|
||||||
export function openSocket({ onMessage, onOpen, onClose }) {
|
export function openSocket({ onMessage, onOpen, onClose }) {
|
||||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||||
|
const url = `${proto}://${window.location.host}/api/ws`
|
||||||
let socket
|
let socket
|
||||||
let retryTimer
|
let retryTimer
|
||||||
let closed = false
|
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 = () => {
|
const connect = () => {
|
||||||
socket = new WebSocket(`${proto}://${window.location.host}/api/ws`)
|
if (closed) return
|
||||||
socket.onopen = () => onOpen?.()
|
socket = new WebSocket(url)
|
||||||
|
socket.onopen = () => {
|
||||||
|
attempt = 0
|
||||||
|
onOpen?.()
|
||||||
|
}
|
||||||
socket.onmessage = (event) => onMessage?.(JSON.parse(event.data))
|
socket.onmessage = (event) => onMessage?.(JSON.parse(event.data))
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
onClose?.()
|
onClose?.()
|
||||||
if (!closed) retryTimer = setTimeout(connect, 2000)
|
schedule()
|
||||||
}
|
}
|
||||||
socket.onerror = () => socket.close()
|
socket.onerror = () => socket.close()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user