chore: 部署门控 — 今日 changelog/audit、bandit nosec、shell CRLF
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user