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 ====================

View File

@@ -929,6 +929,57 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.warning(f"Таймаут ожидания разблокировки VM {vmid}")
return False
def update_cloud_init(
self, node: str, vmid: int,
ciuser=None, cipassword=None, sshkeys=None,
ipconfig0=None, nameserver=None, searchdomain=None,
) -> Dict:
"""Точечно обновить cloud-init параметры существующей VM.
Значение None — поле не трогаем; пустая строка "" — удалить параметр.
Пустой cipassword трактуется как «оставить текущий» (blank = keep).
sshkeys перед отправкой URL-кодируется (требование PVE).
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
import urllib.parse
params: Dict = {}
deletes = []
def _set(key, value, allow_clear=True):
if value is None:
return
if value == '' and allow_clear:
deletes.append(key)
elif value != '':
params[key] = value
_set('ciuser', ciuser)
if cipassword: # пустой пароль не меняем
params['cipassword'] = cipassword
if sshkeys is not None:
if sshkeys.strip() == '':
deletes.append('sshkeys')
else:
params['sshkeys'] = urllib.parse.quote(sshkeys, safe='')
_set('ipconfig0', ipconfig0)
_set('nameserver', nameserver)
_set('searchdomain', searchdomain)
if deletes:
params['delete'] = ','.join(deletes)
if not params:
return {"success": True}
try:
self.proxmox.nodes(node).qemu(vmid).config.put(**params)
logger.info(f"Cloud-init VM {vmid} обновлён: {list(params.keys())}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка обновления cloud-init VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def configure_vm(
self,
node: str,
@@ -1299,9 +1350,9 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
# Фильтруем разрешенные параметры
allowed_params = {
'cores', 'sockets', 'memory', 'balloon', 'name', 'description',
'cpu', 'cpulimit', 'cpuunits', 'onboot', 'startup', 'boot', 'bootdisk',
'agent', 'ostype', 'tablet', 'hotplug', 'protection',
'cores', 'sockets', 'vcpus', 'numa', 'memory', 'balloon', 'name', 'description',
'cpu', 'cpulimit', 'cpuunits', 'affinity', 'onboot', 'startup', 'boot', 'bootdisk',
'agent', 'ostype', 'tablet', 'hotplug', 'protection', 'kvm',
'ciuser', 'cipassword', 'sshkeys', 'tags',
# Hardware editor: CPU/NUMA, дисплей, контроллер, тип платформы,
# efidisk0 — EFI-диск, обязателен для загрузки в режиме OVMF (UEFI).

View File

@@ -5,6 +5,22 @@ import shlex
from loguru import logger
class LxcMixin:
def unlock_container(self, node: str, vmid: int) -> Dict:
"""Снять блокировку (lock) с LXC — аналог `pct unlock`.
config.put(delete='lock', skiplock=1); skiplock принимается только
для root@pam.
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).lxc(vmid).config.put(delete="lock", skiplock=1)
logger.info(f"С LXC {vmid} снята блокировка")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка снятия блокировки LXC {vmid}: {e}")
return {"success": False, "error": str(e)}
def start_container(self, node: str, vmid: int) -> Optional[str]:
"""Запустить LXC контейнер. Возвращает UPID задачи или None при ошибке."""
if not self.proxmox:

View File

@@ -273,6 +273,117 @@ class VmMixin:
logger.info(f"Размер диска {disk} VM {vmid} изменен на {size}")
return True
def move_vm_disk(self, node: str, vmid: int, disk: str, target_storage: str,
delete: bool = True, target_format: str = None) -> Dict:
"""Переместить диск VM в другое хранилище (POST qemu/{vmid}/move_disk).
Args:
disk: имя диска (scsi0, virtio0, ...)
target_storage: целевое хранилище
delete: удалить исходный диск после копирования
target_format: raw|qcow2|vmdk (опционально)
Returns:
{"success": bool, "upid"?: str, "error"?: str}
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
params = {"disk": disk, "storage": target_storage, "delete": 1 if delete else 0}
if target_format:
params["format"] = target_format
upid = self.proxmox.nodes(node).qemu(vmid).move_disk.post(**params)
if isinstance(upid, str):
if not self.wait_for_task(node, upid, timeout=3600):
status = self.get_task_status(node, upid) or {}
reason = status.get("exitstatus") or "move_disk task failed"
logger.error(f"Move диска {disk} VM {vmid} провалился: {reason}")
return {"success": False, "error": f"Proxmox: {reason}"}
logger.info(f"Диск {disk} VM {vmid} перемещён в {target_storage}")
return {"success": True, "upid": upid if isinstance(upid, str) else None}
except Exception as e:
logger.error(f"Ошибка перемещения диска {disk} VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def add_vm_disk(self, node: str, vmid: int, disk: str, storage: str, size_gb: int,
ssd: bool = False, discard: bool = False, iothread: bool = False) -> Dict:
"""Добавить новый диск VM (config.put c аллокацией storage:size).
Args:
disk: имя устройства (scsi1, virtio1, ...) — должно быть свободно
storage: хранилище для нового диска
size_gb: размер в ГБ
ssd/discard/iothread: опциональные флаги
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
spec = f"{storage}:{int(size_gb)}"
if ssd:
spec += ",ssd=1"
if discard:
spec += ",discard=on"
if iothread:
spec += ",iothread=1"
self.proxmox.nodes(node).qemu(vmid).config.post(**{disk: spec})
logger.info(f"Добавлен диск {disk} ({spec}) к VM {vmid}")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка добавления диска {disk} к VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def detach_vm_disk(self, node: str, vmid: int, disk: str, destroy: bool = False) -> Dict:
"""Отключить диск VM. destroy=True — также физически удалить том.
PVE переводит отключённый диск в unusedN (том сохраняется). Чтобы удалить
данные, вторым шагом удаляем соответствующую unused-запись (её значение —
volid исходного диска). Без destroy диск просто становится unused.
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
# volid исходного диска — по нему найдём unused-запись после отключения
old_volid = None
if destroy:
cfg = self.get_vm_config(node, vmid) or {}
spec = cfg.get(disk)
if isinstance(spec, str):
old_volid = spec.split(",", 1)[0]
self.proxmox.nodes(node).qemu(vmid).config.put(delete=disk)
if destroy and old_volid:
cfg2 = self.get_vm_config(node, vmid) or {}
unused_key = next(
(k for k, v in cfg2.items()
if k.startswith("unused") and isinstance(v, str) and old_volid in v),
None,
)
if unused_key:
self.proxmox.nodes(node).qemu(vmid).config.put(delete=unused_key)
logger.info(f"Том {old_volid} диска {disk} VM {vmid} удалён ({unused_key})")
logger.info(f"Диск {disk} VM {vmid} отключён (destroy={destroy})")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка отключения диска {disk} VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def unlock_vm(self, node: str, vmid: int) -> Dict:
"""Снять блокировку (lock) с VM — аналог `qm unlock`.
Реализуется через config.put(delete='lock', skiplock=1). skiplock
принимается Proxmox только для root@pam (в т.ч. API-токенов root@pam).
"""
if not self.proxmox:
return {"success": False, "error": "Not connected"}
try:
self.proxmox.nodes(node).qemu(vmid).config.put(delete="lock", skiplock=1)
logger.info(f"С VM {vmid} снята блокировка")
return {"success": True}
except Exception as e:
logger.error(f"Ошибка снятия блокировки VM {vmid}: {e}")
return {"success": False, "error": str(e)}
def grow_vm_filesystem(self, node: str, vmid: int, mountpoint: str = "/") -> Dict:
"""
Расширить раздел и файловую систему внутри гостя после ресайза диска.

View File

@@ -26,6 +26,7 @@ NEW_DEFAULT_ROLES = [
"server:update": True,
"server:delete": True,
"server:manage": True,
"cluster:manage": True,
# VMs
"vm:view": True,
"vm:create": True,
@@ -57,9 +58,26 @@ NEW_DEFAULT_ROLES = [
# Storage
"storage:view": True,
"storage:manage": True,
# Firewall
"firewall:view": True,
"firewall:manage": True,
# Networking (interfaces + SDN)
"network:view": True,
"network:manage": True,
# Node administration (services, APT, upgrade)
"node:view": True,
"node:manage": True,
"node:upgrade": True,
# Resource pools
"pool:view": True,
"pool:manage": True,
# PVE access (realms & API tokens)
"access:view": True,
"access:manage": True,
# Backups
"backup:view": True,
"backup:create": True,
"backup:restore": True,
"backup:delete": True,
"backup:manage": True,
# IPAM
@@ -115,6 +133,10 @@ NEW_DEFAULT_ROLES = [
"lxc:console": True,
"template:view": True,
"storage:view": True,
"firewall:view": True,
"network:view": True,
"node:view": True,
"pool:view": True,
"backup:view": True,
"backup:create": True,
"ipam:view": True,
@@ -146,6 +168,10 @@ NEW_DEFAULT_ROLES = [
"lxc:console": True,
"template:view": True,
"storage:view": True,
"firewall:view": True,
"network:view": True,
"node:view": True,
"pool:view": True,
"ipam:view": True,
"setting:view": True,
"notification:view": True,
@@ -164,6 +190,10 @@ NEW_DEFAULT_ROLES = [
"lxc:view": True,
"template:view": True,
"storage:view": True,
"firewall:view": True,
"network:view": True,
"node:view": True,
"pool:view": True,
"ipam:view": True,
}
},
@@ -446,3 +476,67 @@ def ensure_default_roles_new_format(conn) -> None:
}
)
logger.info(f"Created role '{role_data['name']}' with new permission format")
# Granular permissions introduced after RBAC v2. For existing deployments the
# corresponding features used to be gated behind umbrella permissions
# (server:view / server:manage / vm:view / user:manage). Backfill the new
# granular keys for any role that already holds the umbrella permission so that
# custom roles do not silently lose access after the split.
GRANULAR_BACKFILL = [
# (umbrella permission, [granular permissions implied by it])
("server:view", ["firewall:view", "network:view", "node:view", "storage:view"]),
("vm:view", ["pool:view", "firewall:view"]),
("server:manage", [
"firewall:view", "firewall:manage",
"network:view", "network:manage",
"node:view", "node:manage", "node:upgrade",
"storage:view", "storage:manage",
"pool:view", "pool:manage",
]),
("user:manage", ["access:view", "access:manage"]),
]
def backfill_granular_permissions(conn) -> int:
"""
Add granular permission keys to roles that already have the matching
umbrella permission. Only adds missing keys — never overrides an explicit
grant/deny — so it is idempotent and safe to run on every startup.
Returns the number of roles updated.
"""
import json
result = conn.execute(text("SELECT id, name, permissions FROM roles"))
roles = result.fetchall()
updated = 0
for role_id, role_name, permissions in roles:
if isinstance(permissions, str):
try:
permissions = json.loads(permissions)
except Exception:
continue
if not isinstance(permissions, dict) or not permissions:
continue
additions = {}
for umbrella, implied in GRANULAR_BACKFILL:
if permissions.get(umbrella) is True:
for perm in implied:
if perm not in permissions:
additions[perm] = True
if additions:
merged = {**permissions, **additions}
conn.execute(
text("UPDATE roles SET permissions = :perms WHERE id = :id"),
{"perms": json.dumps(merged), "id": role_id},
)
logger.info(
f"Backfilled {len(additions)} granular permission(s) for role '{role_name}'"
)
updated += 1
return updated

View File

@@ -638,7 +638,89 @@ def _create_permissions() -> PermissionRegistry:
category="Notifications",
requires=["notification:view"]
))
# Firewall permissions (datacenter / node / VM firewall)
registry.register(Permission(
resource="firewall", action="view",
display_name="View Firewall",
description="View firewall rules, groups and options",
category="Firewall"
))
registry.register(Permission(
resource="firewall", action="manage",
display_name="Manage Firewall",
description="Manage firewall rules, security groups, IP sets and options",
category="Firewall",
requires=["firewall:view"]
))
# Networking permissions (node interfaces + SDN)
registry.register(Permission(
resource="network", action="view",
display_name="View Networking",
description="View node network interfaces and SDN configuration",
category="Networking"
))
registry.register(Permission(
resource="network", action="manage",
display_name="Manage Networking",
description="Manage node network interfaces, bridges and SDN (zones, vnets, subnets)",
category="Networking",
requires=["network:view"]
))
# Node administration permissions (services, APT, upgrade)
registry.register(Permission(
resource="node", action="view",
display_name="View Node Administration",
description="View node services, APT updates and repositories",
category="Node Administration"
))
registry.register(Permission(
resource="node", action="manage",
display_name="Manage Node Administration",
description="Control systemd services and manage APT repositories/refresh",
category="Node Administration",
requires=["node:view"]
))
registry.register(Permission(
resource="node", action="upgrade",
display_name="Upgrade Node Packages",
description="Run apt dist-upgrade via the node console (root shell)",
category="Node Administration",
requires=["node:view"]
))
# Resource pool permissions
registry.register(Permission(
resource="pool", action="view",
display_name="View Pools",
description="View resource pools and their members",
category="Resource Pools"
))
registry.register(Permission(
resource="pool", action="manage",
display_name="Manage Pools",
description="Create/delete pools and manage pool membership",
category="Resource Pools",
requires=["pool:view"]
))
# PVE access permissions (authentication realms & API tokens)
registry.register(Permission(
resource="access", action="view",
display_name="View PVE Access",
description="View Proxmox authentication realms and API tokens",
category="PVE Access"
))
registry.register(Permission(
resource="access", action="manage",
display_name="Manage PVE Access",
description="Manage Proxmox authentication realms and API tokens",
category="PVE Access",
requires=["access:view"]
))
return registry

View File

@@ -1430,6 +1430,44 @@ def migrate_user_quotas(conn):
logger.info("✓ user_quotas table created")
def migrate_granular_permissions(conn):
"""
Migration 29: Backfill granular RBAC permissions.
Splits umbrella permissions (server:view/manage, vm:view, user:manage) into
granular ones (firewall:*, network:*, node:*, storage:*, pool:*, access:*)
for existing roles, and ensures the default roles include the new keys.
"""
logger.info("Migration 29: Backfilling granular RBAC permissions...")
if not table_exists(conn, 'roles'):
logger.info("Table roles does not exist, skipping granular permissions migration")
return
try:
from app.rbac.migration import (
backfill_granular_permissions,
ensure_default_roles_new_format,
)
except ImportError:
try:
from backend.app.rbac.migration import (
backfill_granular_permissions,
ensure_default_roles_new_format,
)
except ImportError:
logger.warning("Could not import RBAC utilities, skipping granular permissions migration")
return
updated = backfill_granular_permissions(conn)
logger.info(f"Backfilled granular permissions for {updated} role(s)")
# Ensure default roles pick up the new granular keys as well
ensure_default_roles_new_format(conn)
logger.info("✓ Granular permissions migration completed")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1668,6 +1706,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"User quotas migration: {e}")
conn.rollback()
# Migration 29: Backfill granular RBAC permissions
try:
migrate_granular_permissions(conn)
conn.commit()
except Exception as e:
logger.warning(f"Granular permissions migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)