cli.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. # -*- coding:utf-8 -*-
  2. """
  3. Configuration loader for DDNS command-line interface.
  4. @author: NewFuture
  5. """
  6. import platform
  7. import sys
  8. from argparse import SUPPRESS, Action, ArgumentParser, RawTextHelpFormatter
  9. from logging import DEBUG, basicConfig, getLevelName
  10. from os import path as os_path
  11. from ..scheduler import get_scheduler
  12. from .file import save_config
  13. __all__ = ["load_config", "str_bool"]
  14. def str_bool(v):
  15. # type: (str | bool | None | int | float | list) -> bool | str
  16. """
  17. parse string to boolean
  18. """
  19. if isinstance(v, bool):
  20. return v
  21. if v is None:
  22. return False
  23. if not isinstance(v, str) and not type(v).__name__ == "unicode":
  24. return bool(v) # For non-string types, convert to string first
  25. if v.lower() in ("yes", "true", "t", "y", "1"): # type: ignore[attribute-defined]
  26. return True
  27. elif v.lower() in ("no", "false", "f", "n", "0"): # type: ignore[attribute-defined]
  28. return False
  29. else:
  30. return v # type: ignore[return-value]
  31. def log_level(value):
  32. """
  33. parse string to log level
  34. or getattr(logging, value.upper())
  35. """
  36. return getLevelName(value if isinstance(value, int) else value.upper())
  37. def _get_system_info_str():
  38. system = platform.system()
  39. release = platform.release()
  40. machine = platform.machine()
  41. arch = platform.architecture()
  42. return "{}-{} {} {}".format(system, release, machine, arch)
  43. def _get_python_info_str():
  44. version = platform.python_version()
  45. branch, py_build_date = platform.python_build()
  46. return "Python-{} {} ({})".format(version, branch, py_build_date)
  47. class ExtendAction(Action):
  48. """兼容 Python <3.8 的 extend action"""
  49. def __call__(self, parser, namespace, values, option_string=None):
  50. items = getattr(namespace, self.dest, None)
  51. if items is None:
  52. items = []
  53. # values 可能是单个值或列表
  54. if isinstance(values, list):
  55. items.extend(values)
  56. else:
  57. items.append(values)
  58. setattr(namespace, self.dest, items)
  59. class NewConfigAction(Action):
  60. """生成配置文件并退出程序"""
  61. def __call__(self, parser, namespace, values, option_string=None):
  62. # 获取配置文件路径
  63. if values and values != "true":
  64. config_path = str(values) # type: str
  65. else:
  66. config_path = getattr(namespace, "config", None) or "config.json" # type: str
  67. config_path = config_path[0] if isinstance(config_path, list) else config_path
  68. if os_path.exists(config_path):
  69. sys.stderr.write("The default %s already exists!\n" % config_path)
  70. sys.stdout.write("Please use `--new-config=%s` to specify a new config file.\n" % config_path)
  71. sys.exit(1)
  72. # 获取当前已解析的参数
  73. current_config = {k: v for k, v in vars(namespace).items() if v is not None}
  74. # 保存配置文件
  75. save_config(config_path, current_config)
  76. sys.stdout.write("%s is generated.\n" % config_path)
  77. sys.exit(0)
  78. def _add_ddns_args(arg): # type: (ArgumentParser) -> None
  79. """Add common DDNS arguments to a parser"""
  80. log_levels = [
  81. "CRITICAL", # 50
  82. "ERROR", # 40
  83. "WARNING", # 30
  84. "INFO", # 20
  85. "DEBUG", # 10
  86. "NOTSET", # 0
  87. ]
  88. arg.add_argument(
  89. "-c",
  90. "--config",
  91. nargs="*",
  92. action=ExtendAction,
  93. metavar="FILE",
  94. help="load config file [配置文件路径, 可多次指定]",
  95. )
  96. arg.add_argument("--debug", action="store_true", help="debug mode [开启调试模式]")
  97. # DDNS Configuration group
  98. ddns = arg.add_argument_group("DDNS Configuration [DDNS配置参数]")
  99. ddns.add_argument(
  100. "--dns",
  101. help="DNS provider [DNS服务提供商]",
  102. choices=[
  103. "51dns",
  104. "alidns",
  105. "aliesa",
  106. "callback",
  107. "cloudflare",
  108. "debug",
  109. "dnscom",
  110. "dnspod_com",
  111. "dnspod",
  112. "edgeone",
  113. "edgeone_dns",
  114. "he",
  115. "huaweidns",
  116. "namesilo",
  117. "noip",
  118. "tencentcloud",
  119. ],
  120. )
  121. ddns.add_argument("--id", help="API ID or email [对应账号ID或邮箱]")
  122. ddns.add_argument("--token", help="API token or key [授权凭证或密钥]")
  123. ddns.add_argument("--endpoint", help="API endpoint URL [API端点URL]")
  124. ddns.add_argument(
  125. "--index4", nargs="*", action=ExtendAction, metavar="RULE", help="IPv4 rules [获取IPv4方式, 多次可配置多规则]"
  126. )
  127. ddns.add_argument(
  128. "--index6", nargs="*", action=ExtendAction, metavar="RULE", help="IPv6 rules [获取IPv6方式, 多次配置多规则]"
  129. )
  130. ddns.add_argument(
  131. "--ipv4", nargs="*", action=ExtendAction, metavar="DOMAIN", help="IPv4 domains [IPv4域名列表, 可配多个域名]"
  132. )
  133. ddns.add_argument(
  134. "--ipv6", nargs="*", action=ExtendAction, metavar="DOMAIN", help="IPv6 domains [IPv6域名列表, 可配多个域名]"
  135. )
  136. ddns.add_argument("--ttl", type=int, help="DNS TTL(s) [设置域名解析过期时间]")
  137. ddns.add_argument("--line", help="DNS line/route [DNS线路设置]")
  138. # Advanced Options group
  139. advanced = arg.add_argument_group("Advanced Options [高级参数]")
  140. advanced.add_argument("--proxy", nargs="*", action=ExtendAction, help="HTTP proxy [设置http代理,可配多个代理连接]")
  141. advanced.add_argument(
  142. "--cache", type=str_bool, nargs="?", const=True, help="set cache [启用缓存开关,或传入保存路径]"
  143. )
  144. advanced.add_argument(
  145. "--no-cache", dest="cache", action="store_const", const=False, help="disable cache [关闭缓存等效 --cache=false]"
  146. )
  147. advanced.add_argument(
  148. "--ssl",
  149. type=str_bool,
  150. nargs="?",
  151. const=True,
  152. help="SSL certificate verification [SSL证书验证方式]: "
  153. "true(强制验证), false(禁用验证), auto(自动降级), /path/to/cert.pem(自定义证书)",
  154. )
  155. advanced.add_argument(
  156. "--no-ssl",
  157. dest="ssl",
  158. action="store_const",
  159. const=False,
  160. help="disable SSL verify [禁用验证, 等效 --ssl=false]",
  161. )
  162. advanced.add_argument("--log_file", metavar="FILE", help="log file [日志文件,默认标准输出]")
  163. advanced.add_argument("--log.file", "--log-file", dest="log_file", help=SUPPRESS) # 隐藏参数
  164. advanced.add_argument("--log_level", type=log_level, metavar="|".join(log_levels), help=None)
  165. advanced.add_argument("--log.level", "--log-level", dest="log_level", type=log_level, help=SUPPRESS) # 隐藏参数
  166. advanced.add_argument("--log_format", metavar="FORMAT", help="set log format [日志格式]")
  167. advanced.add_argument("--log.format", "--log-format", dest="log_format", help=SUPPRESS) # 隐藏参数
  168. advanced.add_argument("--log_datefmt", metavar="FORMAT", help="set log date format [日志时间格式]")
  169. advanced.add_argument("--log.datefmt", "--log-datefmt", dest="log_datefmt", help=SUPPRESS) # 隐藏参数
  170. def _add_task_subcommand_if_needed(parser): # type: (ArgumentParser) -> None
  171. """
  172. Conditionally add task subcommand to avoid Python 2 'too few arguments' error.
  173. Python 2's argparse requires subcommand when subparsers are defined, but Python 3 doesn't.
  174. We only add subparsers when the first argument is likely a subcommand (doesn't start with '-').
  175. """
  176. # Python2 Only add subparsers when first argument is a subcommand (not an option)
  177. if len(sys.argv) <= 1 or (sys.argv[1].startswith("-") and sys.argv[1] != "--help"):
  178. return
  179. # Add subparsers for subcommands
  180. subparsers = parser.add_subparsers(dest="command", help="subcommands [子命令]")
  181. # Create task subcommand parser
  182. task = subparsers.add_parser("task", help="Manage scheduled tasks [管理定时任务]")
  183. task.set_defaults(func=_handle_task_command)
  184. _add_ddns_args(task)
  185. # Add task-specific arguments
  186. task.add_argument(
  187. "-i",
  188. "--install",
  189. nargs="?",
  190. type=int,
  191. const=5,
  192. metavar="MINs",
  193. help="Install task with <mins> [安装定时任务,默认5分钟]",
  194. )
  195. task.add_argument("--uninstall", action="store_true", help="Uninstall scheduled task [卸载定时任务]")
  196. task.add_argument("--status", action="store_true", help="Show task status [显示定时任务状态]")
  197. task.add_argument("--enable", action="store_true", help="Enable scheduled task [启用定时任务]")
  198. task.add_argument("--disable", action="store_true", help="Disable scheduled task [禁用定时任务]")
  199. task.add_argument(
  200. "--scheduler",
  201. choices=["auto", "systemd", "cron", "launchd", "schtasks"],
  202. default="auto",
  203. help="Specify scheduler type [指定定时任务方式]",
  204. )
  205. def load_config(description, doc, version, date):
  206. # type: (str, str, str, str) -> dict
  207. """
  208. 解析命令行参数并返回配置字典。
  209. Args:
  210. description (str): 程序描述
  211. doc (str): 程序文档
  212. version (str): 程序版本
  213. date (str): 构建日期
  214. Returns:
  215. dict: 配置字典
  216. """
  217. parser = ArgumentParser(description=description, epilog=doc, formatter_class=RawTextHelpFormatter)
  218. sysinfo = _get_system_info_str()
  219. pyinfo = _get_python_info_str()
  220. compiled = getattr(sys.modules["__main__"], "__compiled__", "")
  221. version_str = "v{} ({})\n{}\n{}\n{}".format(version, date, pyinfo, sysinfo, compiled)
  222. _add_ddns_args(parser) # Add common DDNS arguments to main parser
  223. # Default behavior (no subcommand) - add all the regular DDNS options
  224. parser.add_argument("-v", "--version", action="version", version=version_str)
  225. parser.add_argument(
  226. "--new-config", metavar="FILE", action=NewConfigAction, nargs="?", help="generate new config [生成配置文件]"
  227. )
  228. # Python 2/3 compatibility: conditionally add subparsers to avoid 'too few arguments' error
  229. # Subparsers are only needed when user provides a subcommand (non-option argument)
  230. _add_task_subcommand_if_needed(parser)
  231. args, unknown = parser.parse_known_args()
  232. # Parse unknown arguments that follow --extra.xxx format
  233. extra_args = {} # type: dict
  234. i = 0
  235. while i < len(unknown):
  236. arg = unknown[i]
  237. if arg.startswith("--extra."):
  238. key = "extra_" + arg[8:] # Remove "--extra." and add "extra_" prefix
  239. # Check if there's a value for this argument
  240. if i + 1 < len(unknown) and not unknown[i + 1].startswith("--"):
  241. extra_args[key] = unknown[i + 1]
  242. i += 2
  243. else:
  244. # No value provided, set to True (flag)
  245. extra_args[key] = True # type: ignore[assignment]
  246. i += 1
  247. else:
  248. # Unknown argument that doesn't match our pattern
  249. sys.stderr.write("Warning: Unknown argument: {}\n".format(arg))
  250. i += 1
  251. # Merge extra_args into args namespace
  252. for k, v in extra_args.items():
  253. setattr(args, k, v)
  254. # Handle task subcommand and exit early if present
  255. if hasattr(args, "func"):
  256. args.func(vars(args))
  257. sys.exit(0)
  258. is_debug = getattr(args, "debug", False)
  259. if is_debug:
  260. # 如果启用调试模式,则强制设置日志级别为 DEBUG
  261. args.log_level = log_level("DEBUG")
  262. if args.cache is None:
  263. args.cache = False # 禁用缓存
  264. # 将 Namespace 对象转换为字典并直接返回
  265. config = vars(args)
  266. return {k: v for k, v in config.items() if v is not None} # 过滤掉 None 值的配置项
  267. def _handle_task_command(args): # type: (dict) -> None
  268. """Handle task subcommand"""
  269. basicConfig(level=args["debug"] and DEBUG or args.get("log_level", "INFO"))
  270. # Use specified scheduler or auto-detect
  271. scheduler_type = args.get("scheduler", "auto")
  272. scheduler = get_scheduler(scheduler_type)
  273. interval = args.get("install", 5) or 5
  274. excluded_keys = ("status", "install", "uninstall", "enable", "disable", "command", "scheduler", "func")
  275. ddns_args = {k: v for k, v in args.items() if k not in excluded_keys and v is not None}
  276. # Execute operations
  277. for op in ["install", "uninstall", "enable", "disable"]:
  278. if not args.get(op):
  279. continue
  280. # Check if task is installed for enable/disable
  281. if op in ["enable", "disable"] and not scheduler.is_installed():
  282. print("DDNS task is not installed" + (" Please install it first." if op == "enable" else "."))
  283. sys.exit(1)
  284. # Execute operation
  285. print("{} DDNS scheduled task...".format(op.title()))
  286. func = getattr(scheduler, op)
  287. result = func(interval, ddns_args) if op == "install" else func()
  288. if result:
  289. past_tense = {
  290. "install": "installed",
  291. "uninstall": "uninstalled",
  292. "enable": "enabled",
  293. "disable": "disabled",
  294. }[op]
  295. suffix = " with {} minute interval".format(interval) if op == "install" else ""
  296. print("DDNS task {} successfully{}".format(past_tense, suffix))
  297. else:
  298. print("Failed to {} DDNS task".format(op))
  299. sys.exit(1)
  300. return
  301. # Show status or auto-install
  302. status = scheduler.get_status()
  303. if args.get("status") or status["installed"]:
  304. print("DDNS Task Status:")
  305. print(" Installed: {}".format("Yes" if status["installed"] else "No"))
  306. print(" Scheduler: {}".format(status["scheduler"]))
  307. if status["installed"]:
  308. print(" Enabled: {}".format(status.get("enabled", "unknown")))
  309. print(" Interval: {} minutes".format(status.get("interval", "unknown")))
  310. print(" Command: {}".format(status.get("command", "unknown")))
  311. print(" Description: {}".format(status.get("description", "")))
  312. else:
  313. print("DDNS task is not installed. Installing with default settings...")
  314. if scheduler.install(interval, ddns_args):
  315. print("DDNS task installed successfully with {} minute interval".format(interval))
  316. else:
  317. print("Failed to install DDNS task")
  318. sys.exit(1)