v1.4.0: Networks page — SDN zone/vnet editing, node interfaces CRUD, IPAM auto-create
This commit is contained in:
@@ -6,6 +6,7 @@ from .vms import router as vms_router
|
||||
from .tasks import router as tasks_router
|
||||
from .backup import router as backup_router
|
||||
from .cluster import router as cluster_router
|
||||
from .networks import router as networks_router
|
||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
|
||||
router = APIRouter()
|
||||
@@ -16,3 +17,4 @@ router.include_router(vms_router)
|
||||
router.include_router(tasks_router)
|
||||
router.include_router(backup_router)
|
||||
router.include_router(cluster_router)
|
||||
router.include_router(networks_router)
|
||||
|
||||
254
backend/app/api/proxmox/networks.py
Normal file
254
backend/app/api/proxmox/networks.py
Normal file
@@ -0,0 +1,254 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from loguru import logger
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, User, IPAMNetwork
|
||||
from ...auth import PermissionChecker
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ._helpers import _get_proxmox_client, templates
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ==================== HTML Page ====================
|
||||
|
||||
@router.get("/networks", response_class=HTMLResponse, include_in_schema=False)
|
||||
def networks_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""Страница управления сетями PVE (SDN + интерфейсы нод)"""
|
||||
from ...i18n import t
|
||||
lang = request.cookies.get("language", "en")
|
||||
proxmox_servers = db.query(ProxmoxServer).all()
|
||||
context = {
|
||||
"request": request,
|
||||
"proxmox_servers": proxmox_servers,
|
||||
"page_title": t("nav_networks", lang),
|
||||
}
|
||||
context = add_i18n_context(request, context)
|
||||
return templates.TemplateResponse("networks.html", context)
|
||||
|
||||
|
||||
# ==================== Node list ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes")
|
||||
def get_server_nodes(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.view"))
|
||||
):
|
||||
"""List nodes for a Proxmox server"""
|
||||
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)
|
||||
nodes = client.get_nodes()
|
||||
return JSONResponse(content={"nodes": nodes})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting nodes for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ==================== Node Network Interfaces ====================
|
||||
|
||||
@router.get("/api/servers/{server_id}/nodes/{node}/networks")
|
||||
def list_node_networks(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.view"))
|
||||
):
|
||||
"""List all network interfaces on a node, with IPAM linkage info."""
|
||||
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)
|
||||
ifaces = client.get_node_networks(node)
|
||||
|
||||
# Fetch all IPAM networks linked to this server once — O(1) round-trips
|
||||
ipam_nets = (
|
||||
db.query(IPAMNetwork)
|
||||
.filter(IPAMNetwork.proxmox_server_id == server_id)
|
||||
.all()
|
||||
)
|
||||
# Build lookup: bridge_name → ipam_network
|
||||
bridge_to_ipam = {n.proxmox_bridge: n for n in ipam_nets if n.proxmox_bridge}
|
||||
|
||||
enriched = []
|
||||
for iface in ifaces:
|
||||
iface_name = iface.get("iface") or iface.get("name", "")
|
||||
ipam_net = bridge_to_ipam.get(iface_name)
|
||||
record = dict(iface)
|
||||
if ipam_net:
|
||||
record["ipam_network_id"] = ipam_net.id
|
||||
record["ipam_cidr"] = ipam_net.network
|
||||
record["ipam_name"] = ipam_net.name
|
||||
enriched.append(record)
|
||||
|
||||
return JSONResponse(content={"node": node, "interfaces": enriched})
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing node {node} networks for server {server_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/networks")
|
||||
async def create_node_network(
|
||||
server_id: int,
|
||||
node: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.manage"))
|
||||
):
|
||||
"""Create a new network interface on a node."""
|
||||
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()
|
||||
iface_type = data.pop("type", None)
|
||||
if not iface_type:
|
||||
raise HTTPException(status_code=400, detail="Interface type is required")
|
||||
|
||||
iface_name = data.get("iface", "")
|
||||
if not iface_name:
|
||||
raise HTTPException(status_code=400, detail="Interface name (iface) is required")
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
result = client.create_node_network(node, iface_type, **data)
|
||||
if result.get("success"):
|
||||
logger.info(
|
||||
f"User {current_user.username} created {iface_type} interface "
|
||||
f"{iface_name} on {server.name}/{node}"
|
||||
)
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create interface"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating node interface: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/nodes/{node}/networks/{iface}")
|
||||
async def update_node_network(
|
||||
server_id: int,
|
||||
node: str,
|
||||
iface: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.manage"))
|
||||
):
|
||||
"""Update a network interface on a node."""
|
||||
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)
|
||||
result = client.update_node_network(node, iface, **data)
|
||||
if result.get("success"):
|
||||
logger.info(
|
||||
f"User {current_user.username} updated interface {iface} "
|
||||
f"on {server.name}/{node}"
|
||||
)
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to update interface"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating node interface {iface}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/nodes/{node}/networks/{iface}")
|
||||
def delete_node_network(
|
||||
server_id: int,
|
||||
node: str,
|
||||
iface: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.manage"))
|
||||
):
|
||||
"""Delete a network interface from a node."""
|
||||
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)
|
||||
result = client.delete_node_network(node, iface)
|
||||
if result.get("success"):
|
||||
logger.info(
|
||||
f"User {current_user.username} deleted interface {iface} "
|
||||
f"from {server.name}/{node}"
|
||||
)
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to delete interface"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting node interface {iface}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/networks/apply")
|
||||
def apply_node_network(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.manage"))
|
||||
):
|
||||
"""Apply pending network configuration changes on a node."""
|
||||
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)
|
||||
result = client.apply_node_network_config(node)
|
||||
if result.get("success"):
|
||||
logger.info(
|
||||
f"User {current_user.username} applied network config "
|
||||
f"on {server.name}/{node}"
|
||||
)
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to apply network config"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying node network config: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/api/servers/{server_id}/nodes/{node}/networks/revert")
|
||||
def revert_node_network(
|
||||
server_id: int,
|
||||
node: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.manage"))
|
||||
):
|
||||
"""Revert pending network configuration changes on a node."""
|
||||
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)
|
||||
result = client.revert_node_network_config(node)
|
||||
if result.get("success"):
|
||||
logger.info(
|
||||
f"User {current_user.username} reverted network config "
|
||||
f"on {server.name}/{node}"
|
||||
)
|
||||
return JSONResponse(content=result)
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to revert network config"))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error reverting node network config: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -1,27 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException, Query, Form, status, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi import APIRouter, Depends, Request, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from loguru import logger
|
||||
from typing import List
|
||||
import ssl
|
||||
import asyncio
|
||||
import httpx
|
||||
import websockets
|
||||
from typing import Optional
|
||||
|
||||
from ...db import get_db
|
||||
from ...models import ProxmoxServer, VMInstance, User, IPAMAllocation, IPAMNetwork, VMSnapshotArchive
|
||||
from ...schemas import ProxmoxServerCreate, ProxmoxServerUpdate, ProxmoxServerResponse
|
||||
from ...proxmox_client import ProxmoxClient, get_proxmox_resources
|
||||
from ...auth import get_current_user, PermissionChecker, require_permission, check_permission
|
||||
from ...models import ProxmoxServer, User, IPAMNetwork
|
||||
from ...auth import PermissionChecker
|
||||
from ...logging_service import LoggingService
|
||||
from ...template_helpers import add_i18n_context
|
||||
from ...ipam_service import IPAMService
|
||||
from ._helpers import (check_vm_access, require_vm_access, _get_proxmox_client,
|
||||
get_next_vmid, archive_and_delete_snapshots,
|
||||
save_vm_instance, get_vm_instance, soft_delete_vm_instance,
|
||||
templates)
|
||||
from ._helpers import _get_proxmox_client
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -271,24 +258,51 @@ async def create_sdn_subnet(
|
||||
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()
|
||||
subnet = data.get('subnet')
|
||||
gateway = data.get('gateway')
|
||||
snat = data.get('snat', False)
|
||||
dnszoneprefix = data.get('dnszoneprefix')
|
||||
|
||||
create_ipam_network = data.get('create_ipam_network', False)
|
||||
|
||||
if not subnet:
|
||||
raise HTTPException(status_code=400, detail="Subnet CIDR is required")
|
||||
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
|
||||
result = client.create_sdn_subnet(vnet, subnet, gateway=gateway, snat=snat, dnszoneprefix=dnszoneprefix)
|
||||
|
||||
|
||||
if result.get('success'):
|
||||
logger.info(f"User {current_user.username} created subnet {subnet} in vnet {vnet}")
|
||||
return JSONResponse(content=result)
|
||||
response = dict(result)
|
||||
|
||||
# Optionally create a matching IPAMNetwork
|
||||
if create_ipam_network:
|
||||
existing = db.query(IPAMNetwork).filter(IPAMNetwork.network == subnet).first()
|
||||
if existing:
|
||||
response['ipam_network_id'] = existing.id
|
||||
response['ipam_already_existed'] = True
|
||||
else:
|
||||
ipam_net = IPAMNetwork(
|
||||
name=f"{vnet} — {subnet}",
|
||||
network=subnet,
|
||||
gateway=gateway or None,
|
||||
proxmox_server_id=server_id,
|
||||
proxmox_bridge=vnet,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(ipam_net)
|
||||
db.commit()
|
||||
db.refresh(ipam_net)
|
||||
response['ipam_network_id'] = ipam_net.id
|
||||
response['ipam_created'] = True
|
||||
logger.info(
|
||||
f"User {current_user.username} auto-created IPAM network "
|
||||
f"{subnet} (id={ipam_net.id}) linked to vnet {vnet}"
|
||||
)
|
||||
|
||||
return JSONResponse(content=response)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to create subnet'))
|
||||
except HTTPException:
|
||||
@@ -324,3 +338,109 @@ def apply_sdn_changes(
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying SDN changes: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/sdn/zones/{zone}")
|
||||
async def update_sdn_zone(
|
||||
server_id: int,
|
||||
zone: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.manage"))
|
||||
):
|
||||
"""Update an existing SDN zone"""
|
||||
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)
|
||||
result = client.update_sdn_zone(zone, **data)
|
||||
if result.get('success'):
|
||||
logger.info(f"User {current_user.username} updated SDN zone: {zone}")
|
||||
return JSONResponse(content=result)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to update zone'))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating SDN zone {zone}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.put("/api/servers/{server_id}/sdn/vnets/{vnet}")
|
||||
async def update_sdn_vnet(
|
||||
server_id: int,
|
||||
vnet: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.manage"))
|
||||
):
|
||||
"""Update an existing SDN VNet"""
|
||||
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)
|
||||
result = client.update_sdn_vnet(vnet, **data)
|
||||
if result.get('success'):
|
||||
logger.info(f"User {current_user.username} updated SDN vnet: {vnet}")
|
||||
return JSONResponse(content=result)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to update vnet'))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating SDN vnet {vnet}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/api/servers/{server_id}/sdn/vnets/{vnet}/subnets/{subnet_cidr:path}")
|
||||
def delete_sdn_subnet(
|
||||
server_id: int,
|
||||
vnet: str,
|
||||
subnet_cidr: str,
|
||||
delete_ipam_network: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("proxmox.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()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||
|
||||
# Accept both dash-encoded and slash CIDR
|
||||
subnet_slash = subnet_cidr.replace("-", "/") if "-" in subnet_cidr else subnet_cidr
|
||||
|
||||
try:
|
||||
client = _get_proxmox_client(server)
|
||||
result = client.delete_sdn_subnet(vnet, subnet_slash)
|
||||
if result.get('success'):
|
||||
logger.info(f"User {current_user.username} deleted subnet {subnet_slash} from vnet {vnet}")
|
||||
response = dict(result)
|
||||
|
||||
if delete_ipam_network:
|
||||
ipam_net = db.query(IPAMNetwork).filter(
|
||||
IPAMNetwork.network == subnet_slash,
|
||||
IPAMNetwork.proxmox_server_id == server_id,
|
||||
).first()
|
||||
if ipam_net:
|
||||
db.delete(ipam_net)
|
||||
db.commit()
|
||||
response['ipam_network_deleted'] = True
|
||||
logger.info(
|
||||
f"User {current_user.username} deleted IPAM network "
|
||||
f"{subnet_slash} (id={ipam_net.id})"
|
||||
)
|
||||
|
||||
return JSONResponse(content=response)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=result.get('error', 'Failed to delete subnet'))
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting SDN subnet {subnet_slash}: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
Reference in New Issue
Block a user