mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-20 08:17:43 +00:00
* feat(mcp): add Streamable HTTP transport Add a modern MCP 2026-07-28 HTTP endpoint for standalone and Web modes, with shared listener configuration, authentication, schema, tests, and bilingual documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): support Python 2 HTTP assertions Normalize response header names and call handler methods through their underlying functions so the HTTP tests behave consistently on Python 2.7 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): harden HTTP request handling Validate mirrored request IDs, align Config Studio listener checks, bound socket reads, and accept the binary wrapper's documented SIGINT exit status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): align HTTP negotiation validation Reject unavailable Accept media types by quality and keep schema host validation consistent with runtime listener checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): validate host schema behavior Assert accepted and rejected host samples instead of comparing Unicode escape representations that differ on Python 2.7. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): avoid cross-engine schema regex Keep the schema pattern contract without executing its ECMA-262 Unicode escape through Python 2's incompatible regex engine. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate HTTP request boundaries Reject empty tokens and malformed JSON-RPC request shapes, and keep Config Studio bracket validation aligned with runtime listener handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): tighten HTTP input validation Reject legacy notifications, nested host brackets, and document the required JSON content type so validation remains consistent across runtime, schema, and Config Studio. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate Origin before method errors Apply the existing Origin policy to non-POST MCP requests and return CORS headers for configured allowed origins. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): clarify proxy Origin allowlist Explain that browser clients behind TLS termination must allowlist the public HTTPS origin even when the public page appears same-origin. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): add standalone server guide Move MCP client setup, transports, tools, security, and protocol limits into dedicated Chinese and English pages with matching navigation and index links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): reject duplicate routing headers Reject repeated MCP protocol, method, and name headers before body consistency checks across Python 2 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate named request headers Validate Mcp-Name for tools/call, prompts/get, and resources/read before dispatching unsupported methods. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
764 lines
29 KiB
Python
764 lines
29 KiB
Python
# -*- coding:utf-8 -*-
|
|
"""
|
|
Configuration loader for DDNS command-line interface.
|
|
@author: NewFuture
|
|
"""
|
|
|
|
import platform
|
|
import sys
|
|
from argparse import SUPPRESS, Action, ArgumentParser, ArgumentTypeError, RawTextHelpFormatter
|
|
from logging import DEBUG, basicConfig, getLevelName, getLogger
|
|
from os import path as os_path
|
|
|
|
from ..http_config import HttpConfigError, resolve_http_settings
|
|
from ..scheduler import get_scheduler
|
|
from .env import load_config as load_env_config
|
|
from .file import DEFAULT_CONFIG_PATHS, load_config as load_file_config, save_config
|
|
|
|
__all__ = ["load_config", "str_bool"]
|
|
|
|
try:
|
|
integer_types = (int, long) # type: ignore[name-defined]
|
|
except NameError:
|
|
integer_types = (int,)
|
|
|
|
|
|
def str_bool(v):
|
|
# type: (str | bool | None | int | float | list) -> bool | str
|
|
"""
|
|
parse string to boolean
|
|
"""
|
|
if isinstance(v, bool):
|
|
return v
|
|
if v is None:
|
|
return False
|
|
if not isinstance(v, str) and not type(v).__name__ == "unicode":
|
|
return bool(v) # For non-string types, convert to string first
|
|
if v.lower() in ("yes", "true", "t", "y", "1"): # type: ignore[attribute-defined]
|
|
return True
|
|
elif v.lower() in ("no", "false", "f", "n", "0"): # type: ignore[attribute-defined]
|
|
return False
|
|
else:
|
|
return v # type: ignore[return-value]
|
|
|
|
|
|
def non_negative_int(value):
|
|
# type: (str) -> int
|
|
"""Parse a non-negative integer CLI option."""
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
raise ArgumentTypeError("must be a non-negative integer")
|
|
if parsed < 0:
|
|
raise ArgumentTypeError("must be a non-negative integer")
|
|
return parsed
|
|
|
|
|
|
def port_number(value):
|
|
# type: (str) -> int
|
|
"""Parse a valid TCP port number."""
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
raise ArgumentTypeError("must be a valid TCP port")
|
|
if parsed < 0 or parsed > 65535:
|
|
raise ArgumentTypeError("must be between 0 and 65535")
|
|
return parsed
|
|
|
|
|
|
def interval_minutes(value):
|
|
# type: (object) -> int
|
|
"""Parse a web scheduler interval."""
|
|
if isinstance(value, (bool, float)):
|
|
raise ArgumentTypeError("must be an integer between 1 and 1440")
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError):
|
|
raise ArgumentTypeError("must be an integer between 1 and 1440")
|
|
if parsed < 1 or parsed > 1440:
|
|
raise ArgumentTypeError("must be an integer between 1 and 1440")
|
|
return parsed
|
|
|
|
|
|
def config_interval_minutes(value):
|
|
# type: (object) -> int
|
|
"""Parse a JSON interval without coercing non-integer types."""
|
|
if isinstance(value, bool) or not isinstance(value, integer_types):
|
|
raise ArgumentTypeError("must be an integer between 1 and 1440")
|
|
return interval_minutes(value)
|
|
|
|
|
|
def log_level(value):
|
|
"""
|
|
parse string to log level
|
|
or getattr(logging, value.upper())
|
|
"""
|
|
return getLevelName(value if isinstance(value, int) else value.upper())
|
|
|
|
|
|
def _get_system_info_str():
|
|
system = platform.system()
|
|
release = platform.release()
|
|
machine = platform.machine()
|
|
arch = platform.architecture()
|
|
return "{}-{} {} {}".format(system, release, machine, arch)
|
|
|
|
|
|
def _get_python_info_str():
|
|
version = platform.python_version()
|
|
branch, py_build_date = platform.python_build()
|
|
return "Python-{} {} ({})".format(version, branch, py_build_date)
|
|
|
|
|
|
class ExtendAction(Action):
|
|
"""兼容 Python <3.8 的 extend action"""
|
|
|
|
def __call__(self, parser, namespace, values, option_string=None):
|
|
items = getattr(namespace, self.dest, None)
|
|
if items is None:
|
|
items = []
|
|
# values 可能是单个值或列表
|
|
if isinstance(values, list):
|
|
items.extend(values)
|
|
else:
|
|
items.append(values)
|
|
setattr(namespace, self.dest, items)
|
|
|
|
|
|
class NewConfigAction(Action):
|
|
"""生成配置文件并退出程序"""
|
|
|
|
def __call__(self, parser, namespace, values, option_string=None):
|
|
# 获取配置文件路径
|
|
if values and values != "true":
|
|
config_path = str(values) # type: str
|
|
else:
|
|
config_path = getattr(namespace, "config", None) or "config.json" # type: str
|
|
config_path = config_path[0] if isinstance(config_path, list) else config_path
|
|
if os_path.exists(config_path):
|
|
sys.stderr.write("The default %s already exists!\n" % config_path)
|
|
sys.stdout.write("Please use `--new-config=%s` to specify a new config file.\n" % config_path)
|
|
sys.exit(1)
|
|
# 获取当前已解析的参数
|
|
current_config = {k: v for k, v in vars(namespace).items() if v is not None}
|
|
# 保存配置文件
|
|
save_config(config_path, current_config)
|
|
sys.stdout.write("%s is generated.\n" % config_path)
|
|
sys.exit(0)
|
|
|
|
|
|
def _add_ddns_args(arg): # type: (ArgumentParser) -> None
|
|
"""Add common DDNS arguments to a parser"""
|
|
log_levels = [
|
|
"CRITICAL", # 50
|
|
"ERROR", # 40
|
|
"WARNING", # 30
|
|
"INFO", # 20
|
|
"DEBUG", # 10
|
|
"NOTSET", # 0
|
|
]
|
|
arg.add_argument(
|
|
"-c",
|
|
"--config",
|
|
nargs="*",
|
|
action=ExtendAction,
|
|
metavar="FILE",
|
|
help="load config file [配置文件路径, 可多次指定]",
|
|
)
|
|
arg.add_argument("--debug", action="store_true", help="debug mode [开启调试模式]")
|
|
|
|
# DDNS Configuration group
|
|
ddns = arg.add_argument_group("DDNS Configuration [DDNS配置参数]")
|
|
ddns.add_argument(
|
|
"--dns",
|
|
help="DNS provider [DNS服务提供商]",
|
|
choices=[
|
|
"51dns",
|
|
"alidns",
|
|
"aliesa",
|
|
"callback",
|
|
"cloudflare",
|
|
"cloudns",
|
|
"debug",
|
|
"dnscom",
|
|
"dnspod_com",
|
|
"dnspod",
|
|
"edgeone",
|
|
"edgeone_dns",
|
|
"he",
|
|
"huaweidns",
|
|
"namesilo",
|
|
"noip",
|
|
"tencentcloud",
|
|
"west",
|
|
],
|
|
)
|
|
ddns.add_argument("--id", help="API ID or email [对应账号ID或邮箱]")
|
|
ddns.add_argument("--token", help="API token or key [授权凭证或密钥]")
|
|
ddns.add_argument("--endpoint", help="API endpoint URL [API端点URL]")
|
|
ddns.add_argument(
|
|
"--index4", nargs="*", action=ExtendAction, metavar="RULE", help="IPv4 rules [获取IPv4方式, 多次可配置多规则]"
|
|
)
|
|
ddns.add_argument(
|
|
"--index6", nargs="*", action=ExtendAction, metavar="RULE", help="IPv6 rules [获取IPv6方式, 多次配置多规则]"
|
|
)
|
|
ddns.add_argument(
|
|
"--ipv4", nargs="*", action=ExtendAction, metavar="DOMAIN", help="IPv4 domains [IPv4域名列表, 可配多个域名]"
|
|
)
|
|
ddns.add_argument(
|
|
"--ipv6", nargs="*", action=ExtendAction, metavar="DOMAIN", help="IPv6 domains [IPv6域名列表, 可配多个域名]"
|
|
)
|
|
ddns.add_argument("--ttl", type=int, help="DNS TTL(s) [设置域名解析过期时间]")
|
|
ddns.add_argument("--line", help="DNS line/route [DNS线路设置]")
|
|
|
|
# Advanced Options group
|
|
advanced = arg.add_argument_group("Advanced Options [高级参数]")
|
|
advanced.add_argument("--proxy", nargs="*", action=ExtendAction, help="HTTP proxy [设置http代理,可配多个代理连接]")
|
|
advanced.add_argument(
|
|
"--cache", type=str_bool, nargs="?", const=True, help="set cache [启用缓存开关,或传入保存路径]"
|
|
)
|
|
advanced.add_argument(
|
|
"--cache-max-age",
|
|
"--cache_max_age",
|
|
dest="cache_max_age",
|
|
type=non_negative_int,
|
|
help="cache file max age in seconds [缓存文件最大有效期,单位秒]",
|
|
)
|
|
advanced.add_argument(
|
|
"--no-cache", dest="cache", action="store_const", const=False, help="disable cache [关闭缓存等效 --cache=false]"
|
|
)
|
|
advanced.add_argument(
|
|
"--ssl",
|
|
type=str_bool,
|
|
nargs="?",
|
|
const=True,
|
|
help="SSL certificate verification [SSL证书验证方式]: "
|
|
"true(强制验证), false(禁用验证), auto(自动降级), /path/to/cert.pem(自定义证书)",
|
|
)
|
|
advanced.add_argument(
|
|
"--no-ssl",
|
|
dest="ssl",
|
|
action="store_const",
|
|
const=False,
|
|
help="disable SSL verify [禁用验证, 等效 --ssl=false]",
|
|
)
|
|
advanced.add_argument("--log_file", metavar="FILE", help="log file [日志文件,默认标准输出]")
|
|
advanced.add_argument("--log.file", "--log-file", dest="log_file", help=SUPPRESS) # 隐藏参数
|
|
advanced.add_argument("--log_level", type=log_level, metavar="|".join(log_levels), help=None)
|
|
advanced.add_argument("--log.level", "--log-level", dest="log_level", type=log_level, help=SUPPRESS) # 隐藏参数
|
|
advanced.add_argument("--log_format", metavar="FORMAT", help="set log format [日志格式]")
|
|
advanced.add_argument("--log.format", "--log-format", dest="log_format", help=SUPPRESS) # 隐藏参数
|
|
advanced.add_argument("--log_datefmt", metavar="FORMAT", help="set log date format [日志时间格式]")
|
|
advanced.add_argument("--log.datefmt", "--log-datefmt", dest="log_datefmt", help=SUPPRESS) # 隐藏参数
|
|
|
|
|
|
def _add_task_subcommand(subparsers):
|
|
# type: (object) -> None
|
|
"""Add scheduled-task command arguments."""
|
|
task = subparsers.add_parser("task", help="Manage scheduled tasks [管理定时任务]")
|
|
task.set_defaults(func=_handle_task_command)
|
|
_add_ddns_args(task)
|
|
|
|
# Add task-specific arguments
|
|
task.add_argument(
|
|
"-i",
|
|
"--install",
|
|
nargs="?",
|
|
type=int,
|
|
const=5,
|
|
metavar="MINs",
|
|
help="Install task with <mins> [安装定时任务,默认5分钟]",
|
|
)
|
|
task.add_argument("--uninstall", action="store_true", help="Uninstall scheduled task [卸载定时任务]")
|
|
task.add_argument("--status", action="store_true", help="Show task status [显示定时任务状态]")
|
|
task.add_argument("--enable", action="store_true", help="Enable scheduled task [启用定时任务]")
|
|
task.add_argument("--disable", action="store_true", help="Disable scheduled task [禁用定时任务]")
|
|
task.add_argument(
|
|
"--scheduler",
|
|
choices=["auto", "systemd", "cron", "launchd", "schtasks"],
|
|
default="auto",
|
|
help="Specify scheduler type [指定定时任务方式]",
|
|
)
|
|
|
|
|
|
def _add_web_subcommand(subparsers):
|
|
# type: (object) -> None
|
|
"""Add embedded dashboard command arguments."""
|
|
web = subparsers.add_parser("web", help="Run local management dashboard [运行本机管理控制台]")
|
|
web.set_defaults(func=_handle_web_command)
|
|
web.add_argument("-c", "--config", metavar="FILE", help="local config file [本机配置文件]")
|
|
web.add_argument("--host", help="HTTP bind host [HTTP 监听地址]")
|
|
web.add_argument("--port", type=port_number, help="shared Web/MCP HTTP port [Web/MCP 共享端口]")
|
|
web.add_argument("--http-token", help="shared Web/MCP bearer token [Web/MCP 共享令牌]")
|
|
web.add_argument(
|
|
"--http-origin",
|
|
dest="http_origins",
|
|
action="append",
|
|
help="allowed MCP browser origin; repeatable [允许的 MCP 浏览器来源,可重复]",
|
|
)
|
|
web.add_argument(
|
|
"--interval",
|
|
type=interval_minutes,
|
|
metavar="MINs",
|
|
help="built-in sync interval; overrides config [内置同步间隔,优先于配置]",
|
|
)
|
|
web.add_argument("--open", action="store_true", help="open dashboard in browser [在浏览器中打开]")
|
|
web.add_argument("--debug", action="store_true", help="enable debug logging [启用调试日志]")
|
|
web.add_argument(
|
|
"--log-level", dest="log_level", type=log_level, default="INFO", help="set dashboard log level [控制台日志级别]"
|
|
)
|
|
|
|
|
|
def _add_mcp_subcommand(subparsers):
|
|
# type: (object) -> None
|
|
"""Add the local stdio MCP server command."""
|
|
mcp = subparsers.add_parser("mcp", help="Run the local MCP server [运行本机 MCP 服务]")
|
|
mcp.set_defaults(func=_handle_mcp_command)
|
|
mcp.add_argument("-c", "--config", metavar="FILE", help="local config file [本机配置文件]")
|
|
mcp.add_argument("--transport", choices=["stdio", "http"], default="stdio", help="MCP transport [MCP 传输方式]")
|
|
mcp.add_argument("--host", help="HTTP bind host [HTTP 监听地址]")
|
|
mcp.add_argument("--port", type=port_number, help="HTTP MCP port [HTTP MCP 端口]")
|
|
mcp.add_argument("--http-token", help="HTTP bearer token [HTTP 令牌]")
|
|
mcp.add_argument(
|
|
"--http-origin",
|
|
dest="http_origins",
|
|
action="append",
|
|
help="allowed MCP browser origin; repeatable [允许的 MCP 浏览器来源,可重复]",
|
|
)
|
|
|
|
|
|
def _add_task_subcommand_if_needed(parser): # type: (ArgumentParser) -> None
|
|
"""
|
|
Conditionally add subcommands to avoid Python 2 'too few arguments' error.
|
|
|
|
Python 2's argparse requires subcommand when subparsers are defined, but Python 3 doesn't.
|
|
We only add subparsers when the first argument is likely a subcommand (doesn't start with '-').
|
|
"""
|
|
# Python2 Only add subparsers when first argument is a subcommand (not an option)
|
|
if len(sys.argv) <= 1 or (sys.argv[1].startswith("-") and sys.argv[1] != "--help"):
|
|
return
|
|
|
|
subparsers = parser.add_subparsers(dest="command", help="subcommands [子命令]")
|
|
_add_task_subcommand(subparsers)
|
|
_add_web_subcommand(subparsers)
|
|
_add_mcp_subcommand(subparsers)
|
|
|
|
|
|
def _expand_web_interval_shorthand():
|
|
# type: () -> None
|
|
"""Treat a top-level --interval option as an implicit web command."""
|
|
if len(sys.argv) <= 1 or sys.argv[1] in ("task", "web", "mcp"):
|
|
return
|
|
if any(argument == "--interval" or argument.startswith("--interval=") for argument in sys.argv[1:]):
|
|
config_paths = _cli_config_paths(sys.argv[1:])
|
|
if config_paths is not None and len(config_paths) != 1:
|
|
_reject_multiple_web_configs()
|
|
sys.argv.insert(1, "web")
|
|
|
|
|
|
def _cli_config_paths(arguments):
|
|
# type: (list[str]) -> list[str] | None
|
|
result = None
|
|
index = 0
|
|
while index < len(arguments):
|
|
argument = arguments[index]
|
|
if argument in ("-c", "--config"):
|
|
if result is None:
|
|
result = []
|
|
index += 1
|
|
while index < len(arguments) and not arguments[index].startswith("-"):
|
|
result.append(arguments[index])
|
|
index += 1
|
|
continue
|
|
if argument.startswith("--config="):
|
|
if result is None:
|
|
result = []
|
|
result.append(argument.split("=", 1)[1])
|
|
index += 1
|
|
return result
|
|
|
|
|
|
def _reject_multiple_web_configs():
|
|
# type: () -> None
|
|
sys.stderr.write("ddns web: exactly one local configuration file is supported.\n")
|
|
sys.exit(2)
|
|
|
|
|
|
def _default_local_config_path():
|
|
# type: () -> str | None
|
|
for candidate in DEFAULT_CONFIG_PATHS:
|
|
candidate = os_path.expanduser(candidate)
|
|
if os_path.isfile(candidate):
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _document_interval(config_path):
|
|
# type: (str) -> tuple[bool, object | None]
|
|
if not config_path or "://" in config_path or not os_path.isfile(config_path):
|
|
return False, None
|
|
document = load_file_config(config_path, raw=True)
|
|
return (True, document.get("interval")) if isinstance(document, dict) and "interval" in document else (False, None)
|
|
|
|
|
|
def _configured_web_interval():
|
|
# type: () -> tuple[str, object] | None
|
|
cli_paths = _cli_config_paths(sys.argv[1:])
|
|
config_path = None
|
|
if cli_paths is not None:
|
|
if len(cli_paths) != 1:
|
|
if any(_document_interval(path)[0] for path in cli_paths):
|
|
_reject_multiple_web_configs()
|
|
return None
|
|
config_path = cli_paths[0]
|
|
else:
|
|
config_path = load_env_config().get("config")
|
|
if config_path:
|
|
from .config import split_array_string
|
|
|
|
config_paths = split_array_string(config_path, preserve_special=False)
|
|
if len(config_paths) != 1:
|
|
if any(_document_interval(path)[0] for path in config_paths):
|
|
_reject_multiple_web_configs()
|
|
return None
|
|
config_path = config_paths[0]
|
|
if not config_path:
|
|
config_path = _default_local_config_path()
|
|
has_interval, interval = _document_interval(config_path)
|
|
if not has_interval:
|
|
return None
|
|
return config_path, interval
|
|
|
|
|
|
def _validate_explicit_web_configs():
|
|
# type: () -> None
|
|
if len(sys.argv) <= 1 or sys.argv[1] != "web":
|
|
return
|
|
config_paths = _cli_config_paths(sys.argv[2:])
|
|
if config_paths is not None and len(config_paths) != 1:
|
|
_reject_multiple_web_configs()
|
|
|
|
|
|
def _validate_explicit_mcp_configs():
|
|
# type: () -> None
|
|
if len(sys.argv) <= 1 or sys.argv[1] != "mcp":
|
|
return
|
|
config_paths = _cli_config_paths(sys.argv[2:])
|
|
if config_paths is not None and len(config_paths) != 1:
|
|
sys.stderr.write("ddns mcp: exactly one local configuration file is supported.\n")
|
|
sys.exit(2)
|
|
|
|
|
|
def _validate_web_mode_arguments():
|
|
# type: () -> None
|
|
if len(sys.argv) <= 1 or sys.argv[1] != "web":
|
|
return
|
|
value_options = ("-c", "--config", "--host", "--port", "--http-token", "--http-origin", "--interval", "--log-level")
|
|
flag_options = ("--open", "--debug", "-h", "--help")
|
|
long_value_prefixes = tuple(option + "=" for option in value_options if option.startswith("--"))
|
|
arguments = sys.argv[2:]
|
|
index = 0
|
|
while index < len(arguments):
|
|
argument = arguments[index]
|
|
if argument in value_options:
|
|
index += 2
|
|
continue
|
|
if argument in flag_options or argument.startswith(long_value_prefixes):
|
|
index += 1
|
|
continue
|
|
sys.stderr.write(
|
|
"ddns web: unsupported option {}; edit the local configuration file instead.\n".format(argument)
|
|
)
|
|
sys.exit(2)
|
|
|
|
|
|
def _validate_mcp_mode_arguments():
|
|
# type: () -> None
|
|
if len(sys.argv) <= 1 or sys.argv[1] != "mcp":
|
|
return
|
|
value_options = ("-c", "--config", "--transport", "--host", "--port", "--http-token", "--http-origin")
|
|
long_value_prefixes = tuple(option + "=" for option in value_options if option.startswith("--"))
|
|
arguments = sys.argv[2:]
|
|
index = 0
|
|
while index < len(arguments):
|
|
argument = arguments[index]
|
|
if argument in value_options:
|
|
index += 2
|
|
continue
|
|
if argument in ("-h", "--help") or argument.startswith(long_value_prefixes):
|
|
index += 1
|
|
continue
|
|
sys.stderr.write("ddns mcp: unsupported option {}.\n".format(argument))
|
|
sys.exit(2)
|
|
|
|
|
|
def _expand_web_config_shorthand():
|
|
# type: () -> None
|
|
arguments = sys.argv[1:]
|
|
if (
|
|
(arguments and arguments[0] in ("task", "web", "mcp"))
|
|
or any(argument in ("-h", "--help", "-v", "--version", "--new-config") for argument in arguments)
|
|
or any(argument.startswith("--new-config=") for argument in arguments)
|
|
):
|
|
return
|
|
configured = _configured_web_interval()
|
|
if configured is None:
|
|
return
|
|
config_path, interval = configured
|
|
try:
|
|
interval = config_interval_minutes(interval)
|
|
except ArgumentTypeError as error:
|
|
sys.stderr.write("ddns web: interval {}\n".format(error))
|
|
sys.exit(2)
|
|
original_arguments = arguments
|
|
prefix = ["web"] if _cli_config_paths(original_arguments) is not None else ["web", "--config", config_path]
|
|
sys.argv[1:] = prefix + original_arguments + ["--interval", str(interval)]
|
|
|
|
|
|
def load_config(description, doc, version, date):
|
|
# type: (str, str, str, str) -> dict
|
|
"""
|
|
解析命令行参数并返回配置字典。
|
|
|
|
Args:
|
|
description (str): 程序描述
|
|
doc (str): 程序文档
|
|
version (str): 程序版本
|
|
date (str): 构建日期
|
|
|
|
Returns:
|
|
dict: 配置字典
|
|
"""
|
|
_expand_web_interval_shorthand()
|
|
_expand_web_config_shorthand()
|
|
_validate_explicit_web_configs()
|
|
_validate_explicit_mcp_configs()
|
|
_validate_web_mode_arguments()
|
|
_validate_mcp_mode_arguments()
|
|
parser = ArgumentParser(description=description, epilog=doc, formatter_class=RawTextHelpFormatter)
|
|
sysinfo = _get_system_info_str()
|
|
pyinfo = _get_python_info_str()
|
|
compiled = getattr(sys.modules["__main__"], "__compiled__", "")
|
|
version_str = "v{} ({})\n{}\n{}\n{}".format(version, date, pyinfo, sysinfo, compiled)
|
|
|
|
_add_ddns_args(parser) # Add common DDNS arguments to main parser
|
|
# Default behavior (no subcommand) - add all the regular DDNS options
|
|
parser.add_argument("-v", "--version", action="version", version=version_str)
|
|
parser.add_argument(
|
|
"--new-config", metavar="FILE", action=NewConfigAction, nargs="?", help="generate new config [生成配置文件]"
|
|
)
|
|
parser.add_argument(
|
|
"--interval",
|
|
type=interval_minutes,
|
|
metavar="MINs",
|
|
help="run the local dashboard with built-in sync interval [按内置同步间隔运行本机控制台]",
|
|
)
|
|
|
|
# Python 2/3 compatibility: conditionally add subparsers to avoid 'too few arguments' error
|
|
# Subparsers are only needed when user provides a subcommand (non-option argument)
|
|
_add_task_subcommand_if_needed(parser)
|
|
|
|
args, unknown = parser.parse_known_args()
|
|
|
|
# Parse unknown arguments that follow --extra.xxx format
|
|
extra_args = {} # type: dict
|
|
i = 0
|
|
while i < len(unknown):
|
|
arg = unknown[i]
|
|
if arg.startswith("--extra."):
|
|
key = "extra_" + arg[8:] # Remove "--extra." and add "extra_" prefix
|
|
# Check if there's a value for this argument
|
|
if i + 1 < len(unknown) and not unknown[i + 1].startswith("--"):
|
|
extra_args[key] = unknown[i + 1]
|
|
i += 2
|
|
else:
|
|
# No value provided, set to True (flag)
|
|
extra_args[key] = True # type: ignore[assignment]
|
|
i += 1
|
|
else:
|
|
# Unknown argument that doesn't match our pattern
|
|
sys.stderr.write("Warning: Unknown argument: {}\n".format(arg))
|
|
i += 1
|
|
|
|
# Merge extra_args into args namespace
|
|
for k, v in extra_args.items():
|
|
setattr(args, k, v)
|
|
|
|
# Handle task subcommand and exit early if present
|
|
if hasattr(args, "func"):
|
|
args.func(vars(args))
|
|
sys.exit(0)
|
|
|
|
is_debug = getattr(args, "debug", False)
|
|
if is_debug:
|
|
# 如果启用调试模式,则强制设置日志级别为 DEBUG
|
|
args.log_level = log_level("DEBUG")
|
|
if args.cache is None:
|
|
args.cache = False # 禁用缓存
|
|
|
|
# 将 Namespace 对象转换为字典并直接返回
|
|
config = vars(args)
|
|
return {k: v for k, v in config.items() if v is not None} # 过滤掉 None 值的配置项
|
|
|
|
|
|
def _handle_task_command(args): # type: (dict) -> None
|
|
"""Handle task subcommand"""
|
|
basicConfig(level=args["debug"] and DEBUG or args.get("log_level", "INFO"))
|
|
|
|
# Use specified scheduler or auto-detect
|
|
scheduler_type = args.get("scheduler", "auto")
|
|
scheduler = get_scheduler(scheduler_type)
|
|
|
|
interval = args.get("install", 5) or 5
|
|
excluded_keys = ("status", "install", "uninstall", "enable", "disable", "command", "scheduler", "func")
|
|
ddns_args = {k: v for k, v in args.items() if k not in excluded_keys and v is not None}
|
|
|
|
# Execute operations
|
|
for op in ["install", "uninstall", "enable", "disable"]:
|
|
if not args.get(op):
|
|
continue
|
|
|
|
# Check if task is installed for enable/disable
|
|
if op in ["enable", "disable"] and not scheduler.is_installed():
|
|
print("DDNS task is not installed" + (" Please install it first." if op == "enable" else "."))
|
|
sys.exit(1)
|
|
# Execute operation
|
|
print("{} DDNS scheduled task...".format(op.title()))
|
|
func = getattr(scheduler, op)
|
|
result = func(interval, ddns_args) if op == "install" else func()
|
|
|
|
if result:
|
|
past_tense = {
|
|
"install": "installed",
|
|
"uninstall": "uninstalled",
|
|
"enable": "enabled",
|
|
"disable": "disabled",
|
|
}[op]
|
|
suffix = " with {} minute interval".format(interval) if op == "install" else ""
|
|
print("DDNS task {} successfully{}".format(past_tense, suffix))
|
|
else:
|
|
print("Failed to {} DDNS task".format(op))
|
|
sys.exit(1)
|
|
return
|
|
|
|
# Show status or auto-install
|
|
status = scheduler.get_status()
|
|
|
|
if args.get("status") or status["installed"]:
|
|
print("DDNS Task Status:")
|
|
print(" Installed: {}".format("Yes" if status["installed"] else "No"))
|
|
print(" Scheduler: {}".format(status["scheduler"]))
|
|
if status["installed"]:
|
|
print(" Enabled: {}".format(status.get("enabled", "unknown")))
|
|
print(" Interval: {} minutes".format(status.get("interval", "unknown")))
|
|
print(" Command: {}".format(status.get("command", "unknown")))
|
|
print(" Description: {}".format(status.get("description", "")))
|
|
else:
|
|
print("DDNS task is not installed. Installing with default settings...")
|
|
if scheduler.install(interval, ddns_args):
|
|
print("DDNS task installed successfully with {} minute interval".format(interval))
|
|
else:
|
|
print("Failed to install DDNS task")
|
|
sys.exit(1)
|
|
|
|
|
|
def _local_config_path(args, command, env_config=None):
|
|
# type: (dict, str, dict | None) -> str | None
|
|
"""Resolve one local config path shared by long-running local services."""
|
|
config_path = args.get("config")
|
|
if not config_path:
|
|
config_path = (env_config if env_config is not None else load_env_config()).get("config")
|
|
if not config_path:
|
|
config_path = _default_local_config_path()
|
|
if config_path:
|
|
from .config import split_array_string
|
|
|
|
config_paths = split_array_string(config_path, preserve_special=False)
|
|
if len(config_paths) != 1:
|
|
sys.stderr.write("ddns {}: exactly one local configuration file is supported.\n".format(command))
|
|
sys.exit(2)
|
|
config_path = config_paths[0]
|
|
if "://" in config_path:
|
|
sys.stderr.write("ddns {}: remote configuration files are not supported.\n".format(command))
|
|
sys.exit(2)
|
|
return config_path
|
|
|
|
|
|
def _local_document(config_path):
|
|
# type: (str | None) -> dict
|
|
"""Read one local root document when it already exists."""
|
|
if not config_path or not os_path.isfile(config_path):
|
|
return {}
|
|
document = load_file_config(config_path, raw=True)
|
|
return document if isinstance(document, dict) else {}
|
|
|
|
|
|
def _http_settings_or_exit(args, document, env_config, command):
|
|
# type: (dict, dict, dict, str) -> dict
|
|
try:
|
|
return resolve_http_settings(cli_config=args, document=document, env_config=env_config)
|
|
except HttpConfigError as error:
|
|
sys.stderr.write("ddns {}: {}\n".format(command, error))
|
|
sys.exit(2)
|
|
|
|
|
|
def _handle_web_command(args):
|
|
# type: (dict) -> None
|
|
"""Run the embedded Web and MCP HTTP server."""
|
|
basicConfig(level=args.get("debug") and DEBUG or args.get("log_level", "INFO"))
|
|
env_config = load_env_config()
|
|
config_path = _local_config_path(args, "web", env_config=env_config)
|
|
document = _local_document(config_path)
|
|
http_settings = _http_settings_or_exit(args, document, env_config, "web")
|
|
|
|
interval = args.get("interval")
|
|
interval_from_config = False
|
|
if interval is None:
|
|
interval = document.get("interval")
|
|
interval_from_config = interval is not None
|
|
if interval is None:
|
|
interval = 5
|
|
try:
|
|
interval = config_interval_minutes(interval) if interval_from_config else interval_minutes(interval)
|
|
except ArgumentTypeError as error:
|
|
sys.stderr.write("ddns web: interval {}\n".format(error))
|
|
sys.exit(2)
|
|
|
|
from ..web import serve
|
|
|
|
serve(
|
|
config_path=config_path,
|
|
host=http_settings["host"],
|
|
port=http_settings["port"],
|
|
token=http_settings["token"],
|
|
origins=http_settings["origins"],
|
|
open_browser=args.get("open", False),
|
|
logger=getLogger(),
|
|
interval=interval,
|
|
)
|
|
|
|
|
|
def _handle_mcp_command(args):
|
|
# type: (dict) -> None
|
|
"""Run the selected MCP transport."""
|
|
basicConfig()
|
|
transport = args.get("transport", "stdio")
|
|
if transport == "stdio":
|
|
if any(args.get(name) is not None for name in ("host", "port", "http_token", "http_origins")):
|
|
sys.stderr.write("ddns mcp: HTTP options require --transport http.\n")
|
|
sys.exit(2)
|
|
env_config = {} if args.get("config") else load_env_config()
|
|
config_path = _local_config_path(args, "mcp", env_config=env_config)
|
|
from ..mcp import serve
|
|
|
|
serve(config_path=config_path)
|
|
return
|
|
|
|
env_config = load_env_config()
|
|
config_path = _local_config_path(args, "mcp", env_config=env_config)
|
|
document = _local_document(config_path)
|
|
http_settings = _http_settings_or_exit(args, document, env_config, "mcp")
|
|
from ..mcp_http import serve as serve_http
|
|
|
|
serve_http(config_path=config_path, settings=http_settings, logger=getLogger())
|