feat: Proxmox management suite — hardware, firewall, pools, disks, storage, access, node admin
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 54s
Deploy to Production / Trigger production update (push) Successful in 7s

- VM/LXC hardware editor with PCI/USB passthrough (BIOS/OVMF + efidisk,
  machine type, VGA, SCSI controller, CPU sockets); passthrough locked while running
- Firewall at datacenter/node/VM levels: rules, security groups, IP sets,
  aliases, options, log — with rule dialog referencing security groups
- Resource pools (CRUD + membership) and per-instance tags editor
- Node disks: physical disk list, SMART viewer, ZFS pool create/destroy, wipe
- Storage: add LVM/LVM-thin/ZFS/RBD/CephFS types + enable/disable toggle
- PVE Access: API tokens (create with one-time secret, delete) and realms (LDAP/AD/OpenID)
- Node admin: systemd services (start/stop/restart), APT updates & repositories
- Shared: SelectTrigger defaults to full width (fixes narrow dropdowns)

Also bundles related in-progress work: auth screen refresh, node shell console
page, server-updates badge, and node:*/access:* RBAC permissions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
markmorado
2026-07-04 05:40:52 +05:00
parent 5c8baefb3f
commit 00e36e1cc6
31 changed files with 2576 additions and 357 deletions

View File

@@ -34,7 +34,7 @@ def _ok_or_400(result: dict):
async def list_realms(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -49,7 +49,7 @@ async def create_realm(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -72,7 +72,7 @@ async def update_realm(
realm: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -90,7 +90,7 @@ async def delete_realm(
server_id: int,
realm: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -108,7 +108,7 @@ async def delete_realm(
async def list_access_users(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -123,7 +123,7 @@ async def list_user_tokens(
server_id: int,
userid: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -140,7 +140,7 @@ async def create_user_token(
tokenid: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:manage")),
):
"""Создать API-токен. Секрет (value) возвращается один раз."""
client = _resolve_client(db, server_id)
@@ -165,7 +165,7 @@ async def delete_user_token(
userid: str,
tokenid: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("user:manage")),
current_user: User = Depends(PermissionChecker("access:manage")),
):
client = _resolve_client(db, server_id)
try:

View File

@@ -35,7 +35,7 @@ async def list_disks(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("storage:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -51,7 +51,7 @@ async def disk_smart(
node: str,
disk: str = Query(..., description="devpath, напр. /dev/sda"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("storage:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -67,7 +67,7 @@ async def wipe_disk(
node: str,
disk: str = Query(..., description="devpath, напр. /dev/sdb"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("storage:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -86,7 +86,7 @@ async def list_zfs_pools(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("storage:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -102,7 +102,7 @@ async def get_zfs_pool(
node: str,
name: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("storage:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -118,7 +118,7 @@ async def create_zfs_pool(
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("storage:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -148,7 +148,7 @@ async def destroy_zfs_pool(
name: str,
cleanup_disks: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("storage:manage")),
):
client = _resolve_client(db, server_id)
try:

View File

@@ -36,7 +36,7 @@ def _ok_or_400(result: dict):
async def get_firewall_options(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -51,7 +51,7 @@ async def update_firewall_options(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -70,7 +70,7 @@ async def update_firewall_options(
async def get_firewall_rules(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -85,7 +85,7 @@ async def create_firewall_rule(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -104,7 +104,7 @@ async def update_firewall_rule(
pos: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -122,7 +122,7 @@ async def delete_firewall_rule(
server_id: int,
pos: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -140,7 +140,7 @@ async def delete_firewall_rule(
async def get_firewall_groups(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -155,7 +155,7 @@ async def create_firewall_group(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -177,7 +177,7 @@ async def delete_firewall_group(
server_id: int,
group: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -194,7 +194,7 @@ async def get_firewall_group_rules(
server_id: int,
group: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -210,7 +210,7 @@ async def create_firewall_group_rule(
group: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -230,7 +230,7 @@ async def update_firewall_group_rule(
pos: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -249,7 +249,7 @@ async def delete_firewall_group_rule(
group: str,
pos: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -267,7 +267,7 @@ async def delete_firewall_group_rule(
async def get_firewall_ipsets(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -282,7 +282,7 @@ async def create_firewall_ipset(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -304,7 +304,7 @@ async def delete_firewall_ipset(
server_id: int,
name: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -321,7 +321,7 @@ async def get_firewall_ipset_entries(
server_id: int,
name: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -337,7 +337,7 @@ async def add_firewall_ipset_entry(
name: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -361,7 +361,7 @@ async def delete_firewall_ipset_entry(
name: str,
cidr: str = Query(..., description="CIDR записи (содержит '/', поэтому в query)"),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -379,7 +379,7 @@ async def delete_firewall_ipset_entry(
async def get_firewall_aliases(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -394,7 +394,7 @@ async def create_firewall_alias(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -417,7 +417,7 @@ async def update_firewall_alias(
name: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -439,7 +439,7 @@ async def delete_firewall_alias(
server_id: int,
name: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -458,7 +458,7 @@ async def get_node_firewall_options(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -474,7 +474,7 @@ async def update_node_firewall_options(
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -494,7 +494,7 @@ async def get_node_firewall_rules(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -510,7 +510,7 @@ async def create_node_firewall_rule(
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -530,7 +530,7 @@ async def update_node_firewall_rule(
pos: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -549,7 +549,7 @@ async def delete_node_firewall_rule(
node: str,
pos: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -568,7 +568,7 @@ async def get_node_firewall_log(
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")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -593,7 +593,7 @@ async def get_guest_firewall_options(
vmid: int,
node: str = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
_norm_type(vm_type)
@@ -613,7 +613,7 @@ async def update_guest_firewall_options(
request: Request,
node: str = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
_norm_type(vm_type)
@@ -635,7 +635,7 @@ async def get_guest_firewall_rules(
vmid: int,
node: str = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
_norm_type(vm_type)
@@ -655,7 +655,7 @@ async def create_guest_firewall_rule(
request: Request,
node: str = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
_norm_type(vm_type)
@@ -679,7 +679,7 @@ async def update_guest_firewall_rule(
request: Request,
node: str = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
_norm_type(vm_type)
@@ -702,7 +702,7 @@ async def delete_guest_firewall_rule(
pos: int,
node: str = Query(...),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("firewall:manage")),
):
client = _resolve_client(db, server_id)
_norm_type(vm_type)
@@ -725,7 +725,7 @@ async def get_guest_firewall_log(
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")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
_norm_type(vm_type)
@@ -743,7 +743,7 @@ async def get_guest_firewall_log(
async def get_firewall_macros(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("firewall:view")),
):
client = _resolve_client(db, server_id)
try:

View File

@@ -18,7 +18,7 @@ router = APIRouter()
def get_server_nodes(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
current_user: User = Depends(PermissionChecker("network:view"))
):
"""List nodes for a Proxmox server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -41,7 +41,7 @@ def list_node_networks(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
current_user: User = Depends(PermissionChecker("network:view"))
):
"""List all network interfaces on a node, with IPAM linkage info."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -110,7 +110,7 @@ async def create_node_network(
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Create a new network interface on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -150,7 +150,7 @@ async def update_node_network(
iface: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Update a network interface on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -181,7 +181,7 @@ def delete_node_network(
node: str,
iface: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Delete a network interface from a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -210,7 +210,7 @@ def apply_node_network(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Apply pending network configuration changes on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -239,7 +239,7 @@ def revert_node_network(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Revert pending network configuration changes on a node."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()

View File

@@ -35,7 +35,7 @@ async def list_services(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -52,7 +52,7 @@ async def service_action(
service: str,
action: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("node:manage")),
):
if action not in ("start", "stop", "restart", "reload"):
raise HTTPException(status_code=400, detail="action must be start/stop/restart/reload")
@@ -73,7 +73,7 @@ async def apt_updates(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -88,7 +88,7 @@ async def apt_refresh(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("node:manage")),
):
client = _resolve_client(db, server_id)
try:
@@ -107,7 +107,7 @@ async def apt_repositories(
server_id: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view")),
current_user: User = Depends(PermissionChecker("node:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -123,7 +123,7 @@ async def set_apt_repository(
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("node:manage")),
):
"""Включить/выключить репозиторий (path + index + enabled)."""
client = _resolve_client(db, server_id)
@@ -148,7 +148,7 @@ async def add_standard_repository(
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("node:manage")),
):
"""Добавить стандартный репозиторий PVE по handle."""
client = _resolve_client(db, server_id)

View File

@@ -32,7 +32,7 @@ def _ok_or_400(result: dict):
async def list_pools(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")),
current_user: User = Depends(PermissionChecker("pool:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -47,7 +47,7 @@ async def get_pool(
server_id: int,
poolid: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:view")),
current_user: User = Depends(PermissionChecker("pool:view")),
):
client = _resolve_client(db, server_id)
try:
@@ -62,7 +62,7 @@ async def create_pool(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("pool:manage")),
):
client = _resolve_client(db, server_id)
data = await request.json()
@@ -84,7 +84,7 @@ async def update_pool(
poolid: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("pool:manage")),
):
"""Добавить/убрать участников пула (vms, storage) или изменить comment."""
client = _resolve_client(db, server_id)
@@ -109,7 +109,7 @@ async def delete_pool(
server_id: int,
poolid: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage")),
current_user: User = Depends(PermissionChecker("pool:manage")),
):
client = _resolve_client(db, server_id)
try:

View File

@@ -20,7 +20,7 @@ router = APIRouter()
def get_sdn_status(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
current_user: User = Depends(PermissionChecker("network:view"))
):
"""Check if SDN is available on the server"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -52,7 +52,7 @@ def get_sdn_status(
def get_sdn_zones(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
current_user: User = Depends(PermissionChecker("network:view"))
):
"""Get all SDN zones"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -73,7 +73,7 @@ def get_sdn_zones(
def get_sdn_dns(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
current_user: User = Depends(PermissionChecker("network:view"))
):
"""Get SDN DNS server entries (zone `dns` option expects one of their IDs)"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -93,7 +93,7 @@ async def create_sdn_zone(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Create a new SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -131,7 +131,7 @@ def delete_sdn_zone(
server_id: int,
zone: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Delete an SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -159,7 +159,7 @@ def delete_sdn_zone(
def get_sdn_vnets(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
current_user: User = Depends(PermissionChecker("network:view"))
):
"""Get all SDN VNets"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -181,7 +181,7 @@ async def create_sdn_vnet(
server_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Create a new SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -220,7 +220,7 @@ def delete_sdn_vnet(
server_id: int,
vnet: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Delete an SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -249,7 +249,7 @@ def get_sdn_subnets(
server_id: int,
vnet: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:view"))
current_user: User = Depends(PermissionChecker("network:view"))
):
"""Get subnets for a VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -272,7 +272,7 @@ async def create_sdn_subnet(
vnet: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Create a subnet in a VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -336,7 +336,7 @@ async def create_sdn_subnet(
def apply_sdn_changes(
server_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Apply pending SDN changes"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -366,7 +366,7 @@ async def update_sdn_zone(
zone: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Update an existing SDN zone"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -395,7 +395,7 @@ async def update_sdn_vnet(
vnet: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Update an existing SDN VNet"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
@@ -425,7 +425,7 @@ def delete_sdn_subnet(
subnet_cidr: str,
delete_ipam_network: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
current_user: User = Depends(PermissionChecker("network:manage"))
):
"""Delete a subnet from a VNet. subnet_cidr uses '-' instead of '/' (e.g. 10.0.0.0-24)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()

View File

@@ -1528,6 +1528,258 @@ async def resize_vm_disk(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/disk/move")
async def move_vm_disk(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Переместить диск VM в другое хранилище (move_disk)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
disk = data.get('disk')
target_storage = data.get('target_storage')
if not disk or not target_storage:
raise HTTPException(status_code=400, detail="Требуются параметры disk и target_storage")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.move_vm_disk(
node, vmid, disk, target_storage,
delete=bool(data.get('delete', True)),
target_format=data.get('format') or None,
)
if result.get('success'):
logger.info(f"User {current_user.username} moved disk {disk} of VM {vmid} to {target_storage}")
return JSONResponse(content={"status": "success", "upid": result.get('upid')})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error moving VM {vmid} disk: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/disk/add")
async def add_vm_disk(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Добавить новый диск VM."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
disk = data.get('disk')
storage = data.get('storage')
size_gb = data.get('size_gb')
if not disk or not storage or not size_gb:
raise HTTPException(status_code=400, detail="Требуются параметры disk, storage и size_gb")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.add_vm_disk(
node, vmid, disk, storage, int(size_gb),
ssd=bool(data.get('ssd', False)),
discard=bool(data.get('discard', False)),
iothread=bool(data.get('iothread', False)),
)
if result.get('success'):
logger.info(f"User {current_user.username} added disk {disk} ({size_gb}G) to VM {vmid}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error adding disk to VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/disk/detach")
async def detach_vm_disk(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Отключить (или удалить) диск VM."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
disk = data.get('disk')
if not disk:
raise HTTPException(status_code=400, detail="Требуется параметр disk")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.detach_vm_disk(node, vmid, disk, destroy=bool(data.get('destroy', False)))
if result.get('success'):
logger.info(f"User {current_user.username} detached disk {disk} of VM {vmid}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error detaching VM {vmid} disk: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/unlock")
def unlock_vm_endpoint(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Снять блокировку (lock) с VM — аналог qm unlock."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.unlock_vm(node, vmid)
if result.get("success"):
logger.info(f"User {current_user.username} unlocked VM {vmid} on {server.name}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error unlocking VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/unlock")
def unlock_container_endpoint(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Снять блокировку (lock) с LXC — аналог pct unlock."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.unlock_container(node, vmid)
if result.get("success"):
logger.info(f"User {current_user.username} unlocked container {vmid} on {server.name}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get("error", "Failed"))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error unlocking container {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/cloud-init")
async def update_vm_cloud_init(
server_id: int,
vmid: int,
node: str,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Обновить cloud-init параметры VM (user/password/SSH/IP/DNS)."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
data = await request.json()
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
result = client.update_cloud_init(
node, vmid,
ciuser=data.get('ciuser'),
cipassword=data.get('cipassword'),
sshkeys=data.get('sshkeys'),
ipconfig0=data.get('ipconfig0'),
nameserver=data.get('nameserver'),
searchdomain=data.get('searchdomain'),
)
if result.get('success'):
logger.info(f"User {current_user.username} updated cloud-init of VM {vmid} on {server.name}")
return JSONResponse(content={"status": "success"})
raise HTTPException(status_code=400, detail=result.get('error', 'Failed'))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating cloud-init for VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/vm/{vmid}/serial/enable")
def enable_vm_serial(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("server:manage"))
):
"""Добавить serial0 (socket) в конфиг VM, если его нет — для serial-консоли.
Возвращает added=True, если устройство было только что добавлено (тогда для
появления в гостевой ОС может потребоваться перезагрузка VM).
"""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
client = _get_proxmox_client(server)
if not client.is_connected():
raise HTTPException(status_code=503, detail="Failed to connect to Proxmox server")
cfg = client.get_vm_config(node, vmid) or {}
present = any(k.startswith("serial") for k in cfg)
added = False
if not present:
client.update_vm_config(node, vmid, {"serial0": "socket"})
added = True
logger.info(f"User {current_user.username} enabled serial0 on VM {vmid}")
return JSONResponse(content={"status": "success", "added": added, "present": True})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error enabling serial for VM {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/disk/resize")
async def resize_container_disk(
server_id: int,
@@ -2885,6 +3137,351 @@ async def terminal_websocket_fixed(
logger.info(f"Terminal session closed for container {vmid}")
# ==================== VM Serial Console WebSocket Proxy ====================
@router.websocket("/ws/serial/{server_id}/{node}/{vmid}")
async def serial_websocket(
websocket: WebSocket,
server_id: int,
node: str,
vmid: int,
token: str = Query(None), # JWT панели (?token=)
db: Session = Depends(get_db)
):
"""WebSocket serial-консоль для VM (qemu) через Proxmox termproxy.
Идентично LXC-терминалу, но использует эндпоинты qemu. Требует, чтобы у VM
был настроен serial0 (см. /vm/{vmid}/serial/enable) и гость выводил в ttyS0.
"""
import websockets
import httpx
from urllib.parse import quote
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "vm:console"):
await websocket.close(code=4003, reason="Permission denied")
return
if not check_vm_access(db, user, server_id, vmid):
await websocket.close(code=4003, reason="Access denied")
return
await websocket.accept()
logger.info(f"Serial WebSocket accepted for VM {vmid} on node {node} (user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
await websocket.close(code=1008, reason="Proxmox server not found")
return
proxmox_ws = None
try:
async with httpx.AsyncClient(verify=False, timeout=10) as client:
if not server.password:
await websocket.close(code=1011, reason="Serial console requires password authentication")
return
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
auth_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/access/ticket",
data={"username": auth_username, "password": server.password}
)
if auth_resp.status_code != 200:
await websocket.close(code=1011, reason="Authentication failed")
return
auth_data = auth_resp.json()["data"]
ticket = auth_data["ticket"]
csrf_token = auth_data["CSRFPreventionToken"]
term_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/termproxy",
headers={"CSRFPreventionToken": csrf_token},
cookies={"PVEAuthCookie": ticket}
)
if term_resp.status_code != 200:
logger.error(f"Serial termproxy failed: {term_resp.status_code} - {term_resp.text}")
await websocket.close(code=1011, reason="Failed to create serial session (is serial0 configured?)")
return
term_data = term_resp.json()["data"]
vncticket = term_data["ticket"]
port = term_data["port"]
proxmox_ws_url = (
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket"
f"?port={port}&vncticket={quote(vncticket, safe='')}"
)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
proxmox_ws = await websockets.connect(
proxmox_ws_url,
ssl=ssl_context,
extra_headers=[("Cookie", f"PVEAuthCookie={ticket}")],
subprotocols=["binary"],
max_size=None,
ping_interval=None,
close_timeout=5
)
await proxmox_ws.send(f"{auth_username}:{vncticket}\n")
try:
ok_raw = await asyncio.wait_for(proxmox_ws.recv(), timeout=10.0)
except asyncio.TimeoutError:
await websocket.close(code=1011, reason="Serial auth timeout")
return
ok_bytes = ok_raw if isinstance(ok_raw, bytes) else ok_raw.encode("utf-8")
if not ok_bytes.startswith(b"OK"):
await websocket.close(code=1011, reason="Serial authentication rejected")
return
if len(ok_bytes) > 2:
await websocket.send_bytes(ok_bytes[2:])
async def client_to_proxmox():
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
if "bytes" in message:
await proxmox_ws.send(message["bytes"])
elif "text" in message:
await proxmox_ws.send(message["text"].encode("utf-8"))
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug(f"serial client_to_proxmox ended: {e}")
async def proxmox_to_client():
try:
async for message in proxmox_ws:
if isinstance(message, bytes):
await websocket.send_bytes(message)
else:
await websocket.send_bytes(message.encode("utf-8"))
except Exception as e:
logger.debug(f"serial proxmox_to_client ended: {e}")
done, pending = await asyncio.wait(
[
asyncio.create_task(client_to_proxmox()),
asyncio.create_task(proxmox_to_client()),
],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
logger.error(f"Serial WebSocket error: {e}")
try:
await websocket.close(code=1011, reason=str(e))
except Exception:
pass
finally:
if proxmox_ws:
try:
await proxmox_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"Serial session closed for VM {vmid}")
# ==================== Node Shell (APT upgrade) ====================
# Разрешённые команды node-termproxy (Proxmox: upgrade | login | ceph_install)
_ALLOWED_NODE_SHELL_CMDS = {"upgrade", "login"}
@router.websocket("/ws/node-shell/{server_id}/{node}")
async def node_shell_websocket(
websocket: WebSocket,
server_id: int,
node: str,
cmd: str = Query("upgrade"), # upgrade | login
token: str = Query(None), # JWT панели (?token=) — браузеры не шлют заголовки на WS
db: Session = Depends(get_db)
):
"""
WebSocket root-терминал на уровне ноды через Proxmox termproxy API.
Используется для установки обновлений (cmd=upgrade → apt dist-upgrade),
как это делает штатная кнопка «Upgrade» в интерфейсе Proxmox.
"""
import websockets
import httpx
from urllib.parse import quote
# --- Authenticate the panel user before opening a root shell -----------
user = authenticate_ws_token(token, db)
if not user:
await websocket.close(code=4001, reason="Authentication required")
return
if not PermissionEngine.has_permission(user, "node:upgrade"):
await websocket.close(code=4003, reason="Permission denied")
return
if cmd not in _ALLOWED_NODE_SHELL_CMDS:
await websocket.close(code=4003, reason="Command not allowed")
return
await websocket.accept()
logger.info(f"Node shell WebSocket accepted for node {node} (cmd={cmd}, user={user.username})")
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
await websocket.close(code=1008, reason="Proxmox server not found")
return
proxmox_ws = None
bytes_to_proxmox = 0
bytes_from_proxmox = 0
try:
# Шаг 1: Auth ticket (termproxy требует пароль, не API-токен)
async with httpx.AsyncClient(verify=False, timeout=10) as client:
if not server.password:
await websocket.close(code=1011, reason="Node shell requires password authentication")
return
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
auth_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/access/ticket",
data={"username": auth_username, "password": server.password}
)
if auth_resp.status_code != 200:
logger.error(f"Auth failed: {auth_resp.status_code} {auth_resp.text}")
await websocket.close(code=1011, reason="Authentication failed")
return
auth_data = auth_resp.json()["data"]
ticket = auth_data["ticket"]
csrf_token = auth_data["CSRFPreventionToken"]
# Шаг 2: Termproxy на уровне ноды с нужной командой
term_resp = await client.post(
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/termproxy",
headers={"CSRFPreventionToken": csrf_token},
cookies={"PVEAuthCookie": ticket},
data={"cmd": cmd},
)
if term_resp.status_code != 200:
logger.error(f"Node termproxy failed: {term_resp.status_code} - {term_resp.text}")
await websocket.close(code=1011, reason="Failed to create node shell session")
return
term_data = term_resp.json()["data"]
vncticket = term_data["ticket"]
port = term_data["port"]
logger.info(f"✅ Node termproxy: node={node}, cmd={cmd}, port={port}")
# Шаг 3: WebSocket к Proxmox (node-level vncwebsocket)
proxmox_ws_url = (
f"wss://{server.ip_address}:8006/api2/json/nodes/{node}/vncwebsocket"
f"?port={port}&vncticket={quote(vncticket, safe='')}"
)
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
proxmox_ws = await websockets.connect(
proxmox_ws_url,
ssl=ssl_context,
extra_headers=[("Cookie", f"PVEAuthCookie={ticket}")],
subprotocols=["binary"],
max_size=None,
ping_interval=None,
close_timeout=5
)
# Auth handshake: "USERNAME:VNCTICKET\n" → "OK"
await proxmox_ws.send(f"{auth_username}:{vncticket}\n")
try:
ok_raw = await asyncio.wait_for(proxmox_ws.recv(), timeout=10.0)
except asyncio.TimeoutError:
logger.error("Node shell auth timeout — no OK received")
await websocket.close(code=1011, reason="Node shell auth timeout")
return
ok_bytes = ok_raw if isinstance(ok_raw, bytes) else ok_raw.encode("utf-8")
if not ok_bytes.startswith(b"OK"):
logger.error(f"Node shell auth rejected: {ok_bytes[:50]!r}")
await websocket.close(code=1011, reason="Node shell authentication rejected")
return
if len(ok_bytes) > 2:
await websocket.send_bytes(ok_bytes[2:])
async def client_to_proxmox():
nonlocal bytes_to_proxmox
try:
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
if "bytes" in message:
bytes_to_proxmox += len(message["bytes"])
await proxmox_ws.send(message["bytes"])
elif "text" in message:
bytes_to_proxmox += len(message["text"])
await proxmox_ws.send(message["text"].encode("utf-8"))
except WebSocketDisconnect:
pass
except Exception as e:
logger.debug(f"client_to_proxmox ended: {e}")
async def proxmox_to_client():
nonlocal bytes_from_proxmox
try:
async for message in proxmox_ws:
if isinstance(message, bytes):
bytes_from_proxmox += len(message)
await websocket.send_bytes(message)
else:
bytes_from_proxmox += len(message)
await websocket.send_bytes(message.encode("utf-8"))
except Exception as e:
logger.debug(f"proxmox_to_client ended: {e}")
done, pending = await asyncio.wait(
[
asyncio.create_task(client_to_proxmox()),
asyncio.create_task(proxmox_to_client()),
],
return_when=asyncio.FIRST_COMPLETED
)
for task in pending:
task.cancel()
except Exception as e:
logger.error(f"Node shell WebSocket error: {e}")
import traceback
logger.error(traceback.format_exc())
try:
await websocket.close(code=1011, reason=str(e))
except Exception:
pass
finally:
logger.info(f"Node shell stats — to Proxmox: {bytes_to_proxmox}B, from Proxmox: {bytes_from_proxmox}B")
if proxmox_ws:
try:
await proxmox_ws.close()
except Exception:
pass
try:
await websocket.close()
except Exception:
pass
logger.info(f"Node shell session closed for node {node}")
# ==================== Bulk Operations API ====================