Browse Source

[feature] Add SSL certificate validation configuration support (#499)

* Initial plan

* Add SSL certificate validation configuration support

- Add --ssl CLI argument with options: true, false, auto, /path/to/cert.pem
- Add DDNS_SSL environment variable support
- Add ssl field to JSON configuration with default "auto"
- Integrate SSL config with provider initialization in main.py
- Update documentation in README.md, doc/cli.md, doc/json.md, doc/env.md
- Add comprehensive tests for SSL configuration integration
- Support configuration priority: CLI > JSON > Environment Variables

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

* Fix SSL config test linting issues and rename file

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

* Fix SSL config tests: Python 2.7 compatibility and improved state management

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

* Fix SSL config test isolation and flake8 issues

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

* fix workflow

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: NewFuture <[email protected]>
Co-authored-by: New Future <[email protected]>
Copilot 5 months ago
parent
commit
43fe2792c6
9 changed files with 248 additions and 1 deletions
  1. 1 0
      .github/workflows/build.yml
  2. 4 0
      .github/workflows/publish.yml
  3. 1 0
      README.md
  4. 2 1
      ddns/__main__.py
  5. 7 0
      ddns/util/config.py
  6. 18 0
      doc/cli.md
  7. 22 0
      doc/env.md
  8. 2 0
      doc/json.md
  9. 191 0
      tests/test_config_ssl.py

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

@@ -67,6 +67,7 @@ jobs:
           sudo ${{env.PY}} get-pip.py
           ${{env.PY}} -m pip install mock==3.0.5
         working-directory: /tmp
+      - run: rm config.json -f
       - name: run unit tests
         run: ${{env.PY}} -m unittest discover tests -v
         env:

+ 4 - 0
.github/workflows/publish.yml

@@ -82,6 +82,8 @@ jobs:
       - run: python3 .github/patch.py version
       - name: Replace url in Readme
         run: sed -i'' -E 's#([("'\''`])(/doc/[^)"'\''`]+)\.md([)"'\''`])#\1https://ddns.newfuture.cc\2.html\3#g; s#([("'\''`])/doc/#\1https://ddns.newfuture.cc/doc/#g' README.md
+      - name: run unit tests
+        run: python -m unittest discover tests -v
       - name: Build package
         run: python -m build --sdist --wheel --outdir dist/
       - uses: pypa/gh-action-pypi-publish@release/v1
@@ -127,6 +129,8 @@ jobs:
         if: runner.os == 'macOS'
         run: python3 -m pip install imageio
 
+      - name: run unit tests
+        run: python -m unittest discover tests -v
       - name: test run.py
         run: python3 ./run.py -h
 

+ 1 - 0
README.md

@@ -178,6 +178,7 @@ python -m ddns -c /path/to/config.json
 | index6 | string\|int\|array |    No    | `"default"` |   ipv6 获取方式    | 可设置 `网卡`、`内网`、`公网`、`正则` 等方式                                                                                                                                             |
 |  ttl   |       number       |    No    |   `null`    | DNS 解析 TTL 时间  | 不设置采用 DNS 默认策略                                                                                                                                                                  |
 | proxy  |   string\|array    |    No    |     无      | http 代理 `;` 分割 | 多代理逐个尝试直到成功,`DIRECT` 为直连                                                                                                                                                  |
+|  ssl   |  string\|boolean   |    No    |  `"auto"`   | SSL证书验证方式    | `true`(强制验证)、`false`(禁用验证)、`"auto"`(自动降级)或自定义CA证书文件路径                                                                                    |
 | debug  |        bool        |    No    |   `false`   |    是否开启调试    | 调试模式,仅命令行参数`--debug`有效                                                                                                                                    |
 | cache  |    string\|bool    |    No    |   `true`    |    是否缓存记录    | 正常情况打开避免频繁更新,默认位置为临时目录下 `ddns.cache`,也可以指定一个具体路径                                                                                                      |
 |  log   |       object       |    No    |   `null`    |  日志配置(可选)  | 日志配置对象,支持`level`、`file`、`format`、`datefmt`参数                                                                                                                               |

+ 2 - 1
ddns/__main__.py

@@ -153,7 +153,8 @@ def main():
     # dns provider class
     dns_name = get_config("dns", "debug")  # type: str # type: ignore
     provider_class = get_provider_class(dns_name)
-    dns = provider_class(get_config("id"), get_config("token"), logger=logger)  # type: ignore
+    ssl_config = get_config("ssl", "auto")  # type: str | bool # type: ignore
+    dns = provider_class(get_config("id"), get_config("token"), logger=logger, verify_ssl=ssl_config)  # type: ignore
 
     if get_config("config"):
         info("loaded Config from: %s", path.abspath(get_config("config")))  # type: ignore

+ 7 - 0
ddns/util/config.py

@@ -120,6 +120,7 @@ def init_config(description, doc, version, date):
             "he",
             "huaweidns",
             "callback",
+            "debug",
         ],
     )
     parser.add_argument("--id", help="API ID or email [对应账号ID或邮箱]")
@@ -173,6 +174,11 @@ def init_config(description, doc, version, date):
         const=False,
         help="disable cache [关闭缓存等效 --cache=false]",
     )
+    parser.add_argument(
+        "--ssl",
+        help="SSL certificate verification [SSL证书验证方式]: "
+        "true(强制验证), false(禁用验证), auto(自动降级), /path/to/cert.pem(自定义证书)",
+    )
     parser.add_argument("--log.file", metavar="FILE", help="log file [日志文件,默认标准输出]")
     parser.add_argument("--log.level", type=log_level, metavar="|".join(log_levels))
     parser.add_argument("--log.format", metavar="FORMAT", help="log format [设置日志打印格式]")
@@ -291,6 +297,7 @@ def generate_config(config_path):
         "index6": "default",
         "ttl": None,
         "proxy": None,
+        "ssl": "auto",
         "log": {"level": "INFO"},
     }
     try:

+ 18 - 0
doc/cli.md

@@ -35,6 +35,7 @@ python run.py -h
 | `--ttl`         |        | 整数            | DNS 解析记录的 TTL 时间(秒)                |
 | `--proxy`       |        | 字符串列表      | HTTP 代理设置,支持多代理重复使用参数        |
 | `--cache`       |        | 布尔/字符串     | 是否启用缓存或自定义缓存路径                 |
+| `--ssl`         |        | 字符串          | SSL证书验证方式                             |
 | `--debug`       |        | 标志            | 开启调试模式(等同于 --log.level=DEBUG)     |
 | `--log.file`    |        | 字符串          | 日志文件路径,不指定则输出到控制台           |
 | `--log.level`   |        | 字符串          | 日志级别                                     |
@@ -57,6 +58,7 @@ python run.py -h
 | `--ttl`      | 秒数                                         | `--ttl 600`                            |
 | `--proxy`    | IP:端口, DIRECT                              | `--proxy 127.0.0.1:1080 --proxy DIRECT` |
 | `--cache`    | true, 文件路径                         | `--cache=true`, `--cache=/path/to/cache.json` |
+| `--ssl`      | true, false, auto, 文件路径                  | `--ssl false`, `--ssl /path/to/cert.pem` |
 | `--debug`    | (无值)                                       | `--debug`                              |
 | `--log.file` | 文件路径                                     | `--log.file=/var/log/ddns.log`         |
 | `--log.level`| DEBUG, INFO, WARNING, ERROR, CRITICAL        | `--log.level=DEBUG`                    |
@@ -196,6 +198,22 @@ HTTP代理设置,支持多代理轮换。
   - `--cache=false` (禁用缓存)
   - `--cache=/path/to/ddns.cache` (自定义缓存路径)
 
+### `--ssl {true|false|auto|PATH}`
+
+SSL证书验证方式,控制HTTPS连接的证书验证行为。
+
+- **默认值**: `auto`
+- **可选值**:
+  - `true`: 强制验证SSL证书(最安全)
+  - `false`: 禁用SSL证书验证(最不安全)
+  - `auto`: 优先验证,SSL证书错误时自动降级(不安全)
+  - 文件路径: 使用指定路径的自定义CA证书(最安全)
+- **示例**:
+  - `--ssl true` (强制验证)
+  - `--ssl false` (禁用验证)
+  - `--ssl auto` (自动降级)
+  - `--ssl /etc/ssl/certs/ca-certificates.crt` (自定义CA证书)
+
 ### `--debug`
 
 启用调试模式(等同于设置`--log.level=DEBUG`)。

+ 22 - 0
doc/env.md

@@ -317,6 +317,28 @@ export DDNS_TOKEN='{"api_key": "your_key", "domain": "__DOMAIN__", "ip": "__IP__
   export DDNS_CACHE="/path/to/ddns.cache"
   ```
 
+### SSL证书验证
+
+#### DDNS_SSL
+
+- **类型**: 字符串或布尔值
+- **必需**: 否
+- **默认值**: `"auto"`
+- **说明**: SSL证书验证方式,控制HTTPS连接的证书验证行为
+- **可选值**:
+  - `"true"`: 强制验证SSL证书(最安全)
+  - `"false"`: 禁用SSL证书验证(最不安全)
+  - `"auto"`: 优先验证,SSL证书错误时自动降级(不安全)
+  - 文件路径: 使用指定路径的自定义CA证书(最安全)
+- **示例**:
+
+  ```bash
+  export DDNS_SSL="true"     # 强制验证SSL证书
+  export DDNS_SSL="false"    # 禁用SSL验证(不推荐)
+  export DDNS_SSL="auto"     # 自动降级模式
+  export DDNS_SSL="/etc/ssl/certs/ca-certificates.crt"  # 自定义CA证书
+  ```
+
 ### 日志配置
 
 #### DDNS_LOG_LEVEL

+ 2 - 0
doc/json.md

@@ -47,6 +47,7 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
 | index6   | string\|int\|array |  否  | `"default"` |   IPv6获取方式    | 详见下方说明                                                                                               |
 |  ttl     |       number       |  否  |   `null`    | DNS解析TTL时间     | 单位为秒,不设置则采用DNS默认策略                                                                          |
 |  proxy   | string\|array      |  否  |     无      | HTTP代理          | 多代理逐个尝试直到成功,`DIRECT`为直连                                                                      |
+|   ssl    | string\|boolean    |  否  |  `"auto"`   | SSL证书验证方式    | `true`(强制验证)、`false`(禁用验证)、`"auto"`(自动降级)或自定义CA证书文件路径                          |
 |  debug   |       boolean      |  否  |   `false`   | 是否开启调试       | 等同于设置log.level=DEBUG,配置文件中设置此字段无效,仅命令行参数`--debug`有效                             |
 |  cache   |    string\|bool    |  否  |   `true`    | 是否缓存记录       | 正常情况打开避免频繁更新,默认位置为临时目录下`ddns.cache`,也可以指定具体路径                              |
 |  log     |       object       |  否  |   `null`    | 日志配置(可选)   | 日志配置对象,支持`level`、`file`、`format`、`datefmt`参数                                                |
@@ -126,6 +127,7 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
   "index6": "public",
   "ttl": 300,
   "proxy": "127.0.0.1:1080;DIRECT",
+  "ssl": "auto",
   "cache": "/var/cache/ddns.cache",
   "log": {
     "level": "DEBUG",

+ 191 - 0
tests/test_config_ssl.py

@@ -0,0 +1,191 @@
+# coding=utf-8
+"""
+Unit tests for SSL configuration integration
+
+@author: GitHub Copilot
+"""
+
+import unittest
+import sys
+import os
+
+# Add parent directory to path for imports
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+try:
+    from unittest.mock import patch
+except ImportError:
+    # Python 2.7 compatibility
+    from mock import patch  # type: ignore
+
+from ddns.util.config import init_config, get_config  # noqa: E402
+from ddns.__init__ import __version__, __description__, __doc__, build_date  # noqa
+from ddns.provider._base import SimpleProvider  # noqa: E402
+
+
+class _TestSSLProvider(SimpleProvider):
+    """Test provider to verify SSL configuration"""
+
+    API = "https://api.example.com"
+
+    def set_record(self, domain, value, record_type="A", ttl=None,
+                   line=None, **extra):
+        return True
+
+
+class TestSSLConfiguration(unittest.TestCase):
+    """Test SSL configuration integration"""
+
+    def setUp(self):
+        """Set up test fixtures"""
+        # Clear global state before each test
+        import ddns.util.config
+        from argparse import Namespace
+        ddns.util.config.__cli_args = Namespace()
+        ddns.util.config.__config = {}
+        # Clear environment variables that might affect tests
+        # Check both uppercase and lowercase variants
+        for key in list(os.environ.keys()):
+            if (key.upper().startswith('DDNS_') or
+                    key.lower().startswith('ddns_')):
+                del os.environ[key]
+
+    def tearDown(self):
+        """Clean up after each test"""
+        # Clear global state after each test
+        import ddns.util.config
+        from argparse import Namespace
+        ddns.util.config.__cli_args = Namespace()
+        ddns.util.config.__config = {}
+        # Clear environment variables that might affect future tests
+        for key in list(os.environ.keys()):
+            if (key.upper().startswith('DDNS_') or
+                    key.lower().startswith('ddns_')):
+                del os.environ[key]
+
+    def test_cli_ssl_false(self):
+        """Test SSL configuration via CLI argument --ssl false"""
+        args = ['test', '--token', 'test', '--ssl', 'false']
+        with patch.object(sys, 'argv', args):
+            init_config(__description__, __doc__, __version__, build_date)
+            ssl_config = get_config('ssl')
+            self.assertEqual(ssl_config, 'false')
+
+    def test_cli_ssl_true(self):
+        """Test SSL configuration via CLI argument --ssl true"""
+        args = ['test', '--token', 'test', '--ssl', 'true']
+        with patch.object(sys, 'argv', args):
+            init_config(__description__, __doc__, __version__, build_date)
+            ssl_config = get_config('ssl')
+            self.assertEqual(ssl_config, 'true')
+
+    def test_cli_ssl_auto(self):
+        """Test SSL configuration via CLI argument --ssl auto"""
+        args = ['test', '--token', 'test', '--ssl', 'auto']
+        with patch.object(sys, 'argv', args):
+            init_config(__description__, __doc__, __version__, build_date)
+            ssl_config = get_config('ssl')
+            self.assertEqual(ssl_config, 'auto')
+
+    def test_cli_ssl_custom_path(self):
+        """Test SSL configuration via CLI argument --ssl /path/to/cert.pem"""
+        args = ['test', '--token', 'test', '--ssl', '/path/to/cert.pem']
+        with patch.object(sys, 'argv', args):
+            init_config(__description__, __doc__, __version__, build_date)
+            ssl_config = get_config('ssl')
+            self.assertEqual(ssl_config, '/path/to/cert.pem')
+
+    def test_env_ssl_false(self):
+        """Test SSL configuration via environment variable DDNS_SSL=false"""
+        # Ensure completely clean environment
+        clean_env = {k: v for k, v in os.environ.items()
+                     if not (k.upper().startswith('DDNS_') or
+                             k.lower().startswith('ddns_'))}
+        clean_env.update({'DDNS_SSL': 'false', 'DDNS_TOKEN': 'test'})
+
+        with patch.dict(os.environ, clean_env, clear=True):
+            with patch.object(sys, 'argv', ['test']):
+                init_config(__description__, __doc__, __version__, build_date)
+                ssl_config = get_config('ssl')
+                self.assertEqual(ssl_config, 'false')
+
+    def test_env_ssl_true(self):
+        """Test SSL configuration via environment variable DDNS_SSL=true"""
+        # Ensure completely clean environment
+        clean_env = {k: v for k, v in os.environ.items()
+                     if not (k.upper().startswith('DDNS_') or
+                             k.lower().startswith('ddns_'))}
+        clean_env.update({'DDNS_SSL': 'true', 'DDNS_TOKEN': 'test'})
+
+        with patch.dict(os.environ, clean_env, clear=True):
+            with patch.object(sys, 'argv', ['test']):
+                init_config(__description__, __doc__, __version__, build_date)
+                ssl_config = get_config('ssl')
+                self.assertEqual(ssl_config, 'true')
+
+    def test_env_ssl_auto(self):
+        """Test SSL configuration via environment variable DDNS_SSL=auto"""
+        # Ensure completely clean environment
+        clean_env = {k: v for k, v in os.environ.items()
+                     if not (k.upper().startswith('DDNS_') or
+                             k.lower().startswith('ddns_'))}
+        clean_env.update({'DDNS_SSL': 'auto', 'DDNS_TOKEN': 'test'})
+
+        with patch.dict(os.environ, clean_env, clear=True):
+            with patch.object(sys, 'argv', ['test']):
+                init_config(__description__, __doc__, __version__, build_date)
+                ssl_config = get_config('ssl')
+                self.assertEqual(ssl_config, 'auto')
+
+    def test_cli_overrides_env(self):
+        """Test that CLI argument overrides environment variable"""
+        # Ensure completely clean environment
+        clean_env = {k: v for k, v in os.environ.items()
+                     if not (k.upper().startswith('DDNS_') or
+                             k.lower().startswith('ddns_'))}
+        clean_env.update({'DDNS_SSL': 'false', 'DDNS_TOKEN': 'test'})
+
+        with patch.dict(os.environ, clean_env, clear=True):
+            with patch.object(sys, 'argv', ['test', '--ssl', 'true']):
+                init_config(__description__, __doc__, __version__, build_date)
+                ssl_config = get_config('ssl')
+                self.assertEqual(ssl_config, 'true')
+
+    def test_default_ssl_config(self):
+        """Test default SSL configuration when none provided"""
+        with patch.object(sys, 'argv', ['test', '--token', 'test']):
+            init_config(__description__, __doc__, __version__, build_date)
+            ssl_config = get_config('ssl', 'auto')
+            self.assertEqual(ssl_config, 'auto')
+
+    def test_provider_ssl_integration(self):
+        """Test that SSL configuration is passed to provider correctly"""
+        provider = _TestSSLProvider('test_id', 'test_token',
+                                    verify_ssl='false')
+        self.assertEqual(provider.verify_ssl, 'false')
+
+        provider = _TestSSLProvider('test_id', 'test_token', verify_ssl=True)
+        self.assertTrue(provider.verify_ssl)
+
+        cert_path = '/path/to/cert.pem'
+        provider = _TestSSLProvider('test_id', 'test_token',
+                                    verify_ssl=cert_path)
+        self.assertEqual(provider.verify_ssl, '/path/to/cert.pem')
+
+    def test_case_insensitive_env_vars(self):
+        """Test that environment variables are case insensitive"""
+        # Ensure completely clean environment
+        clean_env = {k: v for k, v in os.environ.items()
+                     if not (k.upper().startswith('DDNS_') or
+                             k.lower().startswith('ddns_'))}
+        clean_env.update({'ddns_ssl': 'false', 'ddns_token': 'test'})
+
+        with patch.dict(os.environ, clean_env, clear=True):
+            with patch.object(sys, 'argv', ['test']):
+                init_config(__description__, __doc__, __version__, build_date)
+                ssl_config = get_config('ssl')
+                self.assertEqual(ssl_config, 'false')
+
+
+if __name__ == '__main__':
+    unittest.main()