Все цвета — CSS-переменные; тёмная палитра по prefers-color-scheme (режим «авто») и принудительно через data-theme; светлая тема не изменилась. Переключатель — пиктограмма в шапке (авто / светлая / тёмная), выбор в localStorage; theme.js ставит тему до отрисовки CSS (без вспышки, без ослабления CSP). QR 2FA на белой подложке. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
218 lines
25 KiB
Markdown
218 lines
25 KiB
Markdown
# IPAM Manager
|
||
|
||
Реестр IP-адресов и адресных префиксов в разрезе организаций. Проект построен по принципу API-first: backend на FastAPI и PostgreSQL,
|
||
UI-админка — лёгкий SPA без сборки (`web/`), который только отображает ответы API. Макеты — страница «IPAM Manager» дизайн-канваса ros_control.
|
||
|
||
## Быстрый старт
|
||
```bash
|
||
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://<хост>:8088/`, Swagger: `http://<хост>:8088/docs`.
|
||
- Вход: `ADMIN_USERNAME` / `ADMIN_PASSWORD` из `.env`. Суперадминистратор создаётся при первом старте на пустой БД.
|
||
|
||
## Конфигурация (`.env`)
|
||
| Переменная | Назначение |
|
||
|---|---|
|
||
| `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | База данных |
|
||
| `JWT_SECRET` | Ключ подписи токенов. Обязателен, не короче 32 символов, заглушки отклоняются: без корректного значения приложение не стартует |
|
||
| `ADMIN_USERNAME`, `ADMIN_PASSWORD` | Первый суперадминистратор; пароль не короче 8 символов |
|
||
| `APP_PORT` | Порт UI и API на хосте (8088) |
|
||
| `APP_BIND` | Адрес публикации порта: по умолчанию `0.0.0.0`; `127.0.0.1` — только за reverse-proxy |
|
||
| `DB_HOST_PORT` | Порт PostgreSQL на хосте, публикуется только на `127.0.0.1` |
|
||
| `TRUSTED_PROXIES` | CIDR доверенных прокси через запятую; только от них принимается `X-Forwarded-For` |
|
||
| `TOTP_ENC_KEY` | Ключ Fernet для шифрования секретов 2FA (изменение 037). Не задан — приложение стартует, но 2FA недоступна (503); некорректный ключ — отказ старта; потеря ключа означает сброс 2FA всем пользователям |
|
||
|
||
## Архитектура
|
||
| Слой | Технологии |
|
||
|---|---|
|
||
| API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `superadmin`, `admin`, `viewer` с привязкой к организации |
|
||
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0015`), типы `CIDR`/`INET` |
|
||
| UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет |
|
||
|
||
```
|
||
app/ main.py config.py db.py security.py models.py schemas.py services.py request_context.py rotation.py
|
||
app/api/v1/ auth.py refs.py prefixes.py overview.py journal.py users.py
|
||
web/ index.html styles.css app.js fonts/
|
||
alembic/ миграции схемы
|
||
scripts/ gen_env.py seed_demo.py find_duplicate_addresses.py find_unusable_addresses.py
|
||
tests/ автотесты (pytest)
|
||
docs/changes/ планы и итоги доработок docs/reviews/ отчёты ревью
|
||
```
|
||
|
||
## Модель данных и правила
|
||
`organizations` → `vrfs` → `prefixes` (дерево) → `addresses`; `devices` + `device_types`; `isps` + `isp_networks`; `users`; `audit_log`.
|
||
|
||
**Пользователи и организации**
|
||
- `admin`/`viewer` привязаны к одной организации (`organization_id`), `superadmin` — ни к одной. БД допускает `admin`/`viewer` без организации только у отключённой записи (состояние после миграции 0011).
|
||
- Смена роли на `superadmin` снимает организацию автоматически; понижение до `admin`/`viewer` требует указать `organization_id`.
|
||
- Изоляция: `admin`/`viewer` видят и меняют только данные своей организации, включая «Обзор», журнал и счётчики типов устройств. Чужой объект неотличим от несуществующего — 404.
|
||
|
||
**VRF и префиксы**
|
||
- У каждой организации автоматически создаётся VRF `default`. Имя VRF уникально в пределах организации без учёта регистра.
|
||
- Префикс принадлежит VRF своей организации; это гарантирует составной FK в БД.
|
||
- Вложенность префиксов (`parent_id`) определяется автоматически по CIDR. Смена VRF переносит префикс вместе с вложенными, только в пределах организации.
|
||
- Автовыделение вложенного префикса: система выбирает первый свободный выровненный блок заданного размера.
|
||
- Изменения дерева одного VRF выполняются по одному (advisory-lock); разные VRF друг друга не блокируют.
|
||
|
||
**Адреса**
|
||
- IP уникален в пределах VRF и хранится в самом узком содержащем его префиксе.
|
||
- Адрес сети и broadcast (IPv4, префикс ≤ /30) назначить нельзя.
|
||
- Свободные адреса не хранятся, а вычисляются. В общем списке они показываются только для подсетей до /20.
|
||
|
||
**Ёмкость и «Обзор»**
|
||
- Ёмкость префикса — размер его подсети (для IPv4 без адреса сети и broadcast). Занятость считается по всему поддереву.
|
||
- «Обзор» учитывает только IPv4: ёмкость — сумма корневых активных префиксов, назначенные адреса — адреса внутри них.
|
||
|
||
**Удаление**
|
||
- Объекты с зависимыми данными не удаляются (409). Отказ фиксируется в журнале с перечнем мешающих объектов.
|
||
- Организация не удаляется, если у неё есть привязанные пользователи.
|
||
|
||
**Журнал аудита**
|
||
- Событие привязано к организации своей сущности (`organization_id`); `admin`/`viewer` видят только события своей организации, включая отклонённые удаления.
|
||
- Системные события (пользователи, вход, настройки и очистка журнала, типы устройств) не привязаны к организации и видны только `superadmin`.
|
||
- События организации показывают IP и User-Agent исполнителя, в том числе суперадминистратора.
|
||
- После удаления организации её события сохраняются с `organization_id = NULL`.
|
||
|
||
## API (`/api/v1`)
|
||
| Область | Эндпоинты |
|
||
|---|---|
|
||
| Вход | `POST /auth/login`, `POST /auth/login/2fa` (изменение 037), `GET /auth/me` |
|
||
| Справочники | `/organizations`, `/vrfs`, `/isps`, `/device-types`, `/devices` |
|
||
| Префиксы | `/prefixes`, `GET\|POST /prefixes/{id}/subnets/next` (предпросмотр и автовыделение вложенного) |
|
||
| Адреса | `GET\|POST /prefixes/{id}/addresses`, `POST /prefixes/{id}/addresses/next` (автоназначение из пула), `PATCH\|DELETE /addresses/{id}` |
|
||
| Пользователи | `/users`, `POST /users/me/password`, `POST /users/me/2fa/setup\|enable\|disable`, `POST /users/{id}/2fa/reset` (изменение 037) |
|
||
| Журнал | `GET /audit`, `/audit/summary`, `/audit/facets`, `/audit/{uid}`, `GET\|PUT /journal/settings`, `POST /journal/clear` |
|
||
| Сводка | `GET /overview` |
|
||
|
||
**Соглашения**
|
||
- Ошибки возвращаются в формате `{code, message, fields}`; для лимитов добавляются `retry_after_seconds` и `attempts_left`.
|
||
- Пагинация: `limit` ≥ 1, `offset` от 0 до 10 000 000.
|
||
- `null` в текстовом поле `PATCH` очищает его.
|
||
- Каждое изменение данных пишется в журнал аудита.
|
||
|
||
## Безопасность
|
||
**Роли и учётные записи**
|
||
| Роль | Права |
|
||
|---|---|
|
||
| `superadmin` | Все организации; пользователи, создание и удаление организаций, типы устройств, настройки и очистка журнала |
|
||
| `admin` | Запись в своей организации: VRF, префиксы, адреса, устройства, операторы, карточка организации |
|
||
| `viewer` | Чтение своей организации. Роль по умолчанию |
|
||
|
||
- Логин уникален без учёта регистра.
|
||
- Свою учётную запись нельзя понизить, отключить или удалить; последний активный `superadmin` защищён от понижения, отключения и удаления.
|
||
|
||
**Пароли и токены**
|
||
- Смена пароля отзывает ранее выданные токены. Свой пароль меняется только с подтверждением текущего.
|
||
|
||
**Вход**
|
||
- Лимит неудачных попыток за 10 минут:
|
||
- 5 — на пару логин + IP;
|
||
- 20 — на IP;
|
||
- 50 — на логин со всех IP, кроме тех, с которых пользователь успешно входил за 30 дней.
|
||
- Сверх лимита — 429 с `Retry-After`.
|
||
- Двухфакторная аутентификация TOTP — по желанию пользователя (изменение 037), не обязательна. Включается в меню логина
|
||
в шапке: пароль → QR-код (Google Authenticator, Aegis, 1Password, Bitwarden и подобные) → код подтверждения →
|
||
10 одноразовых кодов восстановления (показываются один раз). При включённой 2FA вход идёт в два шага:
|
||
`POST /auth/login` возвращает `mfa_token` вместо токена доступа, `POST /auth/login/2fa` принимает код из приложения
|
||
или код восстановления. Неверный код учитывается в тех же лимитах перебора, что и пароль. Секрет хранится в БД
|
||
зашифрованным ключом `TOTP_ENC_KEY`; коды восстановления — хэшем sha256. Суперадминистратор может сбросить 2FA
|
||
другому пользователю (`POST /users/{id}/2fa/reset`), не отключая свою.
|
||
|
||
**Журнал**
|
||
- Хранит IP клиента и метаданные запроса.
|
||
- Ротация по сроку (90 дней) и по количеству записей (100 000); значения настраиваются.
|
||
- Очистка журнала требует пароль.
|
||
|
||
**Ответы**
|
||
- Заголовки CSP, `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`.
|
||
|
||
## Публикация и эксплуатация
|
||
- Контейнер приложения работает от непривилегированного пользователя, у него есть healthcheck (`/healthz`) и перезапуск `unless-stopped`.
|
||
- Миграции выполняются при старте под advisory-lock. Зависимости зафиксированы в `requirements.lock`
|
||
(обновление: `venv/bin/pip-compile --strip-extras -o requirements.lock requirements.txt`).
|
||
- Приложение отдаёт HTTP, TLS обеспечивает reverse-proxy. Стенд опубликован как `https://rxipam.rxmsk.ru` через общий Caddy хоста
|
||
(`/opt/lvraid/apps/caddy/Caddyfile`, блок `rxipam.rxmsk.ru`):
|
||
- Caddy проксирует на опубликованный порт приложения (`172.19.0.1:8088` — шлюз его Docker-сети). Сети не объединяются:
|
||
в сети Caddy уже есть сервисы с именами `app` и `db`.
|
||
- Из интернета доступны UI и `/api/v1`. `/docs`, `/redoc`, `/openapi.json` — только из частных сетей (RFC 1918/4193, loopback), иначе 404.
|
||
- `TRUSTED_PROXIES=172.16.0.0/12` в `.env`: Caddy приходит в приложение с адреса шлюза Docker-моста, реальный IP клиента берётся
|
||
из `X-Forwarded-For`. Без этого все интернет-клиенты делят один IP — и лимит входа, и журнал.
|
||
- LAN-клиенты по публичному имени идут через hairpin NAT шлюза и видны как `192.168.5.253`. Чтобы видеть их реальные адреса,
|
||
нужен split DNS: `rxipam.rxmsk.ru` → `192.168.5.9` внутри LAN.
|
||
- Переход на ролевую модель (миграция 0011): прежние `admin` становятся `superadmin`, прежние `viewer` отключаются до назначения организации суперадминистратором.
|
||
- Перед обновлением рабочей БД проверьте данные скриптами только для чтения: `scripts/find_duplicate_addresses.py` и `scripts/find_unusable_addresses.py`.
|
||
- Имя compose-проекта задаётся флагом `-p`. Текущий стенд поднят как `ipam_control_006` (`docker compose -p ipam_control_006 …`); без флага команды работают с проектом `ipam_control`.
|
||
|
||
## Интерфейс
|
||
- Экраны: «Обзор», «Префиксы» (дерево по VRF), «Адреса» подсети, «Организации», «Операторы», «Устройства», «Журнал», «Пользователи» (только `superadmin`).
|
||
- В «Пользователях» видна дата и IP последнего входа (изменение 035); «—», если пользователь ещё не входил.
|
||
- Логин в шапке раскрывает меню «Сменить пароль» / «Двухфакторная аутентификация» / «Выйти» (изменения 036, 037).
|
||
- В «Пользователях» у записей с включённой 2FA — бейдж «2FA»; в меню строки суперадминистратора для чужих записей — «Сбросить 2FA» (изменение 037).
|
||
- Тёмная тема (изменение 038): по умолчанию следует настройке ОС/браузера (`prefers-color-scheme`); переключатель-пиктограмма в шапке (солнце/луна/монитор), выбор хранится в `localStorage` браузера.
|
||
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации.
|
||
- Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯».
|
||
- Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей.
|
||
- Экран «Префиксы» загружает до 20 000 префиксов организации и сообщает, если загружены не все.
|
||
|
||
## Тесты
|
||
Тесты работают с приложением и БД, заданными в `.env`, то есть с запущенным стендом: они создают и удаляют временные данные.
|
||
```bash
|
||
docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q
|
||
```
|
||
|
||
## История изменений
|
||
Каждая доработка описана в `docs/changes/<номер>/`: `PLAN.md` — план, `SUMMARY.md` — итог.
|
||
|
||
| № | Изменение | Документы |
|
||
|---|---|---|
|
||
| 001 | Backend и UI-админка | [план](docs/changes/001-ipam-backend/PLAN.md) · [итог](docs/changes/001-ipam-backend/SUMMARY.md) |
|
||
| 002 | Смена VRF у префикса, целостность «VRF ⊂ организация» | [план](docs/changes/002-prefix-vrf-change/PLAN.md) · [итог](docs/changes/002-prefix-vrf-change/SUMMARY.md) |
|
||
| 003 | Журнал: поиск, ротация, очистка | [план](docs/changes/003-journal-search-rotation/PLAN.md) · [итог](docs/changes/003-journal-search-rotation/SUMMARY.md) |
|
||
| 004 | Журнал: IP клиента и метаданные запроса | [план](docs/changes/004-audit-client-ip/PLAN.md) · [итог](docs/changes/004-audit-client-ip/SUMMARY.md) |
|
||
| 005 | Кликабельные строки реестров | [план](docs/changes/005-clickable-rows/PLAN.md) · [итог](docs/changes/005-clickable-rows/SUMMARY.md) |
|
||
| 006 | Раздел «Пользователи» | [план](docs/changes/006-users-management/PLAN.md) · [итог](docs/changes/006-users-management/SUMMARY.md) |
|
||
| 007 | Исправление удаления организации | [план](docs/changes/007-org-delete-fix/PLAN.md) · [итог](docs/changes/007-org-delete-fix/SUMMARY.md) |
|
||
| 008 | Журнал отклонённых удалений | [план](docs/changes/008-blocked-delete-audit/PLAN.md) · [итог](docs/changes/008-blocked-delete-audit/SUMMARY.md) |
|
||
| 009 | Групповые операции в UI | [план](docs/changes/009-bulk-actions/PLAN.md) · [итог](docs/changes/009-bulk-actions/SUMMARY.md) |
|
||
| 010 | Автовыделение вложенного префикса | [план](docs/changes/010-next-free-prefix/PLAN.md) · [итог](docs/changes/010-next-free-prefix/SUMMARY.md) |
|
||
| 011 | Уникальность IP в VRF | [план](docs/changes/011-address-unique-in-vrf/PLAN.md) · [итог](docs/changes/011-address-unique-in-vrf/SUMMARY.md) |
|
||
| 012 | Ограничение попыток входа | [план](docs/changes/012-login-rate-limit/PLAN.md) · [итог](docs/changes/012-login-rate-limit/SUMMARY.md) |
|
||
| 013 | Границы пагинации | [план](docs/changes/013-pagination-bounds/PLAN.md) · [итог](docs/changes/013-pagination-bounds/SUMMARY.md) |
|
||
| 014 | Производительность экрана адресов | [план](docs/changes/014-addresses-listing-performance/PLAN.md) · [итог](docs/changes/014-addresses-listing-performance/SUMMARY.md) |
|
||
| 015 | Запрет адреса сети и broadcast | [план](docs/changes/015-network-broadcast-addresses/PLAN.md) · [итог](docs/changes/015-network-broadcast-addresses/SUMMARY.md) |
|
||
| 016 | Роль по умолчанию — «Просмотр» | [план](docs/changes/016-default-role-viewer/PLAN.md) · [итог](docs/changes/016-default-role-viewer/SUMMARY.md) |
|
||
| 017 | Проверка секретов при старте | [план](docs/changes/017-jwt-secret-validation/PLAN.md) · [итог](docs/changes/017-jwt-secret-validation/SUMMARY.md) |
|
||
| 018 | `null` в PATCH | [план](docs/changes/018-patch-null-handling/PLAN.md) · [итог](docs/changes/018-patch-null-handling/SUMMARY.md) |
|
||
| 019 | Устранение N+1 запросов | [план](docs/changes/019-n-plus-one-queries/PLAN.md) · [итог](docs/changes/019-n-plus-one-queries/SUMMARY.md) |
|
||
| 020 | Автоназначение адреса с учётом вложенных префиксов | [план](docs/changes/020-next-free-address/PLAN.md) · [итог](docs/changes/020-next-free-address/SUMMARY.md) |
|
||
| 021 | Блокировки: администраторы, регистр логина | [план](docs/changes/021-concurrency-locks/PLAN.md) · [итог](docs/changes/021-concurrency-locks/SUMMARY.md) |
|
||
| 022 | Эксплуатация: контейнер, миграции, TLS, зависимости | [план](docs/changes/022-ops-hardening/PLAN.md) · [итог](docs/changes/022-ops-hardening/SUMMARY.md) |
|
||
| 023 | Безопасность и журнал: мелкие улучшения | [план](docs/changes/023-minor-hardening/PLAN.md) · [итог](docs/changes/023-minor-hardening/SUMMARY.md) |
|
||
| 024 | Исправление находок ревью 011–023 | [план](docs/changes/024-review-fixes-011-023/PLAN.md) · [итог](docs/changes/024-review-fixes-011-023/SUMMARY.md) |
|
||
| 025 | Ёмкость частично разбитого префикса | [план](docs/changes/025-prefix-capacity/PLAN.md) · [итог](docs/changes/025-prefix-capacity/SUMMARY.md) |
|
||
| 026 | Политика блокировки входа | [план](docs/changes/026-login-lockout-policy/PLAN.md) · [итог](docs/changes/026-login-lockout-policy/SUMMARY.md) |
|
||
| 027 | Сериализация попыток входа по IP | [план](docs/changes/027-login-ip-serialization/PLAN.md) · [итог](docs/changes/027-login-ip-serialization/SUMMARY.md) |
|
||
| 028 | Экран «Префиксы» без усечения | [план](docs/changes/028-prefixes-ui-pagination/PLAN.md) · [итог](docs/changes/028-prefixes-ui-pagination/SUMMARY.md) |
|
||
| 029 | Целостность дерева префиксов | [план](docs/changes/029-prefix-tree-lock/PLAN.md) · [итог](docs/changes/029-prefix-tree-lock/SUMMARY.md) |
|
||
| 030 | Исправление замечаний ревью 025–029 | [план](docs/changes/030-review-fixes-025-029/PLAN.md) · [итог](docs/changes/030-review-fixes-025-029/SUMMARY.md) |
|
||
| 032 | Ролевая модель с привязкой к организации и суперадминистратором | [план](docs/changes/032-role-model-org-scope/PLAN.md) · [итог](docs/changes/032-role-model-org-scope/SUMMARY.md) |
|
||
| 033 | Исправление находок ревью 032 | [план](docs/changes/033-review-fixes-032/PLAN.md) · [итог](docs/changes/033-review-fixes-032/SUMMARY.md) |
|
||
| 034 | Публикация через Caddy: rxipam.rxmsk.ru | [план](docs/changes/034-caddy-publication/PLAN.md) · [итог](docs/changes/034-caddy-publication/SUMMARY.md) |
|
||
| 035 | Последний вход пользователя: дата и IP | [план](docs/changes/035-user-last-login/PLAN.md) · [итог](docs/changes/035-user-last-login/SUMMARY.md) |
|
||
| 036 | Меню пользователя в шапке: логин с выпадающим списком | [план](docs/changes/036-user-menu/PLAN.md) · [итог](docs/changes/036-user-menu/SUMMARY.md) |
|
||
| 037 | Двухфакторная аутентификация TOTP, по выбору пользователя | [план](docs/changes/037-totp-2fa/PLAN.md) · [итог](docs/changes/037-totp-2fa/SUMMARY.md) |
|
||
| 038 | Тёмная тема UI | [план](docs/changes/038-dark-theme/PLAN.md) · [итог](docs/changes/038-dark-theme/SUMMARY.md) |
|
||
|
||
## Отчёты ревью
|
||
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
|
||
- [Ревью изменений 011–023](docs/reviews/2026-09-26-changes-011-023-review.md) (→ 024)
|
||
- [Повторный анализ кодовой базы](docs/reviews/2026-09-26-codebase-review-2.md) (→ 025–029)
|
||
- [Ревью и тестирование изменений 025–029](docs/reviews/2026-09-26-changes-025-029-review.md) (→ 030)
|
||
- [Ревью и тестирование изменения 030](docs/reviews/2026-09-27-changes-030-review.md)
|
||
- [Пентест (чёрный ящик)](docs/reviews/2026-09-27-pentest.md) (→ план 031, не реализован)
|
||
- [Ревью и тестирование изменения 032](docs/reviews/2026-09-27-changes-032-review.md) (→ 033)
|
||
- [Ревью кодовой базы после 032](docs/reviews/2026-09-27-codebase-review.md) (→ 033)
|