Replace all legacy dotted permission codes (vms.view, proxmox.manage, ...) in PermissionChecker/has_permission across app/api with the new resource:action format (vm:view, server:manage, ...), per LEGACY_PERMISSION_MAP. All 33 codes used by the API are now registered in the permission registry. Also switch the /api/auth/me admin branch to get_all_permissions_v2 so the endpoint emits permissions in the new format only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
400 lines
14 KiB
Python
400 lines
14 KiB
Python
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||
from fastapi.responses import JSONResponse
|
||
from sqlalchemy.orm import Session
|
||
from sqlalchemy import func
|
||
from loguru import logger
|
||
from typing import List
|
||
import ssl
|
||
import asyncio
|
||
import httpx
|
||
import websockets
|
||
|
||
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 ...logging_service import LoggingService
|
||
from ...ipam_service import IPAMService
|
||
|
||
|
||
# ==================== Helper Functions for User Isolation ====================
|
||
|
||
def check_vm_access(db: Session, current_user: User, server_id: int, vmid: int) -> bool:
|
||
"""
|
||
Check if user has access to a specific VM instance.
|
||
|
||
Admins and users with 'vms:view' permission can access all VMs.
|
||
Users with 'user' role can only access VMs where they are the owner.
|
||
|
||
Returns True if access is allowed, False otherwise.
|
||
"""
|
||
# Admin role has full access
|
||
if current_user.role and current_user.role.name == 'admin':
|
||
return True
|
||
|
||
# Check permissions
|
||
if current_user.role and current_user.role.permissions:
|
||
perms = current_user.role.permissions
|
||
# If user has full vms:view, they can access all VMs
|
||
has_full_view = perms.get('vm:view', False) or perms.get('vms:view', False)
|
||
has_own_only = perms.get('vms:view:own', False) or perms.get('vms.view.own', False)
|
||
|
||
if has_full_view and not has_own_only:
|
||
return True
|
||
|
||
# For limited users, check ownership
|
||
if current_user.role and current_user.role.name == 'user':
|
||
instance = db.query(VMInstance).filter(
|
||
VMInstance.server_id == server_id,
|
||
VMInstance.vmid == vmid,
|
||
VMInstance.deleted_at.is_(None)
|
||
).first()
|
||
|
||
if instance and instance.owner_id == current_user.id:
|
||
return True
|
||
return False
|
||
|
||
# Default: allow access (for backwards compatibility)
|
||
return True
|
||
|
||
|
||
def require_vm_access(db: Session, current_user: User, server_id: int, vmid: int):
|
||
"""
|
||
Require user to have access to VM, raise 403 if not.
|
||
"""
|
||
if not check_vm_access(db, current_user, server_id, vmid):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="You do not have access to this virtual machine"
|
||
)
|
||
|
||
|
||
def _get_proxmox_client(server: ProxmoxServer) -> ProxmoxClient:
|
||
"""Build a ProxmoxClient from a ProxmoxServer model (password or token auth)."""
|
||
return ProxmoxClient.from_server(server)
|
||
|
||
|
||
def get_next_vmid(db: Session, server_id: int) -> int:
|
||
"""Get next available VMID from Proxmox server (sequential)"""
|
||
|
||
# Get server from DB
|
||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail="Server not found")
|
||
|
||
try:
|
||
# Create Proxmox client and get next VMID from Proxmox API
|
||
client = _get_proxmox_client(server)
|
||
|
||
# Get next VMID from Proxmox (it returns sequential IDs)
|
||
vmid = client.get_next_vmid()
|
||
if vmid:
|
||
return vmid
|
||
except Exception as e:
|
||
logger.warning(f"Could not get VMID from Proxmox, falling back to DB: {e}")
|
||
|
||
# Fallback: get from DB if Proxmox not available
|
||
used_vmids = set(
|
||
row[0] for row in db.query(VMInstance.vmid)
|
||
.filter(VMInstance.server_id == server_id, VMInstance.deleted_at.is_(None))
|
||
.all()
|
||
)
|
||
|
||
# Find first available VMID starting from 100
|
||
for vmid in range(100, 999999):
|
||
if vmid not in used_vmids:
|
||
return vmid
|
||
|
||
raise HTTPException(status_code=500, detail="No available VMID")
|
||
|
||
|
||
def archive_and_delete_snapshots(
|
||
db: Session,
|
||
client: ProxmoxClient,
|
||
server_id: int,
|
||
server_name: str,
|
||
vmid: int,
|
||
vm_name: str,
|
||
vm_type: str,
|
||
node: str,
|
||
deleted_by: str,
|
||
deletion_reason: str = None
|
||
) -> dict:
|
||
"""
|
||
Archive all snapshots to database before deleting them.
|
||
|
||
This function:
|
||
1. Gets list of all snapshots for the VM/container
|
||
2. Saves each snapshot's configuration to vm_snapshot_archives table
|
||
3. Explicitly deletes each snapshot one by one
|
||
4. Logs each deletion
|
||
|
||
Args:
|
||
db: Database session
|
||
client: ProxmoxClient instance
|
||
server_id: Proxmox server ID
|
||
server_name: Proxmox server name for logging
|
||
vmid: VM/container ID
|
||
vm_name: VM/container name
|
||
vm_type: 'qemu' for VM, 'lxc' for container
|
||
node: Proxmox node name
|
||
deleted_by: Username who initiated deletion
|
||
deletion_reason: Reason for deletion
|
||
|
||
Returns:
|
||
Dict with archived count, deleted count, and any errors
|
||
"""
|
||
result = {
|
||
"archived": 0,
|
||
"deleted": 0,
|
||
"errors": [],
|
||
"snapshots": []
|
||
}
|
||
|
||
try:
|
||
# Get all snapshots
|
||
if vm_type == 'lxc':
|
||
snapshots = client.get_container_snapshots(node, vmid)
|
||
else:
|
||
snapshots = client.get_vm_snapshots(node, vmid)
|
||
|
||
# Filter out 'current' state which is not a real snapshot
|
||
snapshots = [s for s in snapshots if s.get('name') != 'current']
|
||
|
||
if not snapshots:
|
||
logger.info(f"No snapshots found for {vm_type} {vmid}")
|
||
return result
|
||
|
||
logger.info(f"Found {len(snapshots)} snapshots to archive for {vm_type} {vmid}")
|
||
|
||
# Archive and delete each snapshot
|
||
for snap in snapshots:
|
||
snapname = snap.get('name')
|
||
if not snapname:
|
||
continue
|
||
|
||
# Create archive record
|
||
try:
|
||
archive = VMSnapshotArchive(
|
||
server_id=server_id,
|
||
server_name=server_name,
|
||
vmid=vmid,
|
||
vm_name=vm_name,
|
||
vm_type=vm_type,
|
||
node=node,
|
||
snapname=snapname,
|
||
description=snap.get('description'),
|
||
snaptime=snap.get('snaptime'),
|
||
parent=snap.get('parent'),
|
||
vmstate=bool(snap.get('vmstate')),
|
||
snapshot_config=snap,
|
||
deleted_by=deleted_by,
|
||
deletion_reason=deletion_reason
|
||
)
|
||
db.add(archive)
|
||
db.flush() # Get ID immediately
|
||
result["archived"] += 1
|
||
result["snapshots"].append(snapname)
|
||
logger.info(f"Archived snapshot {snapname} for {vm_type} {vmid}")
|
||
except Exception as e:
|
||
logger.error(f"Error archiving snapshot {snapname}: {e}")
|
||
result["errors"].append(f"Archive {snapname}: {str(e)}")
|
||
|
||
# Delete snapshot from Proxmox
|
||
try:
|
||
if vm_type == 'lxc':
|
||
delete_result = client.delete_container_snapshot(node, vmid, snapname, force=True)
|
||
else:
|
||
delete_result = client.delete_vm_snapshot(node, vmid, snapname, force=True)
|
||
|
||
if delete_result.get('success'):
|
||
result["deleted"] += 1
|
||
|
||
# Log the deletion
|
||
LoggingService.log_proxmox_action(
|
||
db=db,
|
||
action="snapshot_delete",
|
||
resource_type=vm_type,
|
||
resource_id=vmid,
|
||
username=deleted_by,
|
||
resource_name=snapname,
|
||
server_id=server_id,
|
||
server_name=server_name,
|
||
node_name=node,
|
||
details={
|
||
"reason": f"Pre-deletion cleanup for {vm_type} {vmid}",
|
||
"cascade_delete": True
|
||
},
|
||
success=True
|
||
)
|
||
logger.info(f"Deleted snapshot {snapname} for {vm_type} {vmid}")
|
||
else:
|
||
error_msg = delete_result.get('error', 'Unknown error')
|
||
result["errors"].append(f"Delete {snapname}: {error_msg}")
|
||
logger.warning(f"Failed to delete snapshot {snapname}: {error_msg}")
|
||
except Exception as e:
|
||
logger.error(f"Error deleting snapshot {snapname}: {e}")
|
||
result["errors"].append(f"Delete {snapname}: {str(e)}")
|
||
|
||
# Commit archive records
|
||
db.commit()
|
||
|
||
logger.info(f"Snapshot cleanup for {vm_type} {vmid}: archived={result['archived']}, deleted={result['deleted']}")
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in archive_and_delete_snapshots for {vm_type} {vmid}: {e}")
|
||
result["errors"].append(f"General error: {str(e)}")
|
||
|
||
return result
|
||
|
||
|
||
def save_vm_instance(
|
||
db: Session,
|
||
server_id: int,
|
||
vmid: int,
|
||
node: str,
|
||
vm_type: str,
|
||
name: str,
|
||
cores: int = None,
|
||
memory: int = None,
|
||
disk_size: int = None,
|
||
ip_address: str = None,
|
||
ip_prefix: int = 24,
|
||
gateway: str = None,
|
||
nameserver: str = None,
|
||
cloud_init_user: str = None,
|
||
cloud_init_password: str = None,
|
||
ssh_keys: str = None,
|
||
template_id: int = None,
|
||
template_name: str = None,
|
||
description: str = None,
|
||
extra_config: dict = None,
|
||
owner_id: int = None
|
||
) -> VMInstance:
|
||
"""Save or update VM instance configuration"""
|
||
|
||
# Проверяем, существует ли уже активная запись
|
||
existing = db.query(VMInstance).filter(
|
||
VMInstance.server_id == server_id,
|
||
VMInstance.vmid == vmid,
|
||
VMInstance.deleted_at.is_(None)
|
||
).first()
|
||
|
||
if existing:
|
||
# Обновляем существующую запись
|
||
existing.node = node
|
||
existing.vm_type = vm_type
|
||
existing.name = name
|
||
existing.cores = cores
|
||
existing.memory = memory
|
||
existing.disk_size = disk_size
|
||
existing.ip_address = ip_address
|
||
existing.ip_prefix = ip_prefix
|
||
existing.gateway = gateway
|
||
existing.nameserver = nameserver
|
||
existing.cloud_init_user = cloud_init_user
|
||
existing.cloud_init_password = cloud_init_password
|
||
existing.ssh_keys = ssh_keys
|
||
existing.template_id = template_id
|
||
existing.template_name = template_name
|
||
existing.description = description
|
||
existing.extra_config = extra_config
|
||
existing.updated_at = func.now()
|
||
db.commit()
|
||
db.refresh(existing)
|
||
return existing
|
||
|
||
# Проверяем, есть ли удалённая запись с тем же vmid (soft-deleted)
|
||
deleted_existing = db.query(VMInstance).filter(
|
||
VMInstance.server_id == server_id,
|
||
VMInstance.vmid == vmid,
|
||
VMInstance.deleted_at.isnot(None)
|
||
).first()
|
||
|
||
if deleted_existing:
|
||
# Восстанавливаем и обновляем удалённую запись
|
||
deleted_existing.node = node
|
||
deleted_existing.vm_type = vm_type
|
||
deleted_existing.name = name
|
||
deleted_existing.cores = cores
|
||
deleted_existing.memory = memory
|
||
deleted_existing.disk_size = disk_size
|
||
deleted_existing.ip_address = ip_address
|
||
deleted_existing.ip_prefix = ip_prefix
|
||
deleted_existing.gateway = gateway
|
||
deleted_existing.nameserver = nameserver
|
||
deleted_existing.cloud_init_user = cloud_init_user
|
||
deleted_existing.cloud_init_password = cloud_init_password
|
||
deleted_existing.ssh_keys = ssh_keys
|
||
deleted_existing.template_id = template_id
|
||
deleted_existing.template_name = template_name
|
||
deleted_existing.description = description
|
||
deleted_existing.extra_config = extra_config
|
||
deleted_existing.owner_id = owner_id
|
||
deleted_existing.status = 'unknown'
|
||
deleted_existing.deleted_at = None # Восстанавливаем запись
|
||
deleted_existing.updated_at = func.now()
|
||
db.commit()
|
||
db.refresh(deleted_existing)
|
||
return deleted_existing
|
||
|
||
# Создаем новую запись
|
||
instance = VMInstance(
|
||
server_id=server_id,
|
||
vmid=vmid,
|
||
node=node,
|
||
vm_type=vm_type,
|
||
name=name,
|
||
cores=cores,
|
||
memory=memory,
|
||
disk_size=disk_size,
|
||
ip_address=ip_address,
|
||
ip_prefix=ip_prefix,
|
||
gateway=gateway,
|
||
nameserver=nameserver,
|
||
cloud_init_user=cloud_init_user,
|
||
cloud_init_password=cloud_init_password,
|
||
ssh_keys=ssh_keys,
|
||
template_id=template_id,
|
||
template_name=template_name,
|
||
description=description,
|
||
extra_config=extra_config,
|
||
owner_id=owner_id
|
||
)
|
||
db.add(instance)
|
||
db.commit()
|
||
db.refresh(instance)
|
||
return instance
|
||
|
||
|
||
def get_vm_instance(db: Session, server_id: int, vmid: int) -> VMInstance:
|
||
"""Get VM instance configuration"""
|
||
return db.query(VMInstance).filter(
|
||
VMInstance.server_id == server_id,
|
||
VMInstance.vmid == vmid,
|
||
VMInstance.deleted_at.is_(None)
|
||
).first()
|
||
|
||
|
||
def soft_delete_vm_instance(db: Session, server_id: int, vmid: int):
|
||
"""Soft delete VM instance (mark as deleted) and release IPAM"""
|
||
instance = get_vm_instance(db, server_id, vmid)
|
||
if instance:
|
||
# Release IPAM allocation
|
||
try:
|
||
ipam = IPAMService(db)
|
||
released = ipam.release_ip_by_vmid(
|
||
proxmox_server_id=server_id,
|
||
proxmox_vmid=vmid,
|
||
released_by="system",
|
||
reason="VM/Container soft deleted"
|
||
)
|
||
if released:
|
||
logger.info(f"Released IPAM for soft-deleted instance {vmid} on server {server_id}")
|
||
except Exception as e:
|
||
logger.warning(f"Failed to release IPAM for soft-deleted instance: {e}")
|
||
|
||
instance.deleted_at = func.now()
|
||
db.commit()
|