mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-19 16:15:45 +00:00
feat(mcp): add Streamable HTTP transport (#736)
* 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>
This commit is contained in:
co-authored by
Copilot App
NewFuture
parent
2501c4a1d8
commit
3c8578e9f4
@@ -72,8 +72,10 @@ ddns/: Main application code
|
||||
__init__.py: Package initialization and version info
|
||||
__main__.py: Entry point for module execution
|
||||
cache.py: Cache management
|
||||
http_config.py: Shared Web and MCP HTTP listener settings
|
||||
ip.py: IP address detection logic
|
||||
mcp.py: Model Context Protocol helper utilities
|
||||
mcp_http.py: MCP Streamable HTTP transport
|
||||
|
||||
config/: Configuration management
|
||||
__init__.py
|
||||
@@ -134,6 +136,7 @@ tests/: Unit tests
|
||||
test_cache.py: Cache tests
|
||||
test_config_*.py: Configuration tests
|
||||
test_ip.py: IP detection tests
|
||||
test_mcp_http.py: MCP Streamable HTTP tests
|
||||
test_provider_*.py: Provider-specific tests
|
||||
test_scheduler_*.py: Scheduler tests
|
||||
test_util_*.py: Utility tests
|
||||
@@ -146,6 +149,7 @@ docs/: Documentation (VitePress-based)
|
||||
cli.md: CLI usage guide
|
||||
env.md: Environment variables guide
|
||||
json.md: JSON configuration guide
|
||||
mcp.md: MCP server guide
|
||||
|
||||
dev/: Developer guides (Chinese)
|
||||
provider.md: Provider development guide
|
||||
|
||||
+8
-2
@@ -109,7 +109,7 @@ Keep the local console running and synchronize every five minutes in the same pr
|
||||
ddns -c config.json --interval 5 --open
|
||||
```
|
||||
|
||||
Providing `--interval` automatically selects Web mode, so the `web` word is optional. You can also set top-level `"interval": 5` in a local JSON file; then `ddns -c config.json` starts Web automatically. The command-line value overrides the configuration, and the existing `ddns web` command remains compatible. Saving a new interval in the page writes it back to the configuration; pausing and resuming affect only the current process. Web mode blocks duplicate scheduling when an enabled system task is detected. Use systemd, launchd, a Windows service, or Docker to supervise the Web process in production.
|
||||
Providing `--interval` automatically selects Web mode, so the `web` word is optional; top-level JSON `"interval": 5` does the same. Web also exposes MCP `2026-07-28` Streamable HTTP at `/mcp`. When the default loopback listener has no token, the Web APIs and `/mcp` are unauthenticated; non-loopback and wildcard listeners require a shared HTTP token and should be protected by an HTTPS reverse proxy. Saving an interval writes it back to the configuration; pausing and resuming affect only the current process.
|
||||
|
||||
Let a local MCP client such as GitHub Copilot inspect cached status or trigger one complete synchronization after user approval:
|
||||
|
||||
@@ -117,7 +117,13 @@ Let a local MCP client such as GitHub Copilot inspect cached status or trigger o
|
||||
ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
This stdio server uses only the Python standard library, opens no network listener, and does not expose configuration credentials to the model. It supports MCP `2026-07-28` and the `2025-11-25` lifecycle used by GitHub Copilot CLI. See the [MCP command documentation](docs/en/config/cli.md#mcp-server) for client setup, tools, and limitations.
|
||||
This stdio server uses only the Python standard library, opens no network listener, and does not expose configuration credentials to the model. It supports MCP `2026-07-28` and the `2025-11-25` lifecycle used by GitHub Copilot CLI. See the [MCP server documentation](docs/en/config/mcp.md) for client setup, tools, and limitations.
|
||||
|
||||
MCP clients that cannot launch a local process can use the standalone HTTP endpoint:
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json --transport http
|
||||
```
|
||||
|
||||
For a headless deployment, install a system task that runs every five minutes:
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ ddns -c config.json
|
||||
ddns -c config.json --interval 5 --open
|
||||
```
|
||||
|
||||
提供 `--interval` 会自动进入 Web 模式,无需额外写 `web`;也可在本地 JSON 顶层配置 `"interval": 5`,此时 `ddns -c config.json` 会自动启动 Web。命令行值优先于配置,原有 `ddns web` 命令继续兼容。页面保存的新间隔会写回配置;暂停和恢复只影响当前进程。检测到已启用的系统任务时,Web 会阻止重复调度。生产环境应使用 systemd、launchd、Windows 服务或 Docker 负责 Web 进程保活。
|
||||
提供 `--interval` 会自动进入 Web 模式,无需额外写 `web`;也可在本地 JSON 顶层配置 `"interval": 5`。Web 同时在 `/mcp` 提供 MCP `2026-07-28` Streamable HTTP。默认回环监听未配置 token 时,Web API 与 `/mcp` 均免认证;非回环或通配监听必须配置共享 HTTP token,并应由 HTTPS 反向代理保护。页面保存的新间隔会写回配置;暂停和恢复只影响当前进程。
|
||||
|
||||
让 GitHub Copilot 等本机 MCP 客户端查询缓存状态,或在人工确认后触发一次完整同步:
|
||||
|
||||
@@ -117,7 +117,13 @@ ddns -c config.json --interval 5 --open
|
||||
ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
该 stdio 服务只使用 Python 标准库,不监听网络,也不会向模型暴露配置凭据;支持 MCP `2026-07-28`,并兼容 GitHub Copilot CLI 使用的 `2025-11-25`。具体客户端配置、工具与限制见 [MCP 命令文档](docs/config/cli.md#mcp-服务)。
|
||||
该 stdio 服务只使用 Python 标准库,不监听网络,也不会向模型暴露配置凭据;支持 MCP `2026-07-28`,并兼容 GitHub Copilot CLI 使用的 `2025-11-25`。具体客户端配置、工具与限制见 [MCP 服务文档](docs/config/mcp.md)。
|
||||
|
||||
不能启动本机进程的 MCP 客户端也可连接独立 HTTP endpoint:
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json --transport http
|
||||
```
|
||||
|
||||
不需要 Web 控制台时,可以安装每 5 分钟执行一次的系统定时任务:
|
||||
|
||||
|
||||
+72
-21
@@ -10,6 +10,7 @@ from argparse import SUPPRESS, Action, ArgumentParser, ArgumentTypeError, RawTex
|
||||
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
|
||||
@@ -286,10 +287,15 @@ def _add_web_subcommand(subparsers):
|
||||
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(
|
||||
"--host", choices=["127.0.0.1", "localhost", "::1"], default="127.0.0.1", help="loopback address [本机监听地址]"
|
||||
"--http-origin",
|
||||
dest="http_origins",
|
||||
action="append",
|
||||
help="allowed MCP browser origin; repeatable [允许的 MCP 浏览器来源,可重复]",
|
||||
)
|
||||
web.add_argument("--port", type=port_number, default=9876, help="dashboard port [控制台端口]")
|
||||
web.add_argument(
|
||||
"--interval",
|
||||
type=interval_minutes,
|
||||
@@ -309,6 +315,16 @@ def _add_mcp_subcommand(subparsers):
|
||||
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
|
||||
@@ -437,7 +453,7 @@ def _validate_web_mode_arguments():
|
||||
# type: () -> None
|
||||
if len(sys.argv) <= 1 or sys.argv[1] != "web":
|
||||
return
|
||||
value_options = ("-c", "--config", "--host", "--port", "--interval", "--log-level")
|
||||
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:]
|
||||
@@ -460,8 +476,8 @@ def _validate_mcp_mode_arguments():
|
||||
# type: () -> None
|
||||
if len(sys.argv) <= 1 or sys.argv[1] != "mcp":
|
||||
return
|
||||
value_options = ("-c", "--config")
|
||||
long_value_prefixes = ("--config=",)
|
||||
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):
|
||||
@@ -646,12 +662,12 @@ def _handle_task_command(args): # type: (dict) -> None
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _local_config_path(args, command):
|
||||
# type: (dict, str) -> str | None
|
||||
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 = load_env_config().get("config")
|
||||
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:
|
||||
@@ -668,19 +684,38 @@ def _local_config_path(args, command):
|
||||
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 local-only embedded management dashboard."""
|
||||
"""Run the embedded Web and MCP HTTP server."""
|
||||
basicConfig(level=args.get("debug") and DEBUG or args.get("log_level", "INFO"))
|
||||
config_path = _local_config_path(args, "web")
|
||||
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 and config_path and os_path.isfile(config_path):
|
||||
document = load_file_config(config_path, raw=True)
|
||||
if isinstance(document, dict):
|
||||
interval = document.get("interval")
|
||||
interval_from_config = interval is not None
|
||||
if interval is None:
|
||||
interval = document.get("interval")
|
||||
interval_from_config = interval is not None
|
||||
if interval is None:
|
||||
interval = 5
|
||||
try:
|
||||
@@ -693,8 +728,10 @@ def _handle_web_command(args):
|
||||
|
||||
serve(
|
||||
config_path=config_path,
|
||||
host=args.get("host", "127.0.0.1"),
|
||||
port=args.get("port", 9876),
|
||||
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,
|
||||
@@ -703,10 +740,24 @@ def _handle_web_command(args):
|
||||
|
||||
def _handle_mcp_command(args):
|
||||
# type: (dict) -> None
|
||||
"""Run the local stdio MCP server."""
|
||||
"""Run the selected MCP transport."""
|
||||
basicConfig()
|
||||
config_path = _local_config_path(args, "mcp")
|
||||
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
|
||||
|
||||
from ..mcp import serve
|
||||
serve(config_path=config_path)
|
||||
return
|
||||
|
||||
serve(config_path=config_path)
|
||||
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())
|
||||
|
||||
@@ -118,6 +118,11 @@ class Config(object):
|
||||
"cache",
|
||||
"cache_max_age",
|
||||
"interval",
|
||||
"http",
|
||||
"http_host",
|
||||
"http_port",
|
||||
"http_token",
|
||||
"http_origins",
|
||||
"ssl",
|
||||
"log_level",
|
||||
"log_format",
|
||||
|
||||
+3
-1
@@ -72,6 +72,8 @@ def load_config(prefix="DDNS_"):
|
||||
config_key = lower_key[len(prefix) :].replace(".", "_") # noqa: E203
|
||||
|
||||
if config_key:
|
||||
env_vars[config_key] = _try_parse_array(value, key=key)
|
||||
env_vars[config_key] = (
|
||||
value.strip() if config_key in ("token", "http_token") else _try_parse_array(value, key=key)
|
||||
)
|
||||
|
||||
return env_vars
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
"cache",
|
||||
"cache_max_age",
|
||||
"interval",
|
||||
"http",
|
||||
"ssl",
|
||||
"log",
|
||||
"extra",
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ def _process_multi_providers(config):
|
||||
result = []
|
||||
|
||||
# 提取全局配置(除providers之外的所有配置)
|
||||
global_config = _flatten_single_config(config, exclude_keys=["providers"], preserve_keys=["extra"])
|
||||
global_config = _flatten_single_config(config, exclude_keys=["providers"], preserve_keys=["extra", "http"])
|
||||
|
||||
# 检查providers和dns字段不能同时使用
|
||||
if global_config.get("dns"):
|
||||
@@ -139,7 +139,7 @@ def load_config(config_path, proxy=None, ssl="auto", raw=False, timeout=None):
|
||||
if "providers" in config and isinstance(config["providers"], list):
|
||||
return _process_multi_providers(config)
|
||||
else:
|
||||
return _flatten_single_config(config, preserve_keys=["extra"])
|
||||
return _flatten_single_config(config, preserve_keys=["extra", "http"])
|
||||
|
||||
|
||||
def save_config(config_path, config):
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Shared HTTP listener configuration for the Web and MCP servers."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import hmac
|
||||
import socket
|
||||
import struct
|
||||
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
except ImportError: # Python 2
|
||||
from urlparse import urlparse
|
||||
|
||||
try:
|
||||
string_types = (basestring,) # type: ignore[name-defined]
|
||||
except NameError:
|
||||
string_types = (str,)
|
||||
|
||||
|
||||
DEFAULT_HTTP_HOST = "127.0.0.1"
|
||||
DEFAULT_HTTP_PORT = 9876
|
||||
HTTP_CONNECTION_TIMEOUT = 15
|
||||
|
||||
|
||||
class HttpConfigError(ValueError):
|
||||
"""Raised when shared HTTP listener settings are invalid."""
|
||||
|
||||
|
||||
def is_loopback_host(host):
|
||||
# type: (str) -> bool
|
||||
"""Return whether a bind host is unambiguously loopback-only."""
|
||||
if not isinstance(host, string_types):
|
||||
return False
|
||||
normalized = host.strip().lower()
|
||||
if normalized == "localhost":
|
||||
return True
|
||||
if normalized.startswith("[") and normalized.endswith("]"):
|
||||
normalized = normalized[1:-1]
|
||||
try:
|
||||
packed = socket.inet_aton(normalized)
|
||||
return struct.unpack("!I", packed)[0] >> 24 == 127
|
||||
except (AttributeError, socket.error, struct.error):
|
||||
pass
|
||||
if normalized == "::1":
|
||||
return True
|
||||
if hasattr(socket, "inet_pton"):
|
||||
try:
|
||||
packed = socket.inet_pton(socket.AF_INET6, normalized)
|
||||
return packed == (b"\x00" * 15 + b"\x01")
|
||||
except (AttributeError, socket.error):
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
def _constant_time_equal(left, right):
|
||||
# type: (str, str) -> bool
|
||||
left_bytes = left.encode("utf-8")
|
||||
right_bytes = right.encode("utf-8")
|
||||
try:
|
||||
return hmac.compare_digest(left_bytes, right_bytes)
|
||||
except AttributeError: # Python 2.7 before compare_digest
|
||||
result = len(left_bytes) ^ len(right_bytes)
|
||||
for index in range(max(len(left_bytes), len(right_bytes))):
|
||||
left_value = ord(left_bytes[index]) if index < len(left_bytes) else 0
|
||||
right_value = ord(right_bytes[index]) if index < len(right_bytes) else 0
|
||||
result |= left_value ^ right_value
|
||||
return result == 0
|
||||
|
||||
|
||||
def _bearer_token(value):
|
||||
# type: (str | None) -> str | None
|
||||
if not isinstance(value, string_types):
|
||||
return None
|
||||
parts = value.strip().split(None, 1)
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
return None
|
||||
return parts[1]
|
||||
|
||||
|
||||
def request_token_matches(headers, token, allow_dashboard_header=False):
|
||||
# type: (object, str | None, bool) -> bool
|
||||
"""Validate the shared HTTP token without reflecting it."""
|
||||
if token is None:
|
||||
return True
|
||||
supplied = _bearer_token(headers.get("Authorization"))
|
||||
if supplied is None and allow_dashboard_header:
|
||||
supplied = headers.get("X-DDNS-Token")
|
||||
if not isinstance(supplied, string_types):
|
||||
return False
|
||||
try:
|
||||
return _constant_time_equal(supplied, token)
|
||||
except (TypeError, UnicodeError):
|
||||
return False
|
||||
|
||||
|
||||
def _normalize_host(value):
|
||||
# type: (object) -> str
|
||||
if not isinstance(value, string_types):
|
||||
raise HttpConfigError("HTTP host must be a string.")
|
||||
host = value.strip()
|
||||
if host.startswith("[") != host.endswith("]"):
|
||||
raise HttpConfigError("HTTP host contains mismatched brackets.")
|
||||
if host.startswith("["):
|
||||
host = host[1:-1]
|
||||
if (
|
||||
not host
|
||||
or any(character in host for character in ("\r", "\n", "\x00", "/", "\\", "[", "]"))
|
||||
or any(character.isspace() for character in host)
|
||||
):
|
||||
raise HttpConfigError("HTTP host must be a non-empty bind address.")
|
||||
return host
|
||||
|
||||
|
||||
def _normalize_port(value):
|
||||
# type: (object) -> int
|
||||
if isinstance(value, (bool, float)):
|
||||
raise HttpConfigError("HTTP port must be an integer from 0 to 65535.")
|
||||
try:
|
||||
port = int(value)
|
||||
except (TypeError, ValueError):
|
||||
raise HttpConfigError("HTTP port must be an integer from 0 to 65535.")
|
||||
if port < 0 or port > 65535:
|
||||
raise HttpConfigError("HTTP port must be an integer from 0 to 65535.")
|
||||
return port
|
||||
|
||||
|
||||
def _normalize_token(value):
|
||||
# type: (object) -> str | None
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, string_types):
|
||||
raise HttpConfigError("HTTP token must be a string or null.")
|
||||
token = value
|
||||
if not token:
|
||||
raise HttpConfigError("HTTP token must be non-empty or null.")
|
||||
if "\r" in token or "\n" in token or "\x00" in token:
|
||||
raise HttpConfigError("HTTP token cannot contain control characters.")
|
||||
try:
|
||||
encoded = token.encode("ascii")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
raise HttpConfigError("HTTP token must contain visible ASCII characters only.")
|
||||
if any(byte < 0x21 or byte > 0x7E for byte in bytearray(encoded)):
|
||||
raise HttpConfigError("HTTP token must contain visible ASCII characters only.")
|
||||
return token
|
||||
|
||||
|
||||
def normalize_origin(value):
|
||||
# type: (object) -> str
|
||||
"""Validate and canonicalize one exact HTTP origin."""
|
||||
if not isinstance(value, string_types):
|
||||
raise HttpConfigError("HTTP origins must contain strings.")
|
||||
origin = value.strip()
|
||||
if any(character.isspace() or ord(character) < 0x20 for character in origin):
|
||||
raise HttpConfigError("HTTP origin is invalid: {}.".format(value))
|
||||
try:
|
||||
parsed = urlparse(origin)
|
||||
port = parsed.port
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
raise HttpConfigError("HTTP origin is invalid: {}.".format(value))
|
||||
if (
|
||||
parsed.scheme.lower() not in ("http", "https")
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.path not in ("", "/")
|
||||
or parsed.params
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise HttpConfigError("HTTP origin must be an exact http(s) origin: {}.".format(value))
|
||||
|
||||
scheme = parsed.scheme.lower()
|
||||
hostname = parsed.hostname.lower()
|
||||
if ":" in hostname:
|
||||
hostname = "[{}]".format(hostname)
|
||||
default_port = 80 if scheme == "http" else 443
|
||||
suffix = "" if port in (None, default_port) else ":{}".format(port)
|
||||
return "{}://{}{}".format(scheme, hostname, suffix)
|
||||
|
||||
|
||||
def _normalize_origins(value):
|
||||
# type: (object) -> list[str]
|
||||
if isinstance(value, string_types):
|
||||
values = [item.strip() for item in value.replace(";", ",").split(",") if item.strip()]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
values = list(value)
|
||||
else:
|
||||
raise HttpConfigError("HTTP origins must be an array.")
|
||||
|
||||
origins = []
|
||||
for item in values:
|
||||
origin = normalize_origin(item)
|
||||
if origin not in origins:
|
||||
origins.append(origin)
|
||||
return origins
|
||||
|
||||
|
||||
def normalize_http_settings(value=None, enforce_bind_auth=True):
|
||||
# type: (dict | None, bool) -> dict
|
||||
"""Normalize a complete or partial HTTP settings object."""
|
||||
if value is None:
|
||||
value = {}
|
||||
if not isinstance(value, dict):
|
||||
raise HttpConfigError("http must be an object.")
|
||||
unknown = set(value) - {"host", "port", "token", "origins"}
|
||||
if unknown:
|
||||
raise HttpConfigError("Unsupported HTTP setting: {}.".format(sorted(unknown)[0]))
|
||||
|
||||
settings = {
|
||||
"host": _normalize_host(value.get("host", DEFAULT_HTTP_HOST)),
|
||||
"port": _normalize_port(value.get("port", DEFAULT_HTTP_PORT)),
|
||||
"token": _normalize_token(value.get("token")),
|
||||
"origins": _normalize_origins(value["origins"] if "origins" in value else []),
|
||||
}
|
||||
if enforce_bind_auth and not is_loopback_host(settings["host"]) and settings["token"] is None:
|
||||
raise HttpConfigError("A non-empty HTTP token is required for non-loopback listeners.")
|
||||
return settings
|
||||
|
||||
|
||||
def resolve_http_settings(cli_config=None, document=None, env_config=None):
|
||||
# type: (dict | None, dict | None, dict | None) -> dict
|
||||
"""Resolve CLI > JSON > environment > defaults HTTP settings."""
|
||||
cli_config = cli_config or {}
|
||||
document = document or {}
|
||||
env_config = env_config or {}
|
||||
if not isinstance(document, dict):
|
||||
raise HttpConfigError("Configuration root must be an object.")
|
||||
|
||||
merged = {}
|
||||
env_keys = {"host": "http_host", "port": "http_port", "token": "http_token", "origins": "http_origins"}
|
||||
for key, env_key in env_keys.items():
|
||||
if env_key in env_config:
|
||||
merged[key] = env_config[env_key]
|
||||
|
||||
if "http" in document:
|
||||
json_settings = document["http"]
|
||||
if not isinstance(json_settings, dict):
|
||||
raise HttpConfigError("http must be an object.")
|
||||
if "origins" in json_settings and not isinstance(json_settings["origins"], (list, tuple)):
|
||||
raise HttpConfigError("HTTP origins must be an array.")
|
||||
merged.update(json_settings)
|
||||
|
||||
cli_keys = {"host": "host", "port": "port", "token": "http_token", "origins": "http_origins"}
|
||||
for key, cli_key in cli_keys.items():
|
||||
if cli_config.get(cli_key) is not None:
|
||||
merged[key] = cli_config[cli_key]
|
||||
return normalize_http_settings(merged)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HTTP_HOST",
|
||||
"DEFAULT_HTTP_PORT",
|
||||
"HTTP_CONNECTION_TIMEOUT",
|
||||
"HttpConfigError",
|
||||
"is_loopback_host",
|
||||
"normalize_http_settings",
|
||||
"normalize_origin",
|
||||
"request_token_matches",
|
||||
"resolve_http_settings",
|
||||
]
|
||||
+40
-10
@@ -66,6 +66,7 @@ TOOLS = (
|
||||
},
|
||||
)
|
||||
_END_OF_INPUT = object()
|
||||
_STDOUT_REDIRECT_LOCK = threading.RLock()
|
||||
|
||||
|
||||
def _error_response(request_id, code, message, data=None):
|
||||
@@ -79,11 +80,17 @@ def _error_response(request_id, code, message, data=None):
|
||||
class McpServer(object):
|
||||
"""Handle modern MCP requests and the Copilot-compatible legacy lifecycle."""
|
||||
|
||||
def __init__(self, config_path=None, logger=None, service_factory=DashboardService):
|
||||
# type: (str | None, logging.Logger | None, object) -> None
|
||||
def __init__(
|
||||
self, config_path=None, logger=None, service_factory=DashboardService, service=None, supported_versions=None
|
||||
):
|
||||
# type: (str | None, logging.Logger | None, object, DashboardService | None, object | None) -> None
|
||||
self.config_path = config_path
|
||||
self.logger = (logger or logging.getLogger()).getChild("mcp")
|
||||
self._service_factory = service_factory
|
||||
self._service = service
|
||||
self.supported_versions = tuple(
|
||||
SUPPORTED_PROTOCOL_VERSIONS if supported_versions is None else supported_versions
|
||||
)
|
||||
self._cancelled = set()
|
||||
self._pending = set()
|
||||
self._cancel_lock = threading.Lock()
|
||||
@@ -106,7 +113,7 @@ class McpServer(object):
|
||||
# type: () -> dict
|
||||
return self._complete_result(
|
||||
{
|
||||
"supportedVersions": list(SUPPORTED_PROTOCOL_VERSIONS),
|
||||
"supportedVersions": list(self.supported_versions),
|
||||
"capabilities": {"tools": {}},
|
||||
"instructions": (
|
||||
"Use get_ddns_status to inspect local cached state. "
|
||||
@@ -133,6 +140,8 @@ class McpServer(object):
|
||||
|
||||
def _new_service(self):
|
||||
# type: () -> DashboardService
|
||||
if self._service is not None:
|
||||
return self._service
|
||||
return self._service_factory(config_path=self.config_path, logger=self.logger)
|
||||
|
||||
def _tool_success(self, status, modern):
|
||||
@@ -166,12 +175,13 @@ class McpServer(object):
|
||||
service = self._new_service()
|
||||
if name == "get_ddns_status":
|
||||
return self._tool_success(service.dashboard(), modern=modern)
|
||||
protocol_stdout = sys.stdout
|
||||
try:
|
||||
sys.stdout = sys.stderr
|
||||
status = service.sync(source="MCP", cancelled=lambda: self._is_cancelled(request_id))
|
||||
finally:
|
||||
sys.stdout = protocol_stdout
|
||||
with _STDOUT_REDIRECT_LOCK:
|
||||
protocol_stdout = sys.stdout
|
||||
try:
|
||||
sys.stdout = sys.stderr
|
||||
status = service.sync(source="MCP", cancelled=lambda: self._is_cancelled(request_id))
|
||||
finally:
|
||||
sys.stdout = protocol_stdout
|
||||
return self._tool_success(status, modern=modern)
|
||||
except DashboardError as error:
|
||||
return self._tool_error(error, modern=modern)
|
||||
@@ -252,7 +262,7 @@ class McpServer(object):
|
||||
request_id,
|
||||
-32022,
|
||||
"Unsupported protocol version",
|
||||
{"supported": list(SUPPORTED_PROTOCOL_VERSIONS), "requested": requested_version},
|
||||
{"supported": list(self.supported_versions), "requested": requested_version},
|
||||
)
|
||||
return self._dispatch_response(request_id, method, params, modern=True)
|
||||
|
||||
@@ -306,6 +316,26 @@ class McpServer(object):
|
||||
return self._handle_legacy_request(request_id, method, params)
|
||||
return self._handle_modern_request(request_id, method, params)
|
||||
|
||||
def handle_modern_message(self, message):
|
||||
# type: (object) -> dict | None
|
||||
"""Handle one modern request without entering the legacy lifecycle."""
|
||||
if not isinstance(message, dict):
|
||||
return _error_response(None, -32600, "Invalid Request")
|
||||
|
||||
request_id = self._request_id(message)
|
||||
method = message.get("method")
|
||||
if message.get("jsonrpc") != "2.0" or not isinstance(method, string_types):
|
||||
return _error_response(request_id, -32600, "Invalid Request")
|
||||
if "id" not in message:
|
||||
return None
|
||||
if request_id is None:
|
||||
return _error_response(None, -32600, "Invalid Request")
|
||||
|
||||
params = message.get("params", {})
|
||||
if not isinstance(params, dict):
|
||||
return _error_response(request_id, -32602, "Invalid params")
|
||||
return self._handle_modern_request(request_id, method, params)
|
||||
|
||||
def handle_line(self, line):
|
||||
# type: (str | bytes) -> dict | None
|
||||
if not isinstance(line, text_type):
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Modern MCP Streamable HTTP transport."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
|
||||
try:
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import urlparse
|
||||
except ImportError: # Python 2
|
||||
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
from SocketServer import ThreadingMixIn
|
||||
from urlparse import urlparse
|
||||
|
||||
from .http_config import (
|
||||
HTTP_CONNECTION_TIMEOUT,
|
||||
is_loopback_host,
|
||||
normalize_http_settings,
|
||||
normalize_origin,
|
||||
request_token_matches,
|
||||
)
|
||||
from .mcp import CLIENT_CAPABILITIES_KEY, PROTOCOL_VERSION, PROTOCOL_VERSION_KEY, McpServer, _error_response
|
||||
from .web.service import DashboardService
|
||||
|
||||
try:
|
||||
string_types = (basestring,) # type: ignore[name-defined]
|
||||
text_type = unicode # type: ignore[name-defined]
|
||||
except NameError:
|
||||
string_types = (str,)
|
||||
text_type = str
|
||||
|
||||
|
||||
MCP_PATH = "/mcp"
|
||||
MAX_BODY_SIZE = 2 * 1024 * 1024
|
||||
JSON_MEDIA_TYPE = "application/json"
|
||||
SSE_MEDIA_TYPE = "text/event-stream"
|
||||
HEADER_MISMATCH = -32020
|
||||
_BASE64_SENTINEL = re.compile(r"^=\?base64\?([A-Za-z0-9+/]*={0,2})\?=$")
|
||||
|
||||
|
||||
class McpHttpRequestError(Exception):
|
||||
"""Map one transport validation failure to an HTTP response."""
|
||||
|
||||
def __init__(self, status, payload, headers=None):
|
||||
# type: (int, dict, dict | None) -> None
|
||||
Exception.__init__(self, status)
|
||||
self.status = status
|
||||
self.payload = payload
|
||||
self.headers = headers or {}
|
||||
|
||||
|
||||
def _json_bytes(payload):
|
||||
# type: (dict) -> bytes
|
||||
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
||||
if not isinstance(content, bytes):
|
||||
content = content.encode("utf-8")
|
||||
return content
|
||||
|
||||
|
||||
def _host_header_is_loopback(host_header):
|
||||
# type: (str) -> bool
|
||||
try:
|
||||
parsed = urlparse("http://{}".format(host_header))
|
||||
parsed.port
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return False
|
||||
return (
|
||||
parsed.username is None
|
||||
and parsed.password is None
|
||||
and not parsed.path
|
||||
and not parsed.query
|
||||
and not parsed.fragment
|
||||
and is_loopback_host(parsed.hostname)
|
||||
)
|
||||
|
||||
|
||||
def _direct_request_origin(host_header):
|
||||
# type: (str) -> str | None
|
||||
try:
|
||||
return normalize_origin("http://{}".format(host_header))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _decode_mirrored_value(value):
|
||||
# type: (str | None) -> str | None
|
||||
"""Decode one MCP mirrored header value before comparison."""
|
||||
if not isinstance(value, string_types):
|
||||
return None
|
||||
value = value.strip(" \t")
|
||||
try:
|
||||
sentinel = _BASE64_SENTINEL.match(value)
|
||||
except UnicodeError:
|
||||
return None
|
||||
if sentinel:
|
||||
encoded = sentinel.group(1)
|
||||
if len(encoded) % 4:
|
||||
return None
|
||||
try:
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
except (binascii.Error, TypeError, ValueError, UnicodeDecodeError):
|
||||
return None
|
||||
return decoded
|
||||
try:
|
||||
encoded_value = value.encode("ascii")
|
||||
except (UnicodeDecodeError, UnicodeEncodeError):
|
||||
return None
|
||||
if value != value.strip() or any(byte < 0x20 or byte > 0x7E for byte in bytearray(encoded_value)):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
class McpHttpEndpoint(object):
|
||||
"""Handle `/mcp` requests independently of the surrounding HTTP server."""
|
||||
|
||||
def __init__(self, server, settings, logger=None):
|
||||
# type: (McpServer, dict, logging.Logger | None) -> None
|
||||
self.server = server
|
||||
self.settings = normalize_http_settings(settings)
|
||||
self.logger = (logger or logging.getLogger()).getChild("mcp.http")
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
# type: () -> str | None
|
||||
return self.settings["token"]
|
||||
|
||||
def _send(self, handler, status, payload=None, extra_headers=None):
|
||||
# type: (BaseHTTPRequestHandler, int, dict | None, dict | None) -> None
|
||||
content = b"" if payload is None else _json_bytes(payload)
|
||||
handler.close_connection = True
|
||||
handler.send_response(status)
|
||||
if payload is not None:
|
||||
handler.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
handler.send_header("Content-Length", str(len(content)))
|
||||
handler.send_header("Cache-Control", "no-store")
|
||||
handler.send_header("Connection", "close")
|
||||
handler.send_header("X-Content-Type-Options", "nosniff")
|
||||
for name, value in (extra_headers or {}).items():
|
||||
handler.send_header(name, value)
|
||||
handler.end_headers()
|
||||
if content and handler.command != "HEAD":
|
||||
handler.wfile.write(content)
|
||||
handler.wfile.flush()
|
||||
|
||||
def _cors_headers(self, handler):
|
||||
# type: (BaseHTTPRequestHandler) -> dict
|
||||
origin = handler.headers.get("Origin")
|
||||
if not origin:
|
||||
return {}
|
||||
try:
|
||||
normalized = normalize_origin(origin)
|
||||
except ValueError:
|
||||
return {}
|
||||
if normalized not in self.settings["origins"]:
|
||||
return {}
|
||||
return {"Access-Control-Allow-Origin": normalized, "Vary": "Origin"}
|
||||
|
||||
def _origin_allowed(self, handler):
|
||||
# type: (BaseHTTPRequestHandler) -> bool
|
||||
origin = handler.headers.get("Origin")
|
||||
if not origin:
|
||||
return True
|
||||
try:
|
||||
normalized = normalize_origin(origin)
|
||||
except ValueError:
|
||||
return False
|
||||
direct = _direct_request_origin(handler.headers.get("Host", ""))
|
||||
return normalized == direct or normalized in self.settings["origins"]
|
||||
|
||||
def _validate_access(self, handler):
|
||||
# type: (BaseHTTPRequestHandler) -> None
|
||||
if self.token is None and not _host_header_is_loopback(handler.headers.get("Host", "")):
|
||||
raise McpHttpRequestError(
|
||||
421, {"error": {"code": "invalid_host", "message": "Unauthenticated HTTP only accepts loopback hosts."}}
|
||||
)
|
||||
if not self._origin_allowed(handler):
|
||||
raise McpHttpRequestError(
|
||||
403, {"error": {"code": "invalid_origin", "message": "HTTP request origin is not allowed."}}
|
||||
)
|
||||
if not request_token_matches(handler.headers, self.token):
|
||||
raise McpHttpRequestError(
|
||||
401,
|
||||
{"error": {"code": "invalid_token", "message": "HTTP bearer token is invalid."}},
|
||||
{"WWW-Authenticate": 'Bearer realm="ddns-mcp"'},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _media_types(header_value):
|
||||
# type: (str) -> set[str]
|
||||
accepted = set()
|
||||
for item in header_value.split(","):
|
||||
parts = [part.strip() for part in item.split(";")]
|
||||
if not parts[0]:
|
||||
continue
|
||||
quality = 1.0
|
||||
valid = True
|
||||
for parameter in parts[1:]:
|
||||
name, separator, value = parameter.partition("=")
|
||||
if separator and name.strip().lower() == "q":
|
||||
try:
|
||||
quality = float(value.strip())
|
||||
except (TypeError, ValueError):
|
||||
valid = False
|
||||
if quality < 0 or quality > 1:
|
||||
valid = False
|
||||
if valid and quality > 0:
|
||||
accepted.add(parts[0].lower())
|
||||
return accepted
|
||||
|
||||
def _read_message(self, handler):
|
||||
# type: (BaseHTTPRequestHandler) -> dict
|
||||
content_type = handler.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type != JSON_MEDIA_TYPE:
|
||||
raise McpHttpRequestError(
|
||||
415, {"error": {"code": "unsupported_media_type", "message": "Content-Type must be application/json."}}
|
||||
)
|
||||
accept = self._media_types(handler.headers.get("Accept", ""))
|
||||
if JSON_MEDIA_TYPE not in accept or SSE_MEDIA_TYPE not in accept:
|
||||
raise McpHttpRequestError(
|
||||
406,
|
||||
{
|
||||
"error": {
|
||||
"code": "not_acceptable",
|
||||
"message": "Accept must include application/json and text/event-stream.",
|
||||
}
|
||||
},
|
||||
)
|
||||
content_length = handler.headers.get("Content-Length")
|
||||
if content_length is None:
|
||||
raise McpHttpRequestError(
|
||||
411, {"error": {"code": "length_required", "message": "Content-Length is required."}}
|
||||
)
|
||||
try:
|
||||
content_length = int(content_length)
|
||||
except (TypeError, ValueError):
|
||||
raise McpHttpRequestError(
|
||||
400, {"error": {"code": "invalid_length", "message": "Content-Length is invalid."}}
|
||||
)
|
||||
if content_length < 0 or content_length > MAX_BODY_SIZE:
|
||||
raise McpHttpRequestError(
|
||||
413, {"error": {"code": "payload_too_large", "message": "Request body is too large."}}
|
||||
)
|
||||
content = handler.rfile.read(content_length)
|
||||
handler.mcp_body_consumed = True
|
||||
try:
|
||||
message = json.loads(content.decode("utf-8"))
|
||||
except (UnicodeDecodeError, TypeError, ValueError):
|
||||
raise McpHttpRequestError(400, _error_response(None, -32700, "Parse error"))
|
||||
if not isinstance(message, dict):
|
||||
raise McpHttpRequestError(400, _error_response(None, -32600, "Invalid Request"))
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _discard_request_body(handler):
|
||||
# type: (BaseHTTPRequestHandler) -> None
|
||||
"""Briefly drain an already-buffered body so Windows can deliver an early response."""
|
||||
if getattr(handler, "mcp_body_consumed", False):
|
||||
return
|
||||
try:
|
||||
content_length = int(handler.headers.get("Content-Length", ""))
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if content_length < 0 or content_length > MAX_BODY_SIZE:
|
||||
return
|
||||
previous_timeout = handler.connection.gettimeout()
|
||||
try:
|
||||
handler.connection.settimeout(0.05)
|
||||
if content_length:
|
||||
handler.rfile.read(content_length)
|
||||
except (IOError, OSError, socket.timeout):
|
||||
return
|
||||
finally:
|
||||
try:
|
||||
handler.connection.settimeout(previous_timeout)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
handler.mcp_body_consumed = True
|
||||
|
||||
def _header_mismatch(self, message, detail):
|
||||
# type: (dict, str) -> McpHttpRequestError
|
||||
request_id = self.server._request_id(message)
|
||||
return McpHttpRequestError(400, _error_response(request_id, HEADER_MISMATCH, detail))
|
||||
|
||||
@staticmethod
|
||||
def _header_values(headers, name):
|
||||
# type: (object, str) -> list[str]
|
||||
get_all = getattr(headers, "get_all", None)
|
||||
if get_all is not None:
|
||||
return get_all(name, [])
|
||||
getheaders = getattr(headers, "getheaders", None)
|
||||
if getheaders is not None:
|
||||
return getheaders(name)
|
||||
value = headers.get(name)
|
||||
return [] if value is None else [value]
|
||||
|
||||
def _validate_message(self, message):
|
||||
# type: (dict) -> None
|
||||
request_id = self.server._request_id(message) if "id" in message else None
|
||||
if (
|
||||
message.get("jsonrpc") != "2.0"
|
||||
or not isinstance(message.get("method"), string_types)
|
||||
or ("id" in message and request_id is None)
|
||||
):
|
||||
raise McpHttpRequestError(400, _error_response(request_id, -32600, "Invalid Request"))
|
||||
if not isinstance(message.get("params", {}), dict):
|
||||
raise McpHttpRequestError(400, _error_response(request_id, -32602, "Invalid params"))
|
||||
|
||||
def _validate_envelope(self, handler, message):
|
||||
# type: (BaseHTTPRequestHandler, dict) -> None
|
||||
for header_name in ("MCP-Protocol-Version", "Mcp-Method", "Mcp-Name"):
|
||||
if len(self._header_values(handler.headers, header_name)) > 1:
|
||||
raise self._header_mismatch(message, "{} must not be repeated.".format(header_name))
|
||||
header_version = handler.headers.get("MCP-Protocol-Version")
|
||||
header_method = handler.headers.get("Mcp-Method")
|
||||
body_method = message.get("method")
|
||||
params = message.get("params")
|
||||
meta = params.get("_meta") if isinstance(params, dict) else None
|
||||
body_version = meta.get(PROTOCOL_VERSION_KEY) if isinstance(meta, dict) else None
|
||||
if not header_version or header_version != body_version:
|
||||
raise self._header_mismatch(message, "MCP-Protocol-Version does not match request metadata.")
|
||||
if body_version != PROTOCOL_VERSION:
|
||||
raise McpHttpRequestError(
|
||||
400,
|
||||
_error_response(
|
||||
self.server._request_id(message),
|
||||
-32022,
|
||||
"Unsupported protocol version",
|
||||
{"supported": [PROTOCOL_VERSION], "requested": body_version},
|
||||
),
|
||||
)
|
||||
if not header_method or header_method != body_method:
|
||||
raise self._header_mismatch(message, "Mcp-Method does not match the JSON-RPC method.")
|
||||
if not isinstance(meta.get(CLIENT_CAPABILITIES_KEY) if isinstance(meta, dict) else None, dict):
|
||||
raise McpHttpRequestError(
|
||||
400,
|
||||
_error_response(
|
||||
self.server._request_id(message), -32602, "{} is required.".format(CLIENT_CAPABILITIES_KEY)
|
||||
),
|
||||
)
|
||||
name_field = {"tools/call": "name", "prompts/get": "name", "resources/read": "uri"}.get(body_method)
|
||||
if name_field is not None:
|
||||
header_name = _decode_mirrored_value(handler.headers.get("Mcp-Name"))
|
||||
body_name = params.get(name_field) if isinstance(params, dict) else None
|
||||
if not header_name or header_name != body_name:
|
||||
raise self._header_mismatch(message, "Mcp-Name does not match the named request.")
|
||||
|
||||
def handle_post(self, handler):
|
||||
# type: (BaseHTTPRequestHandler) -> None
|
||||
handler.mcp_body_consumed = False
|
||||
try:
|
||||
self._validate_access(handler)
|
||||
message = self._read_message(handler)
|
||||
self._validate_message(message)
|
||||
self._validate_envelope(handler, message)
|
||||
except McpHttpRequestError as error:
|
||||
self._discard_request_body(handler)
|
||||
headers = dict(error.headers)
|
||||
headers.update(self._cors_headers(handler))
|
||||
self._send(handler, error.status, error.payload, headers)
|
||||
return
|
||||
|
||||
response = self.server.handle_modern_message(message)
|
||||
|
||||
cors_headers = self._cors_headers(handler)
|
||||
status = 200
|
||||
if response is not None and "error" in response:
|
||||
error_code = response["error"].get("code")
|
||||
if error_code == -32022:
|
||||
status = 400
|
||||
elif error_code == -32601:
|
||||
status = 404
|
||||
try:
|
||||
if response is None:
|
||||
self._send(handler, 202, extra_headers=cors_headers)
|
||||
else:
|
||||
self._send(handler, status, response, cors_headers)
|
||||
except (IOError, OSError, socket.error):
|
||||
self.logger.info("MCP HTTP client disconnected before receiving its response")
|
||||
|
||||
def handle_options(self, handler):
|
||||
# type: (BaseHTTPRequestHandler) -> None
|
||||
origin = handler.headers.get("Origin")
|
||||
try:
|
||||
normalized = normalize_origin(origin)
|
||||
except (TypeError, ValueError):
|
||||
normalized = None
|
||||
if normalized is None or normalized not in self.settings["origins"]:
|
||||
self._send(
|
||||
handler, 403, {"error": {"code": "invalid_origin", "message": "HTTP request origin is not allowed."}}
|
||||
)
|
||||
return
|
||||
headers = {
|
||||
"Access-Control-Allow-Origin": normalized,
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
"Access-Control-Allow-Headers": ("Authorization, Content-Type, MCP-Protocol-Version, Mcp-Method, Mcp-Name"),
|
||||
"Access-Control-Max-Age": "600",
|
||||
"Vary": "Origin",
|
||||
}
|
||||
self._send(handler, 204, extra_headers=headers)
|
||||
|
||||
def method_not_allowed(self, handler):
|
||||
# type: (BaseHTTPRequestHandler) -> None
|
||||
if not self._origin_allowed(handler):
|
||||
self._send(
|
||||
handler, 403, {"error": {"code": "invalid_origin", "message": "HTTP request origin is not allowed."}}
|
||||
)
|
||||
return
|
||||
headers = {"Allow": "POST, OPTIONS"}
|
||||
headers.update(self._cors_headers(handler))
|
||||
self._send(
|
||||
handler, 405, {"error": {"code": "method_not_allowed", "message": "MCP HTTP only accepts POST."}}, headers
|
||||
)
|
||||
|
||||
|
||||
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
"""Threaded MCP HTTP server."""
|
||||
|
||||
allow_reuse_address = False
|
||||
daemon_threads = True
|
||||
connection_timeout = HTTP_CONNECTION_TIMEOUT
|
||||
|
||||
def get_request(self):
|
||||
# type: () -> tuple[object, object]
|
||||
request, client_address = HTTPServer.get_request(self)
|
||||
request.settimeout(self.connection_timeout)
|
||||
return request, client_address
|
||||
|
||||
def server_bind(self):
|
||||
# type: () -> None
|
||||
if sys.platform.startswith("win") and hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
|
||||
self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_EXCLUSIVEADDRUSE, 1)
|
||||
HTTPServer.server_bind(self)
|
||||
|
||||
|
||||
class ThreadingHTTPServerV6(ThreadingHTTPServer):
|
||||
"""IPv6 MCP HTTP server."""
|
||||
|
||||
address_family = socket.AF_INET6
|
||||
|
||||
|
||||
class McpHttpRequestHandler(BaseHTTPRequestHandler):
|
||||
"""Serve only the modern MCP HTTP endpoint."""
|
||||
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "DDNS"
|
||||
sys_version = ""
|
||||
|
||||
@property
|
||||
def endpoint(self):
|
||||
# type: () -> McpHttpEndpoint
|
||||
return self.server.mcp_endpoint # type: ignore[attr-defined]
|
||||
|
||||
def log_message(self, format_string, *args):
|
||||
# type: (str, *object) -> None
|
||||
message = format_string % args
|
||||
request_path = getattr(self, "path", "") or ""
|
||||
if request_path:
|
||||
message = message.replace(request_path, urlparse(request_path).path)
|
||||
self.server.logger.info("%s - %s", self.address_string(), message) # type: ignore[attr-defined]
|
||||
|
||||
def _path_is_mcp(self):
|
||||
# type: () -> bool
|
||||
return urlparse(self.path).path == MCP_PATH
|
||||
|
||||
def do_POST(self):
|
||||
# type: () -> None
|
||||
if not self._path_is_mcp():
|
||||
self.endpoint._send(self, 404, {"error": {"code": "not_found", "message": "Resource not found."}})
|
||||
return
|
||||
self.endpoint.handle_post(self)
|
||||
|
||||
def do_OPTIONS(self):
|
||||
# type: () -> None
|
||||
if not self._path_is_mcp():
|
||||
self.endpoint._send(self, 404, {"error": {"code": "not_found", "message": "Resource not found."}})
|
||||
return
|
||||
self.endpoint.handle_options(self)
|
||||
|
||||
def _method_not_allowed(self):
|
||||
# type: () -> None
|
||||
if self._path_is_mcp():
|
||||
self.endpoint.method_not_allowed(self)
|
||||
else:
|
||||
self.endpoint._send(self, 404, {"error": {"code": "not_found", "message": "Resource not found."}})
|
||||
|
||||
do_GET = _method_not_allowed
|
||||
do_HEAD = _method_not_allowed
|
||||
do_PUT = _method_not_allowed
|
||||
do_DELETE = _method_not_allowed
|
||||
|
||||
|
||||
def create_server(config_path=None, settings=None, service=None, logger=None):
|
||||
# type: (str | None, dict | None, DashboardService | None, logging.Logger | None) -> ThreadingHTTPServer
|
||||
"""Create a standalone modern MCP HTTP server."""
|
||||
settings = normalize_http_settings(settings)
|
||||
logger = logger or logging.getLogger()
|
||||
service = service or DashboardService(config_path=config_path, logger=logger)
|
||||
mcp_server = McpServer(
|
||||
config_path=config_path, logger=logger, service=service, supported_versions=(PROTOCOL_VERSION,)
|
||||
)
|
||||
server_class = ThreadingHTTPServerV6 if ":" in settings["host"] else ThreadingHTTPServer
|
||||
server = server_class((settings["host"], settings["port"]), McpHttpRequestHandler)
|
||||
server.logger = logger.getChild("mcp.http.server")
|
||||
server.http_settings = settings
|
||||
server.mcp_endpoint = McpHttpEndpoint(mcp_server, settings=settings, logger=logger)
|
||||
return server
|
||||
|
||||
|
||||
def serve(config_path=None, settings=None, logger=None):
|
||||
# type: (str | None, dict | None, logging.Logger | None) -> None
|
||||
"""Run the standalone MCP HTTP server until interrupted."""
|
||||
server = create_server(config_path=config_path, settings=settings, logger=logger)
|
||||
host, port = server.server_address[:2]
|
||||
display_host = "127.0.0.1" if host == "0.0.0.0" else "::1" if host == "::" else host
|
||||
if ":" in display_host:
|
||||
display_host = "[{}]".format(display_host)
|
||||
sys.stdout.write("DDNS MCP HTTP: http://{}:{}{}\n".format(display_host, port, MCP_PATH))
|
||||
sys.stdout.flush()
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
sys.stdout.write("\nStopping DDNS MCP HTTP...\n")
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
__all__ = ["MCP_PATH", "McpHttpEndpoint", "McpHttpRequestHandler", "create_server", "request_token_matches", "serve"]
|
||||
+85
-20
@@ -17,13 +17,17 @@ import webbrowser
|
||||
try:
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import quote, urlparse
|
||||
except ImportError: # Python 2
|
||||
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
|
||||
from SocketServer import ThreadingMixIn
|
||||
from urllib import quote
|
||||
from urlparse import urlparse
|
||||
|
||||
from .service import DashboardError, DashboardService
|
||||
from ..http_config import HTTP_CONNECTION_TIMEOUT, is_loopback_host, normalize_http_settings, request_token_matches
|
||||
|
||||
MCP_PATH = "/mcp"
|
||||
|
||||
try:
|
||||
text_type = unicode # type: ignore[name-defined]
|
||||
@@ -61,6 +65,13 @@ class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
|
||||
allow_reuse_address = False
|
||||
daemon_threads = True
|
||||
connection_timeout = HTTP_CONNECTION_TIMEOUT
|
||||
|
||||
def get_request(self):
|
||||
# type: () -> tuple[object, object]
|
||||
request, client_address = HTTPServer.get_request(self)
|
||||
request.settimeout(self.connection_timeout)
|
||||
return request, client_address
|
||||
|
||||
def server_bind(self):
|
||||
# type: () -> None
|
||||
@@ -130,10 +141,18 @@ def _json_bytes(payload):
|
||||
def _local_hostname(host_header):
|
||||
# type: (str) -> bool
|
||||
try:
|
||||
hostname = urlparse("http://{}".format(host_header)).hostname
|
||||
parsed = urlparse("http://{}".format(host_header))
|
||||
parsed.port
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return False
|
||||
return hostname in ("127.0.0.1", "localhost", "::1")
|
||||
return (
|
||||
parsed.username is None
|
||||
and parsed.password is None
|
||||
and not parsed.path
|
||||
and not parsed.query
|
||||
and not parsed.fragment
|
||||
and is_loopback_host(parsed.hostname)
|
||||
)
|
||||
|
||||
|
||||
class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
@@ -155,7 +174,13 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def log_message(self, format_string, *args):
|
||||
# type: (str, *object) -> None
|
||||
self.server.logger.info("%s - %s", self.address_string(), format_string % args) # type: ignore[attr-defined]
|
||||
message = format_string % args
|
||||
request_path = getattr(self, "path", "") or ""
|
||||
if request_path:
|
||||
path = urlparse(request_path).path
|
||||
safe_path = LAUNCH_PATH_PREFIX + "[redacted]" if path.startswith(LAUNCH_PATH_PREFIX) else path
|
||||
message = message.replace(request_path, safe_path)
|
||||
self.server.logger.info("%s - %s", self.address_string(), message) # type: ignore[attr-defined]
|
||||
|
||||
def _security_headers(self):
|
||||
# type: () -> None
|
||||
@@ -206,15 +231,19 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def _request_is_local(self):
|
||||
# type: () -> bool
|
||||
if self.access_token is not None:
|
||||
return True
|
||||
host_header = self.headers.get("Host", "")
|
||||
if _local_hostname(host_header):
|
||||
return True
|
||||
self._send_json(421, {"error": {"code": "invalid_host", "message": "Dashboard only accepts local hosts."}})
|
||||
self._send_json(
|
||||
421, {"error": {"code": "invalid_host", "message": "Unauthenticated dashboard only accepts local hosts."}}
|
||||
)
|
||||
return False
|
||||
|
||||
def _request_has_token(self):
|
||||
# type: () -> bool
|
||||
if self.headers.get("X-DDNS-Token") == self.access_token:
|
||||
if request_token_matches(self.headers, self.access_token, allow_dashboard_header=True):
|
||||
return True
|
||||
self._send_json(403, {"error": {"code": "invalid_token", "message": "Dashboard request token is invalid."}})
|
||||
return False
|
||||
@@ -265,6 +294,9 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
if not self._request_is_local():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
if path == MCP_PATH:
|
||||
self.server.mcp_endpoint.method_not_allowed(self) # type: ignore[attr-defined]
|
||||
return
|
||||
if path.startswith(LAUNCH_PATH_PREFIX):
|
||||
launch_token = path[len(LAUNCH_PATH_PREFIX) :]
|
||||
if head_only or not self.server.consume_launch_token(launch_token): # type: ignore[attr-defined]
|
||||
@@ -274,7 +306,8 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
head_only=head_only,
|
||||
)
|
||||
return
|
||||
self._send_redirect("/#token={}&view=overview".format(self.access_token))
|
||||
token = self.access_token.encode("utf-8") if isinstance(self.access_token, text_type) else self.access_token
|
||||
self._send_redirect("/#token={}&view=overview".format(quote(token, safe="")))
|
||||
return
|
||||
if path in INDEX_PATHS:
|
||||
content = _resource_bytes("index.html")
|
||||
@@ -318,6 +351,9 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_PUT(self):
|
||||
# type: () -> None
|
||||
if urlparse(self.path).path == MCP_PATH:
|
||||
self.server.mcp_endpoint.method_not_allowed(self) # type: ignore[attr-defined]
|
||||
return
|
||||
if not self._request_is_local() or not self._request_has_token():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
@@ -335,6 +371,9 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self):
|
||||
# type: () -> None
|
||||
if urlparse(self.path).path == MCP_PATH:
|
||||
self.server.mcp_endpoint.handle_post(self) # type: ignore[attr-defined]
|
||||
return
|
||||
if not self._request_is_local() or not self._request_has_token():
|
||||
return
|
||||
path = urlparse(self.path).path
|
||||
@@ -361,38 +400,64 @@ class DashboardRequestHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_OPTIONS(self):
|
||||
# type: () -> None
|
||||
if urlparse(self.path).path == MCP_PATH:
|
||||
self.server.mcp_endpoint.handle_options(self) # type: ignore[attr-defined]
|
||||
return
|
||||
self._send_json(405, {"error": {"code": "method_not_allowed", "message": "CORS is not enabled."}})
|
||||
|
||||
def do_DELETE(self):
|
||||
# type: () -> None
|
||||
if urlparse(self.path).path == MCP_PATH:
|
||||
self.server.mcp_endpoint.method_not_allowed(self) # type: ignore[attr-defined]
|
||||
return
|
||||
self._send_not_found()
|
||||
|
||||
def create_server(service=None, host="127.0.0.1", port=9876, logger=None):
|
||||
# type: (DashboardService | None, str, int, logging.Logger | None) -> ThreadingHTTPServer
|
||||
"""Create a local dashboard server without starting its loop."""
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
raise ValueError("Dashboard host must be a loopback address.")
|
||||
|
||||
def create_server(service=None, host="127.0.0.1", port=9876, logger=None, token=None, origins=None):
|
||||
# type: (DashboardService | None, str, int, logging.Logger | None, str | None, list[str] | None) -> ThreadingHTTPServer
|
||||
"""Create the shared Web and MCP HTTP server without starting its loop."""
|
||||
settings = normalize_http_settings({"host": host, "port": port, "token": token, "origins": origins or []})
|
||||
logger = (logger or logging.getLogger()).getChild("web.server")
|
||||
server_class = ThreadingHTTPServerV6 if host == "::1" else ThreadingHTTPServer
|
||||
server = server_class((host, port), DashboardRequestHandler)
|
||||
server_class = ThreadingHTTPServerV6 if ":" in settings["host"] else ThreadingHTTPServer
|
||||
server = server_class((settings["host"], settings["port"]), DashboardRequestHandler)
|
||||
server.dashboard_service = service or DashboardService(logger=logger)
|
||||
server.access_token = binascii.hexlify(os.urandom(24)).decode("ascii")
|
||||
server.access_token = settings["token"]
|
||||
server.http_settings = settings
|
||||
server.launch_token_lock = threading.Lock()
|
||||
server.launch_token = None
|
||||
server.launch_token_expires = 0
|
||||
server.logger = logger
|
||||
from ..mcp import PROTOCOL_VERSION, McpServer
|
||||
from ..mcp_http import McpHttpEndpoint
|
||||
|
||||
mcp_server = McpServer(
|
||||
config_path=server.dashboard_service.config_path,
|
||||
logger=logger,
|
||||
service=server.dashboard_service,
|
||||
supported_versions=(PROTOCOL_VERSION,),
|
||||
)
|
||||
server.mcp_endpoint = McpHttpEndpoint(mcp_server, settings=settings, logger=logger)
|
||||
return server
|
||||
|
||||
|
||||
def serve(config_path=None, host="127.0.0.1", port=9876, open_browser=False, logger=None, interval=5):
|
||||
# type: (str | None, str, int, bool, logging.Logger | None, int) -> None
|
||||
def serve(
|
||||
config_path=None, host="127.0.0.1", port=9876, open_browser=False, logger=None, interval=5, token=None, origins=None
|
||||
):
|
||||
# type: (str | None, str, int, bool, logging.Logger | None, int, str | None, list[str] | None) -> None
|
||||
"""Run the embedded dashboard until interrupted."""
|
||||
logger = logger or logging.getLogger()
|
||||
service = DashboardService(config_path=config_path, logger=logger, scheduler_interval=interval)
|
||||
server = create_server(service=service, host=host, port=port, logger=logger)
|
||||
server = create_server(service=service, host=host, port=port, token=token, origins=origins, logger=logger)
|
||||
bound_host, bound_port = server.server_address[:2]
|
||||
display_host = "127.0.0.1" if bound_host == "0.0.0.0" else bound_host
|
||||
display_host = "127.0.0.1" if bound_host == "0.0.0.0" else "::1" if bound_host == "::" else bound_host
|
||||
if ":" in display_host:
|
||||
display_host = "[{}]".format(display_host)
|
||||
origin = "http://{}:{}".format(display_host, bound_port)
|
||||
url = "{}/launch/{}".format(origin, server.issue_launch_token())
|
||||
url = (
|
||||
"{}/launch/{}".format(origin, server.issue_launch_token())
|
||||
if server.access_token is not None
|
||||
else "{}/#view=overview".format(origin)
|
||||
)
|
||||
try:
|
||||
service.start_scheduler()
|
||||
_write_stdout("DDNS dashboard: {}\n".format(url))
|
||||
|
||||
+22
-3
@@ -19,6 +19,7 @@ from ast import literal_eval
|
||||
from ..config.config import Config, split_array_string
|
||||
from ..config.env import load_config as load_env_config
|
||||
from ..config.file import DEFAULT_CONFIG_PATHS, _flatten_single_config, _process_multi_providers
|
||||
from ..http_config import HttpConfigError, normalize_http_settings
|
||||
from ..provider import get_provider_class
|
||||
from ..util.comment import remove_comment
|
||||
from ..util.fileio import read_file
|
||||
@@ -102,6 +103,11 @@ FLAT_METADATA_FIELDS = {
|
||||
"log_format",
|
||||
"log_level",
|
||||
"interval",
|
||||
"http",
|
||||
"http_host",
|
||||
"http_port",
|
||||
"http_token",
|
||||
"http_origins",
|
||||
"provider",
|
||||
}
|
||||
|
||||
@@ -169,9 +175,9 @@ def resolve_config_path(config_path=None):
|
||||
|
||||
def _global_from_flat(source):
|
||||
# type: (dict) -> dict
|
||||
flat_source = _flatten_single_config(source, preserve_keys=["extra"])
|
||||
flat_source = _flatten_single_config(source, preserve_keys=["extra", "http"])
|
||||
result = {}
|
||||
for key in ("ssl", "proxy", "cache", "cache_max_age", "interval"):
|
||||
for key in ("ssl", "proxy", "cache", "cache_max_age", "interval", "http"):
|
||||
if key in flat_source:
|
||||
result[key] = copy.deepcopy(flat_source[key])
|
||||
|
||||
@@ -191,7 +197,7 @@ def _global_from_flat(source):
|
||||
|
||||
def _provider_from_flat(source, exclude_keys=None):
|
||||
# type: (dict, object) -> dict
|
||||
flat_source = _flatten_single_config(source, preserve_keys=["extra"])
|
||||
flat_source = _flatten_single_config(source, preserve_keys=["extra", "http"])
|
||||
excluded = set(exclude_keys or ())
|
||||
provider = {"provider": flat_source.get("provider", flat_source.get("dns", ""))}
|
||||
for key in PROVIDER_FIELDS:
|
||||
@@ -591,6 +597,17 @@ def validate_document(document, fallback_provider=None): # noqa: C901
|
||||
_validate_inherited_fields(result, "Global")
|
||||
if "interval" in result:
|
||||
result["interval"] = _validate_interval(result["interval"])
|
||||
if "http" in result:
|
||||
if not isinstance(result["http"], dict):
|
||||
raise ConfigValidationError("http must be an object.")
|
||||
http = result["http"]
|
||||
if "origins" in http and not isinstance(http["origins"], (list, tuple)):
|
||||
raise ConfigValidationError("HTTP origins must be an array.")
|
||||
try:
|
||||
normalized_http = normalize_http_settings(http, enforce_bind_auth=False)
|
||||
except HttpConfigError as error:
|
||||
raise ConfigValidationError(text_type(error))
|
||||
result["http"] = {key: normalized_http[key] for key in http}
|
||||
|
||||
validated_providers = []
|
||||
for index, raw_provider in enumerate(providers):
|
||||
@@ -603,6 +620,8 @@ def validate_document(document, fallback_provider=None): # noqa: C901
|
||||
)
|
||||
if "interval" in provider:
|
||||
raise ConfigValidationError("Provider {} interval must be configured globally.".format(index + 1))
|
||||
if "http" in provider:
|
||||
raise ConfigValidationError("Provider {} http must be configured globally.".format(index + 1))
|
||||
provider_name = _validate_string(
|
||||
provider.get("provider"), "Provider {}".format(index + 1), allow_empty=False
|
||||
).lower()
|
||||
|
||||
@@ -181,6 +181,7 @@ export default defineConfig({
|
||||
{ text: '生成与校验', link: '/config/studio' },
|
||||
{ text: 'JSON 配置', link: '/config/json' },
|
||||
{ text: '命令行参数', link: '/config/cli' },
|
||||
{ text: 'MCP 服务', link: '/config/mcp' },
|
||||
{ text: '环境变量', link: '/config/env' }
|
||||
]
|
||||
},
|
||||
@@ -240,6 +241,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: '配置生成与校验', link: '/config/studio' },
|
||||
{ text: '命令行参数', link: '/config/cli' },
|
||||
{ text: 'MCP 服务', link: '/config/mcp' },
|
||||
{ text: '环境变量', link: '/config/env' },
|
||||
{ text: 'JSON配置文件', link: '/config/json' }
|
||||
]
|
||||
@@ -475,6 +477,7 @@ export default defineConfig({
|
||||
{ text: 'Build & Validate', link: '/en/config/studio' },
|
||||
{ text: 'JSON Configuration', link: '/en/config/json' },
|
||||
{ text: 'CLI', link: '/en/config/cli' },
|
||||
{ text: 'MCP Server', link: '/en/config/mcp' },
|
||||
{ text: 'Environment', link: '/en/config/env' }
|
||||
]
|
||||
},
|
||||
@@ -531,6 +534,7 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: 'Build & Validate', link: '/en/config/studio' },
|
||||
{ text: 'CLI Parameters', link: '/en/config/cli' },
|
||||
{ text: 'MCP Server', link: '/en/config/mcp' },
|
||||
{ text: 'Environment Variables', link: '/en/config/env' },
|
||||
{ text: 'JSON Configuration', link: '/en/config/json' }
|
||||
]
|
||||
|
||||
@@ -130,6 +130,7 @@ interface GlobalState {
|
||||
cachePath: string
|
||||
cacheMaxAge: string
|
||||
interval: string
|
||||
httpText: string
|
||||
logLevel: LogLevel
|
||||
logFile: string
|
||||
logFilePresent: boolean
|
||||
@@ -182,6 +183,8 @@ interface StoredDraft {
|
||||
}
|
||||
|
||||
const SCHEMA_URL = configModel.schema.url
|
||||
const DEFAULT_HTTP_HOST = '127.0.0.1'
|
||||
const DEFAULT_HTTP_PORT = 9876
|
||||
const DRAFT_STORAGE_KEY = 'ddns-config-studio-draft-v1'
|
||||
const DRAFT_VERSION = 2
|
||||
const DRAFT_SAVE_DELAY = 400
|
||||
@@ -290,6 +293,8 @@ const copy = {
|
||||
runtimeTitle: '配置缓存、日志与扩展字段',
|
||||
runtimeHint: '先设置全局缓存与日志;如有需要,再为当前服务商覆盖对应设置。',
|
||||
interval: '自动同步间隔(分钟)',
|
||||
httpConfig: 'Web 与 HTTP MCP 配置(JSON 对象)',
|
||||
httpHint: '可选;保存后需重启。token 会按原文写入配置。',
|
||||
cache: '缓存策略',
|
||||
cacheOn: '启用默认缓存',
|
||||
cacheOff: '禁用缓存',
|
||||
@@ -433,6 +438,8 @@ const copy = {
|
||||
runtimeTitle: 'Configure cache, logs & custom fields',
|
||||
runtimeHint: 'Set global cache and logging behavior, then add provider-specific overrides if needed.',
|
||||
interval: 'Automatic sync interval (minutes)',
|
||||
httpConfig: 'Web and HTTP MCP settings (JSON object)',
|
||||
httpHint: 'Optional; restart after saving. The token is written to the configuration verbatim.',
|
||||
cache: 'Cache policy',
|
||||
cacheOn: 'Enable default cache',
|
||||
cacheOff: 'Disable cache',
|
||||
@@ -567,6 +574,7 @@ function makeGlobalState(): GlobalState {
|
||||
cachePath: '',
|
||||
cacheMaxAge: String(configModel.defaults.cacheMaxAge),
|
||||
interval: '',
|
||||
httpText: '',
|
||||
logLevel: configModel.defaults.logLevel,
|
||||
logFile: '',
|
||||
logFilePresent: false,
|
||||
@@ -1066,6 +1074,8 @@ function buildConfiguration(): JsonObject {
|
||||
if (globalState.interval.trim() && Number.isInteger(Number(globalState.interval))) {
|
||||
output.interval = Number(globalState.interval)
|
||||
}
|
||||
const http = parseObjectText(globalState.httpText)
|
||||
if (http && Object.keys(http).length) output.http = http
|
||||
const proxies = parseProxyValue(globalState.proxyText)
|
||||
if (globalState.proxyNull) output.proxy = null
|
||||
else if (proxies.length || globalState.proxyPresent) output.proxy = proxies
|
||||
@@ -1243,6 +1253,7 @@ function globalStateFromDraft(value: unknown): GlobalState | null {
|
||||
cachePath: draftString(value, 'cachePath'),
|
||||
cacheMaxAge: draftString(value, 'cacheMaxAge', fallback.cacheMaxAge),
|
||||
interval: draftString(value, 'interval'),
|
||||
httpText: draftString(value, 'httpText'),
|
||||
logLevel: draftLogLevel(value, fallback.logLevel),
|
||||
logFile: draftString(value, 'logFile'),
|
||||
logFilePresent: draftBoolean(value, 'logFilePresent'),
|
||||
@@ -1474,6 +1485,127 @@ function hasDuplicates(values: unknown[]): boolean {
|
||||
return new Set(normalized).size !== normalized.length
|
||||
}
|
||||
|
||||
function isLoopbackHttpHost(value: string): boolean {
|
||||
const trimmed = value.trim().toLowerCase()
|
||||
if (trimmed.startsWith('[') !== trimmed.endsWith(']')) return false
|
||||
const host = trimmed.startsWith('[') ? trimmed.slice(1, -1) : trimmed
|
||||
if (host === 'localhost' || host === '::1') return true
|
||||
const octets = host.split('.')
|
||||
return (
|
||||
octets.length === 4 &&
|
||||
Number(octets[0]) === 127 &&
|
||||
octets.every((octet) => /^\d+$/.test(octet) && Number(octet) >= 0 && Number(octet) <= 255)
|
||||
)
|
||||
}
|
||||
|
||||
function isExactHttpOrigin(value: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(value)
|
||||
return (
|
||||
['http:', 'https:'].includes(parsed.protocol) &&
|
||||
!parsed.username &&
|
||||
!parsed.password &&
|
||||
(!parsed.pathname || parsed.pathname === '/') &&
|
||||
!parsed.search &&
|
||||
!parsed.hash
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function validateHttpSettings(value: unknown, path: string, diagnostics: Diagnostic[]) {
|
||||
if (!isPlainObject(value)) {
|
||||
diagnostics.push(makeDiagnostic(path, 'error', 'http 必须是对象。', 'http must be an object.'))
|
||||
return
|
||||
}
|
||||
const unknown = Object.keys(value).filter((key) => !['host', 'port', 'token', 'origins'].includes(key))
|
||||
unknown.forEach((key) => {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(childPath(path, key), 'error', '不支持此 HTTP 设置。', 'Unsupported HTTP setting.'),
|
||||
)
|
||||
})
|
||||
const host = 'host' in value ? value.host : DEFAULT_HTTP_HOST
|
||||
const port = 'port' in value ? value.port : DEFAULT_HTTP_PORT
|
||||
const token = 'token' in value ? value.token : null
|
||||
const origins = 'origins' in value ? value.origins : []
|
||||
const rawHost = typeof host === 'string' ? host.trim() : ''
|
||||
const mismatchedBrackets = rawHost.startsWith('[') !== rawHost.endsWith(']')
|
||||
const normalizedHost = rawHost.startsWith('[') && rawHost.endsWith(']') ? rawHost.slice(1, -1) : rawHost
|
||||
if (
|
||||
mismatchedBrackets ||
|
||||
!normalizedHost ||
|
||||
/[\/\\\0\[\]]/.test(normalizedHost) ||
|
||||
/\s/.test(normalizedHost)
|
||||
) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(
|
||||
childPath(path, 'host'),
|
||||
'error',
|
||||
'HTTP 监听地址不能为空,也不能包含空白、斜杠或反斜杠。',
|
||||
'HTTP bind host cannot be empty or contain whitespace or slashes.',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (typeof port !== 'number' || !Number.isInteger(port) || port < 0 || port > 65535) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(
|
||||
childPath(path, 'port'),
|
||||
'error',
|
||||
'HTTP 端口必须是 0 到 65535 的整数。',
|
||||
'HTTP port must be an integer from 0 to 65535.',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (token !== null && typeof token !== 'string') {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(childPath(path, 'token'), 'error', 'HTTP token 必须是字符串或 null。', 'HTTP token must be a string or null.'),
|
||||
)
|
||||
} else if (typeof token === 'string' && !/^[!-~]+$/.test(token)) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(
|
||||
childPath(path, 'token'),
|
||||
'error',
|
||||
'HTTP token 只能包含无空格的可见 ASCII 字符。',
|
||||
'HTTP token must contain visible ASCII characters without spaces.',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (typeof host === 'string' && !mismatchedBrackets && !isLoopbackHttpHost(host) && token === null) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(
|
||||
childPath(path, 'token'),
|
||||
'warning',
|
||||
'非回环 HTTP 监听在运行时必须通过 JSON、命令行或环境变量提供 token。',
|
||||
'Non-loopback HTTP listeners require a token from JSON, CLI, or environment at runtime.',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (!Array.isArray(origins)) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(childPath(path, 'origins'), 'error', 'HTTP origins 必须是数组。', 'HTTP origins must be an array.'),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (hasDuplicates(origins)) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(childPath(path, 'origins'), 'error', 'HTTP origins 不能重复。', 'HTTP origins must be unique.'),
|
||||
)
|
||||
}
|
||||
origins.forEach((origin, index) => {
|
||||
if (typeof origin !== 'string' || !isExactHttpOrigin(origin)) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(
|
||||
`${childPath(path, 'origins')}[${index}]`,
|
||||
'error',
|
||||
'Origin 必须是无路径的精确 HTTP(S) 来源。',
|
||||
'Origin must be an exact HTTP(S) origin without a path.',
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function validateDomainArray(value: unknown, path: string, diagnostics: Diagnostic[]) {
|
||||
if (!Array.isArray(value)) {
|
||||
diagnostics.push(
|
||||
@@ -1788,6 +1920,7 @@ function validateCommonFields(
|
||||
),
|
||||
)
|
||||
}
|
||||
if ('http' in value) validateHttpSettings(value.http, childPath(path, 'http'), diagnostics)
|
||||
if ('ssl' in value && typeof value.ssl !== 'string' && typeof value.ssl !== 'boolean') {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(childPath(path, 'ssl'), 'error', 'SSL 设置必须是字符串或布尔值。', 'SSL setting must be a string or boolean.'),
|
||||
@@ -2214,6 +2347,18 @@ function validateConfig(value: unknown): Diagnostic[] {
|
||||
),
|
||||
)
|
||||
}
|
||||
if ('http' in provider) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(
|
||||
childPath(path, 'http'),
|
||||
'error',
|
||||
'HTTP 监听只能配置在顶层。',
|
||||
'HTTP listener settings can only be configured at the root.',
|
||||
'将 http 移到 providers 数组外。',
|
||||
'Move http outside the providers array.',
|
||||
),
|
||||
)
|
||||
}
|
||||
if (typeof provider.provider !== 'string' || !providerMap.has(provider.provider)) {
|
||||
diagnostics.push(
|
||||
makeDiagnostic(
|
||||
@@ -2347,6 +2492,9 @@ function validateFormState(): Diagnostic[] {
|
||||
),
|
||||
)
|
||||
}
|
||||
diagnostics.push(...validateObjectEditor(globalState.httpText, '$.http'))
|
||||
const http = parseObjectText(globalState.httpText)
|
||||
if (http) validateHttpSettings(http, '$.http', diagnostics)
|
||||
|
||||
providers.value.forEach((provider, index) => {
|
||||
const path = `$.providers[${index}]`
|
||||
@@ -2459,7 +2607,9 @@ function fieldDescription(path: string): string | undefined {
|
||||
|
||||
function diagnosticFieldKey(path: string): string {
|
||||
const providerMatch = path.match(/^\$\.providers\[\d+\](?:\.([a-zA-Z0-9_]+))?/)
|
||||
const httpMatch = path.match(/^\$\.http\.([a-zA-Z0-9_]+)/)
|
||||
const rootMatch = path.match(/^\$\.([a-zA-Z0-9_]+)/)
|
||||
if (httpMatch?.[1]) return 'http'
|
||||
const key = providerMatch?.[1] || rootMatch?.[1] || 'provider'
|
||||
const aliases: Record<string, string> = {
|
||||
cache_max_age: 'cache-age',
|
||||
@@ -2486,7 +2636,7 @@ function openAdvancedForPath(path: string) {
|
||||
if (/\.endpoint(?:$|[.[\]])/.test(path)) providerAdvancedOpen.value = true
|
||||
if (/\.(?:index4|index6|ttl|line)(?:$|[.[\]])/.test(path)) sourceAdvancedOpen.value = true
|
||||
if (/\.ssl(?:$|[.[\]])/.test(path)) networkAdvancedOpen.value = true
|
||||
if (/\.(?:cache|cache_max_age|log|extra)(?:$|[.[\]])/.test(path)) {
|
||||
if (/\.(?:cache|cache_max_age|log|extra|http)(?:$|[.[\]])/.test(path)) {
|
||||
runtimeAdvancedOpen.value = true
|
||||
}
|
||||
}
|
||||
@@ -2513,7 +2663,7 @@ function emptyIssueSummary(): IssueSummary {
|
||||
function sectionForPath(path: string): SectionKey {
|
||||
if (/\.(?:ipv4|ipv6|index4|index6|ttl|line)(?:$|[.[\]])/.test(path)) return 'records'
|
||||
if (/\.(?:proxy|ssl)(?:$|[.[\]])/.test(path)) return 'network'
|
||||
if (/\.(?:cache|cache_max_age|log|extra)(?:$|[.[\]])/.test(path)) return 'runtime'
|
||||
if (/\.(?:cache|cache_max_age|log|extra|http)(?:$|[.[\]])/.test(path)) return 'runtime'
|
||||
return 'provider'
|
||||
}
|
||||
|
||||
@@ -3377,6 +3527,7 @@ function loadConfigurationIntoBuilder(
|
||||
const logFile = logFieldState(value, 'file')
|
||||
const logFormat = logFieldState(value, 'format')
|
||||
const logDatefmt = logFieldState(value, 'datefmt')
|
||||
const httpText = isPlainObject(value.http) ? JSON.stringify(value.http, null, 2) : ''
|
||||
Object.assign(globalState, {
|
||||
proxyText: inputToText(value.proxy),
|
||||
proxyPresent: 'proxy' in value,
|
||||
@@ -3387,6 +3538,7 @@ function loadConfigurationIntoBuilder(
|
||||
cachePath: cache.path,
|
||||
cacheMaxAge: typeof value.cache_max_age === 'number' ? String(value.cache_max_age) : '',
|
||||
interval: typeof value.interval === 'number' ? String(value.interval) : '',
|
||||
httpText,
|
||||
logLevel: logLevelFromObject(value),
|
||||
logFile: logFile.value,
|
||||
logFilePresent: logFile.present,
|
||||
@@ -4374,6 +4526,25 @@ async function useRuntimeCredential(field: 'id' | 'token') {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="subsection-heading">
|
||||
<h3>{{ c.httpConfig }}</h3>
|
||||
<p>{{ c.httpHint }}</p>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="field field-wide">
|
||||
<span>{{ c.httpConfig }}</span>
|
||||
<textarea
|
||||
:id="globalFieldId('http')"
|
||||
v-model="globalState.httpText"
|
||||
rows="6"
|
||||
placeholder='{ "host": "127.0.0.1", "port": 9876 }'
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="subsection-heading">
|
||||
<h3>{{ c.logging }}</h3>
|
||||
</div>
|
||||
|
||||
+10
-48
@@ -317,7 +317,7 @@ SSL证书验证方式,控制HTTPS连接的证书验证行为。
|
||||
|
||||
## Web 控制台与内置调度
|
||||
|
||||
提供 `--interval` 会自动启动仅监听本机回环地址的常驻控制台,无需额外写 `web`。Web 进程同时负责周期同步,不需要 cron、systemd timer、launchd job 或 Windows 任务计划程序重复执行 DDNS。原有 `ddns web` 写法继续兼容。
|
||||
提供 `--interval` 会自动启动常驻控制台,无需额外写 `web`。Web 进程同时负责周期同步,并在同一监听地址的 `/mcp` 提供 [MCP `2026-07-28` Streamable HTTP](mcp.md)。原有 `ddns web` 写法继续兼容。
|
||||
|
||||
```bash
|
||||
ddns -c /etc/ddns/config.json --interval 5 --open
|
||||
@@ -326,60 +326,22 @@ ddns -c /etc/ddns/config.json --interval 5 --open
|
||||
| 参数 | 描述 |
|
||||
|------|------|
|
||||
| `-c, --config FILE` | 选择一份可由控制台维护的本地配置文件 |
|
||||
| `--host` | 本机监听地址:`127.0.0.1`、`localhost` 或 `::1` |
|
||||
| `--port` | 控制台端口,默认 `9876`;设为 `0` 时由系统分配空闲端口 |
|
||||
| `--host` | Web/MCP 共享监听地址;默认 `127.0.0.1`,支持具体局域网地址及 `0.0.0.0` / `::` |
|
||||
| `--port` | Web/MCP 共享端口,默认 `9876`;设为 `0` 时由系统分配空闲端口 |
|
||||
| `--http-token` | Web API 与 HTTP MCP 共享的访问 token;仅允许无空格的可见 ASCII 字符 |
|
||||
| `--http-origin ORIGIN` | 允许浏览器跨源访问 `/mcp` 的精确 HTTP(S) origin;可重复 |
|
||||
| `--interval MINs` | 当前 Web 进程的内置同步间隔,范围 1–1440 分钟,默认 5 |
|
||||
| `--open` | 启动后打开浏览器 |
|
||||
|
||||
`--interval` 是显式的 Web 模式标识;本地 JSON 顶层的 `interval` 也会让 `ddns -c FILE` 自动进入 Web 模式。优先级为命令行 `--interval` > JSON `interval` > 默认 5 分钟。控制台中保存的新间隔会写回 JSON 并立即应用;暂停和恢复只影响当前进程,不会创建额外状态文件。生产环境应使用 systemd service、launchd、Windows 服务或 Docker 重启策略保活 Web 进程,而不是再创建周期任务。
|
||||
监听配置优先级为命令行 > JSON 顶层 `http` > `DDNS_HTTP_*` 环境变量 > `127.0.0.1:9876`。回环地址未配置 token 时,Web API(包括完整配置和服务商凭据)与 `/mcp` 均免认证;非回环或通配监听必须配置非空、无空格的可见 ASCII token。配置 token 后,页面继续使用 `X-DDNS-Token`,也接受 `Authorization: Bearer`;MCP 使用 Bearer token。
|
||||
|
||||
如果检测到已启用的 `ddns task` 系统任务,Web 内置调度会停止执行并显示冲突。可以在控制台中显式接管,或先运行 `ddns task --disable`。两种调度方式不能同时运行。
|
||||
DDNS 不提供 TLS。局域网监听必须置于受信 HTTPS 反向代理之后,并保留 `Authorization`、`Host` 和 `Origin`。浏览器通过 TLS 终止代理访问 `/mcp` 时,即使公网页面看起来同源,也必须把精确的公网 HTTPS origin 配置到 `http.origins` 或 `--http-origin`;原生 MCP 客户端可不发送 `Origin`。
|
||||
|
||||
## MCP 服务
|
||||
`--interval` 是显式的 Web 模式标识;本地 JSON 顶层的 `interval` 也会让 `ddns -c FILE` 自动进入 Web 模式。优先级为命令行 `--interval` > JSON `interval` > 默认 5 分钟。控制台中保存的新间隔会写回 JSON 并立即应用;HTTP 监听和认证设置需重启后生效。暂停和恢复只影响当前进程。
|
||||
|
||||
`mcp` 子命令通过 stdio 启动一个仅使用 Python 标准库的本机 MCP 服务。它不会监听网络,也不会在协议参数或结果中返回服务商凭据。
|
||||
如果检测到已启用的 `ddns task` 系统任务,Web 内置调度会停止执行并显示冲突。可以在控制台中显式接管,或先运行 `ddns task --disable`。不要让多个 Web、MCP 或系统任务进程同时更新同一份配置。
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
配置路径优先级为 `-c/--config` > `DDNS_CONFIG` > 默认本地配置路径。MCP 模式只接受一份本地配置文件,不支持远程 URL 或多个 `-c`。stdout 专用于每行一条的 JSON-RPC 消息,运行日志只写入 stderr。
|
||||
|
||||
### MCP 客户端配置
|
||||
|
||||
在 GitHub Copilot CLI 中可以直接添加:
|
||||
|
||||
```bash
|
||||
copilot mcp add ddns -- ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
也可以在支持 MCP 的客户端配置中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddns": {
|
||||
"command": "ddns",
|
||||
"args": ["mcp", "-c", "/etc/ddns/config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Windows JSON 路径中的反斜杠需要写成 `\\`,也可以将 `command` 替换为 `ddns` 可执行文件的绝对路径。
|
||||
|
||||
### 可用工具
|
||||
|
||||
| 工具 | 参数 | 行为 |
|
||||
|------|------|------|
|
||||
| `get_ddns_status` | 无 | 读取本地配置、缓存地址、缓存记录、服务商摘要和最近同步时间,不实时查询 DNS 服务商 |
|
||||
| `update_dns_records` | 无 | 使用配置中的当前 IP 规则,对所有已配置记录执行一次完整同步,并返回同步后的状态 |
|
||||
|
||||
`update_dns_records` 会修改外部 DNS 状态,客户端应在调用前保留人工确认。工具不接受域名、IP、服务商或凭据参数,避免模型绕过本地配置写入任意记录。
|
||||
|
||||
客户端取消更新时,服务会在 IP 获取规则、域名记录及 DNS 服务商之间停止后续处理;已经发送给服务商的 API 请求无法撤销,可能仍会完成。
|
||||
|
||||
当前实现支持 MCP `2026-07-28` 的无握手协议,并兼容 GitHub Copilot CLI 使用的 `2025-11-25` `initialize` 生命周期;不兼容更早协议,也不提供 HTTP、resources、prompts 或 subscriptions。状态来自 DDNS 本地缓存;关闭缓存后,独立的后续状态请求无法还原上一进程的同步历史,但更新调用本身仍会返回本次结果。
|
||||
MCP 的客户端配置、stdio/HTTP 用法、工具和限制请参阅 [MCP 服务](mcp.md)。
|
||||
|
||||
## Task Management (定时任务管理)
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ DDNS 支持通过环境变量进行配置,环境变量的优先级为:**[命
|
||||
| `DDNS_PROXY` | `http://host:port` 或 DIRECT,支持多代理数组或分号分隔 | HTTP 代理设置 | `DDNS_PROXY="http://127.0.0.1:1080;DIRECT"` |
|
||||
| `DDNS_CACHE` | true、false 或文件路径 | 启用缓存或指定缓存文件路径 | `DDNS_CACHE="/tmp/cache"` |
|
||||
| `DDNS_CACHE_MAX_AGE` | 非负整数(秒) | 缓存文件最大有效期,默认 259200,0 表示每次运行清空已有缓存 | `DDNS_CACHE_MAX_AGE=86400` |
|
||||
| `DDNS_HTTP_HOST` | 监听地址 | Web 与 HTTP MCP 共享监听地址 | `DDNS_HTTP_HOST=127.0.0.1` |
|
||||
| `DDNS_HTTP_PORT` | 0–65535 的整数 | Web 与 HTTP MCP 共享端口 | `DDNS_HTTP_PORT=9876` |
|
||||
| `DDNS_HTTP_TOKEN` | 无空格的可见 ASCII 字符串 | Web API 与 HTTP MCP 共享 token | `DDNS_HTTP_TOKEN="secret"` |
|
||||
| `DDNS_HTTP_ORIGINS` | JSON 数组或逗号/分号分隔列表 | 允许跨源访问 `/mcp` 的精确 origin | `DDNS_HTTP_ORIGINS='["https://ddns.example.com"]'` |
|
||||
| `DDNS_SSL` | true、false、auto 或文件路径 | 设置 SSL 验证方式或指定证书路径 | `DDNS_SSL=false`<br>`DDNS_SSL=/path/ca.crt` |
|
||||
| `DDNS_CRON` | Cron 表达式格式字符串(仅 Docker 环境有效) | Docker 容器内定时任务周期 | `DDNS_CRON="*/10 * * * *"` |
|
||||
| `DDNS_LOG_LEVEL` | DEBUG、INFO、WARNING、ERROR、CRITICAL | 设置日志等级 | `DDNS_LOG_LEVEL="DEBUG"` |
|
||||
@@ -232,6 +236,12 @@ DDNS 支持通过环境变量进行配置,环境变量的优先级为:**[命
|
||||
|
||||
`DDNS_CACHE_MAX_AGE` 控制整个缓存文件的有效期(秒),在下一次运行时按文件 mtime 判断,边界值也算过期,未来 mtime 也算过期。它不是 DNS TTL;缓存仍是扁平 JSON,任何内容写入都会刷新整个文件并影响所有记录,不进行迁移,共享缓存限制不变。
|
||||
|
||||
### Web 与 HTTP MCP
|
||||
|
||||
`DDNS_HTTP_HOST`、`DDNS_HTTP_PORT`、`DDNS_HTTP_TOKEN` 和 `DDNS_HTTP_ORIGINS` 对 `ddns web` 与 `ddns mcp --transport http` 同时生效。命令行参数和 JSON 顶层 `http` 会覆盖这些值。
|
||||
|
||||
回环监听未设置 `DDNS_HTTP_TOKEN` 时,Web API 和 `/mcp` 均免认证;非回环或通配监听必须设置非空 token。DDNS 不内置 TLS,局域网监听应由受信 HTTPS 反向代理保护。
|
||||
|
||||
### SSL证书验证
|
||||
|
||||
#### DDNS_SSL
|
||||
|
||||
@@ -73,12 +73,39 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
|
||||
| cache | string\|bool | 否 | `true` | 是否缓存记录 | 正常情况打开避免频繁更新,默认位置为临时目录下`ddns.{hash}.cache`,也可以指定具体路径 |
|
||||
| cache_max_age | integer | 否 | `259200` | 缓存文件最大有效期(秒) | `0` 表示下一次运行清空已有缓存;与 DNS TTL 无关 |
|
||||
| interval | integer | 否 | 无 | Web 自动同步间隔(分钟) | 顶层配置,范围 1–1440;配置后普通启动会自动进入 Web 模式 |
|
||||
| http | object | 否 | `127.0.0.1:9876` | Web 与 HTTP MCP 共享监听配置 | 顶层配置;支持 `host`、`port`、`token`、`origins` |
|
||||
| log | object | 否 | `null` | 日志配置 | 日志配置对象,支持`level`、`file`、`format`、`datefmt`参数 |
|
||||
|
||||
### interval
|
||||
|
||||
在配置文件顶层设置 `interval` 后,`ddns -c config.json` 会启动常驻 Web 控制台,并按该分钟数自动同步。命令行 `--interval` 优先于配置值;省略两者时,普通 `ddns -c config.json` 仍只同步一次。`interval` 只能配置在顶层,范围为 1–1440。
|
||||
|
||||
### http
|
||||
|
||||
`http` 同时控制 Web 控制台及 MCP `2026-07-28` Streamable HTTP endpoint `/mcp`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"http": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9876,
|
||||
"token": null,
|
||||
"origins": ["https://ddns.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `host` | string | `"127.0.0.1"` | 监听地址;支持具体局域网地址及 `0.0.0.0` / `::` |
|
||||
| `port` | integer | `9876` | 监听端口,范围 0–65535;`0` 由系统分配 |
|
||||
| `token` | string\|null | `null` | Web API 与 HTTP MCP 共享 token;使用无空格的可见 ASCII 字符,明文保存在配置中 |
|
||||
| `origins` | array | `[]` | 允许跨源访问 `/mcp` 的精确 HTTP(S) origin,不支持通配符或路径 |
|
||||
|
||||
回环监听且 `token` 为空时,Web API(包括完整 provider 配置和凭据)与 `/mcp` 均免认证。非回环或通配监听必须配置非空 token;HTTP MCP 通过 `Authorization: Bearer` 发送,Web 页面也使用相同 token。DDNS 不内置 TLS,局域网监听应放在受信 HTTPS 反向代理后,并确保代理不会记录 token。
|
||||
|
||||
监听设置优先级为命令行参数 > JSON `http` > `DDNS_HTTP_HOST`、`DDNS_HTTP_PORT`、`DDNS_HTTP_TOKEN`、`DDNS_HTTP_ORIGINS` > 默认值。保存这些设置后需重启进程。
|
||||
|
||||
### dns
|
||||
|
||||
`dns`参数指定使用的DNS服务商标识,支持以下值, 请参考 [服务商列表](../providers/):
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: DDNS MCP 服务
|
||||
description: 使用 stdio 或 Streamable HTTP 将 DDNS 状态查询和同步工具接入 MCP 客户端
|
||||
---
|
||||
|
||||
# MCP 服务
|
||||
|
||||
DDNS 提供仅使用 Python 标准库的 MCP 服务,让 GitHub Copilot 等客户端查询本地缓存状态,或在人工确认后执行一次完整的 DDNS 同步。服务不会在协议参数或结果中返回 DNS 服务商凭据。
|
||||
|
||||
配置路径优先级为 `-c/--config` > `DDNS_CONFIG` > 默认本地配置路径。MCP 模式只接受一份本地配置文件,不支持远程 URL 或多个 `-c`。
|
||||
|
||||
## stdio(默认)
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
stdio 模式不监听网络。stdout 专用于每行一条 JSON-RPC 消息,运行日志只写入 stderr。
|
||||
|
||||
在 GitHub Copilot CLI 中可以直接添加:
|
||||
|
||||
```bash
|
||||
copilot mcp add ddns -- ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
也可以在支持 MCP 的客户端配置中添加:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddns": {
|
||||
"command": "ddns",
|
||||
"args": ["mcp", "-c", "/etc/ddns/config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Windows JSON 路径中的反斜杠需要写成 `\\`,也可以将 `command` 替换为 `ddns` 可执行文件的绝对路径。
|
||||
|
||||
## Streamable HTTP
|
||||
|
||||
独立启动 HTTP MCP:
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json --transport http
|
||||
```
|
||||
|
||||
默认 endpoint 为 `http://127.0.0.1:9876/mcp`。运行 [Web 控制台](cli.md#web-控制台与内置调度) 时,同一监听地址也会自动提供 `/mcp`。
|
||||
|
||||
独立模式与 Web 共用以下参数:
|
||||
|
||||
| 参数 | 描述 |
|
||||
|------|------|
|
||||
| `--host` | 监听地址;默认 `127.0.0.1`,支持具体局域网地址及 `0.0.0.0` / `::` |
|
||||
| `--port` | 监听端口,默认 `9876`;设为 `0` 时由系统分配 |
|
||||
| `--http-token` | Web API 与 HTTP MCP 共享的 token;仅允许无空格的可见 ASCII 字符 |
|
||||
| `--http-origin ORIGIN` | 允许浏览器访问 `/mcp` 的精确 HTTP(S) origin;可重复 |
|
||||
|
||||
监听配置优先级为命令行 > JSON 顶层 [`http`](json.md#http) > `DDNS_HTTP_*` [环境变量](env.md#web-与-http-mcp) > `127.0.0.1:9876`。
|
||||
|
||||
回环地址未配置 token 时,Web API(包括完整配置和服务商凭据)与 `/mcp` 均免认证;非回环或通配监听必须配置非空 token。MCP 客户端通过 `Authorization: Bearer <token>` 发送凭据。
|
||||
|
||||
DDNS 不提供 TLS。局域网监听必须置于受信 HTTPS 反向代理之后,并保留 `Authorization`、`Host` 和 `Origin`。浏览器通过 TLS 终止代理访问 `/mcp` 时,即使公网页面看起来同源,也必须把精确的公网 HTTPS origin 配置到 `http.origins` 或 `--http-origin`;原生 MCP 客户端可不发送 `Origin`。
|
||||
|
||||
HTTP transport 仅支持 MCP `2026-07-28`。每个请求单独 POST 到 `/mcp`,必须使用 `Content-Type: application/json` 并携带 `MCP-Protocol-Version`、`Mcp-Method`,调用工具时还需 `Mcp-Name`;`Accept` 必须同时包含 `application/json` 和 `text/event-stream`。
|
||||
|
||||
当前 tools-only 实现返回普通 JSON,不提供 SSE、sessions、legacy HTTP、resources、prompts 或 subscriptions。
|
||||
|
||||
## 可用工具
|
||||
|
||||
| 工具 | 参数 | 行为 |
|
||||
|------|------|------|
|
||||
| `get_ddns_status` | 无 | 读取本地配置、缓存地址、缓存记录、服务商摘要和最近同步时间,不实时查询 DNS 服务商 |
|
||||
| `update_dns_records` | 无 | 使用配置中的当前 IP 规则,对所有已配置记录执行一次完整同步,并返回同步后的状态 |
|
||||
|
||||
`update_dns_records` 会修改外部 DNS 状态,客户端应在调用前保留人工确认。工具不接受域名、IP、服务商或凭据参数,避免模型绕过本地配置写入任意记录。
|
||||
|
||||
## 协议和运行限制
|
||||
|
||||
- stdio 支持 MCP `2026-07-28`,并兼容 `2025-11-25` `initialize` 生命周期。
|
||||
- HTTP 仅支持 MCP `2026-07-28`。
|
||||
- stdio 客户端取消更新时,服务会在 IP 获取规则、域名记录及 DNS 服务商之间停止后续处理。
|
||||
- HTTP 不提供 SSE 取消;客户端断开后同步可能继续完成。
|
||||
- 已经发送给 DNS 服务商的 API 请求无法撤销。
|
||||
- 状态来自本地缓存;关闭缓存后,另一进程中的后续状态请求无法还原此前的同步历史,但更新调用本身仍会返回本次结果。
|
||||
- 不要让多个 Web、MCP 或系统任务进程同时更新同一份配置。
|
||||
+10
-48
@@ -285,7 +285,7 @@ ddns --dns alidns --id ACCESS_KEY --token SECRET_KEY \
|
||||
|
||||
## Web console and in-process scheduling
|
||||
|
||||
Providing `--interval` automatically starts the long-running console on a loopback address, so the `web` word is optional. The Web process also owns periodic synchronization, so cron, systemd timers, launchd jobs, and Windows Task Scheduler must not invoke DDNS at the same time. The existing `ddns web` form remains compatible.
|
||||
Providing `--interval` automatically starts the long-running console, so the `web` word is optional. The Web process also owns periodic synchronization and exposes [MCP `2026-07-28` Streamable HTTP](mcp.md) at `/mcp` on the same listener. The existing `ddns web` form remains compatible.
|
||||
|
||||
```bash
|
||||
ddns -c /etc/ddns/config.json --interval 5 --open
|
||||
@@ -294,60 +294,22 @@ ddns -c /etc/ddns/config.json --interval 5 --open
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-c, --config FILE` | Select one local configuration file managed by the console |
|
||||
| `--host` | Loopback listener: `127.0.0.1`, `localhost`, or `::1` |
|
||||
| `--port` | Console port, default `9876`; use `0` to let the OS select an available port |
|
||||
| `--host` | Shared Web/MCP bind host; defaults to `127.0.0.1` and accepts a LAN address or `0.0.0.0` / `::` |
|
||||
| `--port` | Shared Web/MCP port, default `9876`; use `0` to let the OS select an available port |
|
||||
| `--http-token` | Shared Web API and HTTP MCP token; visible ASCII without spaces |
|
||||
| `--http-origin ORIGIN` | Exact HTTP(S) origin allowed to access `/mcp` cross-origin; repeatable |
|
||||
| `--interval MINs` | Built-in interval for the current Web process, from 1 to 1440 minutes, default 5 |
|
||||
| `--open` | Open the browser after startup |
|
||||
|
||||
`--interval` is the explicit Web mode signal; a top-level JSON `interval` also makes `ddns -c FILE` enter Web mode automatically. Precedence is command-line `--interval` > JSON `interval` > the 5-minute default. Saving a new interval in the console writes it to JSON and applies it immediately; pausing and resuming affect only the current process and create no extra state file. In production, use a systemd service, launchd, a Windows service, or a Docker restart policy to supervise the Web process instead of creating another periodic task.
|
||||
Listener precedence is command line > root JSON `http` > `DDNS_HTTP_*` environment variables > `127.0.0.1:9876`. When loopback has no token, the Web APIs (including full configuration and provider credentials) and `/mcp` are unauthenticated. A non-loopback or wildcard listener requires a non-empty visible-ASCII token without spaces. With a token, the page continues to use `X-DDNS-Token` and also accepts `Authorization: Bearer`; MCP uses the Bearer form.
|
||||
|
||||
When an enabled `ddns task` system task is detected, Web scheduling stops and reports a conflict. Explicitly take over in the console or run `ddns task --disable` first. The two scheduling modes cannot run together.
|
||||
DDNS does not provide TLS. Put LAN listeners behind a trusted HTTPS reverse proxy that preserves `Authorization`, `Host`, and `Origin`. Browser access through a TLS-terminating proxy must list the exact public HTTPS origin in `http.origins` or `--http-origin`, even when the public page appears same-origin; native MCP clients may omit `Origin`.
|
||||
|
||||
## MCP server
|
||||
`--interval` is the explicit Web mode signal; a top-level JSON `interval` also makes `ddns -c FILE` enter Web mode automatically. Precedence is command-line `--interval` > JSON `interval` > the 5-minute default. Saving an interval applies it immediately; HTTP listener and authentication changes require a restart.
|
||||
|
||||
The `mcp` subcommand starts a local stdio MCP server implemented with the Python standard library only. It opens no network listener and never returns provider credentials in protocol arguments or results.
|
||||
When an enabled `ddns task` system task is detected, Web scheduling stops and reports a conflict. Explicitly take over in the console or run `ddns task --disable` first. Do not let multiple Web, MCP, or system-task processes update the same configuration concurrently.
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
Configuration path precedence is `-c/--config` > `DDNS_CONFIG` > the conventional local config paths. MCP mode accepts exactly one local file; remote URLs and multiple `-c` values are rejected. stdout is reserved for one JSON-RPC message per line, while runtime logs go to stderr.
|
||||
|
||||
### MCP client configuration
|
||||
|
||||
Add the server directly to GitHub Copilot CLI:
|
||||
|
||||
```bash
|
||||
copilot mcp add ddns -- ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
Alternatively, add the following to an MCP client configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddns": {
|
||||
"command": "ddns",
|
||||
"args": ["mcp", "-c", "/etc/ddns/config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Escape Windows path backslashes as `\\` in JSON, or replace `command` with the absolute path to the `ddns` executable.
|
||||
|
||||
### Available tools
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|------|-----------|----------|
|
||||
| `get_ddns_status` | None | Read local configuration, cached addresses and records, provider summaries, and the latest sync time; it does not query providers live |
|
||||
| `update_dns_records` | None | Resolve current addresses using the configuration, synchronize every configured record once, and return the resulting status |
|
||||
|
||||
`update_dns_records` changes external DNS state, so clients should retain user confirmation before invoking it. The tool accepts no domain, address, provider, or credential arguments, preventing a model from bypassing the local configuration to write arbitrary records.
|
||||
|
||||
When a client cancels an update, the server stops before subsequent IP rules, domain records, or providers. A provider API request that has already been sent cannot be rolled back and may still complete.
|
||||
|
||||
This implementation supports the handshake-free MCP `2026-07-28` protocol and the `2025-11-25` `initialize` lifecycle used by GitHub Copilot CLI. It does not support earlier revisions or expose HTTP, resources, prompts, or subscriptions. Status comes from the local DDNS cache; when caching is disabled, a later independent status request cannot reconstruct sync history from a previous process, although the update call still returns its own result.
|
||||
See [MCP Server](mcp.md) for client configuration, stdio and HTTP usage, tools, and protocol limits.
|
||||
|
||||
## Task Management
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ All environment variables use the `DDNS_` prefix followed by the parameter name
|
||||
| `DDNS_PROXY` | `http://host:port` or `DIRECT`, multiple values separated by semicolons | HTTP proxy settings | `DDNS_PROXY="http://127.0.0.1:1080;DIRECT"` |
|
||||
| `DDNS_CACHE` | `true`, `false`, or file path | Enable or specify cache file | `DDNS_CACHE="/tmp/cache"` |
|
||||
| `DDNS_CACHE_MAX_AGE` | Non-negative integer (seconds) | Whole cache file max age; default 259200, `0` clears an existing cache every invocation | `DDNS_CACHE_MAX_AGE=86400` |
|
||||
| `DDNS_HTTP_HOST` | Bind host | Shared Web and HTTP MCP listener | `DDNS_HTTP_HOST=127.0.0.1` |
|
||||
| `DDNS_HTTP_PORT` | Integer from 0 to 65535 | Shared Web and HTTP MCP port | `DDNS_HTTP_PORT=9876` |
|
||||
| `DDNS_HTTP_TOKEN` | Visible ASCII string without spaces | Shared Web API and HTTP MCP token | `DDNS_HTTP_TOKEN="secret"` |
|
||||
| `DDNS_HTTP_ORIGINS` | JSON array or comma/semicolon-separated list | Exact origins allowed to access `/mcp` cross-origin | `DDNS_HTTP_ORIGINS='["https://ddns.example.com"]'` |
|
||||
| `DDNS_SSL` | `true`, `false`, `auto`, or file path | SSL verification mode or certificate path | `DDNS_SSL=false`<br>`DDNS_SSL=/path/ca.crt` |
|
||||
| `DDNS_CRON` | Cron expression format string (Docker only) | Cron schedule for Docker container | `DDNS_CRON="*/10 * * * *"` |
|
||||
| `DDNS_LOG_LEVEL` | `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` | Logging level | `DDNS_LOG_LEVEL="DEBUG"` |
|
||||
@@ -388,6 +392,12 @@ export DDNS_TOKEN='{"api_key": "your_key", "domain": "__DOMAIN__", "ip": "__IP__
|
||||
|
||||
`DDNS_CACHE_MAX_AGE` controls whole-file expiry in seconds. Expiry is checked on the next invocation; the exact boundary is stale and a future mtime is stale. It is not DNS TTL. The cache remains flat JSON with one mtime, so any content write refreshes every entry; no migration is performed and shared-cache limitations are unchanged.
|
||||
|
||||
### Web and HTTP MCP
|
||||
|
||||
`DDNS_HTTP_HOST`, `DDNS_HTTP_PORT`, `DDNS_HTTP_TOKEN`, and `DDNS_HTTP_ORIGINS` apply to both `ddns web` and `ddns mcp --transport http`. Command-line options and the root JSON `http` object override them.
|
||||
|
||||
When loopback has no `DDNS_HTTP_TOKEN`, the Web APIs and `/mcp` are unauthenticated. Non-loopback and wildcard listeners require a non-empty token. DDNS does not include TLS; protect LAN listeners with a trusted HTTPS reverse proxy.
|
||||
|
||||
### Docker Cron Schedule Configuration
|
||||
|
||||
#### DDNS_CRON
|
||||
|
||||
@@ -71,12 +71,39 @@ Configuration Parameters Table
|
||||
| cache | string\|bool | No | `true` | Enable Record Caching | Enable to avoid frequent updates, default location is `ddns.{hash}.cache` in temp directory, or specify custom path |
|
||||
| cache_max_age | integer | No | `259200` | Cache File Max Age (seconds) | `0` clears an existing cache on the next invocation; distinct from DNS TTL |
|
||||
| interval | integer | No | None | Web automatic synchronization interval (minutes) | Root-only, from 1 to 1440; enables Web mode during normal startup |
|
||||
| http | object | No | `127.0.0.1:9876` | Shared Web and HTTP MCP listener | Root-only; supports `host`, `port`, `token`, and `origins` |
|
||||
| log | object | No | `null` | Log Configuration | Log configuration object, supports `level`, `file`, `format`, `datefmt` parameters |
|
||||
|
||||
### interval
|
||||
|
||||
When a root-level `interval` is present, `ddns -c config.json` starts the long-running Web console and synchronizes at that minute interval. Command-line `--interval` overrides the configured value. When both are omitted, normal `ddns -c config.json` still performs one synchronization and exits. `interval` is root-only and must be from 1 to 1440.
|
||||
|
||||
### http
|
||||
|
||||
`http` controls both the Web console and the MCP `2026-07-28` Streamable HTTP endpoint at `/mcp`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"http": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 9876,
|
||||
"token": null,
|
||||
"origins": ["https://ddns.example.com"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `host` | string | `"127.0.0.1"` | Bind host; accepts a LAN address and `0.0.0.0` / `::` |
|
||||
| `port` | integer | `9876` | Port from 0 to 65535; `0` lets the OS select one |
|
||||
| `token` | string\|null | `null` | Shared Web API and HTTP MCP token; visible ASCII without spaces, stored as plaintext |
|
||||
| `origins` | array | `[]` | Exact HTTP(S) origins allowed to access `/mcp` cross-origin; no wildcard or path |
|
||||
|
||||
When loopback has no token, the Web APIs (including complete provider configuration and credentials) and `/mcp` are unauthenticated. A non-loopback or wildcard listener requires a non-empty token. HTTP MCP sends it with `Authorization: Bearer`, and the Web page uses the same value. DDNS does not include TLS; put LAN listeners behind a trusted HTTPS reverse proxy and ensure the proxy does not log the token.
|
||||
|
||||
Listener precedence is command-line options > JSON `http` > `DDNS_HTTP_HOST`, `DDNS_HTTP_PORT`, `DDNS_HTTP_TOKEN`, and `DDNS_HTTP_ORIGINS` > defaults. Restart the process after saving these settings.
|
||||
|
||||
### dns
|
||||
|
||||
The `dns` parameter specifies the DNS provider identifier. For supported values, please refer to the [Provider List](../providers/):
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
---
|
||||
title: DDNS MCP Server
|
||||
description: Connect DDNS status and synchronization tools to MCP clients over stdio or Streamable HTTP
|
||||
---
|
||||
|
||||
# MCP Server
|
||||
|
||||
DDNS provides a standard-library-only MCP server so clients such as GitHub Copilot can inspect local cached status or run one complete DDNS synchronization after user approval. Provider credentials are never returned in protocol arguments or results.
|
||||
|
||||
Configuration path precedence is `-c/--config` > `DDNS_CONFIG` > the conventional local config paths. MCP mode accepts exactly one local file; remote URLs and multiple `-c` values are rejected.
|
||||
|
||||
## stdio (default)
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
stdio mode opens no network listener. stdout is reserved for one JSON-RPC message per line, while runtime logs go to stderr.
|
||||
|
||||
Add the server directly to GitHub Copilot CLI:
|
||||
|
||||
```bash
|
||||
copilot mcp add ddns -- ddns mcp -c /etc/ddns/config.json
|
||||
```
|
||||
|
||||
Alternatively, add the following to an MCP client configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"ddns": {
|
||||
"command": "ddns",
|
||||
"args": ["mcp", "-c", "/etc/ddns/config.json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Escape Windows path backslashes as `\\` in JSON, or replace `command` with the absolute path to the `ddns` executable.
|
||||
|
||||
## Streamable HTTP
|
||||
|
||||
Start a standalone HTTP MCP server:
|
||||
|
||||
```bash
|
||||
ddns mcp -c /etc/ddns/config.json --transport http
|
||||
```
|
||||
|
||||
The default endpoint is `http://127.0.0.1:9876/mcp`. The [Web console](cli.md#web-console-and-in-process-scheduling) also exposes `/mcp` on its listener.
|
||||
|
||||
Standalone mode and Web share these options:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--host` | Bind host; defaults to `127.0.0.1` and accepts a LAN address or `0.0.0.0` / `::` |
|
||||
| `--port` | Listener port, default `9876`; use `0` to let the OS select one |
|
||||
| `--http-token` | Shared Web API and HTTP MCP token; visible ASCII without spaces |
|
||||
| `--http-origin ORIGIN` | Exact HTTP(S) origin allowed to access `/mcp`; repeatable |
|
||||
|
||||
Listener precedence is command line > root JSON [`http`](json.md#http) > `DDNS_HTTP_*` [environment variables](env.md#web-and-http-mcp) > `127.0.0.1:9876`.
|
||||
|
||||
When loopback has no token, the Web APIs (including full configuration and provider credentials) and `/mcp` are unauthenticated. A non-loopback or wildcard listener requires a non-empty token. MCP clients send it as `Authorization: Bearer <token>`.
|
||||
|
||||
DDNS does not provide TLS. Put LAN listeners behind a trusted HTTPS reverse proxy that preserves `Authorization`, `Host`, and `Origin`. Browser access through a TLS-terminating proxy must list the exact public HTTPS origin in `http.origins` or `--http-origin`, even when the public page appears same-origin; native MCP clients may omit `Origin`.
|
||||
|
||||
HTTP supports MCP `2026-07-28` only. Each message is a separate POST to `/mcp` using `Content-Type: application/json` with `MCP-Protocol-Version` and `Mcp-Method`; tool calls also require `Mcp-Name`. `Accept` must list both `application/json` and `text/event-stream`.
|
||||
|
||||
This tools-only implementation returns plain JSON and does not provide SSE, sessions, legacy HTTP, resources, prompts, or subscriptions.
|
||||
|
||||
## Available tools
|
||||
|
||||
| Tool | Arguments | Behavior |
|
||||
|------|-----------|----------|
|
||||
| `get_ddns_status` | None | Read local configuration, cached addresses and records, provider summaries, and the latest sync time; it does not query providers live |
|
||||
| `update_dns_records` | None | Resolve current addresses using the configuration, synchronize every configured record once, and return the resulting status |
|
||||
|
||||
`update_dns_records` changes external DNS state, so clients should retain user confirmation before invoking it. The tool accepts no domain, address, provider, or credential arguments, preventing a model from bypassing the local configuration to write arbitrary records.
|
||||
|
||||
## Protocol and runtime limits
|
||||
|
||||
- stdio supports MCP `2026-07-28` and the `2025-11-25` `initialize` lifecycle.
|
||||
- HTTP supports MCP `2026-07-28` only.
|
||||
- When a stdio client cancels an update, the server stops before subsequent IP rules, domain records, or providers.
|
||||
- HTTP provides no SSE cancellation; work may continue after a client disconnects.
|
||||
- A provider API request already sent cannot be rolled back.
|
||||
- Status comes from the local cache. With caching disabled, a later request in another process cannot reconstruct previous sync history, although the update call still returns its own result.
|
||||
- Do not let multiple Web, MCP, or system-task processes update the same configuration concurrently.
|
||||
@@ -37,6 +37,7 @@ DDNS is a Python-based dynamic DNS client that automatically updates DNS records
|
||||
|
||||
### Configuration Guides
|
||||
- CLI Arguments: https://ddns.newfuture.cc/config/cli ([.md](https://ddns.newfuture.cc/config/cli.md))
|
||||
- MCP Server: https://ddns.newfuture.cc/config/mcp ([.md](https://ddns.newfuture.cc/config/mcp.md))
|
||||
- Environment Variables: https://ddns.newfuture.cc/config/env ([.md](https://ddns.newfuture.cc/config/env.md))
|
||||
- JSON Configuration: https://ddns.newfuture.cc/config/json ([.md](https://ddns.newfuture.cc/config/json.md))
|
||||
- Interactive Config Studio: https://ddns.newfuture.cc/config/studio.html (build and validate DDNS configurations in the browser)
|
||||
|
||||
+52
-2
@@ -23,6 +23,47 @@
|
||||
"minimum": 1,
|
||||
"maximum": 1440
|
||||
},
|
||||
"http": {
|
||||
"$id": "/properties/http",
|
||||
"type": "object",
|
||||
"title": "Web and MCP HTTP listener",
|
||||
"description": "Web控制台与MCP Streamable HTTP共享的监听和认证配置",
|
||||
"properties": {
|
||||
"host": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^(?:\\[[^\\s/\\\\\\u0000\\[\\]]+\\]|[^\\s/\\\\\\u0000\\[\\]]+)$",
|
||||
"default": "127.0.0.1",
|
||||
"description": "监听地址;非回环地址和通配地址必须同时配置token"
|
||||
},
|
||||
"port": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 65535,
|
||||
"default": 9876
|
||||
},
|
||||
"token": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
],
|
||||
"minLength": 1,
|
||||
"pattern": "^[!-~]+$",
|
||||
"description": "Web API与MCP HTTP共享的Bearer token;回环监听可省略"
|
||||
},
|
||||
"origins": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"default": [],
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^https?://(?:\\[[0-9A-Fa-f:.%]+\\]|[^\\s/@?#:]+)(?::[0-9]+)?/?$"
|
||||
},
|
||||
"description": "允许跨源访问MCP endpoint的精确HTTP(S) origin"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"providers": {
|
||||
"$id": "/properties/providers",
|
||||
"type": "array",
|
||||
@@ -254,8 +295,17 @@
|
||||
"provider"
|
||||
],
|
||||
"not": {
|
||||
"required": [
|
||||
"interval"
|
||||
"anyOf": [
|
||||
{
|
||||
"required": [
|
||||
"interval"
|
||||
]
|
||||
},
|
||||
{
|
||||
"required": [
|
||||
"http"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
||||
+102
-1
@@ -849,6 +849,68 @@ class TestMcpE2E(OfflineE2ETestCase):
|
||||
self.assertFalse(responses[3]["result"]["isError"])
|
||||
self.assertNotIn("resultType", responses[3]["result"])
|
||||
|
||||
def test_modern_streamable_http_tools(self):
|
||||
"""Run modern MCP over the standalone HTTP transport."""
|
||||
config_path = self._write_config(
|
||||
"http-mcp.json", self._callback_config("/callback/http-mcp", ["http-mcp.example.com"], cache=True)
|
||||
)
|
||||
process = self._start(
|
||||
[
|
||||
"mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--config",
|
||||
config_path,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"0",
|
||||
"--http-token",
|
||||
"http-mcp-secret",
|
||||
]
|
||||
)
|
||||
output = process.wait_for_stdout("DDNS MCP HTTP:", timeout=STARTUP_TIMEOUT)
|
||||
endpoint = next(
|
||||
line.split("DDNS MCP HTTP:", 1)[1].strip() for line in output.splitlines() if "DDNS MCP HTTP:" in line
|
||||
)
|
||||
port = urlparse(endpoint).port
|
||||
self.assertIsNotNone(port)
|
||||
|
||||
requests = [
|
||||
self._modern_request(1, "server/discover"),
|
||||
self._modern_request(2, "tools/call", {"name": "update_dns_records", "arguments": {}}),
|
||||
self._modern_request(3, "tools/call", {"name": "get_ddns_status", "arguments": {}}),
|
||||
]
|
||||
responses = []
|
||||
for request in requests:
|
||||
headers = {
|
||||
"Authorization": "Bearer http-mcp-secret",
|
||||
"MCP-Protocol-Version": self.MODERN_VERSION,
|
||||
"Mcp-Method": request["method"],
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
if request["method"] == "tools/call":
|
||||
headers["Mcp-Name"] = request["params"]["name"]
|
||||
status, _, body = self._http_request(port, "/mcp", method="POST", payload=request, headers=headers)
|
||||
self.assertEqual(status, 200, body)
|
||||
responses.append(json.loads(body))
|
||||
|
||||
self.assertEqual(responses[0]["result"]["supportedVersions"], [self.MODERN_VERSION])
|
||||
self.assertEqual(responses[1]["result"]["structuredContent"]["state"], "synced")
|
||||
self.assertEqual(responses[2]["result"]["structuredContent"]["records"][0]["domain"], "http-mcp.example.com")
|
||||
self.assertNotIn("http-mcp-secret", json.dumps(responses))
|
||||
self.assertEqual(len(self.fixture_state.requests_for("/callback/http-mcp")), 1)
|
||||
|
||||
returncode = process.stop()
|
||||
if os.name == "nt":
|
||||
self.assertIsNotNone(returncode)
|
||||
elif E2E_EXECUTABLE:
|
||||
self.assertIn(
|
||||
returncode, (0, 130), "MCP HTTP did not stop after SIGINT.\nstderr:\n{}".format(process.stderr)
|
||||
)
|
||||
else:
|
||||
self.assertEqual(returncode, 0, "MCP HTTP did not stop cleanly.\nstderr:\n{}".format(process.stderr))
|
||||
|
||||
|
||||
class TestWebE2E(OfflineE2ETestCase):
|
||||
"""Exercise the dashboard process and protected JSON API."""
|
||||
@@ -857,7 +919,19 @@ class TestWebE2E(OfflineE2ETestCase):
|
||||
"""Start the real dashboard, synchronize, and manage its scheduler."""
|
||||
config_path = os.path.join(self.temp_dir, "dashboard.json")
|
||||
process = self._start(
|
||||
["web", "--config", config_path, "--host", "127.0.0.1", "--port", "0", "--interval", "30"]
|
||||
[
|
||||
"web",
|
||||
"--config",
|
||||
config_path,
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
"0",
|
||||
"--http-token",
|
||||
"web-http-secret",
|
||||
"--interval",
|
||||
"30",
|
||||
]
|
||||
)
|
||||
output = process.wait_for_stdout("DDNS dashboard:", timeout=STARTUP_TIMEOUT)
|
||||
launch_url = next(
|
||||
@@ -886,6 +960,7 @@ class TestWebE2E(OfflineE2ETestCase):
|
||||
|
||||
location = launch_headers["Location"]
|
||||
access_token = parse_qs(urlparse(location).fragment)["token"][0]
|
||||
self.assertEqual(access_token, "web-http-secret")
|
||||
api_headers = {"X-DDNS-Token": access_token}
|
||||
config_headers = {"X-DDNS-Token": access_token, "Content-Type": "application/json"}
|
||||
callback_token = json.dumps(
|
||||
@@ -946,6 +1021,32 @@ class TestWebE2E(OfflineE2ETestCase):
|
||||
self.assertEqual(dashboard["records"][0]["domain"], "web.example.com")
|
||||
self.assertEqual(dashboard["records"][0]["value"], TEST_IPV4)
|
||||
|
||||
mcp_request = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {
|
||||
"name": "get_ddns_status",
|
||||
"arguments": {},
|
||||
"_meta": {
|
||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||
"io.modelcontextprotocol/clientCapabilities": {},
|
||||
},
|
||||
},
|
||||
}
|
||||
mcp_headers = {
|
||||
"Authorization": "Bearer " + access_token,
|
||||
"MCP-Protocol-Version": "2026-07-28",
|
||||
"Mcp-Method": "tools/call",
|
||||
"Mcp-Name": "get_ddns_status",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
mcp_status, _, mcp_body = self._http_request(
|
||||
port, "/mcp", method="POST", payload=mcp_request, headers=mcp_headers
|
||||
)
|
||||
self.assertEqual(mcp_status, 200, mcp_body)
|
||||
self.assertEqual(json.loads(mcp_body)["result"]["structuredContent"]["state"], "synced")
|
||||
|
||||
callback_requests = self.fixture_state.requests_for("/callback/web")
|
||||
self.assertEqual(len(callback_requests), 1)
|
||||
self.assertEqual(
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import unicode_literals
|
||||
|
||||
import sys
|
||||
|
||||
from __init__ import patch, unittest
|
||||
from __init__ import mock, patch, unittest
|
||||
|
||||
from ddns.config.cli import _handle_mcp_command, load_config
|
||||
|
||||
@@ -34,6 +34,9 @@ class TestMcpSubcommand(unittest.TestCase):
|
||||
self.assertEqual(context.exception.code, 0)
|
||||
self.assertEqual(captured[0]["command"], "mcp")
|
||||
self.assertIsNone(captured[0]["config"])
|
||||
self.assertEqual(captured[0]["transport"], "stdio")
|
||||
self.assertIsNone(captured[0]["host"])
|
||||
self.assertIsNone(captured[0]["port"])
|
||||
|
||||
def test_mcp_subcommand_accepts_local_config(self):
|
||||
"""Pass one explicit local configuration to the MCP handler."""
|
||||
@@ -56,8 +59,8 @@ class TestMcpSubcommand(unittest.TestCase):
|
||||
|
||||
self.assertEqual(context.exception.code, 2)
|
||||
|
||||
def test_mcp_subcommand_rejects_unknown_options(self):
|
||||
"""Fail rather than silently ignoring unsupported MCP options."""
|
||||
def test_mcp_subcommand_rejects_http_options_with_stdio(self):
|
||||
"""Fail rather than silently applying HTTP options to stdio."""
|
||||
sys.argv = ["ddns", "mcp", "--host", "127.0.0.1"]
|
||||
|
||||
with self.assertRaises(SystemExit) as context:
|
||||
@@ -69,7 +72,7 @@ class TestMcpSubcommand(unittest.TestCase):
|
||||
@patch("ddns.mcp.serve")
|
||||
@patch("ddns.config.cli.load_env_config", return_value={})
|
||||
def test_mcp_handler_starts_stdio_server(self, mock_load_env, mock_serve, mock_basic_config):
|
||||
"""Pass the selected path to the stdio server without loading DDNS CLI config."""
|
||||
"""Pass the selected path to the stdio server."""
|
||||
_handle_mcp_command({"config": "config.json"})
|
||||
|
||||
mock_basic_config.assert_called_once_with()
|
||||
@@ -107,6 +110,51 @@ class TestMcpSubcommand(unittest.TestCase):
|
||||
mock_load_env.assert_called_once_with()
|
||||
mock_serve.assert_not_called()
|
||||
|
||||
def test_mcp_subcommand_parses_http_transport_options(self):
|
||||
"""Expose the shared listener controls on the HTTP transport."""
|
||||
sys.argv = [
|
||||
"ddns",
|
||||
"mcp",
|
||||
"--transport",
|
||||
"http",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"8765",
|
||||
"--http-token",
|
||||
"x",
|
||||
"--http-origin",
|
||||
"https://client.example",
|
||||
]
|
||||
captured = [None]
|
||||
|
||||
with patch("ddns.config.cli._handle_mcp_command") as handler:
|
||||
handler.side_effect = lambda args: captured.__setitem__(0, args)
|
||||
with self.assertRaises(SystemExit) as context:
|
||||
load_config("Test DDNS", "Test doc", "1.0.0", "2026-08-12")
|
||||
|
||||
self.assertEqual(context.exception.code, 0)
|
||||
self.assertEqual(captured[0]["transport"], "http")
|
||||
self.assertEqual(captured[0]["host"], "0.0.0.0")
|
||||
self.assertEqual(captured[0]["port"], 8765)
|
||||
self.assertEqual(captured[0]["http_token"], "x")
|
||||
self.assertEqual(captured[0]["http_origins"], ["https://client.example"])
|
||||
|
||||
@patch("ddns.mcp_http.serve")
|
||||
@patch("ddns.config.cli.load_env_config", return_value={"http_host": "0.0.0.0", "http_token": "environment-secret"})
|
||||
@patch("ddns.config.cli.basicConfig")
|
||||
def test_mcp_handler_starts_http_server(self, mock_basic_config, mock_load_env, mock_serve):
|
||||
"""Resolve shared settings before starting standalone HTTP MCP."""
|
||||
_handle_mcp_command({"transport": "http", "port": 0})
|
||||
|
||||
mock_basic_config.assert_called_once_with()
|
||||
mock_load_env.assert_called_once_with()
|
||||
mock_serve.assert_called_once_with(
|
||||
config_path=mock.ANY,
|
||||
settings={"host": "0.0.0.0", "port": 0, "token": "environment-secret", "origins": []},
|
||||
logger=mock.ANY,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -28,12 +28,14 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
sys.argv = self.original_argv
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def _config_file(self, interval=None):
|
||||
def _config_file(self, interval=None, http=None):
|
||||
"""Create one local configuration file for CLI routing tests."""
|
||||
config_path = os.path.join(self.temp_dir, "config.json")
|
||||
document = {"dns": "debug", "ipv4": ["test.example.com"]}
|
||||
if interval is not None:
|
||||
document["interval"] = interval
|
||||
if http is not None:
|
||||
document["http"] = http
|
||||
with io.open(config_path, "w", encoding="utf-8") as config_file:
|
||||
config_file.write(json.dumps(document, ensure_ascii=False))
|
||||
return config_path
|
||||
@@ -49,8 +51,10 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
load_config("Test DDNS", "Test doc", "1.0.0", "2026-01-01")
|
||||
|
||||
self.assertEqual(context.exception.code, 0)
|
||||
self.assertEqual(captured[0]["host"], "127.0.0.1")
|
||||
self.assertEqual(captured[0]["port"], 9876)
|
||||
self.assertIsNone(captured[0]["host"])
|
||||
self.assertIsNone(captured[0]["port"])
|
||||
self.assertIsNone(captured[0]["http_token"])
|
||||
self.assertIsNone(captured[0]["http_origins"])
|
||||
self.assertIsNone(captured[0]["interval"])
|
||||
self.assertFalse(captured[0]["open"])
|
||||
|
||||
@@ -80,8 +84,8 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
self.assertEqual(context.exception.code, 0)
|
||||
self.assertEqual(captured[0]["command"], "web")
|
||||
self.assertEqual(captured[0]["interval"], 12)
|
||||
self.assertEqual(captured[0]["host"], "127.0.0.1")
|
||||
self.assertEqual(captured[0]["port"], 9876)
|
||||
self.assertIsNone(captured[0]["host"])
|
||||
self.assertIsNone(captured[0]["port"])
|
||||
|
||||
def test_interval_shorthand_accepts_local_config(self):
|
||||
"""Keep the common config-first command order for implicit Web mode."""
|
||||
@@ -251,6 +255,10 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
"::1",
|
||||
"--port",
|
||||
"8765",
|
||||
"--http-token",
|
||||
"shared-secret",
|
||||
"--http-origin",
|
||||
"https://client.example",
|
||||
"--interval",
|
||||
"12",
|
||||
"--open",
|
||||
@@ -266,12 +274,14 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
self.assertEqual(captured[0]["config"], "dashboard.json")
|
||||
self.assertEqual(captured[0]["host"], "::1")
|
||||
self.assertEqual(captured[0]["port"], 8765)
|
||||
self.assertEqual(captured[0]["http_token"], "shared-secret")
|
||||
self.assertEqual(captured[0]["http_origins"], ["https://client.example"])
|
||||
self.assertEqual(captured[0]["interval"], 12)
|
||||
self.assertTrue(captured[0]["open"])
|
||||
self.assertTrue(captured[0]["debug"])
|
||||
|
||||
def test_web_subcommand_rejects_public_host(self):
|
||||
"""Reject dashboard bindings outside loopback addresses."""
|
||||
def test_web_subcommand_requires_token_for_public_host(self):
|
||||
"""Reject unauthenticated dashboard bindings outside loopback."""
|
||||
sys.argv = ["ddns", "web", "--host", "0.0.0.0"]
|
||||
|
||||
with self.assertRaises(SystemExit) as context:
|
||||
@@ -326,9 +336,16 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
)
|
||||
|
||||
mock_basic_config.assert_called_once()
|
||||
mock_load_env.assert_not_called()
|
||||
mock_load_env.assert_called_once_with()
|
||||
mock_serve.assert_called_once_with(
|
||||
config_path="dashboard.json", host="127.0.0.1", port=7654, open_browser=True, logger=mock.ANY, interval=9
|
||||
config_path="dashboard.json",
|
||||
host="127.0.0.1",
|
||||
port=7654,
|
||||
token=None,
|
||||
origins=[],
|
||||
open_browser=True,
|
||||
logger=mock.ANY,
|
||||
interval=9,
|
||||
)
|
||||
|
||||
@patch("ddns.web.serve")
|
||||
@@ -341,7 +358,14 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
|
||||
mock_basic_config.assert_called_once()
|
||||
mock_serve.assert_called_once_with(
|
||||
config_path=config_path, host="127.0.0.1", port=7654, open_browser=False, logger=mock.ANY, interval=13
|
||||
config_path=config_path,
|
||||
host="127.0.0.1",
|
||||
port=7654,
|
||||
token=None,
|
||||
origins=[],
|
||||
open_browser=False,
|
||||
logger=mock.ANY,
|
||||
interval=13,
|
||||
)
|
||||
|
||||
@patch("ddns.web.serve")
|
||||
@@ -357,7 +381,14 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
mock_basic_config.assert_called_once()
|
||||
mock_load_env.assert_called_once_with()
|
||||
mock_serve.assert_called_once_with(
|
||||
config_path=config_path, host="127.0.0.1", port=7654, open_browser=False, logger=mock.ANY, interval=17
|
||||
config_path=config_path,
|
||||
host="127.0.0.1",
|
||||
port=7654,
|
||||
token=None,
|
||||
origins=[],
|
||||
open_browser=False,
|
||||
logger=mock.ANY,
|
||||
interval=17,
|
||||
)
|
||||
|
||||
@patch("ddns.web.serve")
|
||||
@@ -370,7 +401,41 @@ class TestWebSubcommand(unittest.TestCase):
|
||||
mock_basic_config.assert_called_once()
|
||||
mock_load_env.assert_called_once_with()
|
||||
mock_serve.assert_called_once_with(
|
||||
config_path="environment.json", host="127.0.0.1", port=7654, open_browser=False, logger=mock.ANY, interval=5
|
||||
config_path="environment.json",
|
||||
host="127.0.0.1",
|
||||
port=7654,
|
||||
token=None,
|
||||
origins=[],
|
||||
open_browser=False,
|
||||
logger=mock.ANY,
|
||||
interval=5,
|
||||
)
|
||||
|
||||
@patch("ddns.web.serve")
|
||||
@patch(
|
||||
"ddns.config.cli.load_env_config",
|
||||
return_value={"http_host": "127.0.0.2", "http_port": "9000", "http_token": "environment"},
|
||||
)
|
||||
@patch("ddns.config.cli.basicConfig")
|
||||
def test_web_handler_applies_http_precedence(self, mock_basic_config, mock_load_env, mock_serve):
|
||||
"""Resolve CLI over JSON over environment HTTP settings."""
|
||||
config_path = self._config_file(
|
||||
http={"host": "0.0.0.0", "port": 9001, "token": "json-secret", "origins": ["https://json.example"]}
|
||||
)
|
||||
|
||||
_handle_web_command({"config": config_path, "port": 0, "http_origins": ["https://cli.example"]})
|
||||
|
||||
mock_basic_config.assert_called_once()
|
||||
mock_load_env.assert_called_once_with()
|
||||
mock_serve.assert_called_once_with(
|
||||
config_path=config_path,
|
||||
host="0.0.0.0",
|
||||
port=0,
|
||||
token="json-secret",
|
||||
origins=["https://cli.example"],
|
||||
open_browser=False,
|
||||
logger=mock.ANY,
|
||||
interval=5,
|
||||
)
|
||||
|
||||
@patch("ddns.web.serve")
|
||||
|
||||
@@ -133,6 +133,17 @@ class TestConfig(unittest.TestCase):
|
||||
self.assertNotIn("interval", config.extra)
|
||||
self.assertEqual(config.extra["custom"], "value")
|
||||
|
||||
def test_http_settings_are_runtime_metadata_not_provider_extra(self):
|
||||
"""Keep structured and environment HTTP settings away from provider APIs."""
|
||||
config = Config(
|
||||
json_config={"dns": "debug", "http": {"host": "127.0.0.1"}},
|
||||
env_config={"http_token": "shared-secret", "http_origins": ["https://client.example"]},
|
||||
)
|
||||
|
||||
self.assertNotIn("http", config.extra)
|
||||
self.assertNotIn("http_token", config.extra)
|
||||
self.assertNotIn("http_origins", config.extra)
|
||||
|
||||
def test_config_array_and_conversion_parameters(self):
|
||||
"""Test array parameter processing and type conversions"""
|
||||
# Array parameters
|
||||
|
||||
@@ -4,7 +4,9 @@ Configuration loader tests for environment variables.
|
||||
"""
|
||||
|
||||
import os
|
||||
import io
|
||||
import unittest
|
||||
import ddns.config.env as env_module
|
||||
from ddns.config.env import load_config
|
||||
|
||||
|
||||
@@ -103,6 +105,36 @@ class TestConfigEnv(unittest.TestCase):
|
||||
self.assertEqual(config.get("cache_max_age"), "86400")
|
||||
del os.environ["DDNS_CACHE_MAX_AGE"]
|
||||
|
||||
def test_http_environment_settings(self):
|
||||
"""Load the shared listener settings without treating origins as a scalar."""
|
||||
os.environ["DDNS_HTTP_HOST"] = "0.0.0.0"
|
||||
os.environ["DDNS_HTTP_PORT"] = "9877"
|
||||
os.environ["DDNS_HTTP_TOKEN"] = "secret"
|
||||
os.environ["DDNS_HTTP_ORIGINS"] = '["https://one.example", "https://two.example"]'
|
||||
|
||||
config = load_config()
|
||||
|
||||
self.assertEqual(config["http_host"], "0.0.0.0")
|
||||
self.assertEqual(config["http_port"], "9877")
|
||||
self.assertEqual(config["http_token"], "secret")
|
||||
self.assertEqual(config["http_origins"], ["https://one.example", "https://two.example"])
|
||||
|
||||
def test_token_values_are_never_array_parsed_or_logged(self):
|
||||
"""Preserve bracket-prefixed secrets without writing them to stderr."""
|
||||
os.environ["DDNS_TOKEN"] = "[provider-secret]"
|
||||
os.environ["DDNS_HTTP_TOKEN"] = "[http-secret]"
|
||||
capture = io.StringIO()
|
||||
original_stderr = env_module.stderr
|
||||
env_module.stderr = capture
|
||||
try:
|
||||
config = load_config()
|
||||
finally:
|
||||
env_module.stderr = original_stderr
|
||||
|
||||
self.assertEqual(config["token"], "[provider-secret]")
|
||||
self.assertEqual(config["http_token"], "[http-secret]")
|
||||
self.assertEqual(capture.getvalue(), "")
|
||||
|
||||
def test_ddns_variables_override_standard_vars(self):
|
||||
"""Test that DDNS variables take precedence over standard environment variables"""
|
||||
self._clear_standard_env()
|
||||
|
||||
@@ -185,7 +185,28 @@ class TestAllConfigFormatsIntegration(unittest.TestCase):
|
||||
self.assertNotIn("default", definition)
|
||||
provider_schema = schema["properties"]["providers"]["items"]
|
||||
self.assertNotIn("interval", provider_schema["properties"])
|
||||
self.assertEqual(provider_schema["not"], {"required": ["interval"]})
|
||||
self.assertEqual(provider_schema["not"], {"anyOf": [{"required": ["interval"]}, {"required": ["http"]}]})
|
||||
|
||||
def test_http_schema_definition(self):
|
||||
"""Define one root-only Web/MCP listener object."""
|
||||
schema_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "schema", "v4.1.json")
|
||||
with open(schema_path, "rb") as schema_file:
|
||||
schema = json.load(schema_file)
|
||||
|
||||
definition = schema["properties"]["http"]
|
||||
self.assertEqual(definition["type"], "object")
|
||||
self.assertFalse(definition["additionalProperties"])
|
||||
self.assertEqual(definition["properties"]["host"]["default"], "127.0.0.1")
|
||||
self.assertIn("pattern", definition["properties"]["host"])
|
||||
self.assertEqual(definition["properties"]["port"]["default"], 9876)
|
||||
self.assertEqual(definition["properties"]["port"]["minimum"], 0)
|
||||
self.assertEqual(definition["properties"]["port"]["maximum"], 65535)
|
||||
self.assertEqual(definition["properties"]["origins"]["default"], [])
|
||||
self.assertEqual(definition["properties"]["token"]["pattern"], "^[!-~]+$")
|
||||
self.assertEqual(
|
||||
definition["properties"]["origins"]["items"]["pattern"],
|
||||
r"^https?://(?:\[[0-9A-Fa-f:.%]+\]|[^\s/@?#:]+)(?::[0-9]+)?/?$",
|
||||
)
|
||||
|
||||
def test_v40_to_v41_compatibility(self):
|
||||
"""Test v4.0 config is compatible with v4.1 processing"""
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the modern MCP Streamable HTTP transport."""
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import base64
|
||||
|
||||
try:
|
||||
from http.client import HTTPConnection
|
||||
except ImportError: # Python 2
|
||||
from httplib import HTTPConnection
|
||||
|
||||
from __init__ import MagicMock, unittest
|
||||
|
||||
from ddns.http_config import HttpConfigError, normalize_http_settings, resolve_http_settings
|
||||
from ddns.mcp import CLIENT_CAPABILITIES_KEY, PROTOCOL_VERSION, PROTOCOL_VERSION_KEY
|
||||
from ddns.mcp_http import McpHttpRequestHandler, create_server
|
||||
|
||||
|
||||
class TestMcpHttpServer(unittest.TestCase):
|
||||
"""Exercise MCP HTTP framing, security, and dispatch."""
|
||||
|
||||
def setUp(self):
|
||||
"""Start one tokenless loopback server with a shared fake service."""
|
||||
self.service = MagicMock()
|
||||
self.service.config_path = "config.json"
|
||||
self.service.dashboard.return_value = {
|
||||
"state": "ready",
|
||||
"message": "ready",
|
||||
"last_sync": None,
|
||||
"addresses": [],
|
||||
"providers": [],
|
||||
"records": [],
|
||||
"token": "provider-secret",
|
||||
}
|
||||
self.service.sync.return_value = self.service.dashboard.return_value
|
||||
self._start_server()
|
||||
|
||||
def _start_server(self, token=None, origins=None):
|
||||
"""Replace the active server with the requested security settings."""
|
||||
if hasattr(self, "server"):
|
||||
self._stop_server()
|
||||
self.server = create_server(
|
||||
settings={"host": "127.0.0.1", "port": 0, "token": token, "origins": origins or []},
|
||||
service=self.service,
|
||||
logger=logging.getLogger("test.mcp.http"),
|
||||
)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
self.addCleanup(self._stop_server)
|
||||
self.port = self.server.server_address[1]
|
||||
|
||||
def _stop_server(self):
|
||||
"""Stop the active test server once."""
|
||||
server = getattr(self, "server", None)
|
||||
if server is None:
|
||||
return
|
||||
self.server = None
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
self.thread.join(2)
|
||||
|
||||
@staticmethod
|
||||
def _message(method, params=None, request_id=1, version=PROTOCOL_VERSION):
|
||||
"""Build one modern MCP request."""
|
||||
params = dict(params or {})
|
||||
params["_meta"] = {
|
||||
PROTOCOL_VERSION_KEY: version,
|
||||
CLIENT_CAPABILITIES_KEY: {},
|
||||
"io.modelcontextprotocol/clientInfo": {"name": "http-test", "version": "1.0"},
|
||||
}
|
||||
message = {"jsonrpc": "2.0", "method": method, "params": params}
|
||||
if request_id is not None:
|
||||
message["id"] = request_id
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def _headers(message, token=None, origin=None, overrides=None):
|
||||
"""Build the required Streamable HTTP headers."""
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version": message["params"]["_meta"][PROTOCOL_VERSION_KEY],
|
||||
"Mcp-Method": message["method"],
|
||||
}
|
||||
if message["method"] == "tools/call":
|
||||
headers["Mcp-Name"] = message["params"]["name"]
|
||||
if token is not None:
|
||||
headers["Authorization"] = "Bearer " + token
|
||||
if origin is not None:
|
||||
headers["Origin"] = origin
|
||||
headers.update(overrides or {})
|
||||
return headers
|
||||
|
||||
def _request(self, message, headers=None, method="POST", path="/mcp", raw_body=None):
|
||||
"""Send one request and return status, headers, and decoded body."""
|
||||
body = (
|
||||
raw_body
|
||||
if raw_body is not None
|
||||
else json.dumps(message, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
)
|
||||
connection = HTTPConnection("127.0.0.1", self.port, timeout=5)
|
||||
try:
|
||||
connection.request(method, path, body=body, headers=headers or {})
|
||||
response = connection.getresponse()
|
||||
content = response.read().decode("utf-8")
|
||||
headers = {name.lower(): value for name, value in response.getheaders()}
|
||||
return response.status, headers, content
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def test_discover_lists_only_modern_http_version(self):
|
||||
"""Advertise the modern protocol without the stdio legacy lifecycle."""
|
||||
message = self._message("server/discover")
|
||||
|
||||
status, _, body = self._request(message, self._headers(message))
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
response = json.loads(body)
|
||||
self.assertEqual(response["result"]["supportedVersions"], [PROTOCOL_VERSION])
|
||||
|
||||
def test_tools_share_service_and_redact_unrelated_fields(self):
|
||||
"""Use one injected service and preserve the MCP status whitelist."""
|
||||
update = self._message("tools/call", {"name": "update_dns_records", "arguments": {}})
|
||||
status_request = self._message("tools/call", {"name": "get_ddns_status", "arguments": {}}, request_id=2)
|
||||
|
||||
update_status, _, update_body = self._request(update, self._headers(update))
|
||||
read_status, _, read_body = self._request(status_request, self._headers(status_request))
|
||||
|
||||
self.assertEqual(update_status, 200)
|
||||
self.assertEqual(read_status, 200)
|
||||
self.assertFalse(json.loads(update_body)["result"]["isError"])
|
||||
self.assertNotIn("provider-secret", read_body)
|
||||
self.service.sync.assert_called_once()
|
||||
self.service.dashboard.assert_called_once()
|
||||
|
||||
def test_notification_returns_accepted_without_body(self):
|
||||
"""A valid notification receives HTTP 202 and no JSON-RPC response."""
|
||||
message = self._message("notifications/cancelled", {"requestId": 1}, request_id=None)
|
||||
|
||||
status, _, body = self._request(message, self._headers(message))
|
||||
|
||||
self.assertEqual(status, 202)
|
||||
self.assertEqual(body, "")
|
||||
|
||||
def test_notification_rejects_legacy_protocol(self):
|
||||
"""Apply modern-only version validation before accepting notifications."""
|
||||
message = self._message("notifications/cancelled", {"requestId": 1}, request_id=None, version="2025-11-25")
|
||||
|
||||
status, _, body = self._request(message, self._headers(message))
|
||||
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], -32022)
|
||||
|
||||
def test_header_mismatches_are_transport_errors(self):
|
||||
"""Reject version, method, and tool-name envelope mismatches."""
|
||||
message = self._message("tools/call", {"name": "get_ddns_status", "arguments": {}})
|
||||
mismatch_headers = (
|
||||
{"MCP-Protocol-Version": "2025-11-25"},
|
||||
{"Mcp-Method": "tools/list"},
|
||||
{"Mcp-Name": "update_dns_records"},
|
||||
)
|
||||
|
||||
for override in mismatch_headers:
|
||||
headers = self._headers(message, overrides=override)
|
||||
status, _, body = self._request(message, headers)
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], -32020)
|
||||
|
||||
def test_rejects_duplicate_routing_headers(self):
|
||||
"""Reject ambiguous routing metadata before dispatch."""
|
||||
message = self._message("tools/list")
|
||||
body = json.dumps(message, separators=(",", ":")).encode("utf-8")
|
||||
request = (
|
||||
"POST /mcp HTTP/1.1\r\n"
|
||||
"Host: 127.0.0.1:{port}\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Accept: application/json, text/event-stream\r\n"
|
||||
"MCP-Protocol-Version: {version}\r\n"
|
||||
"MCP-Protocol-Version: {version}\r\n"
|
||||
"Mcp-Method: tools/list\r\n"
|
||||
"Content-Length: {length}\r\n"
|
||||
"\r\n"
|
||||
).format(port=self.port, version=PROTOCOL_VERSION, length=len(body))
|
||||
connection = socket.create_connection(("127.0.0.1", self.port), timeout=2)
|
||||
connection.sendall(request.encode("ascii") + body)
|
||||
chunks = []
|
||||
while True:
|
||||
chunk = connection.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
connection.close()
|
||||
response = b"".join(chunks)
|
||||
|
||||
self.assertIn(b"400 Bad Request", response)
|
||||
self.assertIn(b"-32020", response)
|
||||
|
||||
def test_transport_errors_never_echo_invalid_request_ids(self):
|
||||
"""Use null response IDs until the JSON-RPC request ID is validated."""
|
||||
message = self._message("tools/list")
|
||||
message["id"] = []
|
||||
mismatch_headers = self._headers(message, overrides={"Mcp-Method": "tools/call"})
|
||||
|
||||
mismatch_status, _, mismatch_body = self._request(message, mismatch_headers)
|
||||
del message["params"]["_meta"][CLIENT_CAPABILITIES_KEY]
|
||||
capability_status, _, capability_body = self._request(message, self._headers(message))
|
||||
|
||||
self.assertEqual(mismatch_status, 400)
|
||||
self.assertIsNone(json.loads(mismatch_body)["id"])
|
||||
self.assertEqual(capability_status, 400)
|
||||
self.assertIsNone(json.loads(capability_body)["id"])
|
||||
|
||||
def test_requires_json_and_both_accepted_media_types(self):
|
||||
"""Enforce current Streamable HTTP media negotiation."""
|
||||
message = self._message("tools/list")
|
||||
wrong_content = self._headers(message, overrides={"Content-Type": "text/plain"})
|
||||
incomplete_accept = self._headers(message, overrides={"Accept": "application/json"})
|
||||
refused_json = self._headers(message, overrides={"Accept": "application/json; q=0, text/event-stream"})
|
||||
invalid_quality = self._headers(message, overrides={"Accept": "application/json; q=invalid, text/event-stream"})
|
||||
|
||||
content_status, _, _ = self._request(message, wrong_content)
|
||||
accept_status, _, _ = self._request(message, incomplete_accept)
|
||||
refused_status, _, _ = self._request(message, refused_json)
|
||||
invalid_status, _, _ = self._request(message, invalid_quality)
|
||||
|
||||
self.assertEqual(content_status, 415)
|
||||
self.assertEqual(accept_status, 406)
|
||||
self.assertEqual(refused_status, 406)
|
||||
self.assertEqual(invalid_status, 406)
|
||||
|
||||
def test_rejects_json_rpc_batches(self):
|
||||
"""Do not accept JSON-RPC batch arrays."""
|
||||
message = self._message("tools/list")
|
||||
raw_body = json.dumps([message]).encode("utf-8")
|
||||
|
||||
status, _, body = self._request(message, self._headers(message), raw_body=raw_body)
|
||||
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], -32600)
|
||||
|
||||
def test_invalid_json_rpc_shape_is_bad_request_before_header_validation(self):
|
||||
"""Classify malformed JSON-RPC requests before mirrored-header checks."""
|
||||
missing_method = self._message("tools/list")
|
||||
del missing_method["method"]
|
||||
missing_jsonrpc = self._message("tools/list")
|
||||
del missing_jsonrpc["jsonrpc"]
|
||||
|
||||
method_status, _, method_body = self._request(missing_method, self._headers(self._message("tools/list")))
|
||||
jsonrpc_status, _, jsonrpc_body = self._request(missing_jsonrpc, self._headers(missing_jsonrpc))
|
||||
|
||||
self.assertEqual(method_status, 400)
|
||||
self.assertEqual(json.loads(method_body)["error"]["code"], -32600)
|
||||
self.assertEqual(jsonrpc_status, 400)
|
||||
self.assertEqual(json.loads(jsonrpc_body)["error"]["code"], -32600)
|
||||
|
||||
def test_rejects_legacy_protocol_on_http(self):
|
||||
"""Keep the legacy initialize era limited to stdio."""
|
||||
message = self._message("tools/list", version="2025-11-25")
|
||||
|
||||
status, _, body = self._request(message, self._headers(message))
|
||||
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], -32022)
|
||||
|
||||
def test_unknown_method_uses_http_not_found_with_json_rpc_error(self):
|
||||
"""Distinguish a modern unsupported method from a missing endpoint."""
|
||||
message = self._message("resources/list")
|
||||
|
||||
status, _, body = self._request(message, self._headers(message))
|
||||
|
||||
self.assertEqual(status, 404)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], -32601)
|
||||
|
||||
def test_named_unsupported_methods_validate_mcp_name_first(self):
|
||||
"""Validate standard named envelopes before reporting unsupported methods."""
|
||||
cases = (
|
||||
("resources/read", {"uri": "file:///status"}, "file:///status"),
|
||||
("prompts/get", {"name": "status"}, "status"),
|
||||
)
|
||||
for method, params, name in cases:
|
||||
message = self._message(method, params)
|
||||
headers = self._headers(message)
|
||||
|
||||
missing_status, _, missing_body = self._request(message, headers)
|
||||
headers["Mcp-Name"] = name
|
||||
valid_status, _, valid_body = self._request(message, headers)
|
||||
|
||||
self.assertEqual(missing_status, 400)
|
||||
self.assertEqual(json.loads(missing_body)["error"]["code"], -32020)
|
||||
self.assertEqual(valid_status, 404)
|
||||
self.assertEqual(json.loads(valid_body)["error"]["code"], -32601)
|
||||
|
||||
def test_decodes_base64_mcp_name_before_validation(self):
|
||||
"""Accept the protocol sentinel encoding for mirrored non-plain values."""
|
||||
message = self._message("tools/call", {"name": "get_ddns_status", "arguments": {}})
|
||||
encoded = base64.b64encode(b"get_ddns_status").decode("ascii")
|
||||
headers = self._headers(message, overrides={"Mcp-Name": "=?base64?{}?=".format(encoded)})
|
||||
|
||||
status, _, body = self._request(message, headers)
|
||||
|
||||
self.assertEqual(status, 200, body)
|
||||
|
||||
def test_accepts_http_optional_whitespace_around_mcp_name(self):
|
||||
"""Ignore header OWS before comparing the mirrored tool name."""
|
||||
message = self._message("tools/call", {"name": "get_ddns_status", "arguments": {}})
|
||||
headers = self._headers(message, overrides={"Mcp-Name": "get_ddns_status "})
|
||||
|
||||
status, _, body = self._request(message, headers)
|
||||
|
||||
self.assertEqual(status, 200, body)
|
||||
|
||||
def test_missing_client_capabilities_is_http_bad_request(self):
|
||||
"""Treat missing required per-request metadata as a transport failure."""
|
||||
message = self._message("tools/list")
|
||||
del message["params"]["_meta"][CLIENT_CAPABILITIES_KEY]
|
||||
headers = self._headers(message)
|
||||
|
||||
status, _, body = self._request(message, headers)
|
||||
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], -32602)
|
||||
|
||||
def test_configured_token_is_required_as_bearer(self):
|
||||
"""Use one configured token without accepting the dashboard header."""
|
||||
self._start_server(token="shared-secret")
|
||||
message = self._message("tools/list")
|
||||
|
||||
missing_status, _, _ = self._request(message, self._headers(message))
|
||||
dashboard_status, _, _ = self._request(
|
||||
message, self._headers(message, overrides={"X-DDNS-Token": "shared-secret"})
|
||||
)
|
||||
valid_status, _, _ = self._request(message, self._headers(message, token="shared-secret"))
|
||||
|
||||
self.assertEqual(missing_status, 401)
|
||||
self.assertEqual(dashboard_status, 401)
|
||||
self.assertEqual(valid_status, 200)
|
||||
|
||||
def test_auth_rejection_does_not_wait_for_an_unsent_body(self):
|
||||
"""Bound early body draining so unauthenticated clients cannot pin a worker."""
|
||||
self._start_server(token="shared-secret")
|
||||
connection = socket.create_connection(("127.0.0.1", self.port), timeout=2)
|
||||
request = (
|
||||
"POST /mcp HTTP/1.1\r\n"
|
||||
"Host: 127.0.0.1:{port}\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
"Accept: application/json, text/event-stream\r\n"
|
||||
"Content-Length: 1\r\n"
|
||||
"\r\n"
|
||||
).format(port=self.port)
|
||||
connection.sendall(request.encode("ascii"))
|
||||
|
||||
response = connection.recv(4096)
|
||||
connection.close()
|
||||
|
||||
self.assertIn(b"401 Unauthorized", response)
|
||||
|
||||
def test_incomplete_headers_are_closed_after_read_timeout(self):
|
||||
"""Bound worker lifetime before request parsing and authentication."""
|
||||
self.server.connection_timeout = 0.1
|
||||
connection = socket.create_connection(("127.0.0.1", self.port), timeout=2)
|
||||
connection.sendall(b"POST /mcp HTTP/1.1\r\nHost: 127.0.0.1")
|
||||
|
||||
response = connection.recv(4096)
|
||||
connection.close()
|
||||
|
||||
self.assertEqual(response, b"")
|
||||
|
||||
def test_origin_policy_and_preflight(self):
|
||||
"""Allow direct/configured origins and reject untrusted browser origins."""
|
||||
allowed = "https://client.example"
|
||||
self._start_server(origins=[allowed])
|
||||
message = self._message("tools/list")
|
||||
direct = "http://127.0.0.1:{}".format(self.port)
|
||||
|
||||
direct_status, _, _ = self._request(message, self._headers(message, origin=direct))
|
||||
allowed_status, allowed_headers, _ = self._request(message, self._headers(message, origin=allowed))
|
||||
denied_status, _, _ = self._request(message, self._headers(message, origin="https://evil.example"))
|
||||
options_status, options_headers, _ = self._request(
|
||||
message, {"Origin": allowed, "Access-Control-Request-Method": "POST"}, method="OPTIONS", raw_body=b""
|
||||
)
|
||||
|
||||
self.assertEqual(direct_status, 200)
|
||||
self.assertEqual(allowed_status, 200)
|
||||
self.assertEqual(allowed_headers["access-control-allow-origin"], allowed)
|
||||
self.assertEqual(denied_status, 403)
|
||||
self.assertEqual(options_status, 204)
|
||||
self.assertIn("MCP-Protocol-Version", options_headers["access-control-allow-headers"])
|
||||
|
||||
def test_allowed_origin_can_read_transport_errors(self):
|
||||
"""Attach CORS headers after Origin validation even when the request fails."""
|
||||
allowed = "https://client.example"
|
||||
self._start_server(token="shared-secret", origins=[allowed])
|
||||
message = self._message("tools/list")
|
||||
headers = self._headers(
|
||||
message, token="shared-secret", origin=allowed, overrides={"Content-Type": "text/plain"}
|
||||
)
|
||||
|
||||
status, response_headers, _ = self._request(message, headers)
|
||||
|
||||
self.assertEqual(status, 415)
|
||||
self.assertEqual(response_headers["access-control-allow-origin"], allowed)
|
||||
|
||||
def test_tokenless_listener_rejects_malformed_loopback_host(self):
|
||||
"""Do not let userinfo syntax bypass the unauthenticated Host check."""
|
||||
message = self._message("tools/list")
|
||||
headers = self._headers(message, overrides={"Host": "attacker@127.0.0.1"})
|
||||
|
||||
status, _, body = self._request(message, headers)
|
||||
|
||||
self.assertEqual(status, 421)
|
||||
self.assertEqual(json.loads(body)["error"]["code"], "invalid_host")
|
||||
|
||||
def test_wrong_methods_and_paths_are_bounded(self):
|
||||
"""Return explicit HTTP errors outside the POST endpoint contract."""
|
||||
message = self._message("tools/list")
|
||||
|
||||
method_status, _, method_body = self._request(message, self._headers(message), method="GET", raw_body=b"")
|
||||
path_status, _, _ = self._request(message, self._headers(message), path="/missing")
|
||||
|
||||
self.assertEqual(method_status, 405)
|
||||
self.assertIn("method_not_allowed", method_body)
|
||||
self.assertEqual(path_status, 404)
|
||||
|
||||
def test_non_post_requests_enforce_origin_policy(self):
|
||||
"""Validate Origin before returning the MCP method error."""
|
||||
allowed = "https://client.example"
|
||||
self._start_server(origins=[allowed])
|
||||
message = self._message("tools/list")
|
||||
|
||||
denied_status, _, _ = self._request(message, {"Origin": "https://evil.example"}, method="GET", raw_body=b"")
|
||||
allowed_status, allowed_headers, _ = self._request(message, {"Origin": allowed}, method="GET", raw_body=b"")
|
||||
|
||||
self.assertEqual(denied_status, 403)
|
||||
self.assertEqual(allowed_status, 405)
|
||||
self.assertEqual(allowed_headers["access-control-allow-origin"], allowed)
|
||||
|
||||
def test_access_log_drops_query_values(self):
|
||||
"""Keep accidental query-string credentials out of HTTP logs."""
|
||||
handler = MagicMock()
|
||||
handler.path = "/mcp?token=do-not-log"
|
||||
handler.address_string.return_value = "127.0.0.1"
|
||||
|
||||
log_message = getattr(McpHttpRequestHandler.log_message, "im_func", McpHttpRequestHandler.log_message)
|
||||
log_message(handler, '"%s" %s', handler.path, 200)
|
||||
|
||||
logged = handler.server.logger.info.call_args[0][-1]
|
||||
self.assertNotIn("do-not-log", logged)
|
||||
|
||||
def test_access_log_handles_request_parse_failures(self):
|
||||
"""Allow the base handler to log errors before assigning a request path."""
|
||||
|
||||
class BareHandler(object):
|
||||
server = MagicMock()
|
||||
|
||||
@staticmethod
|
||||
def address_string():
|
||||
"""Return a stable test peer."""
|
||||
return "127.0.0.1"
|
||||
|
||||
handler = BareHandler()
|
||||
log_message = getattr(McpHttpRequestHandler.log_message, "im_func", McpHttpRequestHandler.log_message)
|
||||
log_message(handler, "%s", "bad request")
|
||||
|
||||
handler.server.logger.info.assert_called_once()
|
||||
|
||||
def test_malformed_request_line_returns_http_error(self):
|
||||
"""Keep pre-routing parser failures inside BaseHTTPRequestHandler."""
|
||||
connection = socket.create_connection(("127.0.0.1", self.port), timeout=2)
|
||||
connection.sendall(b"BROKEN REQUEST\r\n\r\n")
|
||||
|
||||
response = connection.recv(4096)
|
||||
connection.close()
|
||||
|
||||
self.assertIn(b"400", response)
|
||||
|
||||
|
||||
class TestHttpSettings(unittest.TestCase):
|
||||
"""Test listener authentication boundaries independently of sockets."""
|
||||
|
||||
def test_loopback_may_omit_token(self):
|
||||
"""Honor the approved unauthenticated-loopback behavior."""
|
||||
settings = normalize_http_settings({"host": "127.0.0.2", "port": 9876})
|
||||
|
||||
self.assertIsNone(settings["token"])
|
||||
|
||||
def test_non_loopback_requires_non_empty_token(self):
|
||||
"""Reject LAN and wildcard listeners without a configured token."""
|
||||
for host in ("192.0.2.10", "0.0.0.0", "::"):
|
||||
with self.assertRaises(HttpConfigError):
|
||||
normalize_http_settings({"host": host, "port": 9876})
|
||||
|
||||
def test_wildcard_accepts_any_non_empty_token(self):
|
||||
"""Do not impose a token-strength policy beyond non-empty."""
|
||||
settings = normalize_http_settings({"host": "0.0.0.0", "token": "x"})
|
||||
|
||||
self.assertEqual(settings["token"], "x")
|
||||
|
||||
def test_listener_values_reject_ambiguous_input(self):
|
||||
"""Reject fractional ports, malformed hosts, and non-origin URLs."""
|
||||
invalid_settings = (
|
||||
{"port": 1.5},
|
||||
{"host": "127.0.0.1/path"},
|
||||
{"host": "[::1"},
|
||||
{"host": "[[::1]]"},
|
||||
{"origins": None},
|
||||
{"origins": ["https://client.example/path"]},
|
||||
{"token": ""},
|
||||
{"token": " "},
|
||||
{"token": "two words"},
|
||||
{"token": "not ascii 密钥"},
|
||||
)
|
||||
for settings in invalid_settings:
|
||||
with self.assertRaises(HttpConfigError):
|
||||
normalize_http_settings(settings)
|
||||
|
||||
self.assertEqual(normalize_http_settings({"host": "[::1]"})["host"], "::1")
|
||||
|
||||
def test_explicit_null_http_document_is_invalid(self):
|
||||
"""Do not silently replace an explicit null object with environment settings."""
|
||||
with self.assertRaises(HttpConfigError):
|
||||
resolve_http_settings(document={"http": None}, env_config={"http_port": "9000"})
|
||||
|
||||
def test_partial_json_settings_inherit_environment_token(self):
|
||||
"""Apply final bind authentication after JSON/environment precedence."""
|
||||
settings = resolve_http_settings(
|
||||
document={"http": {"host": "0.0.0.0"}}, env_config={"http_token": "environment-secret"}
|
||||
)
|
||||
|
||||
self.assertEqual(settings["token"], "environment-secret")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+189
-5
@@ -23,6 +23,7 @@ except ImportError: # Python 2
|
||||
from urllib2 import HTTPError, Request, urlopen
|
||||
|
||||
from ddns.provider import get_provider_class
|
||||
from ddns.mcp import CLIENT_CAPABILITIES_KEY, PROTOCOL_VERSION, PROTOCOL_VERSION_KEY
|
||||
from ddns.web.server import DashboardRequestHandler, _resource_bytes, _write_stdout, create_server, serve
|
||||
from ddns.web.scheduler import WebScheduler
|
||||
from ddns.web.service import (
|
||||
@@ -237,6 +238,47 @@ class TestDashboardService(unittest.TestCase):
|
||||
with self.assertRaises(ConfigValidationError):
|
||||
validate_document(config)
|
||||
|
||||
def test_http_settings_are_validated_and_kept_global(self):
|
||||
"""Canonicalize the root listener without leaking it into providers."""
|
||||
config = _valid_config()
|
||||
config["http"] = {
|
||||
"host": "0.0.0.0",
|
||||
"port": "9877",
|
||||
"token": "x",
|
||||
"origins": ["https://CLIENT.example/", "https://client.example"],
|
||||
}
|
||||
|
||||
validated = validate_document(config)
|
||||
|
||||
self.assertEqual(
|
||||
validated["http"], {"host": "0.0.0.0", "port": 9877, "token": "x", "origins": ["https://client.example"]}
|
||||
)
|
||||
self.assertNotIn("http", validated["providers"][0])
|
||||
|
||||
def test_partial_http_settings_preserve_environment_fallbacks(self):
|
||||
"""Do not materialize omitted fields that should inherit at startup."""
|
||||
config = _valid_config()
|
||||
config["http"] = {"port": "9877"}
|
||||
|
||||
validated = validate_document(config)
|
||||
|
||||
self.assertEqual(validated["http"], {"port": 9877})
|
||||
|
||||
def test_http_settings_reject_provider_scope_and_null_object(self):
|
||||
"""Keep listener settings root-only and structurally valid."""
|
||||
provider_config = _valid_config()
|
||||
provider_config["providers"][0]["http"] = {"host": "127.0.0.1"}
|
||||
lan_config = _valid_config()
|
||||
lan_config["http"] = {"host": "0.0.0.0"}
|
||||
null_config = _valid_config()
|
||||
null_config["http"] = None
|
||||
|
||||
with self.assertRaises(ConfigValidationError):
|
||||
validate_document(provider_config)
|
||||
self.assertEqual(validate_document(lan_config)["http"], {"host": "0.0.0.0"})
|
||||
with self.assertRaises(ConfigValidationError):
|
||||
validate_document(null_config)
|
||||
|
||||
def test_provider_interval_is_rejected(self):
|
||||
"""Require scheduling metadata at the document root."""
|
||||
config = _valid_config()
|
||||
@@ -910,6 +952,18 @@ class TestWebScheduler(unittest.TestCase):
|
||||
class TestDashboardStartup(unittest.TestCase):
|
||||
"""Test safe dashboard process startup."""
|
||||
|
||||
def test_create_server_preserves_positional_logger_api(self):
|
||||
"""Keep the pre-HTTP public create_server positional signature."""
|
||||
service = MagicMock()
|
||||
service.config_path = "config.json"
|
||||
logger = MagicMock()
|
||||
|
||||
server = create_server(service, "127.0.0.1", 0, logger)
|
||||
self.addCleanup(server.server_close)
|
||||
|
||||
self.assertIsNone(server.access_token)
|
||||
logger.getChild.assert_called()
|
||||
|
||||
@patch("ddns.web.server.sys.stdout")
|
||||
def test_status_output_is_flushed(self, mock_stdout):
|
||||
"""Expose the launch URL immediately for long-running processes."""
|
||||
@@ -918,6 +972,37 @@ class TestDashboardStartup(unittest.TestCase):
|
||||
mock_stdout.write.assert_called_once_with("ready\n")
|
||||
mock_stdout.flush.assert_called_once_with()
|
||||
|
||||
def test_access_log_redacts_launch_token_and_query(self):
|
||||
"""Do not persist bootstrap or query credentials in access logs."""
|
||||
handler = MagicMock()
|
||||
handler.path = "/launch/single-use-secret?token=query-secret"
|
||||
handler.address_string.return_value = "127.0.0.1"
|
||||
|
||||
log_message = getattr(DashboardRequestHandler.log_message, "im_func", DashboardRequestHandler.log_message)
|
||||
log_message(handler, '"%s" %s', handler.path, 302)
|
||||
|
||||
logged = handler.server.logger.info.call_args[0][-1]
|
||||
self.assertNotIn("single-use-secret", logged)
|
||||
self.assertNotIn("query-secret", logged)
|
||||
self.assertIn("/launch/[redacted]", logged)
|
||||
|
||||
def test_access_log_handles_request_parse_failures(self):
|
||||
"""Allow the base handler to log errors before assigning a request path."""
|
||||
|
||||
class BareHandler(object):
|
||||
server = MagicMock()
|
||||
|
||||
@staticmethod
|
||||
def address_string():
|
||||
"""Return a stable test peer."""
|
||||
return "127.0.0.1"
|
||||
|
||||
handler = BareHandler()
|
||||
log_message = getattr(DashboardRequestHandler.log_message, "im_func", DashboardRequestHandler.log_message)
|
||||
log_message(handler, "%s", "bad request")
|
||||
|
||||
handler.server.logger.info.assert_called_once()
|
||||
|
||||
@patch("ddns.web.server.webbrowser.open")
|
||||
@patch("ddns.web.server._write_stdout")
|
||||
@patch("ddns.web.server.create_server")
|
||||
@@ -946,6 +1031,52 @@ class TestDashboardStartup(unittest.TestCase):
|
||||
service.stop_scheduler.assert_called_once_with()
|
||||
server.server_close.assert_called_once_with()
|
||||
|
||||
@patch("ddns.web.server.webbrowser.open")
|
||||
@patch("ddns.web.server._write_stdout")
|
||||
@patch("ddns.web.server.create_server")
|
||||
@patch("ddns.web.server.DashboardService")
|
||||
def test_tokenless_loopback_prints_direct_dashboard_url(
|
||||
self, mock_service_class, mock_create_server, mock_stdout, mock_open
|
||||
):
|
||||
"""Skip the launch-token exchange when loopback authentication is disabled."""
|
||||
service = MagicMock()
|
||||
service.config_path = "dashboard.json"
|
||||
mock_service_class.return_value = service
|
||||
server = MagicMock()
|
||||
server.server_address = ("127.0.0.1", 4321)
|
||||
server.access_token = None
|
||||
server.serve_forever.side_effect = KeyboardInterrupt
|
||||
mock_create_server.return_value = server
|
||||
|
||||
serve(config_path="dashboard.json", open_browser=True)
|
||||
|
||||
output = "".join(call[0][0] for call in mock_stdout.call_args_list)
|
||||
direct_url = "http://127.0.0.1:4321/#view=overview"
|
||||
self.assertIn(direct_url, output)
|
||||
server.issue_launch_token.assert_not_called()
|
||||
mock_open.assert_called_once_with(direct_url)
|
||||
|
||||
@patch("ddns.web.server.create_server")
|
||||
@patch("ddns.web.server.DashboardService")
|
||||
def test_serve_preserves_original_positional_arguments(self, mock_service_class, mock_create_server):
|
||||
"""Append HTTP options without reinterpreting existing positional callers."""
|
||||
logger = MagicMock()
|
||||
service = MagicMock()
|
||||
service.config_path = "dashboard.json"
|
||||
mock_service_class.return_value = service
|
||||
server = MagicMock()
|
||||
server.server_address = ("127.0.0.1", 4321)
|
||||
server.access_token = None
|
||||
server.serve_forever.side_effect = KeyboardInterrupt
|
||||
mock_create_server.return_value = server
|
||||
|
||||
serve("dashboard.json", "127.0.0.1", 0, False, logger, 9)
|
||||
|
||||
mock_service_class.assert_called_once_with(config_path="dashboard.json", logger=logger, scheduler_interval=9)
|
||||
mock_create_server.assert_called_once_with(
|
||||
service=service, host="127.0.0.1", port=0, token=None, origins=None, logger=logger
|
||||
)
|
||||
|
||||
|
||||
class TestDashboardServer(unittest.TestCase):
|
||||
"""Test local HTTP routing and write protections."""
|
||||
@@ -965,8 +1096,11 @@ class TestDashboardServer(unittest.TestCase):
|
||||
self.mock_load_env_config = self.env_patcher.start()
|
||||
self.addCleanup(self.env_patcher.stop)
|
||||
self.service = DashboardService(config_path=config_path)
|
||||
self.server = create_server(service=self.service, host="127.0.0.1", port=0, logger=MagicMock())
|
||||
self.server = create_server(
|
||||
service=self.service, host="127.0.0.1", port=0, token="test-dashboard-token", logger=MagicMock()
|
||||
)
|
||||
self.assertFalse(self.server.allow_reuse_address)
|
||||
self.assertEqual(self.server.connection_timeout, 15)
|
||||
self.thread = threading.Thread(target=self.server.serve_forever)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
@@ -1062,12 +1196,12 @@ class TestDashboardServer(unittest.TestCase):
|
||||
self.assertEqual(status, 403)
|
||||
self.assertEqual(json.loads(content)["error"]["code"], "invalid_token")
|
||||
|
||||
def test_rejects_nonlocal_host_header(self):
|
||||
"""Reject requests carrying a non-loopback Host header."""
|
||||
def test_authenticated_server_accepts_proxy_host_header(self):
|
||||
"""Allow authenticated LAN/reverse-proxy host names."""
|
||||
status, _, content = self._request("/", headers={"Host": "dashboard.example.com"})
|
||||
|
||||
self.assertEqual(status, 421)
|
||||
self.assertEqual(json.loads(content)["error"]["code"], "invalid_host")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("配置管理", content)
|
||||
|
||||
def test_config_api_saves_and_reads_normalized_document(self):
|
||||
"""Persist configuration through the protected JSON API."""
|
||||
@@ -1084,6 +1218,56 @@ class TestDashboardServer(unittest.TestCase):
|
||||
self.assertEqual(get_payload["config"]["providers"][0]["index4"], ["public"])
|
||||
self.assertEqual(get_payload["model"]["defaults"]["provider"], "debug")
|
||||
|
||||
def test_dashboard_api_accepts_shared_bearer_token(self):
|
||||
"""Use the configured HTTP token through either supported Web header."""
|
||||
status, _, content = self._request("/api/config", headers={"Authorization": "Bearer " + self.token})
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("config", json.loads(content))
|
||||
|
||||
def test_integrated_mcp_uses_shared_listener_and_token(self):
|
||||
"""Serve modern MCP from the protected dashboard listener."""
|
||||
message = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/list",
|
||||
"params": {"_meta": {PROTOCOL_VERSION_KEY: PROTOCOL_VERSION, CLIENT_CAPABILITIES_KEY: {}}},
|
||||
}
|
||||
headers = {
|
||||
"Authorization": "Bearer " + self.token,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version": PROTOCOL_VERSION,
|
||||
"Mcp-Method": "tools/list",
|
||||
}
|
||||
|
||||
status, _, content = self._request("/mcp", method="POST", payload=message, headers=headers)
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(
|
||||
[tool["name"] for tool in json.loads(content)["result"]["tools"]], ["get_ddns_status", "update_dns_records"]
|
||||
)
|
||||
|
||||
def test_tokenless_loopback_allows_api_but_rejects_rebound_host(self):
|
||||
"""Honor the approved no-auth loopback mode without dropping Host checks."""
|
||||
self._stop_server()
|
||||
self.server = create_server(service=self.service, host="127.0.0.1", port=0, logger=MagicMock())
|
||||
self.thread = threading.Thread(target=self.server.serve_forever)
|
||||
self.thread.daemon = True
|
||||
self.thread.start()
|
||||
self.base_url = "http://127.0.0.1:{}".format(self.server.server_address[1])
|
||||
self.token = None
|
||||
|
||||
status, _, content = self._request("/api/config")
|
||||
rebound_status, _, rebound_content = self._request("/api/config", headers={"Host": "dashboard.example.com"})
|
||||
malformed_status, _, _ = self._request("/api/config", headers={"Host": "attacker@127.0.0.1"})
|
||||
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn("config", json.loads(content))
|
||||
self.assertEqual(rebound_status, 421)
|
||||
self.assertEqual(json.loads(rebound_content)["error"]["code"], "invalid_host")
|
||||
self.assertEqual(malformed_status, 421)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user