Files
Nexus/server/infrastructure/btpanel/client.py
T
r 51b6d0cdff fix(btpanel): API 签名改为 md5(time+token)
与面板 common.py 一致;原双重 md5 导致密钥校验失败。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-21 10:05:39 +08:00

105 lines
3.6 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())
# 宝塔 common.py: md5(request_time + api_token),非 md5(token) 后再签
token = hashlib.md5(f"{now}{self.creds.api_key}".encode()).hexdigest() # nosec B324
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)