Enforce CHART_AUTH_TOKEN on /api and /ws; restore /api/version for wait-deploy
This commit is contained in:
parent
9777188a43
commit
641492ae62
9 changed files with 214 additions and 15 deletions
|
|
@ -20,5 +20,7 @@ ALERT_COOLDOWN_SECONDS=900
|
|||
# Notifications and access
|
||||
NTFY_TOPIC=
|
||||
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=
|
||||
REPLAY_FILE=
|
||||
|
|
|
|||
28
README.md
28
README.md
|
|
@ -83,3 +83,31 @@ Manual redeploy:
|
|||
curl -X POST -H "Authorization: Bearer $(cat ~/.coolify-token)" \
|
||||
"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
30
app/api/deps.py
Normal 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
25
app/api/meta.py
Normal 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}
|
||||
|
|
@ -1,14 +1,17 @@
|
|||
import time
|
||||
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 app.bars.models import Timeframe
|
||||
from app.analysis.levels import Side
|
||||
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):
|
||||
|
|
@ -37,11 +40,6 @@ class LinePatch(BaseModel):
|
|||
cutoff_t: int | None = None
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
def health():
|
||||
return {"status": "ok", "service": "chart"}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def status(request: Request):
|
||||
runtime = request.app.state.runtime
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import asyncio
|
|||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.api.deps import token_matches
|
||||
from app.bars.models import Timeframe
|
||||
from app.analysis.alerts import AlertEngine
|
||||
from app.analysis.confluence import cluster_levels
|
||||
|
|
@ -47,6 +48,11 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
|||
|
||||
@router.websocket("/ws")
|
||||
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()
|
||||
runtime = websocket.app.state.runtime
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
|
|
|
|||
2
main.py
2
main.py
|
|
@ -7,6 +7,7 @@ from fastapi import FastAPI
|
|||
from fastapi.responses import FileResponse
|
||||
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.ws import router as ws_router
|
||||
from app.config import Settings
|
||||
|
|
@ -32,6 +33,7 @@ async def lifespan(app: FastAPI):
|
|||
app = FastAPI(title="chart", lifespan=lifespan)
|
||||
|
||||
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||
app.include_router(meta_router)
|
||||
app.include_router(api_router)
|
||||
app.include_router(ws_router)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,31 @@
|
|||
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 = {
|
||||
base_tf: '1m',
|
||||
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);
|
||||
|
||||
async function refreshStatus() {
|
||||
const response = await fetch('/api/status');
|
||||
const response = await apiFetch('/api/status');
|
||||
if (response.ok) status.value = await response.json();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
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.send(JSON.stringify({ type: 'subscribe', tf: timeframe.value }));
|
||||
sendPrefs();
|
||||
|
|
@ -75,8 +102,10 @@ createApp({
|
|||
playAlert();
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
socket.onclose = event => {
|
||||
status.value.stream = 'disconnected';
|
||||
// 1008 is the server rejecting our token; ask once, then reconnect.
|
||||
if (event.code === 1008 && !promptForToken()) return;
|
||||
setTimeout(connect, 2000);
|
||||
};
|
||||
}
|
||||
|
|
@ -131,7 +160,7 @@ createApp({
|
|||
drawPoints.value = [];
|
||||
drawMode.value = false;
|
||||
try {
|
||||
const response = await fetch('/api/lines', {
|
||||
const response = await apiFetch('/api/lines', {
|
||||
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 }),
|
||||
});
|
||||
|
|
@ -166,7 +195,7 @@ createApp({
|
|||
syncVisibleLevels();
|
||||
const responses = [];
|
||||
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 => {
|
||||
if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`);
|
||||
|
|
@ -199,7 +228,7 @@ createApp({
|
|||
}
|
||||
|
||||
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' },
|
||||
body: JSON.stringify(changes),
|
||||
});
|
||||
|
|
@ -210,7 +239,7 @@ createApp({
|
|||
}
|
||||
|
||||
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' },
|
||||
body: JSON.stringify({
|
||||
anchor_t: line.anchor_t, anchor_p: line.anchor_p,
|
||||
|
|
@ -224,7 +253,7 @@ createApp({
|
|||
}
|
||||
|
||||
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' },
|
||||
body: JSON.stringify({ cutoff_t: line.cutoff_t }),
|
||||
});
|
||||
|
|
|
|||
79
tests/test_auth.py
Normal file
79
tests/test_auth.py
Normal 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"
|
||||
Loading…
Reference in a new issue