Merge pull request #3221 from Netflix/cname_01

Delegated CNAME DNS validation for ACME
This commit is contained in:
Chad S 2020-10-29 14:50:19 -07:00 committed by GitHub
commit 4bc5899e24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 81 additions and 36 deletions

View File

@ -690,6 +690,20 @@ If you are not using a metric provider you do not need to configure any of these
Plugin Specific Options Plugin Specific Options
----------------------- -----------------------
ACME Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. data:: ACME_DNS_PROVIDER_TYPES
:noindex:
Dictionary of ACME DNS Providers and their requirements.
.. data:: ACME_ENABLE_DELEGATED_CNAME
:noindex:
Enables delegated DNS domain validation using CNAMES. When enabled, Lemur will attempt to follow CNAME records to authoritative DNS servers when creating DNS-01 challenges.
Active Directory Certificate Services Plugin Active Directory Certificate Services Plugin
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -16,6 +16,7 @@ import json
import time import time
import OpenSSL.crypto import OpenSSL.crypto
import dns.resolver
import josepy as jose import josepy as jose
from acme import challenges, errors, messages from acme import challenges, errors, messages
from acme.client import BackwardsCompatibleClientV2, ClientNetwork from acme.client import BackwardsCompatibleClientV2, ClientNetwork
@ -23,7 +24,6 @@ from acme.errors import PollError, TimeoutError, WildcardUnsupportedError
from acme.messages import Error as AcmeError from acme.messages import Error as AcmeError
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
from flask import current_app from flask import current_app
from lemur.authorizations import service as authorization_service from lemur.authorizations import service as authorization_service
from lemur.common.utils import generate_private_key from lemur.common.utils import generate_private_key
from lemur.dns_providers import service as dns_provider_service from lemur.dns_providers import service as dns_provider_service
@ -37,8 +37,9 @@ from retrying import retry
class AuthorizationRecord(object): class AuthorizationRecord(object):
def __init__(self, host, authz, dns_challenge, change_id): def __init__(self, domain, target_domain, authz, dns_challenge, change_id):
self.host = host self.domain = domain
self.target_domain = target_domain
self.authz = authz self.authz = authz
self.dns_challenge = dns_challenge self.dns_challenge = dns_challenge
self.change_id = change_id self.change_id = change_id
@ -91,19 +92,18 @@ class AcmeHandler(object):
self, self,
acme_client, acme_client,
account_number, account_number,
host, domain,
target_domain,
dns_provider, dns_provider,
order, order,
dns_provider_options, dns_provider_options,
): ):
current_app.logger.debug("Starting DNS challenge for {0}".format(host)) current_app.logger.debug(f"Starting DNS challenge for {domain} using target domain {target_domain}.")
change_ids = [] change_ids = []
dns_challenges = self.get_dns_challenges(host, order.authorizations) dns_challenges = self.get_dns_challenges(domain, order.authorizations)
host_to_validate, _ = self.strip_wildcard(host) host_to_validate, _ = self.strip_wildcard(target_domain)
host_to_validate = self.maybe_add_extension( host_to_validate = self.maybe_add_extension(host_to_validate, dns_provider_options)
host_to_validate, dns_provider_options
)
if not dns_challenges: if not dns_challenges:
sentry.captureException() sentry.captureException()
@ -111,15 +111,20 @@ class AcmeHandler(object):
raise Exception("Unable to determine DNS challenges from authorizations") raise Exception("Unable to determine DNS challenges from authorizations")
for dns_challenge in dns_challenges: for dns_challenge in dns_challenges:
# Only prepend '_acme-challenge' if not using CNAME redirection
if domain == target_domain:
host_to_validate = dns_challenge.validation_domain_name(host_to_validate)
change_id = dns_provider.create_txt_record( change_id = dns_provider.create_txt_record(
dns_challenge.validation_domain_name(host_to_validate), host_to_validate,
dns_challenge.validation(acme_client.client.net.key), dns_challenge.validation(acme_client.client.net.key),
account_number, account_number,
) )
change_ids.append(change_id) change_ids.append(change_id)
return AuthorizationRecord( return AuthorizationRecord(
host, order.authorizations, dns_challenges, change_ids domain, target_domain, order.authorizations, dns_challenges, change_ids
) )
def complete_dns_challenge(self, acme_client, authz_record): def complete_dns_challenge(self, acme_client, authz_record):
@ -128,11 +133,11 @@ class AcmeHandler(object):
authz_record.authz[0].body.identifier.value authz_record.authz[0].body.identifier.value
) )
) )
dns_providers = self.dns_providers_for_domain.get(authz_record.host) dns_providers = self.dns_providers_for_domain.get(authz_record.target_domain)
if not dns_providers: if not dns_providers:
metrics.send("complete_dns_challenge_error_no_dnsproviders", "counter", 1) metrics.send("complete_dns_challenge_error_no_dnsproviders", "counter", 1)
raise Exception( raise Exception(
"No DNS providers found for domain: {}".format(authz_record.host) "No DNS providers found for domain: {}".format(authz_record.target_domain)
) )
for dns_provider in dns_providers: for dns_provider in dns_providers:
@ -160,7 +165,7 @@ class AcmeHandler(object):
verified = response.simple_verify( verified = response.simple_verify(
dns_challenge.chall, dns_challenge.chall,
authz_record.host, authz_record.target_domain,
acme_client.client.net.key.public_key(), acme_client.client.net.key.public_key(),
) )
@ -311,12 +316,24 @@ class AcmeHandler(object):
authorizations = [] authorizations = []
for domain in order_info.domains: for domain in order_info.domains:
if not self.dns_providers_for_domain.get(domain):
# If CNAME exists, set host to the target address
target_domain = domain
if current_app.config.get("ACME_ENABLE_DELEGATED_CNAME", False):
cname_result, _ = self.strip_wildcard(domain)
cname_result = challenges.DNS01().validation_domain_name(cname_result)
cname_result = self.get_cname(cname_result)
if cname_result:
target_domain = cname_result
self.autodetect_dns_providers(target_domain)
if not self.dns_providers_for_domain.get(target_domain):
metrics.send( metrics.send(
"get_authorizations_no_dns_provider_for_domain", "counter", 1 "get_authorizations_no_dns_provider_for_domain", "counter", 1
) )
raise Exception("No DNS providers found for domain: {}".format(domain)) raise Exception("No DNS providers found for domain: {}".format(target_domain))
for dns_provider in self.dns_providers_for_domain[domain]:
for dns_provider in self.dns_providers_for_domain[target_domain]:
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type) dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
dns_provider_options = json.loads(dns_provider.credentials) dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id") account_number = dns_provider_options.get("account_id")
@ -324,6 +341,7 @@ class AcmeHandler(object):
acme_client, acme_client,
account_number, account_number,
domain, domain,
target_domain,
dns_provider_plugin, dns_provider_plugin,
order, order,
dns_provider.options, dns_provider.options,
@ -358,7 +376,7 @@ class AcmeHandler(object):
for authz_record in authorizations: for authz_record in authorizations:
dns_challenges = authz_record.dns_challenge dns_challenges = authz_record.dns_challenge
for dns_challenge in dns_challenges: for dns_challenge in dns_challenges:
dns_providers = self.dns_providers_for_domain.get(authz_record.host) dns_providers = self.dns_providers_for_domain.get(authz_record.target_domain)
for dns_provider in dns_providers: for dns_provider in dns_providers:
# Grab account number (For Route53) # Grab account number (For Route53)
dns_provider_plugin = self.get_dns_provider( dns_provider_plugin = self.get_dns_provider(
@ -366,14 +384,14 @@ class AcmeHandler(object):
) )
dns_provider_options = json.loads(dns_provider.credentials) dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id") account_number = dns_provider_options.get("account_id")
host_to_validate, _ = self.strip_wildcard(authz_record.host) host_to_validate, _ = self.strip_wildcard(authz_record.target_domain)
host_to_validate = self.maybe_add_extension( host_to_validate = self.maybe_add_extension(host_to_validate, dns_provider_options)
host_to_validate, dns_provider_options if authz_record.domain == authz_record.target_domain:
) host_to_validate = challenges.DNS01().validation_domain_name(host_to_validate)
dns_provider_plugin.delete_txt_record( dns_provider_plugin.delete_txt_record(
authz_record.change_id, authz_record.change_id,
account_number, account_number,
dns_challenge.validation_domain_name(host_to_validate), host_to_validate,
dns_challenge.validation(acme_client.client.net.key), dns_challenge.validation(acme_client.client.net.key),
) )
@ -392,23 +410,26 @@ class AcmeHandler(object):
:return: :return:
""" """
for authz_record in authorizations: for authz_record in authorizations:
dns_providers = self.dns_providers_for_domain.get(authz_record.host) dns_providers = self.dns_providers_for_domain.get(authz_record.target_domain)
for dns_provider in dns_providers: for dns_provider in dns_providers:
# Grab account number (For Route53) # Grab account number (For Route53)
dns_provider_options = json.loads(dns_provider.credentials) dns_provider_options = json.loads(dns_provider.credentials)
account_number = dns_provider_options.get("account_id") account_number = dns_provider_options.get("account_id")
dns_challenges = authz_record.dns_challenge dns_challenges = authz_record.dns_challenge
host_to_validate, _ = self.strip_wildcard(authz_record.host) host_to_validate, _ = self.strip_wildcard(authz_record.target_domain)
host_to_validate = self.maybe_add_extension( host_to_validate = self.maybe_add_extension(
host_to_validate, dns_provider_options host_to_validate, dns_provider_options
) )
dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type) dns_provider_plugin = self.get_dns_provider(dns_provider.provider_type)
for dns_challenge in dns_challenges: for dns_challenge in dns_challenges:
if authz_record.domain == authz_record.target_domain:
host_to_validate = dns_challenge.validation_domain_name(host_to_validate),
try: try:
dns_provider_plugin.delete_txt_record( dns_provider_plugin.delete_txt_record(
authz_record.change_id, authz_record.change_id,
account_number, account_number,
dns_challenge.validation_domain_name(host_to_validate), host_to_validate,
dns_challenge.validation(acme_client.client.net.key), dns_challenge.validation(acme_client.client.net.key),
) )
except Exception as e: except Exception as e:
@ -431,6 +452,18 @@ class AcmeHandler(object):
raise UnknownProvider("No such DNS provider: {}".format(type)) raise UnknownProvider("No such DNS provider: {}".format(type))
return provider return provider
def get_cname(self, domain):
"""
:param domain: Domain name to look up a CNAME for.
:return: First CNAME target or False if no CNAME record exists.
"""
try:
result = dns.resolver.query(domain, 'CNAME')
if len(result) > 0:
return str(result[0].target).rstrip('.')
except dns.exception.DNSException:
return False
class ACMEIssuerPlugin(IssuerPlugin): class ACMEIssuerPlugin(IssuerPlugin):
title = "Acme" title = "Acme"

View File

@ -49,7 +49,7 @@ class TestAcme(unittest.TestCase):
self.assertEqual(expected, result) self.assertEqual(expected, result)
def test_authz_record(self): def test_authz_record(self):
a = plugin.AuthorizationRecord("host", "authz", "challenge", "id") a = plugin.AuthorizationRecord("domain", "host", "authz", "challenge", "id")
self.assertEqual(type(a), plugin.AuthorizationRecord) self.assertEqual(type(a), plugin.AuthorizationRecord)
@patch("acme.client.Client") @patch("acme.client.Client")
@ -79,7 +79,7 @@ class TestAcme(unittest.TestCase):
iterator = iter(values) iterator = iter(values)
iterable.__iter__.return_value = iterator iterable.__iter__.return_value = iterator
result = self.acme.start_dns_challenge( result = self.acme.start_dns_challenge(
mock_acme, "accountid", "host", mock_dns_provider, mock_order, {} mock_acme, "accountid", "domain", "host", mock_dns_provider, mock_order, {}
) )
self.assertEqual(type(result), plugin.AuthorizationRecord) self.assertEqual(type(result), plugin.AuthorizationRecord)
@ -97,7 +97,7 @@ class TestAcme(unittest.TestCase):
mock_authz.dns_challenge.response = Mock() mock_authz.dns_challenge.response = Mock()
mock_authz.dns_challenge.response.simple_verify = Mock(return_value=True) mock_authz.dns_challenge.response.simple_verify = Mock(return_value=True)
mock_authz.authz = [] mock_authz.authz = []
mock_authz.host = "www.test.com" mock_authz.target_domain = "www.test.com"
mock_authz_record = Mock() mock_authz_record = Mock()
mock_authz_record.body.identifier.value = "test" mock_authz_record.body.identifier.value = "test"
mock_authz.authz.append(mock_authz_record) mock_authz.authz.append(mock_authz_record)
@ -121,7 +121,7 @@ class TestAcme(unittest.TestCase):
mock_authz.dns_challenge.response = Mock() mock_authz.dns_challenge.response = Mock()
mock_authz.dns_challenge.response.simple_verify = Mock(return_value=False) mock_authz.dns_challenge.response.simple_verify = Mock(return_value=False)
mock_authz.authz = [] mock_authz.authz = []
mock_authz.host = "www.test.com" mock_authz.target_domain = "www.test.com"
mock_authz_record = Mock() mock_authz_record = Mock()
mock_authz_record.body.identifier.value = "test" mock_authz_record.body.identifier.value = "test"
mock_authz.authz.append(mock_authz_record) mock_authz.authz.append(mock_authz_record)
@ -270,11 +270,9 @@ class TestAcme(unittest.TestCase):
result, [options["common_name"], "test2.netflix.net"] result, [options["common_name"], "test2.netflix.net"]
) )
@patch( @patch("lemur.plugins.lemur_acme.plugin.AcmeHandler.start_dns_challenge", return_value="test")
"lemur.plugins.lemur_acme.plugin.AcmeHandler.start_dns_challenge", @patch("lemur.plugins.lemur_acme.plugin.current_app", return_value=False)
return_value="test", def test_get_authorizations(self, mock_current_app, mock_start_dns_challenge):
)
def test_get_authorizations(self, mock_start_dns_challenge):
mock_order = Mock() mock_order = Mock()
mock_order.body.identifiers = [] mock_order.body.identifiers = []
mock_domain = Mock() mock_domain = Mock()