Повторный анализ кодовой базы (docs/reviews/2026-09-26-codebase-review-2.md) и доработки:
025 Ёмкость префикса — размер его подсети (а не сумма листьев); «Обзор» считает ёмкость
по корневым активным IPv4-префиксам и адреса внутри них.
026 Политика блокировки входа: 5 неудач на логин+IP, 20 на IP, 50 на логин со всех IP,
кроме известных IP (known_logins, миграция 0009) — владельца нельзя заблокировать анонимно.
027 Сериализация попыток входа по IP (advisory-lock после блокировки логина).
028 UI «Префиксы»: загрузка всех страниц (до 20 000), счётчики по total, предупреждение об усечении.
029 Advisory-lock по VRF для операций, меняющих дерево префиксов и раскладку адресов.
030 Исправление замечаний ревью 025-029: _lock_prefix (VRF блокируется до чтения префикса,
409 при одновременном переносе), константы политики входа перенесены в app/services.py.
Тесты: 14 passed (проверка ёмкости родителя приведена к семантике 025); сквозные сценарии
и гонки — docs/reviews/2026-09-26-changes-025-029-review.md, 2026-09-27-changes-030-review.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
IPAM Manager
Реестр IP-адресов и адресных префиксов в разрезе организаций. API-first: backend на Python (FastAPI) с PostgreSQL,
UI-админка — отдельный лёгкий SPA (web/, без сборки), который только визуализирует ответы API.
Макеты: страница «IPAM Manager» дизайн-канваса ros_control.
Быстрый старт
python3 scripts/gen_env.py # .env со случайными паролями/секретами (в .gitignore)
docker compose up -d --build # postgres + app; миграции применяются автоматически
python3 -m venv venv && venv/bin/pip install -r requirements-dev.txt
venv/bin/python scripts/seed_demo.py # (по желанию) демо-данные из макетов
- UI: http://127.0.0.1:8088/ · Swagger: http://127.0.0.1:8088/docs (порт приложения —
APP_PORTв.env, слушает 0.0.0.0; порт БД — только 127.0.0.1) - Логин/пароль администратора —
ADMIN_USERNAME/ADMIN_PASSWORDиз.env(создаётся при первом старте; пароль не короче 8 символов). JWT_SECRETобязателен и не короче 32 символов (заглушки отклоняются): без него приложение не стартует (изменение 017).scripts/gen_env.pyгенерирует корректные значения.- Порт приложения публикуется на
${APP_BIND:-0.0.0.0}(в.envможно задатьAPP_BIND=127.0.0.1— только loopback, за reverse-proxy с TLS, см. «Публикация»).
Архитектура
| Слой | Технологии |
|---|---|
| API | FastAPI, pydantic v2, JWT (argon2), роли admin (запись) / viewer (чтение) |
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (alembic/versions), типы CIDR/INET |
| UI | статический SPA (ES-модуль, vanilla JS), раздаётся приложением; шрифты IBM Plex — локально в web/fonts/ (woff2 latin+cyrillic, лицензия OFL), внешних зависимостей нет |
app/ main.py config.py db.py security.py models.py schemas.py services.py api/v1/{auth,refs,prefixes,overview}.py
web/ index.html styles.css app.js
alembic/ scripts/{gen_env,seed_demo,find_duplicate_addresses,find_unusable_addresses}.py requirements.lock tests/ docs/changes/ docs/reviews/
Модель данных
organizations → vrfs (по организации, «default» создаётся автоматически) → prefixes (дерево через parent_id,
вложенность определяется автоматически) → addresses; devices + device_types; isps + isp_networks; users; audit_log.
- Ёмкость префикса — размер его собственной подсети (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), уникальность(vrf_id, address). Адрес из диапазона вложенного префикса в родителе назначить нельзя (422), дубль в VRF → 409; новый вложенный префикс забирает адреса родителя из своего диапазона (moved_addressesв журнале); перенос префикса в VRF с тем же адресом → 409. Миграция 0007 останавливается при дублях — найти:scripts/find_duplicate_addresses.py. - Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя (422, изменение 015); уже внесённые найдёт
scripts/find_unusable_addresses.py; загрузка не превышает 100 %. Перенос адресов при создании вложенного префикса или смене VRF, из-за которого адрес стал бы сетевым/broadcast в новом месте, тоже отклоняется 422 с перечислением адресов и целевых префиксов (изменение 024): создание префикса откатывается целиком, смена VRF — без частичных изменений. - Обзор считает использование только по IPv4.
- VRF — часть адресного плана организации: имя уникально в пределах организации (без учёта регистра), в разных организациях имена могут совпадать; в одном VRF может быть много префиксов. Принадлежность VRF организации префикса гарантирует составной FK в БД.
- Смена VRF у префикса (
PATCH /prefixes/{id}сvrf_id) — только среди VRF той же организации; переносится префикс вместе с вложенными, дубль CIDR в целевом VRF → 409 (без частичных изменений), VRF другой организации → 422. - Автовыделение вложенного префикса (изменение 010):
POST /prefixes/{id}/subnets/nextсlength(например 30) создаёт дочерний префикс в первом свободном выровненном блоке родителя (учитываются вложенные префиксы и адреса родителя);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
CRUD: /organizations, /vrfs, /isps, /device-types, /devices, /prefixes, /addresses/{id}, /users
Адреса префикса: GET|POST /prefixes/{id}/addresses (status, q, limit, offset), POST …/addresses/next — автоназначение из пула
(первый свободный адрес вне вложенных префиксов, без адреса сети/broadcast; префикс блокируется на время выдачи — изменение 020).
Пагинация во всех списках: limit ≥ 1 (и не больше предела эндпоинта), offset от 0 до 10 000 000; иначе 422 (изменение 013).
PATCH: null в текстовом поле очищает его (пустая строка), null в status адреса → 422, device_id: null отвязывает устройство (изменение 018).
PATCH /devices/{id} проверяет name (hostname/FQDN) и mac (формат, нормализация к AA:BB:CC:DD:EE:FF) так же, как создание — раньше PATCH пропускал некорректные значения (изменение 024).
Поиск (q) экранирует % и _. Ответы содержат заголовки Content-Security-Policy (кроме /docs), X-Content-Type-Options, X-Frame-Options, Referrer-Policy (изменение 023).
Ошибки: {code, message, fields} (для блокировок/лимитов — дополнительные поля attempts_left, retry_after_seconds). Каждое изменение пишется в audit_log.
Пользователи и роли
GET|POST /users,PATCH|DELETE /users/{id}— управление учётными записями, толькоadmin: логин (3–100 символов, латиница, цифры,. _ -), роль, доступ и пароль (при сбросе администратором). Логин после создания не меняется — он жеsubв токене.POST /users/me/password {current_password, new_password}— смена своего пароля, доступна любой роли; неверный текущий пароль → 403. Ответ содержит новыйaccess_token: прежние токены пользователя после смены пароля (самим или администратором) недействительны (изменение 023, claimpv).PATCH /users/{id}сpasswordдля своей же учётной записи → 422: свой пароль меняется только через/users/me/password(там обязательно подтверждение текущего пароля; изменение 024).- Занятый логин (в том числе в другом регистре) → 409; служебные логины
systemиanonymous, короткий логин или пароль → 422. - Свою учётную запись нельзя понизить, отключить или удалить, как и последнего активного администратора → 409.
- Отключение действует немедленно (токен проверяется по
users.is_activeна каждом запросе). Рольviewerвидит реестр и журнал, но любые изменения получает с 403. - Роль по умолчанию при создании —
viewer(изменение 016). Логин уникален без учёта регистра и в БД (изменение 021); снятие прав администратора и удаление пользователей идут под advisory-lock. - Изменения пишутся в журнал:
user.created,user.updated,user.password_reset,user.deleted(значения паролей не сохраняются).
Журнал
- Отказ в удалении по бизнес-правилу (409: организация, VRF, тип устройства, префикс с адресами, пользователь) фиксируется событием
<сущность>.delete_blocked(изменение 008); в «Данных» записи —reasonиblocked_byсо списками мешающих объектов (totalи до 20 названий: префиксы, устройства, операторы, адреса). GET /audit— поиск и фильтры:q(сообщение, метка объекта, начало ID записи),event_type(prefix.created),entity_type,actor(ui:admin,system,anonymous),date_from/date_to(UTC),limit/offset;GET /audit/summary,/audit/facets,/audit/{uid}.GET|PUT /journal/settings— ротация:retention_days(по умолчанию 90) иmax_entries(100 000),0— без ограничения. Ротация идёт раз в час и сразу при сохранении настроек (advisory-lock защищает от параллельного запуска); каждая ротация с удалениями фиксируется записьюjournal.rotated. Запись — только admin.POST /journal/clear {password}— очистка с подтверждением пароля текущего пользователя (admin); 5 неверных попыток за 10 минут → блокировка на 10 минут (429). В журнале остаётся записьjournal.cleared.- IP и метаданные запроса: каждая запись, созданная в рамках HTTP-запроса, хранит
client_ipиmeta(user_agent,method,path,request_id; ответ содержитX-Request-ID); системные события (ротация) — без IP. ФильтрGET /audit?client_ip=принимает IP или подсеть (192.168.5.0/24), текстовый поискqищет и по началу IP. IP берётся из адреса сокета.X-Forwarded-Forучитывается только от прокси изTRUSTED_PROXIES(CIDR через запятую в.env, по умолчанию пусто) — иначе IP можно подделать. Запросы с самой машины через127.0.0.1Docker показывает адресом шлюза сети (172.x.0.1); с LAN-адреса и удалённых хостов виден реальный источник. - В журнал пишутся также входы (
session.login,session.failed— акторanonymous, только первая неудача в окне по этому логину;session.locked— блокировка,diff.distinct_logins— число разных логинов с этого IP в окне, если сработал лимит по IP) и служебные события (journal.*, акторsystem; неверный пароль очистки —journal.clear_failed/journal.clear_locked). Удаление префикса сforce=trueфиксируетaddresses_deleted. - Вход: три области лимита за 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). - Миграции при старте выполняются под advisory-lock — параллельные реплики не гоняют их одновременно. Зависимости зафиксированы в
requirements.lock(обновление:venv/bin/pip-compile --strip-extras -o requirements.lock requirements.txt). - TLS: приложение отдаёт HTTP; для эксплуатации поставьте reverse-proxy (пример для Caddy) и задайте
APP_BIND=127.0.0.1,TRUSTED_PROXIES=<адрес прокси/сеть Docker>:ipam.example.com { reverse_proxy 127.0.0.1:8088 }
Поведение таблиц UI
Администратор может выбирать строки чекбоксами (в шапке — «выбрать все») на экранах «Организации», «Операторы», «Устройства», «Префиксы» (листовые), «Адреса» (кроме «Свободен») и «Пользователи» (кроме себя);
в «Журнале» выбора нет. Панель над таблицей: «Удалить» везде, «Сменить тип» (устройства), «Статус» (префиксы, адреса), «Разрешить/Отключить доступ» (пользователи). Операции идут по одному запросу
на объект (изменение 009): итог «выполнено N из M», отказы (зависимые объекты, свой аккаунт, последний администратор) показаны списком с причиной, попадают в журнал как *.delete_blocked,
и остаются выбранными. Префиксы удаляются без force: префикс с адресами удаляется из одиночного меню строки.
Строка реестра кликабельна целиком (как в журнале): «Префиксы» — лист открывает адреса подсети, родитель сворачивает/разворачивает ветку; «Организации» — префиксы организации;
«Операторы», «Устройства» и «Адреса» — окно редактирования (свободный адрес — «Назначить адрес» с этим IP). Ссылки, шеврон, меню «⋯» работают как раньше и не запускают действие строки;
Ctrl/Shift+клик и выделение текста тоже игнорируются.
Экран «Пользователи» доступен только администратору: создание, редактирование роли и доступа, удаление на месте, а свой пароль меняется кнопкой «Сменить пароль» в шапке.
Экраны «Префиксы» и «Адреса» догружают все страницы /prefixes организации (не только первые 1000), но не больше PREFIX_UI_CAP (20 000) префиксов; заголовок,
вкладка «Все» и подвал показывают total, а при срабатывании предела над таблицей появляется строка «Загружено X из N…» (изменение 028, находка №3).
Тесты
Идут против приложения в контейнерах, учётные данные берутся из .env:
docker compose up -d --build && venv/bin/python -m pytest -q