Files
ipam_control/app/services.py
T
ayurishchevandClaude Opus 5.5 03d727e496 Задачи 025-030: ёмкость префиксов, политика входа, дерево префиксов
Повторный анализ кодовой базы (docs/reviews/2026-09-26-codebase-review-2.md) и доработки:
025 Ёмкость префикса — размер его подсети (а не сумма листьев); «Обзор» считает ёмкость
    по корневым активным IPv4-префиксам и адреса внутри них.
026 Политика блокировки входа: 5 неудач на логин+IP, 20 на IP, 50 на логин со всех IP,
    кроме известных IP (known_logins, миграция 0009) — владельца нельзя заблокировать анонимно.
027 Сериализация попыток входа по IP (advisory-lock после блокировки логина).
028 UI «Префиксы»: загрузка всех страниц (до 20 000), счётчики по total, предупреждение об усечении.
029 Advisory-lock по VRF для операций, меняющих дерево префиксов и раскладку адресов.
030 Исправление замечаний ревью 025-029: _lock_prefix (VRF блокируется до чтения префикса,
    409 при одновременном переносе), константы политики входа перенесены в app/services.py.

Тесты: 14 passed (проверка ёмкости родителя приведена к семантике 025); сквозные сценарии
и гонки — docs/reviews/2026-09-26-changes-025-029-review.md, 2026-09-27-changes-030-review.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 08:28:50 +03:00

211 lines
10 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 datetime import timedelta
from types import SimpleNamespace
from fastapi import HTTPException
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.request_context import request_meta
from app.models import Address, AuditLog, Base
MAX_CAPACITY = 2**53 - 1
MAX_OFFSET = 10_000_000 # верхняя граница offset во всех списках
# Политика входа (изменение 026): пороги перебора и срок «известного» IP. Вынесены сюда из app.api.v1.auth
# (изменение 030, ревью 025-029 находка №2) — app.rotation использует KNOWN_IP_DAYS и не должен зависеть от API-слоя.
LOGIN_WINDOW = timedelta(minutes=10)
MAX_PER_LOGIN_IP = 5 # логин + IP — блокирует эту пару
MAX_PER_IP = 20 # IP — блокирует любые логины с этого IP
MAX_PER_LOGIN = 50 # логин со всех IP, кроме известных — сигнал широкого перебора, не бьёт по легитимному пользователю
KNOWN_IP_DAYS = 30 # IP считается известным, если вход с него был успешен не позднее этого срока
SYSTEM = SimpleNamespace(username="system")
ANONYMOUS = SimpleNamespace(username="anonymous")
_NOUNS = {
"organization": ("Организация", "f"), "vrf": ("VRF", "m"), "prefix": ("Префикс", "m"), "address": ("Адрес", "m"),
"device": ("Устройство", "n"), "device_type": ("Тип устройства", "m"), "isp": ("Оператор", "m"),
"user": ("Пользователь", "m"),
}
_VERBS = {
"created": ("создан", "создана", "создано"), "updated": ("изменён", "изменена", "изменено"),
"deleted": ("удалён", "удалена", "удалено"), "assigned": ("назначен", "назначена", "назначено"),
}
def make_message(entity_type: str, action: str, label: str, diff: dict | None = None) -> str:
noun, gender = _NOUNS.get(entity_type, (entity_type, "m"))
verb = _VERBS.get(action, (action,) * 3)["mfn".index(gender)]
text = f"{noun} {label} {verb}"
if action == "updated" and diff:
text += ": " + ", ".join(diff)
return text
def actor_of(username: str) -> str:
"""Актор для журнала: служебные — как есть, пользователи UI — с префиксом ui:."""
return username if username in ("system", "anonymous") else f"ui:{username}"
def audit(db: Session, user, entity_type: str, entity, action: str, label: str, diff: dict | None = None, message: str | None = None):
ctx = None if user is SYSTEM else request_meta.get() # системные события (ротация) — без IP, даже если запущены из запроса
db.add(AuditLog(
username=user.username, entity_type=entity_type, entity_id=getattr(entity, "id", None),
entity_label=label, action=action, diff=diff,
message=message or make_message(entity_type, action, label, diff),
client_ip=ctx["client_ip"] if ctx else None, meta=ctx["meta"] if ctx else None,
))
BLOCKERS_LIMIT = 20
_GROUPS = {"prefixes": "префиксы", "devices": "устройства", "isps": "операторы", "addresses": "адреса"}
def blockers(db: Session, labels) -> dict | None:
"""Мешающие удалению объекты: `labels` — select одной текстовой колонки; {"total", "items"} (до BLOCKERS_LIMIT), None — если пусто."""
total = count(db, labels)
return {"total": total, "items": list(db.scalars(labels.limit(BLOCKERS_LIMIT)))} if total else None
def refuse_delete(db: Session, user, entity_type: str, entity, label: str, reason: str, blocked_by: dict | None = None):
"""Отказ в удалении (409): фиксируем предупреждение в журнале (`<entity>.delete_blocked`) и отвечаем прежним текстом `reason`."""
blocked_by = {k: v for k, v in (blocked_by or {}).items() if v}
diff = {"reason": reason, **({"blocked_by": blocked_by} if blocked_by else {})}
noun = _NOUNS.get(entity_type, (entity_type, "m"))[0]
tail = ("связанные объекты (" + ", ".join(f"{_GROUPS[k]}: {v['total']}" for k, v in blocked_by.items()) + ")") if blocked_by else reason[:1].lower() + reason[1:]
audit(db, user, entity_type, entity, "delete_blocked", label, diff, message=f"{noun} {label}: удаление отклонено — {tail}")
commit(db) # к этому моменту в транзакции только запись журнала
raise HTTPException(409, reason)
CONFLICT_MSG = "Конфликт с существующими данными: проверьте уникальность значений и связанные объекты"
def commit(db: Session, conflict_msg: str = CONFLICT_MSG):
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(409, conflict_msg)
def flush(db: Session, conflict_msg: str = CONFLICT_MSG):
"""flush с тем же переводом нарушений уникальности в 409, что и commit."""
try:
db.flush()
except IntegrityError:
db.rollback()
raise HTTPException(409, conflict_msg)
def get_or_404(db: Session, model: type[Base], id_: int, what: str = "Объект"):
obj = db.get(model, id_)
if obj is None:
raise HTTPException(404, f"{what} не найден")
return obj
def capacity(prefix: str) -> int:
net = ipaddress.ip_network(prefix)
n = net.num_addresses
if net.version == 4 and net.prefixlen <= 30:
n -= 2 # сеть и broadcast
return min(n, MAX_CAPACITY)
def utilization(used: int, cap: int) -> int:
return min(round(used * 100 / cap), 100) if cap else 0 # не больше 100 %, даже если в подсети остались адреса сети/broadcast
def network_role(net, ip) -> str | None:
""""network" / "broadcast" для IPv4 с длиной префикса ≤ 30 (как в capacity()), иначе None."""
if net.version == 4 and net.prefixlen <= 30:
if ip == net.network_address:
return "network"
if ip == net.broadcast_address:
return "broadcast"
return None
def apply_update(obj, data: dict) -> dict:
changed = {}
for k, v in data.items():
if getattr(obj, k) != v:
changed[k] = v
setattr(obj, k, v)
return changed
def like_escape(q: str) -> str:
return q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def contains(column, q: str):
"""column ILIKE '%q%' с экранированием % и _ (иначе поиск «%» возвращает всё)."""
return column.ilike(f"%{like_escape(q.strip())}%", escape="\\")
def count(db: Session, stmt) -> int:
return db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
def next_free_subnet(parent: str, length: int, occupied: list[tuple[int, int]]) -> str | None:
"""Первый свободный блок длины `length` внутри `parent`, выровненный по размеру блока.
`occupied` — занятые диапазоны адресов (включительно); без перебора всех подсетей: перескок за конец пересечения."""
net = ipaddress.ip_network(parent)
size = 1 << (net.max_prefixlen - length)
first, last = int(net.network_address), int(net.broadcast_address)
cand = first
for start, end in sorted(occupied):
if end < cand:
continue
if start >= cand + size:
break # блок cand..cand+size-1 свободен
cand = -(-(end + 1) // size) * size # за конец занятого диапазона, с выравниванием
if cand + size - 1 > last:
return None
return str(ipaddress.ip_network((type(net.network_address)(cand), length)))
def free_page(net, occupied: list[int], offset: int, limit: int) -> list[str]:
"""Страница свободных адресов сети: `occupied` — отсортированные занятые адреса (int). Пропуск `offset` — арифметикой по промежуткам,
без перебора адресов (безопасно для IPv6). Для IPv4 с длиной ≤ 30 адреса сети и broadcast не считаются."""
first, last = int(net.network_address), int(net.broadcast_address)
if net.version == 4 and net.prefixlen <= 30:
first, last = first + 1, last - 1
cls, out, skip, cur = type(net.network_address), [], offset, first
for ip in [*occupied, last + 1]:
if ip < cur:
continue
gap_end = min(ip - 1, last)
gap = gap_end - cur + 1
if gap > 0:
if skip >= gap:
skip -= gap
else:
start = cur + skip
skip = 0
out.extend(str(cls(a)) for a in range(start, min(gap_end, start + (limit - len(out)) - 1) + 1))
if len(out) >= limit:
break
cur = max(cur, ip + 1)
return out
def next_free_address(net, occupied: list[tuple[int, int]]) -> str | None:
"""Первый свободный адрес сети; `occupied` — занятые диапазоны (включительно), без перебора адресов.
Для IPv4 с длиной ≤ 30 адреса сети и broadcast не выдаются (как в capacity())."""
first, last = int(net.network_address), int(net.broadcast_address)
if net.version == 4 and net.prefixlen <= 30:
first, last = first + 1, last - 1
cand = first
for start, end in sorted(occupied):
if end < cand:
continue
if start > cand:
break
cand = end + 1
return str(type(net.network_address)(cand)) if cand <= last else None