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>
75 lines
3.1 KiB
Python
75 lines
3.1 KiB
Python
"""Контекст 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)
|