certbot-dns-tencent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- certbot_dns_tencent-0.1.0/LICENSE +21 -0
- certbot_dns_tencent-0.1.0/PKG-INFO +104 -0
- certbot_dns_tencent-0.1.0/README.md +65 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent/__init__.py +5 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent/dns_tencent.py +197 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent/tencent_client.py +148 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent.egg-info/PKG-INFO +104 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent.egg-info/SOURCES.txt +13 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent.egg-info/dependency_links.txt +1 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent.egg-info/entry_points.txt +2 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent.egg-info/requires.txt +3 -0
- certbot_dns_tencent-0.1.0/certbot_dns_tencent.egg-info/top_level.txt +1 -0
- certbot_dns_tencent-0.1.0/pyproject.toml +65 -0
- certbot_dns_tencent-0.1.0/setup.cfg +4 -0
- certbot_dns_tencent-0.1.0/tests/test_tencent_dns.py +361 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 TiyeeJiang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: certbot-dns-tencent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 腾讯云 DNSPod 插件,用于 Certbot 的 DNS-01 验证
|
|
5
|
+
Author-email: tiyee <tiyee@live.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/iDigimon/certbot-dns-tencent
|
|
8
|
+
Project-URL: Repository, https://github.com/iDigimon/certbot-dns-tencent
|
|
9
|
+
Project-URL: Issues, https://github.com/iDigimon/certbot-dns-tencent/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/iDigimon/certbot-dns-tencent/blob/main/README.md
|
|
11
|
+
Keywords: certbot-dns,certbot-plugin,certbot-dns-plugin,certbot-dns-authenticator,certbot-tencent,dnspod
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Plugins
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
27
|
+
Classifier: Topic :: Security
|
|
28
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
29
|
+
Classifier: Topic :: System :: Networking
|
|
30
|
+
Classifier: Topic :: System :: Systems Administration
|
|
31
|
+
Classifier: Topic :: Utilities
|
|
32
|
+
Requires-Python: >=3.8
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
Requires-Dist: certbot>=3.0.0
|
|
36
|
+
Requires-Dist: zope
|
|
37
|
+
Requires-Dist: tencentcloud-sdk-python-dnspod>=3.1.0
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# Certbot DNS 腾讯云插件
|
|
41
|
+
|
|
42
|
+
这是一个用于 Certbot 的腾讯云 DNSPod 插件,支持通过腾讯云 DNSPod API 进行 DNS-01 验证来获取 SSL 证书。支持泛域名。
|
|
43
|
+
|
|
44
|
+
## 功能特性
|
|
45
|
+
|
|
46
|
+
- 支持通过腾讯云 DNSPod API 自动管理 DNS 记录
|
|
47
|
+
- 支持泛域名
|
|
48
|
+
- 支持 certbot 3 版本(更低版本未测试)
|
|
49
|
+
- 兼容 Python 3.8+ 版本
|
|
50
|
+
- 支持 DNS-01 验证方式
|
|
51
|
+
- 自动清理临时 DNS 记录
|
|
52
|
+
- 完善的错误处理和日志记录
|
|
53
|
+
|
|
54
|
+
## 安装
|
|
55
|
+
|
|
56
|
+
### 从 PyPI 安装
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip3 install certbot-dns-tencent
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 配置文件
|
|
63
|
+
|
|
64
|
+
```ini
|
|
65
|
+
dns_tencent_secret_id = ??
|
|
66
|
+
dns_tencent_secret_key = ??
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
修改配置文件权限(假如文件是 `~/tencent.ini`):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
chmod 600 ~/tencent.ini
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 运行
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
certbot certonly \
|
|
79
|
+
--authenticator dns-tencent \
|
|
80
|
+
--dns-tencent-credentials ~/tencent.ini \
|
|
81
|
+
--dns-tencent-propagation-seconds 30 \
|
|
82
|
+
-d "*.example.com" \
|
|
83
|
+
-d "example.com"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 腾讯云权限配置
|
|
87
|
+
|
|
88
|
+
确保您的腾讯云 API 密钥(SecretId / SecretKey)具有 DNSPod 的管理权限:
|
|
89
|
+
|
|
90
|
+
`QcloudDNSPodFullAccess`,或自定义策略包含以下操作:
|
|
91
|
+
|
|
92
|
+
- `dnspod:CreateRecord`
|
|
93
|
+
- `dnspod:DeleteRecord`
|
|
94
|
+
- `dnspod:DescribeRecordList`
|
|
95
|
+
|
|
96
|
+
## 单元测试
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m unittest discover -s tests -p "test_*.py" -v
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 自动部署
|
|
103
|
+
|
|
104
|
+
此程序只是 certbot 的 DNS 验证插件,如果需要自动运行申请部署,可以使用 [AutoCert](https://github.com/tiyee/AutoCert)。
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Certbot DNS 腾讯云插件
|
|
2
|
+
|
|
3
|
+
这是一个用于 Certbot 的腾讯云 DNSPod 插件,支持通过腾讯云 DNSPod API 进行 DNS-01 验证来获取 SSL 证书。支持泛域名。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- 支持通过腾讯云 DNSPod API 自动管理 DNS 记录
|
|
8
|
+
- 支持泛域名
|
|
9
|
+
- 支持 certbot 3 版本(更低版本未测试)
|
|
10
|
+
- 兼容 Python 3.8+ 版本
|
|
11
|
+
- 支持 DNS-01 验证方式
|
|
12
|
+
- 自动清理临时 DNS 记录
|
|
13
|
+
- 完善的错误处理和日志记录
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
### 从 PyPI 安装
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip3 install certbot-dns-tencent
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 配置文件
|
|
24
|
+
|
|
25
|
+
```ini
|
|
26
|
+
dns_tencent_secret_id = ??
|
|
27
|
+
dns_tencent_secret_key = ??
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
修改配置文件权限(假如文件是 `~/tencent.ini`):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
chmod 600 ~/tencent.ini
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 运行
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
certbot certonly \
|
|
40
|
+
--authenticator dns-tencent \
|
|
41
|
+
--dns-tencent-credentials ~/tencent.ini \
|
|
42
|
+
--dns-tencent-propagation-seconds 30 \
|
|
43
|
+
-d "*.example.com" \
|
|
44
|
+
-d "example.com"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 腾讯云权限配置
|
|
48
|
+
|
|
49
|
+
确保您的腾讯云 API 密钥(SecretId / SecretKey)具有 DNSPod 的管理权限:
|
|
50
|
+
|
|
51
|
+
`QcloudDNSPodFullAccess`,或自定义策略包含以下操作:
|
|
52
|
+
|
|
53
|
+
- `dnspod:CreateRecord`
|
|
54
|
+
- `dnspod:DeleteRecord`
|
|
55
|
+
- `dnspod:DescribeRecordList`
|
|
56
|
+
|
|
57
|
+
## 单元测试
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
python -m unittest discover -s tests -p "test_*.py" -v
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 自动部署
|
|
64
|
+
|
|
65
|
+
此程序只是 certbot 的 DNS 验证插件,如果需要自动运行申请部署,可以使用 [AutoCert](https://github.com/tiyee/AutoCert)。
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
腾讯云 DNSPod 认证器插件
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any, Callable, Optional
|
|
8
|
+
|
|
9
|
+
import zope.interface
|
|
10
|
+
from certbot import errors, interfaces
|
|
11
|
+
from certbot.plugins import dns_common
|
|
12
|
+
|
|
13
|
+
from .tencent_client import TencentCloudDNSClient
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@zope.interface.implementer(interfaces.IAuthenticator)
|
|
19
|
+
@zope.interface.provider(interfaces.IPluginFactory)
|
|
20
|
+
class Authenticator(dns_common.DNSAuthenticator):
|
|
21
|
+
"""腾讯云 DNSPod 认证器"""
|
|
22
|
+
|
|
23
|
+
description = "通过腾讯云 DNSPod API 获取证书,使用 DNS-01 验证"
|
|
24
|
+
ttl = 600 # DNS 记录 TTL
|
|
25
|
+
|
|
26
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
27
|
+
super().__init__(*args, **kwargs)
|
|
28
|
+
self.credentials: Optional[dns_common.CredentialsConfiguration] = None
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def add_parser_arguments(cls, add: Callable[..., None], default_propagation_seconds: int = 30) -> None:
|
|
32
|
+
"""添加命令行参数"""
|
|
33
|
+
super().add_parser_arguments(add, default_propagation_seconds)
|
|
34
|
+
add("credentials", help="腾讯云 API 凭证文件路径")
|
|
35
|
+
|
|
36
|
+
def more_info(self) -> str:
|
|
37
|
+
"""返回插件的更多信息"""
|
|
38
|
+
return (
|
|
39
|
+
"此插件通过腾讯云 DNSPod API 配置 DNS 记录来完成 DNS-01 验证。"
|
|
40
|
+
"需要在凭证文件中配置腾讯云 SecretId 和 SecretKey。"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
def _setup_credentials(self) -> None:
|
|
44
|
+
"""设置 API 凭证"""
|
|
45
|
+
self.credentials = self._configure_credentials(
|
|
46
|
+
"credentials",
|
|
47
|
+
"腾讯云 API 凭证文件路径",
|
|
48
|
+
{
|
|
49
|
+
"secret_id": "腾讯云 SecretId",
|
|
50
|
+
"secret_key": "腾讯云 SecretKey",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def _perform(self, domain: str, validation_name: str, validation: str) -> None:
|
|
55
|
+
"""
|
|
56
|
+
添加 DNS TXT 记录进行验证
|
|
57
|
+
|
|
58
|
+
:param domain: 要验证的域名
|
|
59
|
+
:param validation_name: 验证记录名称
|
|
60
|
+
:param validation: 验证值
|
|
61
|
+
"""
|
|
62
|
+
self._get_tencent_client().add_txt_record(validation_name, validation)
|
|
63
|
+
|
|
64
|
+
def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
|
|
65
|
+
"""
|
|
66
|
+
清理 DNS TXT 记录
|
|
67
|
+
|
|
68
|
+
:param domain: 要验证的域名
|
|
69
|
+
:param validation_name: 验证记录名称
|
|
70
|
+
:param validation: 验证值
|
|
71
|
+
"""
|
|
72
|
+
self._get_tencent_client().del_txt_record(validation_name, validation)
|
|
73
|
+
|
|
74
|
+
def _get_tencent_client(self) -> "_TencentCloudDNSHelper":
|
|
75
|
+
"""获取腾讯云 DNS 客户端"""
|
|
76
|
+
if not self.credentials:
|
|
77
|
+
raise errors.Error("凭证未配置")
|
|
78
|
+
|
|
79
|
+
secret_id = self.credentials.conf("secret_id")
|
|
80
|
+
secret_key = self.credentials.conf("secret_key")
|
|
81
|
+
|
|
82
|
+
return _TencentCloudDNSHelper(secret_id, secret_key, self.ttl)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_rr_from_record_name(record_name: str, domain: str) -> str:
|
|
86
|
+
"""
|
|
87
|
+
从记录名称获取主机记录(DNSPod 的 SubDomain)
|
|
88
|
+
|
|
89
|
+
:param record_name: 完整的记录名称
|
|
90
|
+
:param domain: 根域名
|
|
91
|
+
:return: 主机记录
|
|
92
|
+
"""
|
|
93
|
+
if record_name.endswith("." + domain):
|
|
94
|
+
rr = record_name[: -len(domain) - 1]
|
|
95
|
+
else:
|
|
96
|
+
rr = record_name
|
|
97
|
+
|
|
98
|
+
return rr
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _TencentCloudDNSHelper:
|
|
102
|
+
"""腾讯云 DNS 辅助类"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, secret_id: str, secret_key: str, ttl: int):
|
|
105
|
+
"""
|
|
106
|
+
初始化 DNS 辅助类
|
|
107
|
+
|
|
108
|
+
:param secret_id: 腾讯云 SecretId
|
|
109
|
+
:param secret_key: 腾讯云 SecretKey
|
|
110
|
+
:param ttl: DNS 记录 TTL
|
|
111
|
+
"""
|
|
112
|
+
self.client = TencentCloudDNSClient(secret_id, secret_key)
|
|
113
|
+
self.ttl = ttl
|
|
114
|
+
self._record_ids = {} # 存储添加的记录 ID,用于清理
|
|
115
|
+
|
|
116
|
+
def add_txt_record(self, record_name: str, record_content: str) -> None:
|
|
117
|
+
"""
|
|
118
|
+
添加 TXT 记录
|
|
119
|
+
|
|
120
|
+
:param record_name: 记录名称
|
|
121
|
+
:param record_content: 记录内容
|
|
122
|
+
"""
|
|
123
|
+
domain = self._get_domain_from_record_name(record_name)
|
|
124
|
+
rr = _get_rr_from_record_name(record_name, domain)
|
|
125
|
+
|
|
126
|
+
logger.debug(f"添加 TXT 记录: {record_name} -> {record_content}")
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
# 检查是否已存在相同的记录
|
|
130
|
+
existing_records = self.client.get_domain_records(domain, rr, "TXT")
|
|
131
|
+
for record in existing_records:
|
|
132
|
+
if record["value"] == record_content:
|
|
133
|
+
logger.info(f"TXT 记录已存在: {record_name}")
|
|
134
|
+
self._record_ids[record_name] = record["record_id"]
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
# 添加新记录
|
|
138
|
+
record_id = self.client.add_domain_record(domain, rr, "TXT", record_content, self.ttl)
|
|
139
|
+
self._record_ids[record_name] = record_id
|
|
140
|
+
|
|
141
|
+
# 等待 DNS 传播
|
|
142
|
+
time.sleep(10)
|
|
143
|
+
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.error(f"添加 TXT 记录失败: {e}")
|
|
146
|
+
raise errors.PluginError(f"添加 TXT 记录失败: {e}")
|
|
147
|
+
|
|
148
|
+
def del_txt_record(self, record_name: str, record_content: str) -> None:
|
|
149
|
+
"""
|
|
150
|
+
删除 TXT 记录
|
|
151
|
+
|
|
152
|
+
:param record_name: 记录名称
|
|
153
|
+
:param record_content: 记录内容
|
|
154
|
+
"""
|
|
155
|
+
logger.debug(f"删除 TXT 记录: {record_name}")
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
# 优先使用存储的记录 ID 删除
|
|
159
|
+
if record_name in self._record_ids:
|
|
160
|
+
record_id = self._record_ids[record_name]
|
|
161
|
+
domain = self._get_domain_from_record_name(record_name)
|
|
162
|
+
self.client.delete_domain_record(domain, record_id)
|
|
163
|
+
del self._record_ids[record_name]
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
# 如果没有存储的记录 ID,尝试查找并删除
|
|
167
|
+
domain = self._get_domain_from_record_name(record_name)
|
|
168
|
+
rr = _get_rr_from_record_name(record_name, domain)
|
|
169
|
+
|
|
170
|
+
existing_records = self.client.get_domain_records(domain, rr, "TXT")
|
|
171
|
+
for record in existing_records:
|
|
172
|
+
if record["value"] == record_content:
|
|
173
|
+
self.client.delete_domain_record(domain, record["record_id"])
|
|
174
|
+
break
|
|
175
|
+
else:
|
|
176
|
+
logger.warning(f"未找到要删除的 TXT 记录: {record_name}")
|
|
177
|
+
|
|
178
|
+
except Exception as e:
|
|
179
|
+
logger.error(f"删除 TXT 记录失败: {e}")
|
|
180
|
+
# 清理时的错误不应该导致程序失败
|
|
181
|
+
logger.warning(f"清理 TXT 记录时出错,但不影响证书获取: {e}")
|
|
182
|
+
|
|
183
|
+
@staticmethod
|
|
184
|
+
def _get_domain_from_record_name(record_name: str) -> str:
|
|
185
|
+
"""
|
|
186
|
+
从记录名称获取域名
|
|
187
|
+
|
|
188
|
+
:param record_name: 完整的记录名称
|
|
189
|
+
:return: 根域名
|
|
190
|
+
"""
|
|
191
|
+
# 移除 _acme-challenge. 前缀
|
|
192
|
+
if record_name.startswith("_acme-challenge."):
|
|
193
|
+
domain = record_name[16:] # 移除 "_acme-challenge." 前缀
|
|
194
|
+
else:
|
|
195
|
+
domain = record_name
|
|
196
|
+
|
|
197
|
+
return TencentCloudDNSClient.get_root_domain(domain)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
腾讯云 DNSPod API 客户端
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any, Dict, List
|
|
7
|
+
|
|
8
|
+
from tencentcloud.common import credential
|
|
9
|
+
from tencentcloud.common.profile.client_profile import ClientProfile
|
|
10
|
+
from tencentcloud.common.profile.http_profile import HttpProfile
|
|
11
|
+
from tencentcloud.dnspod.v20210323 import dnspod_client, models
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# DNSPod 默认解析线路名称
|
|
16
|
+
DEFAULT_RECORD_LINE = "默认"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TencentCloudDNSClient:
|
|
20
|
+
"""腾讯云 DNSPod 客户端"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, secret_id: str, secret_key: str):
|
|
23
|
+
"""
|
|
24
|
+
初始化腾讯云 DNSPod 客户端
|
|
25
|
+
|
|
26
|
+
:param secret_id: 腾讯云 SecretId
|
|
27
|
+
:param secret_key: 腾讯云 SecretKey
|
|
28
|
+
"""
|
|
29
|
+
self.secret_id = secret_id
|
|
30
|
+
self.secret_key = secret_key
|
|
31
|
+
self.client = self._create_client()
|
|
32
|
+
|
|
33
|
+
def _create_client(self) -> dnspod_client.DnspodClient:
|
|
34
|
+
"""创建 DNSPod 客户端"""
|
|
35
|
+
cred = credential.Credential(self.secret_id, self.secret_key)
|
|
36
|
+
http_profile = HttpProfile(endpoint="dnspod.tencentcloudapi.com")
|
|
37
|
+
client_profile = ClientProfile(httpProfile=http_profile)
|
|
38
|
+
# DNSPod 是非区域性服务,region 传空字符串即可
|
|
39
|
+
return dnspod_client.DnspodClient(cred, "", client_profile)
|
|
40
|
+
|
|
41
|
+
def get_domain_records(
|
|
42
|
+
self, domain_name: str, sub_domain: str, record_type: str = "TXT"
|
|
43
|
+
) -> List[Dict[str, Any]]:
|
|
44
|
+
"""
|
|
45
|
+
获取域名记录
|
|
46
|
+
|
|
47
|
+
:param domain_name: 主域名
|
|
48
|
+
:param sub_domain: 主机记录
|
|
49
|
+
:param record_type: 记录类型
|
|
50
|
+
:return: 记录列表
|
|
51
|
+
"""
|
|
52
|
+
try:
|
|
53
|
+
# 腾讯云 SDK 的请求对象构造函数无参,需先实例化再逐个赋值
|
|
54
|
+
request = models.DescribeRecordListRequest()
|
|
55
|
+
request.Domain = domain_name
|
|
56
|
+
request.SubDomain = sub_domain
|
|
57
|
+
request.RecordType = record_type
|
|
58
|
+
# 查无记录时不报错,便于做幂等的“先查后加”判断
|
|
59
|
+
request.ErrorOnEmpty = "no"
|
|
60
|
+
response = self.client.DescribeRecordList(request)
|
|
61
|
+
|
|
62
|
+
if response.RecordList:
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
"record_id": record.RecordId,
|
|
66
|
+
"sub_domain": record.Name,
|
|
67
|
+
"type": record.Type,
|
|
68
|
+
"value": record.Value,
|
|
69
|
+
"ttl": record.TTL,
|
|
70
|
+
"line": record.Line,
|
|
71
|
+
}
|
|
72
|
+
for record in response.RecordList
|
|
73
|
+
]
|
|
74
|
+
return []
|
|
75
|
+
except Exception as e:
|
|
76
|
+
logger.error(f"获取域名记录失败: {e}")
|
|
77
|
+
raise
|
|
78
|
+
|
|
79
|
+
def add_domain_record(
|
|
80
|
+
self,
|
|
81
|
+
domain_name: str,
|
|
82
|
+
sub_domain: str,
|
|
83
|
+
record_type: str,
|
|
84
|
+
value: str,
|
|
85
|
+
ttl: int = 600,
|
|
86
|
+
) -> int:
|
|
87
|
+
"""
|
|
88
|
+
添加域名记录
|
|
89
|
+
|
|
90
|
+
:param domain_name: 主域名
|
|
91
|
+
:param sub_domain: 主机记录
|
|
92
|
+
:param record_type: 记录类型
|
|
93
|
+
:param value: 记录值
|
|
94
|
+
:param ttl: TTL 值
|
|
95
|
+
:return: 记录 ID
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
request = models.CreateRecordRequest()
|
|
99
|
+
request.Domain = domain_name
|
|
100
|
+
request.SubDomain = sub_domain
|
|
101
|
+
request.RecordType = record_type
|
|
102
|
+
request.RecordLine = DEFAULT_RECORD_LINE
|
|
103
|
+
request.Value = value
|
|
104
|
+
request.TTL = ttl
|
|
105
|
+
response = self.client.CreateRecord(request)
|
|
106
|
+
|
|
107
|
+
if response.RecordId is not None:
|
|
108
|
+
logger.info(f"成功添加 DNS 记录: {sub_domain}.{domain_name} -> {value}")
|
|
109
|
+
return response.RecordId
|
|
110
|
+
else:
|
|
111
|
+
raise Exception("添加记录失败,未返回记录 ID")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.error(f"添加域名记录失败: {e}")
|
|
114
|
+
raise
|
|
115
|
+
|
|
116
|
+
def delete_domain_record(self, domain_name: str, record_id: int) -> bool:
|
|
117
|
+
"""
|
|
118
|
+
删除域名记录
|
|
119
|
+
|
|
120
|
+
:param domain_name: 主域名
|
|
121
|
+
:param record_id: 记录 ID
|
|
122
|
+
:return: 是否成功
|
|
123
|
+
"""
|
|
124
|
+
try:
|
|
125
|
+
request = models.DeleteRecordRequest()
|
|
126
|
+
request.Domain = domain_name
|
|
127
|
+
request.RecordId = record_id
|
|
128
|
+
self.client.DeleteRecord(request)
|
|
129
|
+
|
|
130
|
+
logger.info(f"成功删除 DNS 记录: {record_id}")
|
|
131
|
+
return True
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.error(f"删除域名记录失败: {e}")
|
|
134
|
+
raise
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def get_root_domain(domain: str) -> str:
|
|
138
|
+
"""
|
|
139
|
+
获取根域名
|
|
140
|
+
|
|
141
|
+
:param domain: 完整域名
|
|
142
|
+
:return: 根域名
|
|
143
|
+
"""
|
|
144
|
+
# 简单的根域名提取逻辑,取最后两段
|
|
145
|
+
parts = domain.split(".")
|
|
146
|
+
if len(parts) >= 2:
|
|
147
|
+
return ".".join(parts[-2:])
|
|
148
|
+
return domain
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: certbot-dns-tencent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: 腾讯云 DNSPod 插件,用于 Certbot 的 DNS-01 验证
|
|
5
|
+
Author-email: tiyee <tiyee@live.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/iDigimon/certbot-dns-tencent
|
|
8
|
+
Project-URL: Repository, https://github.com/iDigimon/certbot-dns-tencent
|
|
9
|
+
Project-URL: Issues, https://github.com/iDigimon/certbot-dns-tencent/issues
|
|
10
|
+
Project-URL: Documentation, https://github.com/iDigimon/certbot-dns-tencent/blob/main/README.md
|
|
11
|
+
Keywords: certbot-dns,certbot-plugin,certbot-dns-plugin,certbot-dns-authenticator,certbot-tencent,dnspod
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Plugins
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Intended Audience :: System Administrators
|
|
16
|
+
Classifier: Operating System :: POSIX
|
|
17
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
18
|
+
Classifier: Programming Language :: Python
|
|
19
|
+
Classifier: Programming Language :: Python :: 3
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
27
|
+
Classifier: Topic :: Security
|
|
28
|
+
Classifier: Topic :: System :: Installation/Setup
|
|
29
|
+
Classifier: Topic :: System :: Networking
|
|
30
|
+
Classifier: Topic :: System :: Systems Administration
|
|
31
|
+
Classifier: Topic :: Utilities
|
|
32
|
+
Requires-Python: >=3.8
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
License-File: LICENSE
|
|
35
|
+
Requires-Dist: certbot>=3.0.0
|
|
36
|
+
Requires-Dist: zope
|
|
37
|
+
Requires-Dist: tencentcloud-sdk-python-dnspod>=3.1.0
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# Certbot DNS 腾讯云插件
|
|
41
|
+
|
|
42
|
+
这是一个用于 Certbot 的腾讯云 DNSPod 插件,支持通过腾讯云 DNSPod API 进行 DNS-01 验证来获取 SSL 证书。支持泛域名。
|
|
43
|
+
|
|
44
|
+
## 功能特性
|
|
45
|
+
|
|
46
|
+
- 支持通过腾讯云 DNSPod API 自动管理 DNS 记录
|
|
47
|
+
- 支持泛域名
|
|
48
|
+
- 支持 certbot 3 版本(更低版本未测试)
|
|
49
|
+
- 兼容 Python 3.8+ 版本
|
|
50
|
+
- 支持 DNS-01 验证方式
|
|
51
|
+
- 自动清理临时 DNS 记录
|
|
52
|
+
- 完善的错误处理和日志记录
|
|
53
|
+
|
|
54
|
+
## 安装
|
|
55
|
+
|
|
56
|
+
### 从 PyPI 安装
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip3 install certbot-dns-tencent
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## 配置文件
|
|
63
|
+
|
|
64
|
+
```ini
|
|
65
|
+
dns_tencent_secret_id = ??
|
|
66
|
+
dns_tencent_secret_key = ??
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
修改配置文件权限(假如文件是 `~/tencent.ini`):
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
chmod 600 ~/tencent.ini
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## 运行
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
certbot certonly \
|
|
79
|
+
--authenticator dns-tencent \
|
|
80
|
+
--dns-tencent-credentials ~/tencent.ini \
|
|
81
|
+
--dns-tencent-propagation-seconds 30 \
|
|
82
|
+
-d "*.example.com" \
|
|
83
|
+
-d "example.com"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 腾讯云权限配置
|
|
87
|
+
|
|
88
|
+
确保您的腾讯云 API 密钥(SecretId / SecretKey)具有 DNSPod 的管理权限:
|
|
89
|
+
|
|
90
|
+
`QcloudDNSPodFullAccess`,或自定义策略包含以下操作:
|
|
91
|
+
|
|
92
|
+
- `dnspod:CreateRecord`
|
|
93
|
+
- `dnspod:DeleteRecord`
|
|
94
|
+
- `dnspod:DescribeRecordList`
|
|
95
|
+
|
|
96
|
+
## 单元测试
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m unittest discover -s tests -p "test_*.py" -v
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 自动部署
|
|
103
|
+
|
|
104
|
+
此程序只是 certbot 的 DNS 验证插件,如果需要自动运行申请部署,可以使用 [AutoCert](https://github.com/tiyee/AutoCert)。
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
certbot_dns_tencent/__init__.py
|
|
5
|
+
certbot_dns_tencent/dns_tencent.py
|
|
6
|
+
certbot_dns_tencent/tencent_client.py
|
|
7
|
+
certbot_dns_tencent.egg-info/PKG-INFO
|
|
8
|
+
certbot_dns_tencent.egg-info/SOURCES.txt
|
|
9
|
+
certbot_dns_tencent.egg-info/dependency_links.txt
|
|
10
|
+
certbot_dns_tencent.egg-info/entry_points.txt
|
|
11
|
+
certbot_dns_tencent.egg-info/requires.txt
|
|
12
|
+
certbot_dns_tencent.egg-info/top_level.txt
|
|
13
|
+
tests/test_tencent_dns.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
certbot_dns_tencent
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "certbot-dns-tencent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "腾讯云 DNSPod 插件,用于 Certbot 的 DNS-01 验证"
|
|
9
|
+
readme = {file = "README.md", content-type = "text/markdown"}
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "tiyee", email = "tiyee@live.com"}
|
|
14
|
+
]
|
|
15
|
+
keywords = [
|
|
16
|
+
"certbot-dns",
|
|
17
|
+
"certbot-plugin",
|
|
18
|
+
"certbot-dns-plugin",
|
|
19
|
+
"certbot-dns-authenticator",
|
|
20
|
+
"certbot-tencent",
|
|
21
|
+
"dnspod"
|
|
22
|
+
]
|
|
23
|
+
classifiers = [
|
|
24
|
+
"Development Status :: 4 - Beta",
|
|
25
|
+
"Environment :: Plugins",
|
|
26
|
+
"License :: OSI Approved :: MIT License",
|
|
27
|
+
"Intended Audience :: System Administrators",
|
|
28
|
+
"Operating System :: POSIX",
|
|
29
|
+
"Operating System :: POSIX :: Linux",
|
|
30
|
+
"Programming Language :: Python",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.8",
|
|
33
|
+
"Programming Language :: Python :: 3.9",
|
|
34
|
+
"Programming Language :: Python :: 3.10",
|
|
35
|
+
"Programming Language :: Python :: 3.11",
|
|
36
|
+
"Programming Language :: Python :: 3.12",
|
|
37
|
+
"Programming Language :: Python :: 3.13",
|
|
38
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
39
|
+
"Topic :: Security",
|
|
40
|
+
"Topic :: System :: Installation/Setup",
|
|
41
|
+
"Topic :: System :: Networking",
|
|
42
|
+
"Topic :: System :: Systems Administration",
|
|
43
|
+
"Topic :: Utilities"
|
|
44
|
+
]
|
|
45
|
+
dependencies = [
|
|
46
|
+
"certbot>=3.0.0",
|
|
47
|
+
"zope",
|
|
48
|
+
"tencentcloud-sdk-python-dnspod>=3.1.0"
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
[project.urls]
|
|
52
|
+
Homepage = "https://github.com/iDigimon/certbot-dns-tencent"
|
|
53
|
+
Repository = "https://github.com/iDigimon/certbot-dns-tencent"
|
|
54
|
+
Issues = "https://github.com/iDigimon/certbot-dns-tencent/issues"
|
|
55
|
+
Documentation = "https://github.com/iDigimon/certbot-dns-tencent/blob/main/README.md"
|
|
56
|
+
|
|
57
|
+
[project.entry-points."certbot.plugins"]
|
|
58
|
+
dns-tencent = "certbot_dns_tencent.dns_tencent:Authenticator"
|
|
59
|
+
|
|
60
|
+
[tool.setuptools]
|
|
61
|
+
include-package-data = true
|
|
62
|
+
|
|
63
|
+
[tool.setuptools.packages.find]
|
|
64
|
+
include = ["certbot_dns_tencent*"]
|
|
65
|
+
exclude = ["tests*"]
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
"""
|
|
2
|
+
certbot-dns-tencent 单元测试
|
|
3
|
+
|
|
4
|
+
使用 unittest + unittest.mock,mock 掉腾讯云 DNSPod SDK 客户端,
|
|
5
|
+
无需真实凭证即可运行。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import unittest
|
|
9
|
+
from unittest import mock
|
|
10
|
+
|
|
11
|
+
from certbot import errors
|
|
12
|
+
from certbot.plugins import dns_common
|
|
13
|
+
|
|
14
|
+
from certbot_dns_tencent import tencent_client
|
|
15
|
+
from certbot_dns_tencent.dns_tencent import (
|
|
16
|
+
Authenticator,
|
|
17
|
+
_TencentCloudDNSHelper,
|
|
18
|
+
_get_rr_from_record_name,
|
|
19
|
+
)
|
|
20
|
+
from certbot_dns_tencent.tencent_client import (
|
|
21
|
+
DEFAULT_RECORD_LINE,
|
|
22
|
+
TencentCloudDNSClient,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def make_record_item(record_id, value, name, type_="TXT", ttl=600, line="默认"):
|
|
27
|
+
"""构造一个模拟的 DNSPod RecordListItem"""
|
|
28
|
+
item = mock.Mock()
|
|
29
|
+
item.RecordId = record_id
|
|
30
|
+
item.Value = value
|
|
31
|
+
item.Name = name
|
|
32
|
+
item.Type = type_
|
|
33
|
+
item.TTL = ttl
|
|
34
|
+
item.Line = line
|
|
35
|
+
return item
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TencentCloudDNSClientTest(unittest.TestCase):
|
|
39
|
+
"""客户端层测试:TencentCloudDNSClient"""
|
|
40
|
+
|
|
41
|
+
def _patched_client(self):
|
|
42
|
+
"""返回一个内部 SDK 客户端被 mock 的 TencentCloudDNSClient"""
|
|
43
|
+
with mock.patch.object(tencent_client.dnspod_client, "DnspodClient") as m:
|
|
44
|
+
client = TencentCloudDNSClient("sid", "skey")
|
|
45
|
+
return client, m
|
|
46
|
+
|
|
47
|
+
def test_create_client_uses_credentials_and_endpoint(self):
|
|
48
|
+
"""_create_client 应使用传入的凭证与 dnspod endpoint 创建客户端"""
|
|
49
|
+
with mock.patch.object(tencent_client.dnspod_client, "DnspodClient") as mock_ctor:
|
|
50
|
+
TencentCloudDNSClient("my-id", "my-key")
|
|
51
|
+
|
|
52
|
+
# DnspodClient(cred, "", profile) 被调用一次
|
|
53
|
+
self.assertEqual(mock_ctor.call_count, 1)
|
|
54
|
+
args, _ = mock_ctor.call_args
|
|
55
|
+
cred = args[0]
|
|
56
|
+
# region 传空字符串
|
|
57
|
+
self.assertEqual(args[1], "")
|
|
58
|
+
# 凭证内容正确
|
|
59
|
+
self.assertEqual(cred.secret_id, "my-id")
|
|
60
|
+
self.assertEqual(cred.secret_key, "my-key")
|
|
61
|
+
|
|
62
|
+
def test_get_domain_records_parses_list(self):
|
|
63
|
+
"""get_domain_records 应正确解析 RecordList 为 dict 列表"""
|
|
64
|
+
client, _ = self._patched_client()
|
|
65
|
+
response = mock.Mock()
|
|
66
|
+
response.RecordList = [
|
|
67
|
+
make_record_item(101, "val1", "www"),
|
|
68
|
+
make_record_item(102, "val2", "_acme-challenge", ttl=300),
|
|
69
|
+
]
|
|
70
|
+
client.client.DescribeRecordList = mock.Mock(return_value=response)
|
|
71
|
+
|
|
72
|
+
result = client.get_domain_records("example.com", "www", "TXT")
|
|
73
|
+
|
|
74
|
+
self.assertEqual(len(result), 2)
|
|
75
|
+
self.assertEqual(result[0], {
|
|
76
|
+
"record_id": 101,
|
|
77
|
+
"sub_domain": "www",
|
|
78
|
+
"type": "TXT",
|
|
79
|
+
"value": "val1",
|
|
80
|
+
"ttl": 600,
|
|
81
|
+
"line": "默认",
|
|
82
|
+
})
|
|
83
|
+
self.assertEqual(result[1]["record_id"], 102)
|
|
84
|
+
self.assertEqual(result[1]["ttl"], 300)
|
|
85
|
+
|
|
86
|
+
# 校验请求参数:类型、主机记录、ErrorOnEmpty
|
|
87
|
+
req = client.client.DescribeRecordList.call_args.args[0]
|
|
88
|
+
self.assertEqual(req.Domain, "example.com")
|
|
89
|
+
self.assertEqual(req.SubDomain, "www")
|
|
90
|
+
self.assertEqual(req.RecordType, "TXT")
|
|
91
|
+
self.assertEqual(req.ErrorOnEmpty, "no")
|
|
92
|
+
|
|
93
|
+
def test_get_domain_records_empty(self):
|
|
94
|
+
"""RecordList 为 None 时应返回空列表"""
|
|
95
|
+
client, _ = self._patched_client()
|
|
96
|
+
response = mock.Mock()
|
|
97
|
+
response.RecordList = None
|
|
98
|
+
client.client.DescribeRecordList = mock.Mock(return_value=response)
|
|
99
|
+
|
|
100
|
+
self.assertEqual(client.get_domain_records("example.com", "www"), [])
|
|
101
|
+
|
|
102
|
+
def test_get_domain_records_propagates_error(self):
|
|
103
|
+
"""SDK 抛异常时应向上抛出"""
|
|
104
|
+
client, _ = self._patched_client()
|
|
105
|
+
client.client.DescribeRecordList = mock.Mock(side_effect=RuntimeError("boom"))
|
|
106
|
+
|
|
107
|
+
with self.assertRaises(RuntimeError):
|
|
108
|
+
client.get_domain_records("example.com", "www")
|
|
109
|
+
|
|
110
|
+
def test_add_domain_record_returns_id(self):
|
|
111
|
+
"""add_domain_record 应返回 RecordId"""
|
|
112
|
+
client, _ = self._patched_client()
|
|
113
|
+
response = mock.Mock()
|
|
114
|
+
response.RecordId = 999
|
|
115
|
+
client.client.CreateRecord = mock.Mock(return_value=response)
|
|
116
|
+
|
|
117
|
+
record_id = client.add_domain_record("example.com", "www", "TXT", "val", 600)
|
|
118
|
+
|
|
119
|
+
self.assertEqual(record_id, 999)
|
|
120
|
+
req = client.client.CreateRecord.call_args.args[0]
|
|
121
|
+
self.assertEqual(req.Domain, "example.com")
|
|
122
|
+
self.assertEqual(req.SubDomain, "www")
|
|
123
|
+
self.assertEqual(req.RecordType, "TXT")
|
|
124
|
+
self.assertEqual(req.Value, "val")
|
|
125
|
+
self.assertEqual(req.TTL, 600)
|
|
126
|
+
# 默认线路
|
|
127
|
+
self.assertEqual(req.RecordLine, DEFAULT_RECORD_LINE)
|
|
128
|
+
|
|
129
|
+
def test_add_domain_record_no_id_raises(self):
|
|
130
|
+
"""CreateRecord 未返回 RecordId 时应抛异常"""
|
|
131
|
+
client, _ = self._patched_client()
|
|
132
|
+
response = mock.Mock()
|
|
133
|
+
response.RecordId = None
|
|
134
|
+
client.client.CreateRecord = mock.Mock(return_value=response)
|
|
135
|
+
|
|
136
|
+
with self.assertRaises(Exception):
|
|
137
|
+
client.add_domain_record("example.com", "www", "TXT", "val")
|
|
138
|
+
|
|
139
|
+
def test_delete_domain_record_success(self):
|
|
140
|
+
"""delete_domain_record 成功应返回 True 并传 Domain/RecordId"""
|
|
141
|
+
client, _ = self._patched_client()
|
|
142
|
+
client.client.DeleteRecord = mock.Mock()
|
|
143
|
+
|
|
144
|
+
self.assertTrue(client.delete_domain_record("example.com", 999))
|
|
145
|
+
req = client.client.DeleteRecord.call_args.args[0]
|
|
146
|
+
self.assertEqual(req.Domain, "example.com")
|
|
147
|
+
self.assertEqual(req.RecordId, 999)
|
|
148
|
+
|
|
149
|
+
def test_get_root_domain(self):
|
|
150
|
+
"""根域名提取:取最后两段"""
|
|
151
|
+
self.assertEqual(TencentCloudDNSClient.get_root_domain("a.b.example.com"), "example.com")
|
|
152
|
+
self.assertEqual(TencentCloudDNSClient.get_root_domain("example.com"), "example.com")
|
|
153
|
+
self.assertEqual(TencentCloudDNSClient.get_root_domain("localhost"), "localhost")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class HelperTest(unittest.TestCase):
|
|
157
|
+
"""辅助层测试:_TencentCloudDNSHelper"""
|
|
158
|
+
|
|
159
|
+
def _helper(self):
|
|
160
|
+
"""返回一个内部 client 被 mock 的 helper"""
|
|
161
|
+
with mock.patch.object(tencent_client.dnspod_client, "DnspodClient"):
|
|
162
|
+
helper = _TencentCloudDNSHelper("sid", "skey", 600)
|
|
163
|
+
helper.client = mock.Mock(spec=TencentCloudDNSClient)
|
|
164
|
+
return helper
|
|
165
|
+
|
|
166
|
+
def test_add_txt_record_new(self):
|
|
167
|
+
"""无现存记录时应调用 add_domain_record 并存 id"""
|
|
168
|
+
helper = self._helper()
|
|
169
|
+
helper.client.get_domain_records = mock.Mock(return_value=[])
|
|
170
|
+
helper.client.add_domain_record = mock.Mock(return_value=555)
|
|
171
|
+
|
|
172
|
+
with mock.patch("certbot_dns_tencent.dns_tencent.time.sleep"):
|
|
173
|
+
helper.add_txt_record("_acme-challenge.www.example.com", "token-value")
|
|
174
|
+
|
|
175
|
+
helper.client.add_domain_record.assert_called_once_with(
|
|
176
|
+
"example.com", "_acme-challenge.www", "TXT", "token-value", 600
|
|
177
|
+
)
|
|
178
|
+
# 记录 id 已缓存
|
|
179
|
+
self.assertEqual(
|
|
180
|
+
helper._record_ids["_acme-challenge.www.example.com"], 555
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def test_add_txt_record_already_exists(self):
|
|
184
|
+
"""已存在相同值的记录时应跳过(幂等,不重复添加)"""
|
|
185
|
+
helper = self._helper()
|
|
186
|
+
helper.client.get_domain_records = mock.Mock(
|
|
187
|
+
return_value=[{"record_id": 77, "value": "token-value"}]
|
|
188
|
+
)
|
|
189
|
+
helper.client.add_domain_record = mock.Mock()
|
|
190
|
+
|
|
191
|
+
with mock.patch("certbot_dns_tencent.dns_tencent.time.sleep"):
|
|
192
|
+
helper.add_txt_record("_acme-challenge.example.com", "token-value")
|
|
193
|
+
|
|
194
|
+
# 不应调用添加
|
|
195
|
+
helper.client.add_domain_record.assert_not_called()
|
|
196
|
+
# 复用已有记录的 id
|
|
197
|
+
self.assertEqual(
|
|
198
|
+
helper._record_ids["_acme-challenge.example.com"], 77
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def test_add_txt_record_raises_plugin_error(self):
|
|
202
|
+
"""底层异常应转为 certbot PluginError"""
|
|
203
|
+
helper = self._helper()
|
|
204
|
+
helper.client.get_domain_records = mock.Mock(
|
|
205
|
+
side_effect=RuntimeError("api down")
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
with self.assertRaises(errors.PluginError):
|
|
209
|
+
helper.add_txt_record("_acme-challenge.example.com", "token-value")
|
|
210
|
+
|
|
211
|
+
def test_del_txt_record_with_cached_id(self):
|
|
212
|
+
"""有已存 id 时应优先用 id 删除"""
|
|
213
|
+
helper = self._helper()
|
|
214
|
+
helper._record_ids["_acme-challenge.example.com"] = 42
|
|
215
|
+
helper.client.delete_domain_record = mock.Mock()
|
|
216
|
+
|
|
217
|
+
helper.del_txt_record("_acme-challenge.example.com", "token-value")
|
|
218
|
+
|
|
219
|
+
helper.client.delete_domain_record.assert_called_once_with("example.com", 42)
|
|
220
|
+
# 缓存被清理
|
|
221
|
+
self.assertNotIn("_acme-challenge.example.com", helper._record_ids)
|
|
222
|
+
|
|
223
|
+
def test_del_txt_record_fallback_query(self):
|
|
224
|
+
"""无已存 id 时应回退查询并删除匹配项"""
|
|
225
|
+
helper = self._helper()
|
|
226
|
+
helper.client.get_domain_records = mock.Mock(
|
|
227
|
+
return_value=[
|
|
228
|
+
{"record_id": 1, "value": "other"},
|
|
229
|
+
{"record_id": 2, "value": "token-value"},
|
|
230
|
+
]
|
|
231
|
+
)
|
|
232
|
+
helper.client.delete_domain_record = mock.Mock()
|
|
233
|
+
|
|
234
|
+
helper.del_txt_record("_acme-challenge.example.com", "token-value")
|
|
235
|
+
|
|
236
|
+
# 只删除 value 匹配的那条
|
|
237
|
+
helper.client.delete_domain_record.assert_called_once_with("example.com", 2)
|
|
238
|
+
|
|
239
|
+
def test_del_txt_record_not_found_no_raise(self):
|
|
240
|
+
"""查无记录时不应抛异常(清理失败不影响出证)"""
|
|
241
|
+
helper = self._helper()
|
|
242
|
+
helper.client.get_domain_records = mock.Mock(return_value=[])
|
|
243
|
+
helper.client.delete_domain_record = mock.Mock()
|
|
244
|
+
|
|
245
|
+
# 不应抛出
|
|
246
|
+
helper.del_txt_record("_acme-challenge.example.com", "token-value")
|
|
247
|
+
helper.client.delete_domain_record.assert_not_called()
|
|
248
|
+
|
|
249
|
+
def test_del_txt_record_swallows_error(self):
|
|
250
|
+
"""清理时底层抛异常应被吞掉(不影响证书获取)"""
|
|
251
|
+
helper = self._helper()
|
|
252
|
+
helper.client.get_domain_records = mock.Mock(
|
|
253
|
+
side_effect=RuntimeError("api down")
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# 不应抛出
|
|
257
|
+
helper.del_txt_record("_acme-challenge.example.com", "token-value")
|
|
258
|
+
|
|
259
|
+
def test_get_domain_from_record_name(self):
|
|
260
|
+
"""应正确去掉 _acme-challenge. 前缀并取根域"""
|
|
261
|
+
self.assertEqual(
|
|
262
|
+
_TencentCloudDNSHelper._get_domain_from_record_name(
|
|
263
|
+
"_acme-challenge.www.example.com"
|
|
264
|
+
),
|
|
265
|
+
"example.com",
|
|
266
|
+
)
|
|
267
|
+
self.assertEqual(
|
|
268
|
+
_TencentCloudDNSHelper._get_domain_from_record_name("example.com"),
|
|
269
|
+
"example.com",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class AuthenticatorTest(unittest.TestCase):
|
|
274
|
+
"""认证器层测试:Authenticator"""
|
|
275
|
+
|
|
276
|
+
def test_more_info(self):
|
|
277
|
+
"""more_info 返回非空字符串"""
|
|
278
|
+
self.assertIsInstance(Authenticator.more_info(None), str)
|
|
279
|
+
self.assertTrue(len(Authenticator.more_info(None)) > 0)
|
|
280
|
+
|
|
281
|
+
def test_add_parser_arguments(self):
|
|
282
|
+
"""add_parser_arguments 应注册 credentials 参数"""
|
|
283
|
+
add = mock.Mock()
|
|
284
|
+
Authenticator.add_parser_arguments(add)
|
|
285
|
+
# 至少调用了注册 credentials
|
|
286
|
+
calls = [c.args[0] for c in add.call_args_list if c.args]
|
|
287
|
+
self.assertIn("credentials", calls)
|
|
288
|
+
|
|
289
|
+
def test_get_tencent_client_without_credentials(self):
|
|
290
|
+
"""凭证未配置时 _get_tencent_client 应抛 errors.Error"""
|
|
291
|
+
authenticator = Authenticator.__new__(Authenticator)
|
|
292
|
+
authenticator.credentials = None
|
|
293
|
+
authenticator.ttl = 600
|
|
294
|
+
with self.assertRaises(errors.Error):
|
|
295
|
+
authenticator._get_tencent_client()
|
|
296
|
+
|
|
297
|
+
def test_get_tencent_client_with_credentials(self):
|
|
298
|
+
"""配置了凭证时应返回 helper(client 被 mock)"""
|
|
299
|
+
authenticator = Authenticator.__new__(Authenticator)
|
|
300
|
+
creds = mock.Mock()
|
|
301
|
+
creds.conf = lambda key: {"secret_id": "sid", "secret_key": "skey"}.get(key)
|
|
302
|
+
authenticator.credentials = creds
|
|
303
|
+
authenticator.ttl = 600
|
|
304
|
+
|
|
305
|
+
with mock.patch.object(tencent_client.dnspod_client, "DnspodClient"):
|
|
306
|
+
helper = authenticator._get_tencent_client()
|
|
307
|
+
self.assertIsInstance(helper, _TencentCloudDNSHelper)
|
|
308
|
+
|
|
309
|
+
def test_perform_delegates_to_helper(self):
|
|
310
|
+
"""_perform 应委托给 helper.add_txt_record"""
|
|
311
|
+
authenticator = Authenticator.__new__(Authenticator)
|
|
312
|
+
authenticator.ttl = 600
|
|
313
|
+
authenticator.credentials = mock.Mock()
|
|
314
|
+
|
|
315
|
+
with mock.patch.object(Authenticator, "_get_tencent_client") as m:
|
|
316
|
+
helper = m.return_value
|
|
317
|
+
authenticator._perform(
|
|
318
|
+
"example.com", "_acme-challenge.example.com", "token-value"
|
|
319
|
+
)
|
|
320
|
+
helper.add_txt_record.assert_called_once_with(
|
|
321
|
+
"_acme-challenge.example.com", "token-value"
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
def test_cleanup_delegates_to_helper(self):
|
|
325
|
+
"""_cleanup 应委托给 helper.del_txt_record"""
|
|
326
|
+
authenticator = Authenticator.__new__(Authenticator)
|
|
327
|
+
authenticator.ttl = 600
|
|
328
|
+
authenticator.credentials = mock.Mock()
|
|
329
|
+
|
|
330
|
+
with mock.patch.object(Authenticator, "_get_tencent_client") as m:
|
|
331
|
+
helper = m.return_value
|
|
332
|
+
authenticator._cleanup(
|
|
333
|
+
"example.com", "_acme-challenge.example.com", "token-value"
|
|
334
|
+
)
|
|
335
|
+
helper.del_txt_record.assert_called_once_with(
|
|
336
|
+
"_acme-challenge.example.com", "token-value"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class UtilTest(unittest.TestCase):
|
|
341
|
+
"""工具函数测试"""
|
|
342
|
+
|
|
343
|
+
def test_get_rr_from_record_name(self):
|
|
344
|
+
"""主机记录提取"""
|
|
345
|
+
self.assertEqual(
|
|
346
|
+
_get_rr_from_record_name("_acme-challenge.www.example.com", "example.com"),
|
|
347
|
+
"_acme-challenge.www",
|
|
348
|
+
)
|
|
349
|
+
self.assertEqual(
|
|
350
|
+
_get_rr_from_record_name("www.example.com", "example.com"),
|
|
351
|
+
"www",
|
|
352
|
+
)
|
|
353
|
+
# 不以 .domain 结尾时原样返回
|
|
354
|
+
self.assertEqual(
|
|
355
|
+
_get_rr_from_record_name("example.com", "example.com"),
|
|
356
|
+
"example.com",
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
if __name__ == "__main__":
|
|
361
|
+
unittest.main()
|