Files
pvemanager/backend/app/api/ipam.py
Dilshod Rasulov 6796903712
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m46s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 3m7s
feat(ipam): several IP addresses per instance
A guest was implicitly limited to a single allocation: lookups were
"(server, vmid) -> one row", release_ip_by_vmid freed one address, and
the link tool skipped guests that already had one. An instance can now
hold a primary address plus any number of aliases, managed from the
Network tab of the instance and visible on the IPAM pages.

Schema and service:
- ipam_allocations gains is_primary, assignment_kind, target_interface
  and apply_* columns; migration 43 marks existing rows as primary
- find_allocations_by_resource/set_primary; release_ip_by_vmid now frees
  every address of a deleted guest instead of just the first one
- next-available search may scan the whole subnet when a network has no
  auto-assign pool, skipping node and cached guest addresses so it never
  hands out an address that is already live

Applying to the guest (services/guest_ip_service.py):
- reuses script_engine, so LXC goes through pct exec and VMs through the
  guest agent; the shell script synchronises the whole alias list of an
  interface, which makes add, remove and retry the same code path
- persists via ifupdown, netplan, NetworkManager or sysconfig and never
  restarts guest networking; an unknown stack degrades to runtime_only
  and an unreachable guest keeps the allocation as pending

API and UI:
- /ipam/api/guests/{server_id}/{vmid}/addresses: list, add, apply,
  primary, delete; allocations list accepts guest filters
- instance list exposes ips[] alongside the primary ip
- addresses card on the Network tab, "+N" badge in the IP column,
  primary badge in the allocations table

Fixes found along the way:
- link-allocations registers every address of a guest, carries the MAC
  over, refuses to take an address away from another live guest and no
  longer matches allocations by resource name (which leaked an address
  between same-named guests on different servers)
- IPAM history no longer fails to serialise datetime values, which used
  to abort re-linking with a 500
2026-08-21 23:48:20 +05:00

1365 lines
50 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
IPAM API Routes
IP Address Management endpoints for networks, pools, and allocations.
"""
import logging
import ipaddress as ipaddress_module
from typing import List, NamedTuple, Optional
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_
from sqlalchemy.orm import Session
from ..db import get_db
from ..models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, ProxmoxServer, User, VMInstance
from ..proxmox import ProxmoxClient
from ..schemas import (
IPAMNetworkCreate, IPAMNetworkUpdate, IPAMNetworkResponse, IPAMNetworkStats,
IPAMPoolCreate, IPAMPoolUpdate, IPAMPoolResponse,
IPAMAllocationCreate, IPAMAllocationUpdate, IPAMAllocationResponse,
IPAMHistoryResponse, IPAMAutoAllocateRequest
)
from ..ipam_service import IPAMService
from ..services import guest_ip_service
from .proxmox._helpers import require_vm_access
from ..auth import get_current_user, PermissionChecker
logger = logging.getLogger(__name__)
router = APIRouter()
# ==================== Network API ====================
@router.get("/api/networks", response_model=List[IPAMNetworkResponse])
def get_networks(
request: Request,
is_active: Optional[bool] = None,
proxmox_server_id: Optional[int] = None,
workspace_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get all IPAM networks.
Filtered by the active workspace (X-Active-Workspace header): networks bound to
that workspace plus global networks (workspace_id IS NULL). Admin "All workspaces"
(header 0/absent) shows everything. An explicit ?workspace_id= query param overrides.
"""
from ..models import Workspace
query = db.query(IPAMNetwork)
if is_active is not None:
query = query.filter(IPAMNetwork.is_active == is_active)
if proxmox_server_id:
query = query.filter(IPAMNetwork.proxmox_server_id == proxmox_server_id)
# Resolve workspace scope: explicit query param wins, else the active-workspace header.
ws_filter = workspace_id
if ws_filter is None:
raw = request.headers.get("X-Active-Workspace")
if raw is not None:
try:
parsed = int(raw)
ws_filter = parsed if parsed != 0 else None
except ValueError:
ws_filter = None
if ws_filter:
# Networks of this workspace + global (unassigned) networks.
query = query.filter(
or_(IPAMNetwork.workspace_id == ws_filter, IPAMNetwork.workspace_id.is_(None))
)
networks = query.order_by(IPAMNetwork.name).all()
# Get server names for networks
server_ids = [n.proxmox_server_id for n in networks if n.proxmox_server_id]
servers = {}
if server_ids:
server_records = db.query(ProxmoxServer).filter(ProxmoxServer.id.in_(server_ids)).all()
servers = {s.id: s.name for s in server_records}
# Get workspace names for networks
ws_ids = [n.workspace_id for n in networks if n.workspace_id]
workspaces = {}
if ws_ids:
ws_records = db.query(Workspace.id, Workspace.name).filter(Workspace.id.in_(ws_ids)).all()
workspaces = {w.id: w.name for w in ws_records}
# Add computed stats to each network
ipam = IPAMService(db)
result = []
for network in networks:
stats = ipam.get_network_stats(network.id)
network_dict = {
**network.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
'available_ips': stats.get('available_ips', 0),
'utilization_percent': stats.get('utilization_percent', 0),
'server_name': servers.get(network.proxmox_server_id) if network.proxmox_server_id else None,
'workspace_name': workspaces.get(network.workspace_id) if network.workspace_id else None,
}
result.append(network_dict)
return result
@router.get("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
def get_network(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get a specific IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
ipam = IPAMService(db)
stats = ipam.get_network_stats(network_id)
return {
**network.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('allocated_ips', 0) + stats.get('reserved_ips', 0),
'available_ips': stats.get('available_ips', 0),
'utilization_percent': stats.get('utilization_percent', 0)
}
@router.get("/api/networks/{network_id}/stats", response_model=IPAMNetworkStats)
def get_network_stats(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get detailed statistics for a network"""
ipam = IPAMService(db)
stats = ipam.get_network_stats(network_id)
if not stats:
raise HTTPException(status_code=404, detail="Network not found")
return stats
@router.get("/api/networks/{network_id}/ip-map")
def get_network_ip_map(
network_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get IP address map for visualization"""
ipam = IPAMService(db)
ip_map = ipam.get_network_ip_map(network_id)
if not ip_map:
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
return {"network_id": network_id, "ip_map": ip_map}
@router.post("/api/networks", response_model=IPAMNetworkResponse)
def create_network(
network_data: IPAMNetworkCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Create a new IPAM network"""
# Check for duplicate network CIDR
existing = db.query(IPAMNetwork).filter(IPAMNetwork.network == network_data.network).first()
if existing:
raise HTTPException(status_code=400, detail=f"Network {network_data.network} already exists")
network = IPAMNetwork(**network_data.model_dump())
db.add(network)
db.flush() # assign id before clearing other defaults
_enforce_single_default(db, network)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} created network {network.name} ({network.network})")
return network
@router.put("/api/networks/{network_id}", response_model=IPAMNetworkResponse)
def update_network(
network_id: int,
network_data: IPAMNetworkUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Update an IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
update_data = network_data.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(network, field, value)
_enforce_single_default(db, network)
db.commit()
db.refresh(network)
logger.info(f"User {current_user.username} updated network {network.name}")
return network
def _enforce_single_default(db: Session, network: IPAMNetwork) -> None:
"""Keep at most one default network per workspace: if this one is marked
default, clear the flag on siblings in the same workspace."""
if not network.is_default or network.workspace_id is None:
return
db.query(IPAMNetwork).filter(
IPAMNetwork.workspace_id == network.workspace_id,
IPAMNetwork.id != network.id,
IPAMNetwork.is_default == True, # noqa: E712
).update({IPAMNetwork.is_default: False}, synchronize_session=False)
@router.delete("/api/networks/{network_id}")
def delete_network(
network_id: int,
force: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Delete an IPAM network"""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
# Check for existing allocations
alloc_count = db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).count()
if alloc_count > 0 and not force:
raise HTTPException(
status_code=400,
detail=f"Network has {alloc_count} allocations. Use force=true to delete anyway."
)
# Delete related data
db.query(IPAMAllocation).filter(IPAMAllocation.network_id == network_id).delete()
db.query(IPAMPool).filter(IPAMPool.network_id == network_id).delete()
db.query(IPAMHistory).filter(IPAMHistory.network_id == network_id).delete()
db.delete(network)
db.commit()
logger.info(f"User {current_user.username} deleted network {network.name}")
return {"message": f"Network {network.name} deleted"}
# ==================== Pool API ====================
@router.get("/api/pools", response_model=List[IPAMPoolResponse])
def get_pools(
network_id: Optional[int] = None,
is_active: Optional[bool] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get all IP pools"""
query = db.query(IPAMPool)
if network_id:
query = query.filter(IPAMPool.network_id == network_id)
if is_active is not None:
query = query.filter(IPAMPool.is_active == is_active)
pools = query.order_by(IPAMPool.network_id, IPAMPool.range_start).all()
# Add stats to each pool
ipam = IPAMService(db)
result = []
for pool in pools:
stats = ipam.get_pool_stats(pool.id)
pool_dict = {
**pool.__dict__,
'total_ips': stats.get('total_ips', 0),
'used_ips': stats.get('used_ips', 0),
'available_ips': stats.get('available_ips', 0)
}
result.append(pool_dict)
return result
@router.post("/api/pools", response_model=IPAMPoolResponse)
def create_pool(
pool_data: IPAMPoolCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Create a new IP pool"""
# Validate network exists
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == pool_data.network_id).first()
if not network:
raise HTTPException(status_code=404, detail="Network not found")
# Validate range
ipam = IPAMService(db)
is_valid, error = ipam.validate_pool_range(
pool_data.network_id,
pool_data.range_start,
pool_data.range_end
)
if not is_valid:
raise HTTPException(status_code=400, detail=error)
pool = IPAMPool(**pool_data.model_dump())
db.add(pool)
db.commit()
db.refresh(pool)
logger.info(f"User {current_user.username} created pool {pool.name} in network {network.name}")
return pool
@router.put("/api/pools/{pool_id}", response_model=IPAMPoolResponse)
def update_pool(
pool_id: int,
pool_data: IPAMPoolUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Update an IP pool"""
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
if not pool:
raise HTTPException(status_code=404, detail="Pool not found")
# Validate new range if provided
update_data = pool_data.model_dump(exclude_unset=True)
if 'range_start' in update_data or 'range_end' in update_data:
ipam = IPAMService(db)
is_valid, error = ipam.validate_pool_range(
pool.network_id,
update_data.get('range_start', pool.range_start),
update_data.get('range_end', pool.range_end),
exclude_pool_id=pool_id
)
if not is_valid:
raise HTTPException(status_code=400, detail=error)
for field, value in update_data.items():
setattr(pool, field, value)
db.commit()
db.refresh(pool)
logger.info(f"User {current_user.username} updated pool {pool.name}")
return pool
@router.delete("/api/pools/{pool_id}")
def delete_pool(
pool_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Delete an IP pool"""
pool = db.query(IPAMPool).filter(IPAMPool.id == pool_id).first()
if not pool:
raise HTTPException(status_code=404, detail="Pool not found")
# Update allocations to remove pool reference
db.query(IPAMAllocation).filter(IPAMAllocation.pool_id == pool_id).update({IPAMAllocation.pool_id: None})
db.delete(pool)
db.commit()
logger.info(f"User {current_user.username} deleted pool {pool.name}")
return {"message": f"Pool {pool.name} deleted"}
# ==================== Allocation API ====================
@router.get("/api/allocations", response_model=List[IPAMAllocationResponse])
def get_allocations(
network_id: Optional[int] = None,
pool_id: Optional[int] = None,
status: Optional[str] = None,
resource_type: Optional[str] = None,
proxmox_server_id: Optional[int] = None,
proxmox_vmid: Optional[int] = None,
search: Optional[str] = None,
limit: int = Query(default=100, le=1000),
offset: int = 0,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get IP allocations with filtering"""
query = db.query(IPAMAllocation)
if network_id:
query = query.filter(IPAMAllocation.network_id == network_id)
if pool_id:
query = query.filter(IPAMAllocation.pool_id == pool_id)
if status:
query = query.filter(IPAMAllocation.status == status)
if resource_type:
query = query.filter(IPAMAllocation.resource_type == resource_type)
# Фильтр по конкретному гостю: у него может быть несколько адресов
if proxmox_server_id is not None:
query = query.filter(IPAMAllocation.proxmox_server_id == proxmox_server_id)
if proxmox_vmid is not None:
query = query.filter(IPAMAllocation.proxmox_vmid == proxmox_vmid)
if search:
search_filter = f"%{search}%"
query = query.filter(
(IPAMAllocation.ip_address.like(search_filter)) |
(IPAMAllocation.resource_name.like(search_filter)) |
(IPAMAllocation.hostname.like(search_filter))
)
allocations = query.order_by(IPAMAllocation.ip_address).offset(offset).limit(limit).all()
# Resolve network/pool names with targeted queries (only IDs present in results)
network_ids = {a.network_id for a in allocations if a.network_id}
pool_ids = {a.pool_id for a in allocations if a.pool_id}
networks = {}
if network_ids:
networks = {
n.id: n.name for n in
db.query(IPAMNetwork.id, IPAMNetwork.name)
.filter(IPAMNetwork.id.in_(network_ids)).all()
}
pools = {}
if pool_ids:
pools = {
p.id: p.name for p in
db.query(IPAMPool.id, IPAMPool.name)
.filter(IPAMPool.id.in_(pool_ids)).all()
}
result = []
for alloc in allocations:
alloc_dict = {
**alloc.__dict__,
'network_name': networks.get(alloc.network_id),
'pool_name': pools.get(alloc.pool_id) if alloc.pool_id else None
}
result.append(alloc_dict)
return result
@router.get("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
def get_allocation(
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get a specific allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
pool = db.query(IPAMPool).filter(IPAMPool.id == allocation.pool_id).first() if allocation.pool_id else None
return {
**allocation.__dict__,
'network_name': network.name if network else None,
'pool_name': pool.name if pool else None
}
@router.post("/api/allocations", response_model=IPAMAllocationResponse)
def create_allocation(
alloc_data: IPAMAllocationCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Manually create an IP allocation"""
ipam = IPAMService(db)
allocation, error = ipam.allocate_ip(
ip_address=alloc_data.ip_address,
network_id=alloc_data.network_id,
pool_id=alloc_data.pool_id,
resource_type=alloc_data.resource_type,
resource_id=alloc_data.resource_id,
resource_name=alloc_data.resource_name,
hostname=alloc_data.hostname,
fqdn=alloc_data.fqdn,
mac_address=alloc_data.mac_address,
proxmox_server_id=alloc_data.proxmox_server_id,
proxmox_vmid=alloc_data.proxmox_vmid,
proxmox_node=alloc_data.proxmox_node,
allocation_type=alloc_data.allocation_type,
status=alloc_data.status,
allocated_by=current_user.username,
notes=alloc_data.notes
)
if error:
raise HTTPException(status_code=400, detail=error)
return allocation
@router.post("/api/allocations/auto", response_model=IPAMAllocationResponse)
def auto_allocate_ip(
request: IPAMAutoAllocateRequest,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Automatically allocate the next available IP"""
ipam = IPAMService(db)
allocation, error = ipam.auto_allocate_ip(
network_id=request.network_id,
pool_id=request.pool_id,
resource_type=request.resource_type,
resource_id=request.resource_id,
resource_name=request.resource_name,
hostname=request.hostname,
allocated_by=current_user.username,
notes=request.notes
)
if error:
raise HTTPException(status_code=400, detail=error)
return allocation
@router.put("/api/allocations/{allocation_id}", response_model=IPAMAllocationResponse)
def update_allocation(
allocation_id: int,
alloc_data: IPAMAllocationUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Update an allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
ipam = IPAMService(db)
updated, error = ipam.update_allocation(
allocation.ip_address,
updated_by=current_user.username,
**alloc_data.model_dump(exclude_unset=True)
)
if error:
raise HTTPException(status_code=400, detail=error)
return updated
@router.delete("/api/allocations/{allocation_id}")
def delete_allocation(
allocation_id: int,
reason: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Release/delete an IP allocation"""
allocation = db.query(IPAMAllocation).filter(IPAMAllocation.id == allocation_id).first()
if not allocation:
raise HTTPException(status_code=404, detail="Allocation not found")
ipam = IPAMService(db)
success, error = ipam.release_ip(
allocation.ip_address,
released_by=current_user.username,
reason=reason
)
if not success:
raise HTTPException(status_code=400, detail=error)
return {"message": f"IP {allocation.ip_address} released"}
@router.get("/api/allocations/next-available/{network_id}")
def get_next_available_ip(
network_id: int,
pool_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get the next available IP in a network (without allocating)"""
ipam = IPAMService(db)
ip = ipam.get_next_available_ip(network_id, pool_id)
if not ip:
raise HTTPException(status_code=404, detail="No available IP addresses")
return {"next_available_ip": ip}
# ==================== History API ====================
@router.get("/api/history", response_model=List[IPAMHistoryResponse])
def get_history(
network_id: Optional[int] = None,
ip_address: Optional[str] = None,
action: Optional[str] = None,
limit: int = Query(default=100, le=500),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get IPAM history"""
query = db.query(IPAMHistory)
if network_id:
query = query.filter(IPAMHistory.network_id == network_id)
if ip_address:
query = query.filter(IPAMHistory.ip_address == ip_address)
if action:
query = query.filter(IPAMHistory.action == action)
return query.order_by(IPAMHistory.performed_at.desc()).limit(limit).all()
@router.get("/api/history/ip/{ip_address}", response_model=List[IPAMHistoryResponse])
def get_ip_history(
ip_address: str,
limit: int = Query(default=50, le=200),
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get history for a specific IP address"""
ipam = IPAMService(db)
return ipam.get_ip_history(ip_address, limit)
# ==================== Utility API ====================
@router.get("/api/conflicts")
def detect_conflicts(
network_id: Optional[int] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Detect IP address conflicts"""
ipam = IPAMService(db)
conflicts = ipam.detect_conflicts(network_id)
return {"conflicts": conflicts, "count": len(conflicts)}
@router.get("/api/summary")
def get_ipam_summary(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Get overall IPAM summary"""
networks_count = db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True).count()
pools_count = db.query(IPAMPool).filter(IPAMPool.is_active == True).count()
total_allocated = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'allocated').count()
total_reserved = db.query(IPAMAllocation).filter(IPAMAllocation.status == 'reserved').count()
# Count by resource type
vm_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'vm').count()
lxc_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'lxc').count()
physical_count = db.query(IPAMAllocation).filter(IPAMAllocation.resource_type == 'physical').count()
# Recent activity
recent_allocations = db.query(IPAMHistory).filter(
IPAMHistory.action == 'allocated'
).order_by(IPAMHistory.performed_at.desc()).limit(5).all()
return {
'networks_count': networks_count,
'pools_count': pools_count,
'total_allocated': total_allocated,
'total_reserved': total_reserved,
'vm_count': vm_count,
'lxc_count': lxc_count,
'physical_count': physical_count,
'recent_allocations': [
{
'ip': h.ip_address,
'resource': h.resource_name,
'by': h.performed_by,
'at': h.performed_at.isoformat() if h.performed_at else None
} for h in recent_allocations
]
}
# ==================== Maintenance API ====================
@router.get("/api/orphans")
def get_orphan_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Get orphan IPAM allocations - IPs allocated to VMs that no longer exist.
"""
orphans = []
# 1. Allocations with proxmox_vmid but VM doesn't exist or is deleted
allocations_with_vmid = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_vmid.isnot(None),
IPAMAllocation.proxmox_server_id.isnot(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
for alloc in allocations_with_vmid:
vm = db.query(VMInstance).filter(
VMInstance.vmid == alloc.proxmox_vmid,
VMInstance.server_id == alloc.proxmox_server_id,
VMInstance.deleted_at.is_(None)
).first()
if not vm:
orphans.append({
'id': alloc.id,
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'resource_type': alloc.resource_type,
'proxmox_vmid': alloc.proxmox_vmid,
'proxmox_server_id': alloc.proxmox_server_id,
'reason': 'VM/LXC not found or deleted'
})
# 2. Allocations to non-existent servers
allocations_with_server = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id.isnot(None),
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
server_ids = {a.proxmox_server_id for a in allocations_with_server}
existing_servers = {s.id for s in db.query(ProxmoxServer.id).filter(ProxmoxServer.id.in_(server_ids)).all()}
for alloc in allocations_with_server:
if alloc.proxmox_server_id not in existing_servers:
# Check if not already in orphans
if not any(o['id'] == alloc.id for o in orphans):
orphans.append({
'id': alloc.id,
'ip_address': alloc.ip_address,
'resource_name': alloc.resource_name,
'resource_type': alloc.resource_type,
'proxmox_vmid': alloc.proxmox_vmid,
'proxmox_server_id': alloc.proxmox_server_id,
'reason': f'Server {alloc.proxmox_server_id} not found'
})
return {
'orphans': orphans,
'count': len(orphans)
}
@router.post("/api/cleanup-orphans")
async def cleanup_orphan_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Clean up orphan IPAM allocations - release IPs for VMs that no longer exist.
"""
ipam = IPAMService(db)
released = []
errors = []
# Get orphans
orphans_result = get_orphan_allocations(db, current_user)
orphans = orphans_result['orphans']
for orphan in orphans:
try:
success, error = ipam.release_ip(
ip_address=orphan['ip_address'],
released_by=current_user.username,
reason=f"Orphan cleanup: {orphan['reason']}"
)
if success:
released.append(orphan['ip_address'])
logger.info(f"Released orphan IP {orphan['ip_address']} by {current_user.username}")
else:
errors.append({'ip': orphan['ip_address'], 'error': error})
except Exception as e:
errors.append({'ip': orphan['ip_address'], 'error': str(e)})
return {
'released': released,
'released_count': len(released),
'errors': errors,
'error_count': len(errors)
}
class DiscoveredAddress(NamedTuple):
"""Адрес гостя, найденный в одной из известных IPAM-сетей."""
ip: str
network: object # IPAMNetwork
mac: Optional[str] # MAC интерфейса, если он известен
interface: Optional[str] # имя интерфейса гостя (eth0, ens18, ...)
def _extract_ipv4s(interfaces: list, parsed_networks: list) -> List[DiscoveredAddress]:
"""Все IPv4 гостя, попадающие в известные IPAM-сети, в порядке обнаружения.
Гость может держать несколько адресов, поэтому возвращается список, а не
первое совпадение. Loopback и link-local отбрасываются.
"""
found: List[DiscoveredAddress] = []
seen = set()
for iface in interfaces:
for ip_info in iface.get('ips', []):
if ip_info.get('type') != 'ipv4':
continue
addr = ip_info.get('address', '')
if not addr or addr in seen:
continue
try:
ip_obj = ipaddress_module.ip_address(addr)
except ValueError:
continue
if ip_obj.is_loopback or ip_obj.is_link_local:
continue
for net, net_obj in parsed_networks:
if ip_obj in net_obj:
seen.add(addr)
found.append(DiscoveredAddress(
ip=addr,
network=net,
mac=(iface.get('hardware_address') or '').upper() or None,
interface=iface.get('name') or None,
))
break
return found
@router.get("/api/unlinked")
def get_unlinked_allocations(
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Return VM instances whose current address is not registered in IPAM.
У гостя может быть несколько адресов, поэтому «связан» определяется не по
наличию хоть какой-то аллокации, а по конкретному адресу: если в кэше
инстанса стоит IP, которого нет среди его аллокаций, гость показывается
как несвязанный. Учитываются только адреса из активных IPAM-сетей.
"""
unlinked = []
# All live VMs
vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
# (server_id, vmid) -> множество уже учтённых адресов гостя
linked_ips = {}
for alloc in db.query(
IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid, IPAMAllocation.ip_address
).filter(IPAMAllocation.proxmox_vmid.isnot(None)).all():
linked_ips.setdefault((alloc.proxmox_server_id, alloc.proxmox_vmid), set()).add(
alloc.ip_address
)
servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
# Parse active IPAM networks to filter by IP membership
ipam_nets = []
for net in db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True).all():
try:
ipam_nets.append(ipaddress_module.ip_network(net.network, strict=False))
except ValueError:
pass
for vm in vms:
# Skip VMs with no IP or an IP outside all IPAM networks
if not vm.ip_address:
continue
if vm.ip_address in linked_ips.get((vm.server_id, vm.vmid), ()):
continue
try:
vm_ip = ipaddress_module.ip_address(vm.ip_address)
if not any(vm_ip in net for net in ipam_nets):
continue
except ValueError:
continue
server = servers_map.get(vm.server_id)
unlinked.append({
'id': None,
'ip_address': vm.ip_address,
'resource_name': vm.name,
'resource_type': vm.vm_type,
'can_link': True,
'suggested_vmid': vm.vmid,
'suggested_server_id': vm.server_id,
'suggested_server_name': server.name if server else None
})
return {
'unlinked': unlinked,
'count': len(unlinked),
'linkable_count': len(unlinked)
}
class LinkAllocationsRequest(BaseModel):
server_id: Optional[int] = None # If set, only process this server's VMs
network_id: Optional[int] = None # If set, only match against this IPAM network
@router.post("/api/link-allocations")
def link_allocations_to_vms(
body: LinkAllocationsRequest = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""
Scan Proxmox servers, get VM IP addresses via QEMU guest agent / LXC config,
and create/update IPAM allocations for each VM whose IP belongs to a known IPAM network.
When body.server_id is given: only process that server.
When body.network_id is given: only match against that specific network.
In auto mode (no params): each VM is matched only against networks whose
proxmox_server_id equals the VM's server (or networks without a server binding).
"""
if body is None:
body = LinkAllocationsRequest()
linked = []
not_found = []
service = IPAMService(db)
synced_by = current_user.username if hasattr(current_user, 'username') else 'system'
# Parse all active IPAM networks once (or just the requested one)
net_query = db.query(IPAMNetwork).filter(IPAMNetwork.is_active == True)
if body.network_id:
net_query = net_query.filter(IPAMNetwork.id == body.network_id)
networks = net_query.all()
parsed_networks = []
for net in networks:
try:
parsed_networks.append((net, ipaddress_module.ip_network(net.network, strict=False)))
except ValueError:
pass
if not parsed_networks:
return {'linked': [], 'linked_count': 0, 'not_found': [], 'not_found_count': 0,
'message': 'No active IPAM networks configured'}
# Pre-build set of already-linked (server_id, vmid) pairs
linked_pairs = set(
(a.proxmox_server_id, a.proxmox_vmid)
for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter(
IPAMAllocation.proxmox_vmid.isnot(None)
).all()
)
# All live VMs cached in DB (optionally filtered by server)
vm_query = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None))
if body.server_id:
vm_query = vm_query.filter(VMInstance.server_id == body.server_id)
vms = vm_query.all()
# Group VMs by server_id for efficient client reuse
vms_by_server: dict = {}
for vm in vms:
vms_by_server.setdefault(vm.server_id, []).append(vm)
for server_id, server_vms in vms_by_server.items():
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
continue
# Determine which networks apply for this server.
# If a specific network_id was requested, only use that (already filtered above).
# Otherwise restrict to networks that belong to this server OR are "global" (no server binding).
if body.network_id:
server_networks = parsed_networks # already filtered to the one network
else:
server_networks = [
(n, p) for n, p in parsed_networks
if n.proxmox_server_id == server_id or n.proxmox_server_id is None
]
if not server_networks:
logger.info(f"[IPAM link] Server {server.name} has no matching IPAM networks, skipping")
continue
try:
client = ProxmoxClient.from_server(server)
if not client.is_connected():
logger.warning(f"[IPAM link] Server {server.name} not reachable, skipping")
for vm in server_vms:
if (server_id, vm.vmid) not in linked_pairs:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'server_unreachable'})
continue
except Exception as e:
logger.error(f"[IPAM link] Cannot build client for server {server.name}: {e}")
continue
for vm in server_vms:
# Уже связанных больше не пропускаем: у гостя может появиться
# второй адрес или смениться существующий — и то, и другое раньше
# оставалось незамеченным навсегда.
# For this VM, further filter networks by proxmox_node if set
# (e.g. network tagged to pve1 should not match VMs on pve2)
vm_networks = [
(n, p) for n, p in server_networks
if not n.proxmox_node or n.proxmox_node == vm.node
]
if not vm_networks:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'no_network_for_node'})
continue
# Get interfaces from Proxmox
try:
if vm.vm_type == 'qemu':
interfaces = client.get_vm_interfaces(vm.node, vm.vmid)
else:
interfaces = client.get_container_interfaces(vm.node, vm.vmid)
except Exception as e:
logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}")
interfaces = []
# Все IPv4 гостя, попадающие в его IPAM-сети
discovered = _extract_ipv4s(interfaces, vm_networks)
if not discovered:
not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'no_ip_in_ipam_network'})
continue
already_linked = {
a.ip_address for a in
service.find_allocations_by_resource(server_id, vm.vmid)
}
for found in discovered:
if found.ip in already_linked:
continue # этот адрес гостя уже учтён
alloc, error = service.sync_from_proxmox_vm(
network_id=found.network.id,
proxmox_server_id=server_id,
vmid=vm.vmid,
vm_name=vm.name,
vm_type=vm.vm_type,
ip_address=found.ip,
node=vm.node,
mac_address=found.mac,
synced_by=synced_by,
target_interface=found.interface,
# Адреса сверх первого — алиасы: их поднимают внутри
# гостя, а не через net0/ipconfig0.
assignment_kind='alias',
)
if alloc:
already_linked.add(found.ip)
linked_pairs.add((server_id, vm.vmid))
# Кэш инстанса показывает основной адрес
if alloc.is_primary:
vm.ip_address = found.ip
linked.append({
'ip_address': found.ip,
'resource_name': vm.name,
'vmid': vm.vmid,
'server_id': server_id,
'is_primary': bool(alloc.is_primary),
})
logger.info(
f"[IPAM link] Linked {found.ip} -> VM {vm.vmid} ({vm.name}) on {server.name}"
)
else:
not_found.append({'ip_address': found.ip, 'resource_name': vm.name,
'reason': error or 'sync_error'})
logger.warning(f"[IPAM link] Failed {found.ip} for {vm.name}: {error}")
if linked:
try:
db.commit()
except Exception as e:
db.rollback()
logger.error(f"[IPAM link] Commit failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
return {
'linked': linked,
'linked_count': len(linked),
'not_found': not_found,
'not_found_count': len(not_found)
}
# ==================== Guest addresses (несколько IP на инстанс) ====================
#
# Основной адрес гостя задаётся конфигом Proxmox (net0 / ipconfig0) и здесь
# только помечается флагом. Дополнительные навешиваются алиасами внутри гостя
# силами services/guest_ip_service.py.
class GuestAddressCreate(BaseModel):
network_id: int
pool_id: Optional[int] = None
ip_address: Optional[str] = None # пусто -> следующий свободный в сети/пуле
target_interface: Optional[str] = None # пусто -> интерфейс основного адреса
make_primary: bool = False
hostname: Optional[str] = None
notes: Optional[str] = None
def _allocation_payload(db: Session, alloc: IPAMAllocation) -> dict:
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == alloc.network_id).first()
prefix = None
if network and network.network and '/' in network.network:
prefix = network.network.split('/')[1]
return {
'id': alloc.id,
'ip_address': alloc.ip_address,
'prefix': prefix,
'network_id': alloc.network_id,
'network_name': network.name if network else None,
'gateway': network.gateway if network else None,
'mac_address': alloc.mac_address,
'status': alloc.status,
'is_primary': bool(alloc.is_primary),
'assignment_kind': alloc.assignment_kind,
'target_interface': alloc.target_interface,
'apply_status': alloc.apply_status,
'apply_error': alloc.apply_error,
'applied_at': alloc.applied_at,
'hostname': alloc.hostname,
'notes': alloc.notes,
}
def _guest_or_404(db: Session, server_id: int, vmid: int) -> VMInstance:
guest = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first()
if not guest:
raise HTTPException(status_code=404, detail="Instance not found")
return guest
def _guest_allocation_or_404(db: Session, server_id: int, vmid: int,
allocation_id: int) -> IPAMAllocation:
allocation = db.query(IPAMAllocation).filter(
IPAMAllocation.id == allocation_id,
IPAMAllocation.proxmox_server_id == server_id,
IPAMAllocation.proxmox_vmid == vmid
).first()
if not allocation:
raise HTTPException(status_code=404, detail="Address not found for this instance")
return allocation
def _refresh_primary_cache(db: Session, service: IPAMService, guest: VMInstance) -> None:
"""Кэш инстанса показывает основной адрес — он же в колонке IP списка."""
primary = service.find_allocation_by_resource(guest.server_id, guest.vmid)
if primary:
guest.ip_address = primary.ip_address
db.commit()
@router.get("/api/guests/{server_id}/{vmid}/addresses")
def list_guest_addresses(
server_id: int,
vmid: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:view"))
):
"""Все IP-адреса инстанса: основной первым, затем алиасы."""
require_vm_access(db, current_user, server_id, vmid)
service = IPAMService(db)
rows = service.find_allocations_by_resource(server_id, vmid)
return {
'addresses': [_allocation_payload(db, a) for a in rows],
'count': len(rows),
}
@router.post("/api/guests/{server_id}/{vmid}/addresses")
def add_guest_address(
server_id: int,
vmid: int,
body: GuestAddressCreate,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Выдать инстансу дополнительный адрес и навесить его на интерфейс.
Адрес резервируется в IPAM в любом случае: если гость недоступен, запись
остаётся со статусом pending, и её можно применить позже.
"""
require_vm_access(db, current_user, server_id, vmid)
guest = _guest_or_404(db, server_id, vmid)
service = IPAMService(db)
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == body.network_id).first()
if not network:
raise HTTPException(status_code=404, detail="IPAM network not found")
ip_address = body.ip_address
if not ip_address:
# Доп. адрес выдаётся точечно, поэтому при отсутствии auto-assign пула
# ищем свободный по всей подсети, а не отказываем.
ip_address = service.get_next_available_ip(
body.network_id, body.pool_id, allow_network_scan=True
)
if not ip_address:
raise HTTPException(status_code=409, detail="No free IP left in this network/pool")
interface = body.target_interface
if not interface:
primary = service.find_allocation_by_resource(server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
try:
client = ProxmoxClient.from_server(server)
interface = guest_ip_service.resolve_interface(
client, guest.vm_type, guest.node, vmid,
primary_ip=primary.ip_address if primary else None,
)
except Exception as e:
logger.debug(f"[guest-ip] fallback interface for {vmid}: {e}")
interface = guest_ip_service.DEFAULT_INTERFACE
allocation, error = service.allocate_ip(
ip_address=ip_address,
network_id=body.network_id,
pool_id=body.pool_id,
resource_type=guest.vm_type,
resource_name=guest.name,
hostname=body.hostname,
proxmox_server_id=server_id,
proxmox_vmid=vmid,
proxmox_node=guest.node,
allocated_by=current_user.username,
notes=body.notes,
assignment_kind="alias",
target_interface=interface,
apply_status=guest_ip_service.PENDING,
)
if error:
raise HTTPException(status_code=400, detail=error)
apply_result = guest_ip_service.apply_address(db, allocation)
if body.make_primary:
service.set_primary(server_id, vmid, allocation.id, changed_by=current_user.username)
_refresh_primary_cache(db, service, guest)
db.refresh(allocation)
return {
'address': _allocation_payload(db, allocation),
'applied': apply_result.success,
'apply_status': apply_result.status,
'persist': apply_result.persist,
'error': apply_result.error,
}
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/apply")
def apply_guest_address(
server_id: int,
vmid: int,
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Повторить применение адреса — например, после включения гостя."""
require_vm_access(db, current_user, server_id, vmid)
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
result = guest_ip_service.apply_address(db, allocation)
db.refresh(allocation)
return {
'address': _allocation_payload(db, allocation),
'applied': result.success,
'apply_status': result.status,
'persist': result.persist,
'error': result.error,
}
@router.post("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}/primary")
def set_guest_primary_address(
server_id: int,
vmid: int,
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Пометить адрес основным — он показывается в колонке IP списка инстансов."""
require_vm_access(db, current_user, server_id, vmid)
guest = _guest_or_404(db, server_id, vmid)
_guest_allocation_or_404(db, server_id, vmid, allocation_id)
service = IPAMService(db)
allocation, error = service.set_primary(
server_id, vmid, allocation_id, changed_by=current_user.username
)
if error:
raise HTTPException(status_code=400, detail=error)
_refresh_primary_cache(db, service, guest)
return {'address': _allocation_payload(db, allocation)}
@router.delete("/api/guests/{server_id}/{vmid}/addresses/{allocation_id}")
def remove_guest_address(
server_id: int,
vmid: int,
allocation_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("ipam:manage"))
):
"""Снять адрес с гостя и освободить его в IPAM."""
require_vm_access(db, current_user, server_id, vmid)
guest = _guest_or_404(db, server_id, vmid)
allocation = _guest_allocation_or_404(db, server_id, vmid, allocation_id)
if allocation.assignment_kind != "alias":
raise HTTPException(
status_code=400,
detail="Primary address comes from the NIC config — change it on the Network tab",
)
ip_address = allocation.ip_address
interface = allocation.target_interface or guest_ip_service.DEFAULT_INTERFACE
service = IPAMService(db)
released, error = service.release_ip(
ip_address, released_by=current_user.username,
reason=f"Removed from {guest.name} ({vmid})",
)
if not released:
raise HTTPException(status_code=400, detail=error or "Failed to release IP")
# Аллокации уже нет — синхронизация снимет адрес с интерфейса гостя.
result = guest_ip_service.sync_interface(db, server_id, vmid, interface)
_refresh_primary_cache(db, service, guest)
return {
'released': ip_address,
'removed_from_guest': result.success,
'error': result.error,
}