51b6d0cdff
与面板 common.py 一致;原双重 md5 导致密钥校验失败。 Co-authored-by: Cursor <cursoragent@cursor.com>
105 lines
3.6 KiB
Python
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)
|