mirror of
https://github.com/NewFuture/DDNS.git
synced 2026-10-20 00:16:45 +00:00
* feat(mcp): add Streamable HTTP transport Add a modern MCP 2026-07-28 HTTP endpoint for standalone and Web modes, with shared listener configuration, authentication, schema, tests, and bilingual documentation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): support Python 2 HTTP assertions Normalize response header names and call handler methods through their underlying functions so the HTTP tests behave consistently on Python 2.7 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): harden HTTP request handling Validate mirrored request IDs, align Config Studio listener checks, bound socket reads, and accept the binary wrapper's documented SIGINT exit status. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): align HTTP negotiation validation Reject unavailable Accept media types by quality and keep schema host validation consistent with runtime listener checks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): validate host schema behavior Assert accepted and rejected host samples instead of comparing Unicode escape representations that differ on Python 2.7. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(tests): avoid cross-engine schema regex Keep the schema pattern contract without executing its ECMA-262 Unicode escape through Python 2's incompatible regex engine. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate HTTP request boundaries Reject empty tokens and malformed JSON-RPC request shapes, and keep Config Studio bracket validation aligned with runtime listener handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): tighten HTTP input validation Reject legacy notifications, nested host brackets, and document the required JSON content type so validation remains consistent across runtime, schema, and Config Studio. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate Origin before method errors Apply the existing Origin policy to non-POST MCP requests and return CORS headers for configured allowed origins. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): clarify proxy Origin allowlist Explain that browser clients behind TLS termination must allowlist the public HTTPS origin even when the public page appears same-origin. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs(mcp): add standalone server guide Move MCP client setup, transports, tools, security, and protocol limits into dedicated Chinese and English pages with matching navigation and index links. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): reject duplicate routing headers Reject repeated MCP protocol, method, and name headers before body consistency checks across Python 2 and Python 3. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(mcp): validate named request headers Validate Mcp-Name for tools/call, prompts/get, and resources/read before dispatching unsupported methods. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: NewFuture <6290356+NewFuture@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
473 lines
19 KiB
Python
473 lines
19 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""Local-only HTTP server for the embedded DDNS dashboard."""
|
|
|
|
from __future__ import unicode_literals
|
|
|
|
import binascii
|
|
import json
|
|
import logging
|
|
import os
|
|
import pkgutil
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
|
|
try:
|
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
from socketserver import ThreadingMixIn
|
|
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]
|
|
except NameError:
|
|
text_type = str
|
|
|
|
|
|
STATIC_ASSETS = {
|
|
"/assets/dashboard.css": ("dashboard.css", "text/css; charset=utf-8"),
|
|
"/assets/dashboard.js": ("dashboard.js", "application/javascript; charset=utf-8"),
|
|
"/assets/ddns.svg": ("ddns.svg", "image/svg+xml"),
|
|
}
|
|
INDEX_PATHS = ("/", "/index.html", "/dashboard", "/dashboard/")
|
|
MAX_BODY_SIZE = 2 * 1024 * 1024
|
|
LAUNCH_PATH_PREFIX = "/launch/"
|
|
LAUNCH_TOKEN_TTL = 60
|
|
SOURCE_ASSET_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, os.pardir, "web"))
|
|
PACKAGED_ASSET_ROOT = os.path.join(os.path.dirname(__file__), "static")
|
|
|
|
|
|
def _write_stdout(message):
|
|
# type: (str) -> None
|
|
"""Write Unicode status text on Python 2 and Python 3."""
|
|
try:
|
|
sys.stdout.write(message)
|
|
except UnicodeEncodeError:
|
|
encoding = getattr(sys.stdout, "encoding", None) or sys.getfilesystemencoding() or "utf-8"
|
|
encoded = message.encode(encoding, "replace")
|
|
sys.stdout.write(encoded if sys.version_info[0] < 3 else encoded.decode(encoding))
|
|
sys.stdout.flush()
|
|
|
|
|
|
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
|
|
"""HTTP server that keeps slow dashboard operations isolated."""
|
|
|
|
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)
|
|
|
|
def issue_launch_token(self):
|
|
# type: () -> str
|
|
"""Create a short-lived, single-use browser bootstrap token."""
|
|
token = binascii.hexlify(os.urandom(24)).decode("ascii")
|
|
with self.launch_token_lock:
|
|
self.launch_token = token
|
|
self.launch_token_expires = time.time() + LAUNCH_TOKEN_TTL
|
|
return token
|
|
|
|
def consume_launch_token(self, token):
|
|
# type: (str) -> bool
|
|
"""Consume the current browser bootstrap token exactly once."""
|
|
with self.launch_token_lock:
|
|
valid = token == self.launch_token and time.time() <= self.launch_token_expires
|
|
if valid:
|
|
self.launch_token = None
|
|
self.launch_token_expires = 0
|
|
return valid
|
|
|
|
|
|
class ThreadingHTTPServerV6(ThreadingHTTPServer):
|
|
"""IPv6 loopback variant of the dashboard server."""
|
|
|
|
address_family = socket.AF_INET6
|
|
|
|
|
|
def _resource_file_bytes(root, asset_name):
|
|
# type: (str, str) -> bytes | None
|
|
segments = asset_name.split("/")
|
|
if not asset_name or any(segment in ("", ".", "..") for segment in segments):
|
|
raise IOError("Invalid dashboard asset path: {}".format(asset_name))
|
|
asset_path = os.path.join(root, *segments)
|
|
if not os.path.isfile(asset_path):
|
|
return None
|
|
with open(asset_path, "rb") as asset_file:
|
|
return asset_file.read()
|
|
|
|
|
|
def _resource_bytes(asset_name):
|
|
# type: (str) -> bytes
|
|
for asset_root in (PACKAGED_ASSET_ROOT, SOURCE_ASSET_ROOT):
|
|
content = _resource_file_bytes(asset_root, asset_name)
|
|
if content is not None:
|
|
return content
|
|
resource_path = "static/{}".format(asset_name)
|
|
content = pkgutil.get_data("ddns.web", resource_path)
|
|
if content is None:
|
|
raise IOError("Embedded dashboard resource not found: {}".format(resource_path))
|
|
return content
|
|
|
|
|
|
def _json_bytes(payload):
|
|
# type: (dict | list) -> bytes
|
|
content = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
|
if not isinstance(content, bytes):
|
|
content = content.encode("utf-8")
|
|
return content
|
|
|
|
|
|
def _local_hostname(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)
|
|
)
|
|
|
|
|
|
class DashboardRequestHandler(BaseHTTPRequestHandler):
|
|
"""Serve the dashboard and its same-origin JSON API."""
|
|
|
|
protocol_version = "HTTP/1.1"
|
|
server_version = "DDNS"
|
|
sys_version = ""
|
|
|
|
@property
|
|
def service(self):
|
|
# type: () -> DashboardService
|
|
return self.server.dashboard_service # type: ignore[attr-defined]
|
|
|
|
@property
|
|
def access_token(self):
|
|
# type: () -> str
|
|
return self.server.access_token # 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:
|
|
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
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header("X-Frame-Options", "DENY")
|
|
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
|
|
self.send_header("Cross-Origin-Resource-Policy", "same-origin")
|
|
self.send_header("Permissions-Policy", "camera=(), geolocation=(), microphone=()")
|
|
self.send_header(
|
|
"Content-Security-Policy",
|
|
"default-src 'self'; img-src 'self' data:; style-src 'self'; "
|
|
"script-src 'self'; connect-src 'self'; base-uri 'none'; "
|
|
"frame-ancestors 'none'; form-action 'self'; object-src 'none'",
|
|
)
|
|
|
|
def _send_bytes(self, status, content, content_type, cache_control="no-store", head_only=False):
|
|
# type: (int, bytes, str, str, bool) -> None
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(content)))
|
|
self.send_header("Cache-Control", cache_control)
|
|
self._security_headers()
|
|
self.end_headers()
|
|
if not head_only and self.command != "HEAD":
|
|
self.wfile.write(content)
|
|
|
|
def _send_json(self, status, payload, head_only=False):
|
|
# type: (int, dict | list, bool) -> None
|
|
self._send_bytes(status, _json_bytes(payload), "application/json; charset=utf-8", head_only=head_only)
|
|
|
|
def _send_redirect(self, location):
|
|
# type: (str) -> None
|
|
self.send_response(302)
|
|
self.send_header("Location", location)
|
|
self.send_header("Content-Length", "0")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self._security_headers()
|
|
self.end_headers()
|
|
|
|
def _send_dashboard_error(self, error):
|
|
# type: (DashboardError) -> None
|
|
self._send_json(error.status, {"error": {"code": error.code, "message": text_type(error)}})
|
|
|
|
def _send_not_found(self):
|
|
# type: () -> None
|
|
self._send_json(404, {"error": {"code": "not_found", "message": "Resource not found."}})
|
|
|
|
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": "Unauthenticated dashboard only accepts local hosts."}}
|
|
)
|
|
return False
|
|
|
|
def _request_has_token(self):
|
|
# type: () -> bool
|
|
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
|
|
|
|
def _read_json(self):
|
|
# type: () -> dict
|
|
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
|
if content_type != "application/json":
|
|
error = DashboardError("Content-Type must be application/json.")
|
|
error.status = 415
|
|
error.code = "unsupported_media_type"
|
|
raise error
|
|
content_length = self.headers.get("Content-Length")
|
|
if content_length is None:
|
|
error = DashboardError("Content-Length is required.")
|
|
error.status = 411
|
|
error.code = "length_required"
|
|
raise error
|
|
try:
|
|
content_length = int(content_length)
|
|
except (TypeError, ValueError):
|
|
error = DashboardError("Content-Length is invalid.")
|
|
error.status = 400
|
|
error.code = "invalid_length"
|
|
raise error
|
|
if content_length < 0 or content_length > MAX_BODY_SIZE:
|
|
error = DashboardError("Request body is too large.")
|
|
error.status = 413
|
|
error.code = "payload_too_large"
|
|
raise error
|
|
content = self.rfile.read(content_length)
|
|
try:
|
|
payload = json.loads(content.decode("utf-8"))
|
|
except (UnicodeDecodeError, TypeError, ValueError):
|
|
error = DashboardError("Request body must contain valid UTF-8 JSON.")
|
|
error.status = 400
|
|
error.code = "invalid_json"
|
|
raise error
|
|
if not isinstance(payload, dict):
|
|
error = DashboardError("Request body must be a JSON object.")
|
|
error.status = 400
|
|
error.code = "invalid_json"
|
|
raise error
|
|
return payload
|
|
|
|
def _handle_get(self, head_only=False):
|
|
# type: (bool) -> None
|
|
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]
|
|
self._send_json(
|
|
403,
|
|
{"error": {"code": "invalid_launch_token", "message": "Dashboard launch token is invalid."}},
|
|
head_only=head_only,
|
|
)
|
|
return
|
|
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")
|
|
self._send_bytes(200, content, "text/html; charset=utf-8", head_only=head_only)
|
|
return
|
|
if path in STATIC_ASSETS:
|
|
resource_path, content_type = STATIC_ASSETS[path]
|
|
self._send_bytes(
|
|
200, _resource_bytes(resource_path), content_type, cache_control="no-cache", head_only=head_only
|
|
)
|
|
return
|
|
if path.startswith("/api/") and not self._request_has_token():
|
|
return
|
|
if path == "/api/dashboard":
|
|
self._send_json(200, self.service.dashboard(), head_only=head_only)
|
|
return
|
|
if path == "/api/config":
|
|
self._send_json(200, self.service.config_state(), head_only=head_only)
|
|
return
|
|
self._send_not_found()
|
|
|
|
def do_GET(self):
|
|
# type: () -> None
|
|
try:
|
|
self._handle_get()
|
|
except DashboardError as error:
|
|
self._send_dashboard_error(error)
|
|
except (IOError, OSError) as error:
|
|
self.server.logger.exception("Dashboard resource failure") # type: ignore[attr-defined]
|
|
self._send_json(500, {"error": {"code": "resource_error", "message": text_type(error)}})
|
|
|
|
def do_HEAD(self):
|
|
# type: () -> None
|
|
try:
|
|
self._handle_get(head_only=True)
|
|
except DashboardError as error:
|
|
self._send_dashboard_error(error)
|
|
except (IOError, OSError) as error:
|
|
self.server.logger.exception("Dashboard resource failure") # type: ignore[attr-defined]
|
|
self._send_json(500, {"error": {"code": "resource_error", "message": text_type(error)}}, head_only=True)
|
|
|
|
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
|
|
if path != "/api/config":
|
|
self._send_not_found()
|
|
return
|
|
try:
|
|
payload = self._read_json()
|
|
self._send_json(200, self.service.save(payload.get("config")))
|
|
except DashboardError as error:
|
|
self._send_dashboard_error(error)
|
|
except (IOError, OSError) as error:
|
|
self.server.logger.exception("Dashboard configuration write failure") # type: ignore[attr-defined]
|
|
self._send_json(500, {"error": {"code": "write_error", "message": text_type(error)}})
|
|
|
|
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
|
|
try:
|
|
payload = self._read_json()
|
|
if path == "/api/sync":
|
|
self._send_json(200, self.service.sync())
|
|
elif path == "/api/config/validate":
|
|
self._send_json(200, {"config": self.service.validate(payload.get("config"))})
|
|
elif path == "/api/config/restore":
|
|
self._send_json(200, self.service.restore_backup())
|
|
elif path == "/api/scheduler":
|
|
status = self.service.configure_scheduler(
|
|
payload.get("action", ""), payload.get("scheduler", "auto"), payload.get("interval", 5)
|
|
)
|
|
self._send_json(200, {"scheduler": status})
|
|
else:
|
|
self._send_not_found()
|
|
except DashboardError as error:
|
|
self._send_dashboard_error(error)
|
|
except (IOError, OSError, RuntimeError, ValueError) as error:
|
|
self.server.logger.exception("Dashboard operation failure") # type: ignore[attr-defined]
|
|
self._send_json(500, {"error": {"code": "operation_failed", "message": text_type(error)}})
|
|
|
|
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, 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 ":" in settings["host"] else ThreadingHTTPServer
|
|
server = server_class((settings["host"], settings["port"]), DashboardRequestHandler)
|
|
server.dashboard_service = service or DashboardService(logger=logger)
|
|
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, 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, 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 "::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())
|
|
if server.access_token is not None
|
|
else "{}/#view=overview".format(origin)
|
|
)
|
|
try:
|
|
service.start_scheduler()
|
|
_write_stdout("DDNS dashboard: {}\n".format(url))
|
|
_write_stdout("Config file: {}\n".format(service.config_path))
|
|
if open_browser:
|
|
webbrowser.open(url)
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
_write_stdout("\nStopping DDNS dashboard...\n")
|
|
finally:
|
|
service.stop_scheduler()
|
|
server.server_close()
|