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:
commit
a846d30872
64 files changed
+4194
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user