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
1365 lines
50 KiB
Python
1365 lines
50 KiB
Python
"""
|
||
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,
|
||
}
|