feat: Добавлена новая система авторизации с JWT токенами
- Удален Basic Auth, заменен на современную JWT авторизацию - Добавлена страница входа с красивым интерфейсом - Обновлен фронтенд для работы с JWT токенами - Добавлены новые зависимости: PyJWT, passlib[bcrypt], jinja2 - Создан тестовый скрипт для проверки авторизации - Добавлено руководство по миграции - Обновлена документация и README - Улучшен дизайн поля ввода пароля на странице входа Автор: Сергей Антропов Сайт: https://devops.org.ru
This commit is contained in:
297
app.py
297
app.py
@@ -1,56 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LogBoard+ - Веб-панель для просмотра логов микросервисов
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Optional, List, Dict
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Union
|
||||
from pathlib import Path
|
||||
|
||||
import docker
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body, Request, Response
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel
|
||||
import jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
# Настройки приложения
|
||||
APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001"))
|
||||
DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500"))
|
||||
BASIC_USER = os.getenv("LOGBOARD_USER", "admin")
|
||||
BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin")
|
||||
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") # filter by compose project
|
||||
DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS") # multiple projects filter
|
||||
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME")
|
||||
DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS")
|
||||
SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true"
|
||||
CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10"))
|
||||
CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3"))
|
||||
HEALTH_CHECK_TIMEOUT = int(os.getenv("LOGBOARD_HEALTH_CHECK_TIMEOUT", "2"))
|
||||
|
||||
security = HTTPBasic()
|
||||
app = FastAPI(title="LogBoard+")
|
||||
# Настройки безопасности
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production")
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("SESSION_TIMEOUT", "3600")) // 60 # 1 час по умолчанию
|
||||
|
||||
# Настройки пользователей
|
||||
ADMIN_USERNAME = os.getenv("LOGBOARD_USER", "admin")
|
||||
ADMIN_PASSWORD = os.getenv("LOGBOARD_PASS", "admin")
|
||||
|
||||
# Инициализация FastAPI
|
||||
app = FastAPI(
|
||||
title="LogBoard+",
|
||||
description="Веб-панель для просмотра логов микросервисов",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# Инициализация шаблонов
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Инициализация безопасности
|
||||
security = HTTPBearer()
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
# Инициализация Docker клиента
|
||||
docker_client = docker.from_env()
|
||||
|
||||
# serve snapshots directory (downloadable files)
|
||||
SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots")
|
||||
os.makedirs(SNAP_DIR, exist_ok=True)
|
||||
app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots")
|
||||
|
||||
docker_client = docker.from_env()
|
||||
# Модели данных
|
||||
class UserLogin(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
# ---------- AUTH ----------
|
||||
def check_basic(creds: HTTPBasicCredentials = Depends(security)):
|
||||
if creds.username == BASIC_USER and creds.password == BASIC_PASS:
|
||||
return creds
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
headers={"WWW-Authenticate": "Basic"})
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
|
||||
def token_from_creds(creds: HTTPBasicCredentials) -> str:
|
||||
return base64.b64encode(f"{creds.username}:{creds.password}".encode()).decode()
|
||||
class TokenData(BaseModel):
|
||||
username: Optional[str] = None
|
||||
|
||||
def verify_ws_token(token: str) -> bool:
|
||||
# Функции для работы с паролями
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверяет пароль"""
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хеширует пароль"""
|
||||
return pwd_context.hash(password)
|
||||
|
||||
# Функции для работы с JWT токенами
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Создает JWT токен"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
def verify_token(token: str) -> Optional[str]:
|
||||
"""Проверяет JWT токен и возвращает имя пользователя"""
|
||||
try:
|
||||
raw = base64.b64decode(token.encode(), validate=True).decode()
|
||||
except Exception:
|
||||
return False
|
||||
return raw == f"{BASIC_USER}:{BASIC_PASS}"
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
return None
|
||||
return username
|
||||
except jwt.PyJWTError:
|
||||
return None
|
||||
|
||||
# Функция для проверки аутентификации
|
||||
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
|
||||
"""Получает текущего пользователя из токена"""
|
||||
token = credentials.credentials
|
||||
username = verify_token(token)
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Недействительный токен",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
return username
|
||||
|
||||
# Функция для проверки пользователя
|
||||
def authenticate_user(username: str, password: str) -> bool:
|
||||
"""Аутентифицирует пользователя"""
|
||||
if username == ADMIN_USERNAME:
|
||||
# Для простоты используем прямое сравнение паролей
|
||||
# В продакшене рекомендуется использовать хешированные пароли
|
||||
return password == ADMIN_PASSWORD
|
||||
return False
|
||||
|
||||
# ---------- DOCKER HELPERS ----------
|
||||
def load_excluded_containers() -> List[str]:
|
||||
@@ -274,30 +355,83 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
|
||||
|
||||
# ---------- HTML ----------
|
||||
INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./templates/index.html")
|
||||
def load_index_html(token: str) -> str:
|
||||
def load_index_html() -> str:
|
||||
"""Загружает HTML шаблон главной страницы"""
|
||||
with open(INDEX_PATH, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
return html.replace("__TOKEN__", token)
|
||||
return f.read()
|
||||
|
||||
def load_login_html() -> str:
|
||||
"""Загружает HTML шаблон страницы входа"""
|
||||
login_path = "./templates/login.html"
|
||||
with open(login_path, "r", encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
# ---------- ROUTES ----------
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index(creds: HTTPBasicCredentials = Depends(check_basic)):
|
||||
token = token_from_creds(creds)
|
||||
return HTMLResponse(
|
||||
content=load_index_html(token),
|
||||
headers={
|
||||
"Cache-Control": "no-cache, no-store, must-revalidate",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0"
|
||||
}
|
||||
)
|
||||
async def index(request: Request):
|
||||
"""Главная страница приложения"""
|
||||
# Проверяем наличие токена в cookie
|
||||
access_token = request.cookies.get("access_token")
|
||||
if not access_token:
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
# Проверяем валидность токена
|
||||
username = verify_token(access_token)
|
||||
if not username:
|
||||
return RedirectResponse(url="/login")
|
||||
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
@app.get("/login", response_class=HTMLResponse)
|
||||
async def login_page(request: Request):
|
||||
"""Страница входа"""
|
||||
return templates.TemplateResponse("login.html", {"request": request})
|
||||
|
||||
@app.post("/api/auth/login", response_model=Token)
|
||||
async def login(user_data: UserLogin, response: Response):
|
||||
"""API для входа в систему"""
|
||||
if authenticate_user(user_data.username, user_data.password):
|
||||
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
access_token = create_access_token(
|
||||
data={"sub": user_data.username}, expires_delta=access_token_expires
|
||||
)
|
||||
|
||||
# Устанавливаем cookie с токеном
|
||||
response.set_cookie(
|
||||
key="access_token",
|
||||
value=access_token,
|
||||
httponly=True,
|
||||
secure=False, # Установите True для HTTPS
|
||||
samesite="lax",
|
||||
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||
)
|
||||
|
||||
return {"access_token": access_token, "token_type": "bearer"}
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверное имя пользователя или пароль",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@app.post("/api/auth/logout")
|
||||
async def logout(response: Response):
|
||||
"""API для выхода из системы"""
|
||||
response.delete_cookie(key="access_token")
|
||||
return {"message": "Успешный выход из системы"}
|
||||
|
||||
@app.get("/api/auth/me")
|
||||
async def get_current_user_info(current_user: str = Depends(get_current_user)):
|
||||
"""Получить информацию о текущем пользователе"""
|
||||
return {"username": current_user}
|
||||
|
||||
@app.get("/healthz", response_class=PlainTextResponse)
|
||||
def healthz():
|
||||
"""Health check endpoint"""
|
||||
return "ok"
|
||||
|
||||
@app.get("/api/logs/stats/{container_id}")
|
||||
def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_basic)):
|
||||
def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)):
|
||||
"""Получить статистику логов контейнера"""
|
||||
try:
|
||||
# Ищем контейнер
|
||||
@@ -344,10 +478,8 @@ def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_ba
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
@app.get("/api/excluded-containers")
|
||||
def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
|
||||
"""
|
||||
Получить список исключенных контейнеров
|
||||
"""
|
||||
def api_get_excluded_containers(current_user: str = Depends(get_current_user)):
|
||||
"""Получить список исключенных контейнеров"""
|
||||
return JSONResponse(
|
||||
content={"excluded_containers": load_excluded_containers()},
|
||||
headers={
|
||||
@@ -360,11 +492,9 @@ def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
|
||||
@app.post("/api/excluded-containers")
|
||||
def api_update_excluded_containers(
|
||||
containers: List[str] = Body(...),
|
||||
_: HTTPBasicCredentials = Depends(check_basic)
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""
|
||||
Обновить список исключенных контейнеров
|
||||
"""
|
||||
"""Обновить список исключенных контейнеров"""
|
||||
success = save_excluded_containers(containers)
|
||||
if success:
|
||||
return JSONResponse(
|
||||
@@ -379,7 +509,7 @@ def api_update_excluded_containers(
|
||||
raise HTTPException(status_code=500, detail="Ошибка сохранения списка")
|
||||
|
||||
@app.get("/api/projects")
|
||||
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
|
||||
def api_projects(current_user: str = Depends(get_current_user)):
|
||||
"""Получить список всех проектов Docker Compose"""
|
||||
return JSONResponse(
|
||||
content=get_all_projects(),
|
||||
@@ -391,12 +521,12 @@ def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
|
||||
)
|
||||
|
||||
@app.get("/api/services")
|
||||
def api_services(projects: Optional[str] = Query(None), include_stopped: bool = Query(False),
|
||||
_: HTTPBasicCredentials = Depends(check_basic)):
|
||||
"""
|
||||
Получить список контейнеров с поддержкой множественных проектов
|
||||
projects: список проектов через запятую (например: "project1,project2")
|
||||
"""
|
||||
def api_services(
|
||||
projects: Optional[str] = Query(None),
|
||||
include_stopped: bool = Query(False),
|
||||
current_user: str = Depends(get_current_user)
|
||||
):
|
||||
"""Получить список контейнеров с поддержкой множественных проектов"""
|
||||
project_list = None
|
||||
if projects:
|
||||
project_list = [p.strip() for p in projects.split(",") if p.strip()]
|
||||
@@ -416,11 +546,12 @@ def api_services(projects: Optional[str] = Query(None), include_stopped: bool =
|
||||
|
||||
@app.post("/api/snapshot")
|
||||
def api_snapshot(
|
||||
creds: HTTPBasicCredentials = Depends(check_basic),
|
||||
current_user: str = Depends(get_current_user),
|
||||
container_id: str = Body(..., embed=True),
|
||||
service: str = Body("", embed=True),
|
||||
content: str = Body("", embed=True),
|
||||
):
|
||||
"""Сохранить снимок логов"""
|
||||
# Save posted content as a snapshot file
|
||||
safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12])
|
||||
ts = os.getenv("TZ_TS") or ""
|
||||
@@ -434,19 +565,23 @@ def api_snapshot(
|
||||
return {"file": fname, "url": url}
|
||||
|
||||
# WebSocket: verify token (?token=base64(user:pass))
|
||||
|
||||
# WebSocket: verify token (?token=base64(user:pass)). Supports auto-reconnect by service label.
|
||||
@app.websocket("/ws/logs/{container_id}")
|
||||
async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||
service: Optional[str] = None, project: Optional[str] = None):
|
||||
"""Упрощенный WebSocket для получения логов контейнера"""
|
||||
"""WebSocket для получения логов контейнера"""
|
||||
|
||||
# Принимаем соединение
|
||||
await ws.accept()
|
||||
|
||||
# Проверяем токен
|
||||
if not token or not verify_ws_token(token):
|
||||
await ws.send_text("ERROR: unauthorized")
|
||||
if not token:
|
||||
await ws.send_text("ERROR: token required")
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
await ws.send_text("ERROR: invalid token")
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
@@ -499,20 +634,23 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
|
||||
except:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
print(f"LogBoard+ http://0.0.0.0:{APP_PORT}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)
|
||||
|
||||
|
||||
# WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id
|
||||
@app.websocket("/ws/fan/{service_name}")
|
||||
async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||
project: Optional[str] = None):
|
||||
await ws.accept()
|
||||
if not token or not verify_ws_token(token):
|
||||
await ws.send_text("ERROR: unauthorized")
|
||||
await ws.close(); return
|
||||
|
||||
# Проверяем токен
|
||||
if not token:
|
||||
await ws.send_text("ERROR: token required")
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
await ws.send_text("ERROR: invalid token")
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
# Track active streaming tasks by container id
|
||||
active = {}
|
||||
@@ -589,15 +727,23 @@ async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, tok
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# WebSocket: fan-in for multiple compose services (comma-separated), optional project filter.
|
||||
@app.websocket("/ws/fan_group")
|
||||
async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
|
||||
project: Optional[str] = None):
|
||||
await ws.accept()
|
||||
if not token or not verify_ws_token(token):
|
||||
await ws.send_text("ERROR: unauthorized")
|
||||
await ws.close(); return
|
||||
|
||||
# Проверяем токен
|
||||
if not token:
|
||||
await ws.send_text("ERROR: token required")
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
username = verify_token(token)
|
||||
if not username:
|
||||
await ws.send_text("ERROR: invalid token")
|
||||
await ws.close()
|
||||
return
|
||||
|
||||
svc_set = {s.strip() for s in services.split(",") if s.strip()}
|
||||
if not svc_set:
|
||||
@@ -673,3 +819,8 @@ async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, t
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
print(f"LogBoard+ http://0.0.0.0:{APP_PORT}")
|
||||
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)
|
||||
|
||||
Reference in New Issue
Block a user