IPAM Manager: API, UI-админка, журнал аудита

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>
This commit is contained in:
ayurishchevandClaude Sonnet 5 committed 2026-09-20 12:27:47 +03:00
commit a846d30872
64 files changed
+4194

No files matched your search

+74
View File
@@ -0,0 +1,74 @@
"""Контекст HTTP-запроса для журнала аудита: IP клиента, User-Agent, метод, путь, request_id."""
import ipaddress
import uuid
from contextvars import ContextVar
from app.config import settings
request_meta: ContextVar[dict | None] = ContextVar("request_meta", default=None)
def _parse(value: str):
try:
ip = ipaddress.ip_address(value.strip())
except ValueError:
return None
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip # ::ffff:a.b.c.d -> a.b.c.d
def parse_networks(raw: str) -> list:
nets = []
for item in raw.split(","):
if item.strip():
nets.append(ipaddress.ip_network(item.strip(), strict=False))
return nets
def resolve_client_ip(peer: str | None, x_forwarded_for: str | None, trusted: list) -> str | None:
"""IP клиента. X-Forwarded-For учитывается только от доверенного пира (иначе заголовок можно подделать):
идём по цепочке справа налево и берём первый адрес, не принадлежащий доверенным прокси."""
peer_ip = _parse(peer) if peer else None
if peer_ip is None:
return None
is_trusted = lambda ip: any(ip in net for net in trusted) # noqa: E731
if not x_forwarded_for or not is_trusted(peer_ip):
return str(peer_ip)
for part in reversed([p for p in x_forwarded_for.split(",") if p.strip()]):
ip = _parse(part)
if ip is None:
return str(peer_ip) # мусор в заголовке — доверяем только сокету
if not is_trusted(ip):
return str(ip)
return str(peer_ip)
class RequestContextMiddleware:
"""ASGI-middleware: кладёт метаданные запроса в ContextVar и возвращает X-Request-ID."""
def __init__(self, app):
self.app = app
self.trusted = parse_networks(settings.trusted_proxies)
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
request_id = uuid.uuid4().hex[:12]
peer = scope["client"][0] if scope.get("client") else None
token = request_meta.set({
"client_ip": resolve_client_ip(peer, headers.get("x-forwarded-for"), self.trusted),
"meta": {
"user_agent": headers.get("user-agent", "")[:255], "method": scope["method"],
"path": scope["path"], "request_id": request_id, # только путь — без query-строки
},
})
async def send_with_id(message):
if message["type"] == "http.response.start":
message["headers"] = [*message.get("headers", []), (b"x-request-id", request_id.encode())]
await send(message)
try:
await self.app(scope, receive, send_with_id)
finally:
request_meta.reset(token)