Skip to content

Commit

Permalink
Add resolving of delegated challenges
Browse files Browse the repository at this point in the history
  • Loading branch information
dalbothek committed Feb 28, 2021
1 parent 9eaa845 commit 5c54cd2
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 17 deletions.
39 changes: 37 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
<https://letsencrypt.org/docs/challenge-types/#dns-01-challenge>`_:

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
<https://www.eff.org/deeplinks/2018/02/
technical-deep-dive-securing-automation-acme-dns-challenge-validation>`_
by the `Electronic Frontier Foundation <https://www.eff.org>`_.

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
--------

Expand Down
38 changes: 36 additions & 2 deletions certbot_dns_cloudns/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
<https://letsencrypt.org/docs/challenge-types/#dns-01-challenge>`_:
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
<https://www.eff.org/deeplinks/2018/02/
technical-deep-dive-securing-automation-acme-dns-challenge-validation>`_
by the `Electronic Frontier Foundation <https://www.eff.org>`_.
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
--------
Expand Down
12 changes: 9 additions & 3 deletions certbot_dns_cloudns/_internal/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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 '
Expand All @@ -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():
Expand Down Expand Up @@ -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)
27 changes: 17 additions & 10 deletions certbot_dns_cloudns/_internal/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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):
Expand Down
52 changes: 52 additions & 0 deletions certbot_dns_cloudns/_internal/resolve.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 5c54cd2

Please sign in to comment.