Files
ipam_control/app/api/v1/prefixes.py
T
ayurishchevandClaude Opus 5.5 13e17fbb47 Задачи 011-024: доработки по ревью кодовой базы и исправление находок
Ревью кодовой базы (docs/reviews/2026-09-26-codebase-review.md) и планы по каждой находке:
011 IP уникален в VRF и хранится в самом узком префиксе (addresses.vrf_id, составной FK
    с каскадом при переносе VRF, миграция 0007 с остановкой на дублях).
012 Ограничение попыток входа (login_attempts, 429 + Retry-After), выравнивание времени
    ответа, журнал без вытеснения анонимными событиями (миграция 0006).
013 Границы пагинации: отрицательные/чрезмерные limit/offset дают 422 вместо 500.
014 Экран адресов: страница свободных адресов арифметикой, пагинация в SQL.
015 Запрет адреса сети/broadcast, загрузка не выше 100 %.
016 Роль по умолчанию — viewer.
017 Проверка JWT_SECRET/ADMIN_PASSWORD при старте.
018 null в PATCH очищает текстовые поля; нейтральный текст конфликта БД.
019 Пакетная загрузка в списках вместо N+1.
020 Автоназначение адреса вне вложенных префиксов, с блокировкой префикса.
021 Advisory-lock при снятии прав администратора, уникальный lower(username) (миграция 0008).
022 Контейнер не от root, healthcheck, блокировка миграций, requirements.lock.
023 Экранирование LIKE, журнал отказов очистки, заголовки безопасности, учёт force-удаления,
    отзыв токенов при смене пароля (claim pv, миграция 0005).
024 Исправление находок ревью 011-023 (docs/reviews/2026-09-26-changes-011-023-review.md):
    сериализация попыток входа, запрет переноса адресов в адрес сети/broadcast, журнал входов,
    запрет смены своего пароля через PATCH, валидация PATCH устройства, обновлён тест токенов.

Тесты: 14 passed. Документация: README.md, docs/changes/011-024, docs/reviews.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 21:33:50 +03:00

444 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import ipaddress
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import String, and_, cast, func, or_, select, text, update
from sqlalchemy.orm import Session
from app import schemas as s
from app.db import get_db
from app.models import Address, AddressStatus, Device, Organization, Prefix, PrefixStatus, User, Vrf
from app.security import admin_user, current_user
from app.services import (
MAX_CAPACITY, MAX_OFFSET, apply_update, audit, blockers, capacity, commit, count, flush, contains, free_page, get_or_404, network_role, next_free_address, next_free_subnet, refuse_delete,
utilization,
)
router = APIRouter(dependencies=[Depends(current_user)], tags=["prefixes"])
FREE_LISTING_LIMIT = 4096 # «свободные» строки показываем, только если подсеть не больше /20
# -------------------------------------------------------------------- prefixes
def _usage(db: Session, ids: list[int]) -> dict[int, tuple[int, int]]:
"""{prefix_id: (assigned, stored)} с учётом вложенных префиксов того же VRF."""
if not ids:
return {}
p, c = Prefix.__table__.alias("p"), Prefix.__table__.alias("c")
a = Address.__table__
rows = db.execute(
select(
p.c.id,
func.count().filter(a.c.status == AddressStatus.assigned),
func.count(a.c.id),
)
.select_from(p.join(c, and_(c.c.vrf_id == p.c.vrf_id, c.c.prefix.op("<<=")(p.c.prefix)))
.join(a, a.c.prefix_id == c.c.id))
.where(p.c.id.in_(ids))
.group_by(p.c.id)
).all()
return {r[0]: (r[1], r[2]) for r in rows}
def _depths(db: Session, organization_id: int) -> dict[int, int]:
parents = dict(db.execute(select(Prefix.id, Prefix.parent_id).where(Prefix.organization_id == organization_id)).all())
def depth(i: int) -> int:
d = 0
while parents.get(i) is not None:
i, d = parents[i], d + 1
return d
return {i: depth(i) for i in parents}
def _capacities(db: Session, organization_id: int) -> dict[int, int]:
"""Ёмкость листа — размер подсети; ёмкость родителя — сумма ёмкостей вложенных листьев."""
rows = db.execute(select(Prefix.id, Prefix.parent_id, Prefix.prefix).where(Prefix.organization_id == organization_id)).all()
kids: dict[int, list[int]] = {}
for i, parent, _ in rows:
if parent is not None:
kids.setdefault(parent, []).append(i)
own = {i: capacity(str(cidr)) for i, _, cidr in rows}
memo: dict[int, int] = {}
def cap(i: int) -> int:
if i not in memo:
memo[i] = sum(cap(k) for k in kids[i]) if i in kids else own[i]
return memo[i]
return {i: min(cap(i), MAX_CAPACITY) for i in own}
def _prefix_outs(db: Session, rows: list[Prefix]) -> list[s.PrefixOut]:
usage = _usage(db, [r.id for r in rows])
depths: dict[int, int] = {}
caps: dict[int, int] = {}
vrf_names = dict(db.execute(select(Vrf.id, Vrf.name).where(Vrf.id.in_(list({r.vrf_id for r in rows})))).all()) if rows else {}
for org_id in {r.organization_id for r in rows}:
depths.update(_depths(db, org_id))
caps.update(_capacities(db, org_id))
out = []
for r in rows:
used, stored = usage.get(r.id, (0, 0))
cap = caps.get(r.id, capacity(str(r.prefix)))
out.append(s.PrefixOut(
id=r.id, organization_id=r.organization_id, vrf_id=r.vrf_id, vrf_name=vrf_names[r.vrf_id],
prefix=str(r.prefix), family=ipaddress.ip_network(str(r.prefix)).version,
description=r.description, status=r.status, parent_id=r.parent_id, depth=depths.get(r.id, 0),
is_pool=r.is_pool, note=r.note, used=used, capacity=cap,
utilization=utilization(used, cap), addresses_count=stored,
))
return out
def attach_to_tree(db: Session, p: Prefix, keep_parent: bool = False, exclude: frozenset[int] = frozenset()) -> None:
"""Вписывает префикс в дерево его VRF: находит самого узкого родителя и забирает под себя
вложенные префиксы, чей текущий родитель шире (exclude — уже подчинённые ему, при переносе поддерева)."""
cidr = str(p.prefix)
if not keep_parent:
p.parent_id = db.scalar(
select(Prefix.id)
.where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op(">>")(cidr))
.order_by(func.masklen(Prefix.prefix).desc()).limit(1)
)
plen = ipaddress.ip_network(cidr).prefixlen
inner = db.scalars(
select(Prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(cidr))
).all()
for c in inner:
if c.id in exclude:
continue
cur = db.get(Prefix, c.parent_id) if c.parent_id else None
if cur is None or ipaddress.ip_network(str(cur.prefix)).prefixlen < plen:
c.parent_id = p.id
def _narrowest_prefix(db: Session, vrf_id: int, ip: str) -> Prefix | None:
"""Самый узкий префикс VRF, содержащий адрес."""
return db.scalar(select(Prefix).where(Prefix.vrf_id == vrf_id, Prefix.prefix.op(">>=")(ip)).order_by(func.masklen(Prefix.prefix).desc()).limit(1))
def _unusable_after_rehome(db: Session, p: Prefix) -> list[tuple[str, str]]:
"""(адрес, CIDR целевого префикса) для адресов VRF префикса p (его диапазон), которые при переносе в самый узкий
целевой префикс (как в rehome_addresses) окажутся его адресом сети или broadcast — такой перенос запрещён
(изменение 024, находка №2). Целевой префикс — не обязательно p: при переносе VRF им может оказаться уже существующий
вложенный префикс целевого VRF, поэтому в паре возвращается именно он, а не p."""
db.flush()
rows = db.execute(text(
"SELECT host(a2.address) AS ip, (SELECT x.prefix FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address "
" ORDER BY masklen(x.prefix) DESC LIMIT 1) AS cidr "
"FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).all()
return [(ip, cidr) for ip, cidr in rows if network_role(ipaddress.ip_network(cidr), ipaddress.ip_address(ip))]
def _unusable_message(bad: list[tuple[str, str]]) -> str:
items = ", ".join(f"{ip} ({cidr})" for ip, cidr in bad)
return f"Адреса {items} станут адресом сети/broadcast: освободите их или выберите другой префикс"
def rehome_addresses(db: Session, p: Prefix) -> int:
"""Приводит адреса диапазона префикса к правилу «адрес — в самом узком префиксе VRF»
(новый вложенный забирает адреса родителя из своего диапазона; перенесённый префикс — адреса целевого VRF). Возвращает число перенесённых."""
db.flush()
return db.execute(text(
"UPDATE addresses a SET prefix_id = t.best FROM ("
" SELECT a2.id AS aid, (SELECT x.id FROM prefixes x WHERE x.vrf_id = a2.vrf_id AND x.prefix >>= a2.address ORDER BY masklen(x.prefix) DESC LIMIT 1) AS best "
" FROM addresses a2 WHERE a2.vrf_id = :vrf AND a2.address <<= :cidr) t "
"WHERE a.id = t.aid AND t.best IS NOT NULL AND t.best <> a.prefix_id"), {"vrf": p.vrf_id, "cidr": str(p.prefix)}).rowcount
def _subtree(db: Session, root: Prefix) -> list[Prefix]:
"""Префикс и все вложенные по цепочке parent_id."""
result, frontier = [root], [root.id]
while frontier:
kids = db.scalars(select(Prefix).where(Prefix.parent_id.in_(frontier))).all()
result += kids
frontier = [k.id for k in kids]
return result
def _move_to_vrf(db: Session, p: Prefix, vrf_id: int) -> tuple[dict, int]:
"""Переносит префикс с поддеревом в другой VRF той же организации; возвращает (diff, число префиксов)."""
vrf = get_or_404(db, Vrf, vrf_id, "VRF")
if vrf.organization_id != p.organization_id:
raise HTTPException(422, "Целевой VRF принадлежит другой организации")
subtree = _subtree(db, p)
taken = db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == vrf.id, Prefix.prefix.in_([str(m.prefix) for m in subtree]))).all()
if taken:
raise HTTPException(409, f"В VRF «{vrf.name}» уже есть: {', '.join(str(x) for x in taken)}")
clash = db.scalars(
select(func.host(Address.address)).where(
Address.prefix_id.in_([m.id for m in subtree]),
Address.address.in_(select(Address.address).where(Address.vrf_id == vrf.id)),
).limit(10)
).all()
if clash:
raise HTTPException(409, f"В VRF «{vrf.name}» уже назначены адреса: {', '.join(clash)}")
old_name = p.vrf.name
for m in subtree:
m.vrf = vrf
p.parent_id = None
db.flush()
db.expire_all() # vrf_id адресов обновлён каскадом БД
attach_to_tree(db, p, exclude=frozenset(m.id for m in subtree))
bad = _unusable_after_rehome(db, p)
if bad: # commit ещё не выполнялся — исключение уходит без частичных изменений (rollback при закрытии сессии)
raise HTTPException(422, _unusable_message(bad))
rehome_addresses(db, p)
return {"vrf": f"{old_name} → {vrf.name}", "moved": len(subtree)}, len(subtree)
@router.get("/prefixes", response_model=s.Page[s.PrefixOut])
def list_prefixes(
organization_id: int | None = None, vrf_id: int | None = None, status: PrefixStatus | None = None,
family: int | None = Query(None, ge=4, le=6), q: str = "",
limit: int = Query(100, ge=1, le=1000), offset: int = Query(0, ge=0, le=MAX_OFFSET), db: Session = Depends(get_db),
):
stmt = select(Prefix)
if organization_id:
stmt = stmt.where(Prefix.organization_id == organization_id)
if vrf_id:
stmt = stmt.where(Prefix.vrf_id == vrf_id)
if status:
stmt = stmt.where(Prefix.status == status)
if family:
stmt = stmt.where(func.family(Prefix.prefix) == (4 if family == 4 else 6))
if q:
stmt = stmt.where(or_(contains(cast(Prefix.prefix, String), q), contains(Prefix.description, q)))
total = count(db, stmt)
rows = db.scalars(stmt.order_by(Prefix.vrf_id, Prefix.prefix).limit(limit).offset(offset)).all()
return s.Page(items=_prefix_outs(db, list(rows)), total=total)
@router.get("/prefixes/{id}", response_model=s.PrefixOut)
def get_prefix(id: int, db: Session = Depends(get_db)):
return _prefix_outs(db, [get_or_404(db, Prefix, id, "Префикс")])[0]
@router.post("/prefixes", response_model=s.PrefixOut, status_code=201)
def create_prefix(body: s.PrefixIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
vrf = get_or_404(db, Vrf, body.vrf_id, "VRF")
if vrf.organization_id != body.organization_id:
raise HTTPException(422, "VRF принадлежит другой организации")
get_or_404(db, Organization, body.organization_id, "Организация")
if db.scalar(select(Prefix.id).where(Prefix.vrf_id == vrf.id, Prefix.prefix == body.prefix)):
raise HTTPException(409, "Такой префикс уже есть в этом VRF")
parent_id = body.parent_id
if parent_id is not None:
parent = get_or_404(db, Prefix, parent_id, "Родительский префикс")
if parent.vrf_id != vrf.id or not ipaddress.ip_network(body.prefix).subnet_of(ipaddress.ip_network(str(parent.prefix))):
raise HTTPException(422, "Родительский префикс должен содержать новый и быть в том же VRF")
p = Prefix(**{**body.model_dump(), "parent_id": parent_id})
db.add(p)
flush(db, "Такой префикс уже есть в этом VRF")
attach_to_tree(db, p, keep_parent=parent_id is not None)
bad = _unusable_after_rehome(db, p)
if bad:
msg = _unusable_message(bad) # до rollback: после него объект p истекает
db.rollback()
raise HTTPException(422, msg)
moved = rehome_addresses(db, p)
audit(db, user, "prefix", p, "created", str(p.prefix), {"vrf": vrf.name, **({"moved_addresses": moved} if moved else {})})
commit(db, "Такой префикс уже есть в этом VRF")
return _prefix_outs(db, [p])[0]
def _busy_ranges(db: Session, p: Prefix) -> list[tuple[int, int]]:
"""Занятые диапазоны внутри префикса: вложенные префиксы того же VRF (любой глубины) и адреса, записанные в самом префиксе."""
busy = []
for c in db.scalars(select(Prefix.prefix).where(Prefix.vrf_id == p.vrf_id, Prefix.id != p.id, Prefix.prefix.op("<<")(str(p.prefix)))):
n = ipaddress.ip_network(str(c))
busy.append((int(n.network_address), int(n.broadcast_address)))
busy += [(int(ipaddress.ip_address(a)),) * 2 for a in db.scalars(select(func.host(Address.address)).where(Address.prefix_id == p.id))]
return busy
def _find_subnet(db: Session, parent: Prefix, length: int) -> tuple[str | None, int, int]:
"""(свободный блок | None, min длина, max длина) для вложенного префикса в parent."""
net = ipaddress.ip_network(str(parent.prefix))
lo, hi = net.prefixlen + 1, net.max_prefixlen
if not lo <= length <= hi:
raise HTTPException(422, f"Размер вложенного префикса: от /{lo} до /{hi}" if lo <= hi else "Префикс нельзя дробить: это одиночный адрес")
return next_free_subnet(str(parent.prefix), length, _busy_ranges(db, parent)), lo, hi
@router.get("/prefixes/{id}/subnets/next", response_model=s.SubnetPreview)
def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Depends(get_db)):
"""Предпросмотр: какой блок будет выделен, без создания."""
found, lo, hi = _find_subnet(db, get_or_404(db, Prefix, id, "Префикс"), length)
return s.SubnetPreview(prefix=found, length_min=lo, length_max=hi)
@router.post("/prefixes/{id}/subnets/next", response_model=s.PrefixOut, status_code=201)
def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
"""Создаёт вложенный префикс заданного размера в первом свободном выровненном блоке родителя."""
parent = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # сериализуем параллельные выделения из одного родителя
if parent is None:
raise HTTPException(404, "Префикс не найден")
found, _, _ = _find_subnet(db, parent, body.length)
if found is None:
raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}")
p = Prefix(organization_id=parent.organization_id, vrf_id=parent.vrf_id, prefix=found, parent_id=parent.id,
description=body.description, status=body.status, is_pool=body.is_pool, note=body.note)
db.add(p)
flush(db, "Такой префикс уже есть в этом VRF, повторите запрос")
attach_to_tree(db, p, keep_parent=True)
moved = rehome_addresses(db, p) # адреса родителя из выделенного блока (учтены и при выборе блока, но вдруг появились параллельно)
audit(db, user, "prefix", p, "created", found, {"vrf": parent.vrf.name, "allocated_from": str(parent.prefix), **({"moved_addresses": moved} if moved else {})})
commit(db, "Такой префикс уже есть в этом VRF, повторите запрос")
return _prefix_outs(db, [p])[0]
@router.patch("/prefixes/{id}", response_model=s.PrefixOut)
def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
p = get_or_404(db, Prefix, id, "Префикс")
data = body.model_dump(exclude_unset=True, exclude_none=True)
new_vrf = data.pop("vrf_id", None)
changed = {k: str(v) for k, v in apply_update(p, data).items()}
if new_vrf is not None and new_vrf != p.vrf_id:
changed.update(_move_to_vrf(db, p, new_vrf)[0])
audit(db, user, "prefix", p, "updated", str(p.prefix), changed)
commit(db)
return _prefix_outs(db, [p])[0]
@router.delete("/prefixes/{id}", status_code=204)
def delete_prefix(id: int, force: bool = False, db: Session = Depends(get_db), user: User = Depends(admin_user)):
p = get_or_404(db, Prefix, id, "Префикс")
used = None if force else blockers(db, select(func.host(Address.address)).where(Address.prefix_id == id).order_by(Address.address))
if used:
refuse_delete(db, user, "prefix", p, str(p.prefix), "В префиксе есть адреса; удалите их или используйте force=true", {"addresses": used})
db.execute(update(Prefix).where(Prefix.parent_id == id).values(parent_id=p.parent_id))
gone = count(db, select(Address.id).where(Address.prefix_id == id)) if force else 0
audit(db, user, "prefix", p, "deleted", str(p.prefix), {"force": True, "addresses_deleted": gone} if gone else None)
db.delete(p)
commit(db)
# ------------------------------------------------------------------- addresses
def _addr_out(a: Address, device_name: str | None = None) -> s.AddressOut:
return s.AddressOut(
id=a.id, prefix_id=a.prefix_id, address=s.ip_text(a.address), status=a.status.value,
dns_name=a.dns_name, description=a.description, device_id=a.device_id, device_name=device_name,
note=a.note, updated_at=a.updated_at,
)
def _check_device(db: Session, prefix: Prefix, device_id: int | None):
if device_id is not None:
d = get_or_404(db, Device, device_id, "Устройство")
if d.organization_id != prefix.organization_id:
raise HTTPException(422, "Устройство принадлежит другой организации")
@router.get("/prefixes/{id}/addresses", response_model=s.AddressPage)
def list_addresses(
id: int, status: str = "", q: str = "", limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0, le=MAX_OFFSET),
db: Session = Depends(get_db),
):
"""status: assigned | reserved | deprecated | free | пусто (все; свободные подмешиваются для малых подсетей).
Пагинация — в SQL; страница «свободных» считается арифметически (без перебора адресов подсети)."""
p = get_or_404(db, Prefix, id, "Префикс")
cap = capacity(str(p.prefix))
counts = dict(db.execute(select(Address.status, func.count()).where(Address.prefix_id == id).group_by(Address.status)).all())
stored = sum(counts.values())
summary = s.AddressSummary(
assigned=counts.get(AddressStatus.assigned, 0), reserved=counts.get(AddressStatus.reserved, 0),
deprecated=counts.get(AddressStatus.deprecated, 0), free=max(cap - stored, 0), capacity=cap,
)
if status and status not in {"free", *(x.value for x in AddressStatus)}:
raise HTTPException(422, "Неизвестный статус")
net = ipaddress.ip_network(str(p.prefix))
def occupied() -> list[int]:
return sorted(int(ipaddress.ip_address(s.ip_text(a))) for a in db.scalars(select(Address.address).where(Address.prefix_id == id)))
def free_rows(ips: list[str]) -> list[s.AddressOut]:
return [s.AddressOut(id=None, prefix_id=id, address=ip, status="free") for ip in ips]
if status == "free":
return s.AddressPage(items=free_rows(free_page(net, occupied(), offset, limit)), total=summary.free, summary=summary)
flt = [Address.prefix_id == id]
if status:
flt.append(Address.status == AddressStatus(status))
if q:
flt.append(or_(contains(func.host(Address.address), q), contains(Address.dns_name, q), contains(Address.description, q)))
stmt = select(Address, Device.name).outerjoin(Device, Device.id == Address.device_id).where(*flt)
mixed = not status and not q and cap <= FREE_LISTING_LIMIT # малая подсеть: занятые и свободные вперемешку (список ограничен размером подсети)
if mixed:
rows = [_addr_out(a, dn) for a, dn in db.execute(stmt.order_by(Address.address)).all()]
rows = sorted(rows + free_rows(free_page(net, occupied(), 0, cap)), key=lambda r: ipaddress.ip_address(r.address))
return s.AddressPage(items=rows[offset:offset + limit], total=len(rows), summary=summary)
total = count(db, select(Address.id).where(*flt))
page = db.execute(stmt.order_by(Address.address).limit(limit).offset(offset)).all()
return s.AddressPage(items=[_addr_out(a, dn) for a, dn in page], total=total, summary=summary)
@router.post("/prefixes/{id}/addresses", response_model=s.AddressOut, status_code=201)
def create_address(id: int, body: s.AddressIn, db: Session = Depends(get_db), user: User = Depends(admin_user)):
p = get_or_404(db, Prefix, id, "Префикс")
net, ip = ipaddress.ip_network(str(p.prefix)), ipaddress.ip_address(body.address)
if ip not in net:
raise HTTPException(422, f"Адрес {body.address} не принадлежит префиксу {p.prefix}")
role = network_role(net, ip)
if role:
raise HTTPException(422, f"Адрес {body.address} — {'адрес сети' if role == 'network' else 'broadcast'} префикса {p.prefix}, назначить его нельзя")
narrowest = _narrowest_prefix(db, p.vrf_id, body.address)
if narrowest is not None and narrowest.id != p.id: # адрес хранится в самом узком префиксе VRF
raise HTTPException(422, f"Адрес {body.address} принадлежит вложенному префиксу {narrowest.prefix}, назначьте его там")
_check_device(db, p, body.device_id)
a = Address(prefix_id=id, vrf_id=p.vrf_id, **body.model_dump())
db.add(a)
flush(db, "Адрес уже есть в этом префиксе")
audit(db, user, "address", a, "assigned" if a.status == AddressStatus.assigned else "created", body.address)
commit(db, "Адрес уже есть в этом префиксе")
db.refresh(a)
return _addr_out(a)
@router.post("/prefixes/{id}/addresses/next", response_model=s.AddressOut, status_code=201)
def allocate_next(
id: int, body: s.AddressUpdate | None = None, db: Session = Depends(get_db), user: User = Depends(admin_user)
):
"""Автоназначение первого свободного адреса; только для префиксов с флагом is_pool."""
p = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update()) # параллельные запросы получают разные адреса
if p is None:
raise HTTPException(404, "Префикс не найден")
if not p.is_pool:
raise HTTPException(422, "Префикс не является пулом для автоназначения")
ip = next_free_address(ipaddress.ip_network(str(p.prefix)), _busy_ranges(db, p)) # вложенные префиксы и адрес сети/broadcast пропускаются
if ip is None:
raise HTTPException(409, "В префиксе нет свободных адресов")
data = body.model_dump(exclude_unset=True, exclude_none=True) if body else {}
_check_device(db, p, data.get("device_id"))
a = Address(prefix_id=id, vrf_id=p.vrf_id, address=ip, **data)
db.add(a)
flush(db, "Адрес уже занят, повторите запрос")
audit(db, user, "address", a, "assigned", ip)
commit(db, "Адрес уже занят, повторите запрос")
db.refresh(a)
return _addr_out(a)
@router.patch("/addresses/{id}", response_model=s.AddressOut)
def update_address(id: int, body: s.AddressUpdate, db: Session = Depends(get_db), user: User = Depends(admin_user)):
a = get_or_404(db, Address, id, "Адрес")
data = body.model_dump(exclude_unset=True)
if data.get("dns_name"):
s.AddressIn(address=s.ip_text(a.address), dns_name=data["dns_name"]) # валидация FQDN
_check_device(db, db.get(Prefix, a.prefix_id), data.get("device_id"))
changed = apply_update(a, data)
audit(db, user, "address", a, "updated", s.ip_text(a.address), {k: str(v) for k, v in changed.items()})
commit(db)
db.refresh(a)
return _addr_out(a)
@router.delete("/addresses/{id}", status_code=204)
def delete_address(id: int, db: Session = Depends(get_db), user: User = Depends(admin_user)):
a = get_or_404(db, Address, id, "Адрес")
audit(db, user, "address", a, "deleted", s.ip_text(a.address))
db.delete(a)
commit(db)