v1.4.0: Networks page — SDN zone/vnet editing, node interfaces CRUD, IPAM auto-create

This commit is contained in:
markmorado
2026-04-06 21:13:38 +02:00
parent 6a0564cea2
commit 4546a798d0
13 changed files with 2002 additions and 35 deletions

View File

@@ -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)

View 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))

View File

@@ -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))