from fastapi import FastAPI, Depends, Request from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware 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 import auth as auth_module 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"], ) @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'" 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 import config lan = config.is_lan_ip(ip) if not ip.startswith("100.") else config.is_lan_ip(ip) and _router_reachable() 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 # Public auth endpoints app.include_router(auth.router, prefix="/api/auth", tags=["auth"]) # Protected endpoints app.include_router(docker_router.router, prefix="/api/docker", dependencies=[Depends(auth_module.get_current_user)]) app.include_router(gitea.router, prefix="/api/gitea", dependencies=[Depends(auth_module.get_current_user)]) app.include_router(files.router, prefix="/api/files", dependencies=[Depends(auth_module.get_current_user)]) app.include_router(system.router, prefix="/api/system", dependencies=[Depends(auth_module.get_current_user)]) app.include_router(chat_summary.router, prefix="/api/chat-summary", dependencies=[Depends(auth_module.get_current_user)]) # 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")