Files
New FutureandFuture New 4e2b757f37 feat(branding): refresh DDNS logo and GitHub social cover (#740)
* feat(branding): refresh DDNS logo and social cover

* test(branding): pin reviewed asset exports

* test(branding): remove brand asset checks

* fix(cache): tolerate small filesystem clock skew

* docs(branding): correct cover license reference

---------

Co-authored-by: Future New (WEILAI LIU) <weilail@microsoft.com>
2026-09-13 22:38:36 +08:00

1231 lines
49 KiB
Python

# -*- coding: utf-8 -*-
"""Offline process-level end-to-end tests for the DDNS CLI and dashboard."""
from __future__ import unicode_literals
import io
import json
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import threading
import time
from __init__ import unittest
try:
from http.client import HTTPConnection
from http.server import BaseHTTPRequestHandler, HTTPServer
from socketserver import ThreadingMixIn
from urllib.parse import parse_qs, urlparse
except ImportError: # Python 2
from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer
from httplib import HTTPConnection
from SocketServer import ThreadingMixIn
from urlparse import parse_qs, urlparse
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir))
RUN_SCRIPT = os.path.join(PROJECT_ROOT, "run.py")
E2E_EXECUTABLE = os.environ.get("DDNS_E2E_EXECUTABLE")
if E2E_EXECUTABLE:
E2E_EXECUTABLE = os.path.abspath(E2E_EXECUTABLE)
PROCESS_TIMEOUT = 60 if E2E_EXECUTABLE else 20
STARTUP_TIMEOUT = 60 if E2E_EXECUTABLE else 10
TEST_IPV4 = "192.0.2.44"
TEST_IPV6 = "2001:db8::44"
PROXY_ENVIRONMENT_KEYS = {"ALL_PROXY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"}
def _popen_group_options():
if os.name == "nt":
return {"creationflags": getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)}
return {"start_new_session": True}
def _signal_process_group(process, signal_value):
try:
if os.name == "nt":
process.send_signal(signal_value)
else:
os.killpg(process.pid, signal_value)
except (AttributeError, OSError):
if process.poll() is None:
process.send_signal(signal_value)
def _kill_process_group(process):
try:
if os.name == "nt":
process.kill()
else:
os.killpg(process.pid, signal.SIGKILL)
except OSError:
if process.poll() is None:
process.kill()
class _ProcessResult(object):
"""Captured result from a completed DDNS process."""
def __init__(self, returncode, stdout, stderr):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
class _FixtureState(object):
"""Thread-safe configuration and request state for the local HTTP fixture."""
def __init__(self):
self._lock = threading.Lock()
self.reset()
def reset(self):
with self._lock:
self._requests = []
self._remote_documents = {}
def record(self, request):
with self._lock:
self._requests.append(request)
def set_remote_document(self, path, document):
with self._lock:
self._remote_documents[path] = document
def remote_document(self, path):
with self._lock:
return self._remote_documents.get(path)
def requests_for(self, path):
with self._lock:
return [request.copy() for request in self._requests if request["path"] == path]
class _ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
"""Small concurrent HTTP server used by subprocess-based tests."""
daemon_threads = True
class _FixtureHandler(BaseHTTPRequestHandler):
"""Serve deterministic IP, remote config, and callback endpoints."""
protocol_version = "HTTP/1.1"
def log_message(self, format_string, *args):
pass
def _read_body(self):
content_length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(content_length) if content_length else b""
return body.decode("utf-8")
def _record(self, body=""):
parsed = urlparse(self.path)
self.server.state.record(
{
"method": self.command,
"path": parsed.path,
"query": parse_qs(parsed.query, keep_blank_values=True),
"headers": dict(self.headers.items()),
"body": body,
}
)
return parsed.path
def _send(self, status, body, content_type="text/plain; charset=utf-8"):
if not isinstance(body, bytes):
body = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = self._record()
remote_document = self.server.state.remote_document(path)
if remote_document is not None:
body = (
remote_document
if isinstance(remote_document, str)
else json.dumps(remote_document, ensure_ascii=False, separators=(",", ":"))
)
self._send(200, body, "application/json; charset=utf-8")
elif path == "/ip/invalid":
self._send(200, "address unavailable")
elif path == "/ip/v4":
self._send(200, "current address: {}\n".format(TEST_IPV4))
elif path == "/ip/v6":
self._send(200, "{}\n".format(TEST_IPV6))
elif path == "/ip/error-address":
self._send(403, "upstream 198.51.100.20 unavailable")
elif path == "/ip/mixed-v6":
self._send(200, "2001:db8::192.0.2.10")
elif path == "/ip/compact-v6":
self._send(200, "IP:{}".format(TEST_IPV6))
elif path in ("/callback/fail", "/callback/fail.example.com"):
self._send(400, "callback rejected")
elif path.startswith("/callback/"):
self._send(200, "callback accepted")
else:
self._send(404, "not found")
def do_POST(self):
body = self._read_body()
path = self._record(body)
if path in ("/callback/fail", "/callback/fail.example.com"):
self._send(400, "callback rejected")
elif path.startswith("/callback/"):
self._send(200, "callback accepted")
else:
self._send(404, "not found")
class _BackgroundProcess(object):
"""Run and continuously drain a long-lived child process."""
def __init__(self, command, cwd, env):
self.process = subprocess.Popen(
command,
cwd=cwd,
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding="utf-8",
errors="replace",
bufsize=1,
**_popen_group_options(),
)
self._lock = threading.Lock()
self._stdout = []
self._stderr = []
self._threads = [
threading.Thread(target=self._drain, args=(self.process.stdout, self._stdout)),
threading.Thread(target=self._drain, args=(self.process.stderr, self._stderr)),
]
for thread in self._threads:
thread.daemon = True
thread.start()
def _drain(self, stream, output):
for line in iter(stream.readline, ""):
with self._lock:
output.append(line)
stream.close()
@property
def stdout(self):
with self._lock:
return "".join(self._stdout)
@property
def stderr(self):
with self._lock:
return "".join(self._stderr)
def wait_for_stdout(self, text, timeout=10):
deadline = time.time() + timeout
while time.time() < deadline:
output = self.stdout
if text in output:
return output
if self.process.poll() is not None:
break
time.sleep(0.05)
raise AssertionError(
"Process did not write {!r}.\nstdout:\n{}\nstderr:\n{}".format(text, self.stdout, self.stderr)
)
def stop(self, timeout=5):
if self.process.poll() is None:
if os.name == "nt":
_signal_process_group(self.process, signal.CTRL_C_EVENT)
else:
_signal_process_group(self.process, signal.SIGINT)
deadline = time.time() + timeout
while self.process.poll() is None and time.time() < deadline:
time.sleep(0.05)
if self.process.poll() is None:
if os.name == "nt":
self.process.terminate()
else:
_signal_process_group(self.process, signal.SIGTERM)
deadline = time.time() + 2
while self.process.poll() is None and time.time() < deadline:
time.sleep(0.05)
if self.process.poll() is None:
_kill_process_group(self.process)
self.process.wait()
for thread in self._threads:
thread.join(2)
return self.process.returncode
class OfflineE2ETestCase(unittest.TestCase):
"""Shared isolated process and HTTP helpers."""
@classmethod
def setUpClass(cls):
if E2E_EXECUTABLE and not os.path.isfile(E2E_EXECUTABLE):
raise RuntimeError("DDNS E2E executable does not exist: {}".format(E2E_EXECUTABLE))
cls.fixture_state = _FixtureState()
cls.fixture_server = _ThreadingHTTPServer(("127.0.0.1", 0), _FixtureHandler)
cls.fixture_server.state = cls.fixture_state
cls.fixture_thread = threading.Thread(target=cls.fixture_server.serve_forever)
cls.fixture_thread.daemon = True
cls.fixture_thread.start()
cls.fixture_url = "http://127.0.0.1:{}".format(cls.fixture_server.server_address[1])
@classmethod
def tearDownClass(cls):
cls.fixture_server.shutdown()
cls.fixture_server.server_close()
cls.fixture_thread.join(2)
def setUp(self):
self.fixture_state.reset()
self.temp_dir = tempfile.mkdtemp(prefix="ddns-e2e-")
self.addCleanup(shutil.rmtree, self.temp_dir, ignore_errors=True)
def _environment(self, overrides=None):
env = os.environ.copy()
for key in list(env):
upper_key = key.upper()
if (
upper_key.startswith("DDNS_")
or upper_key in PROXY_ENVIRONMENT_KEYS
or upper_key in ("PYTHONHOME", "PYTHONPATH")
):
del env[key]
env.update(
{
"HOME": self.temp_dir,
"USERPROFILE": self.temp_dir,
"TMPDIR": self.temp_dir,
"TEMP": self.temp_dir,
"TMP": self.temp_dir,
"PYTHONIOENCODING": "utf-8",
"PYTHONUNBUFFERED": "1",
}
)
if not E2E_EXECUTABLE:
env["PYTHONPATH"] = PROJECT_ROOT
if overrides:
env.update(overrides)
return env
def _command(self, arguments, entrypoint="module"):
if E2E_EXECUTABLE:
return [E2E_EXECUTABLE] + arguments
if entrypoint == "script":
return [sys.executable, RUN_SCRIPT] + arguments
return [sys.executable, "-m", "ddns"] + arguments
def _run(self, arguments, entrypoint="module", env=None, timeout=PROCESS_TIMEOUT, input_text=None):
process = subprocess.Popen(
self._command(arguments, entrypoint),
cwd=self.temp_dir,
env=env or self._environment(),
stdin=subprocess.PIPE if input_text is not None else None,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
encoding="utf-8",
errors="replace",
**_popen_group_options(),
)
timed_out = threading.Event()
def kill_on_timeout():
if process.poll() is None:
timed_out.set()
_kill_process_group(process)
timer = threading.Timer(timeout, kill_on_timeout)
timer.daemon = True
timer.start()
try:
stdout, stderr = process.communicate(input_text)
finally:
timer.cancel()
if timed_out.is_set():
self.fail("DDNS process timed out.\nstdout:\n{}\nstderr:\n{}".format(stdout, stderr))
return _ProcessResult(process.returncode, stdout, stderr)
def _start(self, arguments, env=None):
process = _BackgroundProcess(self._command(arguments), self.temp_dir, env or self._environment())
self.addCleanup(process.stop)
return process
def _write_config(self, name, config):
path = os.path.join(self.temp_dir, name)
with io.open(path, "w", encoding="utf-8") as config_file:
json.dump(config, config_file, ensure_ascii=False, indent=2)
return path
def _callback_config(
self, callback_path, domains=None, ipv6_domains=None, cache=False, token="", ttl=300, line="default"
):
return {
"dns": "callback",
"id": self.fixture_url + callback_path,
"token": token,
"ipv4": domains or [],
"index4": ["url:" + self.fixture_url + "/ip/v4"] if domains else False,
"ipv6": ipv6_domains or [],
"index6": ["url:" + self.fixture_url + "/ip/v6"] if ipv6_domains else False,
"ttl": ttl,
"line": line,
"proxy": ["DIRECT"],
"cache": cache,
"ssl": False,
}
def assert_process_success(self, result):
self.assertEqual(
result.returncode,
0,
"Process failed with {}.\nstdout:\n{}\nstderr:\n{}".format(result.returncode, result.stdout, result.stderr),
)
def _http_request(self, port, path, method="GET", payload=None, headers=None):
request_headers = dict(headers or {})
body = None
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
request_headers.setdefault("Content-Type", "application/json")
connection = HTTPConnection("127.0.0.1", port, timeout=5)
try:
connection.request(method, path, body=body, headers=request_headers)
response = connection.getresponse()
content = response.read().decode("utf-8")
return response.status, dict(response.getheaders()), content
finally:
connection.close()
class TestCliE2E(OfflineE2ETestCase):
"""Exercise complete DDNS update flows through public process entrypoints."""
def test_ipv6_runs_when_ipv4_is_disabled_or_fails(self):
"""Update the working family independently of a disabled or unavailable IPv4 source."""
for name, index4, expected_exit in (
("disabled", False, 0),
("unavailable", ["url:" + self.fixture_url + "/ip/invalid"], 1),
):
self.fixture_state.reset()
config = self._callback_config(
"/callback/dual-failure?type=__RECORDTYPE__&ip=__IP__", ["v4.example.com"], ["v6.example.com"]
)
config["index4"] = index4
config_path = self._write_config(name + ".json", config)
result = self._run(["--config", config_path])
self.assertEqual(result.returncode, expected_exit, result.stderr)
requests = self.fixture_state.requests_for("/callback/dual-failure")
self.assertEqual(len(requests), 1, result.stderr)
self.assertEqual(requests[0]["query"], {"type": ["AAAA"], "ip": [TEST_IPV6]})
self.assertEqual(len(self.fixture_state.requests_for("/ip/v6")), 1)
def test_partial_domain_failure_preserves_only_successful_cache(self):
"""Report partial failure and retry the rejected domain, while retaining successful cached records."""
config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True)
config_path = self._write_config("partial-domains.json", config)
for attempt in (1, 2):
result = self._run(["--config", config_path])
self.assertEqual(result.returncode, 1, result.stderr)
self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), attempt)
def test_http_error_address_is_ignored(self):
"""Do not update DNS from the address contained in an IP endpoint error page."""
config = self._callback_config("/callback/error-address?ip=__IP__", ["ip.example.com"])
config["index4"] = ["url:" + self.fixture_url + "/ip/error-address", "url:" + self.fixture_url + "/ip/v4"]
config_path = self._write_config("error-address.json", config)
result = self._run(["--config", config_path])
self.assert_process_success(result)
requests = self.fixture_state.requests_for("/callback/error-address")
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV4])
self.assertEqual(len(self.fixture_state.requests_for("/ip/error-address")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/ip/v4")), 1)
def test_mixed_ipv6_address_is_preserved(self):
"""Forward a complete mixed-notation IPv6 address to the callback."""
config = self._callback_config("/callback/mixed-v6?ip=__IP__", ipv6_domains=["mixed.example.com"])
config["index6"] = ["url:" + self.fixture_url + "/ip/mixed-v6"]
config_path = self._write_config("mixed-v6.json", config)
result = self._run(["--config", config_path])
self.assert_process_success(result)
requests = self.fixture_state.requests_for("/callback/mixed-v6")
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["query"]["ip"], ["2001:db8::192.0.2.10"])
def test_compact_ipv6_label_is_preserved(self):
"""Resolve a compact text response over real HTTP and forward its IPv6 address."""
config = self._callback_config("/callback/compact-v6?ip=__IP__", ipv6_domains=["label.example.com"])
config["index6"] = ["url:" + self.fixture_url + "/ip/compact-v6"]
config_path = self._write_config("compact-v6.json", config)
result = self._run(["--config", config_path])
self.assert_process_success(result)
requests = self.fixture_state.requests_for("/callback/compact-v6")
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["query"]["ip"], [TEST_IPV6])
self.assertEqual(len(self.fixture_state.requests_for("/ip/compact-v6")), 1)
def test_callback_http_failure_is_not_cached(self):
"""Retry a missing callback endpoint instead of caching its error body as a successful update."""
config = self._callback_config("/missing", ["callback.example.com"], cache=True)
config_path = self._write_config("missing-callback.json", config)
for attempt in (1, 2):
result = self._run(["--config", config_path])
self.assertEqual(result.returncode, 1, result.stderr)
self.assertEqual(len(self.fixture_state.requests_for("/missing")), attempt)
def test_cli_only_dual_stack_and_rule_fallback(self):
"""Resolve both address families and fall back after an unusable rule."""
result = self._run(
[
"--dns",
"debug",
"--no-cache",
"--proxy",
"DIRECT",
"--index4",
"url:" + self.fixture_url + "/ip/invalid",
"url:" + self.fixture_url + "/ip/v4",
"--ipv4",
"First.Example.com",
"second.example.com",
"--index6",
"url:" + self.fixture_url + "/ip/v6",
"--ipv6",
"v6.example.com",
],
entrypoint="script",
)
self.assert_process_success(result)
self.assertEqual(result.stdout.count("[IPv4] {}".format(TEST_IPV4)), 2)
self.assertEqual(result.stdout.count("[IPv6] {}".format(TEST_IPV6)), 1)
self.assertIn("first.example.com", result.stderr)
self.assertIn("second.example.com", result.stderr)
self.assertEqual(len(self.fixture_state.requests_for("/ip/invalid")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/ip/v4")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/ip/v6")), 1)
def test_local_config_callback_post_and_cli_precedence(self):
"""Send a real callback request with CLI values overriding file values."""
token = json.dumps(
{
"domain": "__DOMAIN__",
"ip": "__IP__",
"record_type": "__RECORDTYPE__",
"ttl": "__TTL__",
"line": "__LINE__",
},
separators=(",", ":"),
)
config = self._callback_config("/callback/local", ["file.example.com"], token=token, ttl=300, line="file")
config_path = self._write_config("local.json", config)
result = self._run(["-c", config_path, "--ipv4", "CLI.Example.com", "--ttl", "600"])
self.assert_process_success(result)
requests = self.fixture_state.requests_for("/callback/local")
self.assertEqual(len(requests), 1)
self.assertEqual(requests[0]["method"], "POST")
self.assertEqual(
json.loads(requests[0]["body"]),
{"domain": "cli.example.com", "ip": TEST_IPV4, "record_type": "A", "ttl": "600", "line": "file"},
)
self.assertNotIn("file.example.com", requests[0]["body"])
def test_environment_configuration_with_cli_override(self):
"""Load a callback entirely from DDNS_* variables and override its domain."""
callback_path = "/callback/environment"
env = self._environment(
{
"DDNS_DNS": "callback",
"DDNS_ID": self.fixture_url
+ callback_path
+ "?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__&ttl=__TTL__",
"DDNS_TOKEN": "",
"DDNS_IPV4": '["env.example.com"]',
"DDNS_INDEX4": '["url:' + self.fixture_url + '/ip/v4"]',
"DDNS_IPV6": "false",
"DDNS_INDEX6": "false",
"DDNS_TTL": "120",
"DDNS_PROXY": '["DIRECT"]',
"DDNS_CACHE": "false",
"DDNS_SSL": "false",
}
)
result = self._run(["--ipv4", "cli-env.example.com"], env=env)
self.assert_process_success(result)
requests = self.fixture_state.requests_for(callback_path)
self.assertEqual(len(requests), 1)
self.assertEqual(
requests[0]["query"], {"domain": ["cli-env.example.com"], "ip": [TEST_IPV4], "type": ["A"], "ttl": ["120"]}
)
def test_generate_config_with_realistic_cli_options_and_reuse_it(self):
"""Generate a user-style config with a spaced path, then execute it."""
config_path = os.path.join(self.temp_dir, "generated user config.json")
log_path = os.path.join(self.temp_dir, "generated user.log")
callback_path = "/callback/generated"
callback_url = (
self.fixture_url
+ callback_path
+ "?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__&ttl=__TTL__&line=__LINE__"
)
generate = self._run(
[
"--dns",
"callback",
"--id",
callback_url,
"--token",
"{}",
"--index4",
"url:" + self.fixture_url + "/ip/v4",
"--ipv4",
"Generated.Example.com",
"--ttl",
"900",
"--line",
"generated",
"--proxy",
"DIRECT",
"--no-cache",
"--no-ssl",
"--log-level",
"DEBUG",
"--log-file",
log_path,
"--new-config",
config_path,
]
)
self.assert_process_success(generate)
self.assertTrue(os.path.isfile(config_path))
with io.open(config_path, "r", encoding="utf-8") as generated_file:
generated_config = json.load(generated_file)
self.assertEqual(generated_config["dns"], "callback")
self.assertEqual(generated_config["token"], "{}")
self.assertEqual(generated_config["ipv4"], ["Generated.Example.com"])
self.assertEqual(generated_config["index4"], ["url:" + self.fixture_url + "/ip/v4"])
self.assertEqual(generated_config["proxy"], ["DIRECT"])
self.assertFalse(generated_config["cache"])
self.assertFalse(generated_config["ssl"])
result = self._run(["-c", config_path])
self.assert_process_success(result)
requests = self.fixture_state.requests_for(callback_path)
self.assertEqual(len(requests), 1)
self.assertEqual(
requests[0]["query"],
{
"domain": ["generated.example.com"],
"ip": [TEST_IPV4],
"type": ["A"],
"ttl": ["900"],
"line": ["generated"],
},
)
with io.open(log_path, "r", encoding="utf-8") as log_file:
self.assertIn("generated.example.com", log_file.read())
def test_remote_multi_provider_configuration(self):
"""Load a remote v4.1 document and execute every expanded provider."""
second_token = json.dumps(
{
"domain": "__DOMAIN__",
"ip": "__IP__",
"record_type": "__RECORDTYPE__",
"ttl": "__TTL__",
"line": "__LINE__",
},
separators=(",", ":"),
)
remote_config = {
"$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
"proxy": ["DIRECT"],
"ssl": False,
"cache": False,
"ttl": 300,
"line": "global",
"providers": [
{
"provider": "callback",
"id": self.fixture_url
+ "/callback/remote-v4?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__&ttl=__TTL__&line=__LINE__",
"token": "",
"ipv4": ["remote-v4.example.com"],
"index4": ["url:" + self.fixture_url + "/ip/v4"],
"ipv6": [],
"index6": False,
},
{
"provider": "callback",
"id": self.fixture_url + "/callback/remote-v6",
"token": second_token,
"ipv4": [],
"index4": False,
"ipv6": ["remote-v6.example.com"],
"index6": ["url:" + self.fixture_url + "/ip/v6"],
"ttl": 600,
"line": "provider",
},
],
}
remote_path = "/config/multi.json"
self.fixture_state.set_remote_document(remote_path, remote_config)
result = self._run(["-c", self.fixture_url + remote_path, "--proxy", "DIRECT"])
self.assert_process_success(result)
self.assertEqual(len(self.fixture_state.requests_for(remote_path)), 1)
first = self.fixture_state.requests_for("/callback/remote-v4")
second = self.fixture_state.requests_for("/callback/remote-v6")
self.assertEqual(len(first), 1)
self.assertEqual(
first[0]["query"],
{"domain": ["remote-v4.example.com"], "ip": [TEST_IPV4], "type": ["A"], "ttl": ["300"], "line": ["global"]},
)
self.assertEqual(len(second), 1)
self.assertEqual(
json.loads(second[0]["body"]),
{
"domain": "remote-v6.example.com",
"ip": TEST_IPV6,
"record_type": "AAAA",
"ttl": "600",
"line": "provider",
},
)
def test_cache_skips_unchanged_callback_on_second_process(self):
"""Persist an address and avoid a duplicate provider update."""
cache_path = os.path.join(self.temp_dir, "records.cache")
config = self._callback_config(
"/callback/cache?domain=__DOMAIN__&ip=__IP__&type=__RECORDTYPE__", ["cache.example.com"], cache=cache_path
)
config["cache_max_age"] = 3600
config_path = self._write_config("cache.json", config)
first = self._run(["-c", config_path])
second = self._run(["-c", config_path])
self.assert_process_success(first)
self.assert_process_success(second)
self.assertEqual(len(self.fixture_state.requests_for("/callback/cache")), 1)
self.assertIn("using cache", second.stderr)
with io.open(cache_path, "r", encoding="utf-8") as cache_file:
self.assertEqual(json.load(cache_file), {"cache.example.com:A": TEST_IPV4})
def test_multiple_configs_continue_after_failure_and_exit_nonzero(self):
"""Run all configurations but fail the process when one callback fails."""
success_path = self._write_config(
"success.json",
self._callback_config(
"/callback/success?domain=__DOMAIN__&ip=__IP__", ["success.example.com"], cache=False
),
)
failure_path = self._write_config(
"failure.json",
self._callback_config("/callback/fail?domain=__DOMAIN__&ip=__IP__", ["failure.example.com"], cache=False),
)
result = self._run(["-c", success_path, "-c", failure_path])
self.assertEqual(result.returncode, 1)
self.assertEqual(len(self.fixture_state.requests_for("/callback/success")), 1)
self.assertEqual(len(self.fixture_state.requests_for("/callback/fail")), 1)
self.assertIn("Configuration 2 failed", result.stderr)
self.assertIn("Some configurations failed", result.stderr)
class TestMcpE2E(OfflineE2ETestCase):
"""Exercise both supported MCP stdio lifecycles through a real process."""
MODERN_VERSION = "2026-07-28"
LEGACY_VERSION = "2025-11-25"
def _modern_request(self, request_id, method, params=None):
params = dict(params or {})
params["_meta"] = {
"io.modelcontextprotocol/protocolVersion": self.MODERN_VERSION,
"io.modelcontextprotocol/clientCapabilities": {"sampling": {}},
"io.modelcontextprotocol/clientInfo": {"name": "ddns-e2e-client", "version": "1.0"},
}
return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params}
@staticmethod
def _request_lines(requests):
return "".join(json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" for request in requests)
def _run_mcp(self, config_path, requests):
result = self._run(["mcp", "-c", config_path], input_text=self._request_lines(requests))
self.assert_process_success(result)
lines = result.stdout.splitlines()
self.assertEqual(
len(lines),
len([request for request in requests if "id" in request]),
"Unexpected MCP stdout.\nstdout:\n{}\nstderr:\n{}".format(result.stdout, result.stderr),
)
return result, [json.loads(line) for line in lines]
def test_partial_domain_failure_is_a_tool_error(self):
"""Report a rejected domain through MCP and retry it without repeating successful writes."""
config = self._callback_config("/callback/__DOMAIN__", ["ok.example.com", "fail.example.com"], cache=True)
config_path = self._write_config("partial-mcp.json", config)
requests = [
self._modern_request(request_id, "tools/call", {"name": "update_dns_records", "arguments": {}})
for request_id in (1, 2)
]
result, responses = self._run_mcp(config_path, requests)
for response in responses:
self.assertTrue(response["result"]["isError"])
self.assertIn("Synchronization failed", response["result"]["content"][0]["text"])
self.assertEqual(len(self.fixture_state.requests_for("/callback/ok.example.com")), 1, result.stderr)
self.assertEqual(len(self.fixture_state.requests_for("/callback/fail.example.com")), 2, result.stderr)
def test_modern_discovery_tools_update_and_cached_status(self):
"""Discover tools, synchronize dual-stack records, and read cached status."""
token = json.dumps(
{
"api_key": "mcp-secret",
"domain": "__DOMAIN__",
"ip": "__IP__",
"record_type": "__RECORDTYPE__",
"ttl": "__TTL__",
"line": "__LINE__",
},
separators=(",", ":"),
)
config = {
"$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
"proxy": ["DIRECT"],
"ssl": False,
"cache": True,
"cache_max_age": 3600,
"providers": [
{
"provider": "callback",
"id": self.fixture_url + "/callback/mcp",
"token": token,
"ipv4": ["mcp-v4.example.com"],
"index4": ["url:" + self.fixture_url + "/ip/v4"],
"ipv6": ["mcp-v6.example.com"],
"index6": ["url:" + self.fixture_url + "/ip/v6"],
"ttl": 720,
"line": "mcp",
},
{
"provider": "debug",
"ipv4": ["mcp-debug.example.com"],
"index4": ["url:" + self.fixture_url + "/ip/v4"],
"ipv6": [],
"index6": False,
"ttl": 300,
},
],
}
config_path = self._write_config("mcp.json", config)
requests = [
self._modern_request(1, "server/discover"),
self._modern_request(2, "tools/list"),
self._modern_request(3, "tools/call", {"name": "update_dns_records", "arguments": {}}),
self._modern_request(4, "tools/call", {"name": "get_ddns_status", "arguments": {}}),
]
result, responses = self._run_mcp(config_path, requests)
self.assertEqual([response["id"] for response in responses], [1, 2, 3, 4])
self.assertEqual(responses[0]["result"]["supportedVersions"], [self.MODERN_VERSION, self.LEGACY_VERSION])
tools = responses[1]["result"]["tools"]
self.assertEqual([tool["name"] for tool in tools], ["get_ddns_status", "update_dns_records"])
self.assertTrue(tools[0]["annotations"]["readOnlyHint"])
self.assertTrue(tools[1]["annotations"]["destructiveHint"])
update = responses[2]["result"]
self.assertFalse(update["isError"])
self.assertEqual(update["structuredContent"]["state"], "synced")
expected_records = [
("mcp-debug.example.com", "A", TEST_IPV4, "debug"),
("mcp-v4.example.com", "A", TEST_IPV4, "callback"),
("mcp-v6.example.com", "AAAA", TEST_IPV6, "callback"),
]
self.assertEqual(
[
(record["domain"], record["type"], record["value"], record["provider"])
for record in update["structuredContent"]["records"]
],
expected_records,
)
status = responses[3]["result"]
self.assertFalse(status["isError"])
self.assertEqual(status["structuredContent"]["state"], "synced")
self.assertEqual(
[
(record["domain"], record["type"], record["value"], record["provider"])
for record in status["structuredContent"]["records"]
],
expected_records,
)
self.assertNotIn("mcp-secret", result.stdout)
self.assertIn("[IPv4] {}".format(TEST_IPV4), result.stderr)
callback_requests = self.fixture_state.requests_for("/callback/mcp")
self.assertEqual(len(callback_requests), 2)
self.assertEqual(
sorted((json.loads(request["body"]) for request in callback_requests), key=lambda body: body["domain"]),
[
{
"api_key": "mcp-secret",
"domain": "mcp-v4.example.com",
"ip": TEST_IPV4,
"record_type": "A",
"ttl": "720",
"line": "mcp",
},
{
"api_key": "mcp-secret",
"domain": "mcp-v6.example.com",
"ip": TEST_IPV6,
"record_type": "AAAA",
"ttl": "720",
"line": "mcp",
},
],
)
def test_legacy_copilot_initialize_ping_list_and_status(self):
"""Run the lifecycle used by GitHub Copilot CLI over stdio."""
config_path = self._write_config(
"legacy-mcp.json", self._callback_config("/callback/legacy-mcp", ["legacy.example.com"], cache=False)
)
requests = [
{
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": self.LEGACY_VERSION,
"capabilities": {"sampling": {}},
"clientInfo": {"name": "github-copilot-developer", "version": "1.0.80"},
},
},
{"jsonrpc": "2.0", "method": "notifications/initialized"},
{"jsonrpc": "2.0", "id": 1, "method": "ping"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "get_ddns_status", "arguments": {}}},
]
_, responses = self._run_mcp(config_path, requests)
self.assertEqual([response["id"] for response in responses], [0, 1, 2, 3])
self.assertEqual(responses[0]["result"]["protocolVersion"], self.LEGACY_VERSION)
self.assertEqual(responses[1]["result"], {})
self.assertEqual(
[tool["name"] for tool in responses[2]["result"]["tools"]], ["get_ddns_status", "update_dns_records"]
)
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."""
def test_dashboard_config_sync_and_scheduler_lifecycle(self):
"""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",
"--http-token",
"web-http-secret",
"--interval",
"30",
]
)
output = process.wait_for_stdout("DDNS dashboard:", timeout=STARTUP_TIMEOUT)
launch_url = next(
line.split("DDNS dashboard:", 1)[1].strip() for line in output.splitlines() if "DDNS dashboard:" in line
)
parsed_launch_url = urlparse(launch_url)
port = parsed_launch_url.port
launch_path = parsed_launch_url.path
self.assertIsNotNone(port)
status, _, index = self._http_request(port, "/")
asset_status, _, script = self._http_request(port, "/assets/dashboard.js")
unauthorized_status, _, unauthorized_body = self._http_request(port, "/api/dashboard")
launch_status, launch_headers, _ = self._http_request(port, launch_path)
reused_status, _, reused_body = self._http_request(port, launch_path)
self.assertEqual(status, 200)
self.assertIn("<html", index)
self.assertEqual(asset_status, 200)
self.assertIn("/api/dashboard", script)
self.assertEqual(unauthorized_status, 403)
self.assertEqual(json.loads(unauthorized_body)["error"]["code"], "invalid_token")
self.assertEqual(launch_status, 302)
self.assertEqual(reused_status, 403)
self.assertEqual(json.loads(reused_body)["error"]["code"], "invalid_launch_token")
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(
{
"domain": "__DOMAIN__",
"ip": "__IP__",
"record_type": "__RECORDTYPE__",
"ttl": "__TTL__",
"line": "__LINE__",
},
separators=(",", ":"),
)
config = {
"$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
"ssl": False,
"proxy": ["DIRECT"],
"cache": True,
"cache_max_age": 3600,
"interval": 30,
"log": {"level": "INFO"},
"providers": [
{
"provider": "callback",
"id": self.fixture_url + "/callback/web",
"token": callback_token,
"ipv4": ["web.example.com"],
"index4": ["url:" + self.fixture_url + "/ip/v4"],
"ipv6": [],
"index6": False,
"ttl": 180,
"line": "web",
}
],
}
validate_status, _, validate_body = self._http_request(
port, "/api/config/validate", method="POST", payload={"config": config}, headers=config_headers
)
save_status, _, save_body = self._http_request(
port, "/api/config", method="PUT", payload={"config": config}, headers=config_headers
)
sync_status, _, sync_body = self._http_request(
port, "/api/sync", method="POST", payload={}, headers=config_headers
)
dashboard_status, _, dashboard_body = self._http_request(port, "/api/dashboard", headers=api_headers)
self.assertEqual(validate_status, 200, validate_body)
self.assertEqual(
json.loads(validate_body)["config"]["providers"][0]["index4"], ["url:" + self.fixture_url + "/ip/v4"]
)
self.assertEqual(save_status, 200, save_body)
self.assertTrue(os.path.isfile(config_path))
self.assertEqual(sync_status, 200, sync_body)
dashboard = json.loads(dashboard_body)
self.assertEqual(dashboard_status, 200)
self.assertEqual(dashboard["state"], "synced")
self.assertEqual(dashboard["providers"][0]["status"], "synced")
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(
json.loads(callback_requests[0]["body"]),
{"domain": "web.example.com", "ip": TEST_IPV4, "record_type": "A", "ttl": "180", "line": "web"},
)
config["cache"] = False
cache_disable_status, _, cache_disable_body = self._http_request(
port, "/api/config", method="PUT", payload={"config": config}, headers=config_headers
)
self.assertEqual(cache_disable_status, 200, cache_disable_body)
configure_status, _, configure_body = self._http_request(
port,
"/api/scheduler",
method="POST",
payload={"action": "configure", "scheduler": "web", "interval": 1},
headers=config_headers,
)
disable_status, _, disable_body = self._http_request(
port,
"/api/scheduler",
method="POST",
payload={"action": "disable", "scheduler": "web", "interval": 1},
headers=config_headers,
)
enable_status, _, enable_body = self._http_request(
port,
"/api/scheduler",
method="POST",
payload={"action": "enable", "scheduler": "web", "interval": 1},
headers=config_headers,
)
self.assertEqual(configure_status, 200, configure_body)
self.assertEqual(json.loads(configure_body)["scheduler"]["interval"], 1)
self.assertEqual(disable_status, 200, disable_body)
self.assertFalse(json.loads(disable_body)["scheduler"]["enabled"])
self.assertEqual(enable_status, 200, enable_body)
self.assertTrue(json.loads(enable_body)["scheduler"]["enabled"])
deadline = time.time() + 75
scheduled_dashboard = None
while time.time() < deadline:
time.sleep(1)
dashboard_status, _, dashboard_body = self._http_request(port, "/api/dashboard", headers=api_headers)
dashboard = json.loads(dashboard_body)
if (
dashboard_status == 200
and dashboard["scheduler"]["last_run"] is not None
and len(self.fixture_state.requests_for("/callback/web")) >= 2
):
scheduled_dashboard = dashboard
break
self.assertIsNotNone(
scheduled_dashboard,
"Web scheduler did not synchronize.\nstdout:\n{}\nstderr:\n{}".format(process.stdout, process.stderr),
)
self.assertEqual(len(self.fixture_state.requests_for("/callback/web")), 2)
self.assertEqual(scheduled_dashboard["state"], "synced")
self.assertIsNone(scheduled_dashboard["scheduler"]["last_error"])
returncode = process.stop()
if os.name == "nt":
self.assertIsNotNone(returncode)
else:
self.assertEqual(returncode, 0, "Dashboard did not stop cleanly.\nstderr:\n{}".format(process.stderr))
if __name__ == "__main__":
unittest.main()