feat: add ADMIN_KEY auth guard to sensitive settings and system endpoints

Former-commit-id: 0eaa7813a16f13e123e9c131fcf90fcb8bf420fd
This commit is contained in:
anoracleofra-code
2026-03-14 07:56:14 -06:00
parent 9ad35fb5d8
commit 17c41d7ddf
3 changed files with 117 additions and 34 deletions
+5
View File
@@ -13,3 +13,8 @@ AIS_API_KEY= # https://aisstream.io/ — free tier WebSocket key
# Override allowed CORS origins (comma-separated). Defaults to localhost + LAN auto-detect.
# CORS_ORIGINS=http://192.168.1.50:3000,https://my-domain.com
# Admin key — protects sensitive endpoints (API key management, system update).
# If unset, these endpoints remain open (fine for local dev).
# Set this in production and enter the same key in Settings → Admin Key.
# ADMIN_KEY=your-secret-admin-key-here
+75 -29
View File
@@ -16,6 +16,7 @@ _SECRET_VARS = [
"OPENSKY_CLIENT_SECRET",
"LTA_ACCOUNT_KEY",
"CORS_ORIGINS",
"ADMIN_KEY",
]
for _var in _SECRET_VARS:
@@ -35,7 +36,7 @@ for _var in _SECRET_VARS:
except Exception as _e:
logger.error(f"Failed to read secret file {_file_path} for {_var}: {_e}")
from fastapi import FastAPI, Request, Response, Query
from fastapi import FastAPI, Request, Response, Query, Depends, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from services.data_fetcher import start_scheduler, stop_scheduler, get_latest_data, source_timestamps
@@ -44,13 +45,32 @@ from services.carrier_tracker import start_carrier_tracker, stop_carrier_tracker
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from services.schemas import HealthResponse, RefreshResponse
import uvicorn
import hashlib
import json as json_mod
import socket
import threading
limiter = Limiter(key_func=get_remote_address)
# ---------------------------------------------------------------------------
# Admin authentication — protects settings & system endpoints
# Set ADMIN_KEY in .env or Docker secrets. If unset, endpoints remain open
# for local-dev convenience but will log a startup warning.
# ---------------------------------------------------------------------------
_ADMIN_KEY = os.environ.get("ADMIN_KEY", "")
if not _ADMIN_KEY:
logger.warning("ADMIN_KEY is not set — sensitive endpoints are UNPROTECTED. "
"Set ADMIN_KEY in .env or Docker secrets for production.")
def require_admin(request: Request):
"""FastAPI dependency that rejects requests without a valid X-Admin-Key header."""
if not _ADMIN_KEY:
return # No key configured — allow all (local dev)
if request.headers.get("X-Admin-Key") != _ADMIN_KEY:
raise HTTPException(status_code=403, detail="Forbidden — invalid or missing admin key")
def _build_cors_origins():
"""Build a CORS origins whitelist: localhost + LAN IPs + env overrides.
@@ -79,8 +99,6 @@ def _build_cors_origins():
@asynccontextmanager
async def lifespan(app: FastAPI):
import threading
# Start AIS stream first — it loads the disk cache (instant ships) then
# begins accumulating live vessel data via WebSocket in the background.
start_ais_stream()
@@ -127,28 +145,42 @@ app.add_middleware(
from services.data_fetcher import update_all_data
_refresh_in_progress = False
_refresh_lock = threading.Lock()
@app.get("/api/refresh")
@app.get("/api/refresh", response_model=RefreshResponse)
@limiter.limit("2/minute")
async def force_refresh(request: Request):
global _refresh_in_progress
if _refresh_in_progress:
if not _refresh_lock.acquire(blocking=False):
return {"status": "refresh already in progress"}
import threading
def _do_refresh():
global _refresh_in_progress
try:
update_all_data()
finally:
_refresh_in_progress = False
_refresh_in_progress = True
_refresh_lock.release()
t = threading.Thread(target=_do_refresh)
t.start()
return {"status": "refreshing in background"}
@app.post("/api/ais/feed")
@limiter.limit("60/minute")
async def ais_feed(request: Request):
"""Accept AIS-catcher HTTP JSON feed (POST decoded AIS messages)."""
from services.ais_stream import ingest_ais_catcher
try:
body = await request.json()
except Exception:
return Response(content='{"error":"invalid JSON"}', status_code=400, media_type="application/json")
msgs = body.get("msgs", [])
if not msgs:
return {"status": "ok", "ingested": 0}
count = ingest_ais_catcher(msgs)
return {"status": "ok", "ingested": count}
@app.get("/api/live-data")
async def live_data():
@limiter.limit("120/minute")
async def live_data(request: Request):
return get_latest_data()
def _etag_response(request: Request, payload: dict, prefix: str = "", default=None):
@@ -207,12 +239,14 @@ async def live_data_slow(request: Request):
return _etag_response(request, payload, prefix="slow|", default=str)
@app.get("/api/debug-latest")
async def debug_latest_data():
@limiter.limit("30/minute")
async def debug_latest_data(request: Request):
return list(get_latest_data().keys())
@app.get("/api/health")
async def health_check():
@app.get("/api/health", response_model=HealthResponse)
@limiter.limit("30/minute")
async def health_check(request: Request):
import time
d = get_latest_data()
last = d.get("last_updated")
@@ -241,19 +275,24 @@ _start_time = __import__("time").time()
from services.radio_intercept import get_top_broadcastify_feeds, get_openmhz_systems, get_recent_openmhz_calls, find_nearest_openmhz_system
@app.get("/api/radio/top")
async def get_top_radios():
@limiter.limit("30/minute")
async def get_top_radios(request: Request):
return get_top_broadcastify_feeds()
@app.get("/api/radio/openmhz/systems")
async def api_get_openmhz_systems():
@limiter.limit("30/minute")
async def api_get_openmhz_systems(request: Request):
return get_openmhz_systems()
@app.get("/api/radio/openmhz/calls/{sys_name}")
async def api_get_openmhz_calls(sys_name: str):
@limiter.limit("60/minute")
async def api_get_openmhz_calls(request: Request, sys_name: str):
return get_recent_openmhz_calls(sys_name)
@app.get("/api/radio/nearest")
@limiter.limit("60/minute")
async def api_get_nearest_radio(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180),
):
@@ -262,7 +301,9 @@ async def api_get_nearest_radio(
from services.radio_intercept import find_nearest_openmhz_systems_list
@app.get("/api/radio/nearest-list")
@limiter.limit("60/minute")
async def api_get_nearest_radios_list(
request: Request,
lat: float = Query(..., ge=-90, le=90),
lng: float = Query(..., ge=-180, le=180),
limit: int = Query(5, ge=1, le=20),
@@ -272,7 +313,8 @@ async def api_get_nearest_radios_list(
from services.network_utils import fetch_with_curl
@app.get("/api/route/{callsign}")
async def get_flight_route(callsign: str, lat: float = 0.0, lng: float = 0.0):
@limiter.limit("60/minute")
async def get_flight_route(request: Request, callsign: str, lat: float = 0.0, lng: float = 0.0):
r = fetch_with_curl("https://api.adsb.lol/api/0/routeset", method="POST", json_data={"planes": [{"callsign": callsign, "lat": lat, "lng": lng}]}, timeout=10)
if r and r.status_code == 200:
data = r.json()
@@ -330,12 +372,14 @@ class ApiKeyUpdate(BaseModel):
env_key: str
value: str
@app.get("/api/settings/api-keys")
async def api_get_keys():
@app.get("/api/settings/api-keys", dependencies=[Depends(require_admin)])
@limiter.limit("30/minute")
async def api_get_keys(request: Request):
return get_api_keys()
@app.put("/api/settings/api-keys")
async def api_update_key(body: ApiKeyUpdate):
@app.put("/api/settings/api-keys", dependencies=[Depends(require_admin)])
@limiter.limit("10/minute")
async def api_update_key(request: Request, body: ApiKeyUpdate):
ok = update_api_key(body.env_key, body.value)
if ok:
return {"status": "updated", "env_key": body.env_key}
@@ -347,10 +391,12 @@ async def api_update_key(body: ApiKeyUpdate):
from services.news_feed_config import get_feeds, save_feeds, reset_feeds
@app.get("/api/settings/news-feeds")
async def api_get_news_feeds():
@limiter.limit("30/minute")
async def api_get_news_feeds(request: Request):
return get_feeds()
@app.put("/api/settings/news-feeds")
@app.put("/api/settings/news-feeds", dependencies=[Depends(require_admin)])
@limiter.limit("10/minute")
async def api_save_news_feeds(request: Request):
body = await request.json()
ok = save_feeds(body)
@@ -362,8 +408,9 @@ async def api_save_news_feeds(request: Request):
media_type="application/json",
)
@app.post("/api/settings/news-feeds/reset")
async def api_reset_news_feeds():
@app.post("/api/settings/news-feeds/reset", dependencies=[Depends(require_admin)])
@limiter.limit("10/minute")
async def api_reset_news_feeds(request: Request):
ok = reset_feeds()
if ok:
return {"status": "reset", "feeds": get_feeds()}
@@ -375,7 +422,7 @@ async def api_reset_news_feeds():
from pathlib import Path
from services.updater import perform_update, schedule_restart
@app.post("/api/system/update")
@app.post("/api/system/update", dependencies=[Depends(require_admin)])
@limiter.limit("1/minute")
async def system_update(request: Request):
"""Download latest release, backup current files, extract update, and restart."""
@@ -388,7 +435,6 @@ async def system_update(request: Request):
media_type="application/json",
)
# Schedule restart AFTER response flushes (2s delay)
import threading
threading.Timer(2.0, schedule_restart, args=[project_root]).start()
return result
+37 -5
View File
@@ -52,6 +52,17 @@ type Tab = "api-keys" | "news-feeds";
const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) {
const [activeTab, setActiveTab] = useState<Tab>("api-keys");
// --- Admin Key (for protected endpoints) ---
const [adminKey, setAdminKey] = useState(() => {
if (typeof window !== 'undefined') return localStorage.getItem('sb_admin_key') || '';
return '';
});
const adminHeaders = (extra?: Record<string, string>): Record<string, string> => {
const h: Record<string, string> = { ...extra };
if (adminKey) h['X-Admin-Key'] = adminKey;
return h;
};
// --- API Keys state ---
const [apis, setApis] = useState<ApiEntry[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
@@ -67,7 +78,9 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
const fetchKeys = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/api-keys`);
const res = await fetch(`${API_BASE}/api/settings/api-keys`, {
headers: adminHeaders(),
});
if (res.ok) setApis(await res.json());
} catch (e) {
console.error("Failed to fetch API keys", e);
@@ -102,7 +115,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
try {
const res = await fetch(`${API_BASE}/api/settings/api-keys`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
headers: adminHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ env_key: api.env_key, value: editValue }),
});
if (res.ok) { setEditingId(null); fetchKeys(); }
@@ -151,7 +164,7 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
try {
const res = await fetch(`${API_BASE}/api/settings/news-feeds`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
headers: adminHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(feeds),
});
if (res.ok) {
@@ -168,7 +181,10 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
const resetFeeds = async () => {
try {
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, { method: "POST" });
const res = await fetch(`${API_BASE}/api/settings/news-feeds/reset`, {
method: "POST",
headers: adminHeaders(),
});
if (res.ok) {
const d = await res.json();
setFeeds(d.feeds || []);
@@ -220,7 +236,23 @@ const SettingsPanel = React.memo(function SettingsPanel({ isOpen, onClose }: { i
</button>
</div>
{/* Tab Bar */}
{/* Admin Key Bar */}
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-[var(--border-primary)]/40 bg-[var(--bg-primary)]/30">
<Shield size={12} className={adminKey ? "text-green-400" : "text-yellow-500"} />
<span className="text-[9px] font-mono tracking-widest text-[var(--text-muted)] whitespace-nowrap">ADMIN KEY</span>
<input
type="password"
value={adminKey}
onChange={(e) => {
setAdminKey(e.target.value);
localStorage.setItem('sb_admin_key', e.target.value);
}}
placeholder="Enter admin key for protected operations..."
className="flex-1 bg-[var(--bg-primary)]/60 border border-[var(--border-primary)] rounded px-2 py-1 text-[10px] font-mono text-[var(--text-secondary)] outline-none focus:border-cyan-700 placeholder:text-[var(--text-muted)]/50"
/>
{adminKey && <span className="text-[8px] font-mono text-green-400/70 tracking-widest">SET</span>}
</div>
<div className="flex border-b border-[var(--border-primary)]/60">
<button
onClick={() => setActiveTab("api-keys")}