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 ====================
|
||||
|
||||
Reference in New Issue
Block a user