Files
ipam_control/app/main.py
T

115 lines
5.1 KiB
Python
Raw Normal View History

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")