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
983 lines
35 KiB
Python
983 lines
35 KiB
Python
from datetime import datetime
|
||
from typing import Optional, List
|
||
import ipaddress
|
||
import re
|
||
|
||
from pydantic import BaseModel, Field, field_validator, model_validator, EmailStr
|
||
|
||
|
||
_HOSTNAME_LABEL_RE = re.compile(r'^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$')
|
||
|
||
_DNS_NAME_RE = re.compile(r'^[a-zA-Z](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$')
|
||
|
||
|
||
def validate_instance_name(name: str) -> str:
|
||
"""Validate that an instance name is a valid DNS label (RFC 952 / RFC 1123).
|
||
|
||
Proxmox requires VM/CT names to be valid DNS names: start with a letter,
|
||
contain only letters, digits and hyphens, end with a letter or digit,
|
||
max 63 characters. Underscores, spaces and other special characters are
|
||
rejected.
|
||
"""
|
||
name = name.strip()
|
||
if not name:
|
||
raise ValueError("Имя не может быть пустым")
|
||
if len(name) > 63:
|
||
raise ValueError("Имя не может быть длиннее 63 символов")
|
||
if not _DNS_NAME_RE.match(name):
|
||
raise ValueError(
|
||
"Имя должно быть валидным DNS-именем: начинаться с буквы, "
|
||
"содержать только латинские буквы, цифры и дефис, "
|
||
"заканчиваться буквой или цифрой"
|
||
)
|
||
return name
|
||
|
||
|
||
def validate_host(v: str) -> str:
|
||
"""Validate that value is a valid IP address or hostname/FQDN."""
|
||
if v is None:
|
||
raise ValueError('Host is required')
|
||
v = v.strip()
|
||
if not v:
|
||
raise ValueError('Host cannot be empty')
|
||
# Accept valid IP address (v4 or v6)
|
||
try:
|
||
ipaddress.ip_address(v)
|
||
return v
|
||
except ValueError:
|
||
pass
|
||
# Otherwise validate as hostname / FQDN
|
||
host = v.rstrip('.')
|
||
if len(host) > 253 or not host:
|
||
raise ValueError('Invalid host: must be a valid IP address or hostname')
|
||
if all(_HOSTNAME_LABEL_RE.match(label) for label in host.split('.')):
|
||
return v
|
||
raise ValueError('Invalid host: must be a valid IP address or hostname')
|
||
|
||
|
||
# ==================== User Schemas ====================
|
||
|
||
class UserBase(BaseModel):
|
||
username: str = Field(..., min_length=3, max_length=50, description="Username")
|
||
email: EmailStr = Field(..., description="Email address")
|
||
full_name: Optional[str] = Field(None, max_length=100, description="Full name")
|
||
|
||
|
||
class UserCreate(UserBase):
|
||
password: str = Field(..., min_length=6, max_length=100, description="Password")
|
||
|
||
|
||
class UserUpdate(BaseModel):
|
||
email: Optional[EmailStr] = None
|
||
full_name: Optional[str] = Field(None, max_length=100)
|
||
password: Optional[str] = Field(None, min_length=6, max_length=100)
|
||
is_active: Optional[bool] = None
|
||
is_admin: Optional[bool] = None
|
||
role_id: Optional[int] = None
|
||
require_password_change: Optional[bool] = None
|
||
|
||
|
||
class UserResponse(UserBase):
|
||
id: int
|
||
is_active: bool
|
||
is_admin: bool
|
||
two_factor_enabled: bool = False
|
||
created_at: datetime
|
||
last_login: Optional[datetime] = None
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class Token(BaseModel):
|
||
# access_token is absent when the login step succeeded on password but a
|
||
# second factor is still required (two_factor_required=True).
|
||
access_token: Optional[str] = None
|
||
token_type: str = "bearer"
|
||
two_factor_required: Optional[bool] = None
|
||
|
||
|
||
class TokenData(BaseModel):
|
||
username: Optional[str] = None
|
||
|
||
|
||
class LoginRequest(BaseModel):
|
||
username: str = Field(..., description="Username")
|
||
password: str = Field(..., description="Password")
|
||
code: Optional[str] = Field(None, description="TOTP or backup code (when 2FA is enabled)")
|
||
|
||
|
||
# ==================== Two-Factor (2FA) Schemas ====================
|
||
|
||
class TwoFactorStatusResponse(BaseModel):
|
||
enabled: bool
|
||
backup_codes_remaining: int = 0
|
||
|
||
|
||
class TwoFactorSetupResponse(BaseModel):
|
||
secret: str
|
||
otpauth_uri: str
|
||
qr_svg: str
|
||
|
||
|
||
class TwoFactorEnableRequest(BaseModel):
|
||
code: str = Field(..., description="6-digit TOTP code from the authenticator app")
|
||
|
||
|
||
class TwoFactorEnableResponse(BaseModel):
|
||
enabled: bool = True
|
||
backup_codes: List[str]
|
||
|
||
|
||
class TwoFactorDisableRequest(BaseModel):
|
||
password: str = Field(..., description="Current account password")
|
||
code: Optional[str] = Field(None, description="Current TOTP or backup code")
|
||
|
||
|
||
class MessageResponse(BaseModel):
|
||
message: str
|
||
|
||
|
||
class ForgotPasswordRequest(BaseModel):
|
||
email: EmailStr = Field(..., description="Account email to send the reset link to")
|
||
|
||
|
||
class ResetPasswordRequest(BaseModel):
|
||
token: str = Field(..., min_length=8, max_length=128, description="One-time reset token from the emailed link")
|
||
new_password: str = Field(..., description="New password")
|
||
|
||
|
||
# ==================== Proxmox Server Schemas ====================
|
||
|
||
class ProxmoxServerCreate(BaseModel):
|
||
"""Schema for creating a new Proxmox server"""
|
||
name: str = Field(..., min_length=1, max_length=100, description="Server name")
|
||
hostname: str = Field(..., min_length=1, max_length=255, description="Server hostname/FQDN")
|
||
ip_address: str = Field(..., min_length=7, max_length=50, description="IP address")
|
||
port: int = Field(default=8006, ge=1, le=65535, description="Proxmox API port")
|
||
api_user: str = Field(default="root@pam", max_length=100, description="API username (e.g., root@pam)")
|
||
api_token_name: Optional[str] = Field(None, max_length=100, description="API token name")
|
||
api_token_value: Optional[str] = Field(None, max_length=255, description="API token value")
|
||
use_password: bool = Field(default=False, description="Use password instead of token")
|
||
password: Optional[str] = Field(None, max_length=255, description="Password (if use_password=True)")
|
||
auto_create_token: bool = Field(
|
||
default=False,
|
||
description="Authenticate with the password once, then let the panel create its own API token",
|
||
)
|
||
verify_ssl: bool = Field(default=False, description="Verify SSL certificate")
|
||
description: Optional[str] = Field(None, description="Server description")
|
||
|
||
@field_validator('ip_address')
|
||
@classmethod
|
||
def validate_ip_address(cls, v):
|
||
"""Validate IP address or hostname/FQDN"""
|
||
return validate_host(v)
|
||
|
||
@field_validator('name')
|
||
@classmethod
|
||
def validate_name(cls, v):
|
||
"""Validate server name"""
|
||
if not v or not v.strip():
|
||
raise ValueError('Server name cannot be empty')
|
||
return v.strip()
|
||
|
||
@field_validator('hostname')
|
||
@classmethod
|
||
def validate_hostname(cls, v):
|
||
"""Validate hostname"""
|
||
if not v or not v.strip():
|
||
raise ValueError('Hostname cannot be empty')
|
||
return v.strip()
|
||
|
||
@model_validator(mode='after')
|
||
def validate_auth_config(self):
|
||
"""Validate authentication configuration"""
|
||
if self.auto_create_token:
|
||
# The token does not exist yet — it is created during registration
|
||
# using this password, so only the password is required here.
|
||
if not self.password:
|
||
raise ValueError('Password is required when auto_create_token=True')
|
||
elif self.use_password:
|
||
if not self.password:
|
||
raise ValueError('Password is required when use_password=True')
|
||
else:
|
||
if not self.api_token_name or not self.api_token_value:
|
||
raise ValueError('API token name and value are required when using token auth')
|
||
return self
|
||
|
||
|
||
class ProxmoxServerUpdate(BaseModel):
|
||
"""Schema for updating a Proxmox server"""
|
||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
hostname: Optional[str] = Field(None, min_length=1, max_length=255)
|
||
ip_address: Optional[str] = Field(None, min_length=7, max_length=50)
|
||
port: Optional[int] = Field(None, ge=1, le=65535)
|
||
api_user: Optional[str] = Field(None, max_length=100)
|
||
api_token_name: Optional[str] = Field(None, max_length=100)
|
||
api_token_value: Optional[str] = Field(None, max_length=255)
|
||
use_password: Optional[bool] = None
|
||
password: Optional[str] = Field(None, max_length=255)
|
||
verify_ssl: Optional[bool] = None
|
||
description: Optional[str] = None
|
||
|
||
|
||
class ServerWorkspaceBrief(BaseModel):
|
||
"""Compact workspace reference shown on server cards"""
|
||
id: int
|
||
name: str
|
||
color: Optional[str] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class ProxmoxServerResponse(BaseModel):
|
||
"""Schema for Proxmox server response"""
|
||
id: int
|
||
name: str
|
||
hostname: str
|
||
ip_address: str
|
||
port: int
|
||
api_user: str
|
||
verify_ssl: bool
|
||
use_password: bool = False
|
||
cluster_name: Optional[str] = None
|
||
description: Optional[str] = None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
last_check: Optional[datetime] = None
|
||
is_online: Optional[bool] = None
|
||
last_error: Optional[str] = None
|
||
# 'auth' — сервер отвечает, но отверг учётные данные; 'connection' — не отвечает
|
||
last_error_kind: Optional[str] = None
|
||
workspaces: List[ServerWorkspaceBrief] = []
|
||
|
||
# Exclude sensitive fields
|
||
api_token_value: Optional[str] = Field(None, exclude=True)
|
||
password: Optional[str] = Field(None, exclude=True)
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class ProxmoxVMResponse(BaseModel):
|
||
"""Schema for Proxmox VM/Container response"""
|
||
vmid: int
|
||
name: Optional[str] = None
|
||
status: str
|
||
type: str # 'qemu' or 'lxc'
|
||
node: str
|
||
cpu: Optional[float] = None
|
||
mem: Optional[int] = None
|
||
maxmem: Optional[int] = None
|
||
disk: Optional[int] = None
|
||
maxdisk: Optional[int] = None
|
||
uptime: Optional[int] = None
|
||
|
||
|
||
# ==================== OS Template Schemas ====================
|
||
|
||
class OSTemplateGroupBase(BaseModel):
|
||
"""Base schema for OS Template Group"""
|
||
name: str = Field(..., min_length=1, max_length=100, description="Group name (e.g., Ubuntu, Debian)")
|
||
icon: Optional[str] = Field(None, max_length=200, description="Icon (emoji, class or HTML)")
|
||
description: Optional[str] = Field(None, description="Group description")
|
||
sort_order: int = Field(default=0, description="Sort order")
|
||
is_active: bool = Field(default=True, description="Is group active")
|
||
|
||
|
||
class OSTemplateGroupCreate(OSTemplateGroupBase):
|
||
"""Schema for creating OS Template Group"""
|
||
pass
|
||
|
||
|
||
class OSTemplateGroupUpdate(BaseModel):
|
||
"""Schema for updating OS Template Group"""
|
||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
icon: Optional[str] = Field(None, max_length=200)
|
||
description: Optional[str] = None
|
||
sort_order: Optional[int] = None
|
||
is_active: Optional[bool] = None
|
||
|
||
|
||
class OSTemplateGroupResponse(OSTemplateGroupBase):
|
||
"""Schema for OS Template Group response"""
|
||
id: int
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class OSTemplateBase(BaseModel):
|
||
"""Base schema for OS Template"""
|
||
group_id: int = Field(..., description="Template group ID")
|
||
server_id: int = Field(..., description="Proxmox server ID")
|
||
name: str = Field(..., min_length=1, max_length=100, description="Display name")
|
||
vmid: Optional[int] = Field(None, ge=100, description="Proxmox template VMID (None for vztmpl file templates)")
|
||
vm_type: str = Field(default="qemu", description="Template type: qemu (KVM) or lxc")
|
||
volid: Optional[str] = Field(None, max_length=500, description="vztmpl volume id (for LXC file templates)")
|
||
node: Optional[str] = Field(None, max_length=100, description="Proxmox node name")
|
||
default_cores: int = Field(default=1, ge=1, le=128, description="Default CPU cores")
|
||
default_memory: int = Field(default=1024, ge=128, description="Default memory in MB")
|
||
default_disk: int = Field(default=10, ge=1, description="Default disk size in GB")
|
||
min_cores: int = Field(default=1, ge=1, description="Minimum CPU cores")
|
||
min_memory: int = Field(default=512, ge=128, description="Minimum memory in MB")
|
||
min_disk: int = Field(default=5, ge=1, description="Minimum disk size in GB")
|
||
description: Optional[str] = Field(None, description="Template description")
|
||
is_active: bool = Field(default=True, description="Is template active")
|
||
sort_order: int = Field(default=0, description="Sort order")
|
||
|
||
|
||
class OSTemplateCreate(OSTemplateBase):
|
||
"""Schema for creating OS Template"""
|
||
pass
|
||
|
||
|
||
class OSTemplateUpdate(BaseModel):
|
||
"""Schema for updating OS Template"""
|
||
group_id: Optional[int] = None
|
||
server_id: Optional[int] = None
|
||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
vmid: Optional[int] = Field(None, ge=100)
|
||
vm_type: Optional[str] = None
|
||
volid: Optional[str] = Field(None, max_length=500)
|
||
node: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
default_cores: Optional[int] = Field(None, ge=1, le=128)
|
||
default_memory: Optional[int] = Field(None, ge=128)
|
||
default_disk: Optional[int] = Field(None, ge=1)
|
||
min_cores: Optional[int] = Field(None, ge=1)
|
||
min_memory: Optional[int] = Field(None, ge=128)
|
||
min_disk: Optional[int] = Field(None, ge=1)
|
||
description: Optional[str] = None
|
||
is_active: Optional[bool] = None
|
||
sort_order: Optional[int] = None
|
||
|
||
|
||
class OSTemplateResponse(OSTemplateBase):
|
||
"""Schema for OS Template response"""
|
||
id: int
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class OSTemplateWithGroup(OSTemplateResponse):
|
||
"""Schema for OS Template with group info"""
|
||
group_name: Optional[str] = None
|
||
group_icon: Optional[str] = None
|
||
server_name: Optional[str] = None
|
||
|
||
|
||
class VMDeployRequest(BaseModel):
|
||
"""Schema for deploying a new VM from template"""
|
||
template_id: int = Field(..., description="OS Template ID")
|
||
name: str = Field(..., min_length=1, max_length=100, description="New VM name")
|
||
vmid: Optional[int] = Field(None, ge=100, le=999999999, description="Specific VMID to use (optional, for reinstall)")
|
||
target_node: Optional[str] = Field(None, description="Target node for VM deployment (optional, allows cross-node deployment)")
|
||
cores: Optional[int] = Field(None, ge=1, le=128, description="CPU cores")
|
||
memory: Optional[int] = Field(None, ge=128, description="Memory in MB")
|
||
disk: Optional[int] = Field(None, ge=1, description="Disk size in GB")
|
||
target_storage: Optional[str] = Field(None, description="Target storage for VM disk (e.g., local-lvm)")
|
||
start_after_create: bool = Field(default=True, description="Start VM after creation")
|
||
onboot: bool = Field(default=False, description="Start VM on host boot")
|
||
# High Availability
|
||
enable_ha: bool = Field(default=False, description="Enable High Availability for VM (cluster only)")
|
||
# Network configuration
|
||
network_bridge: Optional[str] = Field(default="vmbr0", description="Network bridge")
|
||
ip_address: Optional[str] = Field(None, description="Static IP address")
|
||
gateway: Optional[str] = Field(None, description="Gateway IP")
|
||
# IPAM integration
|
||
ipam_network_id: Optional[int] = Field(None, description="IPAM network ID for auto IP allocation")
|
||
ipam_pool_id: Optional[int] = Field(None, description="IPAM pool ID (optional)")
|
||
# Cloud-init (if template supports)
|
||
cloud_init_user: Optional[str] = Field(None, max_length=50, description="Cloud-init username")
|
||
cloud_init_password: Optional[str] = Field(None, max_length=100, description="Cloud-init password")
|
||
ssh_keys: Optional[str] = Field(None, description="SSH public keys")
|
||
ssh_key_ids: Optional[List[int]] = Field(default=None, description="IDs of saved SSH keys to inject")
|
||
owner_id: Optional[int] = Field(default=None, description="Admin-only: assign instance to this user (also enables using their saved SSH keys)")
|
||
|
||
|
||
class VMDeployResponse(BaseModel):
|
||
"""Schema for VM deploy response"""
|
||
success: bool
|
||
vmid: int
|
||
name: str
|
||
node: str
|
||
server_id: int
|
||
task_upid: Optional[str] = None
|
||
message: str
|
||
|
||
|
||
# ==================== IPAM Schemas ====================
|
||
|
||
class IPAMNetworkBase(BaseModel):
|
||
"""Base schema for IPAM Network"""
|
||
name: str = Field(..., min_length=1, max_length=100, description="Network name")
|
||
description: Optional[str] = Field(None, description="Network description")
|
||
network: str = Field(..., description="Network CIDR (e.g., 10.10.10.0/24)")
|
||
gateway: Optional[str] = Field(None, description="Gateway IP")
|
||
vlan_id: Optional[int] = Field(None, ge=1, le=4094, description="VLAN ID")
|
||
dns_primary: Optional[str] = Field(None, description="Primary DNS server")
|
||
dns_secondary: Optional[str] = Field(None, description="Secondary DNS server")
|
||
dns_domain: Optional[str] = Field(None, max_length=255, description="DNS domain")
|
||
proxmox_server_id: Optional[int] = Field(None, description="Associated Proxmox server ID")
|
||
proxmox_node: Optional[str] = Field(None, max_length=100, description="Proxmox node name (e.g., pve1)")
|
||
proxmox_bridge: Optional[str] = Field(None, max_length=20, description="Proxmox bridge (e.g., vmbr0)")
|
||
workspace_id: Optional[int] = Field(None, description="Workspace this network belongs to")
|
||
is_default: bool = Field(default=False, description="Default network within its workspace")
|
||
is_active: bool = Field(default=True, description="Is network active")
|
||
|
||
@field_validator('network')
|
||
@classmethod
|
||
def validate_network_cidr(cls, v):
|
||
"""Validate network CIDR format"""
|
||
try:
|
||
ipaddress.ip_network(v, strict=False)
|
||
return v
|
||
except ValueError:
|
||
raise ValueError('Invalid network CIDR format (e.g., 10.10.10.0/24)')
|
||
|
||
@field_validator('gateway', 'dns_primary', 'dns_secondary')
|
||
@classmethod
|
||
def validate_ip(cls, v):
|
||
"""Validate IP address format"""
|
||
if v is None:
|
||
return v
|
||
try:
|
||
ipaddress.ip_address(v)
|
||
return v
|
||
except ValueError:
|
||
raise ValueError('Invalid IP address format')
|
||
|
||
|
||
class IPAMNetworkCreate(IPAMNetworkBase):
|
||
"""Schema for creating IPAM Network"""
|
||
pass
|
||
|
||
|
||
class IPAMNetworkUpdate(BaseModel):
|
||
"""Schema for updating IPAM Network"""
|
||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
description: Optional[str] = None
|
||
gateway: Optional[str] = None
|
||
vlan_id: Optional[int] = Field(None, ge=1, le=4094)
|
||
dns_primary: Optional[str] = None
|
||
dns_secondary: Optional[str] = None
|
||
dns_domain: Optional[str] = Field(None, max_length=255)
|
||
proxmox_server_id: Optional[int] = None
|
||
proxmox_node: Optional[str] = Field(None, max_length=100)
|
||
proxmox_bridge: Optional[str] = Field(None, max_length=20)
|
||
workspace_id: Optional[int] = None
|
||
is_default: Optional[bool] = None
|
||
is_active: Optional[bool] = None
|
||
|
||
|
||
class IPAMNetworkResponse(IPAMNetworkBase):
|
||
"""Schema for IPAM Network response"""
|
||
id: int
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
# Computed fields (will be added in API)
|
||
total_ips: Optional[int] = None
|
||
used_ips: Optional[int] = None
|
||
available_ips: Optional[int] = None
|
||
utilization_percent: Optional[float] = None
|
||
server_name: Optional[str] = None
|
||
workspace_name: Optional[str] = None
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class IPAMPoolBase(BaseModel):
|
||
"""Base schema for IPAM Pool"""
|
||
network_id: int = Field(..., description="Parent network ID")
|
||
name: str = Field(..., min_length=1, max_length=100, description="Pool name")
|
||
pool_type: str = Field(default="static", description="Pool type: static, dhcp, reserved")
|
||
range_start: str = Field(..., description="Range start IP")
|
||
range_end: str = Field(..., description="Range end IP")
|
||
auto_assign: bool = Field(default=True, description="Allow auto-assignment from this pool")
|
||
description: Optional[str] = Field(None, description="Pool description")
|
||
is_active: bool = Field(default=True, description="Is pool active")
|
||
|
||
@field_validator('pool_type')
|
||
@classmethod
|
||
def validate_pool_type(cls, v):
|
||
"""Validate pool type"""
|
||
allowed = ['static', 'dhcp', 'reserved']
|
||
if v not in allowed:
|
||
raise ValueError(f'Pool type must be one of: {", ".join(allowed)}')
|
||
return v
|
||
|
||
@field_validator('range_start', 'range_end')
|
||
@classmethod
|
||
def validate_range_ip(cls, v):
|
||
"""Validate IP address format"""
|
||
try:
|
||
ipaddress.ip_address(v)
|
||
return v
|
||
except ValueError:
|
||
raise ValueError('Invalid IP address format')
|
||
|
||
|
||
class IPAMPoolCreate(IPAMPoolBase):
|
||
"""Schema for creating IPAM Pool"""
|
||
pass
|
||
|
||
|
||
class IPAMPoolUpdate(BaseModel):
|
||
"""Schema for updating IPAM Pool"""
|
||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
pool_type: Optional[str] = None
|
||
range_start: Optional[str] = None
|
||
range_end: Optional[str] = None
|
||
auto_assign: Optional[bool] = None
|
||
description: Optional[str] = None
|
||
is_active: Optional[bool] = None
|
||
|
||
|
||
class IPAMPoolResponse(IPAMPoolBase):
|
||
"""Schema for IPAM Pool response"""
|
||
id: int
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
# Computed fields
|
||
total_ips: Optional[int] = None
|
||
used_ips: Optional[int] = None
|
||
available_ips: Optional[int] = None
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class IPAMAllocationBase(BaseModel):
|
||
"""Base schema for IPAM Allocation"""
|
||
network_id: int = Field(..., description="Network ID")
|
||
pool_id: Optional[int] = Field(None, description="Pool ID (optional)")
|
||
ip_address: str = Field(..., description="IP address")
|
||
mac_address: Optional[str] = Field(None, max_length=17, description="MAC address")
|
||
resource_type: Optional[str] = Field(None, description="Resource type: vm, lxc, physical, service, reserved")
|
||
resource_id: Optional[int] = Field(None, description="Resource ID (e.g., VMID)")
|
||
resource_name: Optional[str] = Field(None, max_length=100, description="Resource name")
|
||
proxmox_server_id: Optional[int] = Field(None, description="Proxmox server ID")
|
||
proxmox_vmid: Optional[int] = Field(None, description="Proxmox VMID")
|
||
proxmox_node: Optional[str] = Field(None, max_length=100, description="Proxmox node")
|
||
status: str = Field(default="allocated", description="Status: allocated, reserved, available, conflict")
|
||
allocation_type: str = Field(default="static", description="Type: static, dhcp, floating")
|
||
hostname: Optional[str] = Field(None, max_length=255, description="Hostname")
|
||
fqdn: Optional[str] = Field(None, max_length=255, description="Fully qualified domain name")
|
||
dns_ptr_record: bool = Field(default=False, description="Create PTR record")
|
||
expires_at: Optional[datetime] = Field(None, description="Expiration date (for temp allocations)")
|
||
notes: Optional[str] = Field(None, description="Notes")
|
||
|
||
@field_validator('ip_address')
|
||
@classmethod
|
||
def validate_ip_address(cls, v):
|
||
"""Validate IP address format"""
|
||
try:
|
||
ipaddress.ip_address(v)
|
||
return v
|
||
except ValueError:
|
||
raise ValueError('Invalid IP address format')
|
||
|
||
@field_validator('mac_address')
|
||
@classmethod
|
||
def validate_mac_address(cls, v):
|
||
"""Validate MAC address format"""
|
||
if v is None:
|
||
return v
|
||
import re
|
||
mac_pattern = re.compile(r'^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$')
|
||
if not mac_pattern.match(v):
|
||
raise ValueError('Invalid MAC address format (e.g., AA:BB:CC:DD:EE:FF)')
|
||
return v.upper()
|
||
|
||
@field_validator('status')
|
||
@classmethod
|
||
def validate_status(cls, v):
|
||
"""Validate status"""
|
||
allowed = ['allocated', 'reserved', 'available', 'conflict']
|
||
if v not in allowed:
|
||
raise ValueError(f'Status must be one of: {", ".join(allowed)}')
|
||
return v
|
||
|
||
|
||
class IPAMAllocationCreate(IPAMAllocationBase):
|
||
"""Schema for creating IPAM Allocation"""
|
||
allocated_by: Optional[str] = Field(None, max_length=100, description="Allocated by username")
|
||
|
||
|
||
class IPAMAllocationUpdate(BaseModel):
|
||
"""Schema for updating IPAM Allocation"""
|
||
pool_id: Optional[int] = None
|
||
mac_address: Optional[str] = Field(None, max_length=17)
|
||
resource_type: Optional[str] = None
|
||
resource_id: Optional[int] = None
|
||
resource_name: Optional[str] = Field(None, max_length=100)
|
||
proxmox_server_id: Optional[int] = None
|
||
proxmox_vmid: Optional[int] = None
|
||
proxmox_node: Optional[str] = Field(None, max_length=100)
|
||
status: Optional[str] = None
|
||
allocation_type: Optional[str] = None
|
||
hostname: Optional[str] = Field(None, max_length=255)
|
||
fqdn: Optional[str] = Field(None, max_length=255)
|
||
dns_ptr_record: Optional[bool] = None
|
||
expires_at: Optional[datetime] = None
|
||
notes: Optional[str] = None
|
||
|
||
|
||
class IPAMAllocationResponse(IPAMAllocationBase):
|
||
"""Schema for IPAM Allocation response"""
|
||
id: int
|
||
allocated_by: Optional[str] = None
|
||
allocated_at: datetime
|
||
last_seen: Optional[datetime] = None
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
# Multi-IP: гость может держать несколько адресов
|
||
is_primary: bool = False
|
||
assignment_kind: str = "primary"
|
||
target_interface: Optional[str] = None
|
||
apply_status: Optional[str] = None
|
||
apply_error: Optional[str] = None
|
||
applied_at: Optional[datetime] = None
|
||
# Extended info
|
||
network_name: Optional[str] = None
|
||
pool_name: Optional[str] = None
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class IPAMHistoryResponse(BaseModel):
|
||
"""Schema for IPAM History response"""
|
||
id: int
|
||
ip_address: str
|
||
network_id: Optional[int] = None
|
||
action: str
|
||
old_value: Optional[dict] = None
|
||
new_value: Optional[dict] = None
|
||
resource_type: Optional[str] = None
|
||
resource_id: Optional[int] = None
|
||
resource_name: Optional[str] = None
|
||
performed_by: Optional[str] = None
|
||
performed_at: datetime
|
||
notes: Optional[str] = None
|
||
|
||
model_config = {
|
||
"from_attributes": True
|
||
}
|
||
|
||
|
||
class IPAMAutoAllocateRequest(BaseModel):
|
||
"""Schema for auto-allocating an IP from a network/pool"""
|
||
network_id: int = Field(..., description="Network ID to allocate from")
|
||
pool_id: Optional[int] = Field(None, description="Specific pool ID (optional)")
|
||
resource_type: Optional[str] = Field(None, description="Resource type")
|
||
resource_id: Optional[int] = Field(None, description="Resource ID")
|
||
resource_name: Optional[str] = Field(None, max_length=100, description="Resource name")
|
||
hostname: Optional[str] = Field(None, max_length=255, description="Hostname")
|
||
notes: Optional[str] = Field(None, description="Notes")
|
||
|
||
|
||
class IPAMScanRequest(BaseModel):
|
||
"""Schema for network scan request"""
|
||
network_id: int = Field(..., description="Network ID to scan")
|
||
scan_type: str = Field(default="ping", description="Scan type: ping, arp, full")
|
||
update_last_seen: bool = Field(default=True, description="Update last_seen for found IPs")
|
||
detect_new: bool = Field(default=True, description="Detect and add new allocations")
|
||
|
||
|
||
class IPAMSyncRequest(BaseModel):
|
||
"""Schema for Proxmox sync request"""
|
||
proxmox_server_id: int = Field(..., description="Proxmox server ID to sync from")
|
||
network_id: Optional[int] = Field(None, description="Target network ID (optional)")
|
||
create_allocations: bool = Field(default=True, description="Create allocations for found VMs")
|
||
update_existing: bool = Field(default=True, description="Update existing allocations")
|
||
|
||
|
||
class IPAMNetworkStats(BaseModel):
|
||
"""Schema for network statistics"""
|
||
network_id: int
|
||
network_name: str
|
||
network_cidr: str
|
||
total_ips: int
|
||
allocated_ips: int
|
||
reserved_ips: int
|
||
available_ips: int
|
||
utilization_percent: float
|
||
pools_count: int
|
||
vms_count: int
|
||
lxc_count: int
|
||
physical_count: int
|
||
other_count: int
|
||
|
||
|
||
"""
|
||
Pydantic schemas for notifications
|
||
"""
|
||
|
||
from typing import Optional, List, Any
|
||
from datetime import datetime
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class NotificationCreate(BaseModel):
|
||
"""Schema for creating a notification"""
|
||
user_id: int
|
||
type: str = Field(..., description="Type: vm_status, resource_alert, system, ipam, template")
|
||
level: str = Field(..., description="Level: critical, warning, info, success")
|
||
title: str = Field(..., max_length=255)
|
||
message: Optional[str] = None
|
||
data: Optional[dict] = None
|
||
link: Optional[str] = None
|
||
source: Optional[str] = Field(None, description="Source: proxmox, ipam, system, docker")
|
||
source_id: Optional[str] = None
|
||
expires_at: Optional[datetime] = None
|
||
|
||
|
||
class NotificationUpdate(BaseModel):
|
||
"""Schema for updating a notification"""
|
||
read: Optional[bool] = None
|
||
|
||
|
||
class NotificationResponse(BaseModel):
|
||
"""Schema for notification response"""
|
||
id: int
|
||
user_id: int
|
||
type: str
|
||
level: str
|
||
title: str
|
||
message: Optional[str]
|
||
data: Optional[dict]
|
||
link: Optional[str]
|
||
source: Optional[str]
|
||
source_id: Optional[str]
|
||
read: bool
|
||
read_at: Optional[datetime]
|
||
created_at: datetime
|
||
expires_at: Optional[datetime]
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
class NotificationListResponse(BaseModel):
|
||
"""Schema for notification list response"""
|
||
total: int
|
||
unread_count: int
|
||
notifications: List[NotificationResponse]
|
||
|
||
|
||
class NotificationPreferenceUpdate(BaseModel):
|
||
"""Schema for updating notification preferences"""
|
||
enabled: Optional[bool] = None
|
||
email_enabled: Optional[bool] = None
|
||
email_critical_only: Optional[bool] = None
|
||
telegram_enabled: Optional[bool] = None
|
||
telegram_chat_id: Optional[str] = None
|
||
webhook_url: Optional[str] = None
|
||
notification_levels: Optional[List[str]] = None
|
||
notification_types: Optional[List[str]] = None
|
||
quiet_hours_start: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$")
|
||
quiet_hours_end: Optional[str] = Field(None, pattern=r"^\d{2}:\d{2}$")
|
||
|
||
|
||
class NotificationPreferenceResponse(BaseModel):
|
||
"""Schema for notification preference response"""
|
||
id: int
|
||
user_id: int
|
||
enabled: bool
|
||
email_enabled: bool
|
||
email_critical_only: bool
|
||
telegram_enabled: bool
|
||
telegram_chat_id: Optional[str]
|
||
webhook_url: Optional[str]
|
||
notification_levels: List[str]
|
||
notification_types: List[str]
|
||
quiet_hours_start: Optional[str]
|
||
quiet_hours_end: Optional[str]
|
||
created_at: datetime
|
||
updated_at: datetime
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
# Notification schemas added above
|
||
|
||
|
||
# ==================== Workspace Schemas ====================
|
||
|
||
class WorkspaceCreate(BaseModel):
|
||
name: str = Field(..., min_length=1, max_length=100, description="Workspace name")
|
||
description: Optional[str] = Field(None, max_length=500)
|
||
color: Optional[str] = Field("#667eea", max_length=20, description="Accent color (CSS hex)")
|
||
server_ids: Optional[List[int]] = Field(default_factory=list, description="Initial server IDs")
|
||
user_ids: Optional[List[int]] = Field(default_factory=list, description="Initial user IDs with access")
|
||
|
||
|
||
class WorkspaceUpdate(BaseModel):
|
||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||
description: Optional[str] = Field(None, max_length=500)
|
||
color: Optional[str] = Field(None, max_length=20)
|
||
|
||
|
||
class WorkspaceServerItem(BaseModel):
|
||
id: int
|
||
name: str
|
||
ip_address: str
|
||
is_online: bool
|
||
cluster_name: Optional[str] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class WorkspaceUserItem(BaseModel):
|
||
id: int
|
||
username: str
|
||
email: str
|
||
full_name: Optional[str] = None
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class WorkspaceResponse(BaseModel):
|
||
id: int
|
||
name: str
|
||
description: Optional[str] = None
|
||
color: Optional[str] = None
|
||
is_default: bool
|
||
server_count: int = 0
|
||
user_count: int = 0
|
||
created_at: datetime
|
||
|
||
model_config = {"from_attributes": True}
|
||
|
||
|
||
class WorkspaceDetail(WorkspaceResponse):
|
||
servers: List[WorkspaceServerItem] = []
|
||
users: List[WorkspaceUserItem] = []
|
||
|
||
|
||
# ==================== Network topology ====================
|
||
# Response shape for GET /proxmox/api/network/topology. Deliberately a domain
|
||
# hierarchy (panel → cluster → node → guest) rather than ready-made graph
|
||
# nodes/edges: the frontend derives edges from nesting, and the grouping
|
||
# threshold changes the rendered node count client-side without a round-trip.
|
||
# Fields the graph does not draw yet (bridges, nic.bridge/vlan_tag, tags) are
|
||
# the groundwork for the planned "group by VLAN / by tag" views.
|
||
|
||
|
||
class TopologyNic(BaseModel):
|
||
"""One guest network card, parsed out of its ``netN`` config string."""
|
||
|
||
key: str
|
||
model: Optional[str] = None
|
||
mac: Optional[str] = None
|
||
bridge: Optional[str] = None
|
||
vlan_tag: Optional[int] = None
|
||
trunks: List[int] = []
|
||
firewall: bool = False
|
||
link_down: bool = False
|
||
name: Optional[str] = None
|
||
ip: Optional[str] = None
|
||
gw: Optional[str] = None
|
||
rate: Optional[float] = None
|
||
mtu: Optional[int] = None
|
||
|
||
|
||
class TopologyBridge(BaseModel):
|
||
"""A bridge / bond / SDN vnet a guest can be attached to."""
|
||
|
||
name: str
|
||
type: Optional[str] = None
|
||
cidr: Optional[str] = None
|
||
vlan_aware: bool = False
|
||
ports: Optional[str] = None
|
||
active: bool = False
|
||
zone: Optional[str] = None
|
||
ipam_network_id: Optional[int] = None
|
||
ipam_cidr: Optional[str] = None
|
||
ipam_name: Optional[str] = None
|
||
|
||
|
||
class TopologyGuest(BaseModel):
|
||
id: str
|
||
server_id: int
|
||
node: str
|
||
vmid: int
|
||
type: str # "qemu" | "lxc"
|
||
name: str
|
||
status: str # running | stopped | paused | unknown
|
||
is_template: bool = False
|
||
lock: Optional[str] = None
|
||
cpu: Optional[float] = None
|
||
cores: Optional[int] = None
|
||
mem: Optional[int] = None
|
||
maxmem: Optional[int] = None
|
||
disk: Optional[int] = None
|
||
maxdisk: Optional[int] = None
|
||
uptime: Optional[int] = None
|
||
tags: List[str] = []
|
||
owner_id: Optional[int] = None
|
||
owner_username: Optional[str] = None
|
||
ip: Optional[str] = None
|
||
nics: List[TopologyNic] = []
|
||
|
||
|
||
class TopologyNode(BaseModel):
|
||
id: str
|
||
server_id: int
|
||
server_name: str
|
||
node: str
|
||
status: str # online | offline | unknown
|
||
stale: bool = False # True → rebuilt from cache, server unreachable
|
||
cpu: Optional[float] = None
|
||
maxcpu: Optional[int] = None
|
||
mem: Optional[int] = None
|
||
maxmem: Optional[int] = None
|
||
uptime: Optional[int] = None
|
||
bridges: List[TopologyBridge] = []
|
||
guests: List[TopologyGuest] = []
|
||
|
||
|
||
class TopologyCluster(BaseModel):
|
||
id: str
|
||
name: str
|
||
kind: str # "cluster" | "standalone"
|
||
server_ids: List[int] = []
|
||
online: bool = False
|
||
nodes: List[TopologyNode] = []
|
||
|
||
|
||
class TopologyWarning(BaseModel):
|
||
server_id: Optional[int] = None
|
||
server_name: Optional[str] = None
|
||
code: str # offline | no_credentials | error | nics_truncated
|
||
message: str
|
||
|
||
|
||
class TopologyPanelSummary(BaseModel):
|
||
name: str = "PVEmanager"
|
||
cluster_count: int = 0
|
||
node_count: int = 0
|
||
guest_count: int = 0
|
||
|
||
|
||
class NetworkTopologyResponse(BaseModel):
|
||
generated_at: datetime
|
||
panel: TopologyPanelSummary
|
||
clusters: List[TopologyCluster] = []
|
||
warnings: List[TopologyWarning] = []
|