Задачи 034-035: публикация через Caddy, последний вход пользователя
034 Публикация стенда как https://rxipam.rxmsk.ru через общий Caddy хоста (Caddyfile вне репозитория): UI и /api/v1 доступны из интернета, /docs, /redoc, /openapi.json — только из частных сетей (RFC 1918/4193, loopback). Caddy проксирует на опубликованный порт приложения — общая Docker-сеть отклонена из-за коллизии имён app/db с Nextcloud. TRUSTED_PROXIES=172.16.0.0/12: реальный IP клиента из X-Forwarded-For. Интеграционный тест журнала больше не проверяет подделку XFF с хоста (хост за Caddy доверенный), логику покрывает unit-тест. 035 Дата и IP последнего входа в разделе «Пользователи»: users.last_login_at / last_login_ip (миграция 0014 с заполнением из журнала), запись при успешном входе, UserOut, колонка в UI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
744a025960
commit
8384c2c311
13 files changed
+330
-11
No files matched your search
@@ -11,3 +11,6 @@ DB_HOST_PORT=55432
|
||||
# APP_BIND=127.0.0.1 — публиковать порт приложения только на loopback (за reverse-proxy с TLS); по умолчанию 0.0.0.0
|
||||
# CIDR доверенных reverse-proxy через запятую (у них берётся X-Forwarded-For); по умолчанию пусто — IP клиента = адрес сокета
|
||||
TRUSTED_PROXIES=
|
||||
# Публикация через общий Caddy на этом же хосте (изменение 034): Caddy ходит на опубликованный порт и приходит
|
||||
# с адреса шлюза Docker-моста — доверять диапазону мостов
|
||||
# TRUSTED_PROXIES=172.16.0.0/12
|
||||
@@ -28,7 +28,7 @@ venv/bin/python scripts/seed_demo.py # по желанию: демо-да
|
||||
| Слой | Технологии |
|
||||
|---|---|
|
||||
| API | FastAPI, pydantic v2, JWT (срок 8 ч), пароли в argon2, роли `superadmin`, `admin`, `viewer` с привязкой к организации |
|
||||
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0013`), типы `CIDR`/`INET` |
|
||||
| БД | PostgreSQL 16, SQLAlchemy 2, Alembic (миграции `0001`–`0014`), типы `CIDR`/`INET` |
|
||||
| UI | Статический SPA (vanilla JS, ES-модуль) раздаётся приложением; шрифты IBM Plex хранятся локально, внешних зависимостей нет |
|
||||
|
||||
```
|
||||
@@ -125,18 +125,22 @@ docs/changes/ планы и итоги доработок docs/reviews/
|
||||
- Контейнер приложения работает от непривилегированного пользователя, у него есть healthcheck (`/healthz`) и перезапуск `unless-stopped`.
|
||||
- Миграции выполняются при старте под advisory-lock. Зависимости зафиксированы в `requirements.lock`
|
||||
(обновление: `venv/bin/pip-compile --strip-extras -o requirements.lock requirements.txt`).
|
||||
- Приложение отдаёт HTTP. Для TLS поставьте reverse-proxy и задайте `APP_BIND=127.0.0.1` и `TRUSTED_PROXIES`. Пример для Caddy:
|
||||
```
|
||||
ipam.example.com {
|
||||
reverse_proxy 127.0.0.1:8088
|
||||
}
|
||||
```
|
||||
- Приложение отдаёт 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); «—», если пользователь ещё не входил.
|
||||
- Переключатель организации — только у `superadmin`; `admin`/`viewer` работают в своей организации.
|
||||
- Строка реестра кликабельна целиком. Действия над строкой — в меню «⋯».
|
||||
- Групповые операции через чекбоксы (кроме «Журнала»): удаление, смена типа устройств, статус префиксов и адресов, доступ пользователей.
|
||||
@@ -185,6 +189,8 @@ docker compose -p ipam_control_006 up -d --build && venv/bin/python -m pytest -q
|
||||
| 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) |
|
||||
|
||||
## Отчёты ревью
|
||||
- [Ревью кодовой базы](docs/reviews/2026-09-26-codebase-review.md) (находки → изменения 011–023)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Последний вход пользователя: дата и IP (изменение 035)
|
||||
|
||||
Revision ID: 0014
|
||||
Revises: 0013
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import INET
|
||||
|
||||
|
||||
revision = "0014"
|
||||
down_revision = "0013"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("users", sa.Column("last_login_ip", INET(), nullable=True))
|
||||
|
||||
# Backfill из журнала: последнее событие session.login на username (если журнал ротирован/очищен — останется NULL)
|
||||
op.execute(sa.text(
|
||||
"UPDATE users SET last_login_at = a.ts, last_login_ip = a.client_ip "
|
||||
"FROM (SELECT DISTINCT ON (username) username, ts, client_ip FROM audit_log "
|
||||
"WHERE entity_type = 'session' AND action = 'login' ORDER BY username, ts DESC) a "
|
||||
"WHERE users.username = a.username"
|
||||
))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("users", "last_login_ip")
|
||||
op.drop_column("users", "last_login_at")
|
||||
@@ -121,6 +121,8 @@ def login(body: LoginIn, db: Session = Depends(get_db)):
|
||||
raise HTTPException(401, "Неверный логин или пароль")
|
||||
db.execute(delete(LoginAttempt).where(LoginAttempt.username == name))
|
||||
_remember_login(db, name, ip)
|
||||
user.last_login_at = func.now() # изменение 035
|
||||
user.last_login_ip = ip # изменение 035
|
||||
audit(db, user, "session", None, "login", user.username, message=f"Вход в UI: {user.username}")
|
||||
db.commit()
|
||||
return TokenOut(access_token=create_token(user))
|
||||
|
||||
@@ -149,6 +149,8 @@ class User(Base):
|
||||
organization_id: Mapped[int | None] = mapped_column(ForeignKey("organizations.id"), index=True) # изменение 032: NK для admin/viewer, NULL для superadmin
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
password_changed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # токены с iat раньше — недействительны
|
||||
last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # изменение 035: время последнего успешного входа
|
||||
last_login_ip: Mapped[str | None] = mapped_column(INET) # изменение 035: IP последнего успешного входа
|
||||
|
||||
|
||||
Index("uq_users_lower_username", func.lower(User.username), unique=True) # логин уникален без учёта регистра
|
||||
|
||||
@@ -88,6 +88,13 @@ class UserOut(ORM):
|
||||
role: Role
|
||||
organization_id: int | None = None # изменение 032
|
||||
is_active: bool = True
|
||||
last_login_at: datetime | None = None # изменение 035
|
||||
last_login_ip: str | None = None # изменение 035
|
||||
|
||||
@field_validator("last_login_ip", mode="before")
|
||||
@classmethod
|
||||
def _last_login_ip_text(cls, v):
|
||||
return ip_text(v) if v is not None else None
|
||||
|
||||
|
||||
class UserIn(BaseModel):
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# Публикация IPAM через Caddy: rxipam.rxmsk.ru (изменение 034)
|
||||
|
||||
> Отклонение при выполнении: схема с общей сетью `app_internal` (раздел 1) отменена из-за коллизии имён `app`/`db` — см. `SUMMARY.md`.
|
||||
|
||||
## Context
|
||||
IPAM сейчас доступен только из LAN по HTTP (`192.168.5.9:8088`). Его нужно опубликовать в интернет через общий Caddy
|
||||
(`/opt/lvraid/apps/caddy`) под `rxipam.rxmsk.ru` с TLS.
|
||||
|
||||
**Решения пользователя:**
|
||||
- из интернета доступны UI и `/api/v1`;
|
||||
- Swagger и OpenAPI (`/docs`, `/redoc`, `/openapi.json`) доступны только из частных сетей (RFC 1918 / RFC 4193, loopback);
|
||||
- CNAME `rxipam` → `mkt.rxmsk.ru` в Cloudflare пользователь создаёт сам.
|
||||
|
||||
Отличить вызов API из UI от прямого вызова на уровне прокси нельзя: браузер SPA сам ходит в `/api/v1`.
|
||||
Поэтому API в интернете защищают JWT, роли и лимиты входа приложения.
|
||||
|
||||
**Факты разведки:**
|
||||
- Caddy работает в контейнере `caddy`, сети `app_external` (172.18/16) и `app_internal` (172.19/16).
|
||||
Сертификаты — ACME DNS-01 через Cloudflare, работает и без открытого 80.
|
||||
- Caddy видит настоящие публичные IP клиентов (в логах `93.123.x`, `13.210.x` …), поэтому `remote_ip` для разграничения надёжен.
|
||||
LAN-клиенты, заходящие по публичному имени, приходят с `192.168.5.253`: это шлюз MikroTik (hairpin NAT), он в частном диапазоне.
|
||||
- Приложение (`ipam_control_006-app-1`) — в своей сети `ipam_control_006_default`, Caddy его сейчас не видит.
|
||||
- `X-Forwarded-For` приложение учитывает только от `TRUSTED_PROXIES` (`app/request_context.py::resolve_client_ip`, справа налево).
|
||||
Сейчас список пуст. Без него после публикации все интернет-клиенты получат один IP — IP Caddy, тогда:
|
||||
- лимит 20 неудач на IP блокирует вход всем сразу;
|
||||
- в журнале исчезнут реальные адреса.
|
||||
|
||||
## Изменения
|
||||
|
||||
### 1. Сеть: приложение в `app_internal` (репозиторий ipam_control)
|
||||
- Новый `docker-compose.caddy.yml`: сервису `app` добавить сеть `app_internal` (external) с алиасом `ipam-app`, сеть `default` сохранить.
|
||||
Отдельный файл нужен, чтобы стенд на машине без Caddy (без сети `app_internal`) продолжал подниматься базовым `docker-compose.yml`.
|
||||
- `.env` стенда:
|
||||
- `COMPOSE_FILE=docker-compose.yml:docker-compose.caddy.yml` — команды `docker compose -p ipam_control_006 …` из README и тестов не меняются;
|
||||
- `TRUSTED_PROXIES=172.19.0.0/16` — подсеть `app_internal`. IP контейнера Caddy при пересоздании может смениться, поэтому /32 не подходит.
|
||||
Остальные контейнеры этой сети — свои сервисы, риск подделки XFF от них приемлем.
|
||||
- `.env.example`: закомментированные `COMPOSE_FILE` и `TRUSTED_PROXIES` с пояснением.
|
||||
- `APP_BIND` оставить `0.0.0.0`: прямой LAN-доступ `:8088` сохраняется, порт на шлюзе наружу не проброшен.
|
||||
|
||||
### 2. Caddyfile (`/opt/lvraid/apps/caddy/Caddyfile`)
|
||||
- Перед правкой — копия `Caddyfile.bak-2026-09-27`.
|
||||
- Править **на месте**: файл смонтирован в контейнер одиночным bind-mount `:ro`. Замена inode, как делают редакторы с atomic-rename,
|
||||
оставит контейнер со старым файлом — после правки сверить `docker exec caddy cat /etc/caddy/Caddyfile`.
|
||||
- Новый блок в стиле соседних (`vault`, `artstore`):
|
||||
```
|
||||
# IPAM Manager
|
||||
rxipam.rxmsk.ru {
|
||||
log {
|
||||
output file /var/log/caddy/rxipam.log {
|
||||
roll_size 100mb
|
||||
roll_keep 5
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
level INFO
|
||||
}
|
||||
|
||||
# Swagger/OpenAPI — только из частных сетей (RFC 1918/4193, loopback); из интернета — как будто их нет
|
||||
@docs_external {
|
||||
path /docs /docs/* /redoc /redoc/* /openapi.json
|
||||
not remote_ip private_ranges
|
||||
}
|
||||
respond @docs_external 404
|
||||
|
||||
reverse_proxy ipam-app:8000
|
||||
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
-Server
|
||||
}
|
||||
}
|
||||
```
|
||||
- `remote_ip` (TCP-пир), а не `client_ip`: у Caddy нет `trusted_proxies`, поэтому заголовки клиента на решение не влияют.
|
||||
- XFF к приложению Caddy формирует сам и не доверяет входящему. Приложение берёт из него реальный IP (п. 1).
|
||||
- `level INFO` — access-лог для разбора инцидентов (у соседей `ERROR`). CSP и прочие заголовки ставит приложение.
|
||||
- Применение:
|
||||
- `docker exec caddy caddy validate --config /etc/caddy/Caddyfile`;
|
||||
- `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`, без рестарта: остальные сайты не прерываются.
|
||||
|
||||
### 3. Документация (правила проекта)
|
||||
- `docs/changes/034-caddy-publication/PLAN.md` — этот план; по завершении — `SUMMARY.md`.
|
||||
- `README.md`, раздел «Публикация и эксплуатация»:
|
||||
- пример Caddy заменить фактической схемой: `docker-compose.caddy.yml`, `COMPOSE_FILE`, `TRUSTED_PROXIES`, ограничение Swagger;
|
||||
- добавить строку 034 в историю изменений.
|
||||
- План 031 частично перекрыт. Часть 2 (свой Caddy-профиль) заменяется общим Caddy. Часть 1 (`DOCS_ENABLED=false` по умолчанию)
|
||||
остаётся как вторая линия защиты — отдельной задачей, в этот объём не входит.
|
||||
|
||||
## Замечания (без действий в этом объёме)
|
||||
- LAN-пользователи через публичное имя идут через hairpin и для приложения выглядят одним IP `192.168.5.253`. Они делят лимит
|
||||
«20 неудач на IP» и неразличимы в журнале. Лечится split DNS на MikroTik (`rxipam.rxmsk.ru` → `192.168.5.9` внутри LAN):
|
||||
трафик пойдёт на Caddy напрямую с реальным LAN-IP. Рекомендация пользователю.
|
||||
- Если шлюз когда-либо начнёт делать SNAT входящего интернет-трафика (masquerade на WAN→LAN), интернет-клиенты
|
||||
станут «частными» и получат Swagger. Это проверяется пунктом «из интернета» ниже; при смене конфигурации шлюза — перепроверять.
|
||||
|
||||
## Порядок выполнения
|
||||
1. Пользователь: CNAME `rxipam` → `mkt.rxmsk.ru` (proxy в Cloudflare выключен, как у соседних: иначе `remote_ip` = IP Cloudflare).
|
||||
2. ipam_control: `docker-compose.caddy.yml`, `.env` (`COMPOSE_FILE`, `TRUSTED_PROXIES`), `docker compose -p ipam_control_006 up -d`;
|
||||
проверить `docker exec caddy wget -qO- http://ipam-app:8000/healthz`.
|
||||
3. Caddy: бэкап, правка блока, validate, reload.
|
||||
4. Проверки (ниже), затем документация и коммит в ipam_control (Caddyfile вне репозитория).
|
||||
|
||||
## Проверка
|
||||
- DNS: `getent hosts rxipam.rxmsk.ru` → `62.176.10.113`. Сертификат выпущен: `docker logs caddy` без ошибок ACME,
|
||||
`curl -sI https://rxipam.rxmsk.ru/` → 200, HSTS присутствует.
|
||||
- Из LAN (частный IP): `/` 200, `/api/v1/auth/me` без токена 401, `/docs` и `/openapi.json` 200.
|
||||
- Из интернета (мобильный интернет пользователя или внешний хост):
|
||||
- `/` 200 и вход в UI работает;
|
||||
- `/docs`, `/docs/`, `/redoc`, `/openapi.json`, `/openapi.json?x=1`, `//docs`, `/%64ocs` → 404;
|
||||
- в `rxipam.log` у этих запросов `remote_ip` публичный, `status` 404.
|
||||
- Реальный IP в приложении: войти из интернета → в журнале (событие входа) `client_ip` — публичный адрес, не `172.19.x`.
|
||||
Вход из LAN через FQDN даёт `192.168.5.253`.
|
||||
- Регрессия: `venv/bin/python -m pytest -q` (15 passed) — тесты идут на `127.0.0.1:8088`, мимо Caddy.
|
||||
- Остальные сайты Caddy после reload отвечают: `curl -sI https://vault.rxmsk.ru`, `https://artstore.rxmsk.ru` → не 5xx.
|
||||
|
||||
## Откат
|
||||
- Caddy: вернуть `Caddyfile.bak-2026-09-27` (на месте) и `caddy reload`.
|
||||
- ipam_control: убрать `COMPOSE_FILE`/`TRUSTED_PROXIES` из `.env`, `docker compose -p ipam_control_006 up -d`.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Итог: публикация через Caddy — rxipam.rxmsk.ru (изменение 034)
|
||||
|
||||
План: `PLAN.md`. Стенд `ipam_control_006` опубликован как `https://rxipam.rxmsk.ru` через общий Caddy хоста (`/opt/lvraid/apps/caddy`).
|
||||
Из интернета доступны UI и `/api/v1`; Swagger и OpenAPI — только из частных сетей.
|
||||
|
||||
## Отклонение от плана: без общей Docker-сети
|
||||
План предлагал подключить приложение к сети Caddy `app_internal` (`docker-compose.caddy.yml`, алиас `ipam-app`).
|
||||
При применении обнаружилась коллизия имён. Compose регистрирует имя сервиса как DNS-алиас в каждой сети контейнера,
|
||||
а в `app_internal` уже есть `db` и `app` стека Nextcloud:
|
||||
- приложение по имени `db` ушло в базу Nextcloud и не стартовало;
|
||||
- Caddy адресует Nextcloud как `app:9000` — второй `app` в той же сети ломал бы Nextcloud.
|
||||
|
||||
Подключение откатили примерно через минуту: приложение всё это время перезапускалось, в логе Nextcloud ошибок нет, `rxcloud` отвечает.
|
||||
**Итоговая схема:** Caddy проксирует на опубликованный порт приложения через шлюз своей сети — `reverse_proxy 172.19.0.1:8088`.
|
||||
Сети не объединяются, `docker-compose.yml` не менялся, `docker-compose.caddy.yml` не создавался.
|
||||
|
||||
## Что сделано
|
||||
| Где | Изменение |
|
||||
|---|---|
|
||||
| `/opt/lvraid/apps/caddy/Caddyfile` (вне репозитория) | Блок `rxipam.rxmsk.ru`: access-лог `rxipam.log` (JSON, INFO, ротация); `@docs_external` (`/docs`, `/docs/*`, `/redoc`, `/redoc/*`, `/openapi.json` и `not remote_ip private_ranges`) → 404; `reverse_proxy 172.19.0.1:8088`; HSTS, `-Server`. Бэкап: `Caddyfile.bak-2026-09-27`. Правка на месте, inode сохранён — контейнер видит новый файл; применено `caddy reload` |
|
||||
| `.env` стенда (вне репозитория) | `TRUSTED_PROXIES=172.16.0.0/12`: запросы Caddy приходят в приложение с шлюза Docker-моста (`172.31.0.1`) через NAT хоста. Диапазон вместо /32 — подсеть проекта может смениться при пересоздании сети |
|
||||
| `.env.example` | Закомментированный пример `TRUSTED_PROXIES` для публикации через Caddy на том же хосте |
|
||||
| `README.md` | «Публикация и эксплуатация» — фактическая схема вместо примера; строка 034 в истории |
|
||||
| `tests/test_journal.py` | Из интеграционного `test_client_ip_and_request_meta_recorded` убрана проверка «подделка XFF игнорируется»: с `TRUSTED_PROXIES=172.16.0.0/12` запросы тестов с хоста (через `172.31.0.1`) доверенные. Логику покрывает unit-тест `test_resolve_client_ip_trusts_forwarded_header_only_from_proxies` |
|
||||
|
||||
## Проверки
|
||||
- `caddy validate` — конфигурация корректна; `caddy reload` без рестарта. Соседние сайты после reload: vault, artstore, aycv — 200, rxcloud — 302 (редирект на вход).
|
||||
- Сертификат Let's Encrypt для `rxipam.rxmsk.ru` выпущен (DNS-01, Cloudflare).
|
||||
- Из частной сети через Caddy: `/` 200, `/api/v1/auth/me` без токена 401, `/docs` и `/openapi.json` 200. Заголовки HSTS и CSP присутствуют.
|
||||
- Matcher Swagger проверен на временном Caddy того же образа (тот же `path`, без условия по IP):
|
||||
`/docs`, `//docs`, `/%64ocs`, `/docs/oauth2-redirect`, `/openapi.json`, `/%6fpenapi.json`, `/redoc`, `/%72edoc` → 404; `/` 200, API 401.
|
||||
Проверка важна: приложение само отдаёт Swagger на URL-кодированные пути (`/%64ocs` → 200), и закрывает их именно Caddy.
|
||||
- `private_ranges` раскрывается в `192.168.0.0/16, 172.16.0.0/12, 10.0.0.0/8, 127.0.0.1/8, fd00::/8, ::1`.
|
||||
- Реальный IP клиента: запрос через Caddy с LAN-адреса хоста и подложным `X-Forwarded-For: 8.8.8.8` → в журнале `192.168.5.9`.
|
||||
Подложный заголовок отброшен Caddy, реальный адрес получен из XFF.
|
||||
- `pytest -q` — 15 passed (после правки теста выше).
|
||||
- Пробные неудачные входы (`rv-probe-034`, `rv-probe-034b`) остались в журнале как события `session.failed`.
|
||||
|
||||
## Не проверено (требует пользователя)
|
||||
- DNS: CNAME `rxipam` → `mkt.rxmsk.ru` (proxy Cloudflare выключен) ещё не создан — имя не резолвится.
|
||||
- Доступ из интернета (мобильный интернет или внешний хост): `/` 200 и вход в UI; `/docs`, `/openapi.json` → 404;
|
||||
в `rxipam.log` у этих запросов публичный `remote_ip`; в журнале приложения — публичный `client_ip` события входа.
|
||||
|
||||
## Рекомендации
|
||||
- Доверие `172.16.0.0/12` означает, что процессы и контейнеры **на самом хосте** (приходят через шлюз Docker-моста) могут подставить свой IP в `X-Forwarded-For`. LAN- и интернет-клиенты этого не могут: у LAN-клиентов, идущих напрямую на `:8088`, в журнале реальные адреса. Исключить хост из доверенных можно только прямой сетью Caddy ↔ приложение без NAT — это требует переименовать сервисы `app`/`db` стенда (коллизия выше), отдельная задача.
|
||||
- Split DNS на MikroTik (`rxipam.rxmsk.ru` → `192.168.5.9` в LAN): иначе LAN-клиенты через hairpin видны как `192.168.5.253`
|
||||
и делят лимит «20 неудач на IP».
|
||||
- План 031, часть 1 (`DOCS_ENABLED=false` по умолчанию) — вторая линия защиты Swagger, отдельной задачей.
|
||||
- `172.19.0.1` — шлюз внешней сети `app_internal`. При её пересоздании с другой подсетью обновить `reverse_proxy` в Caddyfile.
|
||||
|
||||
## Откат
|
||||
- Caddy: содержимое `Caddyfile.bak-2026-09-27` записать поверх `Caddyfile` на месте, затем `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
|
||||
- Стенд: убрать `TRUSTED_PROXIES` из `.env`, `docker compose -p ipam_control_006 up -d`.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Последний вход пользователя: дата и IP на странице «Пользователи» (изменение 035)
|
||||
|
||||
## Context
|
||||
Суперадминистратору нужно видеть в разделе «Пользователи», когда каждый пользователь последний раз входил в систему и с какого IP.
|
||||
Сейчас это можно найти только поиском по журналу. `known_logins` для этого не подходит: там IP за 30 дней, ротация удаляет старые записи,
|
||||
и он хранит набор IP, а не последний вход.
|
||||
|
||||
**Решение:** два поля в `users` — время и IP последнего успешного входа. Поля обновляются в `POST /auth/login`,
|
||||
отдаются в `UserOut` и выводятся колонкой в таблице пользователей.
|
||||
IP берётся тот же, что уже вычисляет `request_context` для журнала: реальный адрес клиента за Caddy (`TRUSTED_PROXIES`, изменение 034).
|
||||
|
||||
## Изменения
|
||||
|
||||
### БД и модель
|
||||
- `app/models.py::User`:
|
||||
- `last_login_at: Mapped[datetime | None]` (`DateTime(timezone=True)`);
|
||||
- `last_login_ip: Mapped[str | None]` (`INET`);
|
||||
- комментарий «изменение 035».
|
||||
- Миграция `alembic/versions/0014_users_last_login.py` (down_revision `0013`):
|
||||
- две nullable-колонки;
|
||||
- backfill из журнала: для каждого пользователя берётся последнее событие `session.login` (`DISTINCT ON (username) … ORDER BY username, ts DESC`)
|
||||
по `audit_log.username = users.username`, из него — `ts` и `client_ip`;
|
||||
- если журнал ротирован или очищен, значения остаются `NULL` («—» в UI).
|
||||
- `downgrade` — удалить колонки.
|
||||
|
||||
### API
|
||||
- `app/api/v1/auth.py::login`, ветка успешного входа (рядом с `_remember_login`):
|
||||
`user.last_login_at = func.now()`, `user.last_login_ip = ip`, в той же транзакции, что и запись `session.login`.
|
||||
Неудачные попытки поля не трогают.
|
||||
- `app/schemas.py::UserOut`: `last_login_at: datetime | None = None`, `last_login_ip: str | None = None`.
|
||||
INET приводить к строке тем же способом, что уже используется для `client_ip` в `AuditOut` и адресов (`ip_text`),
|
||||
чтобы в JSON был `"192.168.5.9"`, а не объект. `UserOut` собирается через `model_validate` из ORM, поэтому нужен
|
||||
`@field_validator("last_login_ip", mode="before")` с вызовом `ip_text()`.
|
||||
`UserOut` отдают и `/users` (только `superadmin`), и `/auth/me` (свои данные) — утечки чужих IP нет.
|
||||
|
||||
### UI (`web/app.js::screens.users`)
|
||||
- Новая колонка «Последний вход» после «Статус»:
|
||||
- дата — `fmtDate(u.last_login_at)` (как в журнале, UTC);
|
||||
- под ней или через разделитель — IP моноширинным шрифтом, `muted`;
|
||||
- «—», если входа не было.
|
||||
- Расширить `cols` (`selCols(...)`) и заголовок `.th` на одну колонку. Ширину подобрать так, чтобы таблица не переполнялась,
|
||||
по образцу соседних колонок (≈170–190px).
|
||||
|
||||
### Тесты (минимально)
|
||||
- `tests/test_users.py::test_users_management`: после входа созданного пользователя (`_client(name, ...)`)
|
||||
`GET /users?q=<name>` → у записи заполнены `last_login_at` и `last_login_ip`. Новых тестов не добавлять.
|
||||
|
||||
### Документация (правила проекта)
|
||||
- `docs/changes/035-user-last-login/PLAN.md` — этот план; по завершении — `SUMMARY.md`.
|
||||
- `README.md`:
|
||||
- раздел «Интерфейс»: в «Пользователях» видны дата и IP последнего входа;
|
||||
- миграции `0001`–`0014`;
|
||||
- строка 035 в истории изменений.
|
||||
|
||||
## Исполнение
|
||||
По принятой схеме: код и тест пишет агент на Sonnet (без запуска тестов и стенда), затем ревью, пересборка стенда,
|
||||
проверки и SUMMARY — моя часть.
|
||||
|
||||
## Проверка
|
||||
- `docker compose -p ipam_control_006 up -d --build app` → `alembic current` = `0014`, `alembic check` — чисто;
|
||||
откат до 0013 и повторный upgrade — без ошибок.
|
||||
- Backfill: у суперадминистратора из `.env`, который входил, `last_login_at`/`last_login_ip` совпадают с последним `session.login` в журнале.
|
||||
- Вход через Caddy с LAN-адреса хоста (`--resolve rxipam.rxmsk.ru:443:192.168.5.9`) → `last_login_ip = 192.168.5.9`;
|
||||
неудачная попытка не меняет поля.
|
||||
- `GET /users` — поля в JSON строками; `pytest -q` — все зелёные.
|
||||
- UI: колонка «Последний вход» с датой и IP, «—» у пользователей без входа. В браузере проверяет пользователь, если нужно.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Итог: последний вход пользователя — дата и IP (изменение 035)
|
||||
|
||||
План: `PLAN.md`. В разделе «Пользователи» появилась колонка «Последний вход»: дата (UTC) и IP последнего успешного входа.
|
||||
|
||||
## Что сделано
|
||||
| Файл | Изменение |
|
||||
|---|---|
|
||||
| `app/models.py` | `User.last_login_at` (`timestamptz`), `User.last_login_ip` (`INET`), nullable |
|
||||
| `alembic/versions/0014_users_last_login.py` | Колонки и перенос данных из журнала: последнее событие `session.login` на логин. Если журнал ротирован или очищен — `NULL` |
|
||||
| `app/api/v1/auth.py` | Успешный вход записывает `last_login_at = now()` и `last_login_ip` в той же транзакции, что и `session.login`. IP — тот же, что в журнале (реальный клиент за Caddy, изменение 034). Неудачные попытки поля не трогают |
|
||||
| `app/schemas.py` | `UserOut.last_login_at`, `UserOut.last_login_ip`. INET приводится к строке через `field_validator` с существующей `ip_text()`. Поля видны в `/users` (только `superadmin`) и в `/auth/me` (свои) |
|
||||
| `web/app.js` | Колонка «Последний вход» после «Статус»: дата и IP моноширинным шрифтом, «—», если входа не было |
|
||||
| `tests/test_users.py` | В `test_users_management`: после входа созданного пользователя поля заполнены в `GET /users` |
|
||||
| `README.md` | «Интерфейс», миграции `0001`–`0014`, строка 035 в истории |
|
||||
|
||||
## Проверки
|
||||
- Стенд пересобран: `alembic current` — `0014`, `alembic check` — чисто, откат до 0013 и повторный upgrade — без ошибок.
|
||||
- Перенос данных: у `tstark` дата и IP (`192.168.5.253`) совпадают с последним `session.login` в журнале; у `sbas-sa` входов не было — `NULL`.
|
||||
- Временный `viewer` (создан и удалён через SQL): вход через Caddy с LAN-адреса хоста дал `last_login_ip = 192.168.5.9`
|
||||
и дату входа, в JSON — строки. Неудачная попытка поля не изменила.
|
||||
- `node --check web/app.js`, `import app.main` — чисто.
|
||||
- `pytest -q` — 15 passed. Перед прогоном учётная запись `admin` из `.env` (удалена через UI в 08:54, фикстура `client` без неё не работает) создана заново через SQL как `superadmin` с паролем из `.env`; попытки входа под удалённым `admin` (блокировка «логин + IP») очищены. Создание через SQL в журнал не попало.
|
||||
|
||||
## Не проверено
|
||||
- Колонка в браузере — не просматривалась.
|
||||
@@ -55,12 +55,12 @@ def test_clear_requires_password_and_locks_out(db):
|
||||
|
||||
|
||||
def test_client_ip_and_request_meta_recorded(client, org):
|
||||
hdr = {"User-Agent": "ipam-tests/1.0", "X-Forwarded-For": "203.0.113.9"} # подделка XFF от недоверенного пира
|
||||
# доверие к X-Forwarded-For зависит от TRUSTED_PROXIES стенда (за Caddy хост доверенный, изменение 034) — логику проверяет unit-тест ниже
|
||||
hdr = {"User-Agent": "ipam-tests/1.0"}
|
||||
cidr = f"fd00:{uuid.uuid4().int % 0xF000 + 0x1000:x}:{uuid.uuid4().int % 0xF000 + 0x1000:x}::/64"
|
||||
client.post("/prefixes", headers=hdr, json={"organization_id": org["id"], "vrf_id": org["vrf_id"], "prefix": cidr})
|
||||
entry = client.get("/audit", params={"q": cidr}).json()["items"][0]
|
||||
assert ipaddress.ip_address(entry["client_ip"])
|
||||
assert entry["client_ip"] != "203.0.113.9" # заголовок не доверенного прокси игнорируется
|
||||
assert entry["meta"]["user_agent"] == "ipam-tests/1.0" and entry["meta"]["method"] == "POST" and entry["meta"]["path"] == "/api/v1/prefixes"
|
||||
assert client.get("/audit", params={"q": cidr, "client_ip": entry["client_ip"]}).json()["total"] >= 1 # точный IP
|
||||
assert client.get("/audit", params={"q": cidr, "client_ip": "203.0.113.0/24"}).json()["total"] == 0
|
||||
|
||||
@@ -38,6 +38,11 @@ def test_users_management(client, org):
|
||||
assert other.get("/users").status_code == 403 # список пользователей только для суперадминистратора (изменение 032)
|
||||
assert other.post("/organizations", json={"name": "qa", "inn": "1234567890"}).status_code == 403 # роль «просмотр» — только чтение
|
||||
|
||||
# последний вход фиксируется и виден суперадминистратору (изменение 035)
|
||||
found = client.get("/users", params={"q": name}).json()["items"][0]
|
||||
assert isinstance(found["last_login_at"], str) and found["last_login_at"]
|
||||
assert isinstance(found["last_login_ip"], str) and found["last_login_ip"]
|
||||
|
||||
# отключение действует немедленно, включая ранее выданный токен
|
||||
assert client.patch(f"/users/{uid}", json={"is_active": False}).json()["is_active"] is False
|
||||
assert other.get("/auth/me").status_code == 401
|
||||
|
||||
+3
-2
@@ -698,7 +698,7 @@ screens.users = async () => {
|
||||
const q = S.q || "";
|
||||
const { items, total } = await api("/users", { params: { q, limit: 500 } });
|
||||
S.rows = items;
|
||||
const cols = selCols("minmax(200px,1.5fr) 190px minmax(180px,1fr) 190px minmax(140px,1fr) 44px"); // изменение 032: добавлен столбец для организации
|
||||
const cols = selCols("minmax(200px,1.5fr) 190px minmax(180px,1fr) 190px 180px minmax(140px,1fr) 44px"); // изменение 032: столбец организации; изменение 035: столбец последнего входа
|
||||
setSelectable(items.filter((u) => u.id !== user?.id).map((u) => u.id)); // свою запись выбрать нельзя
|
||||
const rows = items.map((u, n) => {
|
||||
const self = u.id === user?.id;
|
||||
@@ -713,12 +713,13 @@ screens.users = async () => {
|
||||
<span>${badge(u.role === "superadmin" ? "purple" : u.role === "admin" ? "blue" : "", ROLE_RU[u.role] || u.role)}</span>
|
||||
<span class="muted" style="font-size:13px">${u.role === "superadmin" ? "—" : esc(orgName)}</span>
|
||||
<span>${u.is_active ? badge("green", "Доступ разрешён") : badge("red", "Отключён")}</span>
|
||||
<span style="font-size:13px">${u.last_login_at ? `<span style="display:flex;flex-direction:column;gap:2px"><span>${fmtDate(u.last_login_at)}</span><span class="mono muted" style="font-size:12px">${esc(u.last_login_ip || "—")}</span></span>` : '<span class="muted">—</span>'}</span>
|
||||
<span class="muted" style="font-size:13px">${self ? "свои роль и доступ менять нельзя" : ""}</span>
|
||||
<span class="cell-actions rel">${iconBtn(I.dots(), "menu", "Действия", `data-menu="user-row-${u.id}"`)}${popMenu("user-row-" + u.id, items2, "row-pop")}</span></div>`;
|
||||
}).join("");
|
||||
return shell("users", `${header("Пользователи", `${total} ${plural(total, "учётная запись", "учётные записи", "учётных записей")} · роли: суперадминистратор — полные права, администратор — запись в организации, просмотр — чтение`, btn("Добавить пользователя", "users-new", { cls: "primary", icon: I.plus() }))}
|
||||
<div class="card"><div class="filters">${searchBox(q, "Поиск по логину", 260)}</div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Организация</span><span>Статус</span><span></span><span></span></div>
|
||||
${bulkBar(btn("Разрешить доступ", "bulk-access", { data: 'data-value="1"' }) + btn("Отключить доступ", "bulk-access", { data: 'data-value="0"' }))}<div class="tr th" style="--cols:${cols};--h:40px">${selAllCell()}<span>Логин</span><span>Роль</span><span>Организация</span><span>Статус</span><span>Последний вход</span><span></span><span></span></div>
|
||||
${rows || '<div class="empty">Пользователей нет</div>'}<div class="foot">Показано ${items.length} из ${total} ${plural(total, "записи", "записей", "записей")}</div></div>`);
|
||||
};
|
||||
function userDialog(u) {
|
||||
|
||||
Reference in new issue
Block a user