certbot-dns-alias 0.1.0__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.
@@ -0,0 +1 @@
1
+ """CNAME delegation for Certbot's DNS-01 authenticator."""
@@ -0,0 +1,95 @@
1
+ """Validate Certbot INI credentials and construct provider accounts."""
2
+
3
+ from certbot import errors
4
+ from certbot.plugins.dns_common import CredentialsConfiguration
5
+
6
+ from certbot_dns_alias.dns import normalize_name
7
+ from certbot_dns_alias.providers.aliyun import AliyunDNSProvider
8
+ from certbot_dns_alias.providers.base import DNSProvider, ZoneRouter
9
+ from certbot_dns_alias.providers.tencent import TencentDNSProvider
10
+
11
+
12
+ def setting(credentials: CredentialsConfiguration, key: str, default: str = "") -> str:
13
+ value = credentials.conf(key)
14
+ if value is None:
15
+ return default
16
+ if not isinstance(value, str):
17
+ raise errors.PluginError(f"dns_alias_{key} must be a single value")
18
+ return value.strip()
19
+
20
+
21
+ def provider_names(credentials: CredentialsConfiguration) -> list[str]:
22
+ mode = setting(credentials, "provider").lower()
23
+ if mode not in {"aliyun", "tencent", "auto"}:
24
+ raise errors.PluginError("dns_alias_provider must be aliyun, tencent, or auto")
25
+ required = {
26
+ "aliyun": {
27
+ "aliyun_access_key_id": "Alibaba Cloud AccessKey ID",
28
+ "aliyun_access_key_secret": "Alibaba Cloud AccessKey secret",
29
+ },
30
+ "tencent": {
31
+ "tencent_secret_id": "Tencent Cloud SecretId",
32
+ "tencent_secret_key": "Tencent Cloud SecretKey",
33
+ },
34
+ }
35
+ names = (
36
+ [mode]
37
+ if mode != "auto"
38
+ else [
39
+ name
40
+ for name, keys in required.items()
41
+ if any(credentials.conf(key) is not None for key in keys)
42
+ ]
43
+ )
44
+ if not names:
45
+ raise errors.PluginError("dns_alias_provider=auto requires at least one provider's keys")
46
+ for name in names:
47
+ credentials.require(required[name])
48
+ for key in required[name]:
49
+ if not setting(credentials, key):
50
+ raise errors.PluginError(f"dns_alias_{key} must not be empty")
51
+ return names
52
+
53
+
54
+ def configured_zones(credentials: CredentialsConfiguration, provider: str) -> list[str] | None:
55
+ value = credentials.conf(f"{provider}_zones")
56
+ if value is None:
57
+ return None
58
+ # ConfigObj parses an unquoted comma-separated INI value as a list.
59
+ parts = value if isinstance(value, list) else value.split(",")
60
+ if not parts or any(not isinstance(part, str) or not part.strip() for part in parts):
61
+ raise errors.PluginError(f"dns_alias_{provider}_zones must contain DNS zone names")
62
+ return [normalize_name(part) for part in parts]
63
+
64
+
65
+ def validate_credentials(credentials: CredentialsConfiguration) -> None:
66
+ for name in provider_names(credentials):
67
+ configured_zones(credentials, name)
68
+ optional = (
69
+ ["aliyun_region_id", "aliyun_security_token"] if name == "aliyun" else ["tencent_token"]
70
+ )
71
+ for key in optional:
72
+ setting(credentials, key)
73
+
74
+
75
+ def build_router(credentials: CredentialsConfiguration) -> ZoneRouter:
76
+ providers: dict[str, DNSProvider] = {}
77
+ zones = {}
78
+ for name in provider_names(credentials):
79
+ explicit = configured_zones(credentials, name)
80
+ if explicit is not None:
81
+ zones[name] = explicit
82
+ if name == "aliyun":
83
+ providers[name] = AliyunDNSProvider(
84
+ setting(credentials, "aliyun_access_key_id"),
85
+ setting(credentials, "aliyun_access_key_secret"),
86
+ region_id=setting(credentials, "aliyun_region_id", "cn-hangzhou"),
87
+ security_token=setting(credentials, "aliyun_security_token") or None,
88
+ )
89
+ else:
90
+ providers[name] = TencentDNSProvider(
91
+ setting(credentials, "tencent_secret_id"),
92
+ setting(credentials, "tencent_secret_key"),
93
+ token=setting(credentials, "tencent_token") or None,
94
+ )
95
+ return ZoneRouter(providers, zones)
@@ -0,0 +1,115 @@
1
+ """DNS name handling and bounded CNAME resolution."""
2
+
3
+ import logging
4
+ import re
5
+ import time
6
+
7
+ import dns.exception
8
+ import dns.name
9
+ import dns.rdatatype
10
+ import dns.resolver
11
+ from certbot import errors
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def normalize_name(value: str) -> str:
17
+ """Return an absolute, lower-case ASCII DNS name without its trailing dot."""
18
+ try:
19
+ name = dns.name.from_text(value.strip()).canonicalize()
20
+ if name == dns.name.root or any(
21
+ not re.fullmatch(rb"[a-z0-9_-]+", label) for label in name.labels[:-1]
22
+ ):
23
+ raise ValueError("invalid label")
24
+ return name.to_text(omit_final_dot=True)
25
+ except (dns.exception.DNSException, UnicodeError, ValueError) as exc:
26
+ raise errors.PluginError(f"Invalid DNS name: {value!r}") from exc
27
+
28
+
29
+ def relative_name(fqdn: str, zone: str) -> str:
30
+ """Return a provider's host record, using @ for the zone apex."""
31
+ fqdn, zone = normalize_name(fqdn), normalize_name(zone)
32
+ if fqdn == zone:
33
+ return "@"
34
+ if fqdn.endswith("." + zone):
35
+ return fqdn[: -len(zone) - 1]
36
+ raise errors.PluginError(f"DNS name {fqdn} is outside zone {zone}")
37
+
38
+
39
+ class CnameResolver:
40
+ """Follow individual CNAME links, including links in negative DNS answers."""
41
+
42
+ def __init__(
43
+ self,
44
+ resolver: dns.resolver.Resolver | None = None,
45
+ *,
46
+ max_depth: int = 8,
47
+ timeout: float = 10,
48
+ retries: int = 2,
49
+ ) -> None:
50
+ if max_depth < 1 or timeout <= 0 or retries < 0:
51
+ raise errors.PluginError("Invalid CNAME resolver limits")
52
+ self.resolver = resolver if resolver is not None else dns.resolver.Resolver()
53
+ self.max_depth = max_depth
54
+ self.timeout = timeout
55
+ self.retries = retries
56
+
57
+ def resolve(self, fqdn: str, *, require_cname: bool = False) -> str:
58
+ original = current = normalize_name(fqdn)
59
+ visited = {current}
60
+ # One terminal query beyond the allowed number of links is necessary:
61
+ # a chain of exactly max_depth links must still be accepted.
62
+ for depth in range(self.max_depth + 1):
63
+ target = self._target(current)
64
+ if target is None:
65
+ if require_cname and current == original:
66
+ raise errors.PluginError(f"CNAME delegation required for {original}")
67
+ return current
68
+ if target in visited:
69
+ raise errors.PluginError(f"CNAME loop detected for {original} at {target}")
70
+ if depth == self.max_depth:
71
+ raise errors.PluginError(
72
+ f"CNAME chain exceeds {self.max_depth} links for {original}"
73
+ )
74
+ logger.debug("CNAME delegation: %s -> %s", current, target)
75
+ visited.add(target)
76
+ current = target
77
+ raise AssertionError("unreachable")
78
+
79
+ def _target(self, current: str) -> str | None:
80
+ for attempt in range(self.retries + 1):
81
+ try:
82
+ answer = self.resolver.resolve(
83
+ current + ".",
84
+ "CNAME",
85
+ lifetime=self.timeout,
86
+ search=False,
87
+ raise_on_no_answer=False,
88
+ )
89
+ responses = [answer.response]
90
+ except dns.resolver.NXDOMAIN as exc:
91
+ # NXDOMAIN can refer to the CNAME's destination rather than
92
+ # the queried name. Preserve the CNAME in the answer section.
93
+ responses = list(exc.responses().values())
94
+ except dns.resolver.NoAnswer as exc:
95
+ responses = [exc.response()]
96
+ except (dns.exception.Timeout, dns.resolver.NoNameservers) as exc:
97
+ if attempt == self.retries:
98
+ raise errors.PluginError(
99
+ f"Unable to resolve CNAME for {current} after {attempt + 1} attempts "
100
+ f"({type(exc).__name__}); check DNS connectivity and resolvers"
101
+ ) from exc
102
+ time.sleep(min(2**attempt, 4))
103
+ continue
104
+ except dns.exception.DNSException as exc:
105
+ raise errors.PluginError(f"CNAME lookup failed for {current}") from exc
106
+
107
+ owner = dns.name.from_text(current + ".")
108
+ for response in responses:
109
+ for rrset in response.answer:
110
+ if rrset.name == owner and rrset.rdtype == dns.rdatatype.CNAME:
111
+ if len(rrset) != 1:
112
+ raise errors.PluginError(f"Invalid CNAME RRset for {current}")
113
+ return normalize_name(rrset[0].target.to_text())
114
+ return None
115
+ raise AssertionError("unreachable")
@@ -0,0 +1,169 @@
1
+ """Certbot DNS authenticator with CNAME delegation to Aliyun and DNSPod."""
2
+
3
+ import ipaddress
4
+ import logging
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ import dns.resolver
10
+ from certbot import errors
11
+ from certbot.plugins import dns_common
12
+
13
+ from certbot_dns_alias.config import build_router, validate_credentials
14
+ from certbot_dns_alias.dns import CnameResolver, normalize_name, relative_name
15
+ from certbot_dns_alias.providers.base import DNSProvider, ZoneRouter
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class _RecordLease:
22
+ provider: DNSProvider
23
+ zone: str
24
+ target: str
25
+ record_id: str
26
+ created: bool
27
+ users: int = 1
28
+
29
+
30
+ class Authenticator(dns_common.DNSAuthenticator):
31
+ """Place each ACME TXT value in its final CNAME target's managed zone."""
32
+
33
+ description = "DNS-01 with CNAME delegation to Alibaba Cloud DNS or Tencent Cloud DNSPod."
34
+ ttl = 600
35
+
36
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
37
+ super().__init__(*args, **kwargs)
38
+ self.credentials: dns_common.CredentialsConfiguration | None = None
39
+ self._router: ZoneRouter | None = None
40
+ self._resolver: CnameResolver | None = None
41
+ self._challenges: dict[tuple[str, str], list[tuple[str, str, str, str]]] = {}
42
+ self._leases: dict[tuple[str, str, str, str], _RecordLease] = {}
43
+
44
+ @classmethod
45
+ def add_parser_arguments(cls, add, default_propagation_seconds: int = 60) -> None:
46
+ super().add_parser_arguments(add, default_propagation_seconds)
47
+ add("credentials", help="Path to the dns-alias provider credentials INI file.")
48
+ add("ttl", type=int, default=600, help="TXT record TTL in seconds (default: 600).")
49
+ add("cname-max-depth", type=int, default=8, help="Maximum CNAME links (default: 8).")
50
+ add("dns-timeout", type=float, default=10, help="DNS query lifetime (default: 10 seconds).")
51
+ add("dns-retries", type=int, default=2, help="Retries per failed DNS query (default: 2).")
52
+ add("resolvers", help="Comma-separated DNS resolver IPv4/IPv6 addresses.")
53
+ add(
54
+ "require-cname",
55
+ action="store_true",
56
+ default=False,
57
+ help="Fail if the original challenge name has no CNAME delegation.",
58
+ )
59
+
60
+ def more_info(self) -> str:
61
+ return (
62
+ "Follows _acme-challenge CNAME chains and manages individual TXT records "
63
+ "in Alibaba Cloud DNS or Tencent Cloud DNSPod. Select aliyun, tencent, or auto "
64
+ "in the credentials file. Cleanup uses saved record IDs and delegation targets."
65
+ )
66
+
67
+ def _setup_credentials(self) -> None:
68
+ limits = {"ttl": 1, "cname-max-depth": 1, "dns-retries": 0}
69
+ for option, minimum in limits.items():
70
+ value = self.conf(option)
71
+ if value is None or value < minimum:
72
+ raise errors.PluginError(f"--dns-alias-{option} must be at least {minimum}")
73
+ timeout = self.conf("dns-timeout")
74
+ if timeout is None or not math.isfinite(timeout) or timeout <= 0:
75
+ raise errors.PluginError("--dns-alias-dns-timeout must be a finite positive number")
76
+ propagation = self.conf("propagation-seconds")
77
+ if propagation is None or propagation < 0:
78
+ raise errors.PluginError("--dns-alias-propagation-seconds must be non-negative")
79
+
80
+ resolver = dns.resolver.Resolver()
81
+ if self.conf("resolvers"):
82
+ try:
83
+ resolver.nameservers = [
84
+ str(ipaddress.ip_address(address.strip()))
85
+ for address in self.conf("resolvers").split(",")
86
+ ]
87
+ except ValueError as exc:
88
+ raise errors.PluginError(
89
+ "--dns-alias-resolvers must be comma-separated IPv4/IPv6 addresses"
90
+ ) from exc
91
+ self._resolver = CnameResolver(
92
+ resolver,
93
+ max_depth=self.conf("cname-max-depth"),
94
+ timeout=timeout,
95
+ retries=self.conf("dns-retries"),
96
+ )
97
+ self.credentials = self._configure_credentials(
98
+ "credentials",
99
+ "DNS alias credentials INI file",
100
+ None,
101
+ validate_credentials,
102
+ )
103
+ self._router = build_router(self.credentials)
104
+ self.ttl = self.conf("ttl")
105
+
106
+ def _perform(self, domain: str, validation_name: str, validation: str) -> None:
107
+ if self._resolver is None or self._router is None:
108
+ raise errors.PluginError("DNS alias credentials have not been configured")
109
+ challenge = (normalize_name(validation_name), validation)
110
+ target = self._resolver.resolve(
111
+ validation_name,
112
+ require_cname=self.conf("require-cname"),
113
+ )
114
+ provider, zone = self._router.find(target)
115
+ record_key = (provider.name, zone, target, validation)
116
+ if record_key in self._leases:
117
+ self._leases[record_key].users += 1
118
+ else:
119
+ host = relative_name(target, zone)
120
+ records = provider.list_txt_records(zone, host)
121
+ existing = next((record for record in records if record.value == validation), None)
122
+ record_id = (
123
+ existing.id
124
+ if existing
125
+ else provider.create_txt_record(
126
+ zone,
127
+ host,
128
+ validation,
129
+ self.ttl,
130
+ )
131
+ )
132
+ self._leases[record_key] = _RecordLease(
133
+ provider,
134
+ zone,
135
+ target,
136
+ record_id,
137
+ created=existing is None,
138
+ )
139
+ logger.info(
140
+ "TXT challenge ready: %s -> %s (%s)", validation_name, target, provider.name
141
+ )
142
+ # Keep no cleanup state for a write that failed. Keys include the TXT
143
+ # value because apex and wildcard challenges share validation names.
144
+ self._challenges.setdefault(challenge, []).append(record_key)
145
+
146
+ def _cleanup(self, domain: str, validation_name: str, validation: str) -> None:
147
+ challenge = (normalize_name(validation_name), validation)
148
+ keys = self._challenges.get(challenge)
149
+ if not keys:
150
+ return
151
+ record_key = keys[-1]
152
+ lease = self._leases[record_key]
153
+ if lease.users == 1 and lease.created:
154
+ try:
155
+ lease.provider.delete_txt_record(lease.zone, lease.record_id)
156
+ except errors.PluginError as exc:
157
+ # Certbot continues cleaning other challenges. Keep our saved
158
+ # ID so a subsequent cleanup call can retry this deletion.
159
+ logger.warning(
160
+ "Unable to clean TXT record %s (%s): %s", lease.target, lease.provider.name, exc
161
+ )
162
+ return
163
+ logger.info("Cleaned TXT challenge at %s (%s)", lease.target, lease.provider.name)
164
+ lease.users -= 1
165
+ keys.pop()
166
+ if not keys:
167
+ del self._challenges[challenge]
168
+ if lease.users == 0:
169
+ del self._leases[record_key]
@@ -0,0 +1 @@
1
+ """Cloud DNS provider adapters."""
@@ -0,0 +1,127 @@
1
+ """Alibaba Cloud DNS adapter using the official OpenAPI SDK."""
2
+
3
+ from alibabacloud_alidns20150109 import models
4
+ from alibabacloud_alidns20150109.client import Client
5
+ from alibabacloud_tea_openapi.models import Config
6
+ from alibabacloud_tea_util.models import RuntimeOptions
7
+ from certbot import errors
8
+ from Tea.exceptions import TeaException, UnretryableException
9
+
10
+ from certbot_dns_alias.providers.base import DNSProvider, TxtRecord
11
+
12
+
13
+ class AliyunDNSProvider(DNSProvider):
14
+ name = "aliyun"
15
+ page_size = 100
16
+
17
+ def __init__(
18
+ self,
19
+ access_key_id: str,
20
+ access_key_secret: str,
21
+ *,
22
+ region_id: str = "cn-hangzhou",
23
+ security_token: str | None = None,
24
+ ) -> None:
25
+ self.client = Client(
26
+ Config(
27
+ access_key_id=access_key_id,
28
+ access_key_secret=access_key_secret,
29
+ security_token=security_token,
30
+ region_id=region_id,
31
+ endpoint="alidns.aliyuncs.com",
32
+ )
33
+ )
34
+ # Never automatically replay a write whose result may have been lost.
35
+ self.runtime = RuntimeOptions(
36
+ connect_timeout=10000,
37
+ read_timeout=30000,
38
+ autoretry=False,
39
+ )
40
+
41
+ def _call(self, operation: str, request):
42
+ try:
43
+ return getattr(self.client, operation)(request, self.runtime).body
44
+ except (TeaException, UnretryableException) as exc:
45
+ # SDK messages can contain request details. Report only the code.
46
+ code = getattr(exc, "code", None) or type(exc).__name__
47
+ raise errors.PluginError(f"Aliyun DNS {operation} failed ({code})") from exc
48
+
49
+ def list_zones(self) -> list[str]:
50
+ zones = []
51
+ page = 1
52
+ while True:
53
+ body = self._call(
54
+ "describe_domains_with_options",
55
+ models.DescribeDomainsRequest(
56
+ page_number=page,
57
+ page_size=self.page_size,
58
+ ),
59
+ )
60
+ items = body.domains.domain if body.domains else []
61
+ items = items or []
62
+ zones.extend(item.domain_name for item in items)
63
+ if page * self.page_size >= body.total_count:
64
+ return zones
65
+ if not items:
66
+ raise errors.PluginError("Aliyun DNS returned an incomplete zone list")
67
+ page += 1
68
+
69
+ def list_txt_records(self, zone: str, name: str) -> list[TxtRecord]:
70
+ records = []
71
+ page = 1
72
+ while True:
73
+ body = self._call(
74
+ "describe_domain_records_with_options",
75
+ models.DescribeDomainRecordsRequest(
76
+ domain_name=zone,
77
+ rrkey_word=name,
78
+ type="TXT",
79
+ search_mode="EXACT",
80
+ page_number=page,
81
+ page_size=self.page_size,
82
+ ),
83
+ )
84
+ items = body.domain_records.record if body.domain_records else []
85
+ items = items or []
86
+ records.extend(
87
+ TxtRecord(str(item.record_id), item.rr, item.value)
88
+ for item in items
89
+ if item.type == "TXT"
90
+ and item.rr.lower() == name.lower()
91
+ and item.status == "ENABLE"
92
+ and item.line == "default"
93
+ )
94
+ if page * self.page_size >= body.total_count:
95
+ return records
96
+ if not items:
97
+ raise errors.PluginError("Aliyun DNS returned an incomplete TXT record list")
98
+ page += 1
99
+
100
+ def create_txt_record(self, zone: str, name: str, value: str, ttl: int) -> str:
101
+ body = self._call(
102
+ "add_domain_record_with_options",
103
+ models.AddDomainRecordRequest(
104
+ domain_name=zone,
105
+ rr=name,
106
+ type="TXT",
107
+ value=value,
108
+ ttl=ttl,
109
+ line="default",
110
+ ),
111
+ )
112
+ if not body.record_id:
113
+ raise errors.PluginError("Aliyun DNS did not return a new TXT record ID")
114
+ return str(body.record_id)
115
+
116
+ def delete_txt_record(self, zone: str, record_id: str) -> None:
117
+ try:
118
+ self._call(
119
+ "delete_domain_record_with_options",
120
+ models.DeleteDomainRecordRequest(
121
+ record_id=record_id,
122
+ ),
123
+ )
124
+ except errors.PluginError as exc:
125
+ # DeleteDomainRecord takes only a record ID, not a zone name.
126
+ if getattr(exc.__cause__, "code", None) != "InvalidRecordId.NotFound":
127
+ raise
@@ -0,0 +1,81 @@
1
+ """Shared provider contract and managed-zone selection."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+
6
+ from certbot import errors
7
+
8
+ from certbot_dns_alias.dns import normalize_name
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TxtRecord:
13
+ id: str
14
+ name: str
15
+ value: str
16
+
17
+
18
+ class DNSProvider(ABC):
19
+ """Manage individual TXT records without replacing a TXT RRset."""
20
+
21
+ name: str
22
+
23
+ @abstractmethod
24
+ def list_zones(self) -> list[str]:
25
+ """List all managed DNS zones, including all result pages."""
26
+
27
+ @abstractmethod
28
+ def list_txt_records(self, zone: str, name: str) -> list[TxtRecord]:
29
+ """List TXT records at an exact relative name, including all pages."""
30
+
31
+ @abstractmethod
32
+ def create_txt_record(self, zone: str, name: str, value: str, ttl: int) -> str:
33
+ """Create one TXT value and return its record ID."""
34
+
35
+ @abstractmethod
36
+ def delete_txt_record(self, zone: str, record_id: str) -> None:
37
+ """Delete the saved record ID. An already absent record is success."""
38
+
39
+
40
+ class ZoneRouter:
41
+ """Select the longest matching zone across configured provider accounts."""
42
+
43
+ def __init__(
44
+ self,
45
+ providers: dict[str, DNSProvider],
46
+ zones: dict[str, list[str]] | None = None,
47
+ ) -> None:
48
+ self.providers = providers
49
+ self.zones = zones or {}
50
+ self._loaded_zones: dict[str, list[str]] | None = None
51
+
52
+ def find(self, fqdn: str) -> tuple[DNSProvider, str]:
53
+ fqdn = normalize_name(fqdn)
54
+ if self._loaded_zones is None:
55
+ # Cache only a complete discovery; API/permission errors must not
56
+ # silently route to a less specific zone on another provider.
57
+ discovered = {}
58
+ for name, provider in self.providers.items():
59
+ candidates = self.zones[name] if name in self.zones else provider.list_zones()
60
+ discovered[name] = sorted({normalize_name(zone) for zone in candidates})
61
+ self._loaded_zones = discovered
62
+
63
+ matches = [
64
+ (zone.count("."), name, zone)
65
+ for name, zones in self._loaded_zones.items()
66
+ for zone in zones
67
+ if fqdn == zone or fqdn.endswith("." + zone)
68
+ ]
69
+ if not matches:
70
+ raise errors.PluginError(
71
+ f"No managed zone found for {fqdn}; check credentials and configured zones"
72
+ )
73
+ best_depth = max(item[0] for item in matches)
74
+ best = [item for item in matches if item[0] == best_depth]
75
+ if len(best) != 1:
76
+ raise errors.PluginError(
77
+ f"Ambiguous managed zone for {fqdn} on multiple providers; "
78
+ "set provider-specific zones or select a single provider"
79
+ )
80
+ _, provider_name, zone = best[0]
81
+ return self.providers[provider_name], zone
@@ -0,0 +1,106 @@
1
+ """Tencent Cloud DNSPod adapter using the official API v20210323 SDK."""
2
+
3
+ from certbot import errors
4
+ from tencentcloud.common.credential import Credential
5
+ from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException
6
+ from tencentcloud.common.profile.client_profile import ClientProfile
7
+ from tencentcloud.common.profile.http_profile import HttpProfile
8
+ from tencentcloud.dnspod.v20210323 import dnspod_client, models
9
+
10
+ from certbot_dns_alias.providers.base import DNSProvider, TxtRecord
11
+
12
+
13
+ class TencentDNSProvider(DNSProvider):
14
+ name = "tencent"
15
+ page_size = 100
16
+
17
+ def __init__(self, secret_id: str, secret_key: str, *, token: str | None = None) -> None:
18
+ http = HttpProfile(endpoint="dnspod.tencentcloudapi.com", reqTimeout=30)
19
+ self.client = dnspod_client.DnspodClient(
20
+ Credential(secret_id, secret_key, token),
21
+ "",
22
+ ClientProfile(httpProfile=http),
23
+ )
24
+
25
+ def _call(self, operation: str, request):
26
+ try:
27
+ return getattr(self.client, operation)(request)
28
+ except TencentCloudSDKException as exc:
29
+ raise errors.PluginError(
30
+ f"Tencent DNSPod {operation} failed ({exc.get_code()})"
31
+ ) from exc
32
+
33
+ def list_zones(self) -> list[str]:
34
+ zones = []
35
+ offset = 0
36
+ while True:
37
+ request = models.DescribeDomainListRequest()
38
+ request.Type = "ALL"
39
+ request.Offset = offset
40
+ request.Limit = self.page_size
41
+ body = self._call("DescribeDomainList", request)
42
+ items = body.DomainList or []
43
+ zones.extend(item.Name for item in items)
44
+ offset += len(items)
45
+ if offset >= body.DomainCountInfo.DomainTotal:
46
+ return zones
47
+ if not items:
48
+ raise errors.PluginError("Tencent DNSPod returned an incomplete zone list")
49
+
50
+ def list_txt_records(self, zone: str, name: str) -> list[TxtRecord]:
51
+ records = []
52
+ offset = 0
53
+ while True:
54
+ request = models.DescribeRecordListRequest()
55
+ request.Domain = zone
56
+ request.SubDomain = name
57
+ request.RecordType = "TXT"
58
+ request.Offset = offset
59
+ request.Limit = self.page_size
60
+ request.ErrorOnEmpty = "no"
61
+ # Older API/SDK versions raise this code for an empty result.
62
+ # Do not confuse it with a missing zone or a permission failure.
63
+ try:
64
+ body = self._call("DescribeRecordList", request)
65
+ except errors.PluginError as exc:
66
+ if getattr(exc.__cause__, "code", None) == "ResourceNotFound.NoDataOfRecord":
67
+ return records
68
+ raise
69
+ items = body.RecordList or []
70
+ records.extend(
71
+ TxtRecord(str(item.RecordId), item.Name, item.Value)
72
+ for item in items
73
+ if item.Type == "TXT"
74
+ and item.Name.lower() == name.lower()
75
+ and item.Status == "ENABLE"
76
+ and item.LineId == "0"
77
+ )
78
+ offset += len(items)
79
+ if offset >= body.RecordCountInfo.TotalCount:
80
+ return records
81
+ if not items:
82
+ raise errors.PluginError("Tencent DNSPod returned an incomplete TXT record list")
83
+
84
+ def create_txt_record(self, zone: str, name: str, value: str, ttl: int) -> str:
85
+ request = models.CreateRecordRequest()
86
+ request.Domain = zone
87
+ request.SubDomain = name
88
+ request.RecordType = "TXT"
89
+ request.RecordLine = "默认"
90
+ request.RecordLineId = "0"
91
+ request.Value = value
92
+ request.TTL = ttl
93
+ body = self._call("CreateRecord", request)
94
+ if not body.RecordId:
95
+ raise errors.PluginError("Tencent DNSPod did not return a new TXT record ID")
96
+ return str(body.RecordId)
97
+
98
+ def delete_txt_record(self, zone: str, record_id: str) -> None:
99
+ request = models.DeleteRecordRequest()
100
+ request.Domain = zone
101
+ request.RecordId = int(record_id)
102
+ try:
103
+ self._call("DeleteRecord", request)
104
+ except errors.PluginError as exc:
105
+ if getattr(exc.__cause__, "code", None) != "ResourceNotFound.NoDataOfRecord":
106
+ raise
@@ -0,0 +1,283 @@
1
+ Metadata-Version: 2.4
2
+ Name: certbot-dns-alias
3
+ Version: 0.1.0
4
+ Summary: Certbot DNS-01 plugin with CNAME delegation for Alibaba Cloud DNS and Tencent Cloud DNSPod
5
+ Project-URL: Homepage, https://github.com/tiyee/certbot-dns-alias
6
+ Project-URL: Repository, https://github.com/tiyee/certbot-dns-alias
7
+ Project-URL: Issues, https://github.com/tiyee/certbot-dns-alias/issues
8
+ Author-email: tiyee <tiyee@live.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: acme,aliyun,certbot,cname,dns-01,dnspod,tencent
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Plugins
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Security
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: alibabacloud-alidns20150109<5,>=3.0
25
+ Requires-Dist: alibabacloud-tea-openapi<1,>=0.3
26
+ Requires-Dist: alibabacloud-tea-util<1,>=0.3
27
+ Requires-Dist: certbot<6,>=3.0
28
+ Requires-Dist: dnspython<3,>=2.6
29
+ Requires-Dist: tencentcloud-sdk-python-dnspod<4,>=3.1.130
30
+ Description-Content-Type: text/markdown
31
+
32
+ # certbot-dns-alias
33
+
34
+ Certbot DNS-01 插件,通过 CNAME 委托在阿里云 DNS 或腾讯云 DNSPod 管理 TXT 验证记录。
35
+
36
+ Certbot DNS-01 authentication with CNAME delegation, supporting Alibaba Cloud DNS and Tencent Cloud DNSPod.
37
+
38
+ ## 工作方式
39
+
40
+ 在业务域名的 DNS 中预先创建 CNAME:
41
+
42
+ ```dns
43
+ _acme-challenge.example.com. 300 IN CNAME example-com.delegate.example.net.
44
+ ```
45
+
46
+ `delegate.example.net` 托管在阿里云或腾讯云。插件自动跟随 CNAME 链,在最终目标
47
+ `example-com.delegate.example.net` 添加本次挑战的 TXT 值,等待 DNS 传播,完成后按记录 ID 清理。
48
+ 业务域名可以由任意 DNS 服务商托管;插件只需要目标托管区域的 API 凭据。
49
+
50
+ - 支持多级 CNAME、泛域名、一个证书包含多个域名。
51
+ - 支持阿里云、腾讯云单独使用,或 `auto` 模式在一次申请中同时使用两者。
52
+ - 根据托管区域列表做最长 DNS 后缀匹配,支持 `example.co.uk` 和独立托管的子域,匹配包含标签边界。
53
+ - 区域列表及 TXT 查询支持 API 分页;可以显式配置区域以跳过自动枚举。
54
+ - 每个 TXT 值单独创建,保留同名记录的其他值;复用已有的相同有效 TXT 时不删除原记录。
55
+ - 清理使用创建时保存的目标区域和记录 ID;CNAME 发生变化也不会改删其他区域。
56
+ - CNAME 环路、超深链、DNS 超时、权限错误和区域归属冲突会产生明确错误。
57
+
58
+ Python **3.10+**,Certbot **3.x–5.x**。两个云服务商 SDK 均随插件安装。
59
+
60
+ ## 安装
61
+
62
+ ### 本地开发(uv)
63
+
64
+ ```bash
65
+ uv sync --locked
66
+ uv run certbot plugins --text
67
+ uv run certbot --help dns-alias
68
+ ```
69
+
70
+ `uv.lock` 已纳入版本管理,`uv sync` 会创建 `.venv` 并以 editable 模式安装插件。
71
+
72
+ ### 从 PyPI 安装(本项目发布后)
73
+
74
+ 使用 uv 安装 Certbot 和插件到同一个工具环境:
75
+
76
+ ```bash
77
+ uv tool install --with certbot-dns-alias certbot
78
+ certbot plugins --text
79
+ ```
80
+
81
+ 或者在安装 Certbot 的 Python 虚拟环境中执行:
82
+
83
+ ```bash
84
+ python -m pip install certbot-dns-alias
85
+ ```
86
+
87
+ 插件与 Certbot 必须处于同一 Python 环境。如果已有 snap/docker 版 Certbot,需在该运行环境中
88
+ 安装插件,或者改用上述 uv 工具环境。
89
+
90
+ ## 凭据配置
91
+
92
+ 使用不带 INI section 的 `key = value` 格式。完整示例见 [examples](examples)。
93
+ 复制示例后填入密钥,并设置权限:
94
+
95
+ ```bash
96
+ cp examples/tencent.ini credentials.ini
97
+ chmod 600 credentials.ini
98
+ ```
99
+
100
+ ### 阿里云
101
+
102
+ ```ini
103
+ dns_alias_provider = aliyun
104
+ dns_alias_aliyun_access_key_id = YOUR_ACCESS_KEY_ID
105
+ dns_alias_aliyun_access_key_secret = YOUR_ACCESS_KEY_SECRET
106
+ dns_alias_aliyun_zones = delegate.example.net
107
+ ```
108
+
109
+ 可选项:`dns_alias_aliyun_region_id`(默认 `cn-hangzhou`)、
110
+ `dns_alias_aliyun_security_token`(临时 STS 凭据)。固定使用公共端点 `alidns.aliyuncs.com`。
111
+
112
+ ### 腾讯云 DNSPod
113
+
114
+ ```ini
115
+ dns_alias_provider = tencent
116
+ dns_alias_tencent_secret_id = YOUR_SECRET_ID
117
+ dns_alias_tencent_secret_key = YOUR_SECRET_KEY
118
+ dns_alias_tencent_zones = delegate.example.net
119
+ ```
120
+
121
+ 可选项:`dns_alias_tencent_token`(临时会话凭据)。使用腾讯云 API v20210323 和
122
+ `dnspod.tencentcloudapi.com`,不使用旧版 DNSPod Token API 或国际版端点。
123
+
124
+ ### 同时使用两家云服务商
125
+
126
+ ```ini
127
+ dns_alias_provider = auto
128
+ dns_alias_aliyun_access_key_id = YOUR_ACCESS_KEY_ID
129
+ dns_alias_aliyun_access_key_secret = YOUR_ACCESS_KEY_SECRET
130
+ dns_alias_aliyun_zones = ali-delegate.example.net
131
+ dns_alias_tencent_secret_id = YOUR_SECRET_ID
132
+ dns_alias_tencent_secret_key = YOUR_SECRET_KEY
133
+ dns_alias_tencent_zones = tencent-delegate.example.org
134
+ ```
135
+
136
+ 例如:
137
+
138
+ ```dns
139
+ _acme-challenge.example.com. 300 IN CNAME example-com.ali-delegate.example.net.
140
+ _acme-challenge.api.example.org. 300 IN CNAME api-example-org.tencent-delegate.example.org.
141
+ ```
142
+
143
+ 随后在同一命令中传入 `-d example.com -d api.example.org` 即可。`auto` 至少需要一组完整密钥,
144
+ 也可以只配置一家。如果同一最长匹配区域同时属于两个服务商,插件会报错;通过调整显式区域列表
145
+ 或选择单一 `provider` 消除歧义。
146
+
147
+ 所有 `*_zones` 均为可选项,多个区域以逗号分隔,例如 `example.net, example.co.uk`。
148
+ 配置后只允许这些区域,不再调用对应的域名枚举 API;填写云平台实际托管区域名称,
149
+ 不要填写完整 TXT 主机名。未配置时自动枚举当前凭据可见的区域。
150
+ 每家服务商目前支持一个凭据账号。
151
+
152
+ ## 申请和续期
153
+
154
+ 先创建 CNAME,并确保公共 DNS 可以解析;第一次申请可以使用测试环境:
155
+
156
+ ```bash
157
+ uv run certbot certonly \
158
+ --authenticator dns-alias \
159
+ --dns-alias-credentials "$(pwd)/credentials.ini" \
160
+ --dns-alias-require-cname \
161
+ --dns-alias-propagation-seconds 120 \
162
+ --staging \
163
+ --non-interactive --agree-tos --email admin@example.com \
164
+ -d example.com -d '*.example.com'
165
+ ```
166
+
167
+ 测试通过后去掉 `--staging` 申请正式证书。域名和邮箱应替换为自己的值。
168
+ 如果使用 uv 工具安装,命令前缀用 `certbot`。
169
+ Certbot 默认写入 `/etc/letsencrypt`、`/var/lib/letsencrypt` 和 `/var/log/letsencrypt`,
170
+ 运行账号需要相应权限;也可使用 `--config-dir`、`--work-dir` 和 `--logs-dir` 指定目录。
171
+
172
+ Certbot 保存认证器和凭据文件的绝对路径,后续可使用同一环境续期:
173
+
174
+ ```bash
175
+ uv run certbot renew --dry-run
176
+ uv run certbot renew
177
+ ```
178
+
179
+ 凭据文件需长期保留,临时凭据过期前需更新。按部署方式配置定时续期及证书部署 hook。
180
+
181
+ ### 可配置参数
182
+
183
+ | 参数 | 默认值 | 作用 |
184
+ | --- | --- | --- |
185
+ | `--dns-alias-credentials` | 必填 | 凭据 INI 路径 |
186
+ | `--dns-alias-propagation-seconds` | `60` | 全部 TXT 添加后,统一等待的传播时间 |
187
+ | `--dns-alias-ttl` | `600` | 创建 TXT 时的 TTL,需满足云套餐限制 |
188
+ | `--dns-alias-cname-max-depth` | `8` | 允许的最大 CNAME 链接数 |
189
+ | `--dns-alias-dns-timeout` | `10` | 每次 CNAME 查询的总超时,秒 |
190
+ | `--dns-alias-dns-retries` | `2` | 超时或无可用 nameserver 时额外重试次数 |
191
+ | `--dns-alias-resolvers` | 系统 DNS | 逗号分隔的 DNS 服务器 IPv4/IPv6 地址 |
192
+ | `--dns-alias-require-cname` | 关闭 | 原始挑战名称没有 CNAME 时拒绝写入 |
193
+
194
+ 没有 CNAME 时,默认允许直接在原始挑战名称所属托管区域创建 TXT。只允许委托验证时启用
195
+ `--dns-alias-require-cname`。解析器使用绝对 DNS 名称,禁止系统 search suffix 扩展。
196
+ NXDOMAIN / 无 CNAME 表示链终点;超时、SERVFAIL 等解析失败不会当作链终点。
197
+
198
+ TTL 与传播等待时间不同。第一次创建目标主机可能受 DNS 负缓存影响,必要时提高传播等待时间。
199
+ 不同业务域名应使用不同委托主机名;普通域名与其泛域名会共用 `_acme-challenge` 名称,
200
+ 插件会保留本次申请需要的多个 TXT 值。插件不会更新整个 TXT RRset。
201
+
202
+ ## API 权限
203
+
204
+ 阿里云需要:
205
+
206
+ - `alidns:DescribeDomainRecords`
207
+ - `alidns:AddDomainRecord`
208
+ - `alidns:DeleteDomainRecord`
209
+ - 未配置 `dns_alias_aliyun_zones` 时,还需要 `alidns:DescribeDomains`
210
+
211
+ 腾讯云需要:
212
+
213
+ - `dnspod:DescribeRecordList`
214
+ - `dnspod:CreateRecord`
215
+ - `dnspod:DeleteRecord`
216
+ - 未配置 `dns_alias_tencent_zones` 时,还需要 `dnspod:DescribeDomainList`
217
+
218
+ 可根据云平台支持的资源范围将权限限制在委托区域。API 字段与权限参考
219
+ [阿里云 AddDomainRecord](https://www.alibabacloud.com/help/en/dns/api-alidns-2015-01-09-adddomainrecord)、
220
+ [阿里云 DescribeDomains](https://www.alibabacloud.com/help/en/dns/api-alidns-2015-01-09-describedomains) 和
221
+ [腾讯云 DescribeRecordList](https://cloud.tencent.com/document/api/1427/56166)。
222
+
223
+ ## 测试和构建
224
+
225
+ ```bash
226
+ uv sync --locked
227
+ uv run ruff check .
228
+ uv run ruff format --check .
229
+ uv run pytest --cov=certbot_dns_alias --cov-report=term-missing
230
+ uv build
231
+ uv run twine check --strict dist/*
232
+ ```
233
+
234
+ 测试通过 mock 官方 SDK 和 DNS 响应运行,不需要真实 API 密钥,不会写入云端 DNS。
235
+ 包含 Certbot 公共挑战生命周期测试、CNAME 链 / 环路 / 超时 / NXDOMAIN、区域匹配、
236
+ 两个 SDK 的真实请求模型和分页、已有 TXT 保留、多挑战共享与清理失败处理。
237
+ CI 配置 Python 3.10–3.14,以及 Certbot 3.0 兼容性验证。
238
+ Certbot 3.0 的旧 ACME/josepy 依赖需要 `pyOpenSSL<25`;兼容性测试使用这一旧版本依赖组合。
239
+ 新安装默认由 uv 选择最新可用的 Certbot 版本。
240
+
241
+ 目录结构:
242
+
243
+ ```text
244
+ certbot_dns_alias/
245
+ dns_alias.py # Certbot Authenticator、TXT 所有权和清理状态
246
+ dns.py # CNAME 解析、DNS 名称和相对主机记录
247
+ config.py # INI 校验和服务商构建
248
+ providers/
249
+ base.py # 服务商接口及区域路由
250
+ aliyun.py # 阿里云 OpenAPI SDK
251
+ tencent.py # 腾讯云 DNSPod SDK
252
+ examples/ # 三种模式的凭据示例
253
+ tests/ # 无网络单元和生命周期测试
254
+ ```
255
+
256
+ 创建和删除 API 不自动重试写请求,避免响应丢失后的重复创建。清理失败会记录警告并继续清理其他挑战。
257
+ 创建状态保存在当前 Certbot 进程内;进程被强制终止、创建成功但响应丢失或删除失败时,可能留有 TXT,
258
+ 需要在委托区域手动清理。插件不持久化密钥或挑战值,也不会通过重新解析 CNAME 来猜测待删除记录。
259
+
260
+ ## 发布到 PyPI
261
+
262
+ 包名为 `certbot-dns-alias`,Certbot 入口点为 `dns-alias`。
263
+
264
+ 手动发布前,在 `pyproject.toml` 更新版本,再运行 `uv lock`、测试和构建。可先上传 TestPyPI:
265
+
266
+ ```bash
267
+ uv build
268
+ uv publish --publish-url https://test.pypi.org/legacy/ dist/*
269
+ # 正式发布
270
+ uv publish dist/*
271
+ ```
272
+
273
+ 上传时配置对应的 `UV_PUBLISH_TOKEN`。发布新版本前清空旧 `dist` 产物,以免上传旧版本。
274
+
275
+ 仓库包含 `.github/workflows/publish.yml`,GitHub Release 发布时会验证标签与项目版本一致
276
+ (例如版本 `0.1.0` 对应 `v0.1.0`),运行测试、构建 wheel/sdist 并通过 PyPI Trusted Publishing 上传。
277
+ 需先在 PyPI 为仓库 `tiyee/certbot-dns-alias` 配置 Trusted Publisher,工作流文件名 `publish.yml`,
278
+ environment 为 `pypi`;尚未创建 PyPI 项目时可以使用 pending publisher。
279
+ GitHub 仓库中创建同名 environment,可按需要设置发布审核。预发布 Release 只构建,不上传正式 PyPI。
280
+
281
+ ## License
282
+
283
+ MIT,见 [LICENSE](LICENSE)。
@@ -0,0 +1,13 @@
1
+ certbot_dns_alias/__init__.py,sha256=VylHtRr5N_3VpRDJhznxp_mqZtPiYMj_UiL1Vzmi0W4,59
2
+ certbot_dns_alias/config.py,sha256=E256Y5jpe5PHDURpLY2MjC8gGOKUxfFDGGmQ12mOxCw,3830
3
+ certbot_dns_alias/dns.py,sha256=Mge5FLR6EZH_Ijt1OCx7wfLf7kRHLbHyWFw0JX4uWzg,4739
4
+ certbot_dns_alias/dns_alias.py,sha256=w9Kk8dnBWu6LegyOu5hS8pL4hmiCECklH8gDlWBaLy4,7106
5
+ certbot_dns_alias/providers/__init__.py,sha256=0KXnLkdf8_u17QWBgnBEp1Fss6XbeluFOydvCgjU7aI,35
6
+ certbot_dns_alias/providers/aliyun.py,sha256=D4CmsA9vPO10XOaZXNEs8dO4xtsPr92qq7j9qVnzyog,4648
7
+ certbot_dns_alias/providers/base.py,sha256=kq4vKDSkZwCcETddI4ZGadVNfWiWdd6imtDC6DXQQX4,2843
8
+ certbot_dns_alias/providers/tencent.py,sha256=SsLnd7-Uqapv5UXbLeuwZ5LoQ2Ejw8oJtVhSJ323d9g,4339
9
+ certbot_dns_alias-0.1.0.dist-info/METADATA,sha256=fVGA0DgJcUsTeDz8-U80hwL5zZKXpL0tT88-yDXTCYk,11858
10
+ certbot_dns_alias-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ certbot_dns_alias-0.1.0.dist-info/entry_points.txt,sha256=h9Le-acht10yK8Cyf2svaZuyV9EQWbY1MY4YbP8picA,72
12
+ certbot_dns_alias-0.1.0.dist-info/licenses/LICENSE,sha256=ZrKzYat2vy2EQzTVpYheUM-WOPSyYJ20P6RFOGgaltI,1067
13
+ certbot_dns_alias-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [certbot.plugins]
2
+ dns-alias = certbot_dns_alias.dns_alias:Authenticator
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 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.