diff --git a/README.rst b/README.rst index 9ee0e0a..753c59e 100644 --- a/README.rst +++ b/README.rst @@ -6,11 +6,16 @@ Named Arguments --------------- ===================================== ===================================== ``--dns-cloudns-credentials`` ClouDNS credentials_ INI file. - (Required) + `(Required)` ``--dns-cloudns-propagation-seconds`` The number of seconds to wait for DNS to propagate before asking the ACME server to verify the DNS record. - (Default: 60) + `(Default: 60)` +``--dns-cloudns-nameserver`` Nameserver used to resolve CNAME + aliases. (See the + `Challenge Delegation`_ section + below.) + `(Default: System default)` ===================================== ===================================== Credentials @@ -50,6 +55,36 @@ file. This warning will be emitted each time Certbot uses the credentials file, including for renewal, and cannot be silenced except by addressing the issue (e.g., by using a command like ``chmod 600`` to restrict access to the file). +Challenge Delegation +-------------------- +The dns-cloudns plugin supports delegation of ``dns-01`` challenges to +other DNS zones through the use of CNAME records. + +As stated in the `Let's Encrypt documentation +`_: + + Since Let’s Encrypt follows the DNS standards when looking up TXT records + for DNS-01 validation, you can use CNAME records or NS records to delegate + answering the challenge to other DNS zones. This can be used to delegate + the _acme-challenge subdomain to a validation-specific server or zone. It + can also be used if your DNS provider is slow to update, and you want to + delegate to a quicker-updating server. + +This allows the credentials provided to certbot to be limited to either a +sub-zone of the verified domain, or even a completely separate throw-away +domain. This idea is further discussed in `this article +`_ +by the `Electronic Frontier Foundation `_. + +To resolve CNAME aliases properly, Certbot needs to be able to access a public +DNS server. In some setups, especially corporate networks, the challenged +domain might be resolved by a local server instead, hiding configured CNAME and +TXT records from Certbot. In these cases setting the +``--dns-cloudns-nameserver`` option to any public nameserver (e.g. ``1.1.1.1``) +should resolve the issue. + + Examples -------- diff --git a/certbot_dns_cloudns/__init__.py b/certbot_dns_cloudns/__init__.py index 3c6253b..48414d4 100644 --- a/certbot_dns_cloudns/__init__.py +++ b/certbot_dns_cloudns/__init__.py @@ -7,11 +7,16 @@ --------------- ===================================== ===================================== ``--dns-cloudns-credentials`` ClouDNS credentials_ INI file. - (Required) + `(Required)` ``--dns-cloudns-propagation-seconds`` The number of seconds to wait for DNS to propagate before asking the ACME server to verify the DNS record. - (Default: 60) + `(Default: 60)` +``--dns-cloudns-nameserver`` Nameserver used to resolve CNAME + aliases. (See the + `Challenge Delegation`_ section + below.) + `(Default: System default)` ===================================== ===================================== Credentials @@ -51,6 +56,35 @@ including for renewal, and cannot be silenced except by addressing the issue (e.g., by using a command like ``chmod 600`` to restrict access to the file). +Challenge Delegation +-------------------- +The dns-cloudns plugin supports delegation of ``dns-01`` challenges to +other DNS zones through the use of CNAME records. + +As stated in the `Let's Encrypt documentation +`_: + + Since Let’s Encrypt follows the DNS standards when looking up TXT records + for DNS-01 validation, you can use CNAME records or NS records to delegate + answering the challenge to other DNS zones. This can be used to delegate + the _acme-challenge subdomain to a validation-specific server or zone. It + can also be used if your DNS provider is slow to update, and you want to + delegate to a quicker-updating server. + +This allows the credentials provided to certbot to be limited to either a +sub-zone of the verified domain, or even a completely separate throw-away +domain. This idea is further discussed in `this article +`_ +by the `Electronic Frontier Foundation `_. + +To resolve CNAME aliases properly, Certbot needs to be able to access a public +DNS server. In some setups, especially corporate networks, the challenged +domain might be resolved by a local server instead, hiding configured CNAME and +TXT records from Certbot. In these cases setting the +``--dns-cloudns-nameserver`` option to any public nameserver (e.g. ``1.1.1.1``) +should resolve the issue. + Examples -------- diff --git a/certbot_dns_cloudns/_internal/authenticator.py b/certbot_dns_cloudns/_internal/authenticator.py index 5651b00..7fe58f0 100644 --- a/certbot_dns_cloudns/_internal/authenticator.py +++ b/certbot_dns_cloudns/_internal/authenticator.py @@ -8,6 +8,7 @@ from certbot.plugins import dns_common from certbot_dns_cloudns._internal.client import ClouDNSClient +from certbot_dns_cloudns._internal.resolve import resolve_alias logger = logging.getLogger(__name__) @@ -18,7 +19,7 @@ @zope.interface.provider(interfaces.IPluginFactory) class Authenticator(dns_common.DNSAuthenticator): """DNS Authenticator using CLouDNS API - This Authenticator uses the LouDNS API to fulfill a dns-01 challenge. + This Authenticator uses the ClouDNS API to fulfill a dns-01 challenge. """ description = ('Obtain certificates using a DNS TXT record ' @@ -35,6 +36,7 @@ def add_parser_arguments(cls, add): add, default_propagation_seconds=60 ) add('credentials', help='ClouDNS credentials INI file.') + add('nameserver', help='The nameserver used to resolve CNAME aliases.') @staticmethod def more_info(): @@ -69,14 +71,18 @@ def _validate_user_ids(credentials): def _perform(self, _domain, validation_name, validation): self._get_client().add_txt_record( - _domain, validation_name, validation, self.ttl + _domain, self._resolve_alias(validation_name), validation, self.ttl ) def _cleanup(self, _domain, validation_name, validation): self._get_client().del_txt_record( - _domain, validation_name, validation + _domain, self._resolve_alias(validation_name), validation ) + def _resolve_alias(self, validation_name): + return resolve_alias(validation_name, + nameserver=self.conf('nameserver')) + @functools.lru_cache(maxsize=None) def _get_client(self): return ClouDNSClient(self.credentials) diff --git a/certbot_dns_cloudns/_internal/client.py b/certbot_dns_cloudns/_internal/client.py index 5c568b4..0837c95 100644 --- a/certbot_dns_cloudns/_internal/client.py +++ b/certbot_dns_cloudns/_internal/client.py @@ -35,7 +35,11 @@ def auth_params(credentials): class ApiErrorResponse(errors.PluginError): - pass + def __init__(self, response): + self.response = response + super().__init__( + f"Error communicating with the ClouDNS API: {response}" + ) class ClouDNSClient: @@ -127,10 +131,17 @@ def _find_zone_and_host(self, domain): logger.debug(f"Looking up zone {zone_name}.") try: - self._api_request(cloudns_api.zone.get, - domain_name=zone_name) - except ApiErrorResponse: - logger.debug(f"Zone {zone_name} not found") + self._api_request(cloudns_api.zone.get, domain_name=zone_name) + except ApiErrorResponse as e: + response = e.response + if ( + isinstance(response, dict) and + response.get('status_code') == 200 and + response.get('error') == 'Missing domain-name' + ): + logger.debug(f"Zone {zone_name} not found") + else: + raise e else: logger.debug(f"Found zone {zone_name} for {domain}.") return zone_name, domain[:-len(zone_name) - 1] @@ -178,11 +189,7 @@ def _api_request(self, api_method, *args, **kwargs): if self._is_successful(response): return response_content.get('payload') else: - raise ApiErrorResponse( - 'Error communicating with the ClouDNS API: {0}'.format( - response_content - ) - ) + raise ApiErrorResponse(response_content) @staticmethod def _is_successful(response): diff --git a/certbot_dns_cloudns/_internal/resolve.py b/certbot_dns_cloudns/_internal/resolve.py new file mode 100644 index 0000000..3b655e5 --- /dev/null +++ b/certbot_dns_cloudns/_internal/resolve.py @@ -0,0 +1,52 @@ +import functools +import logging + +import dns.resolver +import dns.name +from certbot import errors + +logger = logging.getLogger(__name__) + + +@functools.lru_cache(maxsize=None) +def resolve_alias(domain_name, nameserver): + """ + Performs recursive CNAME lookups for a given domain name. + """ + resolver = _get_resolver(nameserver) + name = dns.name.from_text(domain_name) + + while True: + try: + records = resolver.resolve(name, 'CNAME') + if len(records) > 1: + raise errors.PluginError( + f"Name {name} has multiple CNAME records set: " + f"{', '.join(record.target for record in records)}" + ) + elif len(records) == 1: + resolved_name = records[0].target + logger.debug(f"{name} points to {resolved_name}") + name = resolved_name + else: + break + except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN): + logger.debug(f"No CNAME record found for {name}") + break + + return name.to_text(omit_final_dot=True) + + +@functools.lru_cache(maxsize=None) +def _get_resolver(nameserver): + if nameserver is None: + resolver = dns.resolver.Resolver() + else: + resolver = dns.resolver.Resolver(configure=False) + resolver.nameservers.append(nameserver) + + logger.debug( + f"Using nameserver{'s' if len(resolver.nameservers) > 1 else ''} " + f"{', '.join(resolver.nameservers)}" + ) + return resolver