""" 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, }