import logging import os from contextlib import asynccontextmanager from pathlib import Path from fastapi import FastAPI, Request, HTTPException, WebSocket, WebSocketDisconnect, Query, Depends from fastapi.responses import RedirectResponse, JSONResponse from fastapi.exception_handlers import http_exception_handler from fastapi.middleware.cors import CORSMiddleware from loguru import logger from .config import settings from .db import Base, engine, init_db, check_db_connection from .auth import PermissionChecker, get_current_user from .models import User from .api import dashboard as dashboard_router from .api import proxmox as proxmox_router from .api import auth as auth_router from .api import templates as templates_router from .api import ipam as ipam_router from .api import logs as logs_router from .api import settings as settings_router from .api import notifications as notifications_router from .api import users as users_router from .api import workspaces as workspaces_router from .api import ssh_keys as ssh_keys_router from .logging_middleware import RequestLoggingMiddleware from .language_middleware import LanguageMiddleware from .i18n import I18nService # Configure logging def setup_logging(): """Setup application logging""" log_dir = Path("logs") log_dir.mkdir(exist_ok=True) # Remove default logger logger.remove() # Add console logging (stderr keeps logs off stdout, which may carry data) import sys logger.add( sink=sys.stderr, level=settings.LOG_LEVEL, format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}" ) # Add file logging logger.add( settings.LOG_FILE, level=settings.LOG_LEVEL, format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}", rotation="10 MB", retention="30 days", compression="zip" ) # Intercept standard logging class InterceptHandler(logging.Handler): def emit(self, record): try: level = logger.level(record.levelname).name except ValueError: level = record.levelno frame, depth = logging.currentframe(), 2 while frame.f_code.co_filename == logging.__file__: frame = frame.f_back depth += 1 logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) logging.basicConfig(handlers=[InterceptHandler()], level=0, force=True) # Suppress noisy third-party loggers for noisy_logger in ( "apscheduler", "apscheduler.scheduler", "apscheduler.executors.default", "httpx", "httpcore", "httpcore.http11", "httpcore.connection", "urllib3", "urllib3.connectionpool", "multipart", ): logging.getLogger(noisy_logger).setLevel(logging.WARNING) # Filter uvicorn access log — suppress GET polling endpoints import re as _re _POLL_RE = _re.compile( r'"GET /(proxmox/api/|settings/api/panel|api/notifications/unread-count|proxmox/api/servers)' ) class _UvicornAccessFilter(logging.Filter): def filter(self, record: logging.LogRecord) -> bool: msg = record.getMessage() # Allow all non-200 responses through if '" 2' not in msg: return True return not bool(_POLL_RE.search(msg)) for uvicorn_logger_name in ("uvicorn.access", "uvicorn"): uvicorn_logger = logging.getLogger(uvicorn_logger_name) uvicorn_logger.addFilter(_UvicornAccessFilter()) @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan handler""" logger.info(f"Starting {settings.PANEL_NAME}") # Setup logging setup_logging() # Security checks if not settings.SECRET_KEY or settings.SECRET_KEY == "your-secret-key-here": logger.error("CRITICAL: SECRET_KEY is missing or uses the default insecure value!") logger.error("Please set a secure SECRET_KEY in your .env file.") raise Exception("Insecure SECRET_KEY") if not settings.FERNET_KEY: logger.warning("FERNET_KEY is not set. Sensitive fields (like passwords) will be stored as plaintext.") logger.warning("To enable encryption, generate a key and set FERNET_KEY in your .env file.") # Production hardening warnings (DEBUG is off in production) if not settings.DEBUG: if settings.ADMIN_PASSWORD == "admin123": logger.warning( "ADMIN_PASSWORD uses the insecure default 'admin123'. " "Set a strong ADMIN_PASSWORD env var for production." ) if settings.CORS_ORIGINS.strip() == "*": logger.warning( "CORS_ORIGINS is '*' (any origin). Set an explicit comma-separated " "origin list (e.g. https://panel.example.com) for production." ) # Check database connection if not check_db_connection(): logger.error("Database connection failed!") raise Exception("Cannot connect to database") # Initialize database try: init_db() logger.info("Database initialized successfully") except Exception as e: logger.error(f"Database initialization failed: {e}") raise # Run database migrations automatically try: from .db import engine from migrations.migrations import run_all_migrations run_all_migrations(engine) logger.info("Database migrations completed") except Exception as e: logger.warning(f"Migration check failed: {e}") # Initialize default admin user try: import init_admin init_admin.create_default_admin() logger.info("Default admin user initialization completed") except Exception as e: logger.error(f"Error running admin initialization: {e}") # Register the main event loop for cross-thread WebSocket broadcasts try: import asyncio from .websocket_manager import set_main_loop set_main_loop(asyncio.get_event_loop()) except Exception as e: logger.warning(f"Failed to register main event loop: {e}") # Start background monitoring worker try: from .workers.monitoring_worker import start_monitoring_worker scheduler = start_monitoring_worker() logger.info("Background monitoring worker started") except Exception as e: logger.warning(f"Background worker startup failed: {e}") # Start backup scheduler backup_scheduler = None try: from .services.backup_scheduler import start_backup_scheduler backup_scheduler = start_backup_scheduler() logger.info("Backup scheduler started") except Exception as e: logger.warning(f"Backup scheduler startup failed: {e}") # Start WebSocket metrics broadcaster try: from .services.metrics_broadcaster import start_metrics_broadcaster from .db import SessionLocal start_metrics_broadcaster(SessionLocal) except Exception as e: logger.warning(f"Metrics broadcaster startup failed: {e}") logger.info("Application startup complete") yield # Cleanup try: if 'scheduler' in locals(): scheduler.shutdown() logger.info("Background worker stopped") except Exception as e: logger.warning(f"Background worker shutdown failed: {e}") try: if backup_scheduler: backup_scheduler.shutdown() logger.info("Backup scheduler stopped") except Exception as e: logger.warning(f"Backup scheduler shutdown failed: {e}") logger.info("Application shutdown") def create_app() -> FastAPI: """Create FastAPI application""" app = FastAPI( title=settings.PANEL_NAME, description="Server management panel with Proxmox integration", lifespan=lifespan, debug=settings.DEBUG ) # Add CORS middleware cors_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()] # allow_credentials=True is incompatible with allow_origins=["*"] per the CORS spec. # Disable credentials when wildcard origin is configured. allow_credentials = cors_origins != ["*"] app.add_middleware( CORSMiddleware, allow_origins=cors_origins, allow_credentials=allow_credentials, allow_methods=["*"], allow_headers=["*"], ) # Add request logging middleware app.add_middleware(RequestLoggingMiddleware, enable_api_logging=True) # Add language detection middleware app.add_middleware(LanguageMiddleware) # Security headers middleware @app.middleware("http") async def add_security_headers(request: Request, call_next): response = await call_next(request) response.headers["X-Content-Type-Options"] = "nosniff" response.headers["X-Frame-Options"] = "SAMEORIGIN" 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' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://esm.sh; " "style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net https://fonts.googleapis.com; " "img-src 'self' data: blob:; " "connect-src 'self' wss: ws: https://esm.sh https://api.github.com; " "font-src 'self' data: https://cdnjs.cloudflare.com https://fonts.gstatic.com; " "frame-ancestors 'self';" ) if not settings.DEBUG: response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" return response # Custom exception handler @app.exception_handler(Exception) async def global_exception_handler(request: Request, exc: Exception): logger.error(f"Unhandled exception: {exc}", exc_info=True) return JSONResponse( status_code=500, content={"detail": "Internal server error"} ) @app.exception_handler(HTTPException) async def custom_http_exception_handler(request: Request, exc: HTTPException): logger.warning(f"HTTP exception: {exc.status_code} - {exc.detail}") return await http_exception_handler(request, exc) # Health check endpoint @app.get("/health") async def health_check(): """Health check endpoint""" db_healthy = check_db_connection() return { "status": "healthy" if db_healthy else "unhealthy", "version": settings.VERSION, "database": "connected" if db_healthy else "disconnected" } # Root — API info @app.get("/", include_in_schema=False) async def root(): return {"name": settings.PANEL_NAME, "version": settings.VERSION, "docs": "/docs"} # API for virtual machines list (proxy to proxmox router) @app.get("/api/virtual-machines") async def api_virtual_machines(request: Request): """Proxy to proxmox virtual-machines API""" return RedirectResponse(url="/proxmox/api/virtual-machines", status_code=307) # WebSocket endpoint for real-time task updates @app.websocket("/ws/tasks") async def websocket_tasks(websocket: WebSocket, token: str = Query(None)): """ WebSocket endpoint for live task manager updates. Auth via ?token= query parameter (browsers cannot set WS headers). """ from .auth import decode_access_token from .db import SessionLocal from .models import TaskQueue, ProxmoxTask, User from .websocket_manager import ws_manager import asyncio import json # --- Authenticate -------------------------------------------------- if not token: await websocket.close(code=4001, reason="Missing token") return try: payload = decode_access_token(token) username = payload.get("sub") if not username: raise ValueError("No subject in token") except Exception: await websocket.close(code=4001, reason="Invalid token") return db = SessionLocal() try: user = db.query(User).filter(User.username == username, User.is_active == True).first() if not user: await websocket.close(code=4001, reason="User not found") return user_id = user.id finally: db.close() # --- Connect ------------------------------------------------------- await ws_manager.connect(websocket, user_id) try: # Send initial snapshot db = SessionLocal() try: bulk = db.query(TaskQueue).filter(TaskQueue.user_id == user_id).order_by(TaskQueue.created_at.desc()).limit(30).all() prox = db.query(ProxmoxTask).filter(ProxmoxTask.user_id == user_id).order_by(ProxmoxTask.created_at.desc()).limit(30).all() finally: db.close() all_tasks = sorted( [t.to_dict() for t in bulk] + [t.to_dict() for t in prox], key=lambda x: x.get("created_at") or "", reverse=True, )[:30] await websocket.send_text(json.dumps({"type": "task_list", "tasks": all_tasks}, default=str)) # Keep connection alive – wait for client messages / ping-pong while True: try: raw = await websocket.receive_text() # Legacy plain-text ping if raw == "ping": await websocket.send_text(json.dumps({"type": "pong"})) continue # JSON message dispatcher try: msg = json.loads(raw) except Exception: continue msg_type = msg.get("type", "") if msg_type == "ping": await websocket.send_text(json.dumps({"type": "pong"})) elif msg_type == "subscribe": channel = msg.get("channel", "") if channel: ws_manager.subscribe(websocket, channel) # Immediately send cached payload so client sees data without waiting from .services.metrics_broadcaster import get_cached_metrics _cached = get_cached_metrics(channel) if _cached: try: await websocket.send_text(json.dumps(_cached, default=str)) except Exception: pass elif msg_type == "unsubscribe": channel = msg.get("channel", "") if channel: ws_manager.unsubscribe(websocket, channel) elif msg_type == "request": action = msg.get("action", "") request_id = msg.get("request_id", "") if action in ("get_rrd", "get_node_rrd") and request_id: from .services.metrics_broadcaster import handle_rrd_request from .db import SessionLocal as _SL asyncio.create_task( handle_rrd_request( websocket, request_id, action, msg.get("params", {}), _SL, ) ) except WebSocketDisconnect: break except Exception: break finally: ws_manager.disconnect(websocket, user_id) # Include routers app.include_router(auth_router.router, tags=["auth"]) app.include_router(dashboard_router.router, tags=["dashboard"]) app.include_router(proxmox_router.router, prefix="/proxmox", tags=["proxmox"]) app.include_router(templates_router.router, prefix="/templates", tags=["templates"]) app.include_router(ipam_router.router, prefix="/ipam", tags=["ipam"]) app.include_router(logs_router.router, prefix="/logs", tags=["logs"]) app.include_router(settings_router.router, prefix="/settings", tags=["settings"]) app.include_router(notifications_router.router, tags=["notifications"]) app.include_router(users_router.router, prefix="/admin", tags=["users", "admin"]) app.include_router(workspaces_router.router, tags=["workspaces"]) app.include_router(ssh_keys_router.router, tags=["ssh-keys"]) return app app = create_app()