fix(security): authenticate VNC/terminal WebSockets and harden SSH/config
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 46s

Critical security and robustness fixes from a code audit:

- WS terminal/VNC now require a JWT (?token=), validate the session,
  vms.console permission and VM ownership before bridging to Proxmox
  (previously the LXC terminal gave unauthenticated root shell access).
  Frontend ConsolePage passes the token in both WS URLs.
- Fix SSH fallback for `pvecm delnode`: SSHClient now supports password
  auth (it previously crashed with TypeError); ProxmoxClient stores the
  password for SSH fallbacks and the host port suffix is stripped.
- Add production startup warnings for default ADMIN_PASSWORD and wildcard
  CORS_ORIGINS.
- dashboard: use tz-aware utcnow() instead of naive datetime.utcnow().
- crypto: narrow decrypt_value except to InvalidToken and log failures.
- ssh_client: guard the shared connection cache with a lock; make
  unknown-host-key rejection opt-in via SSH_REJECT_UNKNOWN_HOSTS.
- logging: send loguru console output to stderr instead of stdout.
- Sync version fallback to 1.5.1; remove one-off refactor scripts.
- Add CODE_REVIEW.md audit report.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 06:31:26 +05:00
parent bfd92102cf
commit 50f2ee1966
17 changed files with 394 additions and 503 deletions

View File

@@ -24,6 +24,9 @@ ACCESS_TOKEN_EXPIRE_MINUTES=1440
SSH_TIMEOUT=10
DEFAULT_SSH_USER=root
DEFAULT_SSH_PORT=22
# Reject SSH connections to hosts with unknown/changed host keys (mitigates MITM).
# Enable in production once your servers' host keys are in known_hosts.
SSH_REJECT_UNKNOWN_HOSTS=false
# Security
# In production set to your actual domain(s), e.g.: https://panel.example.com

View File

@@ -1,11 +1,12 @@
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from datetime import timedelta
from ..db import get_db
from ..models import ProxmoxServer, AuditLog, User
from ..auth import PermissionChecker
from ..config import utcnow
router = APIRouter()
@@ -39,7 +40,7 @@ def dashboard_stats(
cluster_q = cluster_q.filter(ProxmoxServer.id.in_(server_ids))
total_clusters = cluster_q.scalar() or 0
since_24h = datetime.utcnow() - timedelta(hours=24)
since_24h = utcnow() - timedelta(hours=24)
total_alerts = db.query(AuditLog).filter(
AuditLog.level.in_(['error', 'critical']),
AuditLog.created_at >= since_24h
@@ -88,7 +89,7 @@ def get_dashboard_alerts(
current_user: User = Depends(PermissionChecker("logs.view"))
):
"""Вернуть события error/critical за последние 24 ч для дашборда"""
since_24h = datetime.utcnow() - timedelta(hours=24)
since_24h = utcnow() - timedelta(hours=24)
alerts = db.query(AuditLog).filter(
AuditLog.level.in_(['error', 'critical']),
AuditLog.created_at >= since_24h

View File

@@ -13,7 +13,9 @@ from ...db import get_db
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
from ...proxmox import ProxmoxClient, get_proxmox_resources
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
from ...auth import (get_current_user, PermissionChecker, require_permission,
check_permission, authenticate_ws_token)
from ...rbac import PermissionEngine
from ...logging_service import LoggingService
from ...ipam_service import IPAMService
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
@@ -2001,15 +2003,28 @@ async def vnc_websocket_proxy(
vncticket: str,
vnc_password: str = None, # VNC пароль сгенерированный через generate-password
auth_ticket: str = None, # Auth ticket переданный с frontend
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
db: Session = Depends(get_db)
):
"""WebSocket прокси для VNC подключения к Proxmox"""
import websockets
import urllib.parse
# --- Authenticate the panel user before bridging to Proxmox ------------
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "vms.console"):
await websocket.close(code=4003, reason="Permission denied")
return
if not check_vm_access(db, user, server_id, vmid):
await websocket.close(code=4003, reason="Access denied")
return
await websocket.accept()
logger.info(f"VNC WebSocket connection accepted for {vmtype}/{vmid}")
logger.info(f"VNC WebSocket connection accepted for {vmtype}/{vmid} (user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
logger.error(f"Proxmox server {server_id} not found")
@@ -2954,6 +2969,7 @@ async def terminal_websocket_fixed(
server_id: int,
node: str,
vmid: int,
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
db: Session = Depends(get_db)
):
"""WebSocket терминал для LXC контейнеров через Proxmox termproxy API"""
@@ -2961,8 +2977,20 @@ async def terminal_websocket_fixed(
import httpx
from urllib.parse import quote
# --- Authenticate the panel user before opening a root shell -----------
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "vms.console"):
await websocket.close(code=4003, reason="Permission denied")
return
if not check_vm_access(db, user, server_id, vmid):
await websocket.close(code=4003, reason="Access denied")
return
await websocket.accept()
logger.info(f"Terminal WebSocket accepted for container {vmid} on node {node}")
logger.info(f"Terminal WebSocket accepted for container {vmid} on node {node} (user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:

View File

@@ -135,6 +135,42 @@ def get_current_session_token(
return payload.get("session")
def authenticate_ws_token(token: Optional[str], db: Session) -> Optional[User]:
"""Authenticate a WebSocket connection from a JWT passed as a query parameter.
Browsers cannot set Authorization headers on WebSocket handshakes, so the
token is supplied via ?token=<jwt>. Returns the authenticated, active User
(with a valid session, if the token is session-bound) or ``None`` on any
failure. Unlike ``decode_access_token`` this never raises — callers close
the socket with an appropriate code instead.
"""
if not token:
return None
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
except jwt.PyJWTError:
return None
username = payload.get("sub")
if not username:
return None
# Validate the bound session, mirroring get_current_user.
session_token = payload.get("session")
if session_token:
from .services.security_service import SecurityService
if not SecurityService.validate_session(db, session_token):
return None
user = db.query(User).options(joinedload(User.role)).filter(User.username == username).first()
if user is None or not user.is_active:
return None
if user.is_locked():
return None
return user
def get_current_active_admin(current_user: User = Depends(get_current_user)) -> User:
"""Get current active admin user"""
if not current_user.is_admin and (not current_user.role or current_user.role.name != "admin"):

View File

@@ -16,7 +16,8 @@ def get_version() -> str:
for version_file in possible_paths:
if version_file.exists():
return version_file.read_text().strip()
return "1.7.0"
# Fallback only if the VERSION file is missing — keep in sync with /VERSION.
return "1.5.1"
class Settings(BaseSettings):
@@ -46,6 +47,10 @@ class Settings(BaseSettings):
SSH_TIMEOUT: int = Field(default=10, env="SSH_TIMEOUT")
DEFAULT_SSH_USER: str = Field(default="root", env="DEFAULT_SSH_USER")
DEFAULT_SSH_PORT: int = Field(default=22, env="DEFAULT_SSH_PORT")
# Reject SSH connections to hosts with unknown/changed host keys (mitigates MITM).
# Default False preserves legacy behaviour (warn & accept); enable in production
# once your servers' host keys are in known_hosts.
SSH_REJECT_UNKNOWN_HOSTS: bool = Field(default=False, env="SSH_REJECT_UNKNOWN_HOSTS")
# CORS — comma-separated list of allowed origins; "*" is insecure in production
CORS_ORIGINS: str = Field(default="*", env="CORS_ORIGINS")

View File

@@ -56,8 +56,15 @@ def decrypt_value(value: str) -> str:
return value
try:
return f.decrypt(value.encode()).decode()
except (InvalidToken, Exception):
# Value is likely plaintext (pre-encryption legacy row)
except InvalidToken:
# Value is likely plaintext (pre-encryption legacy row) or was
# encrypted with a different FERNET_KEY. Return as-is for backward
# compatibility, but warn so key-rotation mistakes are visible.
logger.warning(
"Fernet decryption failed (InvalidToken). Value returned as-is — "
"this is expected for legacy plaintext rows, but check FERNET_KEY "
"if previously-encrypted data is now unreadable."
)
return value

View File

@@ -37,10 +37,11 @@ def setup_logging():
# Remove default logger
logger.remove()
# Add console logging
# Add console logging (stderr keeps logs off stdout, which may carry data)
import sys
logger.add(
sink=lambda msg: print(msg, end=""),
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>"
)
@@ -123,6 +124,19 @@ async def lifespan(app: FastAPI):
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():

View File

@@ -101,6 +101,8 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
self.host = host
self.user = user
self.timeout = timeout
# Stored for SSH fallbacks (e.g. pvecm delnode) that need root password auth
self._password = password
# Улучшенный ключ кеша - включаем token_name для уникальности
self.connection_key = f"{host}:{user}:{token_name or 'password'}"
self.proxmox = None

View File

@@ -286,8 +286,10 @@ class ClusterMixin:
def _delete_cluster_node_via_ssh(self, node_name: str) -> Dict:
"""SSH fallback для удаления ноды через pvecm delnode"""
from app.ssh_client import SSHClient
# self.host may carry an API port suffix (e.g. "1.2.3.4:8006"); SSH needs the bare host.
ssh_host = self.host.split(':')[0] if self.host else self.host
ssh = SSHClient(
hostname=self.host,
hostname=ssh_host,
username='root',
password=getattr(self, '_password', None),
)

View File

@@ -1,5 +1,6 @@
import logging
import asyncio
import threading
from typing import Dict, Optional, Union
from concurrent.futures import ThreadPoolExecutor
import paramiko
@@ -16,17 +17,20 @@ ssh_executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix="ssh_")
class SSHClient:
"""Улучшенный SSH клиент для подключения к серверам с кешированием"""
# Connection cache
# Connection cache (shared across threads — guard every access with _cache_lock)
_connections = {}
_connection_times = {}
_cache_lock = threading.Lock()
def __init__(self, hostname: str, port: int = 22, username: str = "root",
key_path: Optional[str] = None, timeout: int = 10, max_retries: int = 3):
def __init__(self, hostname: str, port: int = 22, username: str = "root",
key_path: Optional[str] = None, timeout: int = 10, max_retries: int = 3,
password: Optional[str] = None):
self.hostname = hostname
self.port = port
self.username = username
self.key_path = key_path
self.password = password
self.timeout = timeout
self.max_retries = max_retries
self.client = None
@@ -35,10 +39,12 @@ class SSHClient:
def _get_cached_connection(self) -> Optional[paramiko.SSHClient]:
"""Get cached connection if available and valid"""
if self.connection_key in self._connections:
client = self._connections[self.connection_key]
with self._cache_lock:
client = self._connections.get(self.connection_key)
if client is None:
return None
connection_time = self._connection_times.get(self.connection_key, 0)
# Check if connection is still valid (max 5 minutes)
if time.time() - connection_time < 300:
try:
@@ -48,26 +54,31 @@ class SSHClient:
return client
except Exception:
pass
# Remove invalid connection
self._cleanup_connection()
self._cleanup_connection_locked()
return None
def _cache_connection(self, client: paramiko.SSHClient):
"""Cache successful connection"""
self._connections[self.connection_key] = client
self._connection_times[self.connection_key] = time.time()
with self._cache_lock:
self._connections[self.connection_key] = client
self._connection_times[self.connection_key] = time.time()
def _cleanup_connection(self):
"""Clean up cached connection"""
if self.connection_key in self._connections:
"""Clean up cached connection (acquires the cache lock)."""
with self._cache_lock:
self._cleanup_connection_locked()
def _cleanup_connection_locked(self):
"""Clean up cached connection. Caller must hold _cache_lock."""
client = self._connections.pop(self.connection_key, None)
if client is not None:
try:
self._connections[self.connection_key].close()
client.close()
except Exception:
pass
del self._connections[self.connection_key]
del self._connection_times[self.connection_key]
self._connection_times.pop(self.connection_key, None)
def connect(self) -> bool:
"""Установить SSH соединение с повторными попытками"""
@@ -83,7 +94,17 @@ class SSHClient:
try:
client = paramiko.SSHClient()
client.load_system_host_keys()
client.set_missing_host_key_policy(paramiko.WarningPolicy())
# Strict host-key checking is opt-in to avoid breaking existing
# deployments whose known_hosts isn't populated yet.
try:
from .config import settings
strict = settings.SSH_REJECT_UNKNOWN_HOSTS
except Exception:
strict = False
if strict:
client.set_missing_host_key_policy(paramiko.RejectPolicy())
else:
client.set_missing_host_key_policy(paramiko.WarningPolicy())
connect_kwargs = {
'hostname': self.hostname,
@@ -93,7 +114,9 @@ class SSHClient:
'banner_timeout': self.timeout,
'auth_timeout': self.timeout,
}
if self.password:
connect_kwargs['password'] = self.password
# Try key authentication first
if self.key_path:
try:
@@ -101,7 +124,7 @@ class SSHClient:
client.connect(**connect_kwargs, look_for_keys=False, allow_agent=False)
except paramiko.AuthenticationException:
logger.debug(f"Key auth failed for {self.hostname}, trying other methods")
# Remove key and try other methods
# Remove key and try other methods (password / agent / default keys)
del connect_kwargs['key_filename']
client.connect(**connect_kwargs, look_for_keys=True, allow_agent=True)
else:
@@ -233,13 +256,14 @@ class SSHClient:
@classmethod
def cleanup_all_connections(cls):
"""Clean up all cached connections"""
for key in list(cls._connections.keys()):
try:
cls._connections[key].close()
except Exception:
pass
cls._connections.clear()
cls._connection_times.clear()
with cls._cache_lock:
for key in list(cls._connections.keys()):
try:
cls._connections[key].close()
except Exception:
pass
cls._connections.clear()
cls._connection_times.clear()
def check_server_status(hostname: str, port: int = 22, username: str = "root",
@@ -400,21 +424,21 @@ async def cleanup_ssh_connections():
try:
await asyncio.sleep(300) # Check every 5 minutes
current_time = time.time()
expired_keys = []
for key, connection_time in SSHClient._connection_times.items():
if current_time - connection_time > 600: # 10 minutes
expired_keys.append(key)
for key in expired_keys:
if key in SSHClient._connections:
try:
SSHClient._connections[key].close()
except Exception:
pass
del SSHClient._connections[key]
del SSHClient._connection_times[key]
logger.debug(f"Cleaned up expired SSH connection: {key}")
with SSHClient._cache_lock:
expired_keys = [
key for key, connection_time in SSHClient._connection_times.items()
if current_time - connection_time > 600 # 10 minutes
]
for key in expired_keys:
client = SSHClient._connections.pop(key, None)
if client is not None:
try:
client.close()
except Exception:
pass
SSHClient._connection_times.pop(key, None)
logger.debug(f"Cleaned up expired SSH connection: {key}")
except Exception as e:
logger.error(f"Error in SSH cleanup task: {e}")
await asyncio.sleep(60) # Wait a minute on error

View File

@@ -1,142 +0,0 @@
import ast
import re
import os
source_file = 'app/proxmox_client.py'
with open(source_file, 'r', encoding='utf-8') as f:
source_code = f.read()
lines = source_code.split('\n')
class Extractor(ast.NodeVisitor):
def __init__(self):
self.methods = {}
self.module_funcs = []
def visit_ClassDef(self, node):
if node.name == 'ProxmoxClient':
for item in node.body:
if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
start = item.lineno - 1
end = item.end_lineno
if item.decorator_list:
start = item.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.methods[item.name] = code
self.generic_visit(node)
def visit_FunctionDef(self, node):
if node.col_offset == 0: # Module-level function
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.module_funcs.append(code)
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node):
if node.col_offset == 0: # Module-level async function
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.module_funcs.append(code)
self.generic_visit(node)
tree = ast.parse(source_code)
extractor = Extractor()
extractor.visit(tree)
methods = extractor.methods
module_funcs = extractor.module_funcs
groups = {
'vm': ['start_vm', 'stop_vm', 'restart_vm', 'shutdown_vm', 'clone_vm', 'change_vm_password',
'set_vm_notes', 'attach_iso', 'detach_iso', 'force_stop_vm', 'get_vm_status', 'get_vm_stats',
'get_vm_rrddata', 'get_vm_vnc', 'get_vm_spice', 'qemu_agent_exec', 'resize_vm_disk', 'reinstall_vm_from_template'],
'lxc': ['start_container', 'stop_container', 'restart_container', 'shutdown_container', 'clone_container',
'change_container_password', 'set_container_notes', 'force_stop_container', 'get_container_status',
'get_container_stats', 'get_container_rrddata', 'get_container_vnc', 'get_container_spice', 'resize_lxc_disk', 'reinstall_lxc_from_template'],
'cluster': ['is_cluster', 'get_ha_resources', 'get_ha_groups', 'is_in_ha', 'add_to_ha', 'remove_from_ha', 'get_ha_status',
'create_cluster', 'get_cluster_join_info', 'join_cluster', 'delete_cluster_node', '_delete_cluster_node_via_ssh'],
'storage': ['get_node_isos', 'get_lxc_storage_templates', 'get_node_storages', 'vzdump_guest', 'get_backup_storages',
'get_backup_jobs', 'create_backup_job', 'update_backup_job', 'delete_backup_job', 'run_backup_job', 'get_backup_job_status'],
'network': ['get_sdn_vnets', 'get_sdn_zones', 'get_sdn_controllers', 'get_sdn_ipams', 'get_sdn_dns',
'apply_sdn', 'create_sdn_zone', 'create_sdn_vnet', 'create_sdn_subnet', 'get_sdn_vnet_subnets',
'get_node_interfaces'],
'snapshot': ['get_vm_snapshots', 'create_vm_snapshot', 'rollback_vm_snapshot', 'delete_vm_snapshot',
'get_container_snapshots', 'create_container_snapshot', 'rollback_container_snapshot', 'delete_container_snapshot',
'get_guest_snapshots', 'create_guest_snapshot', 'rollback_guest_snapshot', 'delete_guest_snapshot']
}
mixins_code = {}
for group, group_methods in groups.items():
mixin_name = group.capitalize() + 'Mixin'
code = f"from typing import List, Dict, Optional, Union, Any\nimport time\nimport urllib3\nfrom loguru import logger\n\nclass {mixin_name}:\n"
has_methods = False
for m in group_methods:
if m in methods:
# indent
method_code = '\n'.join([' ' + l for l in methods[m].split('\n')])
code += method_code + "\n\n"
has_methods = True
if has_methods:
mixins_code[group] = code
os.makedirs('app/proxmox/mixins', exist_ok=True)
for group, code in mixins_code.items():
with open(f'app/proxmox/mixins/{group}.py', 'w') as f:
f.write(code)
print("Mixins created successfully.")
core_methods = []
for m, code in methods.items():
found = False
for group_methods in groups.values():
if m in group_methods:
found = True
break
if not found:
core_methods.append(code)
imports = """import time
import urllib3
import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import List, Dict, Optional, Union, Any
from proxmoxer import ProxmoxAPI, AuthenticationError
from loguru import logger
import threading
from .mixins.vm import VmMixin
from .mixins.lxc import LxcMixin
from .mixins.cluster import ClusterMixin
from .mixins.storage import StorageMixin
from .mixins.network import NetworkMixin
from .mixins.snapshot import SnapshotMixin
proxmox_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="proxmox_")
connection_cache = {}
connection_cache_lock = threading.Lock()
MAX_CACHE_SIZE = 50
"""
client_code = imports
for fcode in module_funcs:
client_code += fcode + "\n\n"
client_code += "class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin):\n"
for mcode in core_methods:
client_code += '\n'.join([' ' + l for l in mcode.split('\n')]) + "\n\n"
with open('app/proxmox/client.py', 'w') as f:
f.write(client_code)
print("Client created successfully.")

View File

@@ -1,141 +0,0 @@
import ast
import os
import re
source_file = 'app/api/proxmox/vms.py'
with open(source_file, 'r', encoding='utf-8') as f:
source_code = f.read()
lines = source_code.split('\n')
class RouterExtractor(ast.NodeVisitor):
def __init__(self):
self.routes = []
self.models = []
self.imports = []
self.other = []
def visit_Import(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
self.imports.append('\n'.join(lines[start:end]))
self.generic_visit(node)
def visit_ImportFrom(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
self.imports.append('\n'.join(lines[start:end]))
self.generic_visit(node)
def visit_ClassDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
self.models.append((node.name, code))
self.generic_visit(node)
def visit_FunctionDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
# Check if it's a router
is_router = False
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
if hasattr(dec.func.value, 'id') and dec.func.value.id == 'router':
is_router = True
break
if is_router:
self.routes.append((node.name, code))
else:
self.other.append((node.name, code))
self.generic_visit(node)
def visit_AsyncFunctionDef(self, node):
if node.col_offset == 0:
start = node.lineno - 1
end = node.end_lineno
if node.decorator_list:
start = node.decorator_list[0].lineno - 1
code = '\n'.join(lines[start:end])
is_router = False
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Attribute):
if hasattr(dec.func.value, 'id') and dec.func.value.id == 'router':
is_router = True
break
if is_router:
self.routes.append((node.name, code))
else:
self.other.append((node.name, code))
self.generic_visit(node)
extractor = RouterExtractor()
tree = ast.parse(source_code)
extractor.visit(tree)
groups = {
'crud': ['sync_vms', 'get_virtual_machines', 'delete_vm', 'delete_container'],
'actions': ['clone_vm', 'clone_container', 'reinstall_vm', 'reinstall_lxc', 'vm_action', 'container_action', 'execute_vm_command', 'execute_vm_script', 'container_exec'],
'ha': ['get_ha_status', 'get_ha_details', 'add_to_ha', 'remove_from_ha'],
'console': ['get_vm_vnc', 'get_container_vnc', 'vnc_websocket', 'get_vm_terminal', 'get_container_terminal', 'terminal_websocket'],
'config': ['change_vm_password', 'change_container_password', 'get_vm_config', 'update_vm_config', 'resize_vm_disk', 'resize_lxc_disk',
'get_container_config', 'update_container_config', 'get_vm_owner', 'update_vm_owner', 'update_vm_notes', 'attach_vm_iso', 'detach_vm_iso',
'get_nodes', 'get_vm_status', 'get_container_status', 'get_vm_interfaces', 'get_container_interfaces', 'get_vm_rrddata', 'get_container_rrddata',
'get_vm_saved_config'],
'lxc': ['get_lxc_templates', 'get_all_lxc_templates', 'get_available_lxc_templates', 'download_lxc_template', 'create_lxc', 'create_lxc_smart', 'clone_lxc'],
'bulk': ['bulk_operation']
}
mixins_code = {}
imports_code = '\n'.join(extractor.imports)
imports_code = re.sub(r'from \.\.\.api', 'from ....api', imports_code)
imports_code = re.sub(r'from \.\.\.models', 'from ....models', imports_code)
imports_code = re.sub(r'from \.\.\.services', 'from ....services', imports_code)
imports_code = re.sub(r'from \.\.\.db', 'from ....db', imports_code)
imports_code = re.sub(r'from \.\.\.auth', 'from ....auth', imports_code)
imports_code = re.sub(r'from \.\.\.proxmox', 'from ....proxmox', imports_code)
imports_code = re.sub(r'from \.\.\.logging', 'from ....logging', imports_code)
imports_code = re.sub(r'from \.\.\.ipam', 'from ....ipam', imports_code)
imports_code = imports_code.replace('from ._helpers', 'from .._helpers')
models_code = '\n\n'.join([m[1] for m in extractor.models])
other_code = '\n\n'.join([o[1] for o in extractor.other])
base_code = f"{imports_code}\n\nrouter = APIRouter()\n\n{models_code}\n\n{other_code}\n\n"
for group, methods in groups.items():
code = base_code
for r_name, r_code in extractor.routes:
if r_name in methods:
code += r_code + "\n\n"
mixins_code[group] = code
for group, code in mixins_code.items():
with open(f'app/api/proxmox/vms/{group}.py', 'w') as f:
f.write(code)
init_code = "from fastapi import APIRouter\n"
for group in groups.keys():
init_code += f"from .{group} import router as {group}_router\n"
init_code += "\nrouter = APIRouter()\n"
for group in groups.keys():
init_code += f"router.include_router({group}_router)\n"
with open('app/api/proxmox/vms/__init__.py', 'w') as f:
f.write(init_code)
print("VMS routes split successfully.")

View File

@@ -1,22 +0,0 @@
import os
for root, dirs, files in os.walk('app'):
for file in files:
if file.endswith('.py'):
filepath = os.path.join(root, file)
with open(filepath, 'r') as f:
content = f.read()
# replace absolute imports
new_content = content.replace('app.proxmox_client', 'app.proxmox')
new_content = new_content.replace('backend.app.proxmox_client', 'backend.app.proxmox')
# replace relative imports
new_content = new_content.replace('..proxmox_client', '..proxmox')
new_content = new_content.replace('...proxmox_client', '...proxmox')
new_content = new_content.replace('from .proxmox_client', 'from .proxmox')
if new_content != content:
with open(filepath, 'w') as f:
f.write(new_content)
print(f"Updated {filepath}")