setup_wizard.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699
  1. """Interactive environment bootstrap for zhuce6."""
  2. from __future__ import annotations
  3. from dataclasses import dataclass
  4. import os
  5. from pathlib import Path
  6. import secrets
  7. import shutil
  8. from typing import Callable
  9. from urllib.parse import urlparse
  10. import httpx
  11. from core.cfmail import enabled_cfmail_accounts
  12. from core.doctor import sslocal_install_guidance
  13. from scripts import setup_cfmail
  14. InputFn = Callable[[str], str]
  15. PrintFn = Callable[[str], None]
  16. @dataclass(frozen=True)
  17. class SetupWizardResult:
  18. env_file: Path
  19. env_updates: dict[str, object]
  20. cfmail_accounts_path: Path | None = None
  21. cfmail_env_path: Path | None = None
  22. def _load_env_defaults(path: Path) -> dict[str, str]:
  23. values: dict[str, str] = {}
  24. if not path.exists():
  25. return values
  26. for raw_line in path.read_text(encoding="utf-8").splitlines():
  27. line = raw_line.strip()
  28. if not line or line.startswith("#"):
  29. continue
  30. if line.startswith("export "):
  31. line = line[7:]
  32. key, sep, value = line.partition("=")
  33. if not sep:
  34. continue
  35. values[key.strip()] = value.strip().strip('"').strip("'")
  36. return values
  37. def _encode_env_value(value: object) -> str:
  38. text = "" if value is None else str(value)
  39. if not text:
  40. return ""
  41. if any(ch.isspace() for ch in text) or "#" in text:
  42. escaped = text.replace("\\", "\\\\").replace('"', '\\"')
  43. return f'"{escaped}"'
  44. return text
  45. def _persist_env_updates(path: Path, updates: dict[str, object]) -> None:
  46. existing_lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
  47. normalized_updates = {key: _encode_env_value(value) for key, value in updates.items()}
  48. handled: set[str] = set()
  49. output_lines: list[str] = []
  50. for line in existing_lines:
  51. stripped = line.strip()
  52. candidate = stripped[7:] if stripped.startswith("export ") else stripped
  53. key, sep, _value = candidate.partition("=")
  54. if sep and key in normalized_updates:
  55. if key in handled:
  56. continue
  57. output_lines.append(f"{key}={normalized_updates[key]}")
  58. handled.add(key)
  59. continue
  60. output_lines.append(line)
  61. for key, value in normalized_updates.items():
  62. if key not in handled:
  63. output_lines.append(f"{key}={value}")
  64. path.parent.mkdir(parents=True, exist_ok=True)
  65. path.write_text("\n".join(output_lines).rstrip() + "\n", encoding="utf-8")
  66. def _prompt_text(
  67. input_fn: InputFn,
  68. print_fn: PrintFn,
  69. label: str,
  70. *,
  71. default: str = "",
  72. required: bool = False,
  73. ) -> str:
  74. while True:
  75. suffix = f" [{default}]" if default else ""
  76. value = input_fn(f"{label}{suffix}: ").strip()
  77. if value:
  78. return value
  79. if default:
  80. return default
  81. if not required:
  82. return ""
  83. print_fn(f"{label} 不能为空, 请重新输入.")
  84. def _prompt_bool(input_fn: InputFn, print_fn: PrintFn, label: str, *, default: bool) -> bool:
  85. default_text = "Y/n" if default else "y/N"
  86. while True:
  87. raw = input_fn(f"{label} [{default_text}]: ").strip().lower()
  88. if not raw:
  89. return default
  90. if raw in {"y", "yes", "1", "true"}:
  91. return True
  92. if raw in {"n", "no", "0", "false"}:
  93. return False
  94. print_fn("请输入 y 或 n.")
  95. def _prompt_choice(
  96. input_fn: InputFn,
  97. print_fn: PrintFn,
  98. label: str,
  99. *,
  100. choices: dict[str, str],
  101. default: str,
  102. ) -> str:
  103. while True:
  104. for key, description in choices.items():
  105. print_fn(f" {key} = {description}")
  106. raw = input_fn(f" {label} [{default}]: ").strip()
  107. if not raw:
  108. return default
  109. if raw in choices:
  110. return raw
  111. print_fn(f"请输入 {', '.join(choices)} 之一.")
  112. def _print_step(print_fn: PrintFn, index: int, total: int, title: str) -> None:
  113. print_fn("")
  114. print_fn("━" * 40)
  115. print_fn(f"[{index}/{total}] {title}")
  116. print_fn("━" * 40)
  117. def _first_cfmail_account_defaults(path: Path) -> dict[str, str]:
  118. accounts = enabled_cfmail_accounts(path)
  119. if not accounts:
  120. return {}
  121. current = accounts[0]
  122. return {
  123. "worker_domain": current.worker_domain,
  124. "email_domain": current.email_domain,
  125. "worker_name": current.name,
  126. "admin_password": current.admin_password,
  127. }
  128. def _infer_zone_name(email_domain: str) -> str:
  129. labels = [part for part in str(email_domain or "").strip().split(".") if part]
  130. if len(labels) >= 2:
  131. return ".".join(labels[-2:])
  132. return str(email_domain or "").strip()
  133. def _validate_proxy(print_fn: PrintFn, proxy_url: str) -> None:
  134. proxy_url = str(proxy_url or "").strip()
  135. if not proxy_url:
  136. return
  137. print_fn(f" 测试代理连通性: {proxy_url}")
  138. try:
  139. response = httpx.get("https://api.openai.com", proxy=proxy_url, timeout=10)
  140. latency_ms = response.elapsed.total_seconds() * 1000
  141. print_fn(f" ✅ 连通 (延迟 {latency_ms:.0f}ms)")
  142. except Exception as exc: # noqa: BLE001
  143. if _is_socks_proxy_url(proxy_url) and _is_missing_socks_support(exc):
  144. print_fn(" ⚠️ 当前环境缺少 SOCKS 依赖, 无法验证该代理.")
  145. print_fn(" 请先运行: uv sync")
  146. print_fn(" 你可以继续, 启动前再补齐依赖.")
  147. return
  148. print_fn(f" ⚠️ 连接失败: {exc}")
  149. print_fn(" 你可以继续, 启动后再排查代理问题.")
  150. def _is_socks_proxy_url(proxy_url: str) -> bool:
  151. scheme = urlparse(str(proxy_url or "").strip()).scheme.lower()
  152. return scheme.startswith("socks")
  153. def _is_missing_socks_support(exc: Exception) -> bool:
  154. message = str(exc).lower()
  155. return "socksio" in message or "using socks proxy" in message
  156. def _validate_cloudflare_credentials(
  157. print_fn: PrintFn,
  158. *,
  159. api_token: str = "",
  160. auth_email: str = "",
  161. auth_key: str = "",
  162. ) -> None:
  163. label = "Cloudflare API Token" if str(api_token or "").strip() else "Cloudflare 全局 Key"
  164. print_fn(f" 验证 {label}...")
  165. try:
  166. with setup_cfmail.CloudflareClient(
  167. api_token,
  168. auth_email=auth_email,
  169. auth_key=auth_key,
  170. timeout=10,
  171. ) as client:
  172. client.verify_token()
  173. except Exception as exc: # noqa: BLE001
  174. print_fn(f" ⚠️ 验证失败: {exc}")
  175. print_fn(" 你可以继续, 后续再检查 Cloudflare 凭据.")
  176. return
  177. print_fn(" ✅ Cloudflare 凭据有效")
  178. def _validate_cpa_management(print_fn: PrintFn, base_url: str) -> None:
  179. print_fn(" 测试连通性...")
  180. try:
  181. response = httpx.get(base_url, timeout=10)
  182. print_fn(f" ✅ 连通 (HTTP {response.status_code})")
  183. except Exception as exc: # noqa: BLE001
  184. print_fn(f" ⚠️ 连接失败: {exc}")
  185. print_fn(" 你可以继续, 启动后再排查 CPA 问题.")
  186. def run_setup_wizard(
  187. env_file: Path | None = None,
  188. *,
  189. input_fn: InputFn = input,
  190. print_fn: PrintFn = print,
  191. ) -> SetupWizardResult:
  192. total_steps = 5
  193. resolved_env_file = Path(env_file or os.getenv("ZHUCE6_ENV_FILE") or Path.cwd() / ".env").expanduser().resolve()
  194. env_defaults = _load_env_defaults(resolved_env_file)
  195. project_root = Path(env_defaults.get("ZHUCE6_PROJECT_ROOT") or resolved_env_file.parent).expanduser().resolve()
  196. config_dir = Path(env_defaults.get("ZHUCE6_CONFIG_DIR") or project_root / "config").expanduser().resolve()
  197. cfmail_accounts_path = Path(
  198. env_defaults.get("ZHUCE6_CFMAIL_CONFIG_PATH") or config_dir / setup_cfmail.DEFAULT_CFMAIL_ACCOUNTS_PATH.name
  199. ).expanduser().resolve()
  200. cfmail_env_path = Path(
  201. env_defaults.get("ZHUCE6_CFMAIL_ENV_FILE") or config_dir / setup_cfmail.DEFAULT_CFMAIL_ENV_PATH.name
  202. ).expanduser().resolve()
  203. cfmail_account_defaults = _first_cfmail_account_defaults(cfmail_accounts_path)
  204. cfmail_env_defaults = _load_env_defaults(cfmail_env_path)
  205. print_fn("╔══════════════════════════════════════╗")
  206. print_fn("║ zhuce6 首次配置向导 ║")
  207. print_fn("╚══════════════════════════════════════╝")
  208. print_fn("")
  209. print_fn("直接回车即可接受 [] 中的默认值.")
  210. print_fn(f"当前 .env 路径: {resolved_env_file}")
  211. _print_step(print_fn, 1, total_steps, "运行模式与后端")
  212. mode = _prompt_choice(
  213. input_fn,
  214. print_fn,
  215. "选择模式",
  216. choices={
  217. "lite": "仅注册",
  218. "full": "注册 + 后端治理",
  219. },
  220. default=env_defaults.get("ZHUCE6_RUN_MODE", "lite") or "lite",
  221. )
  222. backend_default = env_defaults.get("ZHUCE6_BACKEND", "cpa") or "cpa"
  223. backend = "cpa"
  224. if mode == "full":
  225. backend = _prompt_choice(
  226. input_fn,
  227. print_fn,
  228. "选择 full 模式后端",
  229. choices={
  230. "cpa": "CPA Management API",
  231. "sub2api": "sub2api Admin API",
  232. },
  233. default=backend_default if backend_default in {"cpa", "sub2api"} else "cpa",
  234. )
  235. _print_step(print_fn, 2, total_steps, "Dashboard 配置")
  236. host = _prompt_text(input_fn, print_fn, "Dashboard host", default=env_defaults.get("ZHUCE6_HOST", "127.0.0.1"))
  237. port = _prompt_text(input_fn, print_fn, "Dashboard port", default=env_defaults.get("ZHUCE6_PORT", "8000"), required=True)
  238. register_mail_provider = _prompt_text(
  239. input_fn,
  240. print_fn,
  241. "Register mail provider",
  242. default=env_defaults.get("ZHUCE6_REGISTER_MAIL_PROVIDER", "cfmail"),
  243. required=True,
  244. )
  245. _print_step(print_fn, 3, total_steps, "代理配置")
  246. print_fn(" 注册需要海外代理 (日本/台湾/新加坡/香港).")
  247. print_fn(' 如果你已有 Clash/V2Ray 在运行, 选 "1" 填 URL 即可.')
  248. enable_proxy_pool = _prompt_bool(
  249. input_fn,
  250. print_fn,
  251. "Enable proxy pool",
  252. default=env_defaults.get("ZHUCE6_ENABLE_PROXY_POOL", "1").strip().lower() in {"1", "true", "yes", "on"},
  253. )
  254. proxy_pool_config_default = env_defaults.get("ZHUCE6_PROXY_POOL_CONFIG", str(project_root / "clash_config.yaml"))
  255. proxy_pool_direct_urls_default = env_defaults.get("ZHUCE6_PROXY_POOL_DIRECT_URLS", "")
  256. register_proxy_default = env_defaults.get("ZHUCE6_REGISTER_PROXY", "http://127.0.0.1:7899")
  257. proxy_pool_config = ""
  258. proxy_pool_direct_urls = ""
  259. register_proxy = register_proxy_default
  260. validation_proxy_url = ""
  261. if enable_proxy_pool:
  262. proxy_pool_mode = _prompt_choice(
  263. input_fn,
  264. print_fn,
  265. "Proxy pool mode",
  266. choices={
  267. "1": "直接填代理 URL (推荐)",
  268. "2": "提供 Clash YAML 配置文件 (需要 sslocal)",
  269. },
  270. default="1" if proxy_pool_direct_urls_default.strip() else "2",
  271. )
  272. if proxy_pool_mode == "1":
  273. proxy_pool_direct_urls = _prompt_text(
  274. input_fn,
  275. print_fn,
  276. "代理 URL (多个用分号分隔)",
  277. default=proxy_pool_direct_urls_default,
  278. required=True,
  279. )
  280. validation_proxy_url = next((item.strip() for item in proxy_pool_direct_urls.split(";") if item.strip()), "")
  281. proxy_pool_config = ""
  282. register_proxy = validation_proxy_url or register_proxy_default
  283. _validate_proxy(print_fn, validation_proxy_url)
  284. else:
  285. proxy_pool_config = _prompt_text(
  286. input_fn,
  287. print_fn,
  288. "Clash YAML 配置文件",
  289. default=proxy_pool_config_default,
  290. required=True,
  291. )
  292. proxy_pool_direct_urls = ""
  293. register_proxy = register_proxy_default
  294. sslocal_bin = shutil.which("sslocal") or shutil.which("ss-local")
  295. if sslocal_bin:
  296. print_fn(f" 已检测到 sslocal: {sslocal_bin}")
  297. else:
  298. print_fn(" 未检测到 sslocal, 不会自动安装.")
  299. for line in sslocal_install_guidance().splitlines():
  300. print_fn(line)
  301. else:
  302. register_proxy = _prompt_text(
  303. input_fn,
  304. print_fn,
  305. "Register proxy URL",
  306. default=register_proxy_default,
  307. required=True,
  308. )
  309. validation_proxy_url = register_proxy
  310. _validate_proxy(print_fn, validation_proxy_url)
  311. wrote_cfmail = False
  312. providers = {part.strip().lower() for part in register_mail_provider.split(",") if part.strip()}
  313. generated_admin_password = secrets.token_hex(8)
  314. existing_cfmail_available = bool(cfmail_account_defaults) and bool(
  315. cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_ACCOUNT_ID", "") and cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_ZONE_ID", "")
  316. )
  317. if "cfmail" in providers:
  318. _print_step(print_fn, 4, total_steps, "cfmail 邮箱配置")
  319. print_fn(" cfmail 使用 Cloudflare Worker 接收注册验证码.")
  320. print_fn(" 如果要从零部署 cfmail Worker, 最小输入是 Cloudflare API Token + zone_name.")
  321. print_fn(" 如果只有 CF_AUTH_EMAIL + CF_AUTH_KEY, 则需要额外提供一个已部署的 worker_domain.")
  322. reuse_existing_cfmail = existing_cfmail_available and _prompt_bool(
  323. input_fn,
  324. print_fn,
  325. "检测到现有 cfmail 配置, 是否直接复用",
  326. default=True,
  327. )
  328. if reuse_existing_cfmail:
  329. cf_api_token = env_defaults.get("ZHUCE6_CFMAIL_API_TOKEN") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_API_TOKEN", "")
  330. cf_auth_email = env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_EMAIL") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", "")
  331. cf_auth_key = env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_KEY") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_KEY", "")
  332. cf_account_id = env_defaults.get("ZHUCE6_CFMAIL_CF_ACCOUNT_ID") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_ACCOUNT_ID", "")
  333. cf_zone_id = env_defaults.get("ZHUCE6_CFMAIL_CF_ZONE_ID") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_ZONE_ID", "")
  334. worker_name = env_defaults.get("ZHUCE6_CFMAIL_WORKER_NAME") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_WORKER_NAME") or cfmail_account_defaults.get("worker_name", setup_cfmail.DEFAULT_WORKER_NAME)
  335. worker_domain = cfmail_account_defaults.get("worker_domain", "")
  336. email_domain = cfmail_account_defaults.get("email_domain", env_defaults.get("ZHUCE6_CFMAIL_ZONE_NAME", ""))
  337. admin_password = cfmail_account_defaults.get("admin_password", generated_admin_password)
  338. zone_name = env_defaults.get("ZHUCE6_CFMAIL_ZONE_NAME") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_ZONE_NAME") or _infer_zone_name(email_domain)
  339. d1_database_id = (
  340. env_defaults.get("ZHUCE6_D1_DATABASE_ID")
  341. or cfmail_env_defaults.get("ZHUCE6_D1_DATABASE_ID", "")
  342. or str(os.getenv("ZHUCE6_D1_DATABASE_ID", "")).strip()
  343. )
  344. print_fn(f" 复用现有 worker: {worker_name}")
  345. print_fn(f" 复用现有 domain: {email_domain}")
  346. wrote_cfmail = True
  347. else:
  348. cf_api_token = _prompt_text(
  349. input_fn,
  350. print_fn,
  351. "Cloudflare API Token (留空则改用 CF_AUTH_EMAIL + CF_AUTH_KEY)",
  352. default=env_defaults.get("ZHUCE6_CFMAIL_API_TOKEN") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_API_TOKEN", ""),
  353. required=False,
  354. )
  355. cf_auth_email = ""
  356. cf_auth_key = ""
  357. explicit_worker_domain = ""
  358. if cf_api_token:
  359. _validate_cloudflare_credentials(print_fn, api_token=cf_api_token)
  360. else:
  361. print_fn(" 未提供 API Token, 改用 Cloudflare 全局 Key (CF_AUTH_EMAIL + CF_AUTH_KEY).")
  362. cf_auth_email = _prompt_text(
  363. input_fn,
  364. print_fn,
  365. "CF_AUTH_EMAIL",
  366. default=env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_EMAIL") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_EMAIL", ""),
  367. required=True,
  368. )
  369. cf_auth_key = _prompt_text(
  370. input_fn,
  371. print_fn,
  372. "CF_AUTH_KEY",
  373. default=env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_KEY") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_CF_AUTH_KEY", ""),
  374. required=True,
  375. )
  376. _validate_cloudflare_credentials(
  377. print_fn,
  378. auth_email=cf_auth_email,
  379. auth_key=cf_auth_key,
  380. )
  381. explicit_worker_domain = _prompt_text(
  382. input_fn,
  383. print_fn,
  384. "已部署 cfmail worker_domain",
  385. default=cfmail_account_defaults.get("worker_domain", ""),
  386. required=True,
  387. )
  388. zone_name = _prompt_text(
  389. input_fn,
  390. print_fn,
  391. "zone_name",
  392. default=env_defaults.get("ZHUCE6_CFMAIL_ZONE_NAME") or cfmail_env_defaults.get("ZHUCE6_CFMAIL_ZONE_NAME", ""),
  393. required=True,
  394. ).lower()
  395. worker_name = _prompt_text(
  396. input_fn,
  397. print_fn,
  398. "cfmail worker name",
  399. default=env_defaults.get("ZHUCE6_CFMAIL_WORKER_NAME")
  400. or cfmail_env_defaults.get("ZHUCE6_CFMAIL_WORKER_NAME")
  401. or cfmail_account_defaults.get("worker_name", setup_cfmail.DEFAULT_WORKER_NAME),
  402. required=True,
  403. )
  404. email_domain = _prompt_text(
  405. input_fn,
  406. print_fn,
  407. "邮箱域名",
  408. default=cfmail_account_defaults.get("email_domain", zone_name),
  409. required=True,
  410. )
  411. email_domain = setup_cfmail.ensure_mail_domain(zone_name, email_domain)
  412. print_fn(f" admin 密码默认随机生成: {generated_admin_password}")
  413. admin_password = _prompt_text(
  414. input_fn,
  415. print_fn,
  416. "admin 密码",
  417. default=cfmail_account_defaults.get("admin_password", generated_admin_password),
  418. required=True,
  419. )
  420. cf_account_id = ""
  421. cf_zone_id = ""
  422. worker_domain = explicit_worker_domain
  423. d1_database_id = ""
  424. wrote_cfmail = True
  425. else:
  426. reuse_existing_cfmail = False
  427. cf_api_token = ""
  428. cf_auth_email = ""
  429. cf_auth_key = ""
  430. cf_account_id = ""
  431. cf_zone_id = ""
  432. worker_name = ""
  433. email_domain = ""
  434. zone_name = ""
  435. d1_database_id = ""
  436. cfmail_payload = {
  437. "cf_auth_email": cf_auth_email,
  438. "cf_auth_key": cf_auth_key,
  439. "cf_account_id": cf_account_id,
  440. "cf_zone_id": cf_zone_id,
  441. "worker_name": worker_name,
  442. "worker_domain": worker_domain if "cfmail" in providers else "",
  443. "email_domain": email_domain,
  444. "admin_password": admin_password if "cfmail" in providers else "",
  445. "zone_name": zone_name,
  446. "cf_api_token": cf_api_token if "cfmail" in providers else "",
  447. }
  448. cpa_management_base_url = ""
  449. cpa_management_key = ""
  450. sub2api_base_url = ""
  451. sub2api_api_key = ""
  452. sub2api_admin_email = ""
  453. sub2api_admin_password = ""
  454. if mode == "full" and backend == "cpa":
  455. _print_step(print_fn, 5, total_steps, "CPA 配置")
  456. print_fn(" full + cpa 走 CPA Management API.")
  457. cpa_management_base_url = _prompt_text(
  458. input_fn,
  459. print_fn,
  460. "CPA management URL",
  461. default=env_defaults.get("ZHUCE6_CPA_MANAGEMENT_BASE_URL", "http://127.0.0.1:8317/v0/management"),
  462. required=True,
  463. )
  464. _validate_cpa_management(print_fn, cpa_management_base_url)
  465. cpa_management_key = _prompt_text(
  466. input_fn,
  467. print_fn,
  468. "CPA management API key",
  469. default=env_defaults.get("ZHUCE6_CPA_MANAGEMENT_KEY", ""),
  470. required=False,
  471. )
  472. elif mode == "full" and backend == "sub2api":
  473. _print_step(print_fn, 5, total_steps, "sub2api 配置")
  474. print_fn(" full + sub2api 走 sub2api Admin API.")
  475. sub2api_base_url = _prompt_text(
  476. input_fn,
  477. print_fn,
  478. "sub2api base URL",
  479. default=env_defaults.get("ZHUCE6_SUB2API_BASE_URL", "http://127.0.0.1:8080"),
  480. required=True,
  481. )
  482. sub2api_auth_mode = _prompt_choice(
  483. input_fn,
  484. print_fn,
  485. "sub2api 认证方式",
  486. choices={
  487. "api_key": "使用 API Key",
  488. "password": "使用管理员邮箱 + 密码",
  489. },
  490. default="api_key" if env_defaults.get("ZHUCE6_SUB2API_API_KEY", "") else "password",
  491. )
  492. if sub2api_auth_mode == "api_key":
  493. sub2api_api_key = _prompt_text(
  494. input_fn,
  495. print_fn,
  496. "sub2api API Key",
  497. default=env_defaults.get("ZHUCE6_SUB2API_API_KEY", ""),
  498. required=True,
  499. )
  500. else:
  501. sub2api_admin_email = _prompt_text(
  502. input_fn,
  503. print_fn,
  504. "sub2api admin email",
  505. default=env_defaults.get("ZHUCE6_SUB2API_ADMIN_EMAIL", ""),
  506. required=True,
  507. )
  508. sub2api_admin_password = _prompt_text(
  509. input_fn,
  510. print_fn,
  511. "sub2api admin password",
  512. default=env_defaults.get("ZHUCE6_SUB2API_ADMIN_PASSWORD", ""),
  513. required=True,
  514. )
  515. else:
  516. _print_step(print_fn, 5, total_steps, "后端配置")
  517. print_fn(" lite 模式已跳过后端配置.")
  518. env_updates: dict[str, object] = {
  519. "ZHUCE6_RUN_MODE": mode,
  520. "ZHUCE6_HOST": host,
  521. "ZHUCE6_PORT": port,
  522. "ZHUCE6_DASHBOARD_PORT": port,
  523. "ZHUCE6_ENV_FILE": str(resolved_env_file),
  524. "ZHUCE6_CONFIG_DIR": str(config_dir),
  525. "ZHUCE6_BACKEND": backend,
  526. "ZHUCE6_REGISTER_MAIL_PROVIDER": register_mail_provider,
  527. "ZHUCE6_REGISTER_PROXY": register_proxy,
  528. "ZHUCE6_ENABLE_PROXY_POOL": "1" if enable_proxy_pool else "0",
  529. "ZHUCE6_PROXY_POOL_CONFIG": proxy_pool_config,
  530. "ZHUCE6_PROXY_POOL_DIRECT_URLS": proxy_pool_direct_urls,
  531. }
  532. if mode == "full" and backend == "cpa":
  533. env_updates.update(
  534. {
  535. "ZHUCE6_CPA_MANAGEMENT_BASE_URL": cpa_management_base_url,
  536. "ZHUCE6_CPA_MANAGEMENT_KEY": cpa_management_key,
  537. }
  538. )
  539. if mode == "full" and backend == "sub2api":
  540. env_updates.update(
  541. {
  542. "ZHUCE6_SUB2API_BASE_URL": sub2api_base_url,
  543. "ZHUCE6_SUB2API_API_KEY": sub2api_api_key,
  544. "ZHUCE6_SUB2API_ADMIN_EMAIL": sub2api_admin_email,
  545. "ZHUCE6_SUB2API_ADMIN_PASSWORD": sub2api_admin_password,
  546. }
  547. )
  548. print_fn("")
  549. print_fn("━" * 40)
  550. print_fn("配置摘要")
  551. print_fn("━" * 40)
  552. print_fn(f" 模式: {mode}")
  553. if mode == "full":
  554. print_fn(f" 后端: {backend}")
  555. if validation_proxy_url:
  556. print_fn(f" 代理: {validation_proxy_url}")
  557. elif proxy_pool_config:
  558. print_fn(f" 代理: Clash YAML -> {proxy_pool_config}")
  559. else:
  560. print_fn(" 代理: 未设置")
  561. print_fn(f" cfmail: {email_domain or '未启用'}")
  562. print_fn(f" Dashboard: http://{host}:{port}/zhuce6")
  563. should_save = _prompt_bool(input_fn, print_fn, f"保存到 {resolved_env_file}?", default=True)
  564. if should_save:
  565. if wrote_cfmail:
  566. if not reuse_existing_cfmail:
  567. runtime_config = setup_cfmail.prepare_runtime_cfmail_config(
  568. api_token=cfmail_payload["cf_api_token"],
  569. auth_email=cfmail_payload["cf_auth_email"],
  570. auth_key=cfmail_payload["cf_auth_key"],
  571. worker_domain=cfmail_payload["worker_domain"] or None,
  572. zone_name=cfmail_payload["zone_name"],
  573. worker_name=cfmail_payload["worker_name"],
  574. mail_domain=cfmail_payload["email_domain"],
  575. admin_password=cfmail_payload["admin_password"],
  576. accounts_path=cfmail_accounts_path,
  577. provision_env_path=cfmail_env_path,
  578. )
  579. cfmail_payload["worker_domain"] = runtime_config.worker_domain
  580. cfmail_payload["email_domain"] = runtime_config.email_domain
  581. cfmail_payload["admin_password"] = runtime_config.admin_password
  582. cfmail_payload["cf_account_id"] = runtime_config.account_id
  583. cfmail_payload["cf_zone_id"] = runtime_config.zone_id
  584. elif not cfmail_accounts_path.exists() or not cfmail_env_path.exists():
  585. setup_cfmail.write_cfmail_accounts_json(
  586. cfmail_accounts_path,
  587. worker_domain=cfmail_payload["worker_domain"],
  588. email_domain=cfmail_payload["email_domain"],
  589. worker_name=cfmail_payload["worker_name"],
  590. admin_password=cfmail_payload["admin_password"],
  591. )
  592. setup_cfmail.write_cfmail_provision_env(
  593. cfmail_env_path,
  594. api_token=cfmail_payload["cf_api_token"],
  595. auth_email=cfmail_payload["cf_auth_email"],
  596. auth_key=cfmail_payload["cf_auth_key"],
  597. account_id=cfmail_payload["cf_account_id"],
  598. zone_id=cfmail_payload["cf_zone_id"],
  599. worker_name=cfmail_payload["worker_name"],
  600. zone_name=cfmail_payload["zone_name"],
  601. )
  602. env_updates.update(
  603. {
  604. "ZHUCE6_CFMAIL_CONFIG_PATH": str(cfmail_accounts_path),
  605. "ZHUCE6_CFMAIL_ENV_FILE": str(cfmail_env_path),
  606. "ZHUCE6_D1_DATABASE_ID": getattr(runtime_config, "d1_database_id", "") if not reuse_existing_cfmail else d1_database_id,
  607. "ZHUCE6_CFMAIL_API_TOKEN": cfmail_payload["cf_api_token"],
  608. "ZHUCE6_CFMAIL_CF_AUTH_EMAIL": cfmail_payload["cf_auth_email"],
  609. "ZHUCE6_CFMAIL_CF_AUTH_KEY": cfmail_payload["cf_auth_key"],
  610. "ZHUCE6_CFMAIL_CF_ACCOUNT_ID": cfmail_payload["cf_account_id"],
  611. "ZHUCE6_CFMAIL_CF_ZONE_ID": cfmail_payload["cf_zone_id"],
  612. "ZHUCE6_CFMAIL_WORKER_NAME": cfmail_payload["worker_name"],
  613. "ZHUCE6_CFMAIL_ZONE_NAME": cfmail_payload["zone_name"],
  614. }
  615. )
  616. print_fn("")
  617. print_fn("cfmail 运行时配置已保存.")
  618. if cfmail_payload["cf_api_token"]:
  619. print_fn("如果你还没有部署 cfmail Worker, 请在初始化完成后运行:")
  620. print_fn(
  621. f" uv run python scripts/setup_cfmail.py --api-token <token> --zone-name {cfmail_payload['zone_name']}"
  622. )
  623. else:
  624. print_fn("当前使用的是已部署 worker_domain, 初始化不会重新部署 cfmail Worker.")
  625. print_fn("")
  626. _persist_env_updates(resolved_env_file, env_updates)
  627. for key, value in env_updates.items():
  628. os.environ[str(key)] = str(value)
  629. print_fn(f" ✅ 已保存到 {resolved_env_file}")
  630. else:
  631. if wrote_cfmail:
  632. wrote_cfmail = False
  633. print_fn(" 已取消保存, 当前修改未写入磁盘.")
  634. print_fn("")
  635. print_fn(" 下一步:")
  636. print_fn(" uv run python main.py doctor --fix # 自动补齐依赖并检查环境")
  637. print_fn(f" uv run python main.py --mode {mode} # 启动")
  638. print_fn(f"初始化完成: {resolved_env_file}")
  639. if wrote_cfmail:
  640. print_fn(f"cfmail accounts: {cfmail_accounts_path}")
  641. print_fn(f"cfmail env: {cfmail_env_path}")
  642. return SetupWizardResult(
  643. env_file=resolved_env_file,
  644. env_updates=env_updates,
  645. cfmail_accounts_path=cfmail_accounts_path if wrote_cfmail else None,
  646. cfmail_env_path=cfmail_env_path if wrote_cfmail else None,
  647. )