2026-09-20 12:27:47 +03:00
import asyncio
2026-09-26 21:33:50 +03:00
import logging
2026-09-20 12:27:47 +03:00
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
2026-09-26 13:25:33 +03:00
from app.api.v1 import auth , journal , overview , prefixes , refs , users
2026-09-26 21:33:50 +03:00
from app.config import settings , validate_secrets
2026-09-20 12:27:47 +03:00
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
2026-09-26 21:33:50 +03:00
validate_secrets () # приложение не стартует с небезопасной конфигурацией (изменение 017)
log = logging . getLogger ( "ipam" )
2026-09-20 12:27:47 +03:00
DEFAULT_TYPES = [ "Сервер" , "Сетевое оборудование" , "Сетевое хранилище" , "Рабочая станция" , "Другое" ]
def seed ():
with SessionLocal () as db :
2026-09-26 21:33:50 +03:00
if not db . scalar ( select ( User . id ) . limit ( 1 )):
if settings . admin_password :
2026-09-27 11:26:57 +03:00
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
2026-09-26 21:33:50 +03:00
else :
log . warning ( "В БД нет пользователей и ADMIN_PASSWORD не задан: администратор не создан, войти в UI нельзя" )
2026-09-20 12:27:47 +03:00
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 ()
2026-09-26 21:33:50 +03:00
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 )
2026-09-20 12:27:47 +03:00
app = FastAPI ( title = "IPAM Manager API" , version = "1.0.0" , lifespan = lifespan )
app . add_middleware ( RequestContextMiddleware )
2026-09-26 21:33:50 +03:00
app . add_middleware ( SecurityHeadersMiddleware )
2026-09-20 12:27:47 +03:00
api = APIRouter ( prefix = "/api/v1" )
2026-09-26 13:25:33 +03:00
for r in ( auth . router , overview . router , refs . router , prefixes . router , journal . router , users . router ):
2026-09-20 12:27:47 +03:00
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" )