Backend (FastAPI, SQLAlchemy 2, Alembic, PostgreSQL 16): - организации, VRF, префиксы (дерево, использование, автоназначение), адреса, операторы связи, устройства и типы устройств; JWT, роли admin/viewer; - VRF принадлежит организации (составной FK), смена VRF у префикса переносит поддерево, имя VRF уникально в организации; - журнал аудита: поиск и фильтры, ротация (срок/количество), очистка по паролю с блокировкой, IP клиента и метаданные запроса (X-Forwarded-For только от TRUSTED_PROXIES). UI (web/, без сборки): экраны и диалоги по макетам «IPAM Manager», кликабельные строки реестров, локальные шрифты IBM Plex, собственные выпадающие списки. Окружение: docker-compose (postgres + app), миграции Alembic 0001-0004, scripts/gen_env.py, scripts/seed_demo.py, 11 автотестов (pytest). Документация: README.md и docs/changes/001-005 (планы и итоги). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
8.2 KiB
Журнал аудита: IP-адрес актора и метаданные запроса (изменение 004)
Context
В журнале видно, кто и что сделал (actor, message), но не откуда. Нужно фиксировать IP-адрес, с которого пользователь (в т.ч. admin)
выполнил изменение, и сопутствующие метаданные, и показывать их в UI. Сейчас audit() (app/services.py) не знает о запросе:
вызывается из ~30 мест, IP нигде не сохраняется.
Что выяснено (read-only проверка):
- Приложение публикуется через Docker (
0.0.0.0:8088 → 172.x:8000). Запрос с LAN-адреса машины приходит с реальным источником (192.168.5.9), с удалённых хостов — тоже (DNAT сохраняет источник); запрос через127.0.0.1приходит как адрес шлюза docker-сети (172.29.0.1) — это особенностьdocker-proxyдля loopback, не ошибка приложения. Отдельная прокси-настройка для получения реального IP не нужна. - Если позже перед приложением появится reverse-proxy с TLS (как предлагалось), реальный IP будет в
X-Forwarded-For, но доверять заголовку можно только от известных прокси — иначе IP подделывается. Поэтому закладываем настройку доверенных прокси (по умолчанию пусто).
Заодно (жалоба «не вижу обновлений»): сервер отдаёт актуальные app.js/styles.css (проверено по LAN-адресу), но без Cache-Control —
браузер может держать старую версию по эвристике. Включаем ревалидацию (Cache-Control: no-cache + уже есть ETag).
Решение
Каждая запись журнала, созданная в рамках HTTP-запроса, получает client_ip и meta; записи, созданные системой (ротация, миграции) — без IP.
Это покрывает и действия admin, и неудачные входы (anonymous, самый ценный случай для расследования), и любые будущие роли.
Модель данных (миграция 0004)
audit_log:+client_ip INET NULL(индекс),+meta JSONB NULL— расширяемые метаданные запроса:{"user_agent": "…", "method": "POST", "path": "/api/v1/prefixes", "request_id": "…"}(User-Agent ≤ 255 символов; только путь, без query-строки).- Существующие записи остаются с
NULL(в UI «—»). Даунгрейд удаляет колонки.
Backend
app/request_context.py(новый):ContextVarс метаданными текущего запроса и ASGI-middlewareRequestContextMiddleware: определяет IP клиента, User-Agent, метод, путь, генерируетrequest_id(возвращается в заголовкеX-Request-ID).resolve_client_ip(peer, x_forwarded_for, trusted)— чистая функция:X-Forwarded-Forучитывается только если сокет-пир входит вTRUSTED_PROXIES(берётся первый недоверенный адрес справа); иначе — адрес пира. IPv4-mapped IPv6 (::ffff:a.b.c.d) нормализуется в IPv4.app/config.py:trusted_proxies: str = ""(CIDR через запятую);.env.example,docker-compose.yml(проброситьTRUSTED_PROXIES).app/services.py::audit(): читает контекст запроса и заполняетclient_ip/meta; дляuser=SYSTEM(ротация, в т.ч. запущенная из запроса на сохранение настроек) IP не пишется. Сигнатураaudit()не меняется — 30 мест вызова не трогаем.app/main.py: подключить middleware; для статики —Cache-Control: no-cache(подклассStaticFiles).GET /audit: новый фильтрclient_ip(точный IP или подсеть CIDR —inet <<=); текстовый поискqдополнительно ищет по началу IP.AuditOut(+from_row): поляclient_ip: str | None,meta: dict | None. Остальные поля/эндпоинты без изменений (Обзор не затронут).
UI (web/app.js, web/styles.css)
- Таблица журнала: новая колонка «IP-адрес» (моно 13 px, «—» если нет) — сетка
150px 168px 150px 1fr 110px 130px 96px; поиск: «Сообщение, ID или IP». - Окно записи: строки «IP-адрес» (с кнопкой «Копировать») , «User-Agent», «Запрос» (
POST /api/v1/prefixes); блок «Данные» остаётся дляdiff. - Отступление от макета Journal (он без IP) — сознательное, по запросу пользователя; остальная вёрстка не меняется.
Порядок работ
- Скопировать план в
docs/changes/004-audit-client-ip/PLAN.md. - Миграция 0004 + модель (проверка upgrade → check → downgrade → upgrade).
request_context.py, конфиг, middleware,audit()./audit: поле, фильтр, поиск;Cache-Controlдля статики.- UI: колонка и строки окна записи.
- Тесты, проверка через LAN-адрес и в браузере, README, SUMMARY.
Тесты (+2, всего 11; против контейнеров)
- Запись IP: действие через API с заголовком
User-Agent: ipam-tests/1.0→ в/auditу записи есть валидныйclient_ipиmeta.user_agent/method/path; фильтрclient_ipнаходит запись, чужой IP/подсеть — нет; поддельныйX-Forwarded-For: 203.0.113.9от недоверенного пира не попадает в журнал; у записи ротации (system)client_ip = null. - Юнит-тест
resolve_client_ip(без контейнеров): недоверенный пир игнорирует XFF; доверенный — берёт первый недоверенный справа; IPv4-mapped нормализуется.
Проверка end-to-end
docker compose up -d --build → pytest → запрос к API через http://192.168.5.9:8088 (реальный источник) и через 127.0.0.1 (адрес шлюза docker) →
в UI «Журнал» видна колонка IP, окно записи показывает IP/User-Agent/запрос, поиск по IP находит запись → curl -I статики показывает Cache-Control: no-cache.
Допущения (скажите, если не так)
- IP пишется для всех действий из HTTP-запросов (не только admin) — иначе журнал неоднородный; для system — пусто.
- Запросы с самой машины через
127.0.0.1показывают адрес шлюза docker-сети; это поведение Docker и документируется в README. - IP — персональные данные: срок хранения регулируется существующей ротацией журнала; отдельная обезличка не делается.
- За NAT/прокси видна адресация последнего доверенного сегмента, а не «настоящего» узла клиента.