feat: Proxmox management suite — hardware, firewall, pools, disks, storage, access, node admin
- 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:
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 ====================
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
Расширить раздел и файловую систему внутри гостя после ресайза диска.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user