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:
2026-05-09 15:02:04 +08:00
commit 848f804169
55 changed files with 5941 additions and 0 deletions
View File
+128
View File
@@ -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
+28
View File
@@ -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)
+137
View File
@@ -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):
"""关闭 handlerflush 剩余日志"""
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()