Bläddra i källkod

feat(config): add support for extra configuration fields (#562)

* Initial plan

* feat(config): add extra field support to Config class

Co-authored-by: NewFuture <[email protected]>

* feat(config): complete extra field implementation with documentation

Co-authored-by: NewFuture <[email protected]>

* docs(config): add practical examples for extra fields usage

Co-authored-by: NewFuture <[email protected]>

* fix(config): remove func from known_keys, delete docs, update schema

Co-authored-by: NewFuture <[email protected]>

* fix(config): revert v4.0.json changes and fix lint/format issues

Co-authored-by: NewFuture <[email protected]>

* Remove statistics option from ruff-action args

* Update ddns/config/cli.py

Co-authored-by: Copilot <[email protected]>

* fix(config): fix lint error and Python 2.7 compatibility

Co-authored-by: NewFuture <[email protected]>

* fix(config): remove redundant extra unpacking in provider init

Co-authored-by: NewFuture <[email protected]>

* fix(config): make set_record call Python 2.7 compatible

Co-authored-by: NewFuture <[email protected]>

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: NewFuture <[email protected]>
Co-authored-by: New Future <[email protected]>
Co-authored-by: Copilot <[email protected]>
Copilot 3 månader sedan
förälder
incheckning
1600e57aff

+ 1 - 1
.github/workflows/build.yml

@@ -28,7 +28,7 @@ jobs:
       - uses: astral-sh/ruff-action@v3
         with:
           src: "."
-          args: "check --statistics --output-format=github"
+          args: "check --output-format=github"
 
   python:
     strategy:

+ 7 - 2
ddns/__main__.py

@@ -68,7 +68,7 @@ def update_ip(dns, cache, index_rule, domains, record_type, config):
             update_success = True
         else:
             try:
-                result = dns.set_record(domain, address, record_type=record_type, ttl=config.ttl, line=config.line)
+                result = dns.set_record(domain, address, record_type=record_type, ttl=config.ttl, line=config.line, **config.extra)
                 if result:
                     logger.warning("set %s[IPv%s]: %s successfully.", domain, ip_type, address)
                     update_success = True
@@ -92,7 +92,12 @@ def run(config):
     # dns provider class
     provider_class = get_provider_class(config.dns)
     dns = provider_class(
-        config.id, config.token, endpoint=config.endpoint, logger=logger, proxy=config.proxy, ssl=config.ssl
+        config.id,
+        config.token,
+        endpoint=config.endpoint,
+        logger=logger,
+        proxy=config.proxy,
+        ssl=config.ssl,
     )
     cache = Cache.new(config.cache, config.md5(), logger)
     return (

+ 25 - 1
ddns/config/cli.py

@@ -261,7 +261,31 @@ def load_config(description, doc, version, date):
     # Subparsers are only needed when user provides a subcommand (non-option argument)
     _add_task_subcommand_if_needed(parser)
 
-    args = parser.parse_args()
+    args, unknown = parser.parse_known_args()
+
+    # Parse unknown arguments that follow --extra.xxx format
+    extra_args = {}  # type: dict
+    i = 0
+    while i < len(unknown):
+        arg = unknown[i]
+        if arg.startswith("--extra."):
+            key = "extra_" + arg[8:]  # Remove "--extra." and add "extra_" prefix
+            # Check if there's a value for this argument
+            if i + 1 < len(unknown) and not unknown[i + 1].startswith("--"):
+                extra_args[key] = unknown[i + 1]
+                i += 2
+            else:
+                # No value provided, set to True (flag)
+                extra_args[key] = True  # type: ignore[assignment]
+                i += 1
+        else:
+            # Unknown argument that doesn't match our pattern
+            sys.stderr.write("Warning: Unknown argument: {}\n".format(arg))
+            i += 1
+
+    # Merge extra_args into args namespace
+    for k, v in extra_args.items():
+        setattr(args, k, v)
 
     # Handle task subcommand and exit early if present
     if hasattr(args, "func"):

+ 65 - 0
ddns/config/config.py

@@ -74,6 +74,31 @@ class Config(object):
         self._json_config = json_config or {}
         self._env_config = env_config or {}
 
+        # Known configuration keys that should not go into extra
+        self._known_keys = {
+            "dns",
+            "id",
+            "token",
+            "endpoint",
+            "index4",
+            "index6",
+            "ipv4",
+            "ipv6",
+            "ttl",
+            "line",
+            "proxy",
+            "cache",
+            "ssl",
+            "log_level",
+            "log_format",
+            "log_file",
+            "log_datefmt",
+            "extra",
+            "debug",
+            "config",
+            "command",
+        }
+
         # dns related configurations
         self.dns = self._get("dns", "")  # type: str
         self.id = self._get("id", "")  # type: str
@@ -99,6 +124,9 @@ class Config(object):
         self.log_file = self._get("log_file", None)  # type: str | None
         self.log_datefmt = self._get("log_datefmt", "%Y-%m-%dT%H:%M:%S")  # type: str | None
 
+        # Collect extra fields from all config sources
+        self.extra = self._collect_extra()  # type: dict
+
     def _get(self, key, default=None):
         # type: (str, Any) -> Any
         """
@@ -117,6 +145,41 @@ class Config(object):
             return split_array_string(value)
         return value
 
+    def _collect_extra(self):
+        # type: () -> dict
+        """
+        Collect all extra fields from CLI, JSON, and ENV configs that are not known keys.
+        Priority: CLI > JSON > ENV
+        """
+        extra = {}  # type: dict
+
+        # Collect from env config first (lowest priority)
+        for key, value in self._env_config.items():
+            if key.startswith("extra_"):
+                extra_key = key[6:]  # Remove "extra_" prefix
+                extra[extra_key] = value
+            elif key == "extra" and isinstance(value, dict):
+                extra.update(value)
+            elif key not in self._known_keys:
+                extra[key] = value
+
+        # Collect from JSON config (medium priority)
+        for key, value in self._json_config.items():
+            if key == "extra" and isinstance(value, dict):
+                extra.update(value)
+            elif key not in self._known_keys:
+                extra[key] = value
+
+        # Collect from CLI config (highest priority)
+        for key, value in self._cli_config.items():
+            if key.startswith("extra_"):
+                extra_key = key[6:]  # Remove "extra_" prefix
+                extra[extra_key] = value
+            elif key not in self._known_keys:
+                extra[key] = value
+
+        return extra
+
     def md5(self):
         # type: () -> str
         """
@@ -145,5 +208,7 @@ class Config(object):
             "log_format": self.log_format,
             "log_file": self.log_file,
             "log_datefmt": self.log_datefmt,
+            # Extra fields
+            "extra": self.extra,
         }
         return md5(str(dict_var).encode("utf-8")).hexdigest()

+ 2 - 1
ddns/config/env.py

@@ -46,7 +46,8 @@ def load_config(prefix="DDNS_"):
     3. 键名转换:点号转下划线,支持大小写变体
     4. 自动检测标准 Python 环境变量:
        - SSL 验证:PYTHONHTTPSVERIFY
-    5. 其他所有值保持原始字符串格式,去除前后空格
+    5. 支持 extra 字段:DDNS_EXTRA_XXX 会被转换为 extra_xxx
+    6. 其他所有值保持原始字符串格式,去除前后空格
 
     Args:
         prefix (str): 环境变量前缀,默认为 "DDNS_"

+ 20 - 0
doc/examples/config-with-extra.json

@@ -0,0 +1,20 @@
+{
+  "$schema": "https://ddns.newfuture.cc/schema/v4.1.json",
+  "dns": "cloudflare",
+  "id": "[email protected]",
+  "token": "YOUR_API_TOKEN_HERE",
+  "ipv4": [
+    "example.com",
+    "www.example.com"
+  ],
+  "index4": ["default"],
+  "ttl": 600,
+  "extra": {
+    "proxied": true,
+    "comment": "Managed by DDNS - Auto-updated",
+    "tags": [
+      "production",
+      "ddns"
+    ]
+  }
+}

+ 28 - 2
schema/v4.1.json

@@ -218,12 +218,24 @@
                 "DEFAULT"
               ]
             }
+          },
+          "extra": {
+            "type": "object",
+            "title": "Extra Fields",
+            "description": "额外的自定义字段,用于传递DNS服务商特定的参数,如Cloudflare的proxied、comment、tags等",
+            "additionalProperties": true,
+            "examples": [
+              {
+                "proxied": true,
+                "comment": "Managed by DDNS"
+              }
+            ]
           }
         },
         "required": [
           "provider"
         ],
-        "additionalProperties": false
+        "additionalProperties": true
       }
     },
     "id": {
@@ -532,6 +544,20 @@
       },
       "required": [],
       "additionalProperties": false
+    },
+    "extra": {
+      "$id": "/properties/extra",
+      "type": "object",
+      "title": "Extra Fields",
+      "description": "额外的自定义字段,用于传递DNS服务商特定的参数,如Cloudflare的proxied、comment、tags等",
+      "additionalProperties": true,
+      "examples": [
+        {
+          "proxied": true,
+          "comment": "Managed by DDNS",
+          "tags": ["production", "ddns"]
+        }
+      ]
     }
   },
   "not": {
@@ -589,5 +615,5 @@
       ]
     }
   ],
-  "additionalProperties": false
+  "additionalProperties": true
 }

+ 132 - 0
tests/test_config_cli_extra.py

@@ -0,0 +1,132 @@
+# coding=utf-8
+"""
+Unit tests for CLI extra field support in ddns.config.cli module
+@author: GitHub Copilot
+"""
+
+import sys
+from __init__ import unittest
+from ddns.config.cli import load_config  # noqa: E402
+
+
+class TestCliExtraFields(unittest.TestCase):
+    """Test CLI extra field parsing"""
+
+    def setUp(self):
+        """Save original sys.argv"""
+        self.original_argv = sys.argv
+
+    def tearDown(self):
+        """Restore original sys.argv"""
+        sys.argv = self.original_argv
+
+    def test_cli_extra_single_field(self):
+        """Test single --extra.xxx argument"""
+        sys.argv = ["ddns", "--dns", "cloudflare", "--extra.proxied", "true"]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertEqual(config.get("extra_proxied"), "true")
+
+    def test_cli_extra_multiple_fields(self):
+        """Test multiple --extra.xxx arguments"""
+        sys.argv = [
+            "ddns",
+            "--dns",
+            "cloudflare",
+            "--extra.proxied",
+            "true",
+            "--extra.comment",
+            "Test comment",
+            "--extra.priority",
+            "10",
+        ]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertEqual(config.get("extra_proxied"), "true")
+        self.assertEqual(config.get("extra_comment"), "Test comment")
+        self.assertEqual(config.get("extra_priority"), "10")
+
+    def test_cli_extra_with_standard_args(self):
+        """Test --extra.xxx mixed with standard arguments"""
+        sys.argv = [
+            "ddns",
+            "--dns",
+            "alidns",
+            "--id",
+            "[email protected]",
+            "--token",
+            "secret123",
+            "--extra.custom_field",
+            "custom_value",
+            "--ttl",
+            "300",
+        ]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("dns"), "alidns")
+        self.assertEqual(config.get("id"), "[email protected]")
+        self.assertEqual(config.get("token"), "secret123")
+        self.assertEqual(config.get("ttl"), 300)
+        self.assertEqual(config.get("extra_custom_field"), "custom_value")
+
+    def test_cli_extra_flag_without_value(self):
+        """Test --extra.xxx without a value (should be treated as True)"""
+        sys.argv = ["ddns", "--dns", "cloudflare", "--extra.enabled"]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertTrue(config.get("extra_enabled"))
+
+    def test_cli_extra_with_dots_in_name(self):
+        """Test --extra.xxx.yyy format (nested key)"""
+        sys.argv = ["ddns", "--dns", "cloudflare", "--extra.settings.key1", "value1"]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("dns"), "cloudflare")
+        # The key should be settings.key1 (not nested object)
+        self.assertEqual(config.get("extra_settings.key1"), "value1")
+
+    def test_cli_extra_empty_value(self):
+        """Test --extra.xxx with empty string value"""
+        sys.argv = ["ddns", "--dns", "cloudflare", "--extra.comment", ""]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertEqual(config.get("extra_comment"), "")
+
+    def test_cli_extra_numeric_values(self):
+        """Test --extra.xxx with numeric string values"""
+        sys.argv = [
+            "ddns",
+            "--dns",
+            "cloudflare",
+            "--extra.priority",
+            "100",
+            "--extra.weight",
+            "0.5",
+        ]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("extra_priority"), "100")
+        self.assertEqual(config.get("extra_weight"), "0.5")
+
+    def test_cli_extra_special_characters(self):
+        """Test --extra.xxx with special characters in value"""
+        sys.argv = [
+            "ddns",
+            "--dns",
+            "cloudflare",
+            "--extra.url",
+            "https://example.com/path?key=value",
+        ]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("extra_url"), "https://example.com/path?key=value")
+
+    def test_cli_no_extra_args(self):
+        """Test that config works without any extra arguments"""
+        sys.argv = ["ddns", "--dns", "cloudflare", "--id", "[email protected]"]
+        config = load_config("Test DDNS", "Test doc", "1.0.0", "2025-07-04")
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertEqual(config.get("id"), "[email protected]")
+        # No extra_* keys should exist
+        extra_keys = [k for k in config.keys() if k.startswith("extra_")]
+        self.assertEqual(len(extra_keys), 0)
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 145 - 0
tests/test_config_env_extra.py

@@ -0,0 +1,145 @@
+# coding=utf-8
+"""
+Unit tests for environment variable extra field support
+@author: GitHub Copilot
+"""
+
+import os
+from __init__ import unittest
+from ddns.config.env import load_config  # noqa: E402
+
+
+class TestEnvExtraFields(unittest.TestCase):
+    """Test environment variable extra field parsing"""
+
+    def setUp(self):
+        """Clear DDNS environment variables before each test"""
+        self._clear_env_prefix("DDNS_")
+
+    def tearDown(self):
+        """Clean up after tests"""
+        self._clear_env_prefix("DDNS_")
+
+    def _clear_env_prefix(self, prefix):
+        # type: (str) -> None
+        """Clear environment variables with a specific prefix (case-insensitive)"""
+        keys_to_delete = [key for key in os.environ.keys() if key.upper().startswith(prefix.upper())]
+        for key in keys_to_delete:
+            del os.environ[key]
+
+    def test_env_extra_single_field(self):
+        """Test single DDNS_EXTRA_XXX environment variable"""
+        os.environ["DDNS_DNS"] = "cloudflare"
+        os.environ["DDNS_EXTRA_PROXIED"] = "true"
+
+        config = load_config()
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertEqual(config.get("extra_proxied"), "true")
+
+    def test_env_extra_multiple_fields(self):
+        """Test multiple DDNS_EXTRA_XXX environment variables"""
+        os.environ["DDNS_DNS"] = "alidns"
+        os.environ["DDNS_EXTRA_PROXIED"] = "true"
+        os.environ["DDNS_EXTRA_COMMENT"] = "Test comment"
+        os.environ["DDNS_EXTRA_PRIORITY"] = "10"
+
+        config = load_config()
+        self.assertEqual(config.get("dns"), "alidns")
+        self.assertEqual(config.get("extra_proxied"), "true")
+        self.assertEqual(config.get("extra_comment"), "Test comment")
+        self.assertEqual(config.get("extra_priority"), "10")
+
+    def test_env_extra_with_standard_vars(self):
+        """Test DDNS_EXTRA_XXX mixed with standard environment variables"""
+        os.environ["DDNS_DNS"] = "cloudflare"
+        os.environ["DDNS_ID"] = "[email protected]"
+        os.environ["DDNS_TOKEN"] = "secret123"
+        os.environ["DDNS_EXTRA_CUSTOM_FIELD"] = "custom_value"
+        os.environ["DDNS_TTL"] = "300"
+
+        config = load_config()
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertEqual(config.get("id"), "[email protected]")
+        self.assertEqual(config.get("token"), "secret123")
+        self.assertEqual(config.get("ttl"), "300")
+        self.assertEqual(config.get("extra_custom_field"), "custom_value")
+
+    def test_env_extra_case_insensitive(self):
+        """Test that DDNS_EXTRA_XXX is case-insensitive"""
+        os.environ["ddns_extra_field1"] = "value1"
+        os.environ["DDNS_EXTRA_FIELD2"] = "value2"
+        os.environ["Ddns_Extra_Field3"] = "value3"
+
+        config = load_config()
+        self.assertEqual(config.get("extra_field1"), "value1")
+        self.assertEqual(config.get("extra_field2"), "value2")
+        self.assertEqual(config.get("extra_field3"), "value3")
+
+    def test_env_extra_with_underscores(self):
+        """Test DDNS_EXTRA_XXX with underscores in field name"""
+        os.environ["DDNS_EXTRA_CUSTOM_FIELD_NAME"] = "value1"
+        os.environ["DDNS_EXTRA_ANOTHER_FIELD"] = "value2"
+
+        config = load_config()
+        self.assertEqual(config.get("extra_custom_field_name"), "value1")
+        self.assertEqual(config.get("extra_another_field"), "value2")
+
+    def test_env_extra_with_dots(self):
+        """Test DDNS_EXTRA.XXX format (dots converted to underscores)"""
+        os.environ["DDNS_EXTRA.FIELD1"] = "value1"
+        os.environ["DDNS_EXTRA.FIELD2"] = "value2"
+
+        config = load_config()
+        # Dots should be converted to underscores
+        self.assertEqual(config.get("extra_field1"), "value1")
+        self.assertEqual(config.get("extra_field2"), "value2")
+
+    def test_env_extra_numeric_values(self):
+        """Test DDNS_EXTRA_XXX with numeric values"""
+        os.environ["DDNS_EXTRA_PRIORITY"] = "100"
+        os.environ["DDNS_EXTRA_WEIGHT"] = "0.5"
+
+        config = load_config()
+        self.assertEqual(config.get("extra_priority"), "100")
+        self.assertEqual(config.get("extra_weight"), "0.5")
+
+    def test_env_extra_empty_value(self):
+        """Test DDNS_EXTRA_XXX with empty value"""
+        os.environ["DDNS_EXTRA_COMMENT"] = ""
+
+        config = load_config()
+        self.assertEqual(config.get("extra_comment"), "")
+
+    def test_env_no_extra_vars(self):
+        """Test that config works without any extra environment variables"""
+        # Clear all DDNS env vars first
+        self._clear_env_prefix("DDNS_")
+
+        os.environ["DDNS_DNS"] = "cloudflare"
+        os.environ["DDNS_ID"] = "[email protected]"
+
+        config = load_config()
+        self.assertEqual(config.get("dns"), "cloudflare")
+        self.assertEqual(config.get("id"), "[email protected]")
+        # No extra_* keys should exist (only from this test)
+        extra_keys = [k for k in config.keys() if k.startswith("extra_")]
+        # Should have no extra keys from this test's environment variables
+        self.assertEqual(len(extra_keys), 0, "Found unexpected extra keys: {}".format(extra_keys))
+
+    def test_env_extra_json_array(self):
+        """Test DDNS_EXTRA_XXX with JSON array format"""
+        os.environ["DDNS_EXTRA_TAGS"] = '["tag1", "tag2", "tag3"]'
+
+        config = load_config()
+        self.assertEqual(config.get("extra_tags"), ["tag1", "tag2", "tag3"])
+
+    def test_env_extra_special_characters(self):
+        """Test DDNS_EXTRA_XXX with special characters"""
+        os.environ["DDNS_EXTRA_URL"] = "https://example.com/path?key=value&foo=bar"
+
+        config = load_config()
+        self.assertEqual(config.get("extra_url"), "https://example.com/path?key=value&foo=bar")
+
+
+if __name__ == "__main__":
+    unittest.main()

+ 244 - 0
tests/test_config_extra.py

@@ -0,0 +1,244 @@
+# coding=utf-8
+"""
+Unit tests for extra field support in ddns.config.config module
+@author: GitHub Copilot
+"""
+
+from __init__ import unittest
+from ddns.config.config import Config  # noqa: E402
+
+
+class TestConfigExtra(unittest.TestCase):
+    """Test extra field collection from various config sources"""
+
+    def test_extra_from_cli(self):
+        """Test extra fields from CLI config with extra_ prefix"""
+        cli_config = {
+            "dns": "cloudflare",
+            "id": "[email protected]",
+            "extra_proxied": "true",
+            "extra_comment": "Test comment",
+            "extra_custom_field": "custom_value",
+        }
+        config = Config(cli_config=cli_config)
+        self.assertEqual(config.dns, "cloudflare")
+        self.assertIsInstance(config.extra, dict)
+        self.assertEqual(config.extra.get("proxied"), "true")
+        self.assertEqual(config.extra.get("comment"), "Test comment")
+        self.assertEqual(config.extra.get("custom_field"), "custom_value")
+
+    def test_extra_from_json(self):
+        """Test extra fields from JSON config as extra object"""
+        json_config = {
+            "dns": "alidns",
+            "id": "test_id",
+            "extra": {
+                "proxied": False,
+                "comment": "JSON comment",
+                "tags": ["tag1", "tag2"],
+            },
+        }
+        config = Config(json_config=json_config)
+        self.assertEqual(config.dns, "alidns")
+        self.assertIsInstance(config.extra, dict)
+        self.assertFalse(config.extra.get("proxied"))
+        self.assertEqual(config.extra.get("comment"), "JSON comment")
+        self.assertEqual(config.extra.get("tags"), ["tag1", "tag2"])
+
+    def test_extra_from_json_undefined_fields(self):
+        """Test undefined fields in JSON config are collected as extra"""
+        json_config = {
+            "dns": "dnspod",
+            "id": "test_id",
+            "custom_field": "custom_value",
+            "another_field": 123,
+        }
+        config = Config(json_config=json_config)
+        self.assertEqual(config.dns, "dnspod")
+        self.assertEqual(config.extra.get("custom_field"), "custom_value")
+        self.assertEqual(config.extra.get("another_field"), 123)
+
+    def test_extra_from_env(self):
+        """Test extra fields from environment config"""
+        env_config = {
+            "dns": "cloudflare",
+            "extra_proxied": "true",
+            "extra_ttl_override": "300",
+        }
+        config = Config(env_config=env_config)
+        self.assertEqual(config.dns, "cloudflare")
+        self.assertEqual(config.extra.get("proxied"), "true")
+        self.assertEqual(config.extra.get("ttl_override"), "300")
+
+    def test_extra_from_env_undefined_fields(self):
+        """Test undefined fields in env config are collected as extra"""
+        env_config = {
+            "dns": "dnspod",
+            "custom_env_field": "env_value",
+            "another_env_field": "another_value",
+        }
+        config = Config(env_config=env_config)
+        self.assertEqual(config.dns, "dnspod")
+        self.assertEqual(config.extra.get("custom_env_field"), "env_value")
+        self.assertEqual(config.extra.get("another_env_field"), "another_value")
+
+    def test_extra_priority_cli_over_json(self):
+        """Test CLI extra fields have priority over JSON"""
+        cli_config = {
+            "extra_comment": "CLI comment",
+            "extra_field1": "cli_value",
+        }
+        json_config = {
+            "extra": {
+                "comment": "JSON comment",
+                "field1": "json_value",
+                "field2": "json_only",
+            },
+        }
+        config = Config(cli_config=cli_config, json_config=json_config)
+        self.assertEqual(config.extra.get("comment"), "CLI comment")
+        self.assertEqual(config.extra.get("field1"), "cli_value")
+        self.assertEqual(config.extra.get("field2"), "json_only")
+
+    def test_extra_priority_json_over_env(self):
+        """Test JSON extra fields have priority over ENV"""
+        json_config = {
+            "extra": {
+                "comment": "JSON comment",
+                "field1": "json_value",
+            },
+        }
+        env_config = {
+            "extra_comment": "ENV comment",
+            "extra_field1": "env_value",
+            "extra_field2": "env_only",
+        }
+        config = Config(json_config=json_config, env_config=env_config)
+        self.assertEqual(config.extra.get("comment"), "JSON comment")
+        self.assertEqual(config.extra.get("field1"), "json_value")
+        self.assertEqual(config.extra.get("field2"), "env_only")
+
+    def test_extra_priority_all_sources(self):
+        """Test complete priority chain: CLI > JSON > ENV"""
+        cli_config = {
+            "extra_field1": "cli_value",
+        }
+        json_config = {
+            "extra": {
+                "field1": "json_value",
+                "field2": "json_value",
+            },
+        }
+        env_config = {
+            "extra_field1": "env_value",
+            "extra_field2": "env_value",
+            "extra_field3": "env_value",
+        }
+        config = Config(cli_config=cli_config, json_config=json_config, env_config=env_config)
+        self.assertEqual(config.extra.get("field1"), "cli_value")
+        self.assertEqual(config.extra.get("field2"), "json_value")
+        self.assertEqual(config.extra.get("field3"), "env_value")
+
+    def test_extra_empty_by_default(self):
+        """Test extra is empty dict when no extra fields provided"""
+        config = Config()
+        self.assertIsInstance(config.extra, dict)
+        self.assertEqual(len(config.extra), 0)
+
+    def test_extra_does_not_include_known_fields(self):
+        """Test that known configuration fields are not collected as extra"""
+        cli_config = {
+            "dns": "cloudflare",
+            "id": "[email protected]",
+            "token": "secret",
+            "ttl": "300",
+            "extra_custom": "custom_value",
+        }
+        config = Config(cli_config=cli_config)
+        # Known fields should not be in extra
+        self.assertNotIn("dns", config.extra)
+        self.assertNotIn("id", config.extra)
+        self.assertNotIn("token", config.extra)
+        self.assertNotIn("ttl", config.extra)
+        # Only custom field should be in extra
+        self.assertEqual(config.extra.get("custom"), "custom_value")
+
+    def test_extra_with_json_extra_object_and_undefined_fields(self):
+        """Test JSON config with both extra object and undefined fields"""
+        json_config = {
+            "dns": "cloudflare",
+            "extra": {
+                "proxied": True,
+                "comment": "From extra object",
+            },
+            "custom_field": "From undefined field",
+            "another_field": 123,
+        }
+        config = Config(json_config=json_config)
+        # Both should be collected
+        self.assertTrue(config.extra.get("proxied"))
+        self.assertEqual(config.extra.get("comment"), "From extra object")
+        self.assertEqual(config.extra.get("custom_field"), "From undefined field")
+        self.assertEqual(config.extra.get("another_field"), 123)
+
+    def test_extra_in_md5_hash(self):
+        """Test that extra fields are included in MD5 hash"""
+        config1 = Config(cli_config={"dns": "cloudflare", "extra_field": "value1"})
+        config2 = Config(cli_config={"dns": "cloudflare", "extra_field": "value1"})
+        config3 = Config(cli_config={"dns": "cloudflare", "extra_field": "value2"})
+
+        # Same extra should produce same hash
+        self.assertEqual(config1.md5(), config2.md5())
+        # Different extra should produce different hash
+        self.assertNotEqual(config1.md5(), config3.md5())
+
+    def test_extra_with_complex_values(self):
+        """Test extra fields with complex data types"""
+        json_config = {
+            "dns": "cloudflare",
+            "extra": {
+                "tags": ["tag1", "tag2", "tag3"],
+                "settings": {"key1": "value1", "key2": "value2"},
+                "enabled": True,
+                "priority": 10,
+            },
+        }
+        config = Config(json_config=json_config)
+        self.assertEqual(config.extra.get("tags"), ["tag1", "tag2", "tag3"])
+        self.assertEqual(config.extra.get("settings"), {"key1": "value1", "key2": "value2"})
+        self.assertTrue(config.extra.get("enabled"))
+        self.assertEqual(config.extra.get("priority"), 10)
+
+    def test_extra_env_with_extra_object(self):
+        """Test env config with extra object (dict)"""
+        env_config = {
+            "dns": "cloudflare",
+            "extra": {
+                "field1": "value1",
+                "field2": "value2",
+            },
+        }
+        config = Config(env_config=env_config)
+        self.assertEqual(config.extra.get("field1"), "value1")
+        self.assertEqual(config.extra.get("field2"), "value2")
+
+    def test_extra_mixed_prefix_and_object(self):
+        """Test mixing extra_ prefix and extra object in same source"""
+        json_config = {
+            "dns": "cloudflare",
+            "extra": {
+                "from_object": "object_value",
+            },
+            "undefined_field": "undefined_value",
+        }
+        cli_config = {
+            "extra_from_prefix": "prefix_value",
+        }
+        config = Config(cli_config=cli_config, json_config=json_config)
+        self.assertEqual(config.extra.get("from_object"), "object_value")
+        self.assertEqual(config.extra.get("undefined_field"), "undefined_value")
+        self.assertEqual(config.extra.get("from_prefix"), "prefix_value")
+
+
+if __name__ == "__main__":
+    unittest.main()