import asyncio import logging from contextlib import asynccontextmanager from pathlib import Path from fastapi import APIRouter, FastAPI, HTTPException, Request from fastapi.exceptions import RequestValidationError from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from sqlalchemy import select from sqlalchemy.exc import IntegrityError from app.api.v1 import auth, journal, overview, prefixes, refs, users from app.config import settings, validate_secrets from app.db import SessionLocal from app.models import DeviceType, Role, User from app.request_context import RequestContextMiddleware from app.rotation import rotation_loop from app.security import hash_password validate_secrets() # приложение не стартует с небезопасной конфигурацией (изменение 017) log = logging.getLogger("ipam") DEFAULT_TYPES = ["Сервер", "Сетевое оборудование", "Сетевое хранилище", "Рабочая станция", "Другое"] def seed(): with SessionLocal() as db: if not db.scalar(select(User.id).limit(1)): if settings.admin_password: db.add(User(username=settings.admin_username, password_hash=hash_password(settings.admin_password), role=Role.superadmin, organization_id=None)) # изменение 032: role=superadmin, organization_id=None else: log.warning("В БД нет пользователей и ADMIN_PASSWORD не задан: администратор не создан, войти в UI нельзя") if not db.scalar(select(DeviceType.id).limit(1)): db.add_all(DeviceType(name=n, is_default=(n == "Другое")) for n in DEFAULT_TYPES) db.commit() @asynccontextmanager async def lifespan(_: FastAPI): seed() task = asyncio.create_task(rotation_loop()) yield task.cancel() CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self'; frame-ancestors 'none'" DOCS_PATHS = ("/docs", "/redoc", "/openapi.json") # Swagger UI грузит ресурсы с CDN — CSP для него не ставим class SecurityHeadersMiddleware: """ASGI-middleware: заголовки безопасности на все ответы (изменение 023).""" def __init__(self, app): self.app = app async def __call__(self, scope, receive, send): if scope["type"] != "http": return await self.app(scope, receive, send) docs = scope["path"].startswith(DOCS_PATHS) async def send_with_headers(message): if message["type"] == "http.response.start": extra = [(b"x-content-type-options", b"nosniff"), (b"referrer-policy", b"no-referrer")] if not docs: extra += [(b"content-security-policy", CSP.encode()), (b"x-frame-options", b"DENY")] message["headers"] = [*message.get("headers", []), *extra] await send(message) await self.app(scope, receive, send_with_headers) app = FastAPI(title="IPAM Manager API", version="1.0.0", lifespan=lifespan) app.add_middleware(RequestContextMiddleware) app.add_middleware(SecurityHeadersMiddleware) api = APIRouter(prefix="/api/v1") for r in (auth.router, overview.router, refs.router, prefixes.router, journal.router, users.router): api.include_router(r) app.include_router(api) @app.exception_handler(HTTPException) async def http_error(_: Request, exc: HTTPException): extra = exc.detail if isinstance(exc.detail, dict) else {"message": exc.detail} # dict — доп. поля (attempts_left и т.п.) return JSONResponse({"code": exc.status_code, "fields": {}, **extra}, status_code=exc.status_code, headers=exc.headers) @app.exception_handler(IntegrityError) async def integrity_error(_: Request, exc: IntegrityError): # страховка: нарушение ограничения БД не должно давать 500 return JSONResponse({"code": 409, "message": "Конфликт с существующими данными", "fields": {}}, status_code=409) @app.exception_handler(RequestValidationError) async def validation_error(_: Request, exc: RequestValidationError): fields = {".".join(str(x) for x in e["loc"][1:]): e["msg"].removeprefix("Value error, ") for e in exc.errors()} return JSONResponse({"code": 422, "message": "Ошибка валидации", "fields": fields}, status_code=422) @app.get("/healthz", include_in_schema=False) def healthz(): return {"status": "ok"} web = Path(__file__).resolve().parent.parent / "web" if web.is_dir(): class RevalidatedStatic(StaticFiles): """Статика с обязательной ревалидацией по ETag: браузер не держит устаревший UI после обновления.""" async def get_response(self, path, scope): response = await super().get_response(path, scope) response.headers["Cache-Control"] = "no-cache" return response app.mount("/", RevalidatedStatic(directory=web, html=True), name="web")