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

+20
View File
@@ -0,0 +1,20 @@
"""Генерирует .env со случайными учётными данными (не перезаписывает существующий)."""
import pathlib
import secrets
env = pathlib.Path(__file__).resolve().parent.parent / ".env"
if env.exists():
print(f"{env} уже существует — пропускаю")
else:
env.write_text(
"POSTGRES_DB=ipam\n"
"POSTGRES_USER=ipam\n"
f"POSTGRES_PASSWORD={secrets.token_urlsafe(24)}\n"
f"JWT_SECRET={secrets.token_urlsafe(48)}\n"
"ADMIN_USERNAME=admin\n"
f"ADMIN_PASSWORD={secrets.token_urlsafe(18)}\n"
"APP_PORT=8088\n"
"DB_HOST_PORT=55432\n"
)
env.chmod(0o600)
print(f"создан {env}")
+103
View File
@@ -0,0 +1,103 @@
"""Заполняет БД вымышленными данными из макетов IPAM Manager (через API). Запуск: venv/bin/python scripts/seed_demo.py"""
import os
import pathlib
import sys
import httpx
env = dict(l.split("=", 1) for l in pathlib.Path(__file__).resolve().parent.parent.joinpath(".env").read_text().split() if "=" in l)
base = os.environ.get("BASE_URL", f"http://127.0.0.1:{env['APP_PORT']}") + "/api/v1"
c = httpx.Client(base_url=base, timeout=30)
tok = c.post("/auth/login", json={"username": env["ADMIN_USERNAME"], "password": env["ADMIN_PASSWORD"]}).json()["access_token"]
c.headers["Authorization"] = f"Bearer {tok}"
def post(path, **body):
r = c.post(path, json=body)
if r.status_code >= 400:
sys.exit(f"{path}: {r.status_code} {r.text}")
return r.json()
orgs_data = [
("ООО «Технологии связи»", "ТехСвязь", "7701234567", "Москва, ул. Ленина, 15"),
("АО «Центр-Телеком»", "ЦентрТК", "7709876543", "СПб, пр. Невский, 100"),
("ИП Петров А.В.", "Петров", "770100001234", "Казань, ул. Баумана, 5"),
("ООО «СтройМонтаж»", "СтройМ", "7705551234", "Москва, Кутузовский, 32"),
("ООО «Дата-Центр»", "ДЦ", "7704443322", "Москва, Бутово, пром. зона"),
]
orgs = [post("/organizations", name=n, short_name=s, inn=i, address=a) for n, s, i, a in orgs_data]
org = orgs[0]
vrf = {v["name"]: v["id"] for v in c.get("/vrfs", params={"organization_id": org["id"]}).json()["items"]}
vrf["management"] = post("/vrfs", organization_id=org["id"], name="management", route_target="65000:2")["id"]
vrf["guest"] = post("/vrfs", organization_id=org["id"], name="guest", route_target="65000:3")["id"]
prefixes = {}
for cidr, desc, v, status, pool in [
("10.0.0.0/8", "Внутренняя сеть", "default", "active", False),
("10.10.0.0/16", "Москва — серверы и офис", "default", "active", False),
("10.10.1.0/24", "Серверная — production", "default", "active", True),
("10.10.2.0/24", "Серверная — staging", "default", "active", True),
("10.20.0.0/16", "Санкт-Петербург", "default", "active", False),
("10.20.0.0/24", "СПб — офис", "default", "active", False),
("192.168.0.0/16", "Офисные сети", "default", "active", False),
("192.168.10.0/24", "Офис — пользователи", "default", "active", False),
("192.168.20.0/24", "Офис — принтеры", "default", "active", False),
("172.16.0.0/12", "Резерв — будущее расширение", "default", "reserved", False),
("fd00::/8", "ULA — внутренняя IPv6", "default", "active", False),
("10.99.0.0/24", "Управление оборудованием", "management", "active", False),
("10.98.0.0/24", "Гостевой Wi-Fi", "guest", "active", False),
]:
prefixes[cidr] = post("/prefixes", organization_id=org["id"], vrf_id=vrf[v], prefix=cidr, description=desc, status=status, is_pool=pool)["id"]
types = {t["name"]: t["id"] for t in c.get("/device-types").json()["items"]}
devs = {}
for name, t, note in [
("gw-prod.internal", "Сетевое оборудование", "Шлюз — MSK-Core-01"),
("db-master.internal", "Сервер", "PostgreSQL primary"),
("db-replica.internal", "Сервер", "PostgreSQL replica"),
("app-01.internal", "Сервер", "Backend API node 1"),
("monitoring.internal", "Сервер", "Prometheus + Grafana"),
("old-nas.internal", "Сетевое хранилище", "NAS — выведен из эксплуатации"),
]:
devs[name] = post("/devices", name=name, device_type_id=types[t], organization_id=org["id"], note=note)["id"]
def addr(cidr, ip, **kw):
post(f"/prefixes/{prefixes[cidr]}/addresses", address=ip, **kw)
P = "10.10.1.0/24"
named = {1: ("gw-prod.internal", "Шлюз — MSK-Core-01"), 2: ("db-master.internal", "PostgreSQL primary"), 3: ("db-replica.internal", "PostgreSQL replica"), 5: ("app-01.internal", "Backend API node 1"), 105: ("monitoring.internal", "Prometheus + Grafana")}
reserved = {4: "VIP для балансировки", 6: "Резерв", 7: "Резерв", 8: "Резерв"}
free = {100, 101, 102, 103, 104, 106, 107, 108} # 8 свободных
n_assigned = 0
for i in range(1, 255):
ip = f"10.10.1.{i}"
if i in free:
continue
if i == 200:
addr(P, ip, status="deprecated", dns_name="old-nas.internal", description="NAS — выведен", device_id=devs["old-nas.internal"])
elif i in reserved:
addr(P, ip, status="reserved", description=reserved[i])
elif i in named:
dns, d = named[i]
addr(P, ip, dns_name=dns, description=d, device_id=devs[dns])
else:
addr(P, ip, dns_name=f"host-{i}.internal", description="Сервис")
# второй IP у monitoring — в staging-подсети
addr("10.10.2.0/24", "10.10.2.10", dns_name="monitoring.internal", description="Prometheus + Grafana", device_id=devs["monitoring.internal"])
for cidr, prefix, count in [("10.10.2.0/24", "10.10.2", 217), ("192.168.10.0/24", "192.168.10", 196), ("10.20.0.0/24", "10.20.0", 162), ("192.168.20.0/24", "192.168.20", 30)]:
for i in range(20 if cidr == "10.10.2.0/24" else 1, count + (20 if cidr == "10.10.2.0/24" else 1)):
addr(cidr, f"{prefix}.{i}", dns_name=f"h{i}.{prefix.replace('.', '-')}.internal")
for i in range(1, 49):
addr("fd00::/8", f"fd00::{i:x}", description="IPv6 узел")
for name, org_i, nets, hot, contract, note in [
("Ростелеком", 0, ["85.249.12.0/24", "85.249.13.0/24"], "8-800-100-0800", "РТ-2024/156", "Основной канал 1G"),
("МТС", 1, ["213.87.0.0/16"], "8-800-250-0890", "МТС-2025/042", "Резервный канал"),
("Мегафон", 0, ["178.176.72.0/21", "178.176.80.0/21", "178.176.88.0/21"], "8-800-550-0500", "МФ-2024/089", "VPN филиалов"),
("ТТК", 4, ["195.66.128.0/19"], "8-800-775-0775", "ТТК-2025/011", "ДЦ Бутово"),
]:
post("/isps", name=name, organization_id=orgs[org_i]["id"], networks=nets, hotline=hot, contract_number=contract, note=note)
print("seed ok")