Безопасность, пункты 1–4 ревью (docs/changes/021): - приложение не стартует со слабыми секретами: API_TOKEN и SESSION_SECRET пустые, change-me или короче 32 символов, ADMIN_PASSWORD короче 12, невалидный SECRET_KEY; значения секретов в ошибку не попадают; - вход в UI: 5 неверных попыток за 10 минут блокируют IP на 10 минут (429, пароль не проверяется); неверный пароль — 401; событие auth.locked пишется один раз, журнал не засыпается перебором; - SESSION_COOKIE_SECURE — флаг Secure у cookie сессии (по умолчанию выкл.); - _safe_next: редирект только на локальный путь (/ui/move, удаление бэкапов); - .env.bak-* в .gitignore. Тесты: 28 из 28. Стенд: change-me → 401, новый токен → 200, отказ старта со слабым токеном, блокировка входа и редиректы проверены, боевые данные не изменены. Ручная проверка UI пользователем на момент коммита не подтверждена. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
2.7 KiB
Python
72 lines
2.7 KiB
Python
import asyncio
|
|
from contextlib import asynccontextmanager, suppress
|
|
from pathlib import Path
|
|
|
|
from botocore.exceptions import BotoCoreError, ClientError
|
|
from fastapi import FastAPI, Request
|
|
from fastapi.responses import JSONResponse, RedirectResponse
|
|
from fastapi.staticfiles import StaticFiles
|
|
from starlette.middleware.sessions import SessionMiddleware
|
|
|
|
from app import process_lock
|
|
from app.api import v1
|
|
from app.config import get_settings, insecure_settings
|
|
from app.db import init_db
|
|
from app.services import backups, jobs, poller, rotation
|
|
from app.ui import routes as ui
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(app: FastAPI):
|
|
problems = insecure_settings(get_settings())
|
|
if problems:
|
|
raise RuntimeError("Небезопасная конфигурация: " + "; ".join(problems) + "; см. README «Настройки»")
|
|
process_lock.acquire(get_settings().database_url) # один процесс на файловую БД (--workers 1)
|
|
try:
|
|
init_db()
|
|
jobs.fail_stale_jobs()
|
|
task = asyncio.create_task(poller.run()) if get_settings().poll_interval > 0 else None
|
|
reconcile = asyncio.create_task(backups.reconcile()) # связать файлы бакета с метаданными (best-effort)
|
|
rotator = asyncio.create_task(rotation.run()) # ротация журнала по настройкам
|
|
yield
|
|
for t in (task, reconcile, rotator):
|
|
if t:
|
|
t.cancel()
|
|
with suppress(asyncio.CancelledError):
|
|
await t
|
|
finally:
|
|
process_lock.release()
|
|
|
|
|
|
def create_app() -> FastAPI:
|
|
app = FastAPI(title="ros_control", lifespan=lifespan)
|
|
app.add_middleware(
|
|
SessionMiddleware, secret_key=get_settings().session_secret,
|
|
same_site="strict", https_only=get_settings().session_cookie_secure,
|
|
)
|
|
app.mount("/static", StaticFiles(directory=Path(__file__).parent / "ui" / "static"), name="static")
|
|
app.include_router(v1.router)
|
|
app.include_router(ui.router)
|
|
|
|
@app.exception_handler(ui.LoginRequired)
|
|
async def _login(_: Request, e: ui.LoginRequired):
|
|
return RedirectResponse("/login", status_code=303)
|
|
|
|
@app.exception_handler(LookupError)
|
|
async def _404(_: Request, e: LookupError):
|
|
return JSONResponse({"detail": str(e).strip("'\"")}, status_code=404)
|
|
|
|
@app.exception_handler(ValueError)
|
|
async def _400(_: Request, e: ValueError):
|
|
return JSONResponse({"detail": str(e)}, status_code=400)
|
|
|
|
@app.exception_handler(BotoCoreError)
|
|
@app.exception_handler(ClientError)
|
|
async def _502(_: Request, e: Exception):
|
|
return JSONResponse({"detail": f"S3: {e}"}, status_code=502)
|
|
|
|
return app
|
|
|
|
|
|
app = create_app()
|