Enforce CHART_AUTH_TOKEN on /api and /ws; restore /api/version for wait-deploy

This commit is contained in:
Chris Amow 2026-08-10 04:38:36 +00:00
parent 9777188a43
commit 641492ae62
9 changed files with 214 additions and 15 deletions

View file

@ -20,5 +20,7 @@ ALERT_COOLDOWN_SECONDS=900
# Notifications and access # Notifications and access
NTFY_TOPIC= NTFY_TOPIC=
NTFY_SERVER=https://ntfy.sh NTFY_SERVER=https://ntfy.sh
# Blank = no auth (fine locally). In production this is set in Coolify, not
# here — see README. Sent as the X-Chart-Token header, or ?token= for /ws.
CHART_AUTH_TOKEN= CHART_AUTH_TOKEN=
REPLAY_FILE= REPLAY_FILE=

View file

@ -83,3 +83,31 @@ Manual redeploy:
curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \ curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \
"http://127.0.0.1:8000/api/v1/deploy?uuid=dgvch0xqv8uvjfor7dl8bwl9&force=true" "http://127.0.0.1:8000/api/v1/deploy?uuid=dgvch0xqv8uvjfor7dl8bwl9&force=true"
``` ```
## Access token
`CHART_AUTH_TOKEN` guards everything under `/api` plus the `/ws` stream. Leave
it blank and the app is wide open, which is what you want locally — nothing
prompts. Set it and every request needs the token, as the `X-Chart-Token`
header or a `?token=` query parameter (WebSocket handshakes can't carry
headers, hence the second form).
In production the token lives in **Coolify's environment variables**, not in
this repo and not in `.env` — that file is gitignored and never exists in the
built container. Coolify re-injects its env vars into every container it
builds, so the token survives redeploys and reboots.
The browser asks for it once on the first 401 and keeps it in `localStorage`.
To clear it: `localStorage.removeItem('chart-token')`.
`/api/health` and `/api/version` deliberately stay open — `bin/wait-deploy`
polls the latter from whatever machine you pushed from, and neither reveals
anything about the market data or the configuration.
## Saved trendlines
Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json`).
In production `/app/data` is a **Coolify persistent volume** — without it the
container filesystem is ephemeral and every deploy would silently wipe every
line you've drawn.

30
app/api/deps.py Normal file
View file

@ -0,0 +1,30 @@
import secrets
from fastapi import HTTPException, Request, status
def configured_token(app) -> str:
runtime = getattr(app.state, "runtime", None)
return runtime.settings.chart_auth_token if runtime else ""
def token_matches(app, presented: str) -> bool:
"""True when the caller may proceed.
An empty CHART_AUTH_TOKEN leaves everything open, which is what local
development wants — the check only engages once a token is configured.
"""
want = configured_token(app)
if not want:
return True
return secrets.compare_digest(presented or "", want)
def require_token(request: Request) -> None:
presented = request.headers.get("x-chart-token") or request.query_params.get("token", "")
if not token_matches(request.app, presented):
raise HTTPException(
status.HTTP_401_UNAUTHORIZED,
"Missing or invalid chart token",
headers={"WWW-Authenticate": "X-Chart-Token"},
)

25
app/api/meta.py Normal file
View file

@ -0,0 +1,25 @@
"""Endpoints that stay reachable without a token.
`bin/wait-deploy` polls /api/version from whatever machine you pushed from, so
requiring the token here would mean carrying it around just to answer "is my
commit live yet". Neither endpoint exposes anything about the market data or
the configuration.
"""
import os
from fastapi import APIRouter
router = APIRouter(prefix="/api")
# Coolify injects the deployed commit; absent when running locally.
SOURCE_COMMIT = os.environ.get("SOURCE_COMMIT", "dev")
@router.get("/health")
def health():
return {"status": "ok", "service": "chart"}
@router.get("/version")
def version():
return {"commit": SOURCE_COMMIT}

View file

@ -1,14 +1,17 @@
import time import time
import uuid import uuid
from fastapi import APIRouter, HTTPException, Query, Request, Response from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.analysis.levels import Side from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine from app.analysis.manual_lines import ManualLine
from app.api.deps import require_token
router = APIRouter(prefix="/api") # Everything here needs the token when CHART_AUTH_TOKEN is set. /health and
# /version live in app.api.meta and stay open on purpose.
router = APIRouter(prefix="/api", dependencies=[Depends(require_token)])
class LineCreate(BaseModel): class LineCreate(BaseModel):
@ -37,11 +40,6 @@ class LinePatch(BaseModel):
cutoff_t: int | None = None cutoff_t: int | None = None
@router.get("/health")
def health():
return {"status": "ok", "service": "chart"}
@router.get("/status") @router.get("/status")
def status(request: Request): def status(request: Request):
runtime = request.app.state.runtime runtime = request.app.state.runtime

View file

@ -2,6 +2,7 @@ import asyncio
from fastapi import APIRouter, WebSocket, WebSocketDisconnect from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.api.deps import token_matches
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.analysis.alerts import AlertEngine from app.analysis.alerts import AlertEngine
from app.analysis.confluence import cluster_levels from app.analysis.confluence import cluster_levels
@ -47,6 +48,11 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
@router.websocket("/ws") @router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket): async def websocket_endpoint(websocket: WebSocket):
# Browsers cannot set headers on a WebSocket handshake, so the token comes
# in as a query parameter here. 1008 = policy violation.
if not token_matches(websocket.app, websocket.query_params.get("token", "")):
await websocket.close(code=1008, reason="Missing or invalid chart token")
return
await websocket.accept() await websocket.accept()
runtime = websocket.app.state.runtime runtime = websocket.app.state.runtime
queue: asyncio.Queue = asyncio.Queue(maxsize=100) queue: asyncio.Queue = asyncio.Queue(maxsize=100)

View file

@ -7,6 +7,7 @@ from fastapi import FastAPI
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from app.api.meta import router as meta_router
from app.api.routes import router as api_router from app.api.routes import router as api_router
from app.api.ws import router as ws_router from app.api.ws import router as ws_router
from app.config import Settings from app.config import Settings
@ -32,6 +33,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="chart", lifespan=lifespan) app = FastAPI(title="chart", lifespan=lifespan)
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
app.include_router(meta_router)
app.include_router(api_router) app.include_router(api_router)
app.include_router(ws_router) app.include_router(ws_router)

View file

@ -1,5 +1,31 @@
const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue; const { createApp, ref, computed, watch, onMounted, onUnmounted } = Vue;
// Shared access token. Blank when the server runs without CHART_AUTH_TOKEN,
// which is the normal local-development case — nothing prompts.
const TOKEN_KEY = 'chart-token';
let authToken = localStorage.getItem(TOKEN_KEY) || '';
function promptForToken() {
const entered = window.prompt('Access token for this chart', '');
if (entered === null) return false;
authToken = entered.trim();
localStorage.setItem(TOKEN_KEY, authToken);
return true;
}
async function apiFetch(url, options = {}) {
const send = () => fetch(url, {
...options,
headers: authToken
? { ...(options.headers || {}), 'X-Chart-Token': authToken }
: { ...(options.headers || {}) },
});
const response = await send();
// A 401 means nothing was written, so retrying the same request is safe.
if (response.status === 401 && promptForToken()) return send();
return response;
}
const defaultPrefs = { const defaultPrefs = {
base_tf: '1m', base_tf: '1m',
enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false }, enabled: { ma: { '1d': [10, 20, 50, 100, 200], '1h': [] }, manual: true, auto: false },
@ -41,13 +67,14 @@ createApp({
const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length); const allManualSelected = computed(() => manualLines.value.length > 0 && selectedLines.value.length === manualLines.value.length);
async function refreshStatus() { async function refreshStatus() {
const response = await fetch('/api/status'); const response = await apiFetch('/api/status');
if (response.ok) status.value = await response.json(); if (response.ok) status.value = await response.json();
} }
function connect() { function connect() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws'; const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
socket = new WebSocket(`${protocol}://${location.host}/ws`); const query = authToken ? `?token=${encodeURIComponent(authToken)}` : '';
socket = new WebSocket(`${protocol}://${location.host}/ws${query}`);
socket.onopen = () => { socket.onopen = () => {
socket.send(JSON.stringify({ type: 'subscribe', tf: timeframe.value })); socket.send(JSON.stringify({ type: 'subscribe', tf: timeframe.value }));
sendPrefs(); sendPrefs();
@ -75,8 +102,10 @@ createApp({
playAlert(); playAlert();
} }
}; };
socket.onclose = () => { socket.onclose = event => {
status.value.stream = 'disconnected'; status.value.stream = 'disconnected';
// 1008 is the server rejecting our token; ask once, then reconnect.
if (event.code === 1008 && !promptForToken()) return;
setTimeout(connect, 2000); setTimeout(connect, 2000);
}; };
} }
@ -131,7 +160,7 @@ createApp({
drawPoints.value = []; drawPoints.value = [];
drawMode.value = false; drawMode.value = false;
try { try {
const response = await fetch('/api/lines', { const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }), body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }),
}); });
@ -166,7 +195,7 @@ createApp({
syncVisibleLevels(); syncVisibleLevels();
const responses = []; const responses = [];
for (const id of ids) { for (const id of ids) {
responses.push(await fetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' })); responses.push(await apiFetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }));
} }
responses.forEach(response => { responses.forEach(response => {
if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`); if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`);
@ -199,7 +228,7 @@ createApp({
} }
async function updateLineStyle(line, changes) { async function updateLineStyle(line, changes) {
const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes), body: JSON.stringify(changes),
}); });
@ -210,7 +239,7 @@ createApp({
} }
async function updateLineGeometry(line) { async function updateLineGeometry(line) {
const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
anchor_t: line.anchor_t, anchor_p: line.anchor_p, anchor_t: line.anchor_t, anchor_p: line.anchor_p,
@ -224,7 +253,7 @@ createApp({
} }
async function endLineHere(line) { async function endLineHere(line) {
const response = await fetch(`/api/lines/${encodeURIComponent(line.id)}`, { const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cutoff_t: line.cutoff_t }), body: JSON.stringify({ cutoff_t: line.cutoff_t }),
}); });

79
tests/test_auth.py Normal file
View file

@ -0,0 +1,79 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from starlette.websockets import WebSocketDisconnect
from app.api.meta import router as meta_router
from app.api.routes import router as api_router
from app.api.ws import router as ws_router
from app.config import Settings
from app.runtime import Runtime
@pytest.fixture
def client(tmp_path):
def build(token: str) -> TestClient:
settings = Settings(
chart_auth_token=token,
manual_lines_path=tmp_path / "manual_lines.json",
)
app = FastAPI()
app.include_router(meta_router)
app.include_router(api_router)
app.include_router(ws_router)
app.state.runtime = Runtime(settings)
return TestClient(app)
return build
def test_open_when_no_token_configured(client):
assert client("").get("/api/bars").status_code == 200
def test_rejects_missing_token(client):
assert client("s3cret").get("/api/bars").status_code == 401
def test_rejects_wrong_token(client):
response = client("s3cret").get("/api/bars", headers={"X-Chart-Token": "nope"})
assert response.status_code == 401
def test_accepts_header_token(client):
response = client("s3cret").get("/api/bars", headers={"X-Chart-Token": "s3cret"})
assert response.status_code == 200
def test_accepts_query_token(client):
assert client("s3cret").get("/api/bars?token=s3cret").status_code == 200
def test_writes_are_protected(client):
payload = {
"tf": "1m",
"side": "support",
"anchor_t": 1,
"anchor_p": 1.0,
"end_t": 2,
"end_p": 2.0,
}
assert client("s3cret").post("/api/lines", json=payload).status_code == 401
@pytest.mark.parametrize("path", ["/api/health", "/api/version"])
def test_meta_endpoints_stay_open(client, path):
"""bin/wait-deploy polls /api/version without carrying the token."""
assert client("s3cret").get(path).status_code == 200
def test_websocket_rejects_missing_token(client):
with pytest.raises(WebSocketDisconnect) as excinfo:
with client("s3cret").websocket_connect("/ws"):
pass
assert excinfo.value.code == 1008
def test_websocket_accepts_query_token(client):
with client("s3cret").websocket_connect("/ws?token=s3cret") as socket:
assert socket.receive_json()["type"] == "snapshot"