common.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  1. """Shared helpers for zhuce6 operations."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. from datetime import datetime
  6. from pathlib import Path
  7. import subprocess
  8. from urllib.error import HTTPError, URLError
  9. from urllib.parse import urlencode
  10. from urllib.request import Request, urlopen
  11. import uuid
  12. PROJECT_DIR = Path(__file__).resolve().parents[1]
  13. DEFAULT_POOL_DIR = PROJECT_DIR / "pool"
  14. DEFAULT_MANAGEMENT_BASE_URL = "http://127.0.0.1:8317/v0/management"
  15. def now() -> str:
  16. return datetime.now().strftime("%H:%M:%S")
  17. def run_command(args: list[str], timeout: int = 30) -> subprocess.CompletedProcess[str]:
  18. return subprocess.run(
  19. args,
  20. capture_output=True,
  21. text=True,
  22. timeout=timeout,
  23. check=False,
  24. )
  25. def get_management_key() -> str | None:
  26. return str(os.getenv("ZHUCE6_CPA_MANAGEMENT_KEY", "")).strip() or None
  27. def _normalize_management_base_url(base_url: str) -> str:
  28. return str(base_url or DEFAULT_MANAGEMENT_BASE_URL).strip().rstrip("/") or DEFAULT_MANAGEMENT_BASE_URL
  29. def cpa_management_request(
  30. method: str,
  31. path: str,
  32. key: str,
  33. *,
  34. management_base_url: str = DEFAULT_MANAGEMENT_BASE_URL,
  35. body: bytes | None = None,
  36. content_type: str | None = None,
  37. timeout: int = 20,
  38. query: dict[str, object] | None = None,
  39. accept: str = "application/json",
  40. ) -> tuple[int, dict | list | str | None]:
  41. """Send a request to the CPA management API.
  42. Returns (http_status_code, parsed_payload_or_text_or_None).
  43. On connection/timeout errors returns (0, None).
  44. """
  45. base_url = _normalize_management_base_url(management_base_url)
  46. url = f"{base_url}/{path.lstrip('/')}"
  47. if query:
  48. encoded_query = urlencode({k: v for k, v in query.items() if v is not None}, doseq=True)
  49. if encoded_query:
  50. url = f"{url}?{encoded_query}"
  51. headers = {"Authorization": f"Bearer {key}", "Accept": accept}
  52. if content_type:
  53. headers["Content-Type"] = content_type
  54. request = Request(url, data=body, headers=headers, method=method.upper())
  55. try:
  56. with urlopen(request, timeout=timeout) as response:
  57. raw = response.read().decode("utf-8")
  58. try:
  59. payload: dict | list | str | None = json.loads(raw)
  60. except json.JSONDecodeError:
  61. payload = raw
  62. return response.status, payload
  63. except HTTPError as exc:
  64. raw = exc.read().decode("utf-8", errors="replace")
  65. try:
  66. payload = json.loads(raw)
  67. except Exception:
  68. payload = raw or None
  69. return exc.code, payload
  70. except (URLError, TimeoutError, OSError):
  71. return 0, None
  72. class CpaClient:
  73. """CPA management HTTP API client."""
  74. def __init__(
  75. self,
  76. base_url: str,
  77. *,
  78. management_key: str | None = None,
  79. timeout: int = 20,
  80. ) -> None:
  81. self.base_url = _normalize_management_base_url(base_url)
  82. self.management_key = str(management_key or "").strip() or None
  83. self.timeout = max(1, int(timeout))
  84. @classmethod
  85. def from_settings(cls, settings: object, *, timeout: int = 20) -> "CpaClient":
  86. return cls(
  87. getattr(settings, "cpa_management_base_url", DEFAULT_MANAGEMENT_BASE_URL),
  88. management_key=getattr(settings, "cpa_management_key", None),
  89. timeout=timeout,
  90. )
  91. def _resolve_key(self) -> str | None:
  92. if self.management_key:
  93. return self.management_key
  94. self.management_key = get_management_key()
  95. return self.management_key
  96. def _request(
  97. self,
  98. method: str,
  99. path: str,
  100. *,
  101. body: bytes | None = None,
  102. content_type: str | None = None,
  103. query: dict[str, object] | None = None,
  104. accept: str = "application/json",
  105. ) -> tuple[int, dict | list | str | None]:
  106. key = self._resolve_key()
  107. if not key:
  108. return 0, None
  109. return cpa_management_request(
  110. method,
  111. path,
  112. key,
  113. management_base_url=self.base_url,
  114. body=body,
  115. content_type=content_type,
  116. timeout=self.timeout,
  117. query=query,
  118. accept=accept,
  119. )
  120. def list_auth_files(self) -> list[dict[str, object]]:
  121. status, payload = self._request("GET", "auth-files")
  122. if status == 0:
  123. return []
  124. if isinstance(payload, dict):
  125. files = payload.get("files", payload.get("auth_files", []))
  126. elif isinstance(payload, list):
  127. files = payload
  128. else:
  129. files = []
  130. return [item for item in files if isinstance(item, dict)]
  131. def get_auth_file(self, name: str) -> dict[str, object] | None:
  132. normalized_name = str(name or "").strip()
  133. if not normalized_name:
  134. return None
  135. status, payload = self._request(
  136. "GET",
  137. "auth-files/download",
  138. query={"name": normalized_name},
  139. accept="application/json, text/plain;q=0.9, */*;q=0.8",
  140. )
  141. if status == 0 or payload is None:
  142. return None
  143. if isinstance(payload, dict):
  144. return payload
  145. if isinstance(payload, str):
  146. try:
  147. parsed = json.loads(payload)
  148. except json.JSONDecodeError:
  149. return None
  150. return parsed if isinstance(parsed, dict) else None
  151. return None
  152. def delete_auth_file(self, name: str) -> bool:
  153. return self.delete_auth_files([name])
  154. def delete_auth_files(self, names: list[str]) -> bool:
  155. normalized_names = [str(name or "").strip() for name in names if str(name or "").strip()]
  156. if not normalized_names:
  157. return True
  158. for normalized_name in normalized_names:
  159. status, payload = self._request(
  160. "DELETE",
  161. "auth-files",
  162. query={"name": normalized_name},
  163. )
  164. if status not in {200, 204}:
  165. preview = str(payload)
  166. if len(preview) > 200:
  167. preview = preview[:200] + "..."
  168. print(f"[{now()}] [警告] CPA delete 失败 | status={status} | files=1 | {preview}")
  169. return False
  170. return True
  171. def delete_all_auth_files(self) -> bool:
  172. status, _payload = self._request("DELETE", "auth-files", query={"all": "true"})
  173. return status in {200, 204}
  174. def upload_auth_file(self, name: str, content: dict[str, object]) -> bool:
  175. normalized_name = str(name or "").strip()
  176. if not normalized_name:
  177. return False
  178. boundary = f"----zhuce6-{uuid.uuid4().hex}"
  179. file_bytes = json.dumps(content, ensure_ascii=False, indent=2).encode("utf-8")
  180. multipart = b"".join(
  181. [
  182. f"--{boundary}\r\n".encode("utf-8"),
  183. f'Content-Disposition: form-data; name="file"; filename="{normalized_name}"\r\n'.encode("utf-8"),
  184. b"Content-Type: application/json\r\n\r\n",
  185. file_bytes,
  186. b"\r\n",
  187. f"--{boundary}--\r\n".encode("utf-8"),
  188. ]
  189. )
  190. status, payload = self._request(
  191. "POST",
  192. "auth-files",
  193. body=multipart,
  194. content_type=f"multipart/form-data; boundary={boundary}",
  195. )
  196. if status not in {200, 201, 204}:
  197. preview = str(payload)
  198. if len(preview) > 200:
  199. preview = preview[:200] + "..."
  200. print(f"[{now()}] [警告] CPA upload 失败 | status={status} | name={normalized_name} | {preview}")
  201. return status in {200, 201, 204}
  202. def restart_container(self) -> bool:
  203. status, _payload = self._request("POST", "restart")
  204. return status in {200, 202, 204}
  205. def api_call(
  206. self,
  207. *,
  208. auth_index: str,
  209. method: str,
  210. url: str,
  211. headers: dict[str, object] | None = None,
  212. body: str | None = None,
  213. timeout: int | None = None,
  214. ) -> dict[str, object]:
  215. payload = {
  216. "authIndex": str(auth_index or "").strip(),
  217. "method": str(method or "GET").strip().upper() or "GET",
  218. "url": str(url or "").strip(),
  219. "header": headers or {},
  220. }
  221. if body is not None:
  222. payload["body"] = body
  223. key = self._resolve_key()
  224. if not key:
  225. return {}
  226. status, response_payload = cpa_management_request(
  227. "POST",
  228. "api-call",
  229. key,
  230. management_base_url=self.base_url,
  231. body=json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8"),
  232. content_type="application/json",
  233. timeout=timeout or max(self.timeout, 60),
  234. )
  235. if status == 0 or not isinstance(response_payload, dict):
  236. return {}
  237. return response_payload
  238. def health_check(self) -> bool:
  239. status, _payload = self._request("GET", "auth-files")
  240. return status in {200, 401, 403}
  241. def create_backend_client(settings):
  242. """根据 settings.backend 创建对应 client."""
  243. backend = str(getattr(settings, "backend", "cpa") or "cpa").strip().lower() or "cpa"
  244. if backend == "sub2api":
  245. from ops.sub2api_adapter import Sub2ApiAdapter
  246. from ops.sub2api_client import Sub2ApiClient
  247. client = Sub2ApiClient(
  248. base_url=getattr(settings, "sub2api_base_url", "http://127.0.0.1:8080"),
  249. admin_email=getattr(settings, "sub2api_admin_email", ""),
  250. admin_password=getattr(settings, "sub2api_admin_password", ""),
  251. api_key=getattr(settings, "sub2api_api_key", ""),
  252. timeout=20,
  253. )
  254. return Sub2ApiAdapter(client)
  255. return CpaClient.from_settings(settings, timeout=20)