Browse Source

feat(config:line): 支持自定义线路参数 (#501)

* Initial plan

* Add line configuration support for DNS providers

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

* Add line parameter documentation to CLI, ENV, and README

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

* Implement line parameter in config.py and update v4.0 schema, fix lint issues

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

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: NewFuture <[email protected]>
Copilot 5 months ago
parent
commit
1e351642d5

+ 1 - 0
README.md

@@ -51,6 +51,7 @@
 - 其他:
   - 可设置定时任务
   - TTL 配置支持
+  - DNS 线路(运营商)配置支持(国内服务商)
   - 本地文件缓存(减少 API 请求)
   - 地址变更时触发自定义回调 API(与 DDNS 功能互斥)
 

+ 7 - 6
ddns/__main__.py

@@ -74,14 +74,14 @@ def change_dns_record(dns, proxy_list, **kw):
             dns.set_proxy(proxy)
         record_type, domain = kw["record_type"], kw["domain"]
         try:
-            return dns.set_record(domain, kw["ip"], record_type=record_type, ttl=kw["ttl"])
+            return dns.set_record(domain, kw["ip"], record_type=record_type, ttl=kw["ttl"], line=kw.get("line"))
         except Exception as e:
             error("Failed to update %s record for %s: %s", record_type, domain, e)
     return False
 
 
-def update_ip(ip_type, cache, dns, ttl, proxy_list):
-    # type: (str, Cache | None, SimpleProvider, str, list[str]) -> bool | None
+def update_ip(ip_type, cache, dns, ttl, line, proxy_list):
+    # type: (str, Cache | None, SimpleProvider, str, str | None, list[str]) -> bool | None
     """
     更新IP
     """
@@ -110,7 +110,7 @@ def update_ip(ip_type, cache, dns, ttl, proxy_list):
             update_success = True  # At least one domain is successfully cached
         else:
             # Update domain that is not cached or has different IP
-            if change_dns_record(dns, proxy_list, domain=domain, ip=address, record_type=record_type, ttl=ttl):
+            if change_dns_record(dns, proxy_list, domain=domain, ip=address, record_type=record_type, ttl=ttl, line=line):
                 warning("set %s[IPv%s]: %s successfully.", domain, ip_type, address)
                 update_success = True
                 # Cache successful update immediately
@@ -184,8 +184,9 @@ def main():
     else:
         debug("Cache loaded with %d entries.", len(cache))
     ttl = get_config("ttl")  # type: str # type: ignore
-    update_ip("4", cache, dns, ttl, proxy_list)
-    update_ip("6", cache, dns, ttl, proxy_list)
+    line = get_config("line")  # type: str | None # type: ignore
+    update_ip("4", cache, dns, ttl, line, proxy_list)
+    update_ip("6", cache, dns, ttl, line, proxy_list)
 
 
 if __name__ == "__main__":

+ 2 - 0
ddns/util/config.py

@@ -154,6 +154,7 @@ def init_config(description, doc, version, date):
         help="IPv6 domains [IPv6域名列表, 可配置多个域名]",
     )
     parser.add_argument("--ttl", type=int, help="DNS TTL(s) [设置域名解析过期时间]")
+    parser.add_argument("--line", help="DNS line/route [DNS线路设置,如电信、联通、移动等]")
     parser.add_argument(
         "--proxy",
         nargs="*",
@@ -296,6 +297,7 @@ def generate_config(config_path):
         "index4": "default",
         "index6": "default",
         "ttl": None,
+        "line": None,
         "proxy": None,
         "ssl": "auto",
         "log": {"level": "INFO"},

+ 2 - 0
doc/cli.md

@@ -33,6 +33,7 @@ python run.py -h
 | `--index4`      |        | 字符串/数字列表 | IPv4 地址获取方式,支持多种获取方式          |
 | `--index6`      |        | 字符串/数字列表 | IPv6 地址获取方式,支持多种获取方式          |
 | `--ttl`         |        | 整数            | DNS 解析记录的 TTL 时间(秒)                |
+| `--line`        |        | 字符串          | DNS 解析线路,ISP线路选择                   |
 | `--proxy`       |        | 字符串列表      | HTTP 代理设置,支持多代理重复使用参数        |
 | `--cache`       |        | 布尔/字符串     | 是否启用缓存或自定义缓存路径                 |
 | `--ssl`         |        | 字符串          | SSL证书验证方式                             |
@@ -56,6 +57,7 @@ python run.py -h
 | `--index4`   | 数字, default, public, url:, regex:, cmd:, shell: | `--index4 public`, `--index4 "regex:192\\.168\\..*"` |
 | `--index6`   | 数字, default, public, url:, regex:, cmd:, shell: | `--index6 0`, `--index6 public`         |
 | `--ttl`      | 秒数                                         | `--ttl 600`                            |
+| `--line`     | 线路名称                                     | `--line 电信`, `--line telecom`        |
 | `--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` |

+ 2 - 0
doc/env.md

@@ -32,6 +32,7 @@ DDNS 支持通过环境变量进行配置,环境变量的优先级为:**[命
 | `DDNS_INDEX4` | 数组/字符串/数字 | `default` | IPv4地址获取方式 |
 | `DDNS_INDEX6` | 数组/字符串/数字 | `default` | IPv6地址获取方式 |
 | `DDNS_TTL` | 整数 | 无 | DNS解析TTL时间(秒) |
+| `DDNS_LINE` | 字符串 | 无 | DNS解析线路,ISP线路选择 |
 | `DDNS_PROXY` | 数组/字符串 | 无 | HTTP代理设置 |
 | `DDNS_CACHE` | 布尔值/字符串 | `true` | 缓存设置 |
 | `DDNS_LOG_LEVEL` | 字符串 | `INFO` | 日志级别 |
@@ -48,6 +49,7 @@ DDNS 支持通过环境变量进行配置,环境变量的优先级为:**[命
 | `DDNS_IPV6` | JSON数组, 逗号分隔的字符串 | `export DDNS_IPV6="example.com,ipv6.example.com"` |
 | `DDNS_INDEX4` | 数字、default、public、url:、regex:、cmd:、shell: | `export DDNS_INDEX4='["public", "regex:192\\.168\\..*"]'` |
 | `DDNS_INDEX6` | 数字、default、public、url:、regex:、cmd:、shell: | `export DDNS_INDEX6="public"` |
+| `DDNS_LINE` | 线路名称,如默认、电信、联通、移动等 | `export DDNS_LINE="电信"` |
 | `DDNS_PROXY` | IP:端口, DIRECT, 分号分隔的列表 | `export DDNS_PROXY="127.0.0.1:1080;DIRECT"` |
 | `DDNS_CACHE` | true/false, 文件路径 | `export DDNS_CACHE="/path/to/cache.json"` |
 | `DDNS_LOG_LEVEL` | DEBUG, INFO, WARNING, ERROR, CRITICAL | `export DDNS_LOG_LEVEL="DEBUG"` |

+ 23 - 0
doc/json.md

@@ -46,6 +46,7 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
 | index4   | string\|int\|array |  否  | `"default"` |   IPv4获取方式    | 详见下方说明                                                                                               |
 | index6   | string\|int\|array |  否  | `"default"` |   IPv6获取方式    | 详见下方说明                                                                                               |
 |  ttl     |       number       |  否  |   `null`    | DNS解析TTL时间     | 单位为秒,不设置则采用DNS默认策略                                                                          |
+|  line    |       string       |  否  |   `null`    | DNS解析线路       | ISP线路选择,支持的值视DNS服务商而定,如:`"默认"`、`"电信"`、`"联通"`、`"移动"`等                          |
 |  proxy   | string\|array      |  否  |     无      | HTTP代理          | 多代理逐个尝试直到成功,`DIRECT`为直连                                                                      |
 |   ssl    | string\|boolean    |  否  |  `"auto"`   | SSL证书验证方式    | `true`(强制验证)、`false`(禁用验证)、`"auto"`(自动降级)或自定义CA证书文件路径                          |
 |  debug   |       boolean      |  否  |   `false`   | 是否开启调试       | 等同于设置log.level=DEBUG,配置文件中设置此字段无效,仅命令行参数`--debug`有效                             |
@@ -113,6 +114,28 @@ DDNS配置文件遵循JSON模式(Schema),推荐在配置文件中添加`$schem
 }
 ```
 
+### 带线路配置的DNS服务
+
+国内DNS服务商通常支持按运营商线路解析,可以为不同运营商的用户返回不同的IP地址:
+
+```json
+{
+  "$schema": "https://ddns.newfuture.cc/schema/v4.0.json",
+  "id": "12345",
+  "token": "mytokenkey", 
+  "dns": "dnspod",
+  "ipv4": ["telecom.example.com"],
+  "ttl": 600,
+  "line": "电信"
+}
+```
+
+**支持线路的DNS服务商:**
+- **阿里云DNS (alidns)**:`"default"`、`"telecom"`、`"unicom"`、`"mobile"`、`"oversea"`等
+- **DNSPod (dnspod)**:`"默认"`、`"电信"`、`"联通"`、`"移动"`等
+- **腾讯云 (tencentcloud)**:`"默认"`、`"电信"`、`"联通"`、`"移动"`等
+- **华为云 (huaweidns)**:通过额外参数支持线路配置
+
 ### 高级配置示例
 
 ```json

+ 3 - 1
doc/providers/alidns.md

@@ -54,7 +54,9 @@
     "dns": "alidns",
     "ipv6": ["dynamic.mydomain.com"],
     "ttl": 600,
-    "record_type": "A"
+    "record_type": "A",
+    "line": "telecom"
+}
 }
 ```
 

+ 13 - 0
doc/providers/dnspod.en.md

@@ -101,6 +101,19 @@ This method uses your DNSPod account email and password. It is still supported b
 }
 ```
 
+### Example 3: Configuration with Line Settings
+
+```json
+{
+  "id": "123456",
+  "token": "abcdef1234567890abcdef1234567890abcdef12",
+  "dns": "dnspod",
+  "ipv4": ["home.example.com"],
+  "ttl": 600,
+  "line": "电信"
+}
+```
+
 ## Optional Configuration Parameters
 
 ### TTL (Time To Live)

+ 13 - 0
doc/providers/dnspod.md

@@ -76,6 +76,19 @@ API Token 方式更安全,是 DNSPod 推荐的集成方法。
 }
 ```
 
+### 示例 3:带线路配置
+
+```json
+{
+    "id": "123456",
+    "token": "abcdef1234567890abcdef1234567890abcdef12",
+    "dns": "dnspod",
+    "ipv4": ["home.example.com"],
+    "ttl": 600,
+    "line": "电信"
+}
+```
+
 ## 可选参数
 
 ### TTL(生存时间)

+ 20 - 0
schema/v4.0.json

@@ -162,6 +162,26 @@
         null
       ]
     },
+    "line": {
+      "$id": "/properties/line",
+      "type": [
+        "string",
+        "null"
+      ],
+      "title": "DNS Line/Route",
+      "description": "DNS线路设置,如电信、联通、移动等,支持中文和英文线路名称",
+      "default": null,
+      "examples": [
+        "telecom",
+        "电信",
+        "unicom",
+        "联通",
+        "mobile",
+        "移动",
+        "default",
+        "默认"
+      ]
+    },
     "proxy": {
       "$id": "/properties/proxy",
       "type": [

+ 43 - 0
tests/test_provider_alidns.py

@@ -343,6 +343,49 @@ class TestAlidnsProvider(BaseProviderTestCase):
             )
             self.assertTrue(result)
 
+    def test_line_configuration_support(self):
+        """Test that AlidnsProvider supports line configuration"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        old_record = {"RecordId": "123456", "RR": "www", "Line": "default"}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": "123456"}
+
+            # Test with custom line parameter
+            result = provider._update_record("example.com", old_record, "5.6.7.8", "A", 600, "telecom", {})
+
+            mock_request.assert_called_once_with(
+                "UpdateDomainRecord",
+                RecordId="123456",
+                Value="5.6.7.8",
+                RR="www",
+                Type="A",
+                TTL=600,
+                Line="telecom",  # Should use the provided line parameter
+            )
+            self.assertTrue(result)
+
+    def test_create_record_with_line(self):
+        """Test _create_record method with line parameter"""
+        provider = AlidnsProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": "123456"}
+
+            result = provider._create_record("example.com", "www", "example.com", "1.2.3.4", "A", 300, "unicom", {})
+
+            mock_request.assert_called_once_with(
+                "AddDomainRecord",
+                DomainName="example.com",
+                RR="www",
+                Value="1.2.3.4",
+                Type="A",
+                TTL=300,
+                Line="unicom",
+            )
+            self.assertTrue(result)
+
 
 class TestAlidnsProviderIntegration(BaseProviderTestCase):
     """Integration test cases for AlidnsProvider - testing with minimal mocking"""

+ 34 - 0
tests/test_provider_dnspod.py

@@ -361,6 +361,40 @@ class TestDnspodProvider(BaseProviderTestCase):
             self.assertFalse(result)
             self.provider.logger.error.assert_called_once()
 
+    def test_line_configuration_support(self):
+        """Test that DnspodProvider supports line configuration"""
+        with patch("ddns.provider.dnspod.DnspodProvider._request") as mock_request:
+            mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.1"}}
+
+            # Test create record with line parameter
+            result = self.provider._create_record("zone123", "www", "example.com", "192.168.1.1", "A", 600, "电信", {})
+
+            self.assertTrue(result)
+            mock_request.assert_called_once_with(
+                "Record.Create",
+                extra={},
+                domain_id="zone123",
+                sub_domain="www",
+                value="192.168.1.1",
+                record_type="A",
+                record_line="电信",
+                ttl=600,
+            )
+
+    def test_update_record_with_custom_line(self):
+        """Test _update_record method with custom line parameter"""
+        with patch("ddns.provider.dnspod.DnspodProvider._request") as mock_request:
+            mock_request.return_value = {"record": {"id": "12345", "name": "www", "value": "192.168.1.2"}}
+
+            old_record = {"id": "12345", "name": "www", "line": "默认"}
+
+            # Test with custom line parameter
+            result = self.provider._update_record("zone123", old_record, "192.168.1.2", "A", 300, "联通", {})
+
+            self.assertTrue(result)
+            call_args = mock_request.call_args[1]
+            self.assertEqual(call_args["record_line"], "联通")
+
 
 class TestDnspodProviderIntegration(BaseProviderTestCase):
     """DNSPod Provider 集成测试类"""

+ 29 - 0
tests/test_provider_huaweidns.py

@@ -295,6 +295,35 @@ class TestHuaweiDNSProvider(BaseProviderTestCase):
 
             self.assertFalse(result)
 
+    def test_line_configuration_support(self):
+        """Test that HuaweiDNSProvider supports line configuration"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123456"}
+
+            # Test create record with line parameter (line is passed as extra parameter for Huawei)
+            result = provider._create_record("zone123", "www", "example.com", "1.2.3.4", "A", 300, "telecom", {})
+
+            # For Huawei DNS, line can be passed as extra parameter
+            self.assertTrue(result)
+            mock_request.assert_called_once()
+
+    def test_update_record_with_line(self):
+        """Test _update_record method with line parameter"""
+        provider = HuaweiDNSProvider(self.auth_id, self.auth_token)
+
+        old_record = {"id": "rec123", "name": "www.example.com."}
+
+        with patch.object(provider, "_request") as mock_request:
+            mock_request.return_value = {"id": "rec123"}
+
+            # Test with line parameter (line is handled as needed for different DNS providers)
+            result = provider._update_record("zone123", old_record, "5.6.7.8", "A", 600, "unicom", {})
+
+            self.assertTrue(result)
+            mock_request.assert_called_once()
+
 
 class TestHuaweiDNSProviderIntegration(BaseProviderTestCase):
     """Integration test cases for HuaweiDNSProvider - testing with minimal mocking"""

+ 52 - 0
tests/test_provider_tencentcloud.py

@@ -347,6 +347,58 @@ class TestTencentCloudProvider(BaseProviderTestCase):
         self.assertTrue(result)
         self.assertEqual(mock_http.call_count, 3)
 
+    def test_line_configuration_support(self):
+        """Test that TencentCloudProvider supports line configuration"""
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": 123456}
+
+            # Test create record with line parameter
+            result = self.provider._create_record(12345678, "www", "example.com", "1.2.3.4", "A", 300, "电信", {})
+
+            self.assertTrue(result)
+            mock_request.assert_called_once_with(
+                "CreateRecord",
+                Domain="example.com",
+                DomainId=12345678,
+                SubDomain="www",
+                RecordType="A",
+                Value="1.2.3.4",
+                RecordLine="电信",
+                TTL=300,
+                Remark="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+            )
+
+    def test_update_record_with_line(self):
+        """Test _update_record method with line parameter"""
+        old_record = {
+            "RecordId": 123456,
+            "Name": "www",
+            "Line": "默认",
+            "Domain": "example.com",
+            "DomainId": 12345678
+        }
+
+        with patch.object(self.provider, "_request") as mock_request:
+            mock_request.return_value = {"RecordId": 123456}
+
+            # Test with custom line parameter - note that TencentCloud uses old_record.Line when line parameter
+            # doesn't override
+            result = self.provider._update_record(12345678, old_record, "5.6.7.8", "A", 600, "联通", {})
+
+            self.assertTrue(result)
+            mock_request.assert_called_once_with(
+                "ModifyRecord",
+                Domain="example.com",
+                DomainId=12345678,
+                SubDomain="www",
+                RecordId=123456,
+                RecordType="A",
+                RecordLine="默认",  # TencentCloud uses old_record line when available
+                Value="5.6.7.8",
+                TTL=600,
+                Remark="Managed by [DDNS v0.0.0](https://ddns.newfuture.cc)",
+            )
+
 
 class TestTencentCloudProviderIntegration(BaseProviderTestCase):
     """Integration tests for TencentCloudProvider"""