Ревью кодовой базы (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>
444 lines
25 KiB
Python
444 lines
25 KiB
Python
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)
|