import asyncio import logging import time import traceback as tb from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone import docker.errors import httpx from fastapi import Depends, FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from slowapi import Limiter from slowapi.errors import RateLimitExceeded from slowapi.util import get_remote_address import auth_service as auth_module import config from config import CORS_ORIGINS, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID, VOLUME_ROOT, get_client_ip from rbac import require_page _tz_cst = timezone(timedelta(hours=8)) @asynccontextmanager async def lifespan(app: FastAPI): # Startup print("=== Application Startup ===") # Warn if cookies are not marked Secure (must be behind TLS-terminating proxy) import auth_service as auth_svc if not auth_svc.COOKIE_SECURE and not os.environ.get("ALLOW_INSECURE_COOKIES"): print("WARNING: COOKIE_SECURE=False — auth cookies not marked Secure. " "This requires a TLS-terminating reverse proxy (e.g. Caddy) in front. " "Set ALLOW_INSECURE_COOKIES=true to suppress this warning.") # 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.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): """Handle request validation errors with detailed information.""" logging.error(f"Validation error on {request.method} {request.url.path}: {exc.errors()}") return JSONResponse(status_code=422, content={"detail": exc.errors()}) @app.exception_handler(docker.errors.NotFound) async def docker_not_found_handler(request: Request, exc: docker.errors.NotFound): """Handle Docker container/image not found errors.""" logging.warning(f"Docker resource not found: {exc}") return JSONResponse(status_code=404, content={"detail": "Docker resource not found"}) @app.exception_handler(docker.errors.APIError) async def docker_api_error_handler(request: Request, exc: docker.errors.APIError): """Handle Docker API errors.""" logging.error(f"Docker API error: {exc}") return JSONResponse(status_code=503, content={"detail": "Docker service unavailable"}) @app.exception_handler(Exception) async def generic_exception_handler(request: Request, exc: Exception): """Handle all unhandled exceptions.""" logging.error(f"Unhandled exception on {request.method} {request.url.path}: {exc}") logging.error(tb.format_exc()) return JSONResponse(status_code=500, content={"detail": "Internal server error"}) @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 import time import logging from config import DOCKER_HOST _monitor_logger = logging.getLogger(__name__) def _docker_retry(fn, max_retries=3, base_delay=1): for attempt in range(max_retries): try: return fn() except Exception as e: delay = base_delay * (2 ** attempt) _monitor_logger.warning("Docker monitor error (attempt %d/%d): %s. Retrying in %ds...", attempt+1, max_retries, e, delay) if attempt < max_retries - 1: time.sleep(delay) _monitor_logger.error("Docker monitor failed after %d attempts", max_retries) return None client = docker.DockerClient(base_url=DOCKER_HOST) # Store initial states previous_states = {} while True: try: containers = await asyncio.to_thread( lambda: docker_retry(lambda: client.containers.list(all=True)) or [] ) for c in containers: 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 # Auto-discovered router registration (from ROUTER_CONFIGS manifest) from routers import ROUTER_CONFIGS, WS_ROUTES import importlib import re for cfg in ROUTER_CONFIGS: mod = importlib.import_module('routers.' + cfg['module']) router_attr = cfg.get('router_attr', 'router') router_obj = getattr(mod, router_attr) prefix = cfg.get('prefix') tags = cfg.get('tags') deps = cfg.get('deps', []) dep_objects = [] for d in deps: if d == '_inject_user': dep_objects.append(Depends(_inject_user)) elif d.startswith('require_page('): # Extract page name from require_page('gitea') or require_page(gitea) m = re.search(r'require_page\(["\'](.*?)["\']\)', d) page_name = m.group(1) if m else d.split('(')[1].rstrip(')') dep_objects.append(Depends(require_page(page_name))) kwargs = {'dependencies': dep_objects} if dep_objects else {} if prefix is not None: kwargs['prefix'] = prefix if tags: kwargs['tags'] = tags app.include_router(router_obj, **kwargs) for ws in WS_ROUTES: mod = importlib.import_module('routers.' + ws['module']) handler = getattr(mod, ws['handler']) app.add_api_websocket_route(ws['path'], handler) # 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("Test Environment") app.mount("/", StaticFiles(directory=_test_static, html=True), name="static")