Files
pvemanager/backend/app/main.py
markmorado 8ca33681ca
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 59s
Deploy to Production / Trigger production update (push) Successful in 8s
feat(scripts): каталог bash-скриптов для нод и гостей (VM/LXC)
Добавлена библиотека скриптов по образцу VMmanager: свои скрипты (CRUD в UI)
и синхронизация из git-репозитория, параметризация через env-переменные
(без конкатенации в shell), выполнение на нодах Proxmox (SSH), в LXC (pct
exec) и в VM (QEMU guest agent, переиспользует существующий execute_script).
Журнал запусков встроен в общий список задач /api/all-tasks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 04:35:43 +05:00

457 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 .api import appstore as appstore_router
from .api import scripts as scripts_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="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>"
)
# 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}")
# Start App Store catalog scheduler (periodic runtipi-appstore sync)
try:
from .services.appstore_catalog import start_catalog_scheduler
start_catalog_scheduler()
logger.info("App Store catalog scheduler started")
except Exception as e:
logger.warning(f"App Store catalog scheduler startup failed: {e}")
# Start App Store state reconciliation (LXC status vs DB, orphan detection)
try:
from .services.appstore_engine import start_reconcile_scheduler
start_reconcile_scheduler()
logger.info("App Store reconcile scheduler started")
except Exception as e:
logger.warning(f"App Store reconcile scheduler 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=<jwt> 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"])
app.include_router(appstore_router.router, tags=["appstore"])
app.include_router(scripts_router.router, tags=["scripts"])
return app
app = create_app()