Files
nas-tools/dashboard/backend/main.py
T
Gan, Jimmy 3942a344c7
Run Tests / Backend Tests (push) Failing after 1m30s
Run Tests / Test Summary (push) Has been cancelled
Run Tests / Frontend Tests (push) Has started running
fix: handle missing static directory gracefully for tests
2026-04-04 13:31:32 +08:00

237 lines
9.5 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, chat_summary, security, passkey, totp, litellm, cc_connect, info_engine, opc_tasks, opc_agents, opc_ws, opc_projects, auth as auth_router
import auth_service as auth_module
import config
from rbac import require_page
from contextlib import asynccontextmanager
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, get_client_ip
_tz_cst = timezone(timedelta(hours=8))
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
print("=== Application Startup ===")
# Initialize OPC database
from db import opc_db
try:
await opc_db.init_db()
print("OPC database initialized")
except Exception as e:
print(f"Failed to initialize OPC database: {e}")
import traceback
traceback.print_exc()
# Start agent executor service
from services import agent_executor
executor_task = None
try:
executor_task = asyncio.create_task(agent_executor.start_executor())
print("Agent executor service started")
except Exception as e:
print(f"Failed to start agent executor: {e}")
import traceback
traceback.print_exc()
monitor_task = asyncio.create_task(monitor_containers())
yield
# Shutdown
print("=== Application Shutdown ===")
if executor_task:
executor_task.cancel()
monitor_task.cancel()
limiter = Limiter(key_func=get_remote_address)
app = FastAPI(title="NAS Dashboard", lifespan=lifespan)
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 = getattr(request.state, "user", None)
username = user.username if user else "-"
_audit_logger.info(
"%s %s %s %s %s %d %dms",
datetime.now(_tz_cst).strftime("%Y-%m-%dT%H:%M:%S"), get_client_ip(request), username,
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.get("/api/health")
async def health():
return {"status": "ok"}
@app.get("/api/client-ip")
async def client_ip(request: Request):
ip = get_client_ip(request)
# 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.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"))])
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"))])
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(info_engine.router, prefix="/api/info-engine",
dependencies=[Depends(_inject_user), Depends(require_page("dashboard"))])
app.include_router(security.router, prefix="/api/security",
dependencies=[Depends(_inject_user), Depends(require_page("security"))])
# OPC endpoints
app.include_router(opc_tasks.router, prefix="/api/opc",
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
app.include_router(opc_agents.router, prefix="/api/opc",
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
app.include_router(opc_projects.router, prefix="/api/opc",
dependencies=[Depends(_inject_user), Depends(require_page("opc"))])
# WebSocket endpoint (auth handled inside handler via auth cookie; query token fallback temporary)
app.add_api_websocket_route("/ws/terminal", terminal.ws_endpoint)
app.add_api_websocket_route("/ws/opc", opc_ws.websocket_endpoint)
# Mount static files (skip if directory doesn't exist, e.g., in test environments)
import os
if os.path.isdir("/app/static"):
app.mount("/", StaticFiles(directory="/app/static", html=True), name="static")
else:
# In test environment, create a minimal fallback
import tempfile
_test_static = tempfile.mkdtemp()
with open(os.path.join(_test_static, "index.html"), "w") as f:
f.write("<html><body>Test Environment</body></html>")
app.mount("/", StaticFiles(directory=_test_static, html=True), name="static")