Files
Nexus/server/infrastructure/btpanel/client.py
T
r 82426b1941 feat(btpanel): 独立宝塔模块与 SSH 自动获取 API
新增侧栏「宝塔面板」菜单、代理 API 与连接配置页;通过 SSH 读写子机
api.json 自动写入凭据,5 分钟后台重试,含审计修复、检测缓存与并发锁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 09:13:14 +08:00

105 lines
3.7 KiB
Python

"""Baota panel HTTP client — signed POST with Redis-backed session cookies."""
from __future__ import annotations
import hashlib
import json
import logging
import time
from typing import Any
from urllib.parse import urlencode
import httpx
from server.infrastructure.btpanel.credentials import BtPanelCredentials
from server.infrastructure.redis.client import get_redis
logger = logging.getLogger("nexus.btpanel.client")
_COOKIE_TTL = 86400
class BtPanelApiError(Exception):
def __init__(self, message: str, *, status_code: int | None = None, body: str | None = None):
super().__init__(message)
self.status_code = status_code
self.body = body
class BtPanelClient:
def __init__(self, creds: BtPanelCredentials, server_id: int):
self.creds = creds
self.server_id = server_id
def _sign(self) -> dict[str, int | str]:
now = int(time.time())
key_md5 = hashlib.md5(self.creds.api_key.encode()).hexdigest() # nosec B324 — 宝塔 API 协议要求
token = hashlib.md5(f"{now}{key_md5}".encode()).hexdigest() # nosec B324 — 宝塔 API 协议要求
return {"request_time": now, "request_token": token}
def _cookie_key(self) -> str:
return f"btpanel:cookie:{self.server_id}"
async def _load_cookies(self) -> dict[str, str]:
raw = await get_redis().get(self._cookie_key())
if not raw:
return {}
try:
data = json.loads(raw)
return data if isinstance(data, dict) else {}
except json.JSONDecodeError:
return {}
async def _save_cookies(self, jar: httpx.Cookies) -> None:
cookies = {name: value for name, value in jar.items()}
await get_redis().setex(self._cookie_key(), _COOKIE_TTL, json.dumps(cookies))
async def post(self, path: str, data: dict[str, Any] | None = None) -> Any:
"""POST to panel path like ``/system?action=GetSystemTotal``."""
payload = {**(data or {}), **self._sign()}
url = f"{self.creds.base_url}{path}"
stored = await self._load_cookies()
cookies = httpx.Cookies()
for name, value in stored.items():
cookies.set(name, value)
async with httpx.AsyncClient(verify=self.creds.verify_ssl, timeout=60.0) as client:
response = await client.post(url, data=payload, cookies=cookies)
# merge Set-Cookie
if response.cookies:
for name, value in response.cookies.items():
cookies.set(name, value)
await self._save_cookies(cookies)
text = response.text or ""
if response.status_code >= 400:
raise BtPanelApiError(
f"宝塔 API HTTP {response.status_code}",
status_code=response.status_code,
body=text[:500],
)
# Some endpoints return HTML login page when auth fails
stripped = text.lstrip()
if stripped.startswith("<") or stripped.lower().startswith("<!doctype"):
raise BtPanelApiError(
"宝塔返回登录页(检查 API 密钥、IP 白名单或面板地址)",
status_code=response.status_code,
body=text[:200],
)
try:
return response.json()
except json.JSONDecodeError as exc:
raise BtPanelApiError(
"宝塔响应非 JSON",
status_code=response.status_code,
body=text[:500],
) from exc
async def post_form_path(self, path: str, query: dict[str, str], body: dict[str, Any] | None = None) -> Any:
qs = urlencode(query)
full = f"{path}?{qs}" if qs else path
return await self.post(full, body)