Задачи 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>
This commit is contained in:
ayurishchevandClaude Opus 5.5 committed 2026-09-27 08:28:50 +03:00
1 parent 13e17fbb47
commit 03d727e496
25 files changed
+877 -78

No files matched your search

+18 -4
View File
@@ -32,7 +32,11 @@ alembic/ scripts/{gen_env,seed_demo,find_duplicate_addresses,find_unusable_addr
## Модель данных
`organizations` → `vrfs` (по организации, «default» создаётся автоматически) → `prefixes` (дерево через `parent_id`,
вложенность определяется автоматически) → `addresses`; `devices` + `device_types`; `isps` + `isp_networks`; `users`; `audit_log`.
- Ёмкость листового префикса — размер подсети (IPv4 без сетевого/broadcast); родителя — сумма вложенных листьев.
- Ёмкость префикса — размер его собственной подсети (IPv4 без сетевого/broadcast), у листа и у родителя одинаково: после частичного разбиения
(автовыделение, изменение 010) ёмкость родителя не падает до суммы вложенных (изменение 025). `used` считается по адресам всего поддерева того же VRF.
- «Обзор»: `capacity` — сумма ёмкостей «корневых» активных IPv4-префиксов (не вложенных ни в один другой активный IPv4-префикс того же VRF по CIDR,
а не по `parent_id`, чтобы неактивные ветки не влияли на корни); `assigned`/`reserved` — IPv4-адреса этих статусов, лежащие внутри какого-либо корня
(адреса в неактивных ветках не учитываются). «Высокая загрузка» — как раньше, по листовым IPv4-префиксам с ёмкостью > 1 (изменение 025).
- «Свободные» адреса не хранятся, а вычисляются; в списке адресов они показываются для подсетей до /20. Страница `status=free` считается арифметически
(без перебора адресов, `offset` ≤ 10 000 000), остальные фильтры и постраничная выдача — в SQL (изменение 014).
- **IP уникален в пределах VRF и хранится в самом узком префиксе** (изменение 011): `addresses.vrf_id` + составной FK `(prefix_id, vrf_id)` (обновляется каскадом при переносе VRF),
@@ -50,6 +54,8 @@ alembic/ scripts/{gen_env,seed_demo,find_duplicate_addresses,find_unusable_addr
и адреса родителя); `GET` с тем же путём и `?length=` — предпросмотр. Нет места → 409, недопустимый размер → 422. В UI — пункт «Добавить вложенный (авто)» в меню «⋯» префикса.
- VRF, тип устройства, организация с зависимыми объектами не удаляются (409). У организации без префиксов, устройств и операторов
служебный VRF `default` удаляется вместе с ней (исправлено в изменении 007: раньше такое удаление давало 409).
- Изменения дерева префиксов и раскладки адресов одного VRF (создание/удаление/перенос префикса, автовыделение, назначение адреса) выполняются по одному —
`pg_advisory_xact_lock` по `vrf_id` берётся до чтения дерева; разные VRF друг друга не блокируют, чтения (`GET`) не блокируются (изменение 029, находка №5).
## API (`/api/v1`)
`POST /auth/login` · `GET /auth/me` · `GET /overview`
@@ -88,9 +94,15 @@ CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixe
- В журнал пишутся также входы (`session.login`, `session.failed` — актор `anonymous`, только первая неудача в окне **по этому логину**; `session.locked` — блокировка, `diff.distinct_logins` —
число разных логинов с этого IP в окне, если сработал лимит по IP) и служебные события (`journal.*`, актор `system`; неверный пароль очистки — `journal.clear_failed` / `journal.clear_locked`).
Удаление префикса с `force=true` фиксирует `addresses_deleted`.
- **Вход:** 5 неудач на логин и 20 на IP за 10 минут → блокировка на 10 минут (429, `Retry-After`, `retry_after_seconds`; изменение 012). Для несуществующего логина время ответа выравнивается.
Пароль ≤ 128 символов. Попытки одного логина сериализованы (`pg_advisory_xact_lock`) — параллельные запросы не обходят лимит (изменение 024). При ротации по количеству первыми удаляются
`session.failed` и `session.locked`. За reverse-proxy без `TRUSTED_PROXIES` все клиенты делят один IP — лимит по IP заденет всех.
- **Вход:** три области лимита за 10 минут (блокировка — окно после последней неудачи): **логин + IP** — 5 неудач блокирует эту пару; **IP** — 20 неудач блокирует
любые логины с этого IP (как раньше); **логин** — 50 неудач со всех IP блокирует логин, но **не для «известных» IP** — тех, с которых этот пользователь уже успешно
входил за последние 30 дней (`known_logins`, обновляется при каждом успешном входе; устаревшие записи удаляет ротация). Так анонимный клиент, знающий логин
(например, `admin`), не может держать пользователя заблокированным с его обычного рабочего места — только с незнакомых IP (изменение 026, находка №2 ревью). 429 содержит
`Retry-After`/`retry_after_seconds`; для несуществующего логина время ответа выравнивается, пароль ≤ 128 символов.
Попытки одного логина сериализованы (`pg_advisory_xact_lock`, изменение 024), затем — попытки одного IP по всем логинам (изменение 027, находка №7: без этого перебор разных
логинов с одного IP проходит проверку лимита по IP параллельно и превышает его на степень параллелизма); порядок всегда «логин, затем IP», взаимная блокировка исключена.
Цена — попытки с одного IP (в том числе за NAT) обрабатываются по одной, время ответа при массовом переборе растёт на время проверки пароля.
При ротации по количеству первыми удаляются `session.failed` и `session.locked`. За reverse-proxy без `TRUSTED_PROXIES` все клиенты делят один IP — лимит по IP заденет всех.
## Публикация и эксплуатация
- Контейнер приложения работает от непривилегированного пользователя (uid 10001), у сервиса есть healthcheck (`/healthz`), сервисы перезапускаются (`restart: unless-stopped`).
@@ -112,6 +124,8 @@ CRUD: `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices`, `/prefixe
«Операторы», «Устройства» и «Адреса» — окно редактирования (свободный адрес — «Назначить адрес» с этим IP). Ссылки, шеврон, меню «⋯» работают как раньше и не запускают действие строки;
Ctrl/Shift+клик и выделение текста тоже игнорируются.
Экран «Пользователи» доступен только администратору: создание, редактирование роли и доступа, удаление на месте, а свой пароль меняется кнопкой «Сменить пароль» в шапке.
Экраны «Префиксы» и «Адреса» догружают все страницы `/prefixes` организации (не только первые 1000), но не больше `PREFIX_UI_CAP` (20 000) префиксов; заголовок,
вкладка «Все» и подвал показывают `total`, а при срабатывании предела над таблицей появляется строка «Загружено X из N…» (изменение 028, находка №3).
## Тесты
Идут против приложения в контейнерах, учётные данные берутся из `.env`:
+26
View File
@@ -0,0 +1,26 @@
"""known_logins: известные IP для смягчения общей блокировки по логину
Revision ID: 0009
Revises: 0008
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0009"
down_revision = "0008"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"known_logins",
sa.Column("username", sa.String(100), primary_key=True),
sa.Column("client_ip", postgresql.INET(), primary_key=True),
sa.Column("last_seen", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
def downgrade() -> None:
op.drop_table("known_logins")
+61 -26
View File
@@ -1,42 +1,61 @@
"""Вход в UI. Перебор паролей ограничен: {MAX_PER_LOGIN} неудач на логин и {MAX_PER_IP} на IP за {WINDOW_MIN} минут → 429 (изменение 012)."""
"""Вход в UI. Перебор паролей ограничен тремя областями (окно {WINDOW_MIN} минут → 429, изменения 012, 026, 027):
{MAX_PER_LOGIN_IP} неудач на пару логин+IP, {MAX_PER_IP} на IP (любые логины), {MAX_PER_LOGIN} на логин со всех IP, кроме «известных»
(IP, с которого этот пользователь успешно входил за последние {KNOWN_IP_DAYS} дн.) — иначе анонимный клиент, знающий логин,
мог бы держать чужую учётную запись заблокированной с любого IP (находка №2 ревью 2026-09-26)."""
from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import delete, func, select
from sqlalchemy import and_, delete, func, select
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import LoginAttempt, User
from app.models import KnownLogin, LoginAttempt, User
from app.request_context import request_meta
from app.schemas import LoginIn, TokenOut, UserOut
from app.security import create_token, current_user, hash_password, verify_password
from app.services import ANONYMOUS, audit
from app.services import ANONYMOUS, KNOWN_IP_DAYS, LOGIN_WINDOW, MAX_PER_IP, MAX_PER_LOGIN, MAX_PER_LOGIN_IP, audit
router = APIRouter(prefix="/auth", tags=["auth"])
WINDOW = timedelta(minutes=10)
MAX_PER_LOGIN = 5
MAX_PER_IP = 20
__doc__ = __doc__.format(MAX_PER_LOGIN=MAX_PER_LOGIN, MAX_PER_IP=MAX_PER_IP, WINDOW_MIN=int(WINDOW.total_seconds() // 60))
# пороги перебора (LOGIN_WINDOW, MAX_PER_*, KNOWN_IP_DAYS) — в app.services (изменение 030, ревью 025-029 находка №2)
# пространства ключей двухаргументного advisory-lock (не пересекаются с одноаргументными: LOCK_KEY/USERS_ADMIN_LOCK)
LOGIN_LOCK_NS = 7031
IP_LOCK_NS = 7032
__doc__ = __doc__.format(MAX_PER_LOGIN_IP=MAX_PER_LOGIN_IP, MAX_PER_IP=MAX_PER_IP, MAX_PER_LOGIN=MAX_PER_LOGIN,
WINDOW_MIN=int(LOGIN_WINDOW.total_seconds() // 60), KNOWN_IP_DAYS=KNOWN_IP_DAYS)
_DUMMY_HASH = hash_password("dummy-password-for-timing") # выравнивает время ответа для несуществующего логина
def _condition(login: str, ip: str | None, scope: str):
return (LoginAttempt.username == login) if scope == "login" else (LoginAttempt.client_ip == ip)
if scope == "login_ip":
return and_(LoginAttempt.username == login, LoginAttempt.client_ip == ip)
if scope == "ip":
return LoginAttempt.client_ip == ip
return LoginAttempt.username == login # "login" — по всем IP
def _retry_after(db: Session, login: str, ip: str | None) -> tuple[int, str | None, dict[str, int]]:
"""(секунд до конца блокировки, причина 'login'|'ip', {"login": n, "ip": m} — число неудач в каждой области, до лимита).
Блокировка длится окно после последней неудачи. Счётчики по областям отдельно (изменение 024, находка №4):
решение о записи в журнал принимается по конкретному логину, а не по максимуму среди логина и IP."""
def _is_known(db: Session, login: str, ip: str | None) -> bool:
"""IP считается известным для логина, если вход с него был успешен не более KNOWN_IP_DAYS дней назад."""
if ip is None:
return False
cutoff = datetime.now(timezone.utc) - timedelta(days=KNOWN_IP_DAYS)
return db.scalar(select(KnownLogin.username).where(KnownLogin.username == login, KnownLogin.client_ip == ip, KnownLogin.last_seen > cutoff)) is not None
def _retry_after(db: Session, login: str, ip: str | None, known: bool) -> tuple[int, str | None, dict[str, int]]:
"""(секунд до конца блокировки, область 'login_ip'|'ip'|'login', счётчики по каждой области, до лимита).
Блокировка длится окно после последней неудачи. Область 'login' не блокирует, если IP запроса известен
(изменение 026, находка №2); 'login_ip' и 'ip' действуют всегда. Счётчики считаются отдельно (изменение 024,
находка №4): решение о записи в журнал принимается по конкретной области, а не по максимуму среди них."""
now = datetime.now(timezone.utc)
worst, why, counts = 0, None, {}
for scope, limit in (("login", MAX_PER_LOGIN), ("ip", MAX_PER_IP)):
if scope == "ip" and ip is None:
for scope, limit in (("login_ip", MAX_PER_LOGIN_IP), ("ip", MAX_PER_IP), ("login", MAX_PER_LOGIN)):
if scope in ("login_ip", "ip") and ip is None:
continue
rows = db.scalars(select(LoginAttempt.ts).where(_condition(login, ip, scope), LoginAttempt.ts > now - WINDOW).order_by(LoginAttempt.ts.desc()).limit(limit)).all()
rows = db.scalars(select(LoginAttempt.ts).where(_condition(login, ip, scope), LoginAttempt.ts > now - LOGIN_WINDOW).order_by(LoginAttempt.ts.desc()).limit(limit)).all()
counts[scope] = len(rows)
if len(rows) >= limit:
left = int((rows[0] + WINDOW - now).total_seconds()) + 1
if len(rows) >= limit and not (scope == "login" and known):
left = int((rows[0] + LOGIN_WINDOW - now).total_seconds()) + 1
if left > worst:
worst, why = left, scope
return worst, why, counts
@@ -45,7 +64,15 @@ def _retry_after(db: Session, login: str, ip: str | None) -> tuple[int, str | No
def _distinct_logins(db: Session, ip: str) -> int:
"""Число различных логинов, для которых была неудачная попытка с этого IP в окне (для diff записи session.locked)."""
now = datetime.now(timezone.utc)
return db.scalar(select(func.count(func.distinct(LoginAttempt.username))).where(LoginAttempt.client_ip == ip, LoginAttempt.ts > now - WINDOW)) or 0
return db.scalar(select(func.count(func.distinct(LoginAttempt.username))).where(LoginAttempt.client_ip == ip, LoginAttempt.ts > now - LOGIN_WINDOW)) or 0
def _remember_login(db: Session, login: str, ip: str | None) -> None:
"""Отмечает IP как известный для логина (upsert last_seen); вызывается при успешном входе."""
if ip is None:
return
stmt = insert(KnownLogin).values(username=login, client_ip=ip, last_seen=func.now())
db.execute(stmt.on_conflict_do_update(index_elements=[KnownLogin.username, KnownLogin.client_ip], set_={"last_seen": func.now()}))
def _locked(retry: int) -> HTTPException:
@@ -58,27 +85,34 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
ctx = request_meta.get()
ip = ctx["client_ip"] if ctx else None
name = body.username.strip().lower()
# сериализация попыток одного логина (изменение 024, находка №1): без этого параллельные запросы
# проходят проверку блокировки одновременно, и лимит «N за окно» превращается в «N + степень параллелизма»
db.execute(select(func.pg_advisory_xact_lock(func.hashtext(name))))
retry, _, _ = _retry_after(db, name, ip)
# сериализация попыток одного логина (изменение 024, находка №1), затем — этого IP (изменение 027, находка №7):
# порядок всегда «логин, затем IP» исключает взаимную блокировку; без второй блокировки параллельный перебор
# разных логинов с одного IP проходит проверку лимита по IP одновременно, и лимит превышается на степень
# параллелизма — ценой служит то, что попытки с одного IP (в том числе за NAT) обрабатываются по одной,
# и при массовом входе время ответа растёт на время проверки argon2.
db.execute(select(func.pg_advisory_xact_lock(LOGIN_LOCK_NS, func.hashtext(name))))
if ip is not None:
db.execute(select(func.pg_advisory_xact_lock(IP_LOCK_NS, func.hashtext(ip))))
known = _is_known(db, name, ip)
retry, _, _ = _retry_after(db, name, ip, known)
if retry: # блокировка: пароль не проверяем, в журнал не пишем (запись о блокировке уже есть)
raise _locked(retry)
user = db.scalar(select(User).where(User.username == body.username, User.is_active))
if user is None:
verify_password(body.password, _DUMMY_HASH)
if user is None or not verify_password(body.password, user.password_hash):
_, _, before = _retry_after(db, name, ip)
_, _, before = _retry_after(db, name, ip, known)
db.add(LoginAttempt(client_ip=ip, username=name))
db.flush()
retry, why, after = _retry_after(db, name, ip)
retry, why, after = _retry_after(db, name, ip, known)
if retry and before[why] < after[why]: # именно этот запрос впервые пересёк лимит — запись пишем один раз
attempts = after[why]
diff = {"scope": why, "attempts": attempts, "retry_after_seconds": retry}
if why == "ip":
diff["distinct_logins"] = _distinct_logins(db, ip)
scope_ru = {"login_ip": "по логину и IP", "ip": "с IP", "login": "по логину"}[why]
audit(db, ANONYMOUS, "session", None, "locked", body.username[:100], diff,
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({'по логину' if why == 'login' else 'с IP'})")
message=f"Вход заблокирован на {max(1, -(-retry // 60))} мин.: {attempts} неудачных попыток ({scope_ru})")
elif before["login"] == 0: # в журнал — только первая неудача по этому логину в окне (не по IP: иначе перебор логинов с одного IP её не оставит)
audit(db, ANONYMOUS, "session", None, "failed", body.username[:100], message="Неудачная попытка входа в UI")
db.commit()
@@ -86,6 +120,7 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
raise _locked(retry)
raise HTTPException(401, "Неверный логин или пароль")
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
_remember_login(db, name, ip)
audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}")
db.commit()
return TokenOut(access_token=create_token(user))
+31 -9
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy import and_, func, select
from sqlalchemy.orm import Session
from app import schemas as s
@@ -24,18 +24,40 @@ class Overview(BaseModel):
recent_changes: list[s.AuditOut]
def _ipv4_roots(db: Session):
"""Активные IPv4-префиксы, не вложенные ни в один другой активный IPv4-префикс того же VRF (по CIDR, не по
parent_id — дерево может включать неактивные ветки). Основа ёмкости и загрузки «Обзора» (изменение 025, находка №1):
раньше ёмкость считалась суммой листьев, а назначенные адреса — по всем IPv4-адресам, включая нелистовые префиксы."""
q = Prefix.__table__.alias("q")
contained = (
select(1).select_from(q)
.where(q.c.vrf_id == Prefix.vrf_id, q.c.id != Prefix.id, q.c.status == PrefixStatus.active,
func.family(q.c.prefix) == 4, q.c.prefix.op(">>")(Prefix.prefix))
.exists()
)
return select(Prefix.vrf_id, Prefix.prefix).where(Prefix.status == PrefixStatus.active, func.family(Prefix.prefix) == 4, ~contained)
@router.get("/overview", response_model=Overview, tags=["overview"])
def overview(db: Session = Depends(get_db)):
prefixes = db.scalars(select(Prefix).where(Prefix.status == PrefixStatus.active)).all()
outs = _prefix_outs(db, list(prefixes))
active = db.scalars(select(Prefix).where(Prefix.status == PrefixStatus.active)).all()
outs = _prefix_outs(db, list(active))
parents = {p.parent_id for p in outs if p.parent_id}
leaves = [p for p in outs if p.id not in parents] # ёмкость считаем по листьям, чтобы не удваивать
leaves4 = [p for p in leaves if p.family == 4] # IPv6-пространство несопоставимо по размеру — в метрики использования не входит
cap = sum(p.capacity for p in leaves4)
v4 = func.family(Address.address) == 4
assigned = db.scalar(select(func.count()).select_from(Address).where(Address.status == AddressStatus.assigned, v4)) or 0
reserved = db.scalar(select(func.count()).select_from(Address).where(Address.status == AddressStatus.reserved, v4)) or 0
leaves4 = [p for p in outs if p.id not in parents and p.family == 4] # top_prefixes — как раньше, по листьям
top = sorted((p for p in leaves4 if p.capacity > 1), key=lambda p: p.utilization, reverse=True)[:4]
roots_stmt = _ipv4_roots(db)
roots = db.execute(roots_stmt).all()
cap = sum(capacity(str(r.prefix)) for r in roots)
roots_cte = roots_stmt.cte("overview_roots")
counts = dict(db.execute(
select(Address.status, func.count(func.distinct(Address.id)))
.select_from(Address.__table__.join(roots_cte, and_(roots_cte.c.vrf_id == Address.vrf_id, Address.address.op("<<=")(roots_cte.c.prefix))))
.where(func.family(Address.address) == 4, Address.status.in_((AddressStatus.assigned, AddressStatus.reserved)))
.group_by(Address.status)
).all())
assigned = counts.get(AddressStatus.assigned, 0)
reserved = counts.get(AddressStatus.reserved, 0)
recent = db.scalars(select(AuditLog).order_by(AuditLog.id.desc()).limit(4)).all()
return Overview(
prefixes=db.scalar(select(func.count()).select_from(Prefix)) or 0,
+45 -31
View File
@@ -9,12 +9,43 @@ 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,
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
VRF_LOCK_NS = 7033 # advisory-lock пространство: изменения дерева/раскладки адресов одного VRF выполняются по одному (изменение 029, находка №5)
def _lock_vrf(db: Session, *vrf_ids: int) -> None:
"""Сериализует операции над деревом префиксов и раскладкой адресов одного VRF: без этого параллельные запросы
(например, ручное создание и автовыделение в одном родителе) читают дерево по разным снимкам транзакции и могут
оставить неверный parent_id. Несколько VRF (перенос между ними) блокируются в порядке возрастания id — исключает
взаимную блокировку. Вызывается до любых чтений дерева этого VRF; читатели (GET) не блокируются."""
for vrf_id in sorted(set(vrf_ids)):
db.execute(select(func.pg_advisory_xact_lock(VRF_LOCK_NS, vrf_id)))
def _lock_prefix(db: Session, id: int, *extra_vrf_ids: int) -> Prefix:
"""Читает и блокирует префикс вместе с деревом его VRF, устраняя гонку «vrf_id прочитан до блокировки»
(изменение 030, ревью 025-029 находка №1): `vrf_id` читается отдельным SELECT, затем блокируется через
`_lock_vrf` (плюс `extra_vrf_ids` — например, целевой VRF при переносе, одним вызовом), и только потом
префикс перечитывается под `FOR UPDATE` с `populate_existing`, чтобы не взять устаревший объект из identity map.
Если под блокировкой `vrf_id` оказался другим (префикс успели перенести в другой VRF) — 409, без повтора:
повтор накопил бы в транзакции лишнюю блокировку устаревшего VRF, и порядок захвата между попытками перестал
бы быть строго возрастающим (риск взаимной блокировки с зеркальной операцией). Транзакция откатится при
закрытии сессии, и клиент повторяет запрос с нуля, без унаследованных блокировок."""
vrf_id = db.scalar(select(Prefix.vrf_id).where(Prefix.id == id))
if vrf_id is None:
raise HTTPException(404, "Префикс не найден")
_lock_vrf(db, vrf_id, *extra_vrf_ids)
p = db.scalar(select(Prefix).where(Prefix.id == id).with_for_update().execution_options(populate_existing=True))
if p is None:
raise HTTPException(404, "Префикс не найден")
if p.vrf_id != vrf_id:
raise HTTPException(409, "Префикс одновременно изменяется, повторите запрос")
return p
# -------------------------------------------------------------------- prefixes
@@ -50,36 +81,19 @@ def _depths(db: Session, organization_id: int) -> dict[int, int]:
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]:
"""Ёмкость префикса — размер его собственной подсети (изменение 025, находка №1): раньше ёмкость родителя
считалась суммой ёмкостей вложенных листьев, и после частичного разбиения (изменение 010) расходилась
с экраном адресов того же префикса."""
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)))
cap = 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,
@@ -216,6 +230,7 @@ def get_prefix(id: int, db: Session = Depends(get_db)):
@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)):
_lock_vrf(db, body.vrf_id) # до любых чтений дерева VRF (изменение 029)
vrf = get_or_404(db, Vrf, body.vrf_id, "VRF")
if vrf.organization_id != body.organization_id:
raise HTTPException(422, "VRF принадлежит другой организации")
@@ -272,9 +287,7 @@ def preview_subnet(id: int, length: int = Query(ge=1, le=128), db: Session = Dep
@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, "Префикс не найден")
parent = _lock_prefix(db, id) # до любых чтений дерева (изменение 029/030) — сериализует параллельные выделения из одного родителя
found, _, _ = _find_subnet(db, parent, body.length)
if found is None:
raise HTTPException(409, f"В префиксе {parent.prefix} нет свободного блока /{body.length}")
@@ -291,9 +304,12 @@ def allocate_subnet(id: int, body: s.SubnetNextIn, db: Session = Depends(get_db)
@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)
# целевой VRF известен из тела запроса до чтения префикса — блокируем исходный и целевой VRF одним вызовом,
# раньше чем прочитан текущий p.vrf_id (изменение 030, ревью 025-029 находка №1); если new_vrf совпадёт
# с текущим VRF, лишняя блокировка того же VRF безвредна
p = _lock_prefix(db, id, new_vrf) if new_vrf is not None else get_or_404(db, Prefix, id, "Префикс")
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])
@@ -304,7 +320,7 @@ def update_prefix(id: int, body: s.PrefixUpdate, db: Session = Depends(get_db),
@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, "Префикс")
p = _lock_prefix(db, id) # до переподвешивания детей: устаревший p.parent_id иначе достанется всем детям (изменение 029/030)
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})
@@ -377,7 +393,7 @@ def list_addresses(
@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, "Префикс")
p = _lock_prefix(db, id) # выбор самого узкого префикса должен видеть согласованное дерево VRF (изменение 029/030)
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}")
@@ -402,9 +418,7 @@ 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, "Префикс не найден")
p = _lock_prefix(db, id) # занятые диапазоны (вложенные префиксы) должны читаться по согласованному дереву (изменение 029/030)
if not p.is_pool:
raise HTTPException(422, "Префикс не является пулом для автоназначения")
ip = next_free_address(ipaddress.ip_network(str(p.prefix)), _busy_ranges(db, p)) # вложенные префиксы и адрес сети/broadcast пропускаются
+10
View File
@@ -182,6 +182,16 @@ class LoginAttempt(Base):
__table_args__ = (Index("ix_login_attempts_ip_ts", "client_ip", "ts"), Index("ix_login_attempts_user_ts", "username", "ts"))
class KnownLogin(Base):
"""IP, с которых логин уже успешно входил (last_seen обновляется при каждом входе); запись не старше
KNOWN_IP_DAYS исключает логин из общей блокировки «по логину со всех IP» (изменение 026, находка №2).
Записи старше срока удаляет ротация."""
__tablename__ = "known_logins"
username: Mapped[str] = mapped_column(String(100), primary_key=True) # в нижнем регистре, как в login_attempts
client_ip: Mapped[str] = mapped_column(INET, primary_key=True)
last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
class ClearAttempt(Base):
"""Неудачные попытки подтверждения пароля при очистке журнала (для блокировки)."""
__tablename__ = "clear_attempts"
+3 -2
View File
@@ -7,8 +7,8 @@ from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.db import SessionLocal
from app.models import AppSetting, AuditLog, LoginAttempt
from app.services import SYSTEM, audit
from app.models import AppSetting, AuditLog, KnownLogin, LoginAttempt
from app.services import KNOWN_IP_DAYS, SYSTEM, audit # KNOWN_IP_DAYS — из app.services, не из API-слоя (изменение 030, ревью 025-029 находка №2)
log = logging.getLogger("ipam.rotation")
DEFAULTS = {"retention_days": 90, "max_entries": 100_000}
@@ -50,6 +50,7 @@ def rotate(db: Session) -> dict | None:
rest = n - noisy
by_count = noisy + (db.execute(delete(AuditLog).where(AuditLog.id.in_(select(AuditLog.id).order_by(AuditLog.id).limit(rest)))).rowcount if rest > 0 else 0)
db.execute(delete(LoginAttempt).where(LoginAttempt.ts < datetime.now(timezone.utc) - timedelta(days=1)))
db.execute(delete(KnownLogin).where(KnownLogin.last_seen < datetime.now(timezone.utc) - timedelta(days=KNOWN_IP_DAYS))) # изменение 026
if by_age or by_count:
parts = []
if by_age:
+9
View File
@@ -1,4 +1,5 @@
import ipaddress
from datetime import timedelta
from types import SimpleNamespace
from fastapi import HTTPException
@@ -12,6 +13,14 @@ 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")
+30
View File
@@ -0,0 +1,30 @@
# Ёмкость и загрузка частично разбитого префикса (изменение 025)
Находка № 1 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — средняя. Выполняется первым.
## Context
`_capacities` (`app/api/v1/prefixes.py`) считает ёмкость родителя как сумму ёмкостей его листьев, а `used` (`_usage`) — по всем адресам поддерева,
включая адреса самого родителя вне дочерних префиксов. После автовыделения подсетей (изменение 010) частичное разбиение стало обычным сценарием.
Воспроизведено: `172.20.0.0/24` с 40 адресами → `40/254, 16 %`; после выделения одного `/30` → `40/2, 100 %`. Экран адресов того же префикса показывает ёмкость 254.
«Обзор» (`app/api/v1/overview.py`) искажён той же причиной: `assigned` — все IPv4-адреса, `capacity` — сумма только листьев.
## Решение
1. **Ёмкость префикса — размер его собственной подсети.** `capacity(str(prefix))` из `app/services.py` (IPv4 без адреса сети/broadcast для ≤ /30, ограничение `MAX_CAPACITY`).
`_capacities` удалить; в `_prefix_outs` — `cap = capacity(str(r.prefix))`. `used` (`_usage`, по поддереву CIDR того же VRF) не меняется: дублей нет благодаря изменению 011.
Так числа в списке префиксов совпадут с `summary.capacity` экрана адресов.
2. **«Обзор»:**
- «корни» — активные IPv4-префиксы, не содержащиеся ни в одном другом активном IPv4-префиксе того же VRF (по CIDR, не по `parent_id`);
- `capacity` = сумма `capacity()` корней;
- `assigned` / `reserved` = IPv4-адреса со статусом assigned/reserved, лежащие внутри какого-либо корня того же VRF (адреса в неактивных ветках не учитываются) — один SQL-запрос с `EXISTS`/`JOIN` по `address <<= root.prefix`;
- `utilization` — через `utilization()`;
- `top_prefixes` («Высокая загрузка») — как сейчас: листья IPv4 с `capacity > 1`, по убыванию загрузки (формула загрузки листа не меняется);
- `prefixes`, `vrfs`, `recent_changes` — без изменений.
3. Формат ответов API не меняется. UI не меняется.
4. `README.md`, раздел «Модель данных»: строку «Ёмкость листового префикса — размер подсети…; родителя — сумма вложенных листьев» заменить новым правилом; описать расчёт «Обзора».
## Файлы
`app/api/v1/prefixes.py`, `app/api/v1/overview.py`, `README.md`.
## Проверка
- Сценарий из Context: после выделения `/30` ёмкость родителя остаётся 254, загрузка — 16 %.
- «Обзор» на демо-данных: `capacity` равна сумме корней (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 и т.п. — только активные, IPv4), загрузка не больше 100 %.
@@ -0,0 +1,28 @@
# Итог: ёмкость и загрузка частично разбитого префикса (изменение 025)
## Что сделано
- `app/api/v1/prefixes.py`: функция `_capacities` (ёмкость родителя = сумма ёмкостей листьев) удалена; `_prefix_outs` считает ёмкость каждого префикса как `capacity(str(r.prefix))` —
размер его собственной подсети, независимо от того, есть ли у него вложенные префиксы. `used` не менялся (по-прежнему `_usage`, по поддереву CIDR того же VRF). Неиспользуемый импорт `MAX_CAPACITY` убран.
- `app/api/v1/overview.py` переписан:
- «корни» (`_ipv4_roots`) — активные IPv4-префиксы, не вложенные ни в один другой активный IPv4-префикс того же VRF по CIDR (`NOT EXISTS` с оператором `>>`, не по `parent_id`);
- `capacity` — сумма `capacity()` корней;
- `assigned`/`reserved` — один SQL-запрос: `addresses` JOIN CTE корней по `vrf_id` и `address <<= root.prefix`, `COUNT(DISTINCT id)` по статусу, только IPv4;
- `top_prefixes` («Высокая загрузка») не менялся — по-прежнему листовые IPv4-префиксы с ёмкостью > 1, по убыванию загрузки;
- `prefixes`, `vrfs`, `recent_changes` не менялись.
- `README.md`, раздел «Модель данных»: заменено правило ёмкости, описан расчёт «Обзора» по корням.
## Отклонения от плана
Нет. План выполнен как описан.
## Как проверено
1. `venv/bin/python -c 'import app.main'`, `node --check web/app.js` — без ошибок.
2. Стенд `ipam_control_006` пересобран (`docker compose -p ipam_control_006 up -d --build app`), контейнер `healthy`.
3. Сценарий из плана на временной организации (`rv3-cap-test`, скрипт в scratchpad, не в репозитории): `172.20.0.0/24` + 40 адресов → `used=40, capacity=254, utilization=16`;
после `POST /prefixes/{id}/subnets/next {length:30}` — те же `capacity=254, utilization=16` у родителя (раньше падало до `capacity=2, utilization=100`). **PASS.**
4. «Обзор»: результат API (`capacity=16843256`, `assigned=485` с учётом временных данных) сверен с прямым SQL-запросом той же логики (роли посчитаны вручную в psql) после удаления временной
организации (`capacity=16843002`, `assigned=445`) — разница ровно 254/40, что соответствует временному корню `172.20.0.0/24`. **PASS.**
5. Временная организация и все её объекты удалены по завершении проверки.
## Не проверено
- Внешний вид в браузере (браузер недоступен в этой среде) — проверено только через прямые вызовы API и сверку с SQL.
- Сценарий с несколькими VRF и IPv6-префиксами одновременно (на демо-данных стенда — только реальные данные, без специально подготовленного смешанного случая).
@@ -0,0 +1,34 @@
# Политика блокировки входа без блокировки администратора анонимом (изменение 026)
Находка № 2 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — средняя.
## Context
Лимит по логину (5 неудач за 10 минут, изменение 012) действует для всех IP, и во время блокировки отклоняется и верный пароль.
Любой, кто знает логин (например, `admin`), может отправлять 5 неверных попыток раз в 10 минут и держать учётную запись заблокированной.
## Решение (`app/api/v1/auth.py`, `app/models.py`, миграция)
1. **Три области лимита** (окно 10 минут, блокировка — окно после последней неудачи, как сейчас):
| Область | Порог | Кого блокирует |
|---|---|---|
| логин + IP | 5 | этот логин с этого IP |
| IP | 20 | любые логины с этого IP (как сейчас) |
| логин (все IP) | 50 | этот логин со всех IP, **кроме «известных» IP** |
Константы `MAX_PER_LOGIN_IP = 5`, `MAX_PER_IP = 20`, `MAX_PER_LOGIN = 50` в `auth.py`.
2. **Известные IP:** таблица `known_logins (username, client_ip, last_seen)` с первичным ключом `(username, client_ip)` — новая миграция `0009` и модель `KnownLogin`.
При успешном входе — upsert (`INSERT … ON CONFLICT (username, client_ip) DO UPDATE SET last_seen = now()`). IP считается известным, если `last_seen` не старше 30 дней (`KNOWN_IP_DAYS = 30`).
Блокировка по области «логин» не применяется к запросам с известного IP. Области «логин + IP» и «IP» действуют всегда.
Записи старше 30 дней удалять в часовом цикле ротации (`app/rotation.py`, рядом с очисткой `login_attempts`).
3. `_retry_after` возвращает `(секунды, область, счётчики)`, где область — `login_ip` | `ip` | `login`, счётчики — по каждой области. Запись `session.failed` — по-прежнему только первая неудача
этого логина в окне (счётчик по логину во всех IP). `session.locked` — один раз при первом пересечении порога любой областью, в `diff` — `scope` (`login_ip`/`ip`/`login`), `attempts`, `retry_after_seconds`,
для `ip` — `distinct_logins` (как сейчас).
4. Сериализацию попыток по логину (`pg_advisory_xact_lock(hashtext(name))`, изменение 024) и выравнивание времени ответа сохранить.
5. Успешный вход удаляет неудачи этого логина (по всем IP), как сейчас.
6. `README.md`, раздел «Журнал» (абзац «Вход»): новые пороги, известные IP, почему анонимный клиент не может заблокировать вход с рабочего места пользователя.
## Файлы
`app/api/v1/auth.py`, `app/models.py`, `alembic/versions/0009_known_logins.py`, `app/rotation.py`, `README.md`.
## Проверка
- 5 неверных попыток логина с IP A → 429 для A; верный пароль с IP B, с которого этот пользователь входил ранее, — 200.
- 50 неверных попыток логина с разных IP (эмуляция через `TRUSTED_PROXIES` недоступна на стенде → проверка по коду или прямыми вставками в `login_attempts`) → вход с нового IP 429, с известного — 200.
- `alembic check` без расхождений.
@@ -0,0 +1,41 @@
# Итог: политика блокировки входа без блокировки администратора анонимом (изменение 026)
Выполнено вместе с изменением 027 (один файл `app/api/v1/auth.py`); отдельный SUMMARY для 027 — там же, с разницей только в advisory-lock по IP.
## Что сделано
- `app/api/v1/auth.py`:
- три области лимита за окно 10 минут: `login_ip` (5, эта пара логин+IP), `ip` (20, любые логины с этого IP — как раньше), `login` (50, этот логин со всех IP, **кроме известных**);
константы `MAX_PER_LOGIN_IP`, `MAX_PER_IP`, `MAX_PER_LOGIN`;
- `_is_known(db, login, ip)` — IP считается известным для логина, если есть строка в `known_logins` с `last_seen` не старше `KNOWN_IP_DAYS = 30`;
- `_retry_after` переписана: считает счётчики по всем трём областям, блокировка возвращается для первой пересечённой, но `scope == "login"` пропускается (не блокирует), если `known=True`;
`login_ip`/`ip` блокируют всегда;
- `_remember_login(db, login, ip)` — upsert в `known_logins` (`INSERT … ON CONFLICT (username, client_ip) DO UPDATE SET last_seen = now()`, через `sqlalchemy.dialects.postgresql.insert`),
вызывается при успешном входе;
- `session.locked`: `diff.scope` теперь одно из `login_ip`/`ip`/`login` (а не только `login`/`ip`, как в изменении 024), сообщение в журнале — по-русски описывает область.
- `app/models.py`: модель `KnownLogin` (`username`, `client_ip` — составной первичный ключ; `last_seen`).
- `alembic/versions/0009_known_logins.py`: таблица `known_logins`.
- `app/rotation.py`: `known_logins` с `last_seen` старше `KNOWN_IP_DAYS` удаляются вместе с остальной ротацией (импорт `KNOWN_IP_DAYS` из `auth.py`).
- `README.md`, раздел «Журнал» → абзац «Вход»: описаны три области, известные IP и защита администратора от анонимной блокировки.
## Отклонения от плана
Нет. Пороги и пространства имён — как в плане (значения из примера плана: `MAX_PER_LOGIN_IP=5`, `MAX_PER_IP=20`, `MAX_PER_LOGIN=50`, `KNOWN_IP_DAYS=30`).
## Как проверено
1. `venv/bin/python -c 'import app.main'` — без ошибок (нет циклического импорта `rotation.py` → `auth.py`, так как `auth.py` не импортирует `rotation`).
2. Стенд пересобран, `healthy`; миграция `0009` применена (`alembic_version = 0009`); `alembic check` — без расхождений.
3. Сценарий из плана (временный пользователь `rv3-locka`, скрипт в scratchpad):
- 5 неверных попыток логина с одного IP (докер-шлюз, виден серверу как `172.31.0.1`) → 4×401, 5-я попытка сама пересекает порог `login_ip` и получает 429 (как и раньше для одиночного порога);
верный пароль с того же IP сразу после — тоже 429 (область `login_ip` активна);
- верный пароль с **другого** реального IP (127.0.0.1, изнутри контейнера приложения) для того же логина — **200**: анонимный клиент с одного IP не блокирует пользователя с другого. **PASS.**
4. Сценарий «перебор с разных IP» (план: «эмуляция через `TRUSTED_PROXIES` недоступна на стенде → проверка … прямыми вставками в `login_attempts`»), временный пользователь `rv3-lockb`:
- успешный вход с IP A (докер-шлюз) регистрирует его как известный;
- 55 неудачных попыток вставлены напрямую в `login_attempts` с 55 разными фиктивными IP (`203.0.113.1..55`) для этого логина;
- вход с верным паролем с нового, никогда не виденного IP (127.0.0.1 изнутри контейнера) → **429** (область `login`, IP не известен);
- вход с верным паролем с известного IP A → **200** (область `login` не применяется к известному IP; счётчик пересобирается заново после того, как предыдущая проверка его не сбросила). **PASS.**
5. `login_attempts` очищена после проверок (`delete from login_attempts`). Временные пользователи `rv3-locka`/`rv3-lockb` удалены. Осталась одна легитимная строка `known_logins`
(`admin`, IP докер-шлюза) — образовалась от обычных входов администратора при тестировании, не «rv3-» тестовые данные, не удалялась.
## Не проверено
- Реальный перебор с физически разных IP-адресов (сеть стенда не даёт больше двух различимых реальных адресов) — область `login` проверена вставкой записей напрямую в `login_attempts`,
как и предусмотрено планом.
- Поведение при `TRUSTED_PROXIES`, настроенном на реальный прокси (не задан на стенде).
@@ -0,0 +1,21 @@
# Сериализация попыток входа по IP (изменение 027)
Находка № 7 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — низкая. Выполняется вместе с 026 (тот же файл).
## Context
Изменение 024 сериализует попытки одного логина (`pg_advisory_xact_lock(hashtext(логин))`). Параллельные попытки **разных** логинов с одного IP проходят проверку лимита по IP
одновременно, и порог 20 можно превысить на степень параллелизма.
## Решение (`app/api/v1/auth.py`, `login`)
1. После блокировки по логину и до `_retry_after` — вторая блокировка `pg_advisory_xact_lock(hashtext('ip:' || ip))`, если `ip` известен.
Порядок всегда «логин, затем IP»: IP-блокировка берётся последней, поэтому цикл ожидания невозможен.
2. Использовать двухаргументную форму advisory-lock с отдельными пространствами ключей, чтобы исключить коллизии между логинами и IP:
`pg_advisory_xact_lock(<LOGIN_NS>, hashtext(name))` и `pg_advisory_xact_lock(<IP_NS>, hashtext(ip))`; константы пространств — в `auth.py` (например, 7031 и 7032).
3. Комментарий в коде: цена — попытки с одного IP (в том числе за NAT) обрабатываются по одной, время ответа при массовом входе растёт на время argon2.
## Файлы
`app/api/v1/auth.py`, `README.md` (одна фраза в абзаце «Вход»).
## Проверка
16 параллельных неверных входов на 16 разных несуществующих логинов с одного IP при заранее вставленных 15 неудачах этого IP → проверено не больше 5 паролей
(в `login_attempts` добавилось ≤ 5 записей), остальные — 429.
@@ -0,0 +1,28 @@
# Итог: сериализация попыток входа по IP (изменение 027)
Выполнено вместе с изменением 026 (один файл `app/api/v1/auth.py`, один проход); детали трёх областей лимита — в SUMMARY 026.
## Что сделано
- `app/api/v1/auth.py`, `login`: после advisory-lock по логину (`pg_advisory_xact_lock(LOGIN_LOCK_NS, hashtext(name))`, изменение 024) и до `_retry_after` — вторая блокировка по IP,
если `ip` известен: `pg_advisory_xact_lock(IP_LOCK_NS, hashtext(ip))`. Порядок всегда «логин, затем IP» — исключает взаимную блокировку.
- Использована двухаргументная форма advisory-lock с раздельными пространствами ключей (`LOGIN_LOCK_NS = 7031`, `IP_LOCK_NS = 7032`), чтобы `hashtext(логин)` и `hashtext(ip)`
гарантированно не пересекались (не зависит от совпадения хэшей, как было бы при одноаргументной форме с общим пространством).
- Комментарий в коде — плата за сериализацию: попытки с одного IP (в том числе за NAT) обрабатываются по одной, время ответа при массовом переборе растёт на время проверки argon2.
- `README.md`, абзац «Вход»: добавлено предложение о второй сериализации и её цене.
## Отклонения от плана
Нет.
## Как проверено
1. `venv/bin/python -c 'import app.main'` — без ошибок.
2. Стенд пересобран и здоров (см. SUMMARY 026 — общая пересборка для 026+027).
3. Сценарий из плана: 15 неудачных попыток по IP (докер-шлюз) вставлены напрямую в `login_attempts` (порог `MAX_PER_IP = 20`, то есть до блокировки осталось 5); затем 16 параллельных
(через `ThreadPoolExecutor`, 16 потоков) неверных входов на 16 разных **несуществующих** логинов с этого же IP:
- ответы: 4×401, 12×429;
- новых строк в `login_attempts` для этого IP (кроме затравки) — **5** (запрос, доведший счётчик до 20, тоже проверяет пароль и добавляет строку, но получает 429, а не 401 —
поэтому 401 на один меньше числа добавленных строк, само число строк не превышает лимит). Соответствует условию плана «добавилось ≤ 5 записей». **PASS.**
4. `login_attempts` очищена после проверки.
## Не проверено
- Поведение при коллизии `hashtext` между конкретным логином и конкретным IP в старой (одноаргументной) схеме — не воспроизводилось намеренно, замена на двухаргументную форму
устраняет саму возможность, отдельно не тестировалось.
@@ -0,0 +1,24 @@
# Экран «Префиксы»: без молчаливого усечения (изменение 028)
Находка № 3 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — низкая.
## Context
`screens.prefixes` и `screens.address` (`web/app.js`) запрашивают `/prefixes?organization_id=…&limit=1000` одной страницей. Заголовок («N префиксов»), вкладка «Все» и подвал
показывают `all.length` (число загруженных), а не `total`. Родительский список в «Новом префиксе», хлебные крошки экрана адресов и размер по умолчанию в «Добавить вложенный (авто)»
строятся по усечённому набору. Организация, в которой `/22` разбит на `/30`, быстро превышает 1 000 префиксов.
## Решение (`web/app.js`, только UI)
1. Функция `loadPrefixes(orgId)`: загружает все страницы `/prefixes` (`limit=1000`, `offset += 1000`) до `total`, но не больше `PREFIX_UI_CAP = 20000` префиксов;
возвращает `{ items, total }`. Страницы запрашиваются последовательно (порядок `ORDER BY vrf_id, prefix` стабилен).
2. `screens.prefixes` и `screens.address` используют `loadPrefixes` вместо одиночного запроса.
3. Заголовок, вкладка «Все» и подвал используют `total`. Если `items.length < total` (сработал `PREFIX_UI_CAP`), над таблицей — информационная строка
«Загружено X из N префиксов — уточните поиск или выберите VRF» (стиль `.info`).
4. Бэкенд не меняется.
5. `README.md`, раздел «Поведение таблиц UI»: одна фраза о загрузке всех страниц и пределе 20 000.
## Файлы
`web/app.js`, `README.md`.
## Проверка
`node --check web/app.js`; на стенде временная организация с 1 100 префиксами (`/30` через `POST …/subnets/next`) — в UI заголовок «1 101 префикс», все строки доступны.
Данные после проверки удалить.
@@ -0,0 +1,27 @@
# Итог: экран «Префиксы» без молчаливого усечения (изменение 028)
## Что сделано
- `web/app.js`: добавлена функция `loadPrefixes(orgId)` — последовательно догружает все страницы `/prefixes` (`limit=1000`, `offset` увеличивается на длину полученной страницы) до `total`,
но не больше `PREFIX_UI_CAP = 20000` префиксов; возвращает `{ items, total }`.
- `screens.prefixes` и `screens.address` используют `loadPrefixes` вместо одиночного запроса с `limit=1000`.
- Заголовок («N префиксов»), вкладка «Все» и подвал таблицы теперь используют `pl.total` (реальное общее число), а не `all.length` (число загруженных).
- Если сработал предел `PREFIX_UI_CAP` (`items.length < total`), над таблицей выводится информационная строка `.info`: «Загружено X из N префиксов — уточните поиск или выберите VRF».
- Бэкенд не менялся.
- `README.md`, раздел «Поведение таблиц UI»: добавлено предложение о постраничной догрузке и пределе 20 000.
## Отклонения от плана
Нет.
## Как проверено
1. `node --check web/app.js` — без ошибок.
2. Сценарий из плана воспроизведён на API-уровне (браузер недоступен в этой среде, поэтому визуальный рендер заголовка не смотрели глазами, но проверили именно тот запрос/расчёт,
который его формирует): временная организация `rv3-page-test` (scratchpad-скрипт), родитель `10.0.0.0/12`, 1100 вложенных `/30` через `POST …/subnets/next` (по одному, как в плане) —
всего **1101 префикс**. Затем эмуляция ровно алгоритма `loadPrefixes` (`limit=1000`, `offset += len(page.items)`, до `total`) через прямые вызовы API:
- `total=1101`, загружено `1101` элементов за **2 страницы** (1000 + 101) — соответствует «в UI заголовок «1 101 префикс», все строки доступны» из плана. **PASS.**
3. Удаление 1100 вложенных префиксов заняло ~32 с, само создание — ~100 с (растёт с числом уже выделенных блоков в одном родителе — ожидаемо, это не предмет изменения 028).
Временная организация и все её префиксы удалены по завершении (`org delete: 204`).
## Не проверено
- Собственно визуальный рендер (браузер недоступен в этой среде): заголовок, строка-предупреждение `.info` при срабатывании `PREFIX_UI_CAP`, поведение вкладок и подвала на живой странице.
Логика, формирующая эти значения (`pl.total`, `truncated`), проверена чтением кода и совпадает с тем, что подтверждено на уровне API.
- Собственно предел `PREFIX_UI_CAP = 20000` (сценарий с >20 000 префиксов) — не воспроизводился (потребовал бы кратно больше времени на создание тестовых данных).
+25
View File
@@ -0,0 +1,25 @@
# Целостность дерева префиксов при параллельных изменениях (изменение 029)
Находка № 5 из `docs/reviews/2026-09-26-codebase-review-2.md`, серьёзность — низкая.
## Context
`attach_to_tree` определяет родителя и забирает вложенные префиксы по снимку данных транзакции. Два параллельных запроса могут создать в одном VRF пересекающиеся префиксы разной длины
(ручное создание `.0/29` и автовыделение `.4/30`). Уникальность `(vrf_id, prefix)` это не ловит, `parent_id` останется неверным. `FOR UPDATE` в `allocate_subnet` блокирует только строку родителя.
## Решение (`app/api/v1/prefixes.py`)
1. Хелпер `_lock_vrf(db, *vrf_ids)`: `pg_advisory_xact_lock(<VRF_NS>, vrf_id)` для каждого VRF **в порядке возрастания id** (исключает взаимную блокировку); пространство ключей — константа (например, 7033).
2. Вызов в начале каждой операции, меняющей дерево или раскладку адресов, до любых чтений дерева:
- `create_prefix` — VRF из тела запроса;
- `allocate_subnet` — VRF родителя. Сначала прочитать `vrf_id` родителя обычным `SELECT`, затем `_lock_vrf`, затем уже существующий `SELECT … FOR UPDATE`;
- `update_prefix` при смене VRF (`_move_to_vrf`) — исходный и целевой VRF;
- `delete_prefix` — VRF префикса (переподвешивание детей);
- `create_address` и `allocate_next` — VRF префикса (выбор самого узкого префикса и занятых диапазонов должен видеть согласованное дерево).
3. Операции над разными VRF не блокируют друг друга. Чтения (`GET`) не блокируются.
4. `README.md`: одна фраза в разделе «Модель данных» — изменения дерева одного VRF выполняются по одному.
## Файлы
`app/api/v1/prefixes.py`, `README.md`.
## Проверка
Параллельно (потоки) в одном VRF: создание `.0/29` и автовыделение `/30` из `/24` → у выделенного `/30`, если он внутри `/29`, родитель — `/29`.
Цикл из 20 повторов без ошибок и без неверных родителей. Данные после проверки удалить.
@@ -0,0 +1,35 @@
# Итог: целостность дерева префиксов при параллельных изменениях (изменение 029)
## Что сделано
- `app/api/v1/prefixes.py`: константа `VRF_LOCK_NS = 7033` и хелпер `_lock_vrf(db, *vrf_ids)` — `pg_advisory_xact_lock(VRF_LOCK_NS, vrf_id)` для каждого VRF, отсортированных
по возрастанию id (исключает взаимную блокировку при операциях над несколькими VRF).
- Вызов добавлен в начале каждой операции, меняющей дерево или раскладку адресов, до любых чтений дерева:
- `create_prefix` — по `body.vrf_id`, первой строкой обработчика;
- `allocate_subnet` — сначала обычный `SELECT vrf_id` префикса-родителя (без блокировки строки), затем `_lock_vrf`, затем уже существующий `SELECT … FOR UPDATE`;
- `update_prefix` при смене VRF — по исходному и целевому VRF, до вызова `_move_to_vrf`;
- `delete_prefix` — по VRF префикса, до переподвешивания детей;
- `create_address` — по VRF префикса, до проверки «самый узкий префикс»;
- `allocate_next` — обычный `SELECT vrf_id`, затем `_lock_vrf`, затем существующий `SELECT … FOR UPDATE` по префиксу.
- Чтения (`GET`) не блокируются; операции над разными VRF друг друга не блокируют (независимые ключи advisory-lock).
- `README.md`, раздел «Модель данных»: одно предложение о сериализации изменений дерева одного VRF.
## Отклонения от плана
Нет.
## Как проверено
1. `venv/bin/python -c 'import app.main'` — без ошибок.
2. Стенд пересобран, `healthy`.
3. Сценарий из плана (20 повторов, временная организация `rv3-tree-test`, скрипт в scratchpad): в каждой итерации — свой `/24`-родитель, и **параллельно** (два потока) —
явное создание `.0/29` и автовыделение `/30` из родителя (`POST …/subnets/next {length:30}`). Оба запроса каждый раз завершились без ошибок (0 ошибок из 40 запросов).
Итоговое состояние дерева (отдельный `GET` **после** завершения обоих запросов пары — не тело ответа гонки, оно отражает лишь момент собственного commit) проверено на всех
20 итерациях: во всех случаях, когда выделенный `/30` оказался внутри `.0/29`, его `parent_id` равен id `.0/29`, а не `.0/24`. **0 из 20 неверных `parent_id`. PASS.**
- Первый прогон теста дал 5 ложных срабатываний из-за ошибки самого теста (сверка велась по телу HTTP-ответа каждого из двух конкурентных запросов, которое отражает
состояние дерева на момент commit именно этого запроса, а не финальное состояние после обоих); после исправления теста (повторный `GET` уже обоих префиксов после
завершения гонки) все 20 итераций прошли успешно, а прямая проверка в БД (`SELECT … FROM prefixes`) подтвердила, что для всех 5 «проблемных» по первому прогону
итераций конечное состояние в базе уже было верным (гонка не оставляла ошибочных данных, ошибался только клиентский тест).
4. Временная организация и все её префиксы удалены по завершении.
## Не проверено
- Гонка при `update_prefix` (смена VRF, две блокировки) и при одновременном `create_address`/`allocate_next` в одном VRF — проверялась только комбинация
`create_prefix` + `allocate_subnet`, явно описанная в плане; остальные точки вызова `_lock_vrf` проверены только чтением кода.
- Поведение под большей степенью параллелизма (более двух одновременных запросов) и при конкурентных операциях над разными VRF (что они не блокируют друг друга) — не измерялось.
@@ -0,0 +1,44 @@
# Исправление замечаний 1–2 ревью изменений 025–029 (изменение 030)
Источник: `docs/reviews/2026-09-26-changes-025-029-review.md`, замечания № 1 и № 2. Только код. Тесты исполнитель не пишет и не запускает (тестирование — отдельный шаг).
## 1. Чтение префикса до блокировки VRF (низкая, 029)
**Где:** `app/api/v1/prefixes.py` — `delete_prefix`, `update_prefix` (при смене VRF), `create_address`.
**Суть:** префикс читается через `get_or_404` **до** `_lock_vrf`. Возникают две проблемы:
- в `delete_prefix` устаревший `p.parent_id` используется для переподвешивания детей;
- если префикс параллельно переносят в другой VRF, блокируется не тот VRF (`p.vrf_id` прочитан до блокировки).
**Решение:**
1. Хелпер `_lock_prefix(db, id: int, *extra_vrf_ids: int) -> Prefix` рядом с `_lock_vrf`:
- прочитать `vrf_id` префикса отдельным `SELECT Prefix.vrf_id`; нет строки — 404 «Префикс не найден»;
- `_lock_vrf(db, vrf_id, *extra_vrf_ids)` — все VRF одним вызовом, порядок по возрастанию id уже обеспечен `_lock_vrf`;
- перечитать префикс под блокировкой: `select(Prefix).where(Prefix.id == id).with_for_update().execution_options(populate_existing=True)`,
чтобы не взять устаревший объект из identity map сессии;
- если перечитанный `vrf_id` отличается от заблокированного (префикс успели перенести), повторить цикл, не больше 3 попыток, затем 409 «Префикс одновременно изменяется, повторите запрос».
Блокировки транзакции при повторе не снимаются, это допустимо: порядок захвата внутри одной попытки по-прежнему возрастающий.
Если «лишняя» блокировка ухудшает порядок между попытками, допустимо вместо повтора сразу возвращать 409.
Выбрать вариант и указать его в SUMMARY.
2. Применение:
- `delete_prefix`: `p = _lock_prefix(db, id)` вместо `get_or_404` + `_lock_vrf`;
- `create_address`: то же;
- `update_prefix`: целевой VRF (`body.vrf_id`) известен из тела запроса до чтения префикса, поэтому `p = _lock_prefix(db, id, new_vrf)` при заданном `vrf_id`, иначе обычный `get_or_404` (без смены VRF дерево не меняется).
Если `new_vrf` совпал с текущим `p.vrf_id`, лишняя блокировка того же VRF безвредна.
- `allocate_subnet` и `allocate_next` уже читают `vrf_id` до блокировки; перевести их на `_lock_prefix` для единообразия, сохранив поведение (`FOR UPDATE` строки префикса есть в хелпере).
3. Комментарии — кратко, со ссылкой на изменение 030.
## 2. Зависимость ротации от API-слоя (низкая, 026)
**Где:** `app/rotation.py` импортирует `KNOWN_IP_DAYS` из `app.api.v1.auth`.
**Решение:**
1. Перенести константы политики входа — `LOGIN_WINDOW` (сейчас `WINDOW`), `MAX_PER_LOGIN_IP`, `MAX_PER_IP`, `MAX_PER_LOGIN`, `KNOWN_IP_DAYS` — в `app/services.py` отдельным блоком с комментарием.
2. `app/api/v1/auth.py` импортирует их из `app.services`; внутренние имена в `auth.py` не должны расходиться с перенесёнными. `WINDOW` заменить на `LOGIN_WINDOW` по месту, модульная docstring с `.format(...)` продолжает работать.
3. `app/rotation.py` импортирует `KNOWN_IP_DAYS` из `app.services`; импорт `app.api.v1.auth` удалить.
4. Пространства ключей advisory-lock (`LOGIN_LOCK_NS`, `IP_LOCK_NS`) остаются в `auth.py`.
## Артефакты
- `docs/changes/030-review-fixes-025-029/SUMMARY.md`: что сделано, выбранный вариант повтора из п. 1, отклонения.
- `README.md` — только если меняется описанное поведение (ожидается, что не меняется).
## Проверка (выполняет ревьюер, не исполнитель)
- Импорт приложения; `rotation.py` не импортирует API-модули.
- Параллельные сценарии по п. 1: удаление префикса ∥ вставка промежуточного префикса; перенос префикса в другой VRF ∥ создание адреса / удаление.
- Регрессия: `pytest -q`.
@@ -0,0 +1,81 @@
# Итог: исправление замечаний 1–2 ревью изменений 025–029 (изменение 030)
Источник: `docs/reviews/2026-09-26-changes-025-029-review.md`, замечания № 1 и № 2. Только код, без тестов.
## 1. Чтение префикса до блокировки VRF (`app/api/v1/prefixes.py`)
- Добавлен хелпер `_lock_prefix(db, id, *extra_vrf_ids) -> Prefix` рядом с `_lock_vrf`:
1. `vrf_id` читается отдельным `SELECT Prefix.vrf_id` (без блокировки строки); нет строки — 404 «Префикс не найден».
2. `_lock_vrf(db, vrf_id, *extra_vrf_ids)` — все нужные VRF одним вызовом, в возрастающем порядке (как раньше).
3. Префикс перечитывается под блокировкой: `select(Prefix).where(Prefix.id == id).with_for_update().execution_options(populate_existing=True)` —
`populate_existing`, чтобы не получить устаревший объект из identity map сессии.
4. Если перечитанный `vrf_id` не совпал с заблокированным (префикс успели перенести в другой VRF между шагами 1 и 3) — `409 «Префикс одновременно изменяется, повторите запрос»`.
- Применение:
- `delete_prefix`: `p = _lock_prefix(db, id)` вместо `get_or_404` + `_lock_vrf(db, p.vrf_id)`.
- `create_address`: то же самое.
- `update_prefix`: `new_vrf` вычисляется из тела запроса **до** чтения префикса (`data.pop("vrf_id", None)`); дальше
`p = _lock_prefix(db, id, new_vrf) if new_vrf is not None else get_or_404(db, Prefix, id, "Префикс")`. Если целевой VRF совпадает
с текущим, лишняя (повторная) блокировка того же VRF безвредна — как и предполагал план.
- `allocate_subnet`, `allocate_next`: переведены на `_lock_prefix` для единообразия. Прежний код читал `vrf_id` отдельным `SELECT`,
затем `_lock_vrf`, затем `SELECT … FOR UPDATE` без сверки `vrf_id` после блокировки — сейчас эта сверка выполняется хелпером,
т.е. поведение не просто перенесено, а дополнительно защищено той же гарантией, что и остальные точки.
### Выбранный вариант обработки гонки — без повтора, сразу 409
В плане предлагалось два варианта: (а) до 3 попыток внутри `_lock_prefix`, не снимая блокировки между попытками, либо
(б) сразу отдавать 409 без повтора. Выбран вариант **(б)**.
Причина: между двумя попытками цикла блокировка устаревшего (первого прочитанного) `vrf_id` из предыдущей попытки не снимается
(advisory-lock держится до конца транзакции), а на следующей попытке блокируется уже новый `vrf_id`. `_lock_vrf` сортирует по
возрастанию только ids **внутри одного своего вызова** — порядок между накопленными за разные попытки блокировками этой
гарантии не имеет. Если два конкурентных запроса одновременно переносят префиксы «навстречу» друг другу (транзакция A: сначала
видит VRF 5, после гонки — VRF 3; транзакция B — в обратном порядке), возможна ситуация, когда A держит блокировку VRF 5 и
запрашивает VRF 3, а B держит VRF 3 и запрашивает VRF 5 — классический deadlock из-за несогласованного порядка захвата между
попытками. Однократная попытка с немедленным 409 гарантированно не накапливает блокировки разных VRF за пределами одного
согласованного вызова `_lock_vrf`, поэтому этот риск не возникает. Транзакция откатывается при закрытии сессии (as-is для всех
остальных `HTTPException` в этом модуле — см., например, комментарий у `_move_to_vrf`), и клиент просто повторяет весь HTTP-запрос
с чистого листа, без унаследованных блокировок.
Это разумная цена: окно гонки узкое (перенос префикса в другой VRF — редкая административная операция), а 3 внутренние попытки
не устраняют риск deadlock — они его создают.
## 2. Зависимость ротации от API-слоя
- `app/services.py`: добавлен блок констант политики входа (после `MAX_CAPACITY`/`MAX_OFFSET`, с комментарием):
`LOGIN_WINDOW` (бывший `WINDOW`), `MAX_PER_LOGIN_IP`, `MAX_PER_IP`, `MAX_PER_LOGIN`, `KNOWN_IP_DAYS`. Добавлен импорт `from datetime import timedelta`.
- `app/api/v1/auth.py`: собственные определения констант удалены, всё импортируется из `app.services`. `WINDOW` заменён на
`LOGIN_WINDOW` по всем местам использования (`_retry_after`, `_distinct_logins`, форматирование модульной docstring). Внутренние
имена не разошлись с перенесёнными. `LOGIN_LOCK_NS`/`IP_LOCK_NS` (пространства advisory-lock) остались в `auth.py`, как и планировалось.
- `app/rotation.py`: `from app.api.v1.auth import KNOWN_IP_DAYS` заменён на `from app.services import KNOWN_IP_DAYS, SYSTEM, audit`.
Модуль больше не импортирует ничего из `app.api.*`.
## Отклонения от плана
- П. 1: выбран вариант «сразу 409» вместо цикла до 3 попыток — обоснование выше (план явно допускал оба варианта и просил
зафиксировать выбор в SUMMARY).
- В остальном реализация соответствует плану без отклонений.
## Изменённые файлы
- `app/api/v1/prefixes.py` — хелпер `_lock_prefix`, применение в `delete_prefix`, `create_address`, `update_prefix`, `allocate_subnet`, `allocate_next`.
- `app/services.py` — константы политики входа.
- `app/api/v1/auth.py` — импорт констант из `app.services` вместо локальных определений.
- `app/rotation.py` — импорт `KNOWN_IP_DAYS` из `app.services` вместо `app.api.v1.auth`.
`README.md` не менялся: наблюдаемое поведение API не меняется (кроме нового 409 при воспроизведении узкой гонки переноса VRF,
который относится к тому же классу конкурентных ошибок, что уже описан в README для изменения 029).
## Не проверено
Всё поведение — по правилам задачи, автор изменения тесты не пишет и не запускает. Не проверялось (проверяет ревьюер):
- Импорт приложения проверен только `import app.main`; `pytest -q` не запускался.
- Параллельные сценарии из плана: удаление префикса ∥ вставка промежуточного префикса; перенос префикса в другой VRF ∥
создание адреса / удаление — не воспроизводились ни через API, ни вручную.
- Что `_lock_prefix` действительно возвращает 409 при воспроизведённой гонке (реальный `vrf_id` mismatch под нагрузкой).
- Поведение `populate_existing=True` в связке с уже загруженными объектами `Prefix` в сессии (в текущих точках вызова такой
объект до `_lock_prefix` не загружается, поэтому эффект защитный, но не критичен для текущих путей вызова).
- Корректность обновлённой модульной docstring `auth.py` после форматирования (`__doc__.format(...)`) — визуально не отличается
от прежней, но не выводилась и не сравнивалась построчно.
- `rotation.py`: что при импорте больше не вычисляется фиктивный argon2-хэш из `auth.py` (косвенный эффект удаления импорта,
отдельно не измерялся).
@@ -0,0 +1,71 @@
# Ревью и тестирование изменений 025–029 — 2026-09-26
**Объём:** незакоммиченные изменения поверх `13e17fb`, выполненные агентом (Sonnet 5) по планам `docs/changes/025…029`.
Изменены `app/api/v1/{auth,overview,prefixes}.py`, `app/models.py`, `app/rotation.py`, `web/app.js`, `README.md`; добавлена миграция `0009_known_logins`.
**Метод:**
- чтение diff;
- автотесты проекта;
- собственные сквозные сценарии на стенде `ipam_control_006`: API, прямые вставки в БД для эмуляции других IP, headless Chromium для UI. Скрипты лежат во временной папке, в репозиторий не добавлены. Временные данные удалены.
## Итог
Все пять изменений реализованы по планам и работают.
| Проверка | Результат |
|---|---|
| `import app.main`, `node --check web/app.js` | ✅ |
| Стенд: `app` и `db` healthy, миграция `0009`, `alembic check` | ✅ расхождений нет |
| `pytest -q` | ✅ **14 passed**, после приведения одной проверки к семантике 025 (см. ниже) |
| Собственные сценарии 025–029 | ✅ **14/14** |
**Правка тестов.** `test_tree_utilization_and_next_free` проверял прежнее правило «ёмкость родителя = сумма листьев» (`capacity == 6`), которое изменение 025 намеренно отменило. Проверка заменена на `capacity == 65534` (размер `/16`), `used == 2`. Агенту трогать тесты было запрещено, поэтому до этой правки тест падал ожидаемо.
## Результаты сценариев
| Изм. | Сценарий | Результат |
|---|---|---|
| 025 | `/24` с 40 адресами, затем автовыделение `/30`: у родителя `40 / 254 / 16 %` (раньше было `40 / 2 / 100 %`) | ✅ |
| 025 | Ёмкость родителя совпадает с `summary.capacity` экрана адресов | ✅ |
| 025 | «Обзор» совпадает с независимым SQL-расчётом: ёмкость — сумма корневых активных IPv4-префиксов, «назначено» — адреса внутри корней | ✅ |
| 026 | Успешный вход добавляет IP в `known_logins` | ✅ |
| 026 | 5 неудач с чужого IP не блокируют вход владельца с его IP | ✅ |
| 026 | 55 неудач по логину с разных IP: вход с известного IP — 200, с неизвестного — 429 | ✅ |
| 026 | 5 неудач с одного IP: 401×4, затем 429; верный пароль с этого IP тоже 429; `session.locked` с `scope=login_ip` | ✅ |
| 027 | 15 накопленных неудач IP, затем 16 параллельных входов на разные логины: проверено ≤ 5 паролей, остальные 429 | ✅ |
| 028 | Организация с 1 101 префиксом: UI показывает «1101 префикс» (total), предупреждения об усечении нет, ошибок в консоли нет | ✅ |
| 029 | 20 гонок «ручной `/29` ∥ автовыделение `/30`» в одном `/24` (15 раз первым выполнился ручной, 5 — автоматический): у всех префиксов родитель — самый узкий охватывающий, 0 расхождений | ✅ |
Замечание о методике. В первом прогоне сценарий 029 давал ложные «FAIL»: эталон ожидал `.4/30`, а при ручном `/29` первым автовыделение законно выбирает `.8/30`. Сценарий переписан на проверку инварианта дерева (родитель — самый узкий охватывающий префикс). Той же причины касается замечание агента о «5 ложных срабатываниях».
## Замечания ревью
| # | Серьёзность | Изм. | Суть |
|---|---|---|---|
| 1 | Низкая | 029 | `delete_prefix`, `update_prefix` и `create_address` читают префикс **до** блокировки VRF 🔎 |
| 2 | Низкая | 026 | `app/rotation.py` импортирует константу из API-модуля (`app.api.v1.auth.KNOWN_IP_DAYS`) 🔎 |
| 3 | Инфо | 028 | Экран адресов загружает все префиксы организации, до 20 000, ради крошек и диалогов 🔎 |
| 4 | Инфо | 026 | Компромисс политики: распределённый перебор получает до 5 попыток на IP 🔎 |
### 1. Чтение префикса до блокировки VRF (029)
- В `delete_prefix` `p.parent_id` читается до `_lock_vrf`. Если параллельно между `p` и его родителем создан промежуточный префикс, дочерние элементы `p` при удалении будут переподвешены к устаревшему родителю.
- В `update_prefix` и `create_address` блокировка берётся по `vrf_id`, прочитанному до неё. Если в это время префикс переносят в другой VRF, будет заблокирован не тот VRF.
Окно гонки узкое. **Исправление:** как в `allocate_subnet`, сначала `SELECT vrf_id`, затем `_lock_vrf`, затем чтение префикса. Другой вариант — `db.refresh(p)` сразу после блокировки.
### 2. Зависимость ротации от API-слоя (026)
`rotation.py` → `app.api.v1.auth`: служебный модуль зависит от роутера, а при импорте ротации вычисляется фиктивный argon2-хэш. Циклического импорта сейчас нет, но связь хрупкая.
**Исправление:** перенести `KNOWN_IP_DAYS` и пороги входа в `app/services.py` или `app/config.py`.
### 3. Объём загрузки на экране адресов (028)
`screens.address` вызывает `loadPrefixes`, то есть загружает до 20 страниц по 1 000 префиксов, причём каждая страница на сервере пересчитывает глубины (`_depths`) по всей организации. Для крупной организации открытие каждого экрана адресов станет заметно медленнее.
**Рекомендация (отдельной задачей):**
- эндпоинт цепочки предков для хлебных крошек;
- загрузка префиксов VRF для диалогов по требованию, при открытии окна.
### 4. Компромисс политики блокировки (026)
После 026 перебор с N разных IP даёт до 5 попыток на каждый IP и до 50 в сумме по логину для неизвестных IP. До 026 общий лимит был 5 на логин. Это осознанная цена за то, что владельца больше нельзя заблокировать анонимно.
Лимит по IP (20) по-прежнему позволяет заблокировать вход всем пользователям за общим NAT. Если это существенно, можно исключать из лимита по IP известные пары «логин + IP».
## Вывод
Замечания 1–2 можно закрыть небольшой правкой, 3–4 — по желанию, отдельными задачами. Изменения 025–029 вместе с правкой теста готовы к коммиту.
@@ -0,0 +1,116 @@
# Повторный анализ кодовой базы IPAM Manager — 2026-09-26
**Состояние:** коммит `13e17fb` (задачи 001–024). Объём: `app/` и `web/app.js` — около 3 400 строк, миграции 0001–0008.
**Метод:** чтение кода с упором на участки, которые изменения 010–024 затронули или усилили. Подозрительные места проверены на стенде `ipam_control_006` на временных данных, после проверки данные удалены.
**Предыдущие отчёты:** `2026-09-26-codebase-review.md` (13 находок) и `2026-09-26-changes-011-023-review.md` (7 находок).
Пометки: ✅ — воспроизведено на стенде, 🔎 — вывод по коду.
## Итог
Все 13 находок первого ревью закрыты, остаточные замечания указаны ниже. Все 7 находок ревью изменений 011–023 закрыты изменением 024.
Состояние системы:
- `alembic check` без расхождений;
- 14 тестов проходят;
- контейнер работает от непривилегированного пользователя и в статусе `healthy`;
- UI работает под CSP без ошибок в консоли.
Новый анализ выявил одну заметную ошибку: неверная ёмкость и загрузка частично разбитого префикса (№ 1). Её усиливает сценарий автовыделения подсетей из изменения 010. Кроме неё — один риск доступности (№ 2) и несколько низких замечаний.
| # | Серьёзность | Область | Кратко |
|---|---|---|---|
| 1 | Средняя | Данные / отображение | Ёмкость родителя равна сумме ёмкостей листьев: после выделения одного `/30` у `/24` ёмкость 254 → 2, загрузка 16 % → 100 % ✅ |
| 2 | Средняя | Доступность | Анонимный клиент может держать администратора заблокированным: 5 запросов каждые 10 минут, верный пароль тоже отклоняется ✅ |
| 3 | Низкая | UI | Экран «Префиксы» загружает не больше 1 000 префиксов и показывает число загруженных, а не `total`; дерево молча усекается 🔎 |
| 4 | Низкая | Тесты / эксплуатация | Тесты работают с БД рабочего стенда: создают данные и сбрасывают настройки журнала на значения по умолчанию, а не на исходные 🔎 |
| 5 | Низкая | Конкурентность | Дерево префиксов (`parent_id`) поддерживается только кодом: параллельное создание пересекающихся префиксов может оставить неверного родителя 🔎 |
| 6 | Низкая | API | Явный `parent_id` при создании префикса может указать не самого узкого родителя, и дерево разойдётся с вложенностью CIDR 🔎 |
| 7 | Низкая | Безопасность | Лимит в 20 попыток по IP не сериализуется между разными логинами (остаточное замечание 024) 🔎 |
| 8 | Инфо | API | `PATCH /organizations/{id}` требует полное тело (`OrgIn`), частичное обновление даёт 422 🔎 |
| 9 | Инфо | Эксплуатация | Быстрый старт в README (`docker compose up`) поднимает проект `ipam_control`, а рабочий стенд — `ipam_control_006`; легко перепутать поставки 🔎 |
---
## Находки
### 1. Ёмкость и загрузка частично разбитого префикса ✅
`_capacities` (`app/api/v1/prefixes.py`) считает ёмкость родителя как сумму ёмкостей листьев, а `used` (`_usage`) — по всем адресам поддерева, включая адреса самого родителя вне дочерних префиксов. После изменения 010 («Добавить вложенный (авто)») частичное разбиение стало обычным сценарием, и расхождение проявляется сразу.
**Воспроизведение:** `172.20.0.0/24` с 40 адресами — `used 40 / capacity 254 / 16 %`. После автовыделения одного `/30` — `used 40 / capacity 2 / 100 %`. Экран адресов того же префикса по-прежнему показывает ёмкость 254.
Та же причина искажает «Обзор»: `assigned` считается по всем адресам IPv4, а `capacity` — только по листьям. Адреса в нелистовых префиксах завышают загрузку.
**Рекомендация:**
- ёмкость любого префикса — размер его собственной подсети (`capacity(prefix)`);
- на «Обзоре» ёмкость — сумма по корневым префиксам (без родителя) каждого VRF;
- `used` оставить как есть: считается по поддереву, дублей нет благодаря изменению 011.
Так числа станут согласованы с экраном адресов.
### 2. Блокировка входа администратора анонимным клиентом ✅
Лимит по логину (5 неудач за 10 минут, изменение 012) действует для всех IP. Верный пароль во время блокировки тоже отклоняется. Проверено при сверке 012: 429 и `Retry-After` на верный пароль.
Любой, кто знает логин (например, `admin` из README), может отправлять 5 неверных попыток раз в 10 минут и держать администратора заблокированным. Риск был отмечен в плане 012; ниже вариант смягчения.
**Рекомендация:**
- блокировать по паре «логин + IP» (5 попыток), а общий по логину порог сделать выше (например, 50) и рассматривать его как сигнал в журнале;
- альтернатива: не блокировать вход с IP, с которого этот пользователь успешно входил за последние N дней.
### 3. Усечение списка префиксов в UI 🔎
`screens.prefixes` и экран адресов запрашивают `/prefixes?limit=1000`, а заголовок, вкладки и подвал показывают `all.length`, то есть число загруженных префиксов, а не `total`. Родительский список в «Новом префиксе» и умолчание размера в «Добавить вложенный» тоже строятся по усечённому набору.
Организация, у которой `/22` разбит на `/30` (256 префиксов), быстро выходит за 1 000.
**Рекомендация:**
- минимум: показывать `total` и предупреждение «загружено 1 000 из N»;
- лучше: догружать страницы (`offset`) или получать дерево для конкретного VRF и ветки.
### 4. Тесты работают с данными стенда 🔎
`tests/conftest.py` работает с API и БД из `.env`, то есть со стендом `ipam_control_006`, где лежат демо-данные.
- `test_rotation_by_age_and_count` в `finally` выставляет `retention_days=90, max_entries=100000` вместо значений, которые были до теста. Пользовательские настройки ротации теряются.
- Остатки неудачных прогонов (организации `test-*`) видны в UI.
**Рекомендация:**
- отдельный compose-проект или БД для тестов (например, `docker compose -p ipam_test` с другим `.env`);
- в тесте ротации сохранять и восстанавливать исходные настройки.
### 5. Дерево префиксов при параллельных изменениях 🔎
`attach_to_tree` определяет родителя и забирает вложенные префиксы по снимку данных в транзакции. Два параллельных запроса могут создать в одном VRF пересекающиеся префиксы разной длины (ручное создание `.0/29` и автовыделение `.4/30`). Уникальность `(vrf_id, prefix)` это не ловит, и `parent_id` останется неверным.
`FOR UPDATE` в `allocate_subnet` блокирует только строку родителя.
**Рекомендация:** в `create_prefix`, `allocate_subnet` и `_move_to_vrf` брать `pg_advisory_xact_lock` по `vrf_id`: изменения дерева одного VRF выполняются по одному. Дешёвый и полный вариант.
### 6. Явный `parent_id` против вложенности CIDR 🔎
`create_prefix` с `parent_id` (`keep_parent=True`) проверяет только, что новый префикс входит в указанный родитель. Если существует более узкий префикс, который тоже содержит новый, дерево будет указывать на более широкого родителя. `_capacities` и `_depths` работают по `parent_id`, `_usage` — по вложенности CIDR, и эти расчёты разойдутся.
**Рекомендация:** если указан не самый узкий родитель, возвращать 422 или игнорировать `parent_id` и вычислять родителя автоматически, как без параметра.
### 7. Лимит по IP между разными логинами 🔎
Сериализация из изменения 024 работает по логину. Параллельный перебор многих разных логинов с одного IP проходит проверку лимита по IP одновременно и может превысить 20 попыток на степень параллелизма.
**Рекомендация:** вторая advisory-блокировка по `hashtext(ip)`, брать её после блокировки логина, всегда в одном порядке.
### 8. PATCH организации требует полное тело 🔎
`update_org` принимает `OrgIn` (обязательные `name` и `inn`), поэтому `PATCH` с одним полем возвращает 422. UI отправляет полную форму, так что пользователи этого не замечают, но семантика PATCH нарушена.
**Рекомендация:** схема `OrgUpdate` с необязательными полями и `exclude_unset`, как у остальных PATCH.
### 9. Две поставки стенда 🔎
В README быстрый старт использует `docker compose up -d --build`, то есть проект `ipam_control`: он собирает старые контейнеры `ipam_control-*` на тех же портах. Актуальный стенд поднят как `docker compose -p ipam_control_006`.
**Рекомендация:** задать имя проекта в `docker-compose.yml` (`name: ipam_control`) и перенести стенд на него, либо описать в README, какой проект рабочий.
---
## Статус находок первого ревью
| № | Находка | Статус |
|---|---|---|
| 1 | Один IP дважды в VRF | ✅ закрыта (011); остаток: старые адреса сети/broadcast блокируют охватывающий префикс (на стенде 0) |
| 2 | Перебор паролей и вытеснение журнала | ✅ закрыта (012, 024); остаток — № 2 и № 7 этого отчёта |
| 3 | 500 на отрицательных `limit`/`offset` | ✅ закрыта (013) |
| 4 | Ресурсоёмкий экран адресов | ✅ закрыта (014) |
| 5 | Адрес сети/broadcast | ✅ закрыта (015, 024) |
| 6 | Роль по умолчанию admin | ✅ закрыта (016) |
| 7 | Встроенный JWT-секрет | ✅ закрыта (017) |
| 8 | null в PATCH → ложный 409 | ✅ закрыта (018) |
| 9 | N+1 | ✅ в основном закрыта (019); «Обзор» и `_prefix_outs` по-прежнему читают всё дерево организации |
| 10 | Автоназначение и вложенные префиксы | ✅ закрыта (020) |
| 11 | Гонки | ✅ закрыта (021); новая гонка по дереву префиксов — № 5 |
| 12 | Эксплуатация | ✅ закрыта (022); `APP_BIND` по умолчанию `0.0.0.0` (сознательно) |
| 13 | Мелочи | ✅ закрыта (023) |
## Предлагаемый порядок
1. № 1 — ёмкость префиксов: небольшая правка в `_capacities` и `overview`, заметна пользователю сразу.
2. № 2 и № 7 — политика блокировки входа.
3. № 3 и № 5 — масштаб и целостность дерева префиксов.
4. № 4 и № 9 — изоляция тестового окружения и порядок в поставках.
5. № 6 и № 8 — по желанию.
@@ -0,0 +1,47 @@
# Ревью и тестирование изменения 030 — 2026-09-27
**Объём:** правки агента (Sonnet 5) по плану `docs/changes/030-review-fixes-025-029/PLAN.md`, которые закрывают замечания 1 и 2 ревью `2026-09-26-changes-025-029-review.md`.
Изменены `app/api/v1/prefixes.py`, `app/api/v1/auth.py`, `app/services.py`, `app/rotation.py`.
**Тестировались только доработки 1 и 2.** Помимо этого выполнен регрессионный прогон `pytest`.
## Ревью кода
### Замечание 1 — `_lock_prefix`
Хелпер работает в таком порядке:
1. `SELECT vrf_id` префикса;
2. `_lock_vrf` для этого VRF и дополнительных (целевого VRF при переносе), одним вызовом, по возрастанию id;
3. повторное чтение префикса под `FOR UPDATE` с `populate_existing`;
4. если `vrf_id` к этому моменту изменился — 409 без повтора.
Хелпер применён в `delete_prefix`, `update_prefix` (только при смене VRF), `create_address`, `allocate_subnet`, `allocate_next`.
- **Порядок блокировок единый:** везде сначала advisory-блокировка VRF, затем строка префикса. `update_prefix` без смены VRF блокирует только строку (при flush) и не запрашивает блокировку VRF, поэтому цикла ожидания нет.
- **Отказ от повтора обоснован.** Повтор оставил бы в транзакции блокировку устаревшего VRF и нарушил бы возрастающий порядок захвата между попытками. Сценарий 1d показал, что ветка 409 реально срабатывает.
- **Встречные переносы** (A: 5→3, B: 3→5) блокируют пару VRF одним вызовом в одинаковом порядке, взаимной блокировки нет. Подтверждено сценарием 1c.
- **Побочный эффект:** `create_address` теперь берёт `FOR UPDATE` строки префикса, и назначения адресов в одном префиксе выполняются по одному. На нагрузку это не влияет, операции короткие.
### Замечание 2 — константы входа
- `LOGIN_WINDOW`, `MAX_PER_LOGIN_IP`, `MAX_PER_IP`, `MAX_PER_LOGIN`, `KNOWN_IP_DAYS` перенесены в `app/services.py`.
- `auth.py` и `rotation.py` импортируют их оттуда. Пространства ключей advisory-блокировок остались в `auth.py`.
- Импорт `app.rotation` больше не загружает модули `app.api.*`: проверено по `sys.modules`, список пуст. Докстринг `auth.py` форматируется корректно.
Новых замечаний нет.
## Тесты
| Проверка | Результат |
|---|---|
| Стенд `ipam_control_006` пересобран, `app` healthy | ✅ |
| Регрессия `pytest -q` | ✅ 14 passed |
| 2: `app.rotation` не импортирует API-слой | ✅ |
| 2: политика входа не изменилась: 5 неудач → 401×4 + 429, на верный пароль тоже 429, `Retry-After` ≈ 600 с | ✅ |
| 1a: удаление префикса ∥ вставка промежуточного префикса, ×20 | ✅ все пары ответов 204/201; дерево согласовано: у каждого префикса родитель — самый узкий охватывающий |
| 1b: перенос в другой VRF ∥ создание адреса в переносимом префиксе, ×20 | ✅ без 500 и взаимных блокировок; у всех адресов `vrf_id` совпадает с VRF их префикса; все 20 префиксов перенесены |
| 1c: встречные переносы двух префиксов между двумя VRF, ×15 | ✅ всё 200/200, взаимных блокировок нет |
| 1d: перенос ∥ удаление того же префикса, ×10 | ✅ только ожидаемые исходы: 5× (перенос 200, удаление 409 «одновременно изменяется»), 5× (перенос 404, удаление 204) |
| Лог приложения: `deadlock` / `Traceback` | ✅ 0 |
Сценарии выполнялись скриптом во временной папке, в репозиторий он не добавлен. Временные данные (`vt30-*`), попытки входа и записи `known_logins` удалены.
## Вывод
Замечания 1 и 2 закрыты. Изменения 025–030 готовы к коммиту.
+1 -1
View File
@@ -35,7 +35,7 @@ def test_tree_utilization_and_next_free(client, org):
assert page["summary"] == {"assigned": 1, "reserved": 1, "deprecated": 0, "free": 4, "capacity": 6}
assert client.post(f"/prefixes/{leaf['id']}/addresses/next").json()["address"] == "10.203.1.3"
parent = client.get(f"/prefixes/{parent['id']}").json()
assert parent["capacity"] == 6 and parent["used"] == 2 # ёмкость родителя — по вложенным листьям
assert parent["capacity"] == 65534 and parent["used"] == 2 # ёмкость родителя — размер его подсети, used — по поддереву (изменение 025)
def test_in_use_objects_cannot_be_deleted(client, org):
+21 -5
View File
@@ -358,11 +358,25 @@ async function typesDialog() {
}
// ---- prefixes
const PREFIX_UI_CAP = 20000; // защита от неограниченной подгрузки: организация с частичным разбиением может дать сотни тысяч префиксов
async function loadPrefixes(orgId) {
// грузит все страницы /prefixes (порядок ORDER BY vrf_id, prefix стабилен) до total, но не больше PREFIX_UI_CAP —
// иначе заголовок, вкладка «Все» и родительский список в диалогах молча ограничивались первой страницей (изменение 028, находка №3)
let items = [], total = 0, offset = 0;
for (;;) {
const page = await api("/prefixes", { params: { organization_id: orgId, limit: 1000, offset } });
total = page.total;
items = items.concat(page.items);
offset += page.items.length;
if (page.items.length === 0 || offset >= total || items.length >= PREFIX_UI_CAP) break;
}
return { items, total };
}
screens.prefixes = async () => {
const org = currentOrg();
if (!org) return shell("prefixes", header("Префиксы", "Сначала добавьте организацию") + `<div class="card"><div class="empty">Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}</div></div>`);
const [pl, vl] = await Promise.all([
api("/prefixes", { params: { organization_id: org.id, limit: 1000 } }),
loadPrefixes(org.id),
api("/vrfs", { params: { organization_id: org.id } }),
]);
S.prefixes = pl.items;
@@ -393,11 +407,13 @@ screens.prefixes = async () => {
}).join("");
const statusItems = [{ label: "любой", value: "" }, { label: "Активен", value: "active" }, { label: "Резерв", value: "reserved" }, { label: "Устарел", value: "deprecated" }];
const famItems = [{ label: "любое", value: "" }, { label: "IPv4", value: "4" }, { label: "IPv6", value: "6" }];
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${all.length} ${plural(all.length, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
<div class="tabs">${tab(0, "Все", all.length)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
const truncated = pl.total > all.length ? `<div class="info">Загружено ${fmtNum(all.length)} из ${fmtNum(pl.total)} префиксов — уточните поиск или выберите VRF</div>` : "";
return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${pl.total} ${plural(pl.total, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))}
<div class="tabs">${tab(0, "Все", pl.total)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}<div class="grow"></div><button type="button" class="link-muted" data-action="vrfs">${I.gear()}Управление VRF</button></div>
${truncated}
<div class="card"><div class="filters">${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}</div>
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span></span><span>Префикс</span><span>Описание</span><span>VRF</span><span>Статус</span><span>Использование</span><span>Адресов</span><span></span></div>
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}</div></div>`);
${rows || '<div class="empty">Префиксов нет</div>'}<div class="foot">Показано ${visible.length} из ${pl.total} ${plural(pl.total, "префикса", "префиксов", "префиксов")}</div></div>`);
};
function prefixDialog(p) {
const edit = !!p;
@@ -495,7 +511,7 @@ screens.address = async (id) => {
const limit = S.limit || 100;
const [page, plist, devs, vrfs] = await Promise.all([
api(`/prefixes/${id}/addresses`, { params: { status: S.astatus, q: S.q, limit } }),
api("/prefixes", { params: { organization_id: p.organization_id, limit: 1000 } }),
loadPrefixes(p.organization_id),
api("/devices", { params: { organization_id: p.organization_id, limit: 500 } }),
api("/vrfs", { params: { organization_id: p.organization_id } }), // нужен окну «Редактировать префикс»
]);