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

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

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

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

View 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 []

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

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

View File

@@ -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 []