Files
nas-tools/dashboard/backend/main.py
T
Gan, Jimmy 14965c7e03
Deploy Dashboard (Dev) / deploy-dev (push) Successful in 2m49s
feat: add cc-connect dashboard health integration
Add a protected backend health endpoint and a new dashboard page/sidebar entry so cc-connect operational status is visible in the UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-02 23:24:03 +08:00

195 lines
7.9 KiB
Python

from fastapi import FastAPI, Depends, Request
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
from routers import docker_router, gitea, files, terminal, system, auth, chat_summary, security, passkey, totp, litellm, cc_connect
import auth as auth_module
from rbac import require_page, require_write
import asyncio
import httpx
import logging
import time
import jwt
from datetime import datetime, timezone, timedelta
from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, CORS_ORIGINS, SECRET_KEY, ALGORITHM, VOLUME_ROOT
_tz_cst = timezone(timedelta(hours=8))
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title="NAS Dashboard")
app.state.limiter = limiter
app.add_middleware(
CORSMiddleware,
allow_origins=CORS_ORIGINS,
allow_credentials=True,
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type"],
)
# Compress responses (static files and API responses)
app.add_middleware(GZipMiddleware, minimum_size=1000)
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(status_code=429, content={"detail": "Too many requests, try again later"})
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss: ws:; frame-ancestors 'none'"
# Cache static assets with hashed filenames (Vite generates these)
if request.url.path.startswith("/assets/"):
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
# Audit logger
import os
_audit_log_path = os.path.join(VOLUME_ROOT, "docker/nas-dashboard/audit.log")
os.makedirs(os.path.dirname(_audit_log_path), exist_ok=True)
_audit_logger = logging.getLogger("audit")
_audit_logger.setLevel(logging.INFO)
_audit_handler = logging.FileHandler(_audit_log_path)
_audit_handler.setFormatter(logging.Formatter("%(message)s"))
_audit_logger.addHandler(_audit_handler)
_audit_logger.propagate = False
@app.middleware("http")
async def audit_log(request: Request, call_next):
start = time.time()
response = await call_next(request)
duration = int((time.time() - start) * 1000)
user = "-"
try:
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
payload = jwt.decode(auth_header[7:], SECRET_KEY, algorithms=[ALGORITHM])
user = payload.get("sub", "-")
except jwt.PyJWTError:
user = "Invalid/Malformed Token"
except Exception:
pass
_audit_logger.info(
"%s %s %s %s %s %d %dms",
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), request.client.host, user,
request.method, request.url.path, response.status_code, duration,
)
return response
async def monitor_containers():
import docker
from config import DOCKER_HOST
client = docker.DockerClient(base_url=DOCKER_HOST)
# Store initial states
previous_states = {}
while True:
try:
for c in client.containers.list(all=True):
current_status = c.status
if c.id in previous_states:
prev_status = previous_states[c.id]
# Alert if it transitioned from running to exited or unhealthy
if prev_status == "running" and current_status in ("exited", "dead"):
msg = f"⚠️ *Container Alert* ⚠️\nContainer `{c.name}` just stopped unexpectedly (Status: {current_status})."
# Use httpx directly as system.send_notification expects a FastAPI request object usually
if TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID:
proxy_url = os.environ.get("TELEGRAM_PROXY")
async with httpx.AsyncClient(proxy=proxy_url) as hc:
await hc.post(
f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
data={"chat_id": TELEGRAM_CHAT_ID, "text": msg, "parse_mode": "Markdown"},
)
previous_states[c.id] = current_status
except Exception as e:
logging.getLogger(__name__).error("Error in container monitor: %s", e)
await asyncio.sleep(60)
@app.on_event("startup")
async def startup_event():
asyncio.create_task(monitor_containers())
@app.get("/api/health")
async def health():
return {"status": "ok"}
@app.get("/api/client-ip")
async def client_ip(request: Request):
ip = request.client.host
import config
if config.is_trusted_proxy(ip):
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
if forwarded:
ip = forwarded
# LAN detection logic:
# - True LAN IP (192.168.31.x) → lan=True
# - Tailscale IP with router reachable → lan=True (user at home with Tailscale)
# - Tailscale IP without router reachable → lan=False (remote via VPS proxy)
# - Other IPs → lan=False (public internet)
if config.is_lan_ip(ip):
lan = True
elif config.is_tailscale_ip(ip):
lan = _router_reachable()
else:
lan = False
return {"lan": lan}
def _router_reachable():
import socket
try:
s = socket.socket()
s.settimeout(1)
s.connect(("192.168.31.1", 80))
s.close()
return True
except Exception:
return False
# Dependency to store user in request.state for rbac dependencies
async def _inject_user(request: Request, user = Depends(auth_module.get_current_user)):
request.state.user = user
return user
# Public auth endpoints
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
app.include_router(passkey.router, prefix="/api/auth", tags=["passkey"])
app.include_router(totp.router, prefix="/api/auth", tags=["totp"])
# Protected endpoints with page-level RBAC
app.include_router(docker_router.router, prefix="/api/docker",
dependencies=[Depends(_inject_user), Depends(require_page("docker")), Depends(require_write())])
app.include_router(gitea.router, prefix="/api/gitea",
dependencies=[Depends(_inject_user), Depends(require_page("gitea"))])
app.include_router(files.router, prefix="/api/files",
dependencies=[Depends(_inject_user), Depends(require_page("files")), Depends(require_write())])
app.include_router(system.router, prefix="/api/system",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(litellm.router, prefix="/api/litellm",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(cc_connect.router, prefix="/api/cc-connect",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(chat_summary.router, prefix="/api/chat-summary",
dependencies=[Depends(_inject_user), Depends(require_page("chat-digest"))])
app.include_router(security.router, prefix="/api/security",
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
# WebSocket endpoint (auth handled inside handler via Query param)
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")