Files
Nexus/server/infrastructure/btpanel/client.py
T

106 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 http.cookies import SimpleCookie
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)