feat: add firewall, pools, access, disks, node admin and VM hardware management
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m0s
Deploy to Production / Trigger production update (push) Successful in 7s

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:
markmorado
2026-07-04 00:02:15 +05:00
parent c12fbf13e5
commit 5c8baefb3f
37 changed files with 6627 additions and 10 deletions

View File

@@ -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)

View 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))

View 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))

View 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))

View 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))

View 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))

View File

@@ -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,