certbot-dns-dnspod-109 1.0.2__py3-none-any.whl → 1.0.3__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  """DNS Authenticator for Dnspod."""
2
2
  import logging
3
- from typing import Any
3
+ from typing import Any, List, Tuple
4
4
  from typing import Callable
5
5
  from typing import Optional
6
6
 
@@ -9,6 +9,8 @@ from certbot.plugins import dns_common
9
9
  from certbot.plugins.dns_common import CredentialsConfiguration
10
10
 
11
11
  from tencentcloud.common import credential
12
+ from tencentcloud.common.profile.client_profile import ClientProfile
13
+ from tencentcloud.common.profile.http_profile import HttpProfile
12
14
  from tencentcloud.dnspod.v20210323 import dnspod_client, models
13
15
 
14
16
  logger = logging.getLogger(__name__)
@@ -50,18 +52,20 @@ class Authenticator(dns_common.DNSAuthenticator):
50
52
  )
51
53
 
52
54
  def _perform(self, domain: str, validation_name: str, validation: str) -> None:
53
- self._get_dnspod_client().add_txt_record(domain, validation_name, validation, self.ttl)
55
+ self._get_dnspod_client().add_txt_record(validation_name, validation, self.ttl)
54
56
 
55
57
  def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
56
- self._get_dnspod_client().del_txt_record(domain, validation_name, validation)
58
+ self._get_dnspod_client().del_txt_record(validation_name, validation)
57
59
 
58
- def _get_dnspod_client(self) -> "_DnspodClient":
60
+ def _get_dnspod_client(self) -> '_DnspodClient':
59
61
  if not self.credentials: # pragma: no cover
60
62
  raise errors.Error("Plugin has not been prepared, did you set 'credentials'?")
61
63
  if self.credentials.conf('secret_id') and self.credentials.conf('secret_key'):
62
64
  return _DnspodClient(secret_id=self.credentials.conf('secret_id'),
63
65
  secret_key=self.credentials.conf('secret_key'),
64
- endpoint=self.credentials.conf('endpoint') or "https://dnspod.tencentcloudapi.com")
66
+ endpoint=self.credentials.conf('endpoint') or "dnspod.tencentcloudapi.com")
67
+ else:
68
+ raise errors.Error("Missing required credentials: secret_id and secret_key are required")
65
69
 
66
70
 
67
71
  class _DnspodClient:
@@ -69,85 +73,241 @@ class _DnspodClient:
69
73
  Encapsulates all communication with the Tencent Cloud API 3.0.
70
74
  """
71
75
 
72
- def __init__(self, secret_id: str, secret_key: str, endpoint: str) -> None:
73
- # Use Tencent Cloud SDK
76
+ def __init__(self, secret_id: str, secret_key: str, endpoint: str = "dnspod.tencentcloudapi.com") -> None:
77
+ # init Tencent Cloud SDK
74
78
  cred = credential.Credential(secret_id, secret_key)
75
- self.client = dnspod_client.DnspodClient(cred, "")
76
79
 
77
- def add_txt_record(self, domain: str, record_name: str, record_content: str, record_ttl: int) -> None:
80
+ http_profile = HttpProfile()
81
+ http_profile.endpoint = endpoint
82
+
83
+ client_profile = ClientProfile()
84
+ client_profile.httpProfile = http_profile
85
+
86
+ self.client = dnspod_client.DnspodClient(cred, "", client_profile)
87
+
88
+ @staticmethod
89
+ def _normalize_fqdn(name: str) -> str:
90
+ """
91
+ Normalizes a fully qualified domain name (FQDN).
92
+
93
+ This method is used to transform an input FQDN by stripping any trailing
94
+ dots and converting the string to lowercase. This ensures uniformity
95
+ and consistent handling of FQDNs for comparisons and other operations.
96
+
97
+ Parameters:
98
+ name: str
99
+ The fully qualified domain name to normalize.
100
+
101
+ Returns:
102
+ str
103
+ The normalized FQDN with trailing dots removed and all characters
104
+ in lowercase.
105
+ """
106
+ return name.rstrip(".").lower()
107
+
108
+ def _to_ascii_fqdn(self, fqdn: str) -> str:
109
+ """
110
+ Converts a fully qualified domain name (FQDN) to its ASCII representation.
111
+
112
+ This method processes the provided FQDN to ensure it adheres to ASCII encoding
113
+ standards. Non-ASCII labels are converted using IDNA encoding, while ASCII
114
+ labels remain unchanged. Empty labels and invalid input are ignored during
115
+ conversion.
116
+
117
+ Parameters:
118
+ fqdn: str
119
+ The fully qualified domain name to be processed.
120
+
121
+ Returns:
122
+ str
123
+ The ASCII-encoded version of the provided fully qualified domain name.
124
+ """
125
+ fqdn = self._normalize_fqdn(fqdn)
126
+ labels = fqdn.split(".")
127
+ out = []
128
+ for lab in labels:
129
+ if not lab:
130
+ continue
131
+ if all(ord(c) < 128 for c in lab):
132
+ out.append(lab)
133
+ else:
134
+ out.append(lab.encode("idna").decode("ascii"))
135
+ return ".".join(out)
136
+
137
+ def find_hosted_domain(self, record_fqdn: str) -> Tuple[str, str]:
138
+ """
139
+ Determines the hosted domain and the subdomain part for a given fully qualified domain name (FQDN).
140
+
141
+ The method searches the list of all hosted domains to find one that either matches exactly
142
+ or is a parent domain to the provided FQDN. If found, it returns the hosted domain and the
143
+ subdomain part of the FQDN. If no matching hosted domain is found, an exception is raised.
144
+
145
+ Parameters:
146
+ record_fqdn (str): The fully qualified domain name (FQDN) to search for.
147
+
148
+ Returns:
149
+ Tuple[str, str]: A tuple containing:
150
+ - The hosted domain that covers the provided FQDN.
151
+ - The subdomain part of the FQDN relative to the hosted domain
152
+ or "@" if the FQDN matches the hosted domain exactly.
153
+
154
+ Raises:
155
+ PluginError: If no hosted domain is found, that can cover the provided FQDN.
156
+ """
157
+ fqdn = self._to_ascii_fqdn(record_fqdn)
158
+ domains = self._list_all_domain()
159
+
160
+ for domain in domains:
161
+ if fqdn == domain or fqdn.endswith("." + domain):
162
+ if fqdn == domain:
163
+ sub = "@"
164
+ else:
165
+ sub = fqdn[:-(len(domain) + 1)]
166
+ return domain, sub
167
+
168
+ raise errors.PluginError(
169
+ f"Can't find a hosted domain that can cover {record_fqdn}. "
170
+ f"Please make sure you have hosted the domain (e.g. example.com / us.example.com) in DNSPod."
171
+ )
172
+
173
+ def _list_all_domain(self) -> List[str]:
78
174
  """
79
- Add a TXT record using the supplied information.
175
+ Fetches and returns a sorted list of all domains associated with the account.
176
+
177
+ This method interacts with the API to retrieve a complete list of domains by
178
+ iteratively querying the DescribeDomainList API endpoint. It normalizes and
179
+ filters the retrieved domain data, ensuring that the list contains unique
180
+ entries sorted in descending order by their length.
80
181
 
81
- :param domain: The domain to use to look up the DNS zone.
82
- :param record_name: The record name (typically '_acme-challenge.').
83
- :param record_content: The record content (typically the challenge validation string).
84
- :param record_ttl: The record TTL in seconds.
182
+ Returns:
183
+ List[str]: A sorted list of unique domain names.
85
184
  """
86
- logger.info(f"Adding TXT record for {record_name}")
185
+ zones: List[str] = []
186
+ offset = 0
187
+ limit = 3000 # default limit for DescribeDomainList API
188
+
189
+ while True:
190
+ req = models.DescribeDomainListRequest()
191
+ req.Offset = offset
192
+ req.Limit = limit
193
+ resp = self.client.DescribeDomainList(req)
194
+
195
+ domain_list = resp.DomainList or []
196
+ for d in domain_list:
197
+ if getattr(d, "Punycode", None):
198
+ zones.append(self._normalize_fqdn(d.Punycode))
199
+
200
+ total = int(resp.DomainCountInfo.DomainTotal)
201
+ offset += len(domain_list)
202
+ if offset >= total or len(domain_list) == 0:
203
+ break
204
+
205
+ return sorted(set(zones), key=len, reverse=True)
206
+
207
+ def add_txt_record(self, record_name: str, record_content: str, record_ttl: int) -> None:
208
+ """
209
+ Adds a TXT DNS record to the specified domain using the provided details.
210
+
211
+ This function is responsible for creating a TXT type DNS record in the
212
+ appropriate hosted zone identified by the given record name.
213
+
214
+ Parameters:
215
+ record_name (str): The full name of the record (e.g., "_acme-challenge.example.com").
216
+ record_content (str): The value or content of the TXT record.
217
+ record_ttl (int): The time-to-live (TTL) value for the record, dictating how long
218
+ it can be cached.
219
+
220
+ Raises:
221
+ PluginError: If the TXT record addition fails, typically due to an issue in
222
+ the response from the DNS provider.
223
+
224
+ Notes:
225
+ The function automatically determines the hosted domain and subdomain to
226
+ target the correct DNS zone. A log entry is also created upon successful
227
+ record addition with relevant details.
228
+ """
229
+ zone, subdomain = self.find_hosted_domain(record_name)
87
230
 
88
231
  req = models.CreateTXTRecordRequest()
89
- req.Domain = domain
232
+ req.Domain = zone
233
+ req.SubDomain = subdomain
90
234
  req.RecordLine = '默认' # required
91
235
  req.Value = record_content
92
236
  req.TTL = record_ttl
93
- req.SubDomain = record_name.split(".")[0]
94
237
 
95
- response = self.client.CreateTXTRecord(req)
96
- record_id = response.RequestId
238
+ resp = self.client.CreateTXTRecord(req)
97
239
 
240
+ record_id = getattr(resp, "RecordId", None)
98
241
  if not record_id:
99
- raise Exception(f"Failed to add TXT record: {response}")
100
- logger.info(f"Successfully added TXT record: {record_id}")
242
+ raise errors.PluginError(f"Failed to add TXT record, response: {resp}")
243
+ logger.info(
244
+ f"Successfully added TXT record: zone: {zone} sub: {subdomain} record_id: {record_id} request_id: {resp.RequestId}")
101
245
 
102
- def del_txt_record(self, domain: str, record_name: str, record_content: str) -> None:
246
+ def del_txt_record(self, record_name: str, record_content: str) -> None:
103
247
  """
104
- Delete a TXT record using the supplied information.
248
+ Deletes a TXT record from the DNS configuration.
249
+
250
+ This method removes a specific TXT record from the DNS configuration associated with the given
251
+ record name and record content. It identifies the domain and subdomain hosting the record,
252
+ verifies its ID, and submits a request to delete it. In the event the record is not found or
253
+ an error occurs during deletion, appropriate logging messages are generated.
254
+
255
+ Parameters:
256
+ record_name: str
257
+ The fully qualified domain name (FQDN) of the TXT record to be deleted.
258
+ record_content: str
259
+ The content of the TXT record to be deleted.
105
260
 
106
- :param domain: The domain to use to look up the DNS zone.
107
- :param record_name: The record name (typically '_acme-challenge.').
108
- :param record_content: The record content (typically the challenge validation string).
261
+ Raises:
262
+ Exception: If an error occurs during record retrieval or deletion.
109
263
  """
110
264
  try:
111
- logger.info("Deleting TXT record for %s", record_name)
112
-
113
- record_id = self._find_txt_record_id(domain, record_name, record_content)
265
+ domain, subdomain = self.find_hosted_domain(record_name)
266
+ record_id = self._find_txt_record_id(domain, subdomain, record_content)
114
267
  if not record_id:
115
- logger.warning("Record not found, skipping deletion.")
268
+ logger.warning("Record not found, skipping deletion: domain=%s sub=%s", domain, subdomain)
116
269
  return
117
270
 
118
271
  req = models.DeleteRecordRequest()
119
272
  req.Domain = domain
120
273
  req.RecordId = record_id
121
-
122
- response = self.client.DeleteRecord(req)
123
- if response.RequestId:
124
- logger.info(f"Successfully deleted TXT record: {record_id}, RequestId ID: {response.RequestId}")
274
+ resp = self.client.DeleteRecord(req)
275
+ logger.info(
276
+ f"Successfully deleted TXT record: domain: {domain} sub: {subdomain} request_id: {resp.RequestId}")
125
277
  except Exception as e:
126
- logger.error(f"Failed to delete TXT record: {e}")
278
+ logger.error("Failed to delete TXT record: %s", e)
127
279
 
128
- def _find_txt_record_id(self, domain: str, record_name: str, record_content: str) -> Optional[int]:
280
+ def _find_txt_record_id(self, domain: str, subdomain: str, record_content: str) -> Optional[int]:
129
281
  """
130
- Find the record ID for a TXT record with the given name and content.
131
-
132
- :param domain: The domain to use to look up the DNS zone.
133
- :param record_name: The record name (typically '_acme-challenge.').
134
- :param record_content: The record content (typically the challenge validation string).
135
- :returns: The record ID, if found.
282
+ Find the ID of a TXT record that matches the specified domain name, subdomain,
283
+ and record content.
284
+
285
+ This method searches for an existing TXT record by domain, subdomain, and the
286
+ record content within the list of records returned by the client. If a matching
287
+ record is found, its ID is returned. If no match is found, None is returned.
288
+
289
+ Parameters:
290
+ domain: str
291
+ The domain name in which to search for the TXT record.
292
+ subdomain: str
293
+ The subdomain name for the TXT record, typically the host or name
294
+ portion of the record.
295
+ record_content: str
296
+ The text content of the TXT record that is being searched for.
297
+
298
+ Returns:
299
+ Optional[int]: The ID of the matching TXT record if found, otherwise None.
136
300
  """
137
- logger.info(f"Searching for TXT record for {record_name}")
138
-
139
301
  req = models.DescribeRecordFilterListRequest()
140
302
  req.Domain = domain
141
- req.SubDomain = record_name.split(".")[0]
303
+ req.SubDomain = subdomain
142
304
  req.RecordType = ["TXT"]
143
305
 
144
- response = self.client.DescribeRecordFilterList(req)
145
- records = response.RecordList
146
-
147
- for record in records:
148
- if record.Name == record_name.split(".")[0] and record.Value == record_content:
149
- logger.info(f"Found TXT record for {record.Name}")
150
- return record.RecordId
306
+ resp = self.client.DescribeRecordFilterList(req)
307
+ for r in (resp.RecordList or []):
308
+ if r.Name == subdomain and r.Value == record_content:
309
+ logger.info(f"Found TXT record for {r.Name}")
310
+ return int(r.RecordId)
151
311
 
152
- logger.warning(f"No TXT record found for {record_name}")
312
+ logger.warning(f"No TXT record found for {subdomain}")
153
313
  return None
@@ -1,4 +1,4 @@
1
- """Tests for certbot_certbot_dns_dnspod_109._internal.dns_dnspod"""
1
+ """Tests for certbot_dns_dnspod_109._internal.dns_dnspod"""
2
2
 
3
3
  import sys
4
4
  import unittest
@@ -13,7 +13,7 @@ from certbot.plugins.dns_test_common import DOMAIN
13
13
  from certbot.tests import util as test_util
14
14
 
15
15
 
16
- # Simulate the exception returned by Tencent Cloud Dnspod API to test error handling
16
+ # Simulate Tencent Cloud API exception for delete-path robustness tests.
17
17
  class MockApiException(Exception):
18
18
  pass
19
19
 
@@ -25,37 +25,39 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
25
25
 
26
26
  super().setUp()
27
27
 
28
- path = os.path.join(self.tempdir, 'file.ini')
29
- dns_test_common.write({"dnspod_secret_id": "test_id", "dnspod_secret_key": "test_key"}, path)
28
+ path = os.path.join(self.tempdir, "file.ini")
29
+ # This plugin version expects prefixed keys in credentials file.
30
+ dns_test_common.write(
31
+ {"dnspod_secret_id": "test_id", "dnspod_secret_key": "test_key"},
32
+ path
33
+ )
30
34
 
31
- self.config = mock.MagicMock(dnspod_credentials=path,
32
- dnspod_propagation_seconds=0) # Don't wait for actual records to take effect in tests
35
+ self.config = mock.MagicMock(
36
+ dnspod_credentials=path,
37
+ dnspod_propagation_seconds=0,
38
+ )
33
39
 
34
40
  self.auth = Authenticator(self.config, "dnspod")
35
-
36
41
  self.mock_client = mock.MagicMock()
37
- # Mock _get_dnspod_client
38
- self.auth._get_dnspod_client = mock.MagicMock(return_value=self.mock_client)
39
42
 
40
43
  @test_util.patch_display_util()
41
44
  def test_perform(self, unused_mock_get_utility):
42
- # Test whether the perform method calls add_txt_record correctly
43
- self.auth.perform([self.achall])
45
+ with mock.patch.object(self.auth, "_get_dnspod_client", return_value=self.mock_client):
46
+ self.auth.perform([self.achall])
44
47
 
45
- expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.' + DOMAIN, mock.ANY, mock.ANY)]
48
+ expected = [mock.call.add_txt_record("_acme-challenge." + DOMAIN, mock.ANY, 600)]
46
49
  assert expected == self.mock_client.mock_calls
47
50
 
48
51
  def test_cleanup(self):
49
- # Test that the cleanup method calls del_txt_record correctly
50
52
  self.auth._attempt_cleanup = True
51
- self.auth.cleanup([self.achall])
53
+ with mock.patch.object(self.auth, "_get_dnspod_client", return_value=self.mock_client):
54
+ self.auth.cleanup([self.achall])
52
55
 
53
- expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.' + DOMAIN, mock.ANY)]
56
+ expected = [mock.call.del_txt_record("_acme-challenge." + DOMAIN, mock.ANY)]
54
57
  assert expected == self.mock_client.mock_calls
55
58
 
56
59
  def test_no_creds(self):
57
- # Test that an exception is thrown when correct credentials are not provided
58
- path = os.path.join(self.tempdir, 'empty.ini')
60
+ path = os.path.join(self.tempdir, "empty.ini")
59
61
  dns_test_common.write({}, path)
60
62
  self.config.dnspod_credentials = path
61
63
 
@@ -65,9 +67,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
65
67
  auth.perform([self.achall])
66
68
 
67
69
  def test_missing_secret_id(self):
68
- # Test whether an exception is thrown when no credentials are provided
69
- path = os.path.join(self.tempdir, 'no_id.ini')
70
- dns_test_common.write({"secret_key": "test_key"}, path)
70
+ path = os.path.join(self.tempdir, "no_id.ini")
71
+ dns_test_common.write({"dnspod_secret_key": "test_key"}, path)
71
72
  self.config.dnspod_credentials = path
72
73
 
73
74
  from certbot_dns_dnspod_109._internal.dns_dnspod import Authenticator
@@ -76,9 +77,8 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
76
77
  auth.perform([self.achall])
77
78
 
78
79
  def test_missing_secret_key(self):
79
- # Test whether an exception is thrown when no credentials are provided
80
- path = os.path.join(self.tempdir, 'no_key.ini')
81
- dns_test_common.write({"secret_id": "test_id"}, path)
80
+ path = os.path.join(self.tempdir, "no_key.ini")
81
+ dns_test_common.write({"dnspod_secret_id": "test_id"}, path)
82
82
  self.config.dnspod_credentials = path
83
83
 
84
84
  from certbot_dns_dnspod_109._internal.dns_dnspod import Authenticator
@@ -86,115 +86,244 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
86
86
  with pytest.raises(errors.PluginError):
87
87
  auth.perform([self.achall])
88
88
 
89
+ def test_get_dnspod_client_not_prepared(self):
90
+ self.auth.credentials = None
91
+ with pytest.raises(errors.Error):
92
+ self.auth._get_dnspod_client()
93
+
94
+ def test_get_dnspod_client_missing_required(self):
95
+ creds = mock.MagicMock()
96
+ creds.conf.side_effect = lambda k: {"dnspod_secret_id": "id_only"}.get(k)
97
+ self.auth.credentials = creds
98
+ with pytest.raises(errors.Error):
99
+ self.auth._get_dnspod_client()
100
+
101
+ def test_get_dnspod_client_default_endpoint(self):
102
+ from certbot_dns_dnspod_109._internal import dns_dnspod as mod
103
+
104
+ creds = mock.MagicMock()
105
+ creds.conf.side_effect = lambda k: {
106
+ "secret_id": "id",
107
+ "secret_key": "key",
108
+ "endpoint": None,
109
+ }.get(k)
110
+
111
+ self.auth.credentials = creds
112
+
113
+ with mock.patch.object(mod, "_DnspodClient") as m_client_cls:
114
+ self.auth._get_dnspod_client()
115
+ m_client_cls.assert_called_once_with(
116
+ secret_id="id",
117
+ secret_key="key",
118
+ endpoint="dnspod.tencentcloudapi.com",
119
+ )
120
+
121
+ def test_get_dnspod_client_custom_endpoint(self):
122
+ from certbot_dns_dnspod_109._internal import dns_dnspod as mod
123
+
124
+ creds = mock.MagicMock()
125
+ creds.conf.side_effect = lambda k: {
126
+ "secret_id": "id",
127
+ "secret_key": "key",
128
+ "endpoint": "custom.endpoint.tencentcloudapi.com",
129
+ }.get(k)
130
+
131
+ self.auth.credentials = creds
132
+
133
+ with mock.patch.object(mod, "_DnspodClient") as m_client_cls:
134
+ self.auth._get_dnspod_client()
135
+ m_client_cls.assert_called_once_with(
136
+ secret_id="id",
137
+ secret_key="key",
138
+ endpoint="custom.endpoint.tencentcloudapi.com",
139
+ )
140
+
89
141
 
90
142
  class DnspodClientTest(unittest.TestCase):
91
- record_name = "_acme-challenge"
143
+ record_name = "_acme-challenge." + DOMAIN
92
144
  record_content = "test_challenge"
93
145
  record_ttl = 600
94
- domain = DOMAIN
146
+ zone = DOMAIN
147
+ subdomain = "_acme-challenge"
95
148
  record_id = 12345
96
149
 
97
150
  def setUp(self):
98
151
  from certbot_dns_dnspod_109._internal.dns_dnspod import _DnspodClient
99
- # Create a simulated dnspod_client
100
- self.mock_dnspod_client = mock.MagicMock()
101
- self.mock_credential = mock.MagicMock()
152
+
102
153
  self.mock_dnspod_sdk_client = mock.MagicMock()
103
154
 
104
- # Patch dnspod_client.DnspodClient constructor returns mock_dnspod_sdk_client
105
- patcher = mock.patch('certbot_dns_dnspod_109._internal.dns_dnspod.dnspod_client.DnspodClient',
106
- return_value=self.mock_dnspod_sdk_client)
155
+ # Patch SDK client constructor.
156
+ patcher = mock.patch(
157
+ "certbot_dns_dnspod_109._internal.dns_dnspod.dnspod_client.DnspodClient",
158
+ return_value=self.mock_dnspod_sdk_client,
159
+ )
107
160
  patcher.start()
108
161
  self.addCleanup(patcher.stop)
109
162
 
110
- # Patch models module
111
- self.models_patcher = mock.patch('certbot_dns_dnspod_109._internal.dns_dnspod.models')
163
+ # Patch request models used by the implementation.
164
+ self.models_patcher = mock.patch("certbot_dns_dnspod_109._internal.dns_dnspod.models")
112
165
  self.mock_models = self.models_patcher.start()
113
166
  self.addCleanup(self.models_patcher.stop)
114
167
 
115
168
  self.CreateTXTRecordRequest = mock.MagicMock()
116
169
  self.DeleteRecordRequest = mock.MagicMock()
117
170
  self.DescribeRecordFilterListRequest = mock.MagicMock()
171
+ self.DescribeDomainListRequest = mock.MagicMock()
118
172
 
119
173
  self.mock_models.CreateTXTRecordRequest.return_value = self.CreateTXTRecordRequest
120
174
  self.mock_models.DeleteRecordRequest.return_value = self.DeleteRecordRequest
121
175
  self.mock_models.DescribeRecordFilterListRequest.return_value = self.DescribeRecordFilterListRequest
176
+ self.mock_models.DescribeDomainListRequest.return_value = self.DescribeDomainListRequest
177
+
178
+ self.client = _DnspodClient(
179
+ secret_id="test_id",
180
+ secret_key="test_key",
181
+ endpoint="dnspod.tencentcloudapi.com",
182
+ )
183
+
184
+ def test_normalize_fqdn(self):
185
+ # Should strip trailing dots and lowercase.
186
+ assert self.client._normalize_fqdn("WWW.Example.COM.") == "www.example.com"
187
+
188
+ def test_to_ascii_fqdn_ascii_passthrough(self):
189
+ # ASCII labels should remain unchanged except normalization.
190
+ assert self.client._to_ascii_fqdn("WWW.Example.COM.") == "www.example.com"
191
+
192
+ def test_to_ascii_fqdn_idn(self):
193
+ # Non-ASCII labels should be IDNA-encoded.
194
+ out = self.client._to_ascii_fqdn("www.例子.测试.")
195
+ assert out == "www.xn--fsqu00a.xn--0zwm56d"
196
+
197
+ def test_find_hosted_domain_exact_match(self):
198
+ # Exact zone match should return ('zone', '@').
199
+ self.client._list_all_domain = mock.MagicMock(return_value=["example.com"])
200
+ zone, sub = self.client.find_hosted_domain("example.com.")
201
+ assert zone == "example.com"
202
+ assert sub == "@"
203
+
204
+ def test_find_hosted_domain_parent_match(self):
205
+ # Parent zone match should extract left part as subdomain.
206
+ self.client._list_all_domain = mock.MagicMock(return_value=["example.com"])
207
+ zone, sub = self.client.find_hosted_domain("_acme-challenge.example.com.")
208
+ assert zone == "example.com"
209
+ assert sub == "_acme-challenge"
210
+
211
+ def test_find_hosted_domain_longest_suffix_wins(self):
212
+ # Domains are expected to be sorted longest-first; ensure most specific zone is selected.
213
+ self.client._list_all_domain = mock.MagicMock(return_value=["us.example.com", "example.com"])
214
+ zone, sub = self.client.find_hosted_domain("_acme-challenge.us.example.com")
215
+ assert zone == "us.example.com"
216
+ assert sub == "_acme-challenge"
217
+
218
+ def test_find_hosted_domain_not_found(self):
219
+ self.client._list_all_domain = mock.MagicMock(return_value=["example.com"])
220
+ with pytest.raises(errors.PluginError):
221
+ self.client.find_hosted_domain("_acme-challenge.not-hosted.net")
222
+
223
+ def test_list_all_domain_pagination_and_sort(self):
224
+ # Build 2-page API responses and verify dedupe + normalization + sort-by-length-desc.
225
+ d1 = mock.MagicMock()
226
+ d1.Punycode = "Example.COM."
227
+ d2 = mock.MagicMock()
228
+ d2.Punycode = "us.example.com"
229
+ d3 = mock.MagicMock()
230
+ d3.Punycode = "example.com" # duplicate after normalize
231
+ d4 = mock.MagicMock()
232
+ d4.Punycode = None # should be skipped
122
233
 
123
- self.client = _DnspodClient(secret_id="test_id", secret_key="test_key",
124
- endpoint="https://dnspod.tencentcloudapi.com")
234
+ resp1 = mock.MagicMock()
235
+ resp1.DomainList = [d1, d2]
236
+ resp1.DomainCountInfo.DomainTotal = 4
237
+
238
+ resp2 = mock.MagicMock()
239
+ resp2.DomainList = [d3, d4]
240
+ resp2.DomainCountInfo.DomainTotal = 4
241
+
242
+ self.mock_dnspod_sdk_client.DescribeDomainList.side_effect = [resp1, resp2]
243
+
244
+ zones = self.client._list_all_domain()
245
+ assert zones == ["us.example.com", "example.com"]
125
246
 
126
247
  def test_add_txt_record(self):
127
- # Simulate the return of a successful RequestId when adding a record
248
+ # Avoid testing zone lookup path here; isolate CreateTXTRecord request behavior.
249
+ self.client.find_hosted_domain = mock.MagicMock(return_value=(self.zone, self.subdomain))
250
+
128
251
  mock_response = mock.MagicMock()
252
+ mock_response.RecordId = self.record_id
129
253
  mock_response.RequestId = "mock_request_id"
130
254
  self.mock_dnspod_sdk_client.CreateTXTRecord.return_value = mock_response
131
255
 
132
- self.client.add_txt_record(self.domain, self.record_name, self.record_content, self.record_ttl)
133
- self.mock_dnspod_sdk_client.CreateTXTRecord.assert_called_once_with(self.CreateTXTRecordRequest)
256
+ self.client.add_txt_record(self.record_name, self.record_content, self.record_ttl)
134
257
 
135
- # Check if the request parameters are correctly assigned
136
- assert self.CreateTXTRecordRequest.Domain == self.domain
258
+ self.mock_dnspod_sdk_client.CreateTXTRecord.assert_called_once_with(self.CreateTXTRecordRequest)
259
+ assert self.CreateTXTRecordRequest.Domain == self.zone
260
+ assert self.CreateTXTRecordRequest.SubDomain == self.subdomain
137
261
  assert self.CreateTXTRecordRequest.Value == self.record_content
138
262
  assert self.CreateTXTRecordRequest.TTL == self.record_ttl
139
- assert self.CreateTXTRecordRequest.SubDomain == self.record_name
263
+ assert self.CreateTXTRecordRequest.RecordLine == "默认"
140
264
 
141
265
  def test_add_txt_record_failed(self):
142
- # Simulate adding records and return no RequestId
266
+ self.client.find_hosted_domain = mock.MagicMock(return_value=(self.zone, self.subdomain))
267
+
268
+ # Missing RecordId should be treated as failure.
143
269
  mock_response = mock.MagicMock()
144
- mock_response.RequestId = ""
270
+ mock_response.RecordId = None
145
271
  self.mock_dnspod_sdk_client.CreateTXTRecord.return_value = mock_response
146
272
 
147
- with pytest.raises(Exception):
148
- self.client.add_txt_record(self.domain, self.record_name, self.record_content, self.record_ttl)
273
+ with pytest.raises(errors.PluginError):
274
+ self.client.add_txt_record(self.record_name, self.record_content, self.record_ttl)
149
275
 
150
276
  def test_del_txt_record(self):
151
- # Simulate the return of matching records when searching for record IDs
277
+ self.client.find_hosted_domain = mock.MagicMock(return_value=(self.zone, self.subdomain))
278
+
279
+ # _find_txt_record_id checks Name == subdomain and Value == content.
152
280
  mock_record = mock.MagicMock()
153
281
  mock_record.RecordId = self.record_id
154
- mock_record.Name = self.record_name
282
+ mock_record.Name = self.subdomain
155
283
  mock_record.Value = self.record_content
284
+
156
285
  mock_response = mock.MagicMock()
157
286
  mock_response.RecordList = [mock_record]
158
287
  self.mock_dnspod_sdk_client.DescribeRecordFilterList.return_value = mock_response
159
288
 
160
- # Simulate the success of deleting records
161
289
  mock_del_response = mock.MagicMock()
162
290
  mock_del_response.RequestId = "del_request_id"
163
291
  self.mock_dnspod_sdk_client.DeleteRecord.return_value = mock_del_response
164
292
 
165
- self.client.del_txt_record(self.domain, self.record_name, self.record_content)
293
+ self.client.del_txt_record(self.record_name, self.record_content)
166
294
 
167
295
  self.mock_dnspod_sdk_client.DescribeRecordFilterList.assert_called_once_with(
168
- self.DescribeRecordFilterListRequest)
296
+ self.DescribeRecordFilterListRequest
297
+ )
169
298
  self.mock_dnspod_sdk_client.DeleteRecord.assert_called_once_with(self.DeleteRecordRequest)
170
-
171
299
  assert self.DeleteRecordRequest.RecordId == self.record_id
172
- assert self.DeleteRecordRequest.Domain == self.domain
300
+ assert self.DeleteRecordRequest.Domain == self.zone
173
301
 
174
302
  def test_del_txt_record_not_found(self):
175
- # Simulate that no matching records are found
303
+ self.client.find_hosted_domain = mock.MagicMock(return_value=(self.zone, self.subdomain))
304
+
176
305
  mock_response = mock.MagicMock()
177
306
  mock_response.RecordList = []
178
307
  self.mock_dnspod_sdk_client.DescribeRecordFilterList.return_value = mock_response
179
308
 
180
- # Calling to delete a record will not throw an exception but will generate a warning
181
- self.client.del_txt_record(self.domain, self.record_name, self.record_content)
309
+ # Not found should be a no-op.
310
+ self.client.del_txt_record(self.record_name, self.record_content)
182
311
  self.mock_dnspod_sdk_client.DeleteRecord.assert_not_called()
183
312
 
184
313
  def test_del_txt_record_error(self):
185
- # Simulate the return of records when searching for record IDs, but throw an exception when deleting
314
+ self.client.find_hosted_domain = mock.MagicMock(return_value=(self.zone, self.subdomain))
315
+
186
316
  mock_record = mock.MagicMock()
187
317
  mock_record.RecordId = self.record_id
188
- mock_record.Name = self.record_name
318
+ mock_record.Name = self.subdomain
189
319
  mock_record.Value = self.record_content
190
320
  mock_response = mock.MagicMock()
191
321
  mock_response.RecordList = [mock_record]
192
322
  self.mock_dnspod_sdk_client.DescribeRecordFilterList.return_value = mock_response
193
323
 
324
+ # del_txt_record should swallow exceptions and log errors.
194
325
  self.mock_dnspod_sdk_client.DeleteRecord.side_effect = MockApiException("Delete error")
195
-
196
- # Should not throw exceptions, but errors will be logged
197
- self.client.del_txt_record(self.domain, self.record_name, self.record_content)
326
+ self.client.del_txt_record(self.record_name, self.record_content)
198
327
 
199
328
 
200
329
  if __name__ == "__main__":
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: certbot-dns-dnspod-109
3
- Version: 1.0.2
3
+ Version: 1.0.3
4
4
  Summary: Dnspod DNS Authenticator plugin for Certbot
5
5
  Home-page: https://github.com/10935336/certbot-dns-dnspod-109
6
6
  Author: 10935336
@@ -43,6 +43,9 @@ Requires-Dist: pytest; extra == "test"
43
43
 
44
44
 
45
45
  ## Just Another DNSPod DNS Authenticator plugin for Certbot
46
+
47
+ Just Another Tencent Cloud DNSPod DNS Authenticator plugin for [Certbot](https://certbot.eff.org/)
48
+
46
49
  The `certbot-dns-dnspod-109` plugin automates the process of
47
50
  completing a `dns-01` challenge (`~acme.challenges.DNS01`)
48
51
  by creating, and subsequently removing, TXT records using the
@@ -54,6 +57,8 @@ then this is your plugin.
54
57
 
55
58
  Tested on
56
59
  - Certbot 3.0.1
60
+ - Certbot 5.1.0
61
+ - Certbot 5.2.2
57
62
 
58
63
 
59
64
  ## Usage
@@ -71,6 +76,8 @@ pip install git+https://github.com/10935336/certbot-dns-dnspod-109.git
71
76
 
72
77
 
73
78
  #### snap:
79
+ The snap version is deprecated and will not receive updates after version v1.0.2.
80
+
74
81
  ```bash
75
82
  snap install certbot-dns-dnspod-10935336
76
83
  snap set certbot trust-plugin-with-root=ok
@@ -91,6 +98,11 @@ dns_dnspod_109_secret_id=foo
91
98
  dns_dnspod_109_secret_key=bar
92
99
  ```
93
100
 
101
+ Optional parameters:
102
+ ```ini
103
+ dns_dnspod_109_endpoint=dnspod.tencentcloudapi.com
104
+ ```
105
+
94
106
  ### 4. Ready to go
95
107
 
96
108
  #### Usage Examples
@@ -113,6 +125,15 @@ certbot certonly \
113
125
  -d www.example.com
114
126
  ```
115
127
 
128
+ Obtain a wildcard certificate for `example.com`
129
+ ```bash
130
+ certbot certonly \
131
+ -a dns-dnspod-109 \
132
+ --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
133
+ -d example.com \
134
+ -d *.example.com
135
+ ```
136
+
116
137
  To acquire a certificate for example.com, waiting 60 seconds for DNS propagation
117
138
  ```bash
118
139
  certbot certonly \
@@ -122,7 +143,7 @@ certbot certonly \
122
143
  -d example.com
123
144
  ```
124
145
 
125
- Test run
146
+ Test run (Skipping the final certificate issuance)
126
147
  ```bash
127
148
  certbot certonly \
128
149
  --register-unsafely-without-email \
@@ -133,8 +154,22 @@ certbot certonly \
133
154
  --dry-run
134
155
  ```
135
156
 
157
+ Test run a wildcard certificate for `example.com`(Skipping the final certificate issuance)
158
+ ```bash
159
+ certbot certonly \
160
+ --register-unsafely-without-email \
161
+ -a dns-dnspod-109 \
162
+ --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
163
+ --dns-dnspod-109-propagation-seconds 60 \
164
+ -v \
165
+ --dry-run \
166
+ -d example.com \
167
+ -d *.example.com
168
+ ```
169
+
170
+
136
171
  ## Parameter Description
137
- ``--dns-dnspod-109-credentials <path>`` points to the credentials file. (Required)
172
+ ``--dns-dnspod-109-credentials <path>`` Path to the credential file (required)
138
173
 
139
174
  ``--dns-dnspod-109-propagation-seconds`` The number of seconds to wait for DNS propagation before asking the ACME server to verify DNS records. If DNS records appear to be added successfully but verification fails, try increasing this value. (Default: 10)
140
175
 
@@ -151,12 +186,16 @@ certbot certonly \
151
186
 
152
187
  ## 只是另一个适用于 Certbot 的 DNSPod DNS Authenticator 插件
153
188
 
189
+ 只是另一个适用于 [Certbot](https://certbot.eff.org/) 的 Tencent Cloud DNSPod DNS Authenticator 插件
190
+
154
191
  `certbot-dns-dnspod-109` 插件通过使用 Dnspod API(腾讯云 API 3.0)创建并随后删除 TXT 记录,自动完成`dns-01` 质询(`~acme.challenges.DNS01`)。
155
192
 
156
193
  如果你使用 [Dnspod](https://www.dnspod.cn/) ([腾讯云](https://cloud.tencent.com)) 作为你的域名解析服务提供商,那么这就是你的插件。
157
194
 
158
195
  在以下版本中测试通过
159
196
  - Certbot 3.0.1
197
+ - Certbot 5.1.0
198
+ - Certbot 5.2.2
160
199
 
161
200
  ## 使用方法
162
201
 
@@ -172,6 +211,9 @@ pip install git+https://github.com/10935336/certbot-dns-dnspod-109.git
172
211
  ```
173
212
 
174
213
  #### snap:
214
+
215
+ snap 版本已弃用,将不会收到 v1.0.2 版本之后的更新
216
+
175
217
  ```bash
176
218
  snap install certbot-dns-dnspod-10935336
177
219
  snap set certbot trust-plugin-with-root=ok
@@ -186,12 +228,17 @@ snap connect certbot:plugin certbot-dns-dnspod-10935336
186
228
 
187
229
  ### 3. 准备凭证文件
188
230
 
189
- foobar.ini:
231
+ foobar.ini:
190
232
  ```ini
191
233
  dns_dnspod_109_secret_id=foo
192
234
  dns_dnspod_109_secret_key=bar
193
235
  ```
194
236
 
237
+ 可选参数:
238
+ ```ini
239
+ dns_dnspod_109_endpoint=dnspod.tencentcloudapi.com
240
+ ```
241
+
195
242
  ### 4. 准备就绪
196
243
 
197
244
  #### 使用示例
@@ -200,30 +247,40 @@ dns_dnspod_109_secret_key=bar
200
247
 
201
248
  ```bash
202
249
  certbot certonly \
203
- -a dns-dnspod-109 \
204
- --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
205
- -d example.com
250
+ -a dns-dnspod-109 \
251
+ --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
252
+ -d example.com
206
253
  ```
207
254
 
208
255
  获取同时有 `example.com` 和 `www.example.com` 的单个证书
209
256
  ```bash
210
257
  certbot certonly \
211
- -a dns-dnspod-109 \
212
- --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
213
- -d example.com \
214
- -d www.example.com
258
+ -a dns-dnspod-109 \
259
+ --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
260
+ -d example.com \
261
+ -d www.example.com
215
262
  ```
216
263
 
264
+ 获取 `example.com` 的泛域名证书
265
+ ```bash
266
+ certbot certonly \
267
+ -a dns-dnspod-109 \
268
+ --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
269
+ -d example.com \
270
+ -d *.example.com
271
+ ```
272
+
273
+
217
274
  获取 `example.com` 的证书,但设置等待 60 秒(等待 DNS 传播)
218
275
  ```bash
219
276
  certbot certonly \
220
- -a dns-dnspod-109 \
221
- --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
222
- --dns-dnspod-109-propagation-seconds 60 \
223
- -d example.com
277
+ -a dns-dnspod-109 \
278
+ --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
279
+ --dns-dnspod-109-propagation-seconds 60 \
280
+ -d example.com
224
281
  ```
225
282
 
226
- 测试运行
283
+ 测试运行(跳过最终证书颁发)
227
284
  ```bash
228
285
  certbot certonly \
229
286
  --register-unsafely-without-email \
@@ -234,8 +291,22 @@ certbot certonly \
234
291
  --dry-run
235
292
  ```
236
293
 
294
+ 测试运行,获取 `example.com` 的泛域名证书(跳过最终证书颁发)
295
+ ```bash
296
+ certbot certonly \
297
+ --register-unsafely-without-email \
298
+ -a dns-dnspod-109 \
299
+ --dns-dnspod-109-credentials ~/.secrets/certbot/dnspod-109.ini \
300
+ --dns-dnspod-109-propagation-seconds 60 \
301
+ -v \
302
+ --dry-run \
303
+ -d example.com \
304
+ -d *.example.com
305
+ ```
306
+
307
+
237
308
  ## 参数说明
238
- ``--dns-dnspod-109-credentials <路径>`` 指向凭证文件。(必需)
309
+ ``--dns-dnspod-109-credentials <路径>`` 指向凭证文件的路径(必需)
239
310
 
240
- ``--dns-dnspod-109-propagation-seconds`` 在要求 ACME 服务器验证 DNS 记录之前等待 DNS 传播的秒数。如果显示 DNS 记录添加成功但验证失败,则尝试增加此值 (默认值:10)
311
+ ``--dns-dnspod-109-propagation-seconds`` 在要求 ACME 服务器验证 DNS 记录之前等待 DNS 传播的秒数。如果显示 DNS 记录添加成功但验证失败,则尝试增加此值 (默认值:10)
241
312
 
@@ -0,0 +1,11 @@
1
+ certbot_dns_dnspod_109/__init__.py,sha256=qvnjZ72P7cvpavn66_Ws2c7s0Ft6OJpaSiIP8bAtYmc,3479
2
+ certbot_dns_dnspod_109/_internal/__init__.py,sha256=TxsS2fQuUdzQP-YoerMhKrCrodqACPybbe2JDGdtcZw,95
3
+ certbot_dns_dnspod_109/_internal/dns_dnspod.py,sha256=SXBWvXr7NaifW-WE-LYF3jYqqdh2h8hwHnjPG5XlcOU,12850
4
+ certbot_dns_dnspod_109/_internal/tests/__init__.py,sha256=rjj8qB-_GHbjCMAlaq6MKyzVtE_NWf5A6NGYGC8JAPk,68
5
+ certbot_dns_dnspod_109/_internal/tests/dns_dnspod_test.py,sha256=56PwuKH6lGdLhZzI3DrHmEDJqyZWqaCsknrd9_-hqB4,13510
6
+ certbot_dns_dnspod_109-1.0.3.dist-info/LICENSE.txt,sha256=LMXecVrlqXbwhvukkwiAyeQhkUFz_IURG_9RTUdXEj8,10786
7
+ certbot_dns_dnspod_109-1.0.3.dist-info/METADATA,sha256=DEObA_RWSXXhPeHReRI6a3BQoyYWrnBTO2UyP_Uc3yk,8719
8
+ certbot_dns_dnspod_109-1.0.3.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
9
+ certbot_dns_dnspod_109-1.0.3.dist-info/entry_points.txt,sha256=ri1pHfGGa7B3kLCWkTGn8Xg32rdNTLqBv4pDHwD8OUs,93
10
+ certbot_dns_dnspod_109-1.0.3.dist-info/top_level.txt,sha256=TZ8F7YwXuC4Q35y9QMVXnIAAf2aEEhxyBCQVwIK5yvg,23
11
+ certbot_dns_dnspod_109-1.0.3.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- certbot_dns_dnspod_109/__init__.py,sha256=qvnjZ72P7cvpavn66_Ws2c7s0Ft6OJpaSiIP8bAtYmc,3479
2
- certbot_dns_dnspod_109/_internal/__init__.py,sha256=TxsS2fQuUdzQP-YoerMhKrCrodqACPybbe2JDGdtcZw,95
3
- certbot_dns_dnspod_109/_internal/dns_dnspod.py,sha256=nNz-Fyve_1an-D-cMthvV2IcswrrffU-M5hZ2cB6KGM,6393
4
- certbot_dns_dnspod_109/_internal/tests/__init__.py,sha256=rjj8qB-_GHbjCMAlaq6MKyzVtE_NWf5A6NGYGC8JAPk,68
5
- certbot_dns_dnspod_109/_internal/tests/dns_dnspod_test.py,sha256=6J7Rgk7iEeW9J_32bpIx4wmQ9aiyyi7koCNXBbj5fTo,8864
6
- certbot_dns_dnspod_109-1.0.2.dist-info/LICENSE.txt,sha256=LMXecVrlqXbwhvukkwiAyeQhkUFz_IURG_9RTUdXEj8,10786
7
- certbot_dns_dnspod_109-1.0.2.dist-info/METADATA,sha256=VgBZIy_c4Dzu_GhEGF2fJpRSgHzwM0AIP60HS1_6vkA,6853
8
- certbot_dns_dnspod_109-1.0.2.dist-info/WHEEL,sha256=PZUExdf71Ui_so67QXpySuHtCi3-J3wvF4ORK6k_S8U,91
9
- certbot_dns_dnspod_109-1.0.2.dist-info/entry_points.txt,sha256=ri1pHfGGa7B3kLCWkTGn8Xg32rdNTLqBv4pDHwD8OUs,93
10
- certbot_dns_dnspod_109-1.0.2.dist-info/top_level.txt,sha256=TZ8F7YwXuC4Q35y9QMVXnIAAf2aEEhxyBCQVwIK5yvg,23
11
- certbot_dns_dnspod_109-1.0.2.dist-info/RECORD,,