diff --git a/README.md b/README.md index 05c2592..18afdaa 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/alembic/versions/0009_known_logins.py b/alembic/versions/0009_known_logins.py new file mode 100644 index 0000000..6be538d --- /dev/null +++ b/alembic/versions/0009_known_logins.py @@ -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") diff --git a/app/api/v1/auth.py b/app/api/v1/auth.py index a9d50c4..8f262a2 100644 --- a/app/api/v1/auth.py +++ b/app/api/v1/auth.py @@ -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)) diff --git a/app/api/v1/overview.py b/app/api/v1/overview.py index 500f4cc..e03c090 100644 --- a/app/api/v1/overview.py +++ b/app/api/v1/overview.py @@ -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, diff --git a/app/api/v1/prefixes.py b/app/api/v1/prefixes.py index 5e859ec..1393799 100644 --- a/app/api/v1/prefixes.py +++ b/app/api/v1/prefixes.py @@ -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 пропускаются diff --git a/app/models.py b/app/models.py index 537f414..f94fdf7 100644 --- a/app/models.py +++ b/app/models.py @@ -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" diff --git a/app/rotation.py b/app/rotation.py index 1d4078d..43d6b03 100644 --- a/app/rotation.py +++ b/app/rotation.py @@ -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: diff --git a/app/services.py b/app/services.py index e5fcb5f..12a581e 100644 --- a/app/services.py +++ b/app/services.py @@ -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") diff --git a/docs/changes/025-prefix-capacity/PLAN.md b/docs/changes/025-prefix-capacity/PLAN.md new file mode 100644 index 0000000..ddcb1eb --- /dev/null +++ b/docs/changes/025-prefix-capacity/PLAN.md @@ -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 %. diff --git a/docs/changes/025-prefix-capacity/SUMMARY.md b/docs/changes/025-prefix-capacity/SUMMARY.md new file mode 100644 index 0000000..ba76b5d --- /dev/null +++ b/docs/changes/025-prefix-capacity/SUMMARY.md @@ -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-префиксами одновременно (на демо-данных стенда — только реальные данные, без специально подготовленного смешанного случая). diff --git a/docs/changes/026-login-lockout-policy/PLAN.md b/docs/changes/026-login-lockout-policy/PLAN.md new file mode 100644 index 0000000..88b3d5e --- /dev/null +++ b/docs/changes/026-login-lockout-policy/PLAN.md @@ -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` без расхождений. diff --git a/docs/changes/026-login-lockout-policy/SUMMARY.md b/docs/changes/026-login-lockout-policy/SUMMARY.md new file mode 100644 index 0000000..42f9e7f --- /dev/null +++ b/docs/changes/026-login-lockout-policy/SUMMARY.md @@ -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`, настроенном на реальный прокси (не задан на стенде). diff --git a/docs/changes/027-login-ip-serialization/PLAN.md b/docs/changes/027-login-ip-serialization/PLAN.md new file mode 100644 index 0000000..0084007 --- /dev/null +++ b/docs/changes/027-login-ip-serialization/PLAN.md @@ -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(, hashtext(name))` и `pg_advisory_xact_lock(, 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. diff --git a/docs/changes/027-login-ip-serialization/SUMMARY.md b/docs/changes/027-login-ip-serialization/SUMMARY.md new file mode 100644 index 0000000..864e1d8 --- /dev/null +++ b/docs/changes/027-login-ip-serialization/SUMMARY.md @@ -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 в старой (одноаргументной) схеме — не воспроизводилось намеренно, замена на двухаргументную форму + устраняет саму возможность, отдельно не тестировалось. diff --git a/docs/changes/028-prefixes-ui-pagination/PLAN.md b/docs/changes/028-prefixes-ui-pagination/PLAN.md new file mode 100644 index 0000000..88f51ba --- /dev/null +++ b/docs/changes/028-prefixes-ui-pagination/PLAN.md @@ -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 префикс», все строки доступны. +Данные после проверки удалить. diff --git a/docs/changes/028-prefixes-ui-pagination/SUMMARY.md b/docs/changes/028-prefixes-ui-pagination/SUMMARY.md new file mode 100644 index 0000000..58c5a3b --- /dev/null +++ b/docs/changes/028-prefixes-ui-pagination/SUMMARY.md @@ -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 префиксов) — не воспроизводился (потребовал бы кратно больше времени на создание тестовых данных). diff --git a/docs/changes/029-prefix-tree-lock/PLAN.md b/docs/changes/029-prefix-tree-lock/PLAN.md new file mode 100644 index 0000000..a8a305d --- /dev/null +++ b/docs/changes/029-prefix-tree-lock/PLAN.md @@ -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_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 повторов без ошибок и без неверных родителей. Данные после проверки удалить. diff --git a/docs/changes/029-prefix-tree-lock/SUMMARY.md b/docs/changes/029-prefix-tree-lock/SUMMARY.md new file mode 100644 index 0000000..514e444 --- /dev/null +++ b/docs/changes/029-prefix-tree-lock/SUMMARY.md @@ -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 (что они не блокируют друг друга) — не измерялось. diff --git a/docs/changes/030-review-fixes-025-029/PLAN.md b/docs/changes/030-review-fixes-025-029/PLAN.md new file mode 100644 index 0000000..28e8761 --- /dev/null +++ b/docs/changes/030-review-fixes-025-029/PLAN.md @@ -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`. diff --git a/docs/changes/030-review-fixes-025-029/SUMMARY.md b/docs/changes/030-review-fixes-025-029/SUMMARY.md new file mode 100644 index 0000000..82db7f2 --- /dev/null +++ b/docs/changes/030-review-fixes-025-029/SUMMARY.md @@ -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` (косвенный эффект удаления импорта, + отдельно не измерялся). diff --git a/docs/reviews/2026-09-26-changes-025-029-review.md b/docs/reviews/2026-09-26-changes-025-029-review.md new file mode 100644 index 0000000..9e8afb6 --- /dev/null +++ b/docs/reviews/2026-09-26-changes-025-029-review.md @@ -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 вместе с правкой теста готовы к коммиту. diff --git a/docs/reviews/2026-09-26-codebase-review-2.md b/docs/reviews/2026-09-26-codebase-review-2.md new file mode 100644 index 0000000..dcd5807 --- /dev/null +++ b/docs/reviews/2026-09-26-codebase-review-2.md @@ -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 — по желанию. diff --git a/docs/reviews/2026-09-27-changes-030-review.md b/docs/reviews/2026-09-27-changes-030-review.md new file mode 100644 index 0000000..17c5d52 --- /dev/null +++ b/docs/reviews/2026-09-27-changes-030-review.md @@ -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 готовы к коммиту. diff --git a/tests/test_api.py b/tests/test_api.py index f6c347d..20b59dc 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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): diff --git a/web/app.js b/web/app.js index 3cd16ae..7fd69d2 100644 --- a/web/app.js +++ b/web/app.js @@ -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("Префиксы", "Сначала добавьте организацию") + `
Нет организаций. ${btn("Добавить организацию", "org-new", { cls: "primary" })}
`); 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() }))} -
${tab(0, "Все", all.length)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}
+ const truncated = pl.total > all.length ? `
Загружено ${fmtNum(all.length)} из ${fmtNum(pl.total)} префиксов — уточните поиск или выберите VRF
` : ""; + return shell("prefixes", `${crumbsOrg()}${header("Префиксы", `${pl.total} ${plural(pl.total, "префикс", "префикса", "префиксов")} · ${vl.total} VRF · организация: ${esc(org.name)}`, btn("Добавить префикс", "pfx-new", { cls: "primary", icon: I.plus() }))} +
${tab(0, "Все", pl.total)}${vl.items.map((v) => tab(v.id, v.name, v.prefixes_count)).join("")}
+${truncated}
${orgSwitcher()}${searchBox(S.q || "", "Поиск: префикс, описание", 280)}${filterBtn("status", "Статус", statusItems, S.status ?? "")}${filterBtn("family", "Семейство", famItems, S.family ?? "")}
${bulkBar(bulkMenu("bulk-status", "Статус", Object.entries(PREFIX_STATUS).map(([value, [, label]]) => ({ label, value }))))}
${selAllCell()}ПрефиксОписаниеVRFСтатусИспользованиеАдресов
-${rows || '
Префиксов нет
'}
Показано ${visible.length} из ${all.length} ${plural(all.length, "префикса", "префиксов", "префиксов")}
`); +${rows || '
Префиксов нет
'}
Показано ${visible.length} из ${pl.total} ${plural(pl.total, "префикса", "префиксов", "префиксов")}
`); }; 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 } }), // нужен окну «Редактировать префикс» ]);