9 Commits

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

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

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

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

Fixes found along the way:
- link-allocations registers every address of a guest, carries the MAC
  over, refuses to take an address away from another live guest and no
  longer matches allocations by resource name (which leaked an address
  between same-named guests on different servers)
- IPAM history no longer fails to serialise datetime values, which used
  to abort re-linking with a 500
2026-08-21 23:48:20 +05:00
c9d8e8207f fix(proxmox): read LXC addresses from netN and live interfaces
get_container_interfaces() looked for ipconfigN keys, which exist only on
QEMU cloud-init guests. LXC keeps the address inside the netN string, so
the method never returned anything and containers showed no IP at all.

Parse netN with the existing net_parser, fall back to
/nodes/{node}/lxc/{vmid}/interfaces for DHCP guests (skipped when the
config already carries a static address or the container is stopped), and
validate addresses so loopback, link-local and the literal "dhcp" are
dropped. IPv6 from ip6/inet6 is reported as well.
2026-08-21 23:47:45 +05:00
f9425082f7 chore: ignore local session summaries under docs/summaries
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m39s
Session write-ups are kept locally next to docs/plans and
docs/superpowers, not pushed to the repository.
2026-08-21 21:31:03 +05:00
dc53c415db docs: changelog entry for the LXC console fix and removed endpoint
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m40s
Flags the removal of GET /api/{server_id}/container/{vmid}/terminal as a
breaking change and documents the termproxy task failures and the black
console screen it came with.
2026-08-21 21:13:15 +05:00
7d60afb9fa fix(console): stop leaking termproxy sessions and orphaned terminals
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m22s
Deploy to Production / Trigger production update (push) Failing after 11s
SonarCloud / SonarCloud Analysis (push) Failing after 2m39s
Opening an LXC console left a failed task on the Proxmox node:

  failed waiting for client: timed out
  TASK ERROR: command '/usr/bin/termproxy 5900 ... vzctlconsole101' failed

connectTerminal() called GET /api/{sid}/container/{vid}/terminal first,
which creates a termproxy session on the node, but only the `node` field
of the response was used - port and ticket were discarded. The ws
endpoint then created a second termproxy session and connected to that
one. Nobody ever attached to the first, so Proxmox waited ~10s for a
client and failed the task.

The console also rendered a black screen in dev: React StrictMode mounts
the effect twice, connect* are async, and the effect cleanup ran before
they had assigned wsRef/termRef, so it had nothing to clean up. Two
xterm instances ended up in the same container - the first one filled
the viewport while the live one was pushed out of the overflow-hidden
area, which is why the badge read "connected" over an empty screen.

- drop the redundant REST call; the ws endpoint owns the termproxy
- remove the now unused GET /api/{server_id}/container/{vmid}/terminal
- add a per-session cancellation guard: a stale connect closes its own
  socket and disposes its own terminal without touching refs that may
  already point at the live session
- route mount and the reconnect button through a single startSession()
  that cancels and tears down the previous session first
- apply the same guard to the node shell page
2026-08-21 21:06:44 +05:00
30e92da8b5 fix(rbac): make network and IPAM access admin-only by default
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m26s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m46s
network:view is no longer implied by server:view, and neither network:view
nor ipam:view is granted to the non-admin default roles (operator, user,
demo). The network topology, node/SDN interface configuration and IPAM are
administrative surfaces, not part of the baseline "can see servers"
umbrella, so they should be granted explicitly rather than inherited.
2026-08-21 20:26:38 +05:00
markmorado
4f290b92fb feat: make topology guest cards link to the instance detail page
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 9s
SonarCloud / SonarCloud Analysis (push) Failing after 2m25s
Clicking a guest card now navigates to /instances/:serverId/:vmid, the
same route used from the Instances list, with a hover highlight to
signal it's clickable.
2026-08-20 14:32:42 +05:00
markmorado
295d3fc06e docs: add changelog and API reference entries for NFS export scan
Some checks failed
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Failing after 2m21s
Document the new GET /api/backups/scan-nfs/{server_id} endpoint and
the storage-add dialog's export scan button in CHANGELOG(.ru).md and
WIKI(.ru).md.
2026-08-20 10:25:17 +05:00
markmorado
31d17fca61 feat: scan NFS server for available exports in storage-add dialog
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Failing after 1m20s
Deploy to Production / Trigger production update (push) Failing after 10s
SonarCloud / SonarCloud Analysis (push) Has been cancelled
Add a "scan exports" button next to the server field for NFS storages
that calls Proxmox's /nodes/{node}/scan/nfs and turns the export path
input into a select populated with the real exports found on the
target server, instead of requiring the path to be typed by hand.
2026-08-20 10:23:04 +05:00
39 changed files with 3344 additions and 341 deletions

3
.gitignore vendored
View File

@@ -87,3 +87,6 @@ docs/superpowers/
# Планы разработки не коммитим в репозиторий # Планы разработки не коммитим в репозиторий
docs/plans/ docs/plans/
# Сводки сессий (локальные, не для репозитория)
docs/summaries/

View File

@@ -4,6 +4,23 @@ All notable changes to PVEmanager will be documented in this file.
--- ---
## [Unreleased]
### 🖥️ LXC console — failed termproxy tasks and the black screen
- **⚠️ Breaking change: `GET /api/{server_id}/container/{vmid}/terminal` has been removed.** The WebSocket endpoint `/proxmox/ws/terminal/{server_id}/{node}/{vmid}` creates the termproxy session itself, so the REST call was unused by the panel and left an orphaned session on the node on every invocation. External clients should open the WebSocket directly
- **Opening an LXC console no longer leaves a failed task on the node** — `failed waiting for client: timed out` / `TASK ERROR: command '/usr/bin/termproxy 5900 ... vzctlconsole<id>' failed`. The console page created a termproxy session over REST (using only the `node` field of the response and discarding port and ticket), then a second one over the WebSocket endpoint and attached to that one. Nobody ever attached to the first, so Proxmox waited ~10 s for a client and failed the task
- **Console no longer shows a black screen with a "connected" badge in dev** — React StrictMode mounts the effect twice and the connect helpers are async, so effect cleanup ran before they had assigned `wsRef`/`termRef` and cleaned up nothing. Two xterm instances ended up in the same container: the one filling the viewport was orphaned while the live one was pushed out of the overflow-hidden area. Sessions now carry a cancellation guard, and both mount and the reconnect button go through a single entry point that tears the previous session down first. The same guard was applied to the node shell page
---
### 🔍 NFS storage dialog — scan for available exports
- **Added a "scan exports" button** next to the server field when adding an NFS storage — calls Proxmox's `GET /nodes/{node}/scan/nfs` and turns the export path field from a free-text input into a select populated with the exports actually found on the target server, instead of requiring the path to be typed by hand and guessed
- New endpoint `GET /api/backups/scan-nfs/{server_id}?server=<ip>` on the backend, running the scan through the server's first available node
---
## [v1.17.0] - 2026-08-20 ## [v1.17.0] - 2026-08-20
### 💾 Move LXC container disks to another storage ### 💾 Move LXC container disks to another storage

View File

@@ -4,6 +4,23 @@
--- ---
## [Unreleased]
### 🖥️ Консоль LXC — ошибки termproxy и чёрный экран
- **⚠️ Ломающее изменение: эндпоинт `GET /api/{server_id}/container/{vmid}/terminal` удалён.** WebSocket-эндпоинт `/proxmox/ws/terminal/{server_id}/{node}/{vmid}` создаёт termproxy-сессию сам, поэтому REST-вызов не использовался панелью и при каждом вызове оставлял на ноде висячую сессию. Внешним клиентам следует открывать WebSocket напрямую
- **Открытие консоли LXC больше не оставляет на ноде задачу с ошибкой** — `failed waiting for client: timed out` / `TASK ERROR: command '/usr/bin/termproxy 5900 ... vzctlconsole<id>' failed`. Страница консоли создавала termproxy-сессию через REST (используя из ответа только поле `node` и выбрасывая порт и тикет), затем вторую через WebSocket-эндпоинт и подключалась ко второй. К первой не подключался никто — Proxmox ждал клиента ~10 секунд и завершал задачу ошибкой
- **Консоль больше не показывает чёрный экран при статусе «connected» в dev-режиме** — React StrictMode монтирует эффект дважды, а connect-функции асинхронны, поэтому cleanup эффекта срабатывал до того, как они записывали `wsRef`/`termRef`, и ничего не чистил. В одном контейнере оказывалось два xterm: видимый был осиротевшим, а живой уходил за границу области с `overflow-hidden`. Теперь у сессии есть флаг отмены, а монтирование и кнопка «Переподключиться» идут через единую точку входа, которая сначала гасит предыдущую сессию. Тот же флаг применён к странице shell ноды
---
### 🔍 Диалог NFS-хранилища — поиск доступных экспортов
- **Добавлена кнопка поиска экспортов** рядом с полем «Сервер» при добавлении NFS-хранилища — вызывает `GET /nodes/{node}/scan/nfs` Proxmox и превращает поле пути экспорта из текстового ввода в выпадающий список с реальными экспортами, найденными на целевом сервере, вместо ручного набора пути наугад
- Новый эндпоинт `GET /api/backups/scan-nfs/{server_id}?server=<ip>` на бэкенде — сканирование выполняется через первую доступную ноду сервера
---
## [v1.17.0] - 2026-08-20 ## [v1.17.0] - 2026-08-20
### 💾 Перенос дисков LXC-контейнера на другое хранилище ### 💾 Перенос дисков LXC-контейнера на другое хранилище

View File

@@ -262,6 +262,7 @@ This section summarizes key API endpoints. All require JWT authentication.
### Backups & Storages ### Backups & Storages
- `GET /api/backups/storages/{server_id}` — list storages - `GET /api/backups/storages/{server_id}` — list storages
- `GET /api/backups/scan-nfs/{server_id}?server=<ip>` — scan an NFS server for available exports
- `POST /api/backups/storages/{server_id}` — add storage - `POST /api/backups/storages/{server_id}` — add storage
- `PUT /api/backups/storages/{server_id}/{storage_id}` — update storage - `PUT /api/backups/storages/{server_id}/{storage_id}` — update storage
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — delete storage - `DELETE /api/backups/storages/{server_id}/{storage_id}` — delete storage
@@ -2173,6 +2174,7 @@ curl -H "Authorization: Bearer eyJ..." \
| GET | `/logs/api/logs` | Get audit logs | | GET | `/logs/api/logs` | Get audit logs |
| GET | `/logs/api/stats` | Get log statistics | | GET | `/logs/api/stats` | Get log statistics |
| GET | `/api/backups/storages/{server_id}` | List Proxmox storages | | GET | `/api/backups/storages/{server_id}` | List Proxmox storages |
| GET | `/api/backups/scan-nfs/{server_id}` | Scan NFS server for exports |
| POST | `/api/backups/create` | Create VM/LXC backup | | POST | `/api/backups/create` | Create VM/LXC backup |
| POST | `/api/backups/restore` | Restore VM/LXC from backup | | POST | `/api/backups/restore` | Restore VM/LXC from backup |
| GET | `/api/backups/list/{server_id}` | List backup files | | GET | `/api/backups/list/{server_id}` | List backup files |

View File

@@ -261,6 +261,7 @@ CORS_ORIGINS=*
### Резервные копии и хранилища ### Резервные копии и хранилища
- `GET /api/backups/storages/{server_id}` — список хранилищ - `GET /api/backups/storages/{server_id}` — список хранилищ
- `GET /api/backups/scan-nfs/{server_id}?server=<ip>` — сканировать NFS-сервер на доступные экспорты
- `POST /api/backups/storages/{server_id}` — добавить хранилище - `POST /api/backups/storages/{server_id}` — добавить хранилище
- `PUT /api/backups/storages/{server_id}/{storage_id}` — обновить хранилище - `PUT /api/backups/storages/{server_id}/{storage_id}` — обновить хранилище
- `DELETE /api/backups/storages/{server_id}/{storage_id}` — удалить хранилище - `DELETE /api/backups/storages/{server_id}/{storage_id}` — удалить хранилище
@@ -2086,6 +2087,7 @@ curl -H "Authorization: Bearer eyJ..." \
| GET | `/logs/api/logs` | Получить логи аудита | | GET | `/logs/api/logs` | Получить логи аудита |
| GET | `/logs/api/stats` | Статистика логов | | GET | `/logs/api/stats` | Статистика логов |
| GET | `/api/backups/storages/{server_id}` | Список хранилищ Proxmox | | GET | `/api/backups/storages/{server_id}` | Список хранилищ Proxmox |
| GET | `/api/backups/scan-nfs/{server_id}` | Сканировать NFS-сервер на экспорты |
| POST | `/api/backups/create` | Создать копию VM/LXC | | POST | `/api/backups/create` | Создать копию VM/LXC |
| POST | `/api/backups/restore` | Восстановить VM/LXC из копии | | POST | `/api/backups/restore` | Восстановить VM/LXC из копии |
| GET | `/api/backups/list/{server_id}` | Список файлов копий | | GET | `/api/backups/list/{server_id}` | Список файлов копий |

View File

@@ -5,7 +5,7 @@ IP Address Management endpoints for networks, pools, and allocations.
import logging import logging
import ipaddress as ipaddress_module import ipaddress as ipaddress_module
from typing import List, Optional from typing import List, NamedTuple, Optional
from pydantic import BaseModel from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy import or_ from sqlalchemy import or_
@@ -21,6 +21,8 @@ from ..schemas import (
IPAMHistoryResponse, IPAMAutoAllocateRequest IPAMHistoryResponse, IPAMAutoAllocateRequest
) )
from ..ipam_service import IPAMService 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 from ..auth import get_current_user, PermissionChecker
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -382,6 +384,8 @@ def get_allocations(
pool_id: Optional[int] = None, pool_id: Optional[int] = None,
status: Optional[str] = None, status: Optional[str] = None,
resource_type: Optional[str] = None, resource_type: Optional[str] = None,
proxmox_server_id: Optional[int] = None,
proxmox_vmid: Optional[int] = None,
search: Optional[str] = None, search: Optional[str] = None,
limit: int = Query(default=100, le=1000), limit: int = Query(default=100, le=1000),
offset: int = 0, offset: int = 0,
@@ -402,6 +406,13 @@ def get_allocations(
if resource_type: if resource_type:
query = query.filter(IPAMAllocation.resource_type == 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: if search:
search_filter = f"%{search}%" search_filter = f"%{search}%"
@@ -789,13 +800,29 @@ async def cleanup_orphan_allocations(
} }
def _extract_primary_ipv4(interfaces: list, parsed_networks: list): class DiscoveredAddress(NamedTuple):
"""Extract first IPv4 that belongs to any IPAM network from interface list.""" """Адрес гостя, найденный в одной из известных 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 iface in interfaces:
for ip_info in iface.get('ips', []): for ip_info in iface.get('ips', []):
if ip_info.get('type') != 'ipv4': if ip_info.get('type') != 'ipv4':
continue continue
addr = ip_info.get('address', '') addr = ip_info.get('address', '')
if not addr or addr in seen:
continue
try: try:
ip_obj = ipaddress_module.ip_address(addr) ip_obj = ipaddress_module.ip_address(addr)
except ValueError: except ValueError:
@@ -804,8 +831,15 @@ def _extract_primary_ipv4(interfaces: list, parsed_networks: list):
continue continue
for net, net_obj in parsed_networks: for net, net_obj in parsed_networks:
if ip_obj in net_obj: if ip_obj in net_obj:
return addr, net seen.add(addr)
return None, None 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") @router.get("/api/unlinked")
@@ -814,21 +848,26 @@ def get_unlinked_allocations(
current_user: User = Depends(PermissionChecker("ipam:manage")) current_user: User = Depends(PermissionChecker("ipam:manage"))
): ):
""" """
Return VM instances that have no IPAM allocation linked to them (by vmid+server_id). Return VM instances whose current address is not registered in IPAM.
Only VMs whose cached ip_address falls within an active IPAM network are included.
У гостя может быть несколько адресов, поэтому «связан» определяется не по
наличию хоть какой-то аллокации, а по конкретному адресу: если в кэше
инстанса стоит IP, которого нет среди его аллокаций, гость показывается
как несвязанный. Учитываются только адреса из активных IPAM-сетей.
""" """
unlinked = [] unlinked = []
# All live VMs # All live VMs
vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all() vms = db.query(VMInstance).filter(VMInstance.deleted_at.is_(None)).all()
# Pre-load all linked (proxmox_server_id, proxmox_vmid) pairs for fast lookup # (server_id, vmid) -> множество уже учтённых адресов гостя
linked_pairs = set( linked_ips = {}
(a.proxmox_server_id, a.proxmox_vmid) for alloc in db.query(
for a in db.query(IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid).filter( IPAMAllocation.proxmox_server_id, IPAMAllocation.proxmox_vmid, IPAMAllocation.ip_address
IPAMAllocation.proxmox_vmid.isnot(None) ).filter(IPAMAllocation.proxmox_vmid.isnot(None)).all():
).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()} servers_map = {s.id: s for s in db.query(ProxmoxServer).all()}
@@ -841,12 +880,11 @@ def get_unlinked_allocations(
pass pass
for vm in vms: for vm in vms:
if (vm.server_id, vm.vmid) in linked_pairs:
continue
# Skip VMs with no IP or an IP outside all IPAM networks # Skip VMs with no IP or an IP outside all IPAM networks
if not vm.ip_address: if not vm.ip_address:
continue continue
if vm.ip_address in linked_ips.get((vm.server_id, vm.vmid), ()):
continue
try: try:
vm_ip = ipaddress_module.ip_address(vm.ip_address) vm_ip = ipaddress_module.ip_address(vm.ip_address)
if not any(vm_ip in net for net in ipam_nets): if not any(vm_ip in net for net in ipam_nets):
@@ -972,9 +1010,9 @@ def link_allocations_to_vms(
continue continue
for vm in server_vms: for vm in server_vms:
# Skip already linked # Уже связанных больше не пропускаем: у гостя может появиться
if (server_id, vm.vmid) in linked_pairs: # второй адрес или смениться существующий — и то, и другое раньше
continue # оставалось незамеченным навсегда.
# For this VM, further filter networks by proxmox_node if set # For this VM, further filter networks by proxmox_node if set
# (e.g. network tagged to pve1 should not match VMs on pve2) # (e.g. network tagged to pve1 should not match VMs on pve2)
@@ -997,40 +1035,59 @@ def link_allocations_to_vms(
logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}") logger.debug(f"[IPAM link] Cannot get interfaces for {vm.name}/{vm.vmid}: {e}")
interfaces = [] interfaces = []
# Find first IPv4 in one of this VM's applicable IPAM networks # Все IPv4 гостя, попадающие в его IPAM-сети
ip_found, matching_network = _extract_primary_ipv4(interfaces, vm_networks) discovered = _extract_ipv4s(interfaces, vm_networks)
if not ip_found: if not discovered:
not_found.append({'ip_address': '-', 'resource_name': vm.name, not_found.append({'ip_address': '-', 'resource_name': vm.name,
'reason': 'no_ip_in_ipam_network'}) 'reason': 'no_ip_in_ipam_network'})
continue continue
alloc, error = service.sync_from_proxmox_vm( already_linked = {
network_id=matching_network.id, a.ip_address for a in
proxmox_server_id=server_id, service.find_allocations_by_resource(server_id, vm.vmid)
vmid=vm.vmid, }
vm_name=vm.name,
vm_type=vm.vm_type,
ip_address=ip_found,
node=vm.node,
synced_by=synced_by
)
if alloc: for found in discovered:
# Update the cached ip_address in vm_instances as well if found.ip in already_linked:
vm.ip_address = ip_found continue # этот адрес гостя уже учтён
linked_pairs.add((server_id, vm.vmid)) # avoid double-linking
linked.append({ alloc, error = service.sync_from_proxmox_vm(
'ip_address': ip_found, network_id=found.network.id,
'resource_name': vm.name, proxmox_server_id=server_id,
'vmid': vm.vmid, vmid=vm.vmid,
'server_id': server_id vm_name=vm.name,
}) vm_type=vm.vm_type,
logger.info(f"[IPAM link] Linked {ip_found} -> VM {vm.vmid} ({vm.name}) on {server.name}") ip_address=found.ip,
else: node=vm.node,
not_found.append({'ip_address': ip_found, 'resource_name': vm.name, mac_address=found.mac,
'reason': error or 'sync_error'}) synced_by=synced_by,
logger.warning(f"[IPAM link] Failed {ip_found} for {vm.name}: {error}") 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: if linked:
try: try:
@@ -1047,3 +1104,261 @@ def link_allocations_to_vms(
'not_found_count': len(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,
}

View File

@@ -53,6 +53,35 @@ def get_storages(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/backups/scan-nfs/{server_id}")
def scan_nfs(
server_id: int,
server: str,
node: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("backup:manage")),
):
"""Scan an NFS server for available exports, to populate the storage-add dialog."""
pve_server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not pve_server:
raise HTTPException(status_code=404, detail="Server not found")
try:
client = _get_proxmox_client(pve_server)
target_node = node
if not target_node:
nodes = client.get_nodes()
if not nodes:
raise HTTPException(status_code=400, detail="No nodes available to scan from")
target_node = nodes[0]["node"]
exports = client.scan_nfs_exports(target_node, server)
return JSONResponse(content={"exports": exports})
except HTTPException:
raise
except Exception as e:
logger.error(f"Error scanning NFS exports on {server} for server {server_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/backups/storages/{server_id}") @router.post("/api/backups/storages/{server_id}")
async def create_storage( async def create_storage(
server_id: int, server_id: int,

View File

@@ -164,20 +164,26 @@ def get_all_virtual_machines(
ipam_networks = db.query(IPAMNetwork).all() ipam_networks = db.query(IPAMNetwork).all()
network_map = {n.id: n for n in ipam_networks} network_map = {n.id: n for n in ipam_networks}
# Build IPAM lookups # Build IPAM lookups.
# Только по идентификатору гостя: сопоставление по имени давало чужой адрес
# одноимённым гостям на разных серверах (два разных "wireguard").
# У гостя может быть несколько адресов: в ipam_by_vmid кладём основной
# (он идёт в колонку IP), в ipam_all_by_vmid — весь список для поля ips.
ipam_by_vmid = {} ipam_by_vmid = {}
ipam_by_name = {} ipam_all_by_vmid = {}
for alloc in ipam_allocations: for alloc in ipam_allocations:
if alloc.proxmox_server_id and alloc.proxmox_vmid: if alloc.proxmox_server_id and alloc.proxmox_vmid:
ipam_by_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
ipam_all_by_vmid.setdefault(key, []).append(alloc)
current = ipam_by_vmid.get(key)
# Основной — тот, у кого стоит флаг; иначе самая ранняя запись
# (аллокации старше миграции multi-IP флага не имеют).
if current is None or (alloc.is_primary and not current.is_primary):
ipam_by_vmid[key] = alloc
elif alloc.resource_id: elif alloc.resource_id:
ipam_by_vmid[(None, alloc.resource_id)] = alloc ipam_by_vmid.setdefault((None, alloc.resource_id), alloc)
if alloc.hostname:
ipam_by_name[alloc.hostname.lower()] = alloc
if alloc.resource_name:
ipam_by_name[alloc.resource_name.lower()] = alloc
# Build base query for cached VMs (not deleted, not templates) # Build base query for cached VMs (not deleted, not templates)
# joinedload(owner) avoids an N+1 query when rendering the owner column. # joinedload(owner) avoids an N+1 query when rendering the owner column.
query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter( query = db.query(VMInstance).options(joinedload(VMInstance.owner)).filter(
@@ -267,14 +273,29 @@ def get_all_virtual_machines(
# Get IP from IPAM or cache # Get IP from IPAM or cache
ipam_alloc = ( ipam_alloc = (
ipam_by_vmid.get((vm.server_id, vm.vmid)) or ipam_by_vmid.get((vm.server_id, vm.vmid)) or
ipam_by_vmid.get((None, vm.vmid)) or ipam_by_vmid.get((None, vm.vmid))
ipam_by_name.get(vm.name.lower())
) )
ip_address = ipam_alloc.ip_address if ipam_alloc else (vm.ip_address or "") ip_address = ipam_alloc.ip_address if ipam_alloc else (vm.ip_address or "")
ip_hostname = ipam_alloc.hostname if ipam_alloc else "" ip_hostname = ipam_alloc.hostname if ipam_alloc else ""
owner = ipam_alloc.allocated_by if ipam_alloc else "" owner = ipam_alloc.allocated_by if ipam_alloc else ""
# Полный список адресов гостя: основной первым, дальше алиасы
guest_allocs = sorted(
ipam_all_by_vmid.get((vm.server_id, vm.vmid), []),
key=lambda a: (not bool(a.is_primary), a.id),
)
ip_list = [
{
"ip": a.ip_address,
"is_primary": bool(a.is_primary),
"assignment_kind": a.assignment_kind,
"target_interface": a.target_interface,
"apply_status": a.apply_status,
}
for a in guest_allocs
]
# Get network name from IPAM # Get network name from IPAM
ip_network_name = "" ip_network_name = ""
@@ -312,8 +333,11 @@ def get_all_virtual_machines(
"cores": vm.cores or 0, "cores": vm.cores or 0,
"memory": vm.memory or 0, "memory": vm.memory or 0,
"disk": live.get('disk') or 0, "disk": live.get('disk') or 0,
# ip/ip_address — основной адрес (обратная совместимость),
# ips — все адреса гостя
"ip": ip_address, "ip": ip_address,
"ip_address": ip_address, "ip_address": ip_address,
"ips": ip_list,
"ip_hostname": ip_network_name, "ip_hostname": ip_network_name,
"os": os_template, "os": os_template,
"os_template": os_template, "os_template": os_template,
@@ -2884,79 +2908,6 @@ def get_vm_terminal(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/{server_id}/container/{vmid}/terminal")
def get_container_terminal(
server_id: int,
vmid: int,
node: str,
db: Session = Depends(get_db),
current_user: User = Depends(PermissionChecker("vm:console"))
):
"""Получить данные для xterm.js терминального подключения к LXC контейнеру"""
import requests
# VPS-style user isolation: check container ownership for limited users
require_vm_access(db, current_user, server_id, vmid)
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail="Proxmox server not found")
try:
password_to_use = server.password if server.password else None
auth_username = server.api_user.split("!")[0] if "!" in server.api_user else server.api_user
if not password_to_use:
raise HTTPException(status_code=400, detail="Terminal requires password authentication")
# 1. Получаем auth ticket
auth_response = requests.post(
f"https://{server.ip_address}:8006/api2/json/access/ticket",
data={"username": auth_username, "password": password_to_use},
verify=server.verify_ssl,
timeout=10
)
if auth_response.status_code != 200:
raise HTTPException(status_code=401, detail="Failed to authenticate to Proxmox")
auth_data = auth_response.json().get("data", {})
auth_ticket = auth_data.get("ticket")
csrf_token = auth_data.get("CSRFPreventionToken")
# 2. Создаём terminal proxy
term_response = requests.post(
f"https://{server.ip_address}:8006/api2/json/nodes/{node}/lxc/{vmid}/termproxy",
headers={"CSRFPreventionToken": csrf_token},
cookies={"PVEAuthCookie": auth_ticket},
verify=server.verify_ssl,
timeout=10
)
if term_response.status_code != 200:
raise HTTPException(status_code=500, detail=f"Failed to create terminal proxy: {term_response.text}")
term_data = term_response.json().get("data", {})
response_data = {
'port': term_data.get('port'),
'ticket': term_data.get('ticket'),
'host': server.ip_address,
'node': node,
'vmid': vmid,
'type': 'lxc',
'auth_ticket': auth_ticket
}
logger.info(f"User {current_user.username} opened terminal for LXC {vmid}")
return JSONResponse(content=response_data)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting terminal for LXC {vmid}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/{server_id}/container/{vmid}/exec") @router.post("/api/{server_id}/container/{vmid}/exec")
async def exec_in_container( async def exec_in_container(
server_id: int, server_id: int,

View File

@@ -10,7 +10,9 @@ from datetime import datetime, timezone
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import and_, or_, text from sqlalchemy import and_, or_, text
from .models import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory from .models import (
IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory, VMInstance, ProxmoxServer
)
def utcnow() -> datetime: def utcnow() -> datetime:
@@ -18,6 +20,21 @@ def utcnow() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _json_safe(value: Any) -> Any:
"""Привести значение к JSON-сериализуемому виду для колонок истории.
old_value/new_value — JSON-колонки, а в diff попадают и datetime-поля
(last_seen, expires_at): без конвертации INSERT падает на json.dumps.
"""
if isinstance(value, dict):
return {key: _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
if isinstance(value, datetime):
return value.isoformat()
return value
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -142,7 +159,8 @@ class IPAMService:
self, self,
network_id: int, network_id: int,
pool_id: Optional[int] = None, pool_id: Optional[int] = None,
prefer_sequential: bool = True prefer_sequential: bool = True,
allow_network_scan: bool = False
) -> Optional[str]: ) -> Optional[str]:
""" """
Get the next available IP address from a network or specific pool. Get the next available IP address from a network or specific pool.
@@ -151,6 +169,9 @@ class IPAMService:
network_id: Network ID network_id: Network ID
pool_id: Optional specific pool ID pool_id: Optional specific pool ID
prefer_sequential: If True, prefer sequential IPs; if False, can use any gap prefer_sequential: If True, prefer sequential IPs; if False, can use any gap
allow_network_scan: искать по всей подсети, когда auto-assign пулов
нет. Нужно там, где адрес выдаётся точечно (доп. IP инстанса),
а не по политике пула.
Returns: Returns:
Available IP address string or None if no IPs available Available IP address string or None if no IPs available
@@ -179,7 +200,7 @@ class IPAMService:
IPAMPool.auto_assign == True IPAMPool.auto_assign == True
).order_by(IPAMPool.id).all() ).order_by(IPAMPool.id).all()
if not pools: if not pools and not allow_network_scan:
logger.warning(f"No active auto-assign pools found for network {network_id}") logger.warning(f"No active auto-assign pools found for network {network_id}")
return None return None
@@ -213,7 +234,34 @@ class IPAMService:
except ValueError as e: except ValueError as e:
logger.error(f"Invalid IP range in pool {pool.id}: {e}") logger.error(f"Invalid IP range in pool {pool.id}: {e}")
continue continue
# Пулов нет (или в них всё занято) — при явном разрешении идём по всей
# подсети: адрес и сеть/broadcast исключает сам ip_network.hosts().
if allow_network_scan and not pool_id:
# Вне пула в подсети живут адреса, которых нет в IPAM: сами ноды
# Proxmox и гости, чей адрес известен панели только из кэша.
# Без этого скан спокойно предложил бы адрес работающей ноды.
known = set(occupied)
known.update(
row[0] for row in self.db.query(VMInstance.ip_address).filter(
VMInstance.ip_address.isnot(None),
VMInstance.deleted_at.is_(None)
).all() if row[0]
)
known.update(
row[0] for row in self.db.query(ProxmoxServer.ip_address).filter(
ProxmoxServer.ip_address.isnot(None)
).all() if row[0]
)
try:
for ip_obj in ipaddress.ip_network(network.network, strict=False).hosts():
ip = str(ip_obj)
if ip not in known:
logger.info(f"Found available IP: {ip} in network {network.network}")
return ip
except ValueError as e:
logger.error(f"Invalid network {network.network}: {e}")
logger.warning(f"No available IPs found in network {network_id}") logger.warning(f"No available IPs found in network {network_id}")
return None return None
@@ -234,7 +282,11 @@ class IPAMService:
allocation_type: str = "static", allocation_type: str = "static",
status: str = "allocated", status: str = "allocated",
allocated_by: Optional[str] = None, allocated_by: Optional[str] = None,
notes: Optional[str] = None notes: Optional[str] = None,
is_primary: bool = False,
assignment_kind: str = "primary",
target_interface: Optional[str] = None,
apply_status: Optional[str] = None
) -> Tuple[Optional[IPAMAllocation], Optional[str]]: ) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
""" """
Allocate an IP address. Allocate an IP address.
@@ -310,7 +362,11 @@ class IPAMService:
hostname=hostname, hostname=hostname,
fqdn=fqdn, fqdn=fqdn,
allocated_by=allocated_by, allocated_by=allocated_by,
notes=notes notes=notes,
is_primary=is_primary,
assignment_kind=assignment_kind,
target_interface=target_interface,
apply_status=apply_status
) )
self.db.add(allocation) self.db.add(allocation)
@@ -436,46 +492,51 @@ class IPAMService:
reason: Optional[str] = None reason: Optional[str] = None
) -> Tuple[bool, Optional[str]]: ) -> Tuple[bool, Optional[str]]:
""" """
Release IP allocation by Proxmox VM/container ID. Release every IP allocation of a Proxmox VM/container.
Used when VM/container is deleted. Used when the VM/container is deleted.
Args: У гостя может быть несколько адресов (основной + алиасы), поэтому
proxmox_server_id: Proxmox server ID освобождаются все: иначе после удаления гостя часть адресов навсегда
proxmox_vmid: VM/container ID оставалась бы занятой.
released_by: Username performing the release
reason: Reason for release
Returns: Returns:
Tuple of (success, error message or released IP) Tuple of (success, comma-separated released IPs or error message)
""" """
# First try to find by proxmox_server_id and proxmox_vmid allocations = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
allocation = self.find_allocation_by_resource(proxmox_server_id, proxmox_vmid)
# Fallback: search by resource_id if proxmox fields are not set. # Fallback: search by resource_id if proxmox fields are not set.
# ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление # ВАЖНО: только среди записей БЕЗ proxmox_server_id — иначе удаление
# VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid # VM с vmid=100 на сервере A освобождало IP виртуалки с тем же vmid
# на сервере B (записи с заполненным server_id находит основной поиск). # на сервере B (записи с заполненным server_id находит основной поиск).
if not allocation: if not allocations:
allocation = self.db.query(IPAMAllocation).filter( allocations = self.db.query(IPAMAllocation).filter(
IPAMAllocation.resource_id == proxmox_vmid, IPAMAllocation.resource_id == proxmox_vmid,
IPAMAllocation.proxmox_server_id.is_(None), IPAMAllocation.proxmox_server_id.is_(None),
IPAMAllocation.status.in_(['allocated', 'reserved']) IPAMAllocation.status.in_(['allocated', 'reserved'])
).first() ).all()
if allocation: if allocations:
logger.debug(f"Found allocation by resource_id={proxmox_vmid} (legacy)") logger.debug(f"Found allocation(s) by resource_id={proxmox_vmid} (legacy)")
if not allocation: if not allocations:
logger.debug(f"No IPAM allocation found for server {proxmox_server_id}, vmid {proxmox_vmid}") logger.debug(f"No IPAM allocation found for server {proxmox_server_id}, vmid {proxmox_vmid}")
return False, None # Not an error, just no allocation to release return False, None # Not an error, just no allocation to release
ip_address = allocation.ip_address released, errors = [], []
success, error = self.release_ip(ip_address, released_by, reason) for allocation in allocations:
ip_address = allocation.ip_address
if success: success, error = self.release_ip(ip_address, released_by, reason)
logger.info(f"Auto-released IP {ip_address} for deleted VM/container {proxmox_vmid}") if success:
return True, ip_address released.append(ip_address)
else:
return False, error errors.append(error or ip_address)
if released:
logger.info(
f"Auto-released IP(s) {', '.join(released)} for deleted VM/container {proxmox_vmid}"
)
return True, ", ".join(released)
return False, "; ".join(errors) if errors else None
def update_allocation( def update_allocation(
self, self,
@@ -537,17 +598,65 @@ class IPAMService:
return allocation, None return allocation, None
def find_allocations_by_resource(
self,
proxmox_server_id: int,
proxmox_vmid: int
) -> List[IPAMAllocation]:
"""Все активные адреса гостя: основной первым, дальше по дате выдачи."""
rows = self.db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == proxmox_server_id,
IPAMAllocation.proxmox_vmid == proxmox_vmid,
IPAMAllocation.status.in_(['allocated', 'reserved'])
).all()
return sorted(rows, key=lambda a: (not bool(a.is_primary), a.id))
def find_allocation_by_resource( def find_allocation_by_resource(
self, self,
proxmox_server_id: int, proxmox_server_id: int,
proxmox_vmid: int proxmox_vmid: int
) -> Optional[IPAMAllocation]: ) -> Optional[IPAMAllocation]:
"""Find active allocation by Proxmox VM/container""" """Основной адрес гостя (у гостя их может быть несколько).
return self.db.query(IPAMAllocation).filter(
Если флаг is_primary нигде не проставлен — например, записи старше
миграции multi-IP, — берём самую раннюю аллокацию.
"""
rows = self.find_allocations_by_resource(proxmox_server_id, proxmox_vmid)
return rows[0] if rows else None
def set_primary(
self,
proxmox_server_id: int,
proxmox_vmid: int,
allocation_id: int,
changed_by: Optional[str] = None
) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
"""Сделать адрес основным, сняв флаг с прежнего. Основной ровно один."""
rows = self.db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == proxmox_server_id, IPAMAllocation.proxmox_server_id == proxmox_server_id,
IPAMAllocation.proxmox_vmid == proxmox_vmid, IPAMAllocation.proxmox_vmid == proxmox_vmid
IPAMAllocation.status.in_(['allocated', 'reserved']) ).all()
).first() target = next((a for a in rows if a.id == allocation_id), None)
if not target:
return None, "Allocation does not belong to this guest"
for row in rows:
row.is_primary = (row.id == allocation_id)
self._log_history(
ip_address=target.ip_address,
network_id=target.network_id,
action="modified",
new_value={'is_primary': True},
resource_type=target.resource_type,
resource_id=target.resource_id,
resource_name=target.resource_name,
performed_by=changed_by,
notes="Marked as primary address"
)
self.db.commit()
self.db.refresh(target)
return target, None
def release_all_by_server(self, proxmox_server_id: int, released_by: str = None) -> int: def release_all_by_server(self, proxmox_server_id: int, released_by: str = None) -> int:
""" """
@@ -675,7 +784,17 @@ class IPAMService:
return True, None return True, None
# ==================== Sync & Scan Operations ==================== # ==================== Sync & Scan Operations ====================
def _guest_exists(self, server_id: Optional[int], vmid: Optional[int]) -> bool:
"""Существует ли ещё гость, за которым числится аллокация."""
if server_id is None or vmid is None:
return False
return self.db.query(VMInstance.id).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None)
).first() is not None
def sync_from_proxmox_vm( def sync_from_proxmox_vm(
self, self,
network_id: int, network_id: int,
@@ -686,18 +805,36 @@ class IPAMService:
ip_address: str, ip_address: str,
node: str, node: str,
mac_address: Optional[str] = None, mac_address: Optional[str] = None,
synced_by: str = "system" synced_by: str = "system",
target_interface: Optional[str] = None,
assignment_kind: str = "primary"
) -> Tuple[Optional[IPAMAllocation], Optional[str]]: ) -> Tuple[Optional[IPAMAllocation], Optional[str]]:
""" """
Sync/create allocation for a Proxmox VM. Sync/create allocation for a Proxmox VM.
Called when discovering VMs from Proxmox. Called when discovering VMs from Proxmox.
Гость может иметь несколько адресов: первый заведённый становится
основным, последующие — обычными записями того же гостя.
""" """
# Check if already allocated # Check if already allocated
existing = self.db.query(IPAMAllocation).filter( existing = self.db.query(IPAMAllocation).filter(
IPAMAllocation.ip_address == ip_address IPAMAllocation.ip_address == ip_address
).first() ).first()
if existing: if existing:
# Два живых гостя с одним адресом (например, устаревший ip= в конфиге
# остановленного контейнера) не должны молча отбирать аллокацию друг
# у друга: владелец сохраняется, конфликт возвращается вызывающему.
belongs_to_other = (
existing.proxmox_vmid is not None
and (existing.proxmox_server_id, existing.proxmox_vmid) != (proxmox_server_id, vmid)
)
if belongs_to_other and self._guest_exists(existing.proxmox_server_id, existing.proxmox_vmid):
return None, (
f"IP {ip_address} is already linked to "
f"{existing.resource_name or 'unknown'} (VMID {existing.proxmox_vmid}) — "
f"duplicate address, resolve it in Proxmox first"
)
# Update existing allocation # Update existing allocation
if existing.proxmox_vmid != vmid or existing.proxmox_server_id != proxmox_server_id: if existing.proxmox_vmid != vmid or existing.proxmox_server_id != proxmox_server_id:
return self.update_allocation( return self.update_allocation(
@@ -725,7 +862,14 @@ class IPAMService:
self.db.commit() self.db.commit()
return existing, None return existing, None
# Create new allocation # Create new allocation. Основным делаем первый адрес гостя —
# именно он попадёт в колонку IP списка инстансов.
has_primary = self.db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == proxmox_server_id,
IPAMAllocation.proxmox_vmid == vmid,
IPAMAllocation.is_primary.is_(True)
).first() is not None
return self.allocate_ip( return self.allocate_ip(
ip_address=ip_address, ip_address=ip_address,
network_id=network_id, network_id=network_id,
@@ -736,7 +880,10 @@ class IPAMService:
proxmox_node=node, proxmox_node=node,
mac_address=mac_address, mac_address=mac_address,
allocated_by=synced_by, allocated_by=synced_by,
notes="Auto-synced from Proxmox" notes="Auto-synced from Proxmox",
is_primary=not has_primary,
assignment_kind="primary" if not has_primary else assignment_kind,
target_interface=target_interface
) )
def detect_conflicts(self, network_id: Optional[int] = None) -> List[Dict[str, Any]]: def detect_conflicts(self, network_id: Optional[int] = None) -> List[Dict[str, Any]]:
@@ -801,8 +948,8 @@ class IPAMService:
ip_address=ip_address, ip_address=ip_address,
network_id=network_id, network_id=network_id,
action=action, action=action,
old_value=old_value, old_value=_json_safe(old_value),
new_value=new_value, new_value=_json_safe(new_value),
resource_type=resource_type, resource_type=resource_type,
resource_id=resource_id, resource_id=resource_id,
resource_name=resource_name, resource_name=resource_name,

View File

@@ -87,6 +87,21 @@ class IPAMAllocation(Base):
status = Column(String(20), default="allocated", nullable=False) status = Column(String(20), default="allocated", nullable=False)
allocation_type = Column(String(20), default="static", nullable=False) allocation_type = Column(String(20), default="static", nullable=False)
# Гость может держать несколько адресов: ровно один из них основной
# (живёт в net0/ipconfig0 и показывается в колонке IP), остальные
# навешиваются алиасами на интерфейс уже внутри гостя.
is_primary = Column(Boolean, default=False, nullable=False)
assignment_kind = Column(String(10), default="primary", nullable=False) # primary | alias
target_interface = Column(String(20), nullable=True) # eth0, ens18, ...
# Состояние применения алиаса к живому гостю.
# applied — поднят и закреплён в конфиге ОС; runtime_only — поднят, но
# стек не распознан и ребут его не переживёт; pending — гость был
# недоступен; failed — скрипт вернул ошибку.
apply_status = Column(String(20), nullable=True)
apply_error = Column(Text, nullable=True)
applied_at = Column(DateTime(timezone=True), nullable=True)
hostname = Column(String(255), nullable=True, index=True) hostname = Column(String(255), nullable=True, index=True)
fqdn = Column(String(255), nullable=True) fqdn = Column(String(255), nullable=True)
@@ -106,6 +121,7 @@ class IPAMAllocation(Base):
Index('idx_ipam_alloc_network', 'network_id'), Index('idx_ipam_alloc_network', 'network_id'),
Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'), Index('idx_ipam_alloc_resource', 'resource_type', 'resource_id'),
Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'), Index('idx_ipam_alloc_proxmox', 'proxmox_server_id', 'proxmox_vmid'),
Index('idx_ipam_alloc_primary', 'proxmox_server_id', 'proxmox_vmid', 'is_primary'),
Index('idx_ipam_alloc_status', 'status'), Index('idx_ipam_alloc_status', 'status'),
) )

View File

@@ -1,4 +1,5 @@
import time import time
import ipaddress
import urllib3 import urllib3
import asyncio import asyncio
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
@@ -10,6 +11,7 @@ from functools import lru_cache
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
import base64 import base64
from .net_parser import parse_guest_nics
from .mixins.vm import VmMixin from .mixins.vm import VmMixin
from .mixins.lxc import LxcMixin from .mixins.lxc import LxcMixin
from .mixins.cluster import ClusterMixin from .mixins.cluster import ClusterMixin
@@ -1642,62 +1644,89 @@ class ProxmoxClient(VmMixin, LxcMixin, ClusterMixin, StorageMixin, NetworkMixin,
logger.debug(f"netstat unavailable for node {node}: {e}") logger.debug(f"netstat unavailable for node {node}: {e}")
return [] return []
def get_container_interfaces(self, node: str, vmid: int) -> List[Dict]: def get_container_interfaces(self, node: str, vmid: int,
include_live: bool = True) -> List[Dict]:
""" """
Получить сетевые интерфейсы контейнера Получить сетевые интерфейсы контейнера с их IP адресами.
LXC держит адрес внутри строки netN
("name=eth0,bridge=vmbr0,hwaddr=..,ip=10.0.0.5/24"); ключа ipconfigN у
контейнеров не бывает — это cloud-init QEMU. При ip=dhcp статики в
конфиге нет вовсе, поэтому фактические адреса запущенного контейнера
дочитываются из /nodes/{node}/lxc/{vmid}/interfaces.
Args: Args:
node: Имя ноды node: Имя ноды
vmid: ID контейнера vmid: ID контейнера
include_live: опрашивать ли /interfaces (бесполезно для остановленного CT)
Returns: Returns:
Список интерфейсов с IP адресами Список интерфейсов: {'name', 'hardware_address', 'ips': [{'address', 'type', 'prefix'}]}
""" """
if not self.proxmox: if not self.proxmox:
return [] return []
interfaces: Dict[str, Dict] = {}
def _slot(name: str, mac: str = '') -> Dict:
iface = interfaces.setdefault(
name, {'name': name, 'hardware_address': '', 'ips': []}
)
if mac and not iface['hardware_address']:
iface['hardware_address'] = mac
return iface
def _add_ip(iface: Dict, value: str) -> None:
"""Принимает '10.0.0.5/24' либо '10.0.0.5'; мусор и dhcp игнорирует."""
addr, _, prefix = (value or '').strip().partition('/')
if not addr or addr in ('dhcp', 'manual', 'auto'):
return
try:
ip_obj = ipaddress.ip_address(addr)
except ValueError:
return
if ip_obj.is_loopback or ip_obj.is_link_local:
return
if any(existing['address'] == addr for existing in iface['ips']):
return
iface['ips'].append({
'address': addr,
'type': 'ipv6' if ip_obj.version == 6 else 'ipv4',
'prefix': int(prefix) if prefix.isdigit() else (128 if ip_obj.version == 6 else 32),
})
# 1) Статика из конфига контейнера
try: try:
# Для LXC можем получить IP из конфигурации
config = self.proxmox.nodes(node).lxc(vmid).config.get() config = self.proxmox.nodes(node).lxc(vmid).config.get()
interfaces = [] for nic in parse_guest_nics(config):
iface = _slot(nic.name or f'eth{nic.index}', nic.mac or '')
# Проверяем net0, net1 и т.д. _add_ip(iface, nic.ip or '')
for i in range(10): _add_ip(iface, nic.ip6 or '')
net_key = f'net{i}'
if net_key in config:
net_config = config[net_key]
# Парсим строку конфигурации сети
iface_info = {'name': f'eth{i}', 'ips': []}
# Пытаемся извлечь MAC адрес
if 'hwaddr=' in net_config:
mac = net_config.split('hwaddr=')[1].split(',')[0]
iface_info['hardware_address'] = mac
interfaces.append(iface_info)
# Также проверяем IP из конфигурации
for i in range(10):
ipconfig_key = f'ipconfig{i}'
if ipconfig_key in config and interfaces and i < len(interfaces):
ipconfig = config[ipconfig_key]
# Парсим ip=192.168.1.10/24,gw=192.168.1.1
if 'ip=' in ipconfig:
ip_part = ipconfig.split('ip=')[1].split(',')[0]
if ip_part != 'dhcp':
# Разделяем IP и префикс
if '/' in ip_part:
ip, prefix = ip_part.split('/')
interfaces[i]['ips'].append({
'address': ip,
'type': 'ipv4',
'prefix': int(prefix)
})
return [iface for iface in interfaces if iface.get('ips')]
except Exception as e: except Exception as e:
logger.debug(f"Не удалось получить сетевые интерфейсы контейнера {vmid}: {e}") logger.debug(f"Не удалось прочитать конфиг контейнера {vmid}: {e}")
return []
# 2) Живые адреса — единственный источник для DHCP-контейнеров.
# Если статика уже найдена, лишний запрос не делаем: у остановленного
# контейнера этот эндпоинт всё равно отдаёт ошибку.
has_static_v4 = any(
ip_info['type'] == 'ipv4'
for iface in interfaces.values() for ip_info in iface['ips']
)
if not has_static_v4 and include_live:
try:
for iface_data in (self.proxmox.nodes(node).lxc(vmid).interfaces.get() or []):
name = iface_data.get('name') or ''
if not name or name == 'lo':
continue
iface = _slot(name, (iface_data.get('hwaddr') or '').upper())
_add_ip(iface, iface_data.get('inet') or '')
_add_ip(iface, iface_data.get('inet6') or '')
except Exception as e:
logger.debug(
f"Интерфейсы контейнера {vmid} недоступны (возможно, остановлен): {e}"
)
return [iface for iface in interfaces.values() if iface['ips']]
def execute_command(self, node: str, vmid: int, command: str, timeout: int = 30) -> Dict: def execute_command(self, node: str, vmid: int, command: str, timeout: int = 30) -> Dict:
""" """

View File

@@ -239,6 +239,16 @@ class StorageMixin:
logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}") logger.error(f"Ошибка получения статусов хранилищ с {self.host}: {e}")
return status return status
def scan_nfs_exports(self, node: str, server: str) -> List[Dict]:
"""
Получить список NFS-экспортов удалённого сервера через
GET /nodes/{node}/scan/nfs — для выбора export в диалоге добавления
хранилища вместо ручного ввода пути.
"""
if not self.proxmox:
return []
return self.proxmox.nodes(node).scan.nfs.get(server=server)
def get_node_storages(self, node: str, content: str = None) -> List[Dict]: def get_node_storages(self, node: str, content: str = None) -> List[Dict]:
"""Get storages available on a specific node, optionally filtered by content type""" """Get storages available on a specific node, optionally filtered by content type"""
if not self.proxmox: if not self.proxmox:

View File

@@ -137,12 +137,10 @@ NEW_DEFAULT_ROLES = [
"template:view": True, "template:view": True,
"storage:view": True, "storage:view": True,
"firewall:view": True, "firewall:view": True,
"network:view": True,
"node:view": True, "node:view": True,
"pool:view": True, "pool:view": True,
"backup:view": True, "backup:view": True,
"backup:create": True, "backup:create": True,
"ipam:view": True,
"user:view": True, "user:view": True,
"log:view": True, "log:view": True,
"log:export": True, "log:export": True,
@@ -172,10 +170,8 @@ NEW_DEFAULT_ROLES = [
"template:view": True, "template:view": True,
"storage:view": True, "storage:view": True,
"firewall:view": True, "firewall:view": True,
"network:view": True,
"node:view": True, "node:view": True,
"pool:view": True, "pool:view": True,
"ipam:view": True,
# No setting:view — that grants the panel-wide settings page. A # No setting:view — that grants the panel-wide settings page. A
# user's own profile lives at /profile and needs no permission. # user's own profile lives at /profile and needs no permission.
"notification:view": True, "notification:view": True,
@@ -195,10 +191,8 @@ NEW_DEFAULT_ROLES = [
"template:view": True, "template:view": True,
"storage:view": True, "storage:view": True,
"firewall:view": True, "firewall:view": True,
"network:view": True,
"node:view": True, "node:view": True,
"pool:view": True, "pool:view": True,
"ipam:view": True,
} }
}, },
] ]
@@ -489,7 +483,10 @@ def ensure_default_roles_new_format(conn) -> None:
# custom roles do not silently lose access after the split. # custom roles do not silently lose access after the split.
GRANULAR_BACKFILL = [ GRANULAR_BACKFILL = [
# (umbrella permission, [granular permissions implied by it]) # (umbrella permission, [granular permissions implied by it])
("server:view", ["firewall:view", "network:view", "node:view", "storage:view"]), # network:view/manage is deliberately NOT implied by server:view - the
# network topology and node/SDN interface config are admin-only, not part
# of the baseline "can see servers" umbrella.
("server:view", ["firewall:view", "node:view", "storage:view"]),
("vm:view", ["pool:view", "firewall:view"]), ("vm:view", ["pool:view", "firewall:view"]),
("server:manage", [ ("server:manage", [
"firewall:view", "firewall:manage", "firewall:view", "firewall:manage",

View File

@@ -643,6 +643,13 @@ class IPAMAllocationResponse(IPAMAllocationBase):
last_seen: Optional[datetime] = None last_seen: Optional[datetime] = None
created_at: datetime created_at: datetime
updated_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 # Extended info
network_name: Optional[str] = None network_name: Optional[str] = None
pool_name: Optional[str] = None pool_name: Optional[str] = None

View File

@@ -0,0 +1,163 @@
#!/bin/sh
# Привести набор дополнительных IPv4-адресов (alias) интерфейса к заданному.
#
# Параметры приходят переменными окружения (их подставляет
# script_engine.render_params, значения не конкатенируются в команды):
# IFACE — интерфейс гостя, напр. eth0
# ADDRESSES — список CIDR через пробел; пустая строка = снять все алиасы
# PVEM_ROOT — префикс путей; в госте всегда пуст, задаётся только тестами
#
# Скрипт идемпотентен: он синхронизирует состояние, а не «добавляет».
# Под управлением находятся только адреса из своего state-файла — адреса,
# настроенные в госте вручную, не трогаются.
#
# Сеть намеренно НЕ перезапускается: адрес поднимается через `ip addr add`
# здесь и сейчас, а конфиг ОС правится только ради переживания ребута.
# Никаких `netplan apply` / `ifup` / `nmcli con up` — они рвут связь с
# работающим гостем.
[ -n "$IFACE" ] || { echo "missing IFACE"; exit 2; }
ROOT="${PVEM_ROOT:-}"
STATE_DIR="$ROOT/var/lib/pvemanager"
STATE="$STATE_DIR/aliases.$IFACE"
mkdir -p "$STATE_DIR" 2>/dev/null || true
PREV=""
[ -f "$STATE" ] && PREV=$(cat "$STATE")
# 1. Снять адреса, ушедшие из списка (только наши, из state-файла)
for old in $PREV; do
keep=0
for new in $ADDRESSES; do
[ "$old" = "$new" ] && keep=1
done
[ "$keep" = "1" ] && continue
ip addr del "$old" dev "$IFACE" 2>/dev/null || true
done
# 2. Поднять недостающие
for a in $ADDRESSES; do
addr=${a%%/*}
if ip -4 addr show dev "$IFACE" 2>/dev/null | grep -qw "$addr"; then
continue
fi
if ! ip addr add "$a" dev "$IFACE"; then
echo "failed to add $a on $IFACE"
exit 1
fi
done
printf '%s\n' "$ADDRESSES" > "$STATE"
# 3. Закрепить в конфиге ОС — первый распознанный стек выигрывает.
# Ветки идут последовательно, а не через elif: наличие инструмента ещё не
# значит, что он управляет этим интерфейсом (типичный случай — NetworkManager
# в LXC), и тогда управление должно уходить дальше по списку.
persist=none
MARKER="# managed by PVEmanager"
IFACES_FILE="$ROOT/etc/network/interfaces"
IFUP_DIR="$ROOT/etc/network/interfaces.d"
IFUP_FILE="$IFUP_DIR/pvemanager-aliases-$IFACE"
NETPLAN_DIR="$ROOT/etc/netplan"
NETPLAN_FILE="$NETPLAN_DIR/99-pvemanager-aliases.yaml"
SYSCONFIG_DIR="$ROOT/etc/sysconfig/network-scripts"
# --- ifupdown (Debian/Ubuntu) ---
if [ "$persist" = none ] && [ -d "$IFUP_DIR" ] && [ -f "$IFACES_FILE" ] && \
command -v ifup >/dev/null 2>&1; then
# Шаблоны LXC от Proxmox создают interfaces.d, но не подключают его из
# /etc/network/interfaces — без source-строки файлы оттуда не читаются,
# поэтому добавляем её (идемпотентно). Список интерфейсов не трогаем:
# его перезаписывает Proxmox при правке NIC.
if ! grep -qE '^[[:space:]]*(source|source-directory)' "$IFACES_FILE"; then
echo "source /etc/network/interfaces.d/*" >> "$IFACES_FILE"
fi
if [ -z "$ADDRESSES" ]; then
rm -f "$IFUP_FILE"
else
{
echo "$MARKER — не редактировать вручную"
n=0
for a in $ADDRESSES; do
n=$((n + 1))
echo "auto $IFACE:$n"
echo "iface $IFACE:$n inet static"
echo " address $a"
done
} > "$IFUP_FILE"
fi
persist=ifupdown
fi
# --- netplan (Ubuntu cloud-образы) ---
if [ "$persist" = none ] && [ -d "$NETPLAN_DIR" ]; then
if [ -z "$ADDRESSES" ]; then
rm -f "$NETPLAN_FILE"
else
{
echo "$MARKER — не редактировать вручную"
echo "network:"
echo " version: 2"
echo " ethernets:"
echo " $IFACE:"
echo " addresses:"
for a in $ADDRESSES; do
echo " - $a"
done
} > "$NETPLAN_FILE"
chmod 600 "$NETPLAN_FILE" 2>/dev/null || true
fi
persist=netplan
fi
# --- NetworkManager (RHEL-based VM, современные десктопные образы) ---
if [ "$persist" = none ] && command -v nmcli >/dev/null 2>&1; then
# Пустой ответ = интерфейсом NM не управляет; тогда идём дальше.
CON=$(nmcli -g GENERAL.CONNECTION device show "$IFACE" 2>/dev/null)
if [ -n "$CON" ]; then
for old in $PREV; do
nmcli con mod "$CON" -ipv4.addresses "$old" >/dev/null 2>&1 || true
done
for a in $ADDRESSES; do
nmcli con mod "$CON" +ipv4.addresses "$a" >/dev/null 2>&1 || true
done
persist=nm
fi
fi
# --- sysconfig / network-scripts (RHEL, CentOS, Rocky, Alma) ---
# Шаблоны LXC этих дистрибутивов Proxmox настраивает именно так
# (pve-container, модуль Redhat.pm), а NetworkManager в них обычно не стоит.
if [ "$persist" = none ] && [ -d "$SYSCONFIG_DIR" ]; then
# Сносим прежние свои ifcfg-алиасы; чужие файлы без маркера не трогаем.
for f in "$SYSCONFIG_DIR/ifcfg-$IFACE":*; do
[ -f "$f" ] || continue
if head -n 1 "$f" 2>/dev/null | grep -q "$MARKER"; then
rm -f "$f"
fi
done
n=0
for a in $ADDRESSES; do
n=$((n + 1))
addr=${a%%/*}
plen=${a##*/}
{
echo "$MARKER — не редактировать вручную"
echo "DEVICE=$IFACE:$n"
echo "NAME=$IFACE:$n"
echo "ONPARENT=yes"
echo "ONBOOT=yes"
echo "BOOTPROTO=none"
echo "IPADDR=$addr"
echo "PREFIX=$plen"
} > "$SYSCONFIG_DIR/ifcfg-$IFACE:$n"
done
persist=sysconfig
fi
echo "PVEMANAGER_PERSIST=$persist"
echo "PVEMANAGER_OK=1"

View File

@@ -0,0 +1,222 @@
"""Дополнительные IP-адреса гостя (alias поверх основного интерфейса).
Основной адрес живёт в конфиге Proxmox (`net0` у LXC, `ipconfig0` у QEMU) и
здесь не трогается. Дополнительные навешиваются внутри гостя как alias и
учитываются в IPAM: одна аллокация — один адрес.
Транспорт не изобретаем: `services/script_engine.py` уже умеет выполнять
скрипт и в LXC (`pct exec` по SSH ноды), и в VM (QEMU guest agent).
Модуль работает синхронизацией, а не пошаговым add/remove: в гостя всегда
уезжает полный список alias-адресов интерфейса, а скрипт приводит состояние
к нему. Поэтому добавление, удаление и повторное применение — один и тот же
код, а расхождения (адрес сняли руками) чинятся сами.
"""
from __future__ import annotations
import ipaddress
import re
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
from loguru import logger
from sqlalchemy.orm import Session
from ..config import utcnow
from ..models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
from . import script_engine
_SCRIPT_PATH = Path(__file__).parent / "guest_ip" / "sync_aliases.sh"
_SYNC_TIMEOUT = 60
# Значения IPAMAllocation.apply_status
APPLIED = "applied" # поднят и закреплён в конфиге ОС
RUNTIME_ONLY = "runtime_only" # поднят, но стек не распознан — ребут не переживёт
PENDING = "pending" # гость недоступен, попробуем позже
FAILED = "failed" # скрипт отработал с ошибкой
_PERSIST_RE = re.compile(r"^PVEMANAGER_PERSIST=(\w+)$", re.MULTILINE)
_OK_RE = re.compile(r"^PVEMANAGER_OK=1$", re.MULTILINE)
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,15}$")
DEFAULT_INTERFACE = "eth0"
@dataclass
class ApplyResult:
success: bool
status: str
persist: Optional[str] = None
error: Optional[str] = None
output: str = ""
def _load_script() -> str:
return _SCRIPT_PATH.read_text(encoding="utf-8")
def prefix_for(db: Session, allocation: IPAMAllocation) -> Optional[int]:
"""Длина префикса адреса — берётся из сети, которой он принадлежит."""
network = db.query(IPAMNetwork).filter(IPAMNetwork.id == allocation.network_id).first()
if not network or not network.network:
return None
try:
return ipaddress.ip_network(network.network, strict=False).prefixlen
except ValueError:
return None
def alias_allocations(db: Session, server_id: int, vmid: int,
interface: str) -> List[IPAMAllocation]:
"""Все alias-адреса гостя на конкретном интерфейсе, по возрастанию адреса."""
rows = db.query(IPAMAllocation).filter(
IPAMAllocation.proxmox_server_id == server_id,
IPAMAllocation.proxmox_vmid == vmid,
IPAMAllocation.assignment_kind == "alias",
IPAMAllocation.target_interface == interface,
IPAMAllocation.status.in_(["allocated", "reserved"]),
).all()
def _sort_key(row: IPAMAllocation):
try:
return (0, int(ipaddress.ip_address(row.ip_address)))
except ValueError:
return (1, 0)
return sorted(rows, key=_sort_key)
def _guest_target(db: Session, server_id: int, vmid: int,
allocation: Optional[IPAMAllocation] = None
) -> Tuple[Optional[ProxmoxServer], Optional[str], Optional[str]]:
"""(server, node, vm_type) для запуска скрипта; None-ы, если гость неизвестен."""
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
guest = db.query(VMInstance).filter(
VMInstance.server_id == server_id,
VMInstance.vmid == vmid,
VMInstance.deleted_at.is_(None),
).first()
node = (guest.node if guest else None) or (allocation.proxmox_node if allocation else None)
vm_type = guest.vm_type if guest else None
if not vm_type and allocation and allocation.resource_type in ("qemu", "lxc"):
vm_type = allocation.resource_type
return server, node, vm_type
def resolve_interface(client, vm_type: str, node: str, vmid: int,
primary_ip: Optional[str] = None) -> str:
"""Интерфейс по умолчанию — тот, на котором висит основной адрес гостя.
Данные берём у существующих методов клиента; если гость молчит,
возвращаем eth0 — пользователь всё равно может выбрать интерфейс вручную.
"""
try:
if vm_type == "lxc":
interfaces = client.get_container_interfaces(node, vmid)
else:
interfaces = client.get_vm_interfaces(node, vmid)
except Exception as e:
logger.debug(f"[guest-ip] не удалось получить интерфейсы {vmid}: {e}")
return DEFAULT_INTERFACE
fallback = None
for iface in interfaces or []:
name = iface.get("name") or ""
if not name or name == "lo":
continue
fallback = fallback or name
if primary_ip:
for ip_info in iface.get("ips") or []:
if ip_info.get("address") == primary_ip:
return name
return fallback or DEFAULT_INTERFACE
def sync_interface(db: Session, server_id: int, vmid: int, interface: str,
allocation: Optional[IPAMAllocation] = None) -> ApplyResult:
"""Привести alias-адреса интерфейса в госте к тому, что записано в IPAM.
Обновляет apply_status у всех alias-аллокаций этого интерфейса: они
применяются одним вызовом, значит и исход у них общий.
"""
if not _IFACE_RE.match(interface or ""):
return ApplyResult(False, FAILED, error=f"Некорректное имя интерфейса: {interface}")
server, node, vm_type = _guest_target(db, server_id, vmid, allocation)
rows = alias_allocations(db, server_id, vmid, interface)
if not server:
return _finish(db, rows, ApplyResult(False, PENDING, error="Сервер Proxmox не найден"))
if not node or not vm_type:
return _finish(db, rows, ApplyResult(
False, PENDING, error="Гость не найден в кэше — синхронизируйте инстансы"))
addresses = []
for row in rows:
prefix = prefix_for(db, row)
if prefix is None:
return _finish(db, rows, ApplyResult(
False, FAILED,
error=f"Не удалось определить префикс сети для {row.ip_address}"))
addresses.append(f"{row.ip_address}/{prefix}")
script = script_engine.render_params(
_load_script(), {"IFACE": interface, "ADDRESSES": " ".join(addresses)}
)
result = script_engine.execute(
server, target_type="guest", node=node, vmid=vmid, vm_type=vm_type,
script=script, interpreter="/bin/sh", timeout=_SYNC_TIMEOUT,
)
output = result.output or ""
if not result.success or not _OK_RE.search(output):
error = (result.error or output or "").strip()[:1000] or "Скрипт не завершился успешно"
# exit_code == -1 — транспорт: гость выключен, нет SSH к ноде или нет
# guest agent. Это не ошибка конфигурации, поэтому pending: адрес
# закреплён в IPAM и применится, когда гость станет доступен.
status = PENDING if result.exit_code == -1 else FAILED
return _finish(db, rows, ApplyResult(False, status, error=error, output=output))
persist_match = _PERSIST_RE.search(output)
persist = persist_match.group(1) if persist_match else "none"
status = RUNTIME_ONLY if persist == "none" else APPLIED
return _finish(db, rows, ApplyResult(True, status, persist=persist, output=output))
def _finish(db: Session, rows: List[IPAMAllocation], result: ApplyResult) -> ApplyResult:
"""Записать исход применения во все затронутые аллокации."""
for row in rows:
row.apply_status = result.status
row.apply_error = result.error
if result.success:
row.applied_at = utcnow()
db.commit()
return result
def apply_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
"""Применить (или переприменить) alias-адрес к гостю."""
if allocation.assignment_kind != "alias":
return ApplyResult(True, APPLIED, persist="primary",
output="Основной адрес задаётся конфигом Proxmox")
return sync_interface(
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
allocation.target_interface or DEFAULT_INTERFACE, allocation,
)
def remove_address(db: Session, allocation: IPAMAllocation) -> ApplyResult:
"""Снять alias-адрес с гостя.
К моменту вызова аллокация должна быть уже освобождена или удалена —
скрипт снимает всё, чего нет в переданном ему списке.
"""
if allocation.assignment_kind != "alias":
return ApplyResult(True, APPLIED, persist="primary")
return sync_interface(
db, allocation.proxmox_server_id, allocation.proxmox_vmid,
allocation.target_interface or DEFAULT_INTERFACE, allocation,
)

View File

@@ -5,6 +5,7 @@ Uses APScheduler for periodic tasks
import asyncio import asyncio
import re import re
import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from loguru import logger from loguru import logger
@@ -108,12 +109,17 @@ class MonitoringWorker:
# задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу. # задачу failed. Транзитный сетевой сбой не должен «убивать» живую задачу.
UPID_FAIL_THRESHOLD = 3 UPID_FAIL_THRESHOLD = 3
# Как часто перепроверять IP гостя у Proxmox. Синк идёт каждые 10 секунд,
# а адрес меняется редко — без троттлинга это сотни лишних запросов в минуту.
IP_DETECT_TTL = 300
def __init__(self): def __init__(self):
self.last_vm_states: Dict[str, str] = {} # vm_id -> status self.last_vm_states: Dict[str, str] = {} # vm_id -> status
self.last_resource_alerts: Dict[str, float] = {} # resource_id -> last_alert_time self.last_resource_alerts: Dict[str, float] = {} # resource_id -> last_alert_time
self.last_server_states: Dict[int, bool] = {} # server_id -> is_online self.last_server_states: Dict[int, bool] = {} # server_id -> is_online
self.last_server_alerts: Dict[int, float] = {} # server_id -> last_alert_time self.last_server_alerts: Dict[int, float] = {} # server_id -> last_alert_time
self.upid_fail_counts: Dict[int, int] = {} # ProxmoxTask.id -> consecutive fetch failures self.upid_fail_counts: Dict[int, int] = {} # ProxmoxTask.id -> consecutive fetch failures
self.last_ip_detect: Dict[tuple, float] = {} # (server_id, vmid) -> monotonic time
def _create_proxmox_client(self, server: ProxmoxServer) -> ProxmoxClient: def _create_proxmox_client(self, server: ProxmoxServer) -> ProxmoxClient:
"""Create ProxmoxClient from server model, raising if auth is missing.""" """Create ProxmoxClient from server model, raising if auth is missing."""
@@ -619,18 +625,20 @@ class MonitoringWorker:
IPAMAllocation.status.in_(['allocated', 'reserved']) IPAMAllocation.status.in_(['allocated', 'reserved'])
).all() ).all()
# Build IPAM lookups # Build IPAM lookups.
# Только по идентификатору гостя: сопоставление по имени давало чужой
# адрес одноимённым гостям на разных серверах (два разных "wireguard").
# У гостя может быть несколько адресов — в кэш инстанса пишем
# основной (is_primary); без флага берём самую раннюю запись.
ipam_by_server_vmid = {} # (server_id, vmid) -> allocation ipam_by_server_vmid = {} # (server_id, vmid) -> allocation
ipam_by_name = {} # hostname/resource_name -> allocation
for alloc in ipam_allocations: for alloc in ipam_allocations:
if alloc.proxmox_server_id and alloc.proxmox_vmid: if alloc.proxmox_server_id and alloc.proxmox_vmid:
ipam_by_server_vmid[(alloc.proxmox_server_id, alloc.proxmox_vmid)] = alloc key = (alloc.proxmox_server_id, alloc.proxmox_vmid)
if alloc.hostname: current = ipam_by_server_vmid.get(key)
ipam_by_name[alloc.hostname.lower()] = alloc if current is None or (alloc.is_primary and not current.is_primary):
if alloc.resource_name: ipam_by_server_vmid[key] = alloc
ipam_by_name[alloc.resource_name.lower()] = alloc
# Detect clusters: servers that see multiple nodes are part of a cluster # Detect clusters: servers that see multiple nodes are part of a cluster
# Group servers by their node set # Group servers by their node set
server_nodes = {} # server_id -> set of node names server_nodes = {} # server_id -> set of node names
@@ -749,26 +757,34 @@ class MonitoringWorker:
all_seen.add((server_id, vmid)) all_seen.add((server_id, vmid))
# Get IP from IPAM # Get IP from IPAM
ipam_alloc = ( ipam_alloc = ipam_by_server_vmid.get((server_id, vmid))
ipam_by_server_vmid.get((server_id, vmid)) or
ipam_by_name.get(vm_name.lower())
)
ip_address = ipam_alloc.ip_address if ipam_alloc else None ip_address = ipam_alloc.ip_address if ipam_alloc else None
# If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent) # If no IPAM IP, try to detect from Proxmox (LXC config or QEMU guest agent).
if not ip_address: # Троттлим: адрес гостя меняется куда реже, чем идёт синк.
detect_key = (server_id, vmid)
detect_due = (time.monotonic() - self.last_ip_detect.get(detect_key, 0.0)) >= self.IP_DETECT_TTL
if not ip_address and detect_due:
self.last_ip_detect[detect_key] = time.monotonic()
try: try:
px_client = server_clients.get(server_id) px_client = server_clients.get(server_id)
if px_client and node_name: if px_client and node_name:
if vm_type == 'lxc': if vm_type == 'lxc':
config = px_client.proxmox.nodes(node_name).lxc(vmid).config.get() # Статика из netN + живые адреса запущенного
for i in range(4): # контейнера (DHCP виден только там)
ipconfig = config.get(f'ipconfig{i}', '') for iface in px_client.get_container_interfaces(
if 'ip=' in str(ipconfig): node_name, vmid,
ip_part = str(ipconfig).split('ip=')[1].split(',')[0] include_live=vm_data.get('status') == 'running',
if ip_part and ip_part != 'dhcp': ):
ip_address = ip_part.split('/')[0] if '/' in ip_part else ip_part for ip_info in iface.get('ips') or []:
if ip_info.get('type') != 'ipv4':
continue
addr = ip_info.get('address', '')
if addr:
ip_address = addr
break break
if ip_address:
break
elif vm_type == 'qemu' and vm_data.get('status') == 'running': elif vm_type == 'qemu' and vm_data.get('status') == 'running':
result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get() result = px_client.proxmox.nodes(node_name).qemu(vmid).agent('network-get-interfaces').get()
if result and 'result' in result: if result and 'result' in result:
@@ -862,6 +878,11 @@ class MonitoringWorker:
db.rollback() db.rollback()
return return
# Не копим троттлинг-метки по гостям, которых больше нет
self.last_ip_detect = {
key: seen_at for key, seen_at in self.last_ip_detect.items() if key in all_seen
}
# Mark VMs that no longer exist as deleted # Mark VMs that no longer exist as deleted
# Get all active VMs from synced servers # Get all active VMs from synced servers
synced_server_ids = set(server.id for server in servers) synced_server_ids = set(server.id for server in servers)

View File

@@ -1838,6 +1838,48 @@ def migrate_app_operations_server_id(conn):
logger.info("✓ app_operations.server_id already exists") logger.info("✓ app_operations.server_id already exists")
def migrate_ipam_multi_ip(conn):
"""Migration 43: несколько IP-адресов на одного гостя.
Раньше на (server_id, vmid) подразумевалась ровно одна аллокация. Теперь
их может быть несколько: один основной (net0/ipconfig0, попадает в колонку
IP) и произвольное число алиасов, навешенных внутри гостя. Колонки
apply_* хранят состояние применения алиаса к живому гостю.
"""
if not table_exists(conn, 'ipam_allocations'):
logger.info("Table ipam_allocations does not exist, skipping multi-IP migration")
return
added = False
for column, ddl in (
('is_primary', 'BOOLEAN NOT NULL DEFAULT FALSE'),
('assignment_kind', "VARCHAR(10) NOT NULL DEFAULT 'primary'"),
('target_interface', 'VARCHAR(20)'),
('apply_status', 'VARCHAR(20)'),
('apply_error', 'TEXT'),
('applied_at', 'TIMESTAMP WITH TIME ZONE'),
):
if add_column_if_not_exists(conn, 'ipam_allocations', column, ddl):
added = True
if added:
conn.execute(text(
"CREATE INDEX IF NOT EXISTS idx_ipam_alloc_primary "
"ON ipam_allocations(proxmox_server_id, proxmox_vmid, is_primary)"
))
# Всё, что существовало до этой миграции, было единственным адресом
# своего гостя — значит основным. Записи без привязки к гостю
# (ручные брони) основными не считаем.
conn.execute(text("""
UPDATE ipam_allocations
SET is_primary = TRUE, assignment_kind = 'primary'
WHERE proxmox_vmid IS NOT NULL AND is_primary = FALSE
"""))
logger.info("✓ Added multi-IP columns to ipam_allocations")
else:
logger.info("✓ ipam_allocations multi-IP columns already exist")
def migrate_setting_view_panel_only(conn): def migrate_setting_view_panel_only(conn):
"""Migration 42: drop setting:view where it was only granting the profile page. """Migration 42: drop setting:view where it was only granting the profile page.
@@ -2247,6 +2289,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"setting:view split migration: {e}") logger.warning(f"setting:view split migration: {e}")
conn.rollback() conn.rollback()
# Migration 43: several IP addresses per guest
try:
migrate_ipam_multi_ip(conn)
conn.commit()
except Exception as e:
logger.warning(f"IPAM multi-IP migration: {e}")
conn.rollback()
logger.info("=" * 50) logger.info("=" * 50)
logger.info("✓ All migrations completed successfully") logger.info("✓ All migrations completed successfully")
logger.info("=" * 50) logger.info("=" * 50)

View File

@@ -0,0 +1,105 @@
from unittest.mock import MagicMock
from app.proxmox.client import ProxmoxClient
def _client_with_mock(proxmox):
c = ProxmoxClient.__new__(ProxmoxClient)
c.proxmox = proxmox
return c
def _lxc_mock(config=None, interfaces=None):
"""MagicMock whose nodes(..).lxc(..).config/.interfaces behave like Proxmox."""
px = MagicMock()
lxc = px.nodes.return_value.lxc.return_value
if isinstance(config, Exception):
lxc.config.get.side_effect = config
else:
lxc.config.get.return_value = config or {}
if isinstance(interfaces, Exception):
lxc.interfaces.get.side_effect = interfaces
else:
lxc.interfaces.get.return_value = interfaces or []
return px, lxc
def test_static_ip_comes_from_net0_not_ipconfig():
px, lxc = _lxc_mock(config={
"hostname": "gitea",
"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:AA:BB:CC,ip=10.10.10.9/24,gw=10.10.10.1,type=veth",
})
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 109)
assert ifaces == [{
"name": "eth0",
"hardware_address": "BC:24:11:AA:BB:CC",
"ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}],
}]
# Static address is authoritative — no extra call to the live endpoint.
lxc.interfaces.get.assert_not_called()
def test_dhcp_container_falls_back_to_live_interfaces():
px, _ = _lxc_mock(
config={"net0": "name=eth0,bridge=vmbr0,hwaddr=BC:24:11:00:11:22,ip=dhcp,type=veth"},
interfaces=[
{"name": "lo", "inet": "127.0.0.1/8"},
{"name": "eth0", "hwaddr": "bc:24:11:00:11:22", "inet": "10.10.10.55/24",
"inet6": "fe80::be24:11ff:fe00:1122/64"},
],
)
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 120)
assert ifaces == [{
"name": "eth0",
"hardware_address": "BC:24:11:00:11:22",
"ips": [{"address": "10.10.10.55", "type": "ipv4", "prefix": 24}],
}]
def test_stopped_dhcp_container_yields_nothing():
px, _ = _lxc_mock(
config={"net0": "name=eth0,bridge=vmbr0,ip=dhcp,type=veth"},
interfaces=RuntimeError("500 CT is not running"),
)
assert _client_with_mock(px).get_container_interfaces("pve1", 120) == []
def test_multiple_nics_and_ipv6_static():
px, _ = _lxc_mock(config={
"net0": "name=eth0,bridge=vmbr0,ip=10.10.10.9/24,gw=10.10.10.1",
"net1": "name=eth1,bridge=vmbr1,ip=192.168.50.4/24,ip6=2001:db8::4/64",
})
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 121)
assert [i["name"] for i in ifaces] == ["eth0", "eth1"]
assert ifaces[1]["ips"] == [
{"address": "192.168.50.4", "type": "ipv4", "prefix": 24},
{"address": "2001:db8::4", "type": "ipv6", "prefix": 64},
]
def test_unreadable_config_still_tries_live_interfaces():
px, _ = _lxc_mock(
config=RuntimeError("permission denied"),
interfaces=[{"name": "eth0", "hwaddr": "aa:bb:cc:dd:ee:ff", "inet": "10.0.0.7/24"}],
)
ifaces = _client_with_mock(px).get_container_interfaces("pve1", 122)
assert ifaces[0]["ips"] == [{"address": "10.0.0.7", "type": "ipv4", "prefix": 24}]
def test_no_proxmox_connection():
c = ProxmoxClient.__new__(ProxmoxClient)
c.proxmox = None
assert c.get_container_interfaces("pve1", 100) == []
def test_include_live_false_skips_live_endpoint():
px, lxc = _lxc_mock(
config={"net0": "name=eth0,bridge=vmbr0,ip=dhcp,type=veth"},
interfaces=[{"name": "eth0", "inet": "10.0.0.8/24"}],
)
# Stopped container: querying /interfaces would only produce a 500.
assert _client_with_mock(px).get_container_interfaces("pve1", 130, include_live=False) == []
lxc.interfaces.get.assert_not_called()

View File

@@ -0,0 +1,298 @@
"""Скрипт синхронизации alias-адресов: детект стека и содержимое конфигов.
Скрипт запускается настоящим /bin/sh в песочнице: PVEM_ROOT подменяет корень
файловой системы, а ip/nmcli/ifup подставляются заглушками через PATH. Так
проверяется именно shell-логика — то, что реально выполняется внутри гостя.
"""
import os
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "app" / "services" / "guest_ip" / "sync_aliases.sh"
FAKE_IP = """#!/bin/sh
echo "$*" >> "$FAKE_LOG/ip.log"
# 'addr show' печатает пустоту: считаем, что на интерфейсе адресов ещё нет
exit 0
"""
FAKE_NMCLI = """#!/bin/sh
echo "$*" >> "$FAKE_LOG/nmcli.log"
if [ "$1" = "-g" ]; then
printf '%s' "$NM_CONNECTION"
fi
exit 0
"""
FAKE_IFUP = """#!/bin/sh
exit 0
"""
@pytest.fixture
def sandbox(tmp_path):
"""Песочница с фейковыми ip/nmcli/ifup и пустым «корнем» гостя."""
root = tmp_path / "root"
bindir = tmp_path / "bin"
logdir = tmp_path / "log"
for path in (root, bindir, logdir):
path.mkdir(parents=True)
for name, body in (("ip", FAKE_IP), ("nmcli", FAKE_NMCLI), ("ifup", FAKE_IFUP)):
target = bindir / name
target.write_text(body)
target.chmod(0o755)
def run(addresses="", iface="eth0", nm_connection="", tools=("ip",)):
"""Запустить скрипт. tools — какие команды «установлены» в госте."""
env = {
"PATH": f"{bindir}:{os.environ['PATH']}",
"PVEM_ROOT": str(root),
"FAKE_LOG": str(logdir),
"NM_CONNECTION": nm_connection,
"IFACE": iface,
"ADDRESSES": addresses,
}
# Прячем инструменты, которых в этом сценарии быть не должно
for name in ("ip", "nmcli", "ifup"):
path = bindir / name
hidden = bindir / f"{name}.hidden"
if name in tools and hidden.exists():
hidden.rename(path)
elif name not in tools and path.exists():
path.rename(hidden)
result = subprocess.run(
["/bin/sh", str(SCRIPT)], env=env, capture_output=True, text=True, timeout=30
)
return result
def persist_of(result) -> str:
for line in result.stdout.splitlines():
if line.startswith("PVEMANAGER_PERSIST="):
return line.split("=", 1)[1]
raise AssertionError(f"нет маркера persist: {result.stdout} {result.stderr}")
def log(name: str) -> str:
path = logdir / f"{name}.log"
return path.read_text() if path.exists() else ""
return {"root": root, "run": run, "persist_of": persist_of, "log": log}
def _mkdirs(root: Path, *relative: str):
for item in relative:
(root / item).mkdir(parents=True, exist_ok=True)
# --- sysconfig / network-scripts ----------------------------------------------
def test_sysconfig_branch_writes_ifcfg_alias(sandbox):
"""RHEL-based LXC: alias закрепляется отдельным ifcfg-файлом."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24")
assert sandbox["persist_of"](result) == "sysconfig"
written = (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").read_text()
assert "DEVICE=eth0:1" in written
assert "IPADDR=10.10.10.90" in written
assert "PREFIX=24" in written
assert "BOOTPROTO=none" in written
assert "ONBOOT=yes" in written
assert "managed by PVEmanager" in written
def test_sysconfig_numbers_aliases_in_order(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
scripts = root / "etc/sysconfig/network-scripts"
assert "IPADDR=10.10.10.90" in (scripts / "ifcfg-eth0:1").read_text()
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:2").read_text()
def test_sysconfig_drops_files_of_removed_addresses(sandbox):
"""Список сократился — лишний ifcfg удаляется, оставшийся переписывается."""
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
sandbox["run"](addresses="10.10.10.91/24")
assert not (scripts / "ifcfg-eth0:2").exists()
assert "IPADDR=10.10.10.91" in (scripts / "ifcfg-eth0:1").read_text()
def test_sysconfig_removes_everything_on_empty_list(sandbox):
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
result = sandbox["run"](addresses="")
assert sandbox["persist_of"](result) == "sysconfig"
assert list(scripts.glob("ifcfg-eth0:*")) == []
def test_sysconfig_keeps_foreign_ifcfg_files(sandbox):
"""Файлы, написанные не нами, остаются нетронутыми."""
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
foreign = scripts / "ifcfg-eth0:9"
foreign.write_text("DEVICE=eth0:9\nIPADDR=10.10.10.250\n")
sandbox["run"](addresses="10.10.10.90/24")
assert foreign.exists()
assert "10.10.10.250" in foreign.read_text()
def test_sysconfig_removes_alias_del_from_interface(sandbox):
"""Ушедший адрес снимается с интерфейса, а не только из конфига."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
sandbox["run"](addresses="")
assert "addr del 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
# --- порядок веток -------------------------------------------------------------
def test_networkmanager_without_connection_falls_through_to_sysconfig(sandbox):
"""nmcli установлен, но интерфейсом не управляет — частый случай в LXC."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="",
tools=("ip", "nmcli"))
assert sandbox["persist_of"](result) == "sysconfig"
assert (root / "etc/sysconfig/network-scripts/ifcfg-eth0:1").exists()
def test_networkmanager_with_connection_wins_over_sysconfig(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
assert sandbox["persist_of"](result) == "nm"
assert "+ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
assert list((root / "etc/sysconfig/network-scripts").glob("ifcfg-*")) == []
def test_networkmanager_drops_previous_address(sandbox):
_mkdirs(sandbox["root"], "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
sandbox["run"](addresses="10.10.10.91/24", nm_connection="System eth0",
tools=("ip", "nmcli"))
assert "-ipv4.addresses 10.10.10.90/24" in sandbox["log"]("nmcli")
def test_ifupdown_wins_when_several_stacks_present(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/network/interfaces.d", "etc/netplan", "etc/sysconfig/network-scripts")
(root / "etc/network/interfaces").write_text("auto eth0\niface eth0 inet dhcp\n")
result = sandbox["run"](addresses="10.10.10.90/24", nm_connection="System eth0",
tools=("ip", "nmcli", "ifup"))
assert sandbox["persist_of"](result) == "ifupdown"
written = (root / "etc/network/interfaces.d/pvemanager-aliases-eth0").read_text()
assert "iface eth0:1 inet static" in written
assert "address 10.10.10.90/24" in written
# source-строка дописывается, раз шаблон её не содержит
assert "source /etc/network/interfaces.d/*" in (root / "etc/network/interfaces").read_text()
def test_ifupdown_needs_ifup_present(sandbox):
"""Каталог interfaces.d без ifupdown — не наш стек, уходим в sysconfig."""
root = sandbox["root"]
_mkdirs(root, "etc/network/interfaces.d", "etc/sysconfig/network-scripts")
(root / "etc/network/interfaces").write_text("auto eth0\n")
result = sandbox["run"](addresses="10.10.10.90/24", tools=("ip",))
assert sandbox["persist_of"](result) == "sysconfig"
def test_netplan_branch_writes_yaml(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/netplan", "etc/sysconfig/network-scripts")
result = sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
assert sandbox["persist_of"](result) == "netplan"
written = (root / "etc/netplan/99-pvemanager-aliases.yaml").read_text()
assert " eth0:" in written
assert " - 10.10.10.90/24" in written
assert " - 10.10.10.91/24" in written
def test_no_known_stack_reports_none_but_still_adds_address(sandbox):
"""Адрес поднимается всегда; не закрепился — об этом говорит persist=none."""
result = sandbox["run"](addresses="10.10.10.90/24")
assert sandbox["persist_of"](result) == "none"
assert "addr add 10.10.10.90/24 dev eth0" in sandbox["log"]("ip")
assert "PVEMANAGER_OK=1" in result.stdout
# --- общие инварианты ----------------------------------------------------------
def test_state_file_tracks_managed_addresses(sandbox):
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24 10.10.10.91/24")
assert (root / "var/lib/pvemanager/aliases.eth0").read_text().strip() == \
"10.10.10.90/24 10.10.10.91/24"
def test_second_run_is_idempotent(sandbox):
"""Повтор с тем же списком не должен ни удалять, ни добавлять заново."""
root = sandbox["root"]
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24")
before = sandbox["log"]("ip")
sandbox["run"](addresses="10.10.10.90/24")
added_again = sandbox["log"]("ip")[len(before):]
assert "addr del" not in added_again
def test_other_interface_aliases_are_untouched(sandbox):
root = sandbox["root"]
scripts = root / "etc/sysconfig/network-scripts"
_mkdirs(root, "etc/sysconfig/network-scripts")
sandbox["run"](addresses="10.10.10.90/24", iface="eth0")
sandbox["run"](addresses="10.10.10.91/24", iface="eth1")
assert (scripts / "ifcfg-eth0:1").exists()
assert (scripts / "ifcfg-eth1:1").exists()
def test_missing_iface_is_rejected(sandbox):
result = sandbox["run"](addresses="10.10.10.90/24", iface="")
assert result.returncode == 2
assert "missing IFACE" in result.stdout

View File

@@ -0,0 +1,218 @@
"""Дополнительные адреса гостя: что уезжает в гостя и как трактуется ответ."""
import pytest
from app.ipam_service import IPAMService
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
from app.services import guest_ip_service, script_engine
OK_IFUPDOWN = "PVEMANAGER_PERSIST=ifupdown\nPVEMANAGER_OK=1\n"
OK_NO_PERSIST = "PVEMANAGER_PERSIST=none\nPVEMANAGER_OK=1\n"
class _Recorder:
"""Подменяет script_engine.execute и запоминает, что ушло в гостя."""
def __init__(self, result):
self.result = result
self.calls = []
def __call__(self, server, **kwargs):
self.calls.append(kwargs)
return self.result
@property
def last_script(self) -> str:
return self.calls[-1]["script"]
def exported(self, name: str) -> str:
"""Значение переменной из блока export, который добавляет render_params."""
for line in self.last_script.splitlines():
if line.startswith(f"export {name}="):
return line.split("=", 1)[1].strip().strip("'")
raise AssertionError(f"{name} not exported: {self.last_script[:200]}")
@pytest.fixture
def guest(db_session):
server = ProxmoxServer(name="Devserver", ip_address="192.168.43.254", hostname="dev",
api_user="root@pam", use_password=True, password="pw")
db_session.add(server)
db_session.commit()
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
is_active=True)
db_session.add(network)
db_session.commit()
instance = VMInstance(server_id=server.id, vmid=120, node="dev", vm_type="lxc",
name="act-runner", status="running")
db_session.add(instance)
db_session.commit()
service = IPAMService(db_session)
def add_alias(ip, interface="eth0"):
allocation, error = service.allocate_ip(
ip_address=ip, network_id=network.id, resource_type="lxc",
resource_name="act-runner", proxmox_server_id=server.id, proxmox_vmid=120,
proxmox_node="dev", allocated_by="tester", assignment_kind="alias",
target_interface=interface, apply_status=guest_ip_service.PENDING,
)
assert error is None
return allocation
return {"db": db_session, "server": server, "network": network, "instance": instance,
"service": service, "add_alias": add_alias}
def _patch(monkeypatch, result):
recorder = _Recorder(result)
monkeypatch.setattr(guest_ip_service.script_engine, "execute", recorder)
return recorder
def test_apply_sends_every_alias_of_the_interface(guest, monkeypatch):
"""В гостя уезжает полный список адресов, а не только новый."""
guest["add_alias"]("10.10.10.90")
second = guest["add_alias"]("10.10.10.91")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.apply_address(guest["db"], second)
assert result.success and result.status == guest_ip_service.APPLIED
assert recorder.exported("IFACE") == "eth0"
assert recorder.exported("ADDRESSES") == "10.10.10.90/24 10.10.10.91/24"
assert recorder.calls[-1]["vm_type"] == "lxc"
assert recorder.calls[-1]["vmid"] == 120
def test_prefix_comes_from_the_ipam_network(guest, monkeypatch):
guest["network"].network = "10.10.0.0/16"
guest["db"].commit()
allocation = guest["add_alias"]("10.10.10.90")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest_ip_service.apply_address(guest["db"], allocation)
assert recorder.exported("ADDRESSES") == "10.10.10.90/16"
def test_unrecognised_stack_is_runtime_only(guest, monkeypatch):
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(True, OK_NO_PERSIST, 0))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert result.success
assert result.status == guest_ip_service.RUNTIME_ONLY
assert allocation.apply_status == guest_ip_service.RUNTIME_ONLY
assert allocation.applied_at is not None
def test_unreachable_guest_keeps_allocation_pending(guest, monkeypatch):
"""Гость выключен или нет SSH — адрес остаётся за ним, статус pending."""
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(
False, "", -1, error="Не удалось подключиться по SSH к Devserver"))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert not result.success
assert result.status == guest_ip_service.PENDING
assert allocation.apply_status == guest_ip_service.PENDING
assert "SSH" in allocation.apply_error
assert allocation.applied_at is None
# Аллокация никуда не делась — адрес зарезервирован
assert guest["db"].query(IPAMAllocation).count() == 1
def test_script_error_marks_allocation_failed(guest, monkeypatch):
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(
False, "failed to add 10.10.10.90/24 on eth0", 1))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert result.status == guest_ip_service.FAILED
assert allocation.apply_status == guest_ip_service.FAILED
def test_missing_ok_marker_is_not_treated_as_success(guest, monkeypatch):
"""exit=0 без маркера — скрипт не доработал; успехом это не считаем."""
allocation = guest["add_alias"]("10.10.10.90")
_patch(monkeypatch, script_engine.ExecResult(True, "some noise", 0))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert not result.success and result.status == guest_ip_service.FAILED
def test_released_address_disappears_from_the_synced_list(guest, monkeypatch):
"""Снятие адреса = синхронизация без него: скрипт сам сделает ip addr del."""
keep = guest["add_alias"]("10.10.10.90")
drop = guest["add_alias"]("10.10.10.91")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest["service"].release_ip(drop.ip_address, released_by="tester")
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
assert recorder.exported("ADDRESSES") == "10.10.10.90/24"
assert keep.apply_status == guest_ip_service.APPLIED
def test_last_address_removal_sends_empty_list(guest, monkeypatch):
only = guest["add_alias"]("10.10.10.90")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest["service"].release_ip(only.ip_address, released_by="tester")
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
assert recorder.exported("ADDRESSES") == ""
def test_addresses_of_other_interfaces_are_not_touched(guest, monkeypatch):
guest["add_alias"]("10.10.10.90", interface="eth0")
guest["add_alias"]("10.10.10.91", interface="eth1")
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth1")
assert recorder.exported("IFACE") == "eth1"
assert recorder.exported("ADDRESSES") == "10.10.10.91/24"
def test_bogus_interface_name_never_reaches_the_guest(guest, monkeypatch):
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.sync_interface(
guest["db"], guest["server"].id, 120, "eth0; rm -rf /")
assert not result.success and result.status == guest_ip_service.FAILED
assert recorder.calls == []
def test_primary_address_is_not_pushed_into_the_guest(guest, monkeypatch):
"""Основной адрес задаётся конфигом Proxmox — скрипт для него не нужен."""
allocation, _ = guest["service"].allocate_ip(
ip_address="10.10.10.5", network_id=guest["network"].id,
proxmox_server_id=guest["server"].id, proxmox_vmid=120,
allocated_by="tester", is_primary=True, assignment_kind="primary",
)
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.apply_address(guest["db"], allocation)
assert result.success
assert recorder.calls == []
def test_unknown_guest_is_pending_not_crash(guest, monkeypatch):
allocation = guest["add_alias"]("10.10.10.90")
guest["instance"].deleted_at = __import__("app.config", fromlist=["utcnow"]).utcnow()
guest["db"].commit()
recorder = _patch(monkeypatch, script_engine.ExecResult(True, OK_IFUPDOWN, 0))
result = guest_ip_service.sync_interface(guest["db"], guest["server"].id, 120, "eth0")
assert result.status == guest_ip_service.PENDING
assert recorder.calls == []

View File

@@ -0,0 +1,178 @@
"""Tests for IPAM link-allocations: MAC propagation and duplicate-IP handling."""
import ipaddress
import pytest
from app.api.ipam import _extract_ipv4s
from app.ipam_service import IPAMService
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
# --- _extract_ipv4s -----------------------------------------------------------
HOME_NET = ipaddress.ip_network("10.10.10.0/24")
class _Net:
"""Stand-in for an IPAMNetwork row (only .id is read by the caller)."""
def __init__(self, id_=1):
self.id = id_
def test_extract_returns_mac_and_interface_of_matching_nic():
interfaces = [
{"name": "eth0", "hardware_address": "bc:24:11:aa:bb:cc",
"ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}]},
]
found = _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])
assert len(found) == 1
assert (found[0].ip, found[0].mac, found[0].interface) == (
"10.10.10.9", "BC:24:11:AA:BB:CC", "eth0")
def test_extract_returns_every_address_in_ipam_networks():
"""Гость с несколькими адресами отдаёт их все, а не только первый."""
interfaces = [
{"name": "eth0", "hardware_address": "AA:AA:AA:AA:AA:AA",
"ips": [{"address": "192.168.99.5", "type": "ipv4", "prefix": 24},
{"address": "10.10.10.9", "type": "ipv4", "prefix": 24},
{"address": "10.10.10.90", "type": "ipv4", "prefix": 24}]},
{"name": "eth1", "hardware_address": "BB:BB:BB:BB:BB:BB",
"ips": [{"address": "10.10.10.44", "type": "ipv4", "prefix": 24}]},
]
found = _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])
assert [f.ip for f in found] == ["10.10.10.9", "10.10.10.90", "10.10.10.44"]
assert [f.interface for f in found] == ["eth0", "eth0", "eth1"]
assert found[-1].mac == "BB:BB:BB:BB:BB:BB"
def test_extract_skips_loopback_and_link_local():
interfaces = [{"name": "eth0", "ips": [
{"address": "127.0.0.1", "type": "ipv4", "prefix": 8},
{"address": "169.254.1.5", "type": "ipv4", "prefix": 16},
{"address": "10.10.10.9", "type": "ipv4", "prefix": 24},
]}]
assert [f.ip for f in _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])] == ["10.10.10.9"]
def test_extract_without_mac_returns_none():
interfaces = [{"name": "eth0", "ips": [{"address": "10.10.10.9", "type": "ipv4", "prefix": 24}]}]
assert _extract_ipv4s(interfaces, [(_Net(), HOME_NET)])[0].mac is None
def test_extract_no_match_returns_empty_list():
interfaces = [{"name": "eth0", "hardware_address": "AA:BB:CC:DD:EE:FF",
"ips": [{"address": "192.168.43.222", "type": "ipv4", "prefix": 24}]}]
assert _extract_ipv4s(interfaces, [(_Net(), HOME_NET)]) == []
# --- sync_from_proxmox_vm -----------------------------------------------------
@pytest.fixture
def ipam_env(db_session):
"""A server, a network and a helper to add guests."""
server = ProxmoxServer(name="Homelab: Dev", ip_address="10.10.10.2", hostname="pve1",
api_user="root@pam", use_password=True, password="x")
db_session.add(server)
db_session.commit()
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
is_active=True)
db_session.add(network)
db_session.commit()
def add_guest(vmid, name, vm_type="lxc", deleted_at=None):
guest = VMInstance(server_id=server.id, vmid=vmid, node="pve1", vm_type=vm_type,
name=name, status="running", deleted_at=deleted_at)
db_session.add(guest)
db_session.commit()
return guest
return {"db": db_session, "server": server, "network": network, "add_guest": add_guest,
"service": IPAMService(db_session)}
def _sync(env, vmid, name, ip, mac=None):
return env["service"].sync_from_proxmox_vm(
network_id=env["network"].id,
proxmox_server_id=env["server"].id,
vmid=vmid,
vm_name=name,
vm_type="lxc",
ip_address=ip,
node="pve1",
mac_address=mac,
synced_by="tester",
)
def test_sync_stores_mac_on_new_allocation(ipam_env):
ipam_env["add_guest"](109, "gitea")
alloc, error = _sync(ipam_env, 109, "gitea", "10.10.10.17", "BC:24:11:AA:BB:CC")
assert error is None
assert alloc.mac_address == "BC:24:11:AA:BB:CC"
assert alloc.proxmox_vmid == 109
def test_duplicate_ip_does_not_steal_allocation_from_a_live_guest(ipam_env):
"""The 10.10.10.101 case: two live guests advertising the same address."""
ipam_env["add_guest"](101, "proxmox-center")
ipam_env["add_guest"](104, "powerdns-admin")
first, error = _sync(ipam_env, 101, "proxmox-center", "10.10.10.101")
assert error is None and first is not None
second, error = _sync(ipam_env, 104, "powerdns-admin", "10.10.10.101")
assert second is None
assert "already linked to proxmox-center" in error
assert "VMID 101" in error
# Owner untouched.
alloc = ipam_env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.101").one()
assert (alloc.proxmox_vmid, alloc.resource_name) == (101, "proxmox-center")
def test_allocation_of_a_vanished_guest_is_reclaimed(ipam_env):
"""A guest that no longer exists must not hold the address hostage."""
ipam_env["add_guest"](101, "old-guest", deleted_at=None)
_sync(ipam_env, 101, "old-guest", "10.10.10.101")
# Guest disappears from Proxmox (soft-deleted by the sync worker).
guest = ipam_env["db"].query(VMInstance).filter(VMInstance.vmid == 101).one()
from app.config import utcnow
guest.deleted_at = utcnow()
ipam_env["db"].commit()
ipam_env["add_guest"](104, "powerdns-admin")
alloc, error = _sync(ipam_env, 104, "powerdns-admin", "10.10.10.101")
assert error is None
assert (alloc.proxmox_vmid, alloc.resource_name) == (104, "powerdns-admin")
def test_manual_allocation_without_vmid_is_adopted(ipam_env):
"""A hand-made record with no Proxmox link is still linkable."""
ipam_env["add_guest"](110, "postgresql")
manual, error = ipam_env["service"].allocate_ip(
ip_address="10.10.10.19", network_id=ipam_env["network"].id,
resource_name="reserved by hand", allocated_by="admin")
assert error is None and manual.proxmox_vmid is None
alloc, error = _sync(ipam_env, 110, "postgresql", "10.10.10.19", "AA:BB:CC:DD:EE:FF")
assert error is None
assert alloc.proxmox_vmid == 110
def test_resync_of_the_same_guest_is_idempotent(ipam_env):
ipam_env["add_guest"](115, "termix")
first, _ = _sync(ipam_env, 115, "termix", "10.10.10.36", "AA:11:22:33:44:55")
again, error = _sync(ipam_env, 115, "termix", "10.10.10.36", "AA:11:22:33:44:55")
assert error is None
assert again.id == first.id
assert ipam_env["db"].query(IPAMAllocation).count() == 1

View File

@@ -0,0 +1,257 @@
"""Несколько IP-адресов на одном инстансе: сервис, API и колонка IP."""
import pytest
from app.ipam_service import IPAMService
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
from app.services import guest_ip_service, script_engine
OK_OUTPUT = "PVEMANAGER_PERSIST=ifupdown\nPVEMANAGER_OK=1\n"
@pytest.fixture
def env(db_session, seed_users):
server = ProxmoxServer(name="Homelab: Dev", ip_address="10.10.10.2", hostname="dev",
api_user="root@pam", use_password=True, password="pw",
is_online=True)
db_session.add(server)
db_session.commit()
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24", gateway="10.10.10.1",
is_active=True)
db_session.add(network)
db_session.commit()
instance = VMInstance(server_id=server.id, vmid=109, node="dev", vm_type="lxc",
name="gitea", status="running")
db_session.add(instance)
db_session.commit()
service = IPAMService(db_session)
def allocate(ip, primary=False, kind="alias", interface="eth0"):
allocation, error = service.allocate_ip(
ip_address=ip, network_id=network.id, resource_type="lxc", resource_name="gitea",
proxmox_server_id=server.id, proxmox_vmid=109, proxmox_node="dev",
allocated_by="tester", is_primary=primary, assignment_kind=kind,
target_interface=interface,
)
assert error is None, error
return allocation
return {"db": db_session, "server": server, "network": network, "instance": instance,
"service": service, "allocate": allocate}
@pytest.fixture(autouse=True)
def no_real_guest(monkeypatch):
"""Скрипт в гостя не уезжает — транспорт проверяется отдельным тестом."""
monkeypatch.setattr(
guest_ip_service.script_engine, "execute",
lambda server, **kwargs: script_engine.ExecResult(True, OK_OUTPUT, 0),
)
# --- сервис -------------------------------------------------------------------
def test_guest_can_hold_several_addresses(env):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
rows = env["service"].find_allocations_by_resource(env["server"].id, 109)
assert [r.ip_address for r in rows] == ["10.10.10.17", "10.10.10.90", "10.10.10.91"]
assert rows[0].is_primary is True
def test_primary_lookup_returns_the_flagged_address(env):
env["allocate"]("10.10.10.90")
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
assert found.id == primary.id
def test_lookup_falls_back_to_earliest_without_flag(env):
"""Записи старше миграции multi-IP флага не имеют — берём самую раннюю."""
first = env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
found = env["service"].find_allocation_by_resource(env["server"].id, 109)
assert found.id == first.id
def test_set_primary_leaves_exactly_one_primary(env):
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
new = env["allocate"]("10.10.10.90")
updated, error = env["service"].set_primary(env["server"].id, 109, new.id, changed_by="tester")
assert error is None and updated.id == new.id
env["db"].refresh(old)
assert [a.is_primary for a in env["service"].find_allocations_by_resource(env["server"].id, 109)] \
== [True, False]
assert old.is_primary is False
def test_set_primary_rejects_foreign_allocation(env):
other = env["service"].allocate_ip(
ip_address="10.10.10.50", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
updated, error = env["service"].set_primary(env["server"].id, 109, other.id)
assert updated is None and "does not belong" in error
def test_deleting_a_guest_releases_every_address(env):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.91")
released, ips = env["service"].release_ip_by_vmid(env["server"].id, 109, released_by="tester")
assert released is True
assert "10.10.10.17" in ips and "10.10.10.91" in ips
assert env["db"].query(IPAMAllocation).count() == 0
# --- API ----------------------------------------------------------------------
def test_list_addresses_returns_primary_first(env, client, admin_headers):
env["allocate"]("10.10.10.90")
env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.get(
f"/ipam/api/guests/{env['server'].id}/109/addresses", headers=admin_headers)
assert response.status_code == 200
body = response.json()
assert body["count"] == 2
assert body["addresses"][0]["ip_address"] == "10.10.10.17"
assert body["addresses"][0]["is_primary"] is True
assert body["addresses"][0]["prefix"] == "24"
def test_add_address_allocates_and_applies(env, client, admin_headers):
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0"},
)
assert response.status_code == 200, response.text
body = response.json()
assert body["applied"] is True
assert body["apply_status"] == guest_ip_service.APPLIED
assert body["address"]["assignment_kind"] == "alias"
stored = env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.90").one()
assert stored.proxmox_vmid == 109
assert stored.target_interface == "eth0"
def test_add_address_picks_next_free_ip(env, client, admin_headers):
env["allocate"]("10.10.10.2", primary=True, kind="primary")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "target_interface": "eth0"},
)
assert response.status_code == 200, response.text
assigned = response.json()["address"]["ip_address"]
assert assigned.startswith("10.10.10.")
assert assigned not in ("10.10.10.1", "10.10.10.2") # шлюз и занятый адрес
def test_add_address_can_take_over_as_primary(env, client, admin_headers):
old = env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0", "make_primary": True},
)
assert response.status_code == 200, response.text
env["db"].refresh(old)
env["db"].refresh(env["instance"])
assert old.is_primary is False
# Кэш инстанса показывает новый основной адрес
assert env["instance"].ip_address == "10.10.10.90"
def test_duplicate_address_is_rejected(env, client, admin_headers):
env["allocate"]("10.10.10.90")
response = client.post(
f"/ipam/api/guests/{env['server'].id}/109/addresses",
headers=admin_headers,
json={"network_id": env["network"].id, "ip_address": "10.10.10.90",
"target_interface": "eth0"},
)
assert response.status_code == 400
assert "already allocated" in response.json()["detail"]
def test_release_address_frees_it(env, client, admin_headers):
alias = env["allocate"]("10.10.10.90")
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{alias.id}", headers=admin_headers)
assert response.status_code == 200, response.text
assert response.json()["released"] == "10.10.10.90"
assert env["db"].query(IPAMAllocation).filter(
IPAMAllocation.ip_address == "10.10.10.90").first() is None
def test_primary_address_cannot_be_released_here(env, client, admin_headers):
primary = env["allocate"]("10.10.10.17", primary=True, kind="primary")
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{primary.id}", headers=admin_headers)
assert response.status_code == 400
assert "NIC config" in response.json()["detail"]
assert env["db"].query(IPAMAllocation).count() == 1
def test_address_of_another_guest_is_not_accessible(env, client, admin_headers):
foreign = env["service"].allocate_ip(
ip_address="10.10.10.50", network_id=env["network"].id,
proxmox_server_id=env["server"].id, proxmox_vmid=999, allocated_by="tester")[0]
response = client.delete(
f"/ipam/api/guests/{env['server'].id}/109/addresses/{foreign.id}", headers=admin_headers)
assert response.status_code == 404
def test_instance_list_exposes_all_addresses(env, client, admin_headers):
env["allocate"]("10.10.10.17", primary=True, kind="primary")
env["allocate"]("10.10.10.90")
response = client.get("/proxmox/api/virtual-machines", headers=admin_headers)
assert response.status_code == 200
vm = next(v for v in response.json() if v["vmid"] == 109)
assert vm["ip"] == "10.10.10.17" # колонка IP показывает основной
assert [a["ip"] for a in vm["ips"]] == ["10.10.10.17", "10.10.10.90"]
assert [a["is_primary"] for a in vm["ips"]] == [True, False]
def test_network_scan_skips_node_and_cached_guest_ips(env):
"""Скан вне пула не должен предлагать адрес ноды или известного гостя."""
# Нода Homelab: Dev живёт на 10.10.10.2, гость gitea — на 10.10.10.3
env["instance"].ip_address = "10.10.10.3"
env["db"].commit()
ip = env["service"].get_next_available_ip(
env["network"].id, None, allow_network_scan=True)
assert ip not in ("10.10.10.1", "10.10.10.2", "10.10.10.3")

View File

@@ -0,0 +1,74 @@
"""IP shown for a guest must come from its own IPAM allocation, never from a
same-named guest on another server."""
import pytest
from app.models import IPAMAllocation, IPAMNetwork, ProxmoxServer, VMInstance
@pytest.mark.integration
class TestVMListIPAMLookup:
@pytest.fixture(autouse=True)
def setup(self, db_session, seed_users):
def _server(name, ip):
server = ProxmoxServer(name=name, hostname=name, ip_address=ip, port=8006,
api_user="root@pam", use_password=True, password="pw",
verify_ssl=False, is_online=False)
db_session.add(server)
db_session.commit()
db_session.refresh(server)
return server
# Two distinct servers, each with its own node that happens to share a name.
self.server_a = _server("homelab-dev", "10.10.10.2")
self.server_b = _server("devserver", "192.168.43.254")
network = IPAMNetwork(name="Home-Net", network="10.10.10.0/24",
gateway="10.10.10.1", is_active=True)
db_session.add(network)
db_session.commit()
# Same guest name on both servers — the real-world "wireguard" case.
self.linked = VMInstance(server_id=self.server_a.id, vmid=107, node="dev",
vm_type="lxc", name="wireguard", status="running")
self.other = VMInstance(server_id=self.server_b.id, vmid=102, node="dev",
vm_type="lxc", name="wireguard", status="running")
db_session.add_all([self.linked, self.other])
db_session.commit()
db_session.add(IPAMAllocation(
ip_address="10.10.10.35", network_id=network.id, status="allocated",
resource_type="lxc", resource_name="wireguard",
proxmox_server_id=self.server_a.id, proxmox_vmid=107, proxmox_node="dev",
))
db_session.commit()
self.db = db_session
def _instances(self, client, headers):
response = client.get("/proxmox/api/virtual-machines", headers=headers)
assert response.status_code == 200
return {(vm["server_id"], vm["vmid"]): vm for vm in response.json()}
def test_allocation_is_not_shared_with_a_same_named_guest(self, client, admin_headers):
vms = self._instances(client, admin_headers)
assert vms[(self.server_a.id, 107)]["ip"] == "10.10.10.35"
# The namesake on the other server owns no allocation and has no cached IP.
assert vms[(self.server_b.id, 102)]["ip"] == ""
def test_cached_ip_still_wins_when_no_allocation_exists(self, client, admin_headers):
self.other.ip_address = "192.168.43.246"
self.db.commit()
vms = self._instances(client, admin_headers)
assert vms[(self.server_b.id, 102)]["ip"] == "192.168.43.246"
assert vms[(self.server_a.id, 107)]["ip"] == "10.10.10.35"
def test_allocation_hostname_does_not_leak_either(self, client, admin_headers):
"""A hostname on the allocation used to match guests by name as well."""
alloc = self.db.query(IPAMAllocation).one()
alloc.hostname = "wireguard"
self.db.commit()
vms = self._instances(client, admin_headers)
assert vms[(self.server_b.id, 102)]["ip"] == ""

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle } from 'lucide-react'; import { Plus, Pencil, Trash2, Database, CalendarClock, Power, PowerOff, AlertTriangle, Search } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
@@ -15,6 +15,7 @@ import {
useBackupStorages, useProxmoxBackupJobs, useBackupStorages, useProxmoxBackupJobs,
useCreateStorage, useUpdateStorage, useDeleteStorage, useCreateStorage, useUpdateStorage, useDeleteStorage,
useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob, useCreateProxmoxJob, useUpdateProxmoxJob, useDeleteProxmoxJob,
useScanNfsExports,
type ProxmoxJobInput, type ProxmoxJobInput,
} from '@/hooks/use-backups'; } from '@/hooks/use-backups';
import { formatBytes } from '@/lib/format'; import { formatBytes } from '@/lib/format';
@@ -88,6 +89,8 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
const { t } = useTranslation(); const { t } = useTranslation();
const createStorage = useCreateStorage(serverId); const createStorage = useCreateStorage(serverId);
const updateStorage = useUpdateStorage(serverId); const updateStorage = useUpdateStorage(serverId);
const scanNfs = useScanNfsExports(serverId);
const [nfsExports, setNfsExports] = useState<string[]>([]);
const [form, setForm] = useState<StorageForm>(() => editing ? { const [form, setForm] = useState<StorageForm>(() => editing ? {
...emptyForm, ...emptyForm,
storage: editing.storage, storage: editing.storage,
@@ -102,6 +105,20 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
} : emptyForm); } : emptyForm);
const set = <K extends keyof StorageForm>(k: K, v: StorageForm[K]) => setForm(p => ({ ...p, [k]: v })); const set = <K extends keyof StorageForm>(k: K, v: StorageForm[K]) => setForm(p => ({ ...p, [k]: v }));
const handleScanNfs = () => {
if (!form.server.trim()) { toast.error(t('storages.server_required')); return; }
setNfsExports([]);
scanNfs.mutate(form.server.trim(), {
onSuccess: (data) => {
const paths = (data.exports ?? []).map(e => e.path).filter(Boolean);
setNfsExports(paths);
if (paths.length === 0) toast.error(t('storages.no_exports_found'));
else if (!paths.includes(form.export)) set('export', paths[0]);
},
onError: (e: Error) => toast.error(e.message),
});
};
const submit = () => { const submit = () => {
if (!editing && !form.storage.trim()) { toast.error(t('storages.id_required')); return; } if (!editing && !form.storage.trim()) { toast.error(t('storages.id_required')); return; }
const done = (msg: string) => { toast.success(msg); onOpenChange(false); }; const done = (msg: string) => { toast.success(msg); onOpenChange(false); };
@@ -185,8 +202,27 @@ function StorageDialog({ serverId, open, onOpenChange, editing }: {
)} )}
{!editing && form.type === 'nfs' && ( {!editing && form.type === 'nfs' && (
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div><Label>{t('storages.server')}</Label><Input value={form.server} onChange={e => set('server', e.target.value)} className="mt-1" placeholder="10.0.0.5" /></div> <div>
<div><Label>{t('storages.export')}</Label><Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" /></div> <Label>{t('storages.server')}</Label>
<div className="mt-1 flex gap-1.5">
<Input value={form.server} onChange={e => { set('server', e.target.value); setNfsExports([]); }} placeholder="10.0.0.5" />
<Button type="button" variant="outline" size="icon" className="shrink-0" title={t('storages.scan_exports')}
disabled={scanNfs.isPending} onClick={handleScanNfs}>
<Search className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<Label>{t('storages.export')}</Label>
{nfsExports.length > 0 ? (
<Select value={form.export} onValueChange={v => v && set('export', v)}>
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
<SelectContent>{nfsExports.map(p => <SelectItem key={p} value={p}>{p}</SelectItem>)}</SelectContent>
</Select>
) : (
<Input value={form.export} onChange={e => set('export', e.target.value)} className="mt-1" placeholder="/exports/backup" />
)}
</div>
</div> </div>
)} )}
{!editing && form.type === 'cifs' && ( {!editing && form.type === 'cifs' && (

View File

@@ -73,6 +73,14 @@ interface VNCData {
auth_ticket?: string; auth_ticket?: string;
} }
// Сессия консоли переживает один проход эффекта: connect* асинхронны, а React
// StrictMode в dev монтирует эффект дважды. Без флага отмены вторая пара
// xterm/RFB ложится в тот же контейнер поверх первой — «осиротевший» терминал
// остаётся в DOM и занимает видимую область, из-за чего экран выглядит пустым.
interface SessionGuard {
cancelled: boolean;
}
interface RFBHandle { interface RFBHandle {
disconnect: () => void; disconnect: () => void;
sendCtrlAltDel: () => void; sendCtrlAltDel: () => void;
@@ -108,12 +116,37 @@ export default function ConsolePage() {
const vid = Number(vmid); const vid = Number(vmid);
const power = usePowerAction(sid, vid, type, node); const power = usePowerAction(sid, vid, type, node);
const guardRef = useRef<SessionGuard | null>(null);
// Освобождает ресурсы текущей сессии (RFB / xterm / WebSocket / keepalive).
const cleanupSession = useCallback(() => {
if (rfbRef.current) {
try { rfbRef.current.disconnect(); } catch { /* */ }
rfbRef.current = null;
}
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (keepaliveRef.current) {
clearInterval(keepaliveRef.current);
keepaliveRef.current = null;
}
if (termRef.current) {
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
t.cleanup();
t.terminal.dispose();
termRef.current = null;
}
}, []);
// ==================== VNC Console (QEMU) ==================== // ==================== VNC Console (QEMU) ====================
const connectVNC = useCallback(async () => { const connectVNC = useCallback(async (guard: SessionGuard) => {
try { try {
const data = await apiClient.get<VNCData>( const data = await apiClient.get<VNCData>(
`/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}` `/proxmox/api/${sid}/vm/${vid}/vnc?node=${node}`
); );
if (guard.cancelled) return;
// Dynamic import noVNC. The package ships CJS, so depending on bundler // Dynamic import noVNC. The package ships CJS, so depending on bundler
// interop the result can be: { default: RFB }, { default: { default: RFB, __esModule: true } }, // interop the result can be: { default: RFB }, { default: { default: RFB, __esModule: true } },
@@ -149,7 +182,7 @@ export default function ConsolePage() {
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`; const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/vnc/${sid}/${data.node}/qemu/${vid}?${params}`;
if (!containerRef.current) return; if (guard.cancelled || !containerRef.current) return;
// Pass URL string to RFB, NOT an open WebSocket // Pass URL string to RFB, NOT an open WebSocket
const rfb = new RFB(containerRef.current, wsUrl, { const rfb = new RFB(containerRef.current, wsUrl, {
@@ -160,10 +193,12 @@ export default function ConsolePage() {
rfb.resizeSession = false; rfb.resizeSession = false;
rfb.addEventListener('connect', () => { rfb.addEventListener('connect', () => {
if (guard.cancelled) return;
setStatus('connected'); setStatus('connected');
}); });
rfb.addEventListener('disconnect', (e: CustomEvent) => { rfb.addEventListener('disconnect', (e: CustomEvent) => {
if (guard.cancelled) return;
if (e.detail?.clean) { if (e.detail?.clean) {
setStatus('error'); setStatus('error');
setErrorMsg('Connection closed'); setErrorMsg('Connection closed');
@@ -176,6 +211,7 @@ export default function ConsolePage() {
rfbRef.current = rfb as RFBHandle; rfbRef.current = rfb as RFBHandle;
setScaleToFit(true); setScaleToFit(true);
} catch (err) { } catch (err) {
if (guard.cancelled) return;
setStatus('error'); setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect'); setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
} }
@@ -183,7 +219,7 @@ export default function ConsolePage() {
// ==================== xterm.js session (LXC terminal / VM serial) ==================== // ==================== xterm.js session (LXC terminal / VM serial) ====================
// Общая обвязка xterm + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси. // Общая обвязка xterm + WebSocket. wsPath — путь до нужного ws-эндпоинта Proxmox-прокси.
const setupXtermSession = useCallback(async (wsPath: string) => { const setupXtermSession = useCallback(async (wsPath: string, guard: SessionGuard) => {
// Dynamic import xterm.js // Dynamic import xterm.js
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([ const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'), import('@xterm/xterm'),
@@ -191,7 +227,7 @@ export default function ConsolePage() {
import('@xterm/addon-web-links'), import('@xterm/addon-web-links'),
]); ]);
if (!containerRef.current) return; if (guard.cancelled || !containerRef.current) return;
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon(); const webLinksAddon = new WebLinksAddon();
@@ -219,9 +255,12 @@ export default function ConsolePage() {
const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`; const wsUrl = `${wsProto}//${window.location.host}${wsPath}${tokenParam}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
if (guard.cancelled) {
ws.close();
return;
}
setStatus('connected'); setStatus('connected');
const { cols, rows } = terminal; const { cols, rows } = terminal;
ws.send(`1:${cols}:${rows}:`); ws.send(`1:${cols}:${rows}:`);
@@ -231,11 +270,20 @@ export default function ConsolePage() {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (guard.cancelled) return;
if (event.data instanceof ArrayBuffer) terminal.write(new Uint8Array(event.data)); if (event.data instanceof ArrayBuffer) terminal.write(new Uint8Array(event.data));
else terminal.write(event.data); else terminal.write(event.data);
}; };
ws.onclose = () => { setStatus('error'); setErrorMsg('Terminal connection closed'); }; ws.onclose = () => {
ws.onerror = () => { setStatus('error'); setErrorMsg('Terminal connection error'); }; if (guard.cancelled) return;
setStatus('error');
setErrorMsg('Terminal connection closed');
};
ws.onerror = () => {
if (guard.cancelled) return;
setStatus('error');
setErrorMsg('Terminal connection error');
};
terminal.onData((data: string) => { terminal.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@@ -249,62 +297,75 @@ export default function ConsolePage() {
const handleResize = () => fitAddon.fit(); const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
// Сессию могли отменить, пока грузились чанки xterm. Убираем за собой сами и
// НЕ трогаем refs: они уже могут указывать на актуальную сессию. Иначе в
// контейнере остаётся невидимый терминал поверх живого — чёрный экран.
if (guard.cancelled) {
window.removeEventListener('resize', handleResize);
terminal.dispose();
ws.close();
return;
}
wsRef.current = ws;
termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) }; termRef.current = { terminal, fitAddon, cleanup: () => window.removeEventListener('resize', handleResize) };
}, []); }, []);
// ==================== Terminal Console (LXC) ==================== // ==================== Terminal Console (LXC) ====================
const connectTerminal = useCallback(async () => { const connectTerminal = useCallback(async (guard: SessionGuard) => {
try { try {
const data = await apiClient.get<VNCData>( // Termproxy-сессию создаёт сам ws-эндпоинт. Отдельный REST-вызов
`/proxmox/api/${sid}/container/${vid}/terminal?node=${node}` // (GET .../container/{vmid}/terminal) поднимал бы на ноде вторую
); // termproxy-сессию, к которой никто не подключается: Proxmox ждёт клиента
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${data.node}/${vid}`); // ~10 секунд и завершает задачу с «failed waiting for client: timed out».
await setupXtermSession(`/proxmox/ws/terminal/${sid}/${node}/${vid}`, guard);
} catch (err) { } catch (err) {
if (guard.cancelled) return;
setStatus('error'); setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect'); setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
} }
}, [sid, vid, node, setupXtermSession]); }, [sid, vid, node, setupXtermSession]);
// ==================== Serial Console (QEMU) ==================== // ==================== Serial Console (QEMU) ====================
const connectSerial = useCallback(async () => { const connectSerial = useCallback(async (guard: SessionGuard) => {
try { try {
// Убедиться, что у VM есть serial0 (добавит при отсутствии) // Убедиться, что у VM есть serial0 (добавит при отсутствии)
await apiClient.post(`/proxmox/api/${sid}/vm/${vid}/serial/enable?node=${node}`); await apiClient.post(`/proxmox/api/${sid}/vm/${vid}/serial/enable?node=${node}`);
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`); if (guard.cancelled) return;
await setupXtermSession(`/proxmox/ws/serial/${sid}/${node}/${vid}`, guard);
} catch (err) { } catch (err) {
if (guard.cancelled) return;
setStatus('error'); setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect'); setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
} }
}, [sid, vid, node, setupXtermSession]); }, [sid, vid, node, setupXtermSession]);
// ==================== Connect on mount ==================== // ==================== Connect on mount ====================
// Старт новой сессии: старая помечается отменённой и полностью убирается,
// чтобы в контейнере всегда был ровно один живой терминал/RFB.
const startSession = useCallback(() => {
if (guardRef.current) guardRef.current.cancelled = true;
cleanupSession();
const guard: SessionGuard = { cancelled: false };
guardRef.current = guard;
setStatus('connecting');
setErrorMsg('');
if (isSerial) connectSerial(guard);
else if (type === 'qemu') connectVNC(guard);
else connectTerminal(guard);
}, [isSerial, type, connectSerial, connectVNC, connectTerminal, cleanupSession]);
useEffect(() => { useEffect(() => {
if (isSerial) { startSession();
connectSerial();
} else if (type === 'qemu') {
connectVNC();
} else {
connectTerminal();
}
return () => { return () => {
// Cleanup if (guardRef.current) guardRef.current.cancelled = true;
if (rfbRef.current) { cleanupSession();
try { (rfbRef.current as { disconnect: () => void }).disconnect(); } catch { /* */ }
}
if (wsRef.current) {
wsRef.current.close();
}
if (keepaliveRef.current) {
clearInterval(keepaliveRef.current);
}
if (termRef.current) {
const t = termRef.current as { terminal: { dispose: () => void }; cleanup: () => void };
t.cleanup();
t.terminal.dispose();
}
}; };
}, [type, isSerial, connectVNC, connectTerminal, connectSerial]); }, [startSession, cleanupSession]);
// ==================== Fullscreen ==================== // ==================== Fullscreen ====================
const toggleFullscreen = () => { const toggleFullscreen = () => {
@@ -574,13 +635,7 @@ export default function ConsolePage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={startSession}
setStatus('connecting');
setErrorMsg('');
if (isSerial) connectSerial();
else if (type === 'qemu') connectVNC();
else connectTerminal();
}}
> >
{t('console.reconnect')} {t('console.reconnect')}
</Button> </Button>

View File

@@ -25,10 +25,30 @@ export default function NodeShellPage() {
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null); const keepaliveRef = useRef<ReturnType<typeof setInterval> | null>(null);
const termRef = useRef<{ terminal: { dispose: () => void }; cleanup: () => void } | null>(null); const termRef = useRef<{ terminal: { dispose: () => void }; cleanup: () => void } | null>(null);
// Флаг отмены: connect() асинхронен, а React StrictMode в dev монтирует эффект
// дважды — без него второй xterm ложится в контейнер поверх первого и видимой
// остаётся «осиротевшая» пустая сессия.
const guardRef = useRef<{ cancelled: boolean } | null>(null);
const sid = Number(serverId); const sid = Number(serverId);
const connect = useCallback(async () => { const cleanupSession = useCallback(() => {
if (wsRef.current) {
wsRef.current.close();
wsRef.current = null;
}
if (keepaliveRef.current) {
clearInterval(keepaliveRef.current);
keepaliveRef.current = null;
}
if (termRef.current) {
termRef.current.cleanup();
termRef.current.terminal.dispose();
termRef.current = null;
}
}, []);
const connect = useCallback(async (guard: { cancelled: boolean }) => {
try { try {
const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([ const [{ Terminal }, { FitAddon }, { WebLinksAddon }] = await Promise.all([
import('@xterm/xterm'), import('@xterm/xterm'),
@@ -36,7 +56,7 @@ export default function NodeShellPage() {
import('@xterm/addon-web-links'), import('@xterm/addon-web-links'),
]); ]);
if (!containerRef.current) return; if (guard.cancelled || !containerRef.current) return;
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
const webLinksAddon = new WebLinksAddon(); const webLinksAddon = new WebLinksAddon();
@@ -65,9 +85,12 @@ export default function NodeShellPage() {
const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/node-shell/${sid}/${node}?${params}`; const wsUrl = `${wsProto}//${window.location.host}/proxmox/ws/node-shell/${sid}/${node}?${params}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
if (guard.cancelled) {
ws.close();
return;
}
setStatus('connected'); setStatus('connected');
const { cols, rows } = terminal; const { cols, rows } = terminal;
ws.send(`1:${cols}:${rows}:`); ws.send(`1:${cols}:${rows}:`);
@@ -77,6 +100,7 @@ export default function NodeShellPage() {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (guard.cancelled) return;
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
terminal.write(new Uint8Array(event.data)); terminal.write(new Uint8Array(event.data));
} else { } else {
@@ -85,10 +109,12 @@ export default function NodeShellPage() {
}; };
ws.onclose = () => { ws.onclose = () => {
if (guard.cancelled) return;
setStatus('error'); setStatus('error');
setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто')); setErrorMsg(t('console.terminal_closed', 'Соединение с терминалом закрыто'));
}; };
ws.onerror = () => { ws.onerror = () => {
if (guard.cancelled) return;
setStatus('error'); setStatus('error');
setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом')); setErrorMsg(t('console.terminal_error', 'Ошибка соединения с терминалом'));
}; };
@@ -106,24 +132,43 @@ export default function NodeShellPage() {
const handleResize = () => fitAddon.fit(); const handleResize = () => fitAddon.fit();
window.addEventListener('resize', handleResize); window.addEventListener('resize', handleResize);
// Сессию могли отменить, пока грузились чанки xterm. Убираем за собой сами
// и не трогаем refs — они уже могут указывать на актуальную сессию.
if (guard.cancelled) {
window.removeEventListener('resize', handleResize);
terminal.dispose();
ws.close();
return;
}
wsRef.current = ws;
termRef.current = { terminal, cleanup: () => window.removeEventListener('resize', handleResize) }; termRef.current = { terminal, cleanup: () => window.removeEventListener('resize', handleResize) };
} catch (err) { } catch (err) {
if (guard.cancelled) return;
setStatus('error'); setStatus('error');
setErrorMsg(err instanceof Error ? err.message : 'Failed to connect'); setErrorMsg(err instanceof Error ? err.message : 'Failed to connect');
} }
}, [sid, node, cmd, t]); }, [sid, node, cmd, t]);
const startSession = useCallback(() => {
if (guardRef.current) guardRef.current.cancelled = true;
cleanupSession();
const guard = { cancelled: false };
guardRef.current = guard;
setStatus('connecting');
setErrorMsg('');
connect(guard);
}, [connect, cleanupSession]);
useEffect(() => { useEffect(() => {
connect(); startSession();
return () => { return () => {
if (wsRef.current) wsRef.current.close(); if (guardRef.current) guardRef.current.cancelled = true;
if (keepaliveRef.current) clearInterval(keepaliveRef.current); cleanupSession();
if (termRef.current) {
termRef.current.cleanup();
termRef.current.terminal.dispose();
}
}; };
}, [connect]); }, [startSession, cleanupSession]);
const toggleFullscreen = () => { const toggleFullscreen = () => {
if (!document.fullscreenElement) { if (!document.fullscreenElement) {
@@ -182,11 +227,7 @@ export default function NodeShellPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => { onClick={startSession}
setStatus('connecting');
setErrorMsg('');
connect();
}}
> >
{t('console.reconnect', 'Переподключиться')} {t('console.reconnect', 'Переподключиться')}
</Button> </Button>

View File

@@ -0,0 +1,235 @@
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useIPAMNetworks, useIPAMPools, useAddGuestAddress } from '@/hooks/use-ipam';
import { toast } from 'sonner';
const IP_RE = /^\d{1,3}(\.\d{1,3}){3}$/;
interface Props {
open: boolean;
onClose: () => void;
serverId: number;
vmid: number;
/** Имена интерфейсов гостя, среди которых можно выбрать целевой */
interfaces: string[];
}
export default function AddGuestIPDialog({ open, onClose, serverId, vmid, interfaces }: Props) {
const { t } = useTranslation();
// Диалог монтируется только на время показа (см. NetworkingTab), поэтому
// состояние достаточно задать начальными значениями — сбрасывать эффектом
// при каждом открытии не нужно.
const [networkId, setNetworkId] = useState('');
const [poolId, setPoolId] = useState('');
const [mode, setMode] = useState<'auto' | 'manual'>('auto');
const [ip, setIp] = useState('');
const [iface, setIface] = useState(interfaces[0] ?? '');
const [makePrimary, setMakePrimary] = useState(false);
const { data: networks } = useIPAMNetworks();
const { data: pools } = useIPAMPools(networkId ? Number(networkId) : undefined);
const addAddress = useAddGuestAddress(serverId, vmid);
const activeNetworks = useMemo(
() => (networks ?? []).filter((n) => n.is_active !== false),
[networks],
);
const submit = () => {
if (!networkId) {
toast.error(t('ipam.err_network_required', 'Выберите сеть'));
return;
}
if (mode === 'manual' && !IP_RE.test(ip)) {
toast.error(t('ipam.err_bad_ip', 'Некорректный IP-адрес'));
return;
}
addAddress.mutate(
{
network_id: Number(networkId),
pool_id: poolId ? Number(poolId) : undefined,
ip_address: mode === 'manual' ? ip : undefined,
target_interface: iface || undefined,
make_primary: makePrimary,
},
{
onSuccess: (res) => {
const address = res.address?.ip_address ?? '';
if (res.applied) {
if (res.apply_status === 'runtime_only') {
toast.warning(
t('ipam.address_runtime_only', {
defaultValue: '{{ip}} поднят, но не закреплён: стек сети гостя не распознан',
ip: address,
}),
);
} else {
toast.success(
t('ipam.address_added', { defaultValue: 'Адрес {{ip}} назначен', ip: address }),
);
}
} else {
// Адрес всё равно зарезервирован — применить можно позже
toast.warning(
t('ipam.address_reserved_not_applied', {
defaultValue: '{{ip}} зарезервирован, но не применён: {{error}}',
ip: address,
error: res.error ?? '',
}),
);
}
onClose();
},
onError: (e) => toast.error((e as Error).message),
},
);
};
return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Globe className="h-4 w-4" />
{t('ipam.add_address_title', 'Добавить IP-адрес')}
</DialogTitle>
<DialogDescription>
{t(
'ipam.add_address_description',
'Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('ipam.network', 'Сеть')}</Label>
<Select value={networkId} onValueChange={(v) => { setNetworkId(v ?? ''); setPoolId(''); }}>
<SelectTrigger>
<SelectValue placeholder={t('ipam.select_network', 'Выберите сеть')} />
</SelectTrigger>
<SelectContent>
{activeNetworks.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>
<span className="flex items-center gap-2">
{n.name}
<span className="text-xs text-muted-foreground">{n.network}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label>{t('ipam.pool', 'Пул')}</Label>
<Select value={poolId} onValueChange={(v) => setPoolId(v ?? '')} disabled={!networkId}>
<SelectTrigger>
<SelectValue placeholder={t('ipam.any_pool', 'Любой')} />
</SelectTrigger>
<SelectContent>
{(pools ?? []).map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
<span className="flex items-center gap-2">
{p.name}
<span className="text-xs text-muted-foreground">
{p.range_start} – {p.range_end}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label>{t('ipam.address_mode', 'Адрес')}</Label>
<Select value={mode} onValueChange={(v) => setMode(v as 'auto' | 'manual')}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t('ipam.next_free', 'Следующий свободный')}</SelectItem>
<SelectItem value="manual">{t('ipam.manual_ip', 'Указать вручную')}</SelectItem>
</SelectContent>
</Select>
</div>
{mode === 'manual' && (
<div className="space-y-1.5">
<Label htmlFor="guest-ip">IP</Label>
<Input
id="guest-ip"
value={ip}
onChange={(e) => setIp(e.target.value)}
placeholder="10.10.10.90"
className="font-mono"
/>
</div>
)}
</div>
<div className="space-y-1.5">
<Label>{t('ipam.target_interface', 'Интерфейс гостя')}</Label>
{interfaces.length > 0 ? (
<Select value={iface} onValueChange={(v) => setIface(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="eth0" />
</SelectTrigger>
<SelectContent>
{interfaces.map((name) => (
<SelectItem key={name} value={name}>{name}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={iface}
onChange={(e) => setIface(e.target.value)}
placeholder="eth0"
className="font-mono"
/>
)}
</div>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={makePrimary} onChange={(e) => setMakePrimary(e.target.checked)} />
{t('ipam.make_primary', 'Сделать основным адресом')}
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>{t('common.cancel', 'Отмена')}</Button>
<Button onClick={submit} disabled={addAddress.isPending || !networkId}>
{t('ipam.add_address_submit', 'Выдать адрес')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -1031,16 +1031,33 @@ export default function InstancesPage() {
}, },
{ {
id: 'ip', id: 'ip',
accessorKey: 'ip_address', // Поиск идёт по всем адресам гостя, а показывается основной
accessorFn: (vm) => [vm.ip_address, ...(vm.ips ?? []).map((a) => a.ip)]
.filter(Boolean)
.join(' '),
filterFn: 'includesString', filterFn: 'includesString',
meta: { filter: 'text' }, meta: { filter: 'text' },
header: 'IP', header: 'IP',
cell: ({ getValue }) => { cell: ({ row }) => {
const ip = getValue<string>(); const vm = row.original;
return ip ? ( const primary = vm.ip_address;
<span className="font-mono text-xs">{ip}</span> const extra = (vm.ips ?? []).filter((a) => a.ip !== primary);
) : ( if (!primary) {
<span className="text-xs text-muted-foreground">—</span> return <span className="text-xs text-muted-foreground">—</span>;
}
return (
<span className="flex items-center gap-1.5">
<span className="font-mono text-xs">{primary}</span>
{extra.length > 0 && (
<Badge
variant="secondary"
className="text-2xs"
title={extra.map((a) => a.ip).join(', ')}
>
{t('ipam.extra_addresses', { defaultValue: 'ещё {{count}}', count: extra.length })}
</Badge>
)}
</span>
); );
}, },
size: 130, size: 130,

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Globe, Wifi, Plus, Trash2 } from 'lucide-react'; import { Globe, Wifi, Plus, Trash2, Star, RefreshCw } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -13,9 +13,17 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useVMInterfaces, useVMConfig, useVMStatus, useUpdateConfig } from '@/hooks/use-instances'; import { useVMInterfaces, useVMConfig, useVMStatus, useUpdateConfig } from '@/hooks/use-instances';
import {
useGuestAddresses,
useApplyGuestAddress,
useSetPrimaryGuestAddress,
useRemoveGuestAddress,
} from '@/hooks/use-ipam';
import type { GuestAddress } from '@/types';
import { formatBytes } from '@/lib/format'; import { formatBytes } from '@/lib/format';
import { toast } from 'sonner'; import { toast } from 'sonner';
import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog'; import AddNetworkInterfaceDialog from '../AddNetworkInterfaceDialog';
import AddGuestIPDialog from '../AddGuestIPDialog';
interface Props { interface Props {
serverId: number; serverId: number;
@@ -41,6 +49,23 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
.sort(([a], [b]) => a.localeCompare(b)) .sort(([a], [b]) => a.localeCompare(b))
: []; : [];
// Имена интерфейсов гостя для выбора цели alias-адреса: сперва то, что
// реально видно в госте, иначе имена из конфига (LXC пишет name=ethN).
const guestInterfaces = (() => {
const live = (ifaces?.interfaces ?? [])
.map((i) => i.name)
.filter((name): name is string => !!name && name !== 'lo');
if (live.length > 0) return live;
return netDevices
.map(([, value]) =>
String(value)
.split(',')
.find((part) => part.startsWith('name='))
?.slice(5),
)
.filter((name): name is string => !!name);
})();
const removeNic = () => { const removeNic = () => {
if (!deleteTarget) return; if (!deleteTarget) return;
updateConfig.mutate( updateConfig.mutate(
@@ -73,6 +98,9 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
</Card> </Card>
</div> </div>
{/* IPAM addresses of this guest */}
<GuestAddressesCard serverId={serverId} vmid={vmid} interfaces={guestInterfaces} />
{/* Configured network devices */} {/* Configured network devices */}
<Card> <Card>
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@@ -199,3 +227,213 @@ export default function NetworkingTab({ serverId, vmid, type, node }: Props) {
</div> </div>
); );
} }
function GuestAddressesCard({ serverId, vmid, interfaces }: {
serverId: number;
vmid: number;
interfaces: string[];
}) {
const { t } = useTranslation();
const { data, isLoading } = useGuestAddresses(serverId, vmid);
const applyAddress = useApplyGuestAddress(serverId, vmid);
const setPrimary = useSetPrimaryGuestAddress(serverId, vmid);
const removeAddress = useRemoveGuestAddress(serverId, vmid);
const [showAdd, setShowAdd] = useState(false);
const [releaseTarget, setReleaseTarget] = useState<GuestAddress | null>(null);
const addresses = data?.addresses ?? [];
// Статус применения alias-адреса: applied — закреплён в конфиге гостя,
// runtime_only — поднят, но не переживёт перезагрузку, pending/failed —
// адрес зарезервирован в IPAM, но на гостя не попал.
const statusBadge = (address: GuestAddress) => {
if (address.assignment_kind !== 'alias') {
return <Badge variant="secondary" className="text-xs">{t('ipam.from_nic_config', 'из конфига NIC')}</Badge>;
}
switch (address.apply_status) {
case 'applied':
return <Badge variant="secondary" className="text-xs">{t('ipam.applied', 'применён')}</Badge>;
case 'runtime_only':
return (
<Badge
variant="outline"
className="text-xs"
title={t('ipam.runtime_only_hint', 'Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет')}
>
{t('ipam.runtime_only', 'до перезагрузки')}
</Badge>
);
case 'pending':
return (
<Badge variant="outline" className="text-xs" title={address.apply_error ?? undefined}>
{t('ipam.not_applied', 'не применён')}
</Badge>
);
case 'failed':
return (
<Badge variant="destructive" className="text-xs" title={address.apply_error ?? undefined}>
{t('common.error', 'Ошибка')}
</Badge>
);
default:
return <Badge variant="outline" className="text-xs">{t('ipam.discovered', 'обнаружен')}</Badge>;
}
};
const release = () => {
if (!releaseTarget) return;
removeAddress.mutate(releaseTarget.id, {
onSuccess: (res) => {
if (res.removed_from_guest) {
toast.success(t('ipam.address_released', { defaultValue: 'Адрес {{ip}} освобождён', ip: res.released }));
} else {
toast.warning(
t('ipam.address_released_not_removed', {
defaultValue: '{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}',
ip: res.released,
error: res.error ?? '',
}),
);
}
setReleaseTarget(null);
},
onError: (e) => toast.error((e as Error).message),
});
};
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Globe className="h-4 w-4" />
{t('ipam.addresses', 'IP-адреса')}
{addresses.length > 0 && <Badge variant="secondary">{addresses.length}</Badge>}
</CardTitle>
<Button size="sm" variant="outline" onClick={() => setShowAdd(true)}>
<Plus className="mr-1.5 h-3.5 w-3.5" />
{t('ipam.add_address', 'Добавить IP')}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-2">
{isLoading ? (
<p className="py-4 text-center text-sm text-muted-foreground">{t('common.loading', 'Загрузка...')}</p>
) : addresses.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
{t('ipam.no_addresses', 'Адресов в IPAM нет')}
</p>
) : (
addresses.map((address) => (
<div key={address.id} className="rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-sm">
{address.ip_address}
{address.prefix ? `/${address.prefix}` : ''}
</span>
{address.is_primary && (
<Badge className="text-xs">{t('ipam.primary', 'основной')}</Badge>
)}
{statusBadge(address)}
{address.target_interface && (
<Badge variant="outline" className="text-xs">{address.target_interface}</Badge>
)}
</div>
<div className="flex items-center gap-1">
{address.assignment_kind === 'alias' && address.apply_status !== 'applied' && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={applyAddress.isPending}
title={t('ipam.apply', 'Применить')}
onClick={() =>
applyAddress.mutate(address.id, {
onSuccess: (res) =>
res.applied
? toast.success(t('ipam.applied', 'применён'))
: toast.error(res.error ?? t('common.error', 'Ошибка')),
onError: (e) => toast.error((e as Error).message),
})
}
>
<RefreshCw className="h-3.5 w-3.5" />
</Button>
)}
{!address.is_primary && (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={setPrimary.isPending}
title={t('ipam.make_primary', 'Сделать основным адресом')}
onClick={() =>
setPrimary.mutate(address.id, {
onError: (e) => toast.error((e as Error).message),
})
}
>
<Star className="h-3.5 w-3.5" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive hover:text-destructive"
disabled={address.assignment_kind !== 'alias'}
title={
address.assignment_kind === 'alias'
? t('ipam.release', 'Освободить')
: t('ipam.primary_managed_by_nic', 'Основной адрес задаётся конфигом интерфейса')
}
onClick={() => setReleaseTarget(address)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{address.apply_error && (
<p className="mt-1 text-xs text-muted-foreground">{address.apply_error}</p>
)}
</div>
))
)}
</CardContent>
{showAdd && (
<AddGuestIPDialog
open
onClose={() => setShowAdd(false)}
serverId={serverId}
vmid={vmid}
interfaces={interfaces}
/>
)}
<Dialog open={!!releaseTarget} onOpenChange={(o) => !o && setReleaseTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('ipam.release_title', 'Освободить адрес?')}</DialogTitle>
<DialogDescription>
{t('ipam.release_description', {
defaultValue:
'Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.',
ip: releaseTarget?.ip_address ?? '',
})}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setReleaseTarget(null)}>
{t('common.cancel', 'Отмена')}
</Button>
<Button variant="destructive" onClick={release} disabled={removeAddress.isPending}>
{t('ipam.release', 'Освободить')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}

View File

@@ -44,7 +44,9 @@ export default function IPAMAllocationsPage() {
a.ip_address?.toLowerCase().includes(s) || a.ip_address?.toLowerCase().includes(s) ||
a.resource_name?.toLowerCase().includes(s) || a.resource_name?.toLowerCase().includes(s) ||
a.mac_address?.toLowerCase().includes(s) || a.mac_address?.toLowerCase().includes(s) ||
a.description?.toLowerCase().includes(s) a.description?.toLowerCase().includes(s) ||
// поиск по VMID, чтобы собрать все адреса одного гостя
String(a.proxmox_vmid ?? '').includes(s)
); );
} }
return true; return true;
@@ -108,12 +110,25 @@ export default function IPAMAllocationsPage() {
<TableBody> <TableBody>
{filtered.map(a => ( {filtered.map(a => (
<TableRow key={a.id}> <TableRow key={a.id}>
<TableCell className="font-mono">{a.ip_address}</TableCell> <TableCell className="font-mono">
<span className="flex items-center gap-1.5">
{a.ip_address}
{/* У гостя может быть несколько адресов — помечаем основной */}
{a.is_primary && (
<Badge variant="secondary" className="text-2xs">{t('ipam.primary')}</Badge>
)}
</span>
</TableCell>
<TableCell> <TableCell>
<Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge> <Badge variant={a.status === 'allocated' ? 'default' : a.status === 'reserved' ? 'secondary' : 'outline'}>{a.status}</Badge>
</TableCell> </TableCell>
<TableCell className="text-xs">{a.network_name || '—'}</TableCell> <TableCell className="text-xs">{a.network_name || '—'}</TableCell>
<TableCell className="text-xs">{a.resource_name || '—'}</TableCell> <TableCell className="text-xs">
{a.resource_name || '—'}
{a.proxmox_vmid != null && (
<span className="ml-1 text-muted-foreground">#{a.proxmox_vmid}</span>
)}
</TableCell>
<TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell> <TableCell className="font-mono text-xs">{a.mac_address || '—'}</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell> <TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">{a.description || '—'}</TableCell>
<TableCell> <TableCell>

View File

@@ -235,7 +235,20 @@ function UnlinkedCard() {
const handleLink = () => { const handleLink = () => {
link.mutate(undefined, { link.mutate(undefined, {
onSuccess: (r) => toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`), onSuccess: (r) => {
toast.success(`${t('ipam.linked')}: ${r.linked?.length ?? 0}`);
// Пропуски (дубль IP, недоступный сервер, адрес вне сетей IPAM) иначе
// видны только в логах бэкенда — показываем причины первых записей.
const skipped = r.not_found ?? [];
if (skipped.length > 0) {
toast.warning(t('ipam.link_skipped', { count: skipped.length }), {
description: skipped
.slice(0, 3)
.map((s) => `${s.resource_name}: ${s.reason}`)
.join('\n'),
});
}
},
onError: (e: Error) => toast.error(e.message), onError: (e: Error) => toast.error(e.message),
}); });
}; };

View File

@@ -1,5 +1,6 @@
import { memo } from 'react'; import { memo } from 'react';
import { Handle, Position, type NodeProps } from '@xyflow/react'; import { Handle, Position, type NodeProps } from '@xyflow/react';
import { Link } from 'react-router';
import { Box, Monitor } from 'lucide-react'; import { Box, Monitor } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -14,9 +15,10 @@ function GuestNodeImpl({ data }: NodeProps) {
const extraTags = guest.tags.length - VISIBLE_TAGS; const extraTags = guest.tags.length - VISIBLE_TAGS;
return ( return (
<div <Link
to={`/instances/${guest.server_id}/${guest.vmid}?node=${guest.node}&type=${guest.type}`}
className={cn( className={cn(
'w-[250px] rounded-md border bg-card px-2.5 py-2 shadow-sm', 'block w-[250px] rounded-md border bg-card px-2.5 py-2 shadow-sm transition-colors hover:border-primary/60 hover:bg-accent/40',
running ? 'border-emerald-500/50' : 'border-border', running ? 'border-emerald-500/50' : 'border-border',
)} )}
> >
@@ -47,7 +49,7 @@ function GuestNodeImpl({ data }: NodeProps) {
</div> </div>
) : null} ) : null}
<Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" /> <Handle type="source" position={Position.Bottom} isConnectable={false} className="!bg-border" />
</div> </Link>
); );
} }

View File

@@ -70,6 +70,15 @@ export function useDeleteProxmoxJob(serverId: number) {
}); });
} }
export function useScanNfsExports(serverId: number) {
return useMutation({
mutationFn: (server: string) =>
apiClient.get<{ exports: { path: string }[] }>(
`/proxmox/api/backups/scan-nfs/${serverId}?server=${encodeURIComponent(server)}`
),
});
}
export function useCreateStorage(serverId: number) { export function useCreateStorage(serverId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -1,6 +1,12 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@/lib/api-client'; import { apiClient } from '@/lib/api-client';
import type { IPAMNetwork, IPAMPool, IPAMAllocation } from '@/types'; import type {
IPAMNetwork,
IPAMPool,
IPAMAllocation,
GuestAddress,
GuestAddressApplyStatus,
} from '@/types';
export const ipamKeys = { export const ipamKeys = {
networks: ['ipam-networks'] as const, networks: ['ipam-networks'] as const,
@@ -251,11 +257,17 @@ export function useCleanupOrphans() {
}); });
} }
export interface IPAMLinkSkipped {
ip_address: string;
resource_name: string;
reason: string;
}
export function useLinkAllocations() { export function useLinkAllocations() {
const invalidate = useInvalidateIPAM(); const invalidate = useInvalidateIPAM();
return useMutation({ return useMutation({
mutationFn: (body?: { server_id?: number; network_id?: number }) => mutationFn: (body?: { server_id?: number; network_id?: number }) =>
apiClient.post<{ linked: unknown[]; not_found: unknown[] }>('/ipam/api/link-allocations', body ?? {}), apiClient.post<{ linked: unknown[]; not_found: IPAMLinkSkipped[] }>('/ipam/api/link-allocations', body ?? {}),
onSuccess: () => invalidate(), onSuccess: () => invalidate(),
}); });
} }
@@ -283,3 +295,95 @@ export function useIPHistoryLookup() {
mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`), mutationFn: (ip: string) => apiClient.get<unknown[]>(`/ipam/api/history/ip/${encodeURIComponent(ip)}`),
}); });
} }
// ==================== Адреса инстанса (несколько IP на гостя) ====================
export interface GuestAddressApplyResult {
address: GuestAddress;
applied: boolean;
apply_status: GuestAddressApplyStatus;
persist?: string | null;
error?: string | null;
}
export const guestAddressKey = (serverId: number, vmid: number) =>
['guest-addresses', serverId, vmid] as const;
export function useGuestAddresses(serverId: number, vmid: number, enabled = true) {
return useQuery({
queryKey: guestAddressKey(serverId, vmid),
queryFn: () =>
apiClient.get<{ addresses: GuestAddress[]; count: number }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
),
enabled: enabled && serverId > 0 && vmid > 0,
});
}
/** Инвалидация адресов гостя + всего, что показывает IP инстанса. */
function useInvalidateGuestAddresses(serverId: number, vmid: number) {
const qc = useQueryClient();
const invalidateIPAM = useInvalidateIPAM();
return () => {
qc.invalidateQueries({ queryKey: guestAddressKey(serverId, vmid) });
qc.invalidateQueries({ queryKey: ['instances'] });
qc.invalidateQueries({ queryKey: ['vms'] });
invalidateIPAM();
};
}
export function useAddGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (body: {
network_id: number;
pool_id?: number;
ip_address?: string;
target_interface?: string;
make_primary?: boolean;
hostname?: string;
notes?: string;
}) =>
apiClient.post<GuestAddressApplyResult>(
`/ipam/api/guests/${serverId}/${vmid}/addresses`,
body,
),
onSuccess: () => invalidate(),
});
}
export function useApplyGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.post<GuestAddressApplyResult>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/apply`,
{},
),
onSuccess: () => invalidate(),
});
}
export function useSetPrimaryGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.post<{ address: GuestAddress }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}/primary`,
{},
),
onSuccess: () => invalidate(),
});
}
export function useRemoveGuestAddress(serverId: number, vmid: number) {
const invalidate = useInvalidateGuestAddresses(serverId, vmid);
return useMutation({
mutationFn: (allocationId: number) =>
apiClient.delete<{ released: string; removed_from_guest: boolean; error?: string | null }>(
`/ipam/api/guests/${serverId}/${vmid}/addresses/${allocationId}`,
),
onSuccess: () => invalidate(),
});
}

View File

@@ -1246,6 +1246,9 @@
"path": "Path", "path": "Path",
"server": "Server", "server": "Server",
"export": "Export", "export": "Export",
"scan_exports": "Scan server for exports",
"server_required": "Enter the server address first",
"no_exports_found": "No exports found — check /etc/exports on the server",
"share": "Share", "share": "Share",
"datastore": "Datastore", "datastore": "Datastore",
"username": "Username", "username": "Username",
@@ -1416,6 +1419,7 @@
"no_unlinked": "All VMs are linked", "no_unlinked": "All VMs are linked",
"link_allocations": "Scan & link", "link_allocations": "Scan & link",
"linked": "Linked", "linked": "Linked",
"link_skipped": "Skipped: {{count}}",
"ip_history": "IP history lookup", "ip_history": "IP history lookup",
"pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.", "pool_required_hint": "Auto-allocation requires an auto-assign pool in the network.",
"resource_required": "Please specify a resource before allocating an IP.", "resource_required": "Please specify a resource before allocating an IP.",
@@ -1480,7 +1484,40 @@
"allocation_created": "Allocation created", "allocation_created": "Allocation created",
"allocation_updated": "Allocation updated", "allocation_updated": "Allocation updated",
"delete_allocation_confirm": "Release IP {{ip}}?", "delete_allocation_confirm": "Release IP {{ip}}?",
"notes": "Notes" "notes": "Notes",
"addresses": "IP addresses",
"add_address": "Add IP",
"add_address_title": "Add IP address",
"add_address_description": "The address is allocated in IPAM and attached to the guest interface as an alias.",
"add_address_submit": "Assign address",
"no_addresses": "No addresses in IPAM",
"primary": "primary",
"make_primary": "Make primary address",
"primary_managed_by_nic": "The primary address comes from the NIC config",
"from_nic_config": "from NIC config",
"applied": "applied",
"not_applied": "not applied",
"runtime_only": "until reboot",
"runtime_only_hint": "Address is up, but the guest network stack was not recognised — it will not survive a reboot",
"discovered": "discovered",
"apply": "Apply",
"release": "Release",
"release_title": "Release address?",
"release_description": "Address {{ip}} will be removed from the guest interface and released in IPAM.",
"address_added": "Address {{ip}} assigned",
"address_released": "Address {{ip}} released",
"address_released_not_removed": "{{ip}} released in IPAM, but could not be removed from the guest: {{error}}",
"address_runtime_only": "{{ip}} is up but not persisted: guest network stack was not recognised",
"address_reserved_not_applied": "{{ip}} reserved but not applied: {{error}}",
"address_mode": "Address",
"next_free": "Next free",
"manual_ip": "Enter manually",
"target_interface": "Guest interface",
"any_pool": "Any",
"pool": "Pool",
"err_network_required": "Select a network",
"err_bad_ip": "Invalid IP address",
"extra_addresses": "+{{count}} more"
}, },
"logs": { "logs": {
"level": "Level", "level": "Level",

View File

@@ -1228,6 +1228,9 @@
"path": "Путь", "path": "Путь",
"server": "Сервер", "server": "Сервер",
"export": "Экспорт", "export": "Экспорт",
"scan_exports": "Найти экспорты на сервере",
"server_required": "Сначала укажите адрес сервера",
"no_exports_found": "Экспорты не найдены — проверьте /etc/exports на сервере",
"share": "Шара", "share": "Шара",
"datastore": "Datastore", "datastore": "Datastore",
"username": "Пользователь", "username": "Пользователь",
@@ -1398,6 +1401,7 @@
"no_unlinked": "Все VM связаны", "no_unlinked": "Все VM связаны",
"link_allocations": "Сканировать и связать", "link_allocations": "Сканировать и связать",
"linked": "Связано", "linked": "Связано",
"link_skipped": "Пропущено: {{count}}",
"ip_history": "История по IP", "ip_history": "История по IP",
"pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.", "pool_required_hint": "Авто-выделение требует пул с auto-assign в сети.",
"resource_required": "Укажите ресурс перед выделением IP.", "resource_required": "Укажите ресурс перед выделением IP.",
@@ -1462,7 +1466,40 @@
"allocation_created": "Аллокация создана", "allocation_created": "Аллокация создана",
"allocation_updated": "Аллокация обновлена", "allocation_updated": "Аллокация обновлена",
"delete_allocation_confirm": "Освободить IP {{ip}}?", "delete_allocation_confirm": "Освободить IP {{ip}}?",
"notes": "Заметки" "notes": "Заметки",
"addresses": "IP-адреса",
"add_address": "Добавить IP",
"add_address_title": "Добавить IP-адрес",
"add_address_description": "Адрес выделяется в IPAM и навешивается на интерфейс гостя дополнительным (alias).",
"add_address_submit": "Выдать адрес",
"no_addresses": "Адресов в IPAM нет",
"primary": "основной",
"make_primary": "Сделать основным адресом",
"primary_managed_by_nic": "Основной адрес задаётся конфигом интерфейса",
"from_nic_config": "из конфига NIC",
"applied": "применён",
"not_applied": "не применён",
"runtime_only": "до перезагрузки",
"runtime_only_hint": "Адрес поднят, но стек сети гостя не распознан — после перезагрузки исчезнет",
"discovered": "обнаружен",
"apply": "Применить",
"release": "Освободить",
"release_title": "Освободить адрес?",
"release_description": "Адрес {{ip}} будет снят с интерфейса гостя и освобождён в IPAM.",
"address_added": "Адрес {{ip}} назначен",
"address_released": "Адрес {{ip}} освобождён",
"address_released_not_removed": "{{ip}} освобождён в IPAM, но снять его с гостя не удалось: {{error}}",
"address_runtime_only": "{{ip}} поднят, но не закреплён: стек сети гостя не распознан",
"address_reserved_not_applied": "{{ip}} зарезервирован, но не применён: {{error}}",
"address_mode": "Адрес",
"next_free": "Следующий свободный",
"manual_ip": "Указать вручную",
"target_interface": "Интерфейс гостя",
"any_pool": "Любой",
"pool": "Пул",
"err_network_required": "Выберите сеть",
"err_bad_ip": "Некорректный IP-адрес",
"extra_addresses": "ещё {{count}}"
}, },
"logs": { "logs": {
"level": "Уровень", "level": "Уровень",

View File

@@ -116,6 +116,14 @@ export interface VMInstance {
netin_rate?: number; netin_rate?: number;
netout_rate?: number; netout_rate?: number;
ip_address?: string; ip_address?: string;
/** Все адреса гостя; ip_address — основной из этого списка */
ips?: Array<{
ip: string;
is_primary: boolean;
assignment_kind?: 'primary' | 'alias';
target_interface?: string | null;
apply_status?: GuestAddressApplyStatus | null;
}>;
tags?: string; tags?: string;
template?: boolean; template?: boolean;
description?: string; description?: string;
@@ -397,10 +405,41 @@ export interface IPAMAllocation {
fqdn?: string; fqdn?: string;
allocation_type?: string; allocation_type?: string;
notes?: string; notes?: string;
/** Основной адрес гостя — он попадает в колонку IP списка инстансов */
is_primary?: boolean;
/** primary — задан конфигом NIC, alias — навешен внутри гостя */
assignment_kind?: 'primary' | 'alias';
target_interface?: string | null;
apply_status?: GuestAddressApplyStatus | null;
apply_error?: string | null;
applied_at?: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
/** Состояние применения alias-адреса к живому гостю */
export type GuestAddressApplyStatus = 'applied' | 'runtime_only' | 'pending' | 'failed';
/** Один адрес инстанса на вкладке «Сеть» */
export interface GuestAddress {
id: number;
ip_address: string;
prefix?: string | null;
network_id: number;
network_name?: string | null;
gateway?: string | null;
mac_address?: string | null;
status: string;
is_primary: boolean;
assignment_kind: 'primary' | 'alias';
target_interface?: string | null;
apply_status?: GuestAddressApplyStatus | null;
apply_error?: string | null;
applied_at?: string | null;
hostname?: string | null;
notes?: string | null;
}
// ==================== Template Types ==================== // ==================== Template Types ====================
export interface OSTemplateGroup { export interface OSTemplateGroup {

View File

@@ -507,7 +507,6 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
"template:view": true, "template:view": true,
"storage:view": true, "storage:view": true,
"backup:view": true, "backup:create": true, "backup:view": true, "backup:create": true,
"ipam:view": true,
"user:view": true, "user:view": true,
"log:view": true, "log:export": true, "log:view": true, "log:export": true,
"setting:view": true, "setting:view": true,
@@ -523,7 +522,6 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
"lxc:view": true, "lxc:start": true, "lxc:stop": true, "lxc:restart": true, "lxc:console": true, "lxc:view": true, "lxc:start": true, "lxc:stop": true, "lxc:restart": true, "lxc:console": true,
"template:view": true, "template:view": true,
"storage:view": true, "storage:view": true,
"ipam:view": true,
"notification:view": true, "notification:manage": true "notification:view": true, "notification:manage": true
}', TRUE, TRUE), }', TRUE, TRUE),
('demo', 'Demo User', 'Read-only access for demonstration', '{ ('demo', 'Demo User', 'Read-only access for demonstration', '{
@@ -532,8 +530,7 @@ INSERT INTO roles (name, display_name, description, permissions, is_system, is_a
"vm:view": true, "vm:view": true,
"lxc:view": true, "lxc:view": true,
"template:view": true, "template:view": true,
"storage:view": true, "storage:view": true
"ipam:view": true
}', TRUE, TRUE) }', TRUE, TRUE)
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;