feat: add firewall, pools, access, disks, node admin and VM hardware management
Backend: new proxmox mixins and API routes for firewall, pools, access control, node admin and disks; extend storage and VM endpoints. Frontend: node managers (firewall, pool, access, disk, node admin), VM firewall and hardware tabs, PCI/USB device dialogs, related hooks and locale strings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,11 @@ from .cluster import router as cluster_router
|
||||
from .networks import router as networks_router
|
||||
from .lxc import router as lxc_router
|
||||
from .images import router as images_router
|
||||
from .firewall import router as firewall_router
|
||||
from .pools import router as pools_router
|
||||
from .disks import router as disks_router
|
||||
from .access import router as access_router
|
||||
from .node_admin import router as node_admin_router
|
||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
|
||||
router = APIRouter()
|
||||
@@ -22,3 +27,8 @@ router.include_router(cluster_router)
|
||||
router.include_router(networks_router)
|
||||
router.include_router(lxc_router)
|
||||
router.include_router(images_router)
|
||||
router.include_router(firewall_router)
|
||||
router.include_router(pools_router)
|
||||
router.include_router(disks_router)
|
||||
router.include_router(access_router)
|
||||
router.include_router(node_admin_router)
|
||||
|
||||
177
backend/app/api/proxmox/access.py
Normal file
177
backend/app/api/proxmox/access.py
Normal file
@@ -0,0 +1,177 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||
return client
|
||||
|
||||
|
||||
def _ok_or_400(result: dict):
|
||||
if result.get("success"):
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||
|
||||
|
||||
# ==================== Realms ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/access/realms")
|
||||
async def list_realms(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"realms": await _run_in_executor(client.get_realms)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing realms: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/access/realms")
|
||||
async def create_realm(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
realm = data.pop("realm", None)
|
||||
realm_type = data.pop("type", None)
|
||||
if not realm or not realm_type:
|
||||
raise HTTPException(status_code=400, detail="realm and type are required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.create_realm(realm, realm_type, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating realm: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/access/realms/{realm}")
|
||||
async def update_realm(
|
||||
server_id: int,
|
||||
realm: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.update_realm(realm, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating realm {realm}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/access/realms/{realm}")
|
||||
async def delete_realm(
|
||||
server_id: int,
|
||||
realm: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_realm(realm)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting realm {realm}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Users & API tokens ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/access/users")
|
||||
async def list_access_users(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"users": await _run_in_executor(client.get_access_users)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing access users: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/access/users/{userid}/tokens")
|
||||
async def list_user_tokens(
|
||||
server_id: int,
|
||||
userid: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"tokens": await _run_in_executor(lambda: client.get_user_tokens(userid))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing tokens for {userid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/access/users/{userid}/tokens/{tokenid}")
|
||||
async def create_user_token(
|
||||
server_id: int,
|
||||
userid: str,
|
||||
tokenid: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
"""Создать API-токен. Секрет (value) возвращается один раз."""
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.create_user_token(
|
||||
userid, tokenid,
|
||||
comment=data.get("comment"),
|
||||
privsep=bool(data.get("privsep", True)),
|
||||
expire=data.get("expire"),
|
||||
)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating token for {userid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/access/users/{userid}/tokens/{tokenid}")
|
||||
async def delete_user_token(
|
||||
server_id: int,
|
||||
userid: str,
|
||||
tokenid: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("user:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_user_token(userid, tokenid)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting token {tokenid} for {userid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
161
backend/app/api/proxmox/disks.py
Normal file
161
backend/app/api/proxmox/disks.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||
return client
|
||||
|
||||
|
||||
def _ok_or_400(result: dict):
|
||||
if result.get("success"):
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||
|
||||
|
||||
# ==================== Physical disks ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/disks")
|
||||
async def list_disks(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"disks": await _run_in_executor(lambda: client.get_node_disks(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing disks on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/disks/smart")
|
||||
async def disk_smart(
|
||||
server_id: int,
|
||||
node: str,
|
||||
disk: str = Query(..., description="devpath, напр. /dev/sda"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"smart": await _run_in_executor(lambda: client.get_disk_smart(node, disk))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SMART for {disk} on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/disks/wipe")
|
||||
async def wipe_disk(
|
||||
server_id: int,
|
||||
node: str,
|
||||
disk: str = Query(..., description="devpath, напр. /dev/sdb"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.wipe_disk(node, disk)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error wiping disk {disk} on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== ZFS pools ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/disks/zfs")
|
||||
async def list_zfs_pools(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"pools": await _run_in_executor(lambda: client.get_zfs_pools(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing ZFS pools on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/disks/zfs/{name}")
|
||||
async def get_zfs_pool(
|
||||
server_id: int,
|
||||
node: str,
|
||||
name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"pool": await _run_in_executor(lambda: client.get_zfs_pool(node, name))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ZFS pool {name} on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/disks/zfs")
|
||||
async def create_zfs_pool(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
devices = data.get("devices")
|
||||
if not name or not devices:
|
||||
raise HTTPException(status_code=400, detail="name and devices are required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.create_zfs_pool(
|
||||
node, name, devices,
|
||||
raidlevel=data.get("raidlevel", "single"),
|
||||
ashift=int(data.get("ashift", 12)),
|
||||
compression=data.get("compression", "on"),
|
||||
add_storage=bool(data.get("add_storage", True)),
|
||||
)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ZFS pool on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/nodes/{node}/disks/zfs/{name}")
|
||||
async def destroy_zfs_pool(
|
||||
server_id: int,
|
||||
node: str,
|
||||
name: str,
|
||||
cleanup_disks: bool = Query(False),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.destroy_zfs_pool(
|
||||
node, name, cleanup_config=True, cleanup_disks=cleanup_disks)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error destroying ZFS pool {name} on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
753
backend/app/api/proxmox/firewall.py
Normal file
753
backend/app/api/proxmox/firewall.py
Normal file
@@ -0,0 +1,753 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
"""Достать сервер и подключённого клиента (или 404/503)."""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||
return client
|
||||
|
||||
|
||||
def _ok_or_400(result: dict):
|
||||
"""Единый разбор ответа мутаций клиента ({success, error})."""
|
||||
if result.get("success"):
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||
|
||||
|
||||
# ==================== Options ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/options")
|
||||
async def get_firewall_options(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"options": await _run_in_executor(client.get_cluster_firewall_options)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/firewall/options")
|
||||
async def update_firewall_options(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.update_cluster_firewall_options(**data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating firewall options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Datacenter rules ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/rules")
|
||||
async def get_firewall_rules(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"rules": await _run_in_executor(client.get_cluster_firewall_rules)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall rules: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/firewall/rules")
|
||||
async def create_firewall_rule(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.create_cluster_firewall_rule(**data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating firewall rule: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/firewall/rules/{pos}")
|
||||
async def update_firewall_rule(
|
||||
server_id: int,
|
||||
pos: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.update_cluster_firewall_rule(pos, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating firewall rule {pos}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/firewall/rules/{pos}")
|
||||
async def delete_firewall_rule(
|
||||
server_id: int,
|
||||
pos: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_cluster_firewall_rule(pos)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting firewall rule {pos}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Security groups ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/groups")
|
||||
async def get_firewall_groups(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"groups": await _run_in_executor(client.get_firewall_groups)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall groups: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/firewall/groups")
|
||||
async def create_firewall_group(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
group = data.get("group")
|
||||
if not group:
|
||||
raise HTTPException(status_code=400, detail="Group name is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.create_firewall_group(group, data.get("comment"))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating firewall group: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/firewall/groups/{group}")
|
||||
async def delete_firewall_group(
|
||||
server_id: int,
|
||||
group: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_group(group)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting firewall group {group}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/groups/{group}/rules")
|
||||
async def get_firewall_group_rules(
|
||||
server_id: int,
|
||||
group: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"rules": await _run_in_executor(lambda: client.get_firewall_group_rules(group))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting rules for group {group}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/firewall/groups/{group}/rules")
|
||||
async def create_firewall_group_rule(
|
||||
server_id: int,
|
||||
group: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.create_firewall_group_rule(group, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating rule in group {group}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/firewall/groups/{group}/rules/{pos}")
|
||||
async def update_firewall_group_rule(
|
||||
server_id: int,
|
||||
group: str,
|
||||
pos: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.update_firewall_group_rule(group, pos, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating rule {pos} in group {group}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/firewall/groups/{group}/rules/{pos}")
|
||||
async def delete_firewall_group_rule(
|
||||
server_id: int,
|
||||
group: str,
|
||||
pos: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_group_rule(group, pos)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting rule {pos} in group {group}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== IP sets ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/ipsets")
|
||||
async def get_firewall_ipsets(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"ipsets": await _run_in_executor(client.get_firewall_ipsets)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall ipsets: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/firewall/ipsets")
|
||||
async def create_firewall_ipset(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="IP set name is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.create_firewall_ipset(name, data.get("comment"))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating firewall ipset: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/firewall/ipsets/{name}")
|
||||
async def delete_firewall_ipset(
|
||||
server_id: int,
|
||||
name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_ipset(name)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting firewall ipset {name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/ipsets/{name}/entries")
|
||||
async def get_firewall_ipset_entries(
|
||||
server_id: int,
|
||||
name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"entries": await _run_in_executor(lambda: client.get_firewall_ipset_entries(name))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting entries for ipset {name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/firewall/ipsets/{name}/entries")
|
||||
async def add_firewall_ipset_entry(
|
||||
server_id: int,
|
||||
name: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
cidr = data.get("cidr")
|
||||
if not cidr:
|
||||
raise HTTPException(status_code=400, detail="CIDR is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.add_firewall_ipset_entry(
|
||||
name, cidr, data.get("comment"), bool(data.get("nomatch")))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding entry to ipset {name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/firewall/ipsets/{name}/entries")
|
||||
async def delete_firewall_ipset_entry(
|
||||
server_id: int,
|
||||
name: str,
|
||||
cidr: str = Query(..., description="CIDR записи (содержит '/', поэтому в query)"),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_ipset_entry(name, cidr)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting entry from ipset {name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Aliases ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/aliases")
|
||||
async def get_firewall_aliases(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"aliases": await _run_in_executor(client.get_firewall_aliases)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall aliases: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/firewall/aliases")
|
||||
async def create_firewall_alias(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
name, cidr = data.get("name"), data.get("cidr")
|
||||
if not name or not cidr:
|
||||
raise HTTPException(status_code=400, detail="Name and CIDR are required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.create_firewall_alias(name, cidr, data.get("comment"))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating firewall alias: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/firewall/aliases/{name}")
|
||||
async def update_firewall_alias(
|
||||
server_id: int,
|
||||
name: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
cidr = data.get("cidr")
|
||||
if not cidr:
|
||||
raise HTTPException(status_code=400, detail="CIDR is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.update_firewall_alias(name, cidr, data.get("comment"), data.get("rename"))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating firewall alias {name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/firewall/aliases/{name}")
|
||||
async def delete_firewall_alias(
|
||||
server_id: int,
|
||||
name: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_firewall_alias(name)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting firewall alias {name}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Node-level: options ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/firewall/options")
|
||||
async def get_node_firewall_options(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"options": await _run_in_executor(lambda: client.get_node_firewall_options(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} firewall options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/firewall/options")
|
||||
async def update_node_firewall_options(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.update_node_firewall_options(node, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating node {node} firewall options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Node-level: rules ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/firewall/rules")
|
||||
async def get_node_firewall_rules(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"rules": await _run_in_executor(lambda: client.get_node_firewall_rules(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} firewall rules: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/firewall/rules")
|
||||
async def create_node_firewall_rule(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.create_node_firewall_rule(node, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating node {node} firewall rule: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/firewall/rules/{pos}")
|
||||
async def update_node_firewall_rule(
|
||||
server_id: int,
|
||||
node: str,
|
||||
pos: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.update_node_firewall_rule(node, pos, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating node {node} firewall rule {pos}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/nodes/{node}/firewall/rules/{pos}")
|
||||
async def delete_node_firewall_rule(
|
||||
server_id: int,
|
||||
node: str,
|
||||
pos: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_node_firewall_rule(node, pos)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting node {node} firewall rule {pos}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/firewall/log")
|
||||
async def get_node_firewall_log(
|
||||
server_id: int,
|
||||
node: str,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
start: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"log": await _run_in_executor(lambda: client.get_node_firewall_log(node, limit, start))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} firewall log: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Guest-level (VM/LXC) ====================
|
||||
|
||||
def _norm_type(vm_type: str) -> str:
|
||||
if vm_type not in ("qemu", "lxc", "vm", "ct"):
|
||||
raise HTTPException(status_code=400, detail="vm_type must be qemu/lxc")
|
||||
return vm_type
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/options")
|
||||
async def get_guest_firewall_options(
|
||||
server_id: int,
|
||||
vm_type: str,
|
||||
vmid: int,
|
||||
node: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
return JSONResponse(content={"options": await _run_in_executor(
|
||||
lambda: client.get_guest_firewall_options(node, vm_type, vmid))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting guest {vmid} firewall options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/options")
|
||||
async def update_guest_firewall_options(
|
||||
server_id: int,
|
||||
vm_type: str,
|
||||
vmid: int,
|
||||
request: Request,
|
||||
node: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.update_guest_firewall_options(node, vm_type, vmid, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating guest {vmid} firewall options: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules")
|
||||
async def get_guest_firewall_rules(
|
||||
server_id: int,
|
||||
vm_type: str,
|
||||
vmid: int,
|
||||
node: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
return JSONResponse(content={"rules": await _run_in_executor(
|
||||
lambda: client.get_guest_firewall_rules(node, vm_type, vmid))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting guest {vmid} firewall rules: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules")
|
||||
async def create_guest_firewall_rule(
|
||||
server_id: int,
|
||||
vm_type: str,
|
||||
vmid: int,
|
||||
request: Request,
|
||||
node: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.create_guest_firewall_rule(node, vm_type, vmid, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating guest {vmid} firewall rule: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules/{pos}")
|
||||
async def update_guest_firewall_rule(
|
||||
server_id: int,
|
||||
vm_type: str,
|
||||
vmid: int,
|
||||
pos: int,
|
||||
request: Request,
|
||||
node: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.update_guest_firewall_rule(node, vm_type, vmid, pos, **data)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating guest {vmid} firewall rule {pos}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/rules/{pos}")
|
||||
async def delete_guest_firewall_rule(
|
||||
server_id: int,
|
||||
vm_type: str,
|
||||
vmid: int,
|
||||
pos: int,
|
||||
node: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.delete_guest_firewall_rule(node, vm_type, vmid, pos)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting guest {vmid} firewall rule {pos}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/guests/{vm_type}/{vmid}/firewall/log")
|
||||
async def get_guest_firewall_log(
|
||||
server_id: int,
|
||||
vm_type: str,
|
||||
vmid: int,
|
||||
node: str = Query(...),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
start: int = Query(0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
_norm_type(vm_type)
|
||||
try:
|
||||
return JSONResponse(content={"log": await _run_in_executor(
|
||||
lambda: client.get_guest_firewall_log(node, vm_type, vmid, limit, start))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting guest {vmid} firewall log: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Macros (read-only) ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/firewall/macros")
|
||||
async def get_firewall_macros(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"macros": await _run_in_executor(client.get_firewall_macros)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall macros: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
165
backend/app/api/proxmox/node_admin.py
Normal file
165
backend/app/api/proxmox/node_admin.py
Normal file
@@ -0,0 +1,165 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||
return client
|
||||
|
||||
|
||||
def _ok_or_400(result: dict):
|
||||
if result.get("success"):
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||
|
||||
|
||||
# ==================== Services ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/services")
|
||||
async def list_services(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"services": await _run_in_executor(lambda: client.get_node_services(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing services on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/services/{service}/{action}")
|
||||
async def service_action(
|
||||
server_id: int,
|
||||
node: str,
|
||||
service: str,
|
||||
action: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
if action not in ("start", "stop", "restart", "reload"):
|
||||
raise HTTPException(status_code=400, detail="action must be start/stop/restart/reload")
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.node_service_action(node, service, action)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error {action} service {service} on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== APT: updates ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/apt/updates")
|
||||
async def apt_updates(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"updates": await _run_in_executor(lambda: client.get_apt_updates(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting APT updates on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/apt/refresh")
|
||||
async def apt_refresh(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.apt_refresh(node)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing APT on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== APT: repositories ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/apt/repositories")
|
||||
async def apt_repositories(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"repositories": await _run_in_executor(lambda: client.get_apt_repositories(node))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting APT repositories on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/apt/repositories")
|
||||
async def set_apt_repository(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
"""Включить/выключить репозиторий (path + index + enabled)."""
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
path = data.get("path")
|
||||
index = data.get("index")
|
||||
if not path or index is None:
|
||||
raise HTTPException(status_code=400, detail="path and index are required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(
|
||||
lambda: client.set_apt_repository(node, path, int(index), bool(data.get("enabled")))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling APT repo on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/apt/repositories")
|
||||
async def add_standard_repository(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
"""Добавить стандартный репозиторий PVE по handle."""
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
handle = data.get("handle")
|
||||
if not handle:
|
||||
raise HTTPException(status_code=400, detail="handle is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.add_apt_standard_repository(node, handle)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding standard repo on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
121
backend/app/api/proxmox/pools.py
Normal file
121
backend/app/api/proxmox/pools.py
Normal file
@@ -0,0 +1,121 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User
|
||||
from ...auth import PermissionChecker
|
||||
from ._helpers import _get_proxmox_client
|
||||
from ...proxmox import _run_in_executor
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _resolve_client(db: Session, server_id: int):
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
|
||||
return client
|
||||
|
||||
|
||||
def _ok_or_400(result: dict):
|
||||
if result.get("success"):
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Operation failed"))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/pools")
|
||||
async def list_pools(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"pools": await _run_in_executor(client.get_pools)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing pools: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/servers/{server_id}/pools/{poolid}")
|
||||
async def get_pool(
|
||||
server_id: int,
|
||||
poolid: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:view")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return JSONResponse(content={"pool": await _run_in_executor(lambda: client.get_pool(poolid))})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pool {poolid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/pools")
|
||||
async def create_pool(
|
||||
server_id: int,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
poolid = data.get("poolid")
|
||||
if not poolid:
|
||||
raise HTTPException(status_code=400, detail="poolid is required")
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.create_pool(poolid, data.get("comment"))))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating pool: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/pools/{poolid}")
|
||||
async def update_pool(
|
||||
server_id: int,
|
||||
poolid: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
"""Добавить/убрать участников пула (vms, storage) или изменить comment."""
|
||||
client = _resolve_client(db, server_id)
|
||||
data = await request.json()
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.update_pool(
|
||||
poolid,
|
||||
vms=data.get("vms"),
|
||||
storage=data.get("storage"),
|
||||
comment=data.get("comment"),
|
||||
delete=bool(data.get("delete")),
|
||||
)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating pool {poolid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/pools/{poolid}")
|
||||
async def delete_pool(
|
||||
server_id: int,
|
||||
poolid: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("server:manage")),
|
||||
):
|
||||
client = _resolve_client(db, server_id)
|
||||
try:
|
||||
return _ok_or_400(await _run_in_executor(lambda: client.delete_pool(poolid)))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting pool {poolid}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1383,6 +1383,40 @@ def get_vm_config(
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/nodes/{node}/hardware/pci")
|
||||
def list_node_pci_devices(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Список PCI-устройств ноды для passthrough (hostpciN)."""
|
||||
server = _resolve_server(db, server_id)
|
||||
client = _get_client_or_503(server)
|
||||
try:
|
||||
return JSONResponse(content=client.get_node_pci_devices(node))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing PCI devices on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/api/{server_id}/nodes/{node}/hardware/usb")
|
||||
def list_node_usb_devices(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vm:view"))
|
||||
):
|
||||
"""Список USB-устройств ноды для passthrough (usbN)."""
|
||||
server = _resolve_server(db, server_id)
|
||||
client = _get_client_or_503(server)
|
||||
try:
|
||||
return JSONResponse(content=client.get_node_usb_devices(node))
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing USB devices on {node}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/{server_id}/vm/{vmid}/config")
|
||||
async def update_vm_config(
|
||||
server_id: int,
|
||||
|
||||
@@ -16,6 +16,11 @@ from .mixins.cluster import ClusterMixin
|
||||
from .mixins.storage import StorageMixin
|
||||
from .mixins.network import NetworkMixin
|
||||
from .mixins.snapshot import SnapshotMixin
|
||||
from .mixins.firewall import FirewallMixin
|
||||
from .mixins.pools import PoolsMixin
|
||||
from .mixins.disks import DisksMixin
|
||||
from .mixins.access import AccessMixin
|
||||
from .mixins.node_admin import NodeAdminMixin
|
||||
|
||||
proxmox_executor = ThreadPoolExecutor(max_workers=20, thread_name_prefix="proxmox_")
|
||||
|
||||
@@ -82,7 +87,7 @@ def get_cached_client(host: str, user: str, password_hash: str = None,
|
||||
token_value=token_value_hash
|
||||
)
|
||||
|
||||
class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin):
|
||||
class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin, SnapshotMixin, FirewallMixin, PoolsMixin, DisksMixin, AccessMixin, NodeAdminMixin):
|
||||
@classmethod
|
||||
def from_server(cls, server) -> "ProxmoxClient":
|
||||
"""Single source of truth for building a client from a ProxmoxServer model.
|
||||
@@ -1297,11 +1302,19 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
'cores', 'sockets', 'memory', 'balloon', 'name', 'description',
|
||||
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'startup', 'boot', 'bootdisk',
|
||||
'agent', 'ostype', 'tablet', 'hotplug', 'protection',
|
||||
'ciuser', 'cipassword', 'sshkeys',
|
||||
'ciuser', 'cipassword', 'sshkeys', 'tags',
|
||||
# Hardware editor: CPU/NUMA, дисплей, контроллер, тип платформы,
|
||||
# efidisk0 — EFI-диск, обязателен для загрузки в режиме OVMF (UEFI).
|
||||
'numa', 'vga', 'scsihw', 'machine', 'bios', 'efidisk0',
|
||||
}
|
||||
indexed_re = re.compile(r'^(net|scsi|virtio|ide|sata|ipconfig)\d+$')
|
||||
# Удаляемые ключи: сетевые интерфейсы + опциональные булевы флаги
|
||||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
|
||||
# Индексированные ключи: сеть/диски + passthrough (PCI/USB) + serial
|
||||
indexed_re = re.compile(
|
||||
r'^(net|scsi|virtio|ide|sata|ipconfig|hostpci|usb|serial)\d+$'
|
||||
)
|
||||
# Удаляемые ключи: интерфейсы, passthrough-устройства + опциональные флаги
|
||||
delete_token_re = re.compile(
|
||||
r'^(net\d+|hostpci\d+|usb\d+|serial\d+|startup|onboot|protection|tags)$'
|
||||
)
|
||||
|
||||
filtered_config = {}
|
||||
for k, v in config.items():
|
||||
@@ -1372,7 +1385,7 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
|
||||
'searchdomain', 'tags',
|
||||
}
|
||||
net_re = re.compile(r'^net\d+$')
|
||||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection)$')
|
||||
delete_token_re = re.compile(r'^(net\d+|startup|onboot|protection|tags)$')
|
||||
|
||||
filtered_config = {}
|
||||
for k, v in config.items():
|
||||
|
||||
110
backend/app/proxmox/mixins/access.py
Normal file
110
backend/app/proxmox/mixins/access.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from typing import List, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class AccessMixin:
|
||||
"""
|
||||
PVE Access: realms (домены аутентификации, /access/domains) и
|
||||
API-токены пользователей (/access/users/{userid}/token).
|
||||
Геттеры возвращают данные как есть, мутации — {"success": bool, ...}.
|
||||
"""
|
||||
|
||||
# ---------- Realms (auth domains) ----------
|
||||
|
||||
def get_realms(self) -> List[Dict]:
|
||||
"""Список realms: [{realm, type, comment, tfa?}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.access.domains.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting realms: {e}")
|
||||
return []
|
||||
|
||||
def create_realm(self, realm: str, realm_type: str, **kwargs) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.access.domains.post(realm=realm, type=realm_type, **kwargs)
|
||||
return {"success": True, "realm": realm}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating realm {realm}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_realm(self, realm: str, **kwargs) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.access.domains(realm).put(**kwargs)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating realm {realm}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_realm(self, realm: str) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.access.domains(realm).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting realm {realm}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Users & API tokens ----------
|
||||
|
||||
def get_access_users(self) -> List[Dict]:
|
||||
"""Список пользователей PVE: [{userid, enable, comment, ...}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.access.users.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting access users: {e}")
|
||||
return []
|
||||
|
||||
def get_user_tokens(self, userid: str) -> List[Dict]:
|
||||
"""Токены пользователя: [{tokenid, comment, expire, privsep}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.access.users(userid).token.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting tokens for {userid}: {e}")
|
||||
return []
|
||||
|
||||
def create_user_token(self, userid: str, tokenid: str,
|
||||
comment: Optional[str] = None, privsep: bool = True,
|
||||
expire: Optional[int] = None) -> Dict:
|
||||
"""
|
||||
Создать API-токен. Возвращает секрет (value) — показывается один раз.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params: Dict = {"privsep": 1 if privsep else 0}
|
||||
if comment:
|
||||
params["comment"] = comment
|
||||
if expire:
|
||||
params["expire"] = expire
|
||||
result = self.proxmox.access.users(userid).token(tokenid).post(**params)
|
||||
data = dict(result or {})
|
||||
return {
|
||||
"success": True,
|
||||
"value": data.get("value"),
|
||||
"full_tokenid": data.get("full-tokenid") or f"{userid}!{tokenid}",
|
||||
"info": data.get("info"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating token {tokenid} for {userid}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_user_token(self, userid: str, tokenid: str) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.access.users(userid).token(tokenid).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting token {tokenid} for {userid}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
119
backend/app/proxmox/mixins/disks.py
Normal file
119
backend/app/proxmox/mixins/disks.py
Normal file
@@ -0,0 +1,119 @@
|
||||
from typing import List, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class DisksMixin:
|
||||
"""
|
||||
Физические диски ноды, SMART и ZFS-пулы (/nodes/{node}/disks/*).
|
||||
Геттеры возвращают данные как есть, мутации — {"success": bool, "upid"?, "error"?}.
|
||||
"""
|
||||
|
||||
# ---------- Physical disks ----------
|
||||
|
||||
def get_node_disks(self, node: str) -> List[Dict]:
|
||||
"""
|
||||
Список физических дисков ноды: devpath, size, model, serial, type
|
||||
(ssd/hdd/nvme), health (SMART), wearout, used, vendor и т.д.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).disks.list.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting disks for node {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_disk_smart(self, node: str, disk: str) -> Dict:
|
||||
"""SMART-данные диска (health + attributes)."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).disks.smart.get(disk=disk) or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SMART for {disk} on {node}: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def wipe_disk(self, node: str, disk: str) -> Dict:
|
||||
"""Очистить диск (wipe). ВНИМАНИЕ: уничтожает данные."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).disks.wipedisk.put(disk=disk)
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error wiping disk {disk} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- ZFS pools ----------
|
||||
|
||||
def get_zfs_pools(self, node: str) -> List[Dict]:
|
||||
"""Список ZFS-пулов ноды: name, size, alloc, free, frag, dedup, health."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).disks.zfs.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ZFS pools for node {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_zfs_pool(self, node: str, name: str) -> Dict:
|
||||
"""Детали ZFS-пула, включая дерево устройств (children)."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).disks.zfs(name).get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting ZFS pool {name} on {node}: {e}")
|
||||
return {}
|
||||
|
||||
def create_zfs_pool(self, node: str, name: str, devices: str,
|
||||
raidlevel: str = "single", ashift: int = 12,
|
||||
compression: str = "on", add_storage: bool = True) -> Dict:
|
||||
"""
|
||||
Создать ZFS-пул из устройств.
|
||||
|
||||
Args:
|
||||
devices: список devpath через запятую ("/dev/sdb,/dev/sdc")
|
||||
raidlevel: single | mirror | raid10 | raidz | raidz2 | raidz3
|
||||
ashift: 9..16 (обычно 12 для 4K-секторов)
|
||||
compression: on | off | lz4 | zstd | gzip
|
||||
add_storage: сразу зарегистрировать пул как storage в PVE
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).disks.zfs.post(
|
||||
name=name,
|
||||
devices=devices,
|
||||
raidlevel=raidlevel,
|
||||
ashift=ashift,
|
||||
compression=compression,
|
||||
add_storage=1 if add_storage else 0,
|
||||
)
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating ZFS pool {name} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def destroy_zfs_pool(self, node: str, name: str,
|
||||
cleanup_config: bool = True, cleanup_disks: bool = False) -> Dict:
|
||||
"""
|
||||
Уничтожить ZFS-пул.
|
||||
|
||||
Args:
|
||||
cleanup_config: убрать связанную запись storage из PVE
|
||||
cleanup_disks: затереть диски (wipe) после уничтожения пула
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {}
|
||||
if cleanup_config:
|
||||
params["cleanup-config"] = 1
|
||||
if cleanup_disks:
|
||||
params["cleanup-disks"] = 1
|
||||
upid = self.proxmox.nodes(node).disks.zfs(name).delete(**params)
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error destroying ZFS pool {name} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
436
backend/app/proxmox/mixins/firewall.py
Normal file
436
backend/app/proxmox/mixins/firewall.py
Normal file
@@ -0,0 +1,436 @@
|
||||
from typing import List, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class FirewallMixin:
|
||||
"""
|
||||
Datacenter-level Proxmox firewall: options, rules, security groups,
|
||||
IP sets and aliases. Все методы работают с /cluster/firewall/*.
|
||||
|
||||
Геттеры возвращают списки/словари как есть, мутации — dict
|
||||
{"success": bool, "error"?: str}, по образцу SDN-миксина.
|
||||
"""
|
||||
|
||||
# ---------- Options ----------
|
||||
|
||||
def get_cluster_firewall_options(self) -> Dict:
|
||||
"""Опции датацентр-фаервола (enable, policy_in/out, log_ratelimit, …)."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.cluster.firewall.options.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cluster firewall options: {e}")
|
||||
return {}
|
||||
|
||||
def update_cluster_firewall_options(self, **opts) -> Dict:
|
||||
"""Обновить опции датацентр-фаервола."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.options.put(**opts)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating cluster firewall options: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Datacenter rules ----------
|
||||
|
||||
def get_cluster_firewall_rules(self) -> List[Dict]:
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.cluster.firewall.rules.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting cluster firewall rules: {e}")
|
||||
return []
|
||||
|
||||
def create_cluster_firewall_rule(self, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.rules.post(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating cluster firewall rule: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_cluster_firewall_rule(self, pos: int, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.rules(pos).put(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating cluster firewall rule {pos}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_cluster_firewall_rule(self, pos: int) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.rules(pos).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting cluster firewall rule {pos}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Security groups ----------
|
||||
|
||||
def get_firewall_groups(self) -> List[Dict]:
|
||||
"""Список security groups: [{group, comment, digest}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.cluster.firewall.groups.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall groups: {e}")
|
||||
return []
|
||||
|
||||
def create_firewall_group(self, group: str, comment: Optional[str] = None) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {"group": group}
|
||||
if comment:
|
||||
params["comment"] = comment
|
||||
self.proxmox.cluster.firewall.groups.post(**params)
|
||||
return {"success": True, "group": group}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating firewall group {group}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_firewall_group(self, group: str) -> Dict:
|
||||
"""Удалить security group целиком (должна быть пустой в PVE)."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.groups(group).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting firewall group {group}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_firewall_group_rules(self, group: str) -> List[Dict]:
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.cluster.firewall.groups(group).get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting rules for firewall group {group}: {e}")
|
||||
return []
|
||||
|
||||
def create_firewall_group_rule(self, group: str, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.groups(group).post(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating rule in firewall group {group}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_firewall_group_rule(self, group: str, pos: int, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.groups(group)(pos).put(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating rule {pos} in group {group}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_firewall_group_rule(self, group: str, pos: int) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.groups(group)(pos).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting rule {pos} in group {group}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- IP sets ----------
|
||||
|
||||
def get_firewall_ipsets(self) -> List[Dict]:
|
||||
"""Список IP set'ов: [{name, comment, digest}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.cluster.firewall.ipset.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall ipsets: {e}")
|
||||
return []
|
||||
|
||||
def create_firewall_ipset(self, name: str, comment: Optional[str] = None) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {"name": name}
|
||||
if comment:
|
||||
params["comment"] = comment
|
||||
self.proxmox.cluster.firewall.ipset.post(**params)
|
||||
return {"success": True, "name": name}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating firewall ipset {name}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_firewall_ipset(self, name: str) -> Dict:
|
||||
"""Удалить IP set целиком (должен быть пустым в PVE)."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.ipset(name).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting firewall ipset {name}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_firewall_ipset_entries(self, name: str) -> List[Dict]:
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.cluster.firewall.ipset(name).get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting entries for ipset {name}: {e}")
|
||||
return []
|
||||
|
||||
def add_firewall_ipset_entry(self, name: str, cidr: str,
|
||||
comment: Optional[str] = None,
|
||||
nomatch: bool = False) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {"cidr": cidr}
|
||||
if comment:
|
||||
params["comment"] = comment
|
||||
if nomatch:
|
||||
params["nomatch"] = 1
|
||||
self.proxmox.cluster.firewall.ipset(name).post(**params)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding entry {cidr} to ipset {name}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_firewall_ipset_entry(self, name: str, cidr: str) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.ipset(name)(cidr).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting entry {cidr} from ipset {name}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Aliases ----------
|
||||
|
||||
def get_firewall_aliases(self) -> List[Dict]:
|
||||
"""Список алиасов: [{name, cidr, comment, ipversion}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.cluster.firewall.aliases.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall aliases: {e}")
|
||||
return []
|
||||
|
||||
def create_firewall_alias(self, name: str, cidr: str,
|
||||
comment: Optional[str] = None) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {"name": name, "cidr": cidr}
|
||||
if comment:
|
||||
params["comment"] = comment
|
||||
self.proxmox.cluster.firewall.aliases.post(**params)
|
||||
return {"success": True, "name": name}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating firewall alias {name}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_firewall_alias(self, name: str, cidr: str,
|
||||
comment: Optional[str] = None,
|
||||
rename: Optional[str] = None) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {"cidr": cidr}
|
||||
if comment is not None:
|
||||
params["comment"] = comment
|
||||
if rename:
|
||||
params["rename"] = rename
|
||||
self.proxmox.cluster.firewall.aliases(name).put(**params)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating firewall alias {name}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_firewall_alias(self, name: str) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.cluster.firewall.aliases(name).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting firewall alias {name}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Node-level: options ----------
|
||||
|
||||
def get_node_firewall_options(self, node: str) -> Dict:
|
||||
"""Опции фаервола ноды (enable, log_level_in/out, nosmurfs, …)."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).firewall.options.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} firewall options: {e}")
|
||||
return {}
|
||||
|
||||
def update_node_firewall_options(self, node: str, **opts) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).firewall.options.put(**opts)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating node {node} firewall options: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- Node-level: rules ----------
|
||||
|
||||
def get_node_firewall_rules(self, node: str) -> List[Dict]:
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).firewall.rules.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} firewall rules: {e}")
|
||||
return []
|
||||
|
||||
def create_node_firewall_rule(self, node: str, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).firewall.rules.post(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating node {node} firewall rule: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_node_firewall_rule(self, node: str, pos: int, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).firewall.rules(pos).put(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating node {node} firewall rule {pos}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_node_firewall_rule(self, node: str, pos: int) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).firewall.rules(pos).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting node {node} firewall rule {pos}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_node_firewall_log(self, node: str, limit: int = 100, start: int = 0) -> List[Dict]:
|
||||
"""Строки firewall-лога ноды: [{n, t}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).firewall.log.get(limit=limit, start=start) or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting node {node} firewall log: {e}")
|
||||
return []
|
||||
|
||||
# ---------- Guest-level (VM/LXC) ----------
|
||||
|
||||
def _guest_fw(self, node: str, vm_type: str, vmid: int):
|
||||
"""Ресурс /nodes/{node}/{qemu|lxc}/{vmid}/firewall для proxmoxer."""
|
||||
kind = "qemu" if vm_type in ("vm", "qemu") else "lxc"
|
||||
return self.proxmox.nodes(node)(kind)(vmid).firewall
|
||||
|
||||
def get_guest_firewall_options(self, node: str, vm_type: str, vmid: int) -> Dict:
|
||||
"""Опции фаервола VM/LXC (enable, dhcp, macfilter, ipfilter, policy_in/out, …)."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self._guest_fw(node, vm_type, vmid).options.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting guest {vmid} firewall options: {e}")
|
||||
return {}
|
||||
|
||||
def update_guest_firewall_options(self, node: str, vm_type: str, vmid: int, **opts) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self._guest_fw(node, vm_type, vmid).options.put(**opts)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating guest {vmid} firewall options: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_guest_firewall_rules(self, node: str, vm_type: str, vmid: int) -> List[Dict]:
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self._guest_fw(node, vm_type, vmid).rules.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting guest {vmid} firewall rules: {e}")
|
||||
return []
|
||||
|
||||
def create_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self._guest_fw(node, vm_type, vmid).rules.post(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating guest {vmid} firewall rule: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, pos: int, **rule) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self._guest_fw(node, vm_type, vmid).rules(pos).put(**rule)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating guest {vmid} firewall rule {pos}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_guest_firewall_rule(self, node: str, vm_type: str, vmid: int, pos: int) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self._guest_fw(node, vm_type, vmid).rules(pos).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting guest {vmid} firewall rule {pos}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def get_guest_firewall_log(self, node: str, vm_type: str, vmid: int,
|
||||
limit: int = 100, start: int = 0) -> List[Dict]:
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self._guest_fw(node, vm_type, vmid).log.get(limit=limit, start=start) or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting guest {vmid} firewall log: {e}")
|
||||
return []
|
||||
|
||||
# ---------- Macros (read-only, для подсказок при создании правил) ----------
|
||||
|
||||
def get_firewall_macros(self) -> List[Dict]:
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.cluster.firewall.macros.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting firewall macros: {e}")
|
||||
return []
|
||||
97
backend/app/proxmox/mixins/node_admin.py
Normal file
97
backend/app/proxmox/mixins/node_admin.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from typing import List, Dict
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class NodeAdminMixin:
|
||||
"""
|
||||
Администрирование ноды: systemd-сервисы (/nodes/{node}/services)
|
||||
и APT (обновления и репозитории, /nodes/{node}/apt).
|
||||
"""
|
||||
|
||||
# ---------- Services ----------
|
||||
|
||||
def get_node_services(self, node: str) -> List[Dict]:
|
||||
"""Список сервисов ноды: [{service, name, desc, state, active-state}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).services.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting services for {node}: {e}")
|
||||
return []
|
||||
|
||||
def node_service_action(self, node: str, service: str, action: str) -> Dict:
|
||||
"""
|
||||
Управление сервисом: start | stop | restart | reload.
|
||||
Возвращает UPID задачи.
|
||||
"""
|
||||
if action not in ("start", "stop", "restart", "reload"):
|
||||
return {"success": False, "error": "invalid action"}
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
endpoint = getattr(self.proxmox.nodes(node).services(service), action)
|
||||
upid = endpoint.post()
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error {action} service {service} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- APT: updates ----------
|
||||
|
||||
def get_apt_updates(self, node: str) -> List[Dict]:
|
||||
"""Доступные обновления пакетов: [{Package, Version, OldVersion, Title}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.nodes(node).apt.update.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting APT updates for {node}: {e}")
|
||||
return []
|
||||
|
||||
def apt_refresh(self, node: str) -> Dict:
|
||||
"""Обновить списки пакетов (apt-get update). Возвращает UPID."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
upid = self.proxmox.nodes(node).apt.update.post()
|
||||
return {"success": True, "upid": upid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing APT on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
# ---------- APT: repositories ----------
|
||||
|
||||
def get_apt_repositories(self, node: str) -> Dict:
|
||||
"""Информация о репозиториях APT (files, standard-repos, errors)."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.nodes(node).apt.repositories.get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting APT repositories for {node}: {e}")
|
||||
return {}
|
||||
|
||||
def set_apt_repository(self, node: str, path: str, index: int, enabled: bool) -> Dict:
|
||||
"""Включить/выключить конкретный репозиторий (по файлу и индексу)."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).apt.repositories.post(
|
||||
path=path, index=index, enabled=1 if enabled else 0,
|
||||
)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error toggling APT repo {path}#{index} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def add_apt_standard_repository(self, node: str, handle: str) -> Dict:
|
||||
"""Добавить стандартный репозиторий PVE (handle из standard-repos)."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.nodes(node).apt.repositories.put(handle=handle)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding standard repo {handle} on {node}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
82
backend/app/proxmox/mixins/pools.py
Normal file
82
backend/app/proxmox/mixins/pools.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import List, Dict, Optional
|
||||
from loguru import logger
|
||||
|
||||
|
||||
class PoolsMixin:
|
||||
"""
|
||||
Resource pools (/pools) — группировка VM/LXC и хранилищ.
|
||||
Геттеры возвращают данные как есть, мутации — {"success": bool, "error"?: str}.
|
||||
"""
|
||||
|
||||
def get_pools(self) -> List[Dict]:
|
||||
"""Список пулов: [{poolid, comment}]."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
return list(self.proxmox.pools.get() or [])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pools: {e}")
|
||||
return []
|
||||
|
||||
def get_pool(self, poolid: str) -> Dict:
|
||||
"""Детали пула, включая members (VM/LXC/storage)."""
|
||||
if not self.proxmox:
|
||||
return {}
|
||||
try:
|
||||
return dict(self.proxmox.pools(poolid).get() or {})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting pool {poolid}: {e}")
|
||||
return {}
|
||||
|
||||
def create_pool(self, poolid: str, comment: Optional[str] = None) -> Dict:
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params = {"poolid": poolid}
|
||||
if comment:
|
||||
params["comment"] = comment
|
||||
self.proxmox.pools.post(**params)
|
||||
return {"success": True, "poolid": poolid}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating pool {poolid}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def update_pool(self, poolid: str, vms: Optional[str] = None,
|
||||
storage: Optional[str] = None, comment: Optional[str] = None,
|
||||
delete: bool = False) -> Dict:
|
||||
"""
|
||||
Добавить/убрать участников пула.
|
||||
|
||||
Args:
|
||||
vms: список vmid через запятую ("100,101")
|
||||
storage: список storage id через запятую
|
||||
delete: True — убрать указанных участников, иначе добавить
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
params: Dict = {}
|
||||
if vms:
|
||||
params["vms"] = vms
|
||||
if storage:
|
||||
params["storage"] = storage
|
||||
if comment is not None:
|
||||
params["comment"] = comment
|
||||
if delete:
|
||||
params["delete"] = 1
|
||||
self.proxmox.pools(poolid).put(**params)
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating pool {poolid}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
def delete_pool(self, poolid: str) -> Dict:
|
||||
"""Удалить пул (в PVE должен быть пустым)."""
|
||||
if not self.proxmox:
|
||||
return {"success": False, "error": "Not connected"}
|
||||
try:
|
||||
self.proxmox.pools(poolid).delete()
|
||||
return {"success": True}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting pool {poolid}: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
@@ -215,3 +215,43 @@ class StorageMixin:
|
||||
logger.error(f"Ошибка получения backup-хранилищ для {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_node_pci_devices(self, node: str) -> List[Dict]:
|
||||
"""
|
||||
Получить список PCI-устройств ноды для passthrough (hostpciN).
|
||||
|
||||
Returns:
|
||||
Список Dict с полями: id (0000:01:00.0), vendor_name, device_name,
|
||||
iommugroup, mdev, subsystem_* и т.д. Отсортирован по id.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
devices = self.proxmox.nodes(node).hardware.pci.get()
|
||||
return sorted(devices, key=lambda d: d.get('id', ''))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения PCI-устройств ноды {node}: {e}")
|
||||
return []
|
||||
|
||||
def get_node_usb_devices(self, node: str) -> List[Dict]:
|
||||
"""
|
||||
Получить список USB-устройств ноды для passthrough (usbN).
|
||||
|
||||
Returns:
|
||||
Список Dict с полями: busnum, devnum, vendid, prodid, port,
|
||||
usbpath, class, speed, prodname/manufacturer. Отсортирован по usbpath.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
# Не все версии PVE отдают hardware/usb; scanusb — исторический fallback.
|
||||
try:
|
||||
devices = self.proxmox.nodes(node).hardware.usb.get()
|
||||
except Exception:
|
||||
devices = self.proxmox.nodes(node).scanusb.get()
|
||||
# Отбрасываем корневые хабы без vendor/product id — их нельзя пробросить.
|
||||
cleaned = [d for d in devices if d.get('vendid') and d.get('prodid')]
|
||||
return sorted(cleaned, key=lambda d: str(d.get('usbpath') or d.get('port') or ''))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения USB-устройств ноды {node}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user