# Журнал аудита: 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-middleware `RequestContextMiddleware`: определяет 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) — сознательное, по запросу пользователя; остальная вёрстка не меняется. ## Порядок работ 0. Скопировать план в `docs/changes/004-audit-client-ip/PLAN.md`. 1. Миграция 0004 + модель (проверка upgrade → check → downgrade → upgrade). 2. `request_context.py`, конфиг, middleware, `audit()`. 3. `/audit`: поле, фильтр, поиск; `Cache-Control` для статики. 4. UI: колонка и строки окна записи. 5. Тесты, проверка через LAN-адрес и в браузере, README, SUMMARY. ## Тесты (+2, всего 11; против контейнеров) 1. Запись 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`. 2. Юнит-тест `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/прокси видна адресация последнего доверенного сегмента, а не «настоящего» узла клиента.