"""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(" Any: qs = urlencode(query) full = f"{path}?{qs}" if qs else path return await self.post(full, body)