doctor.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. """Environment doctor checks for zhuce6."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. import importlib
  5. import os
  6. from pathlib import Path
  7. import shutil
  8. import subprocess
  9. import sys
  10. import tempfile
  11. from typing import Iterable
  12. from urllib.parse import urlparse
  13. from core.cfmail import enabled_cfmail_accounts
  14. from core.settings import AppSettings
  15. from dashboard.api import _cpa_dependency_payload, _sub2api_dependency_payload
  16. @dataclass(frozen=True)
  17. class DoctorCheck:
  18. name: str
  19. status: str
  20. summary: str
  21. detail: str = ""
  22. required_for: tuple[str, ...] = ("lite", "full")
  23. @dataclass(frozen=True)
  24. class DoctorReport:
  25. settings: AppSettings
  26. checks: tuple[DoctorCheck, ...]
  27. lite_available: bool
  28. full_available: bool
  29. full_cpa_available: bool
  30. full_sub2api_available: bool
  31. def sslocal_install_guidance() -> str:
  32. return "\n".join(
  33. [
  34. "如果你需要 SS 节点代理池, 请安装 shadowsocks-rust:",
  35. "",
  36. "Linux:",
  37. "Linux (Debian/Ubuntu):",
  38. " curl -fsSL https://github.com/shadowsocks/shadowsocks-rust/releases/latest/download/shadowsocks-v*-x86_64-unknown-linux-gnu.tar.xz | tar -xJ -C /usr/local/bin sslocal",
  39. "",
  40. "macOS:",
  41. " brew install shadowsocks-rust",
  42. "",
  43. "Windows:",
  44. " 下载: https://github.com/shadowsocks/shadowsocks-rust/releases/latest",
  45. " 选择 shadowsocks-*-x86_64-pc-windows-msvc.zip, 解压 sslocal.exe 到 PATH",
  46. "",
  47. "如果你已有代理 (Clash/V2Ray), 可以跳过安装:",
  48. " 在 .env 中设置: ZHUCE6_PROXY_POOL_DIRECT_URLS=socks5://127.0.0.1:7891",
  49. ]
  50. )
  51. def _project_root(settings: AppSettings | None = None) -> Path:
  52. return (settings.project_root if settings is not None else AppSettings.from_env().project_root).resolve()
  53. def apply_doctor_fixes(settings: AppSettings | None = None) -> list[str]:
  54. active_settings = settings or AppSettings.from_env()
  55. repo_root = _project_root(active_settings)
  56. actions: list[str] = []
  57. subprocess.run(["uv", "sync"], cwd=str(repo_root), check=True)
  58. actions.append(f"uv sync @ {repo_root}")
  59. worker_dir = repo_root / "vendor" / "cfmail-worker" / "worker"
  60. if (worker_dir / "package.json").is_file():
  61. subprocess.run(["npm", "install", "--no-fund", "--no-audit"], cwd=str(worker_dir), check=True)
  62. actions.append(f"npm install @ {worker_dir}")
  63. return actions
  64. def _check_python_version(_settings: AppSettings) -> DoctorCheck:
  65. current = sys.version_info
  66. required = (3, 11)
  67. if current >= required:
  68. return DoctorCheck(
  69. name="python",
  70. status="ok",
  71. summary=f"Python {current.major}.{current.minor}.{current.micro} 满足 >= 3.11",
  72. )
  73. return DoctorCheck(
  74. name="python",
  75. status="error",
  76. summary=f"Python {current.major}.{current.minor}.{current.micro} 低于 >= 3.11",
  77. )
  78. def _check_env_file(settings: AppSettings) -> DoctorCheck:
  79. if not settings.env_file.exists():
  80. return DoctorCheck("env", "error", f".env 不存在: {settings.env_file}")
  81. try:
  82. settings.env_file.read_text(encoding="utf-8")
  83. except OSError as exc:
  84. return DoctorCheck("env", "error", f".env 无法读取: {exc}")
  85. return DoctorCheck("env", "ok", f".env 可读取: {settings.env_file}")
  86. def _check_core_dependencies(_settings: AppSettings) -> DoctorCheck:
  87. modules = {
  88. "fastapi": "fastapi",
  89. "uvicorn": "uvicorn",
  90. "PyYAML": "yaml",
  91. "httpx": "httpx",
  92. "curl_cffi": "curl_cffi",
  93. "sqlmodel": "sqlmodel",
  94. "cbor2": "cbor2",
  95. "jwcrypto": "jwcrypto",
  96. "filelock": "filelock",
  97. "psutil": "psutil",
  98. "socksio": "socksio",
  99. }
  100. missing: list[str] = []
  101. for display_name, module_name in modules.items():
  102. try:
  103. importlib.import_module(module_name)
  104. except ModuleNotFoundError:
  105. missing.append(display_name)
  106. if missing:
  107. return DoctorCheck("deps", "error", f"缺少核心依赖: {', '.join(missing)}")
  108. return DoctorCheck("deps", "ok", "核心依赖齐全")
  109. def _check_cfmail(settings: AppSettings) -> DoctorCheck:
  110. providers = {part.strip().lower() for part in settings.register_mail_provider.split(",") if part.strip()}
  111. if "cfmail" not in providers:
  112. return DoctorCheck("cfmail", "skip", "register 未启用 cfmail", required_for=())
  113. missing = settings.validate_cfmail_env()
  114. if missing:
  115. return DoctorCheck("cfmail", "error", f"cfmail 缺少环境变量: {', '.join(missing)}")
  116. configured_path = Path(
  117. str(os.getenv("ZHUCE6_CFMAIL_CONFIG_PATH", str(settings.config_dir / "cfmail_accounts.json")))
  118. ).expanduser().resolve()
  119. accounts = enabled_cfmail_accounts(configured_path)
  120. if not accounts:
  121. return DoctorCheck("cfmail", "error", f"cfmail 账号配置为空: {configured_path}")
  122. active = accounts[0]
  123. return DoctorCheck(
  124. "cfmail",
  125. "ok",
  126. f"cfmail 已配置: {active.name} -> {active.email_domain}",
  127. detail=str(configured_path),
  128. )
  129. def _check_proxy(settings: AppSettings) -> DoctorCheck:
  130. direct_proxy = str(settings.register_proxy or "").strip()
  131. direct_urls = str(settings.proxy_pool_direct_urls or "").strip()
  132. config_path = settings.proxy_pool_config
  133. socks_proxies: list[str] = []
  134. if direct_proxy and _is_socks_proxy_url(direct_proxy):
  135. socks_proxies.append(direct_proxy)
  136. if direct_urls:
  137. socks_proxies.extend(
  138. [item.strip() for item in direct_urls.split(";") if item.strip() and _is_socks_proxy_url(item.strip())]
  139. )
  140. if socks_proxies and not _has_socksio():
  141. return DoctorCheck(
  142. "proxy",
  143. "error",
  144. "已配置 SOCKS 代理, 但缺少 SOCKS 支持依赖",
  145. detail=f"缺少 Python 包: socksio\n请先运行: uv sync\n检测到的 SOCKS 代理: {', '.join(socks_proxies)}",
  146. )
  147. if direct_proxy:
  148. return DoctorCheck("proxy", "ok", f"register 代理已配置: {direct_proxy}")
  149. if direct_urls:
  150. count = len([item for item in direct_urls.split(";") if item.strip()])
  151. return DoctorCheck("proxy", "ok", f"direct proxy URLs 已配置: {count} 条")
  152. if config_path:
  153. if not Path(config_path).exists():
  154. return DoctorCheck("proxy", "error", f"代理池配置不存在: {config_path}")
  155. return DoctorCheck("proxy", "ok", f"代理池配置存在: {config_path}")
  156. return DoctorCheck("proxy", "error", "未配置 register_proxy, direct proxy URLs 或 proxy pool config")
  157. def _is_socks_proxy_url(proxy_url: str) -> bool:
  158. scheme = urlparse(str(proxy_url or "").strip()).scheme.lower()
  159. return scheme.startswith("socks")
  160. def _has_socksio() -> bool:
  161. try:
  162. importlib.import_module("socksio")
  163. except ModuleNotFoundError:
  164. return False
  165. return True
  166. def _touch_directory(path: Path) -> tuple[bool, str]:
  167. try:
  168. path.mkdir(parents=True, exist_ok=True)
  169. with tempfile.NamedTemporaryFile(prefix=".doctor-", dir=path, delete=True):
  170. pass
  171. except OSError as exc:
  172. return False, str(exc)
  173. return True, "ok"
  174. def _check_directory_writable(settings: AppSettings) -> DoctorCheck:
  175. targets: list[Path] = [
  176. settings.config_dir,
  177. settings.state_dir,
  178. settings.log_dir,
  179. settings.pool_dir,
  180. settings.env_file.parent,
  181. ]
  182. failures: list[str] = []
  183. for directory in targets:
  184. ok, detail = _touch_directory(directory)
  185. if not ok:
  186. failures.append(f"{directory}: {detail}")
  187. if failures:
  188. return DoctorCheck("dirs", "error", "目录不可写", detail="; ".join(failures))
  189. return DoctorCheck("dirs", "ok", "核心目录可写")
  190. def _check_sslocal(settings: AppSettings) -> DoctorCheck:
  191. if settings.proxy_pool_direct_urls.strip():
  192. return DoctorCheck("sslocal", "skip", "使用 direct proxy URLs, 不依赖 sslocal", required_for=())
  193. if not settings.proxy_pool_config:
  194. return DoctorCheck("sslocal", "skip", "未启用基于配置文件的代理池", required_for=())
  195. sslocal_bin = shutil.which("sslocal") or shutil.which("ss-local")
  196. if sslocal_bin:
  197. return DoctorCheck("sslocal", "ok", f"sslocal 可用: {sslocal_bin}")
  198. return DoctorCheck(
  199. "sslocal",
  200. "error",
  201. "未安装 sslocal",
  202. detail=sslocal_install_guidance(),
  203. )
  204. def _check_cpa_management(settings: AppSettings) -> DoctorCheck:
  205. payload = _cpa_dependency_payload(settings)
  206. if settings.runtime_mode == "lite":
  207. return DoctorCheck("cpa", "skip", "lite 模式不检查 CPA", required_for=())
  208. if settings.backend != "cpa":
  209. return DoctorCheck("cpa", "skip", "当前 backend 不是 cpa", required_for=())
  210. if bool(payload.get("management_reachable")):
  211. return DoctorCheck("cpa", "ok", "CPA management 可达", required_for=("full",))
  212. return DoctorCheck(
  213. "cpa",
  214. "error",
  215. "CPA management 不可达",
  216. detail=f"management_reachable={payload.get('management_reachable', False)}",
  217. required_for=("full",),
  218. )
  219. def _check_sub2api(settings: AppSettings) -> DoctorCheck:
  220. payload = _sub2api_dependency_payload(settings)
  221. if settings.runtime_mode == "lite":
  222. return DoctorCheck("sub2api", "skip", "lite 模式不检查 sub2api", required_for=())
  223. if settings.backend != "sub2api":
  224. return DoctorCheck("sub2api", "skip", "当前 backend 不是 sub2api", required_for=())
  225. if payload.get("status") == "ok":
  226. return DoctorCheck("sub2api", "ok", "sub2api 可达", detail=str(payload.get("base_url") or settings.sub2api_base_url), required_for=("full",))
  227. error = str(payload.get("error") or "unreachable")
  228. auth_configured = bool(payload.get("auth_configured"))
  229. return DoctorCheck(
  230. "sub2api",
  231. "error",
  232. f"sub2api 不可用: {error}",
  233. detail=f"base_url={settings.sub2api_base_url}\nauth_configured={auth_configured}",
  234. required_for=("full",),
  235. )
  236. def _is_lite_available(checks: Iterable[DoctorCheck]) -> bool:
  237. relevant_names = {"python", "env", "deps", "cfmail", "proxy", "dirs", "sslocal"}
  238. relevant = [check for check in checks if check.name in relevant_names and check.status != "skip"]
  239. return all(check.status == "ok" for check in relevant)
  240. def _is_full_cpa_available(checks: Iterable[DoctorCheck]) -> bool:
  241. if not _is_lite_available(checks):
  242. return False
  243. relevant = [check for check in checks if check.name == "cpa" and check.status != "skip"]
  244. return all(check.status == "ok" for check in relevant) and bool(relevant)
  245. def _is_full_sub2api_available(checks: Iterable[DoctorCheck]) -> bool:
  246. if not _is_lite_available(checks):
  247. return False
  248. relevant = [check for check in checks if check.name == "sub2api" and check.status != "skip"]
  249. return all(check.status == "ok" for check in relevant) and bool(relevant)
  250. def collect_doctor_report(settings: AppSettings | None = None) -> DoctorReport:
  251. active_settings = settings or AppSettings.from_env()
  252. checks = (
  253. _check_python_version(active_settings),
  254. _check_env_file(active_settings),
  255. _check_core_dependencies(active_settings),
  256. _check_cfmail(active_settings),
  257. _check_proxy(active_settings),
  258. _check_directory_writable(active_settings),
  259. _check_sslocal(active_settings),
  260. _check_cpa_management(active_settings),
  261. _check_sub2api(active_settings),
  262. )
  263. lite_available = _is_lite_available(checks)
  264. full_cpa_available = _is_full_cpa_available(checks)
  265. full_sub2api_available = _is_full_sub2api_available(checks)
  266. return DoctorReport(
  267. settings=active_settings,
  268. checks=checks,
  269. lite_available=lite_available,
  270. full_available=full_cpa_available if active_settings.backend == "cpa" else full_sub2api_available if active_settings.backend == "sub2api" else False,
  271. full_cpa_available=full_cpa_available,
  272. full_sub2api_available=full_sub2api_available,
  273. )
  274. def format_doctor_report(report: DoctorReport) -> str:
  275. lines = [
  276. "zhuce6 doctor",
  277. f"env_file: {report.settings.env_file}",
  278. "",
  279. ]
  280. for check in report.checks:
  281. lines.append(f"- {check.name:<8} {check.status:<5} {check.summary}")
  282. if check.detail:
  283. for detail_line in str(check.detail).splitlines():
  284. lines.append(f" {detail_line}" if detail_line else "")
  285. lines.extend(
  286. [
  287. "",
  288. "conclusion:",
  289. f"- lite: {'available' if report.lite_available else 'unavailable'}",
  290. f"- full: {'available' if report.full_available else 'unavailable'}",
  291. f"- full(cpa): {'available' if report.full_cpa_available else 'unavailable'}",
  292. f"- full(sub2api): {'available' if report.full_sub2api_available else 'unavailable'}",
  293. ]
  294. )
  295. return "\n".join(lines)