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, info_engine 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(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"))]) # 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")