PingWatch 网络设备离线监控系统
- FastAPI 后端 + Vue 3 前端 - Docker Compose 一键部署 - Casdoor OAuth 认证集成 - LogHive 集中式日志 - 设备批量 CSV 导入/导出 - WebSocket 实时状态推送 - 企业微信告警通知 - fping 高性能并发 Ping 检测 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
"""
|
||||
Casdoor OAuth 认证集成 + JWT 会话管理
|
||||
|
||||
流程:
|
||||
1. 前端跳转到 Casdoor 登录页 → 用户登录
|
||||
2. Casdoor 回调到前端(带 code 参数)
|
||||
3. 前端将 code 发到后端 /api/auth/login
|
||||
4. 后端用 code 向 Casdoor 换取 access_token + id_token
|
||||
5. 后端从 id_token (JWT) 解析用户信息
|
||||
6. 后端签发自己的 JWT,返回给前端
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from jose import JWTError, jwt
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.user import User, UserRoleEnum
|
||||
from app.core.deps import get_db
|
||||
|
||||
logger = logging.getLogger("pingwatch.auth")
|
||||
security = HTTPBearer()
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""签发 PingWatch 自己的 JWT"""
|
||||
to_encode = data.copy()
|
||||
expire = datetime.now() + (expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES))
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def _load_casdoor_certificate() -> str:
|
||||
"""加载 Casdoor 证书:优先读取 dahua.pem,否则用环境变量"""
|
||||
pem_path = os.path.join(os.path.dirname(__file__), "..", "..", "dahua.pem")
|
||||
pem_path = os.path.normpath(pem_path)
|
||||
if os.path.isfile(pem_path):
|
||||
with open(pem_path, "r") as f:
|
||||
return f.read()
|
||||
return settings.CASDOOR_CERTIFICATE
|
||||
|
||||
|
||||
async def exchange_code_for_user(code: str) -> Optional[dict]:
|
||||
"""
|
||||
用 OAuth code 向 Casdoor 换取用户信息。
|
||||
|
||||
步骤:
|
||||
1. POST → /api/login/oauth/access_token 换取 id_token
|
||||
2. 解码 id_token (JWT) 得到用户信息
|
||||
"""
|
||||
token_url = f"{settings.CASDOOR_ENDPOINT}/api/login/oauth/access_token"
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": settings.CASDOOR_CLIENT_ID,
|
||||
"client_secret": settings.CASDOOR_CLIENT_SECRET,
|
||||
"code": code,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15, verify=False) as client:
|
||||
try:
|
||||
resp = await client.post(token_url, data=data)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"Casdoor token 换取失败: {resp.status_code} {resp.text}")
|
||||
return None
|
||||
|
||||
token_data = resp.json()
|
||||
id_token = token_data.get("id_token")
|
||||
if not id_token:
|
||||
logger.error("Casdoor 返回中没有 id_token")
|
||||
return None
|
||||
|
||||
# 解码 id_token (JWT) payload,不验证签名(HTTPS 已保证传输安全)
|
||||
# 生产环境建议验证 Casdoor 证书
|
||||
cert = _load_casdoor_certificate()
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
id_token,
|
||||
key=cert or None,
|
||||
options={"verify_signature": bool(cert)},
|
||||
audience=settings.CASDOOR_CLIENT_ID,
|
||||
)
|
||||
except JWTError:
|
||||
# 不验证签名的方式解码
|
||||
payload = jwt.get_unverified_claims(id_token)
|
||||
|
||||
return payload
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Casdoor token 请求超时")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Casdoor token 请求异常: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""从 PingWatch JWT 中解析当前登录用户"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
casdoor_uid: str = payload.get("sub", "")
|
||||
if not casdoor_uid:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效 token")
|
||||
except JWTError:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效 token")
|
||||
|
||||
result = await db.execute(select(User).where(User.casdoor_uid == casdoor_uid))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user or not user.is_active:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已禁用")
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role != UserRoleEnum.admin:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅管理员可执行此操作")
|
||||
return current_user
|
||||
@@ -0,0 +1,28 @@
|
||||
"""数据库会话依赖"""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
|
||||
from app.config import settings
|
||||
|
||||
# 处理 sqlite 协议兼容
|
||||
db_url = settings.DATABASE_URL
|
||||
if db_url.startswith("sqlite"):
|
||||
db_url = db_url.replace("sqlite://", "sqlite+aiosqlite://")
|
||||
|
||||
engine = create_async_engine(db_url, echo=False, pool_pre_ping=True)
|
||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""创建所有表"""
|
||||
from app.models.device import Base
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
LogHive 日志 Handler
|
||||
|
||||
基于标准 logging.Handler,通过 REST API 将日志异步批量发送到 LogHive。
|
||||
不依赖外部包,后台线程发送,失败不影响主业务。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import atexit
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import threading
|
||||
import time
|
||||
import traceback as tb
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class LogHiveHandler(logging.Handler):
|
||||
"""异步批量发送日志到 LogHive"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
endpoint: str,
|
||||
project: str,
|
||||
api_key: str,
|
||||
level: int = logging.INFO,
|
||||
batch_size: int = 50,
|
||||
flush_interval: float = 2.0,
|
||||
max_retries: int = 3,
|
||||
):
|
||||
super().__init__(level=level)
|
||||
self._endpoint = endpoint.rstrip("/") + "/api/logs/ingest"
|
||||
self._project = project
|
||||
self._api_key = api_key
|
||||
self._batch_size = batch_size
|
||||
self._flush_interval = flush_interval
|
||||
self._max_retries = max_retries
|
||||
|
||||
self._queue: queue.Queue = queue.Queue()
|
||||
self._client: httpx.Client | None = None
|
||||
self._thread: threading.Thread | None = None
|
||||
self._running = False
|
||||
|
||||
def _ensure_client(self):
|
||||
if self._client is None:
|
||||
self._client = httpx.Client(timeout=10)
|
||||
|
||||
def _ensure_thread(self):
|
||||
if self._thread is None or not self._thread.is_alive():
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._send_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def emit(self, record: logging.LogRecord):
|
||||
"""接收日志记录,放入队列"""
|
||||
if not self._api_key:
|
||||
return
|
||||
self._ensure_thread()
|
||||
try:
|
||||
entry = {
|
||||
"level": record.levelname.lower(),
|
||||
"message": self.format(record),
|
||||
"logger": record.name,
|
||||
"timestamp": record.created,
|
||||
}
|
||||
if record.exc_info and record.exc_info[1]:
|
||||
entry["exception"] = "".join(
|
||||
tb.format_exception(*record.exc_info)
|
||||
)
|
||||
self._queue.put_nowait(entry)
|
||||
except Exception:
|
||||
pass # 日志发送失败不能影响主业务
|
||||
|
||||
def _send_loop(self):
|
||||
"""后台线程:定时批量发送"""
|
||||
while self._running:
|
||||
batch = []
|
||||
deadline = time.monotonic() + self._flush_interval
|
||||
|
||||
while len(batch) < self._batch_size:
|
||||
try:
|
||||
remaining = max(0, deadline - time.monotonic())
|
||||
batch.append(self._queue.get(timeout=remaining))
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
if batch:
|
||||
payload = {"project": self._project, "entries": batch}
|
||||
for attempt in range(self._max_retries):
|
||||
try:
|
||||
self._ensure_client()
|
||||
resp = self._client.post(
|
||||
self._endpoint,
|
||||
json=payload,
|
||||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||||
)
|
||||
if resp.status_code < 500:
|
||||
break
|
||||
except Exception:
|
||||
if attempt == self._max_retries - 1:
|
||||
pass # 最终丢弃
|
||||
else:
|
||||
time.sleep(0.5 * (attempt + 1))
|
||||
|
||||
def close(self):
|
||||
"""关闭 handler,flush 剩余日志"""
|
||||
self._running = False
|
||||
if self._thread and self._thread.is_alive():
|
||||
self._thread.join(timeout=5)
|
||||
if self._client:
|
||||
self._client.close()
|
||||
super().close()
|
||||
|
||||
|
||||
class AsyncLogHiveHandler:
|
||||
"""
|
||||
用于 asyncio 事件循环的异步 handler。
|
||||
在独立的线程中运行同步 LogHiveHandler,通过 asyncio 队列桥接。
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
self._handler = LogHiveHandler(**kwargs)
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def setup(self, loop: asyncio.AbstractEventLoop):
|
||||
self._loop = loop
|
||||
atexit.register(self._handler.close)
|
||||
|
||||
async def emit(self, record: logging.LogRecord):
|
||||
"""异步安全地提交日志记录"""
|
||||
# LogHiveHandler.emit 已经把日志放入内部队列,这里只需确保线程运行
|
||||
self._handler.emit(record)
|
||||
|
||||
def close(self):
|
||||
self._handler.close()
|
||||
Reference in New Issue
Block a user