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
|
# 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=
|
||||||
|
|
|
||||||
28
README.md
28
README.md
|
|
@ -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
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 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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
2
main.py
2
main.py
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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