From 5d18838868e5f228ccea490c6b1a8e349a027028 Mon Sep 17 00:00:00 2001 From: Harm Weites Date: Thu, 22 Feb 2018 17:17:28 +0100 Subject: [PATCH] Use Cloudflare as DNS provider for LE certs (#945) * Use Cloudflare as DNS provider for LE certs * Better handle dns_provider plugins --- docs/requirements.txt | 1 + lemur/plugins/lemur_acme/cloudflare.py | 76 ++++++++++++++++++++++++++ lemur/plugins/lemur_acme/plugin.py | 39 ++++++++----- lemur/plugins/lemur_acme/route53.py | 2 +- setup.py | 1 + 5 files changed, 103 insertions(+), 16 deletions(-) create mode 100644 lemur/plugins/lemur_acme/cloudflare.py diff --git a/docs/requirements.txt b/docs/requirements.txt index 0362eadf..e89bedf4 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ +CloudFlare==1.7.5 Flask==0.12 Flask-RESTful==0.3.6 Flask-SQLAlchemy==2.1 diff --git a/lemur/plugins/lemur_acme/cloudflare.py b/lemur/plugins/lemur_acme/cloudflare.py new file mode 100644 index 00000000..2a665b38 --- /dev/null +++ b/lemur/plugins/lemur_acme/cloudflare.py @@ -0,0 +1,76 @@ +import time +import CloudFlare + +from flask import current_app + + +def cf_api_call(): + cf_key = current_app.config.get('ACME_CLOUDFLARE_KEY', '') + cf_email = current_app.config.get('ACME_CLOUDFLARE_EMAIL', '') + return CloudFlare.CloudFlare(email=cf_email, token=cf_key) + + +def find_zone_id(host): + elements = host.split('.') + cf = cf_api_call() + + n = 1 + + while n < 5: + n = n + 1 + domain = '.'.join(elements[-n:]) + current_app.logger.debug("Trying to get ID for zone {0}".format(domain)) + + try: + zone = cf.zones.get(params={'name': domain, 'per_page': 1}) + except Exception as e: + current_app.logger.error("Cloudflare API error: %s" % e) + pass + + if len(zone) == 1: + break + + if len(zone) == 0: + current_app.logger.error('No zone found') + return + else: + return zone[0]['id'] + + +def wait_for_dns_change(change_id, account_number=None): + cf = cf_api_call() + zone_id, record_id = change_id + while True: + r = cf.zones.get(zone_id, record_id) + current_app.logger.debug("Record status: %s" % r['status']) + if r['status'] == 'active': + break + time.sleep(1) + return + + +def create_txt_record(host, value, account_number): + cf = cf_api_call() + zone_id = find_zone_id(host) + if not zone_id: + return + + txt_record = {'name': host, 'type': 'TXT', 'content': value} + + current_app.logger.debug("Creating TXT record {0} with value {1}".format(host, value)) + + try: + r = cf.zones.dns_records.post(zone_id, data=txt_record) + except Exception as e: + current_app.logger.error('/zones.dns_records.post %s: %s' % (txt_record['name'], e)) + return zone_id, r['id'] + + +def delete_txt_record(change_id, account_number, host, value): + cf = cf_api_call() + zone_id, record_id = change_id + current_app.logger.debug("Removing record with id {0}".format(record_id)) + try: + cf.zones.dns_records.delete(zone_id, record_id) + except Exception as e: + current_app.logger.error('/zones.dns_records.post: %s' % e) diff --git a/lemur/plugins/lemur_acme/plugin.py b/lemur/plugins/lemur_acme/plugin.py index bcd9371a..3dabc575 100644 --- a/lemur/plugins/lemur_acme/plugin.py +++ b/lemur/plugins/lemur_acme/plugin.py @@ -25,8 +25,6 @@ from lemur.common.utils import validate_conf from lemur.plugins.bases import IssuerPlugin from lemur.plugins import lemur_acme as acme -from .route53 import delete_txt_record, create_txt_record, wait_for_r53_change - def find_dns_challenge(authz): for combo in authz.body.resolved_combinations: @@ -45,12 +43,13 @@ class AuthorizationRecord(object): self.change_id = change_id -def start_dns_challenge(acme_client, account_number, host): +def start_dns_challenge(acme_client, account_number, host, dns_provider): + current_app.logger.debug("Starting DNS challenge for {0}".format(host)) authz = acme_client.request_domain_challenges(host) [dns_challenge] = find_dns_challenge(authz) - change_id = create_txt_record( + change_id = dns_provider.create_txt_record( dns_challenge.validation_domain_name(host), dns_challenge.validation(acme_client.key), account_number @@ -64,8 +63,8 @@ def start_dns_challenge(acme_client, account_number, host): ) -def complete_dns_challenge(acme_client, account_number, authz_record): - wait_for_r53_change(authz_record.change_id, account_number=account_number) +def complete_dns_challenge(acme_client, account_number, authz_record, dns_provider): + dns_provider.wait_for_dns_change(authz_record.change_id, account_number=account_number) response = authz_record.dns_challenge.response(acme_client.key) @@ -96,12 +95,12 @@ def request_certificate(acme_client, authorizations, csr): OpenSSL.crypto.FILETYPE_PEM, cert_response.body ).decode('utf-8') - # https://github.com/alex/letsencrypt-aws/commit/853ea7f93f141fe18d9ef12aee6b3388f98b4830 - pem_certificate_chain = b"\n".join( - OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert) + pem_certificate_chain = "\n".join( + OpenSSL.crypto.dump_certificate(OpenSSL.crypto.FILETYPE_PEM, cert.decode("utf-8")) for cert in acme_client.fetch_chain(cert_response) ).decode('utf-8') + current_app.logger.debug("{0} {1}".format(type(pem_certificate). type(pem_certificate_chain))) return pem_certificate, pem_certificate_chain @@ -113,12 +112,15 @@ def setup_acme_client(): key = jose.JWKRSA(key=generate_private_key('RSA2048')) + current_app.logger.debug("Connecting with directory at {0}".format(directory_url)) client = Client(directory_url, key) registration = client.register( messages.NewRegistration.from_data(email=email) ) + current_app.logger.debug("Connected: {0}".format(registration.uri)) + client.agree_to_tos(registration) return client, registration @@ -129,26 +131,30 @@ def get_domains(options): :param options: :return: """ + current_app.logger.debug("Fetching domains") + domains = [options['common_name']] if options.get('extensions'): for name in options['extensions']['sub_alt_names']['names']: - domains.append(name.value) + domains.append(name) + + current_app.logger.debug("Got these domains: {0}".format(domains)) return domains -def get_authorizations(acme_client, account_number, domains): +def get_authorizations(acme_client, account_number, domains, dns_provider): authorizations = [] try: for domain in domains: - authz_record = start_dns_challenge(acme_client, account_number, domain) + authz_record = start_dns_challenge(acme_client, account_number, domain, dns_provider) authorizations.append(authz_record) for authz_record in authorizations: - complete_dns_challenge(acme_client, account_number, authz_record) + complete_dns_challenge(acme_client, account_number, authz_record, dns_provider) finally: for authz_record in authorizations: dns_challenge = authz_record.dns_challenge - delete_txt_record( + dns_provider.delete_txt_record( authz_record.change_id, account_number, dns_challenge.validation_domain_name(authz_record.host), @@ -177,6 +183,9 @@ class ACMEIssuerPlugin(IssuerPlugin): ] validate_conf(current_app, required_vars) + self.dns_provider_name = current_app.config.get('ACME_DNS_PROVIDER', 'route53') + current_app.logger.debug("Using DNS provider: {0}".format(self.dns_provider_name)) + self.dns_provider = __import__(self.dns_provider_name, globals(), locals(), [], 1) super(ACMEIssuerPlugin, self).__init__(*args, **kwargs) def create_certificate(self, csr, issuer_options): @@ -191,7 +200,7 @@ class ACMEIssuerPlugin(IssuerPlugin): acme_client, registration = setup_acme_client() account_number = current_app.config.get('ACME_AWS_ACCOUNT_NUMBER') domains = get_domains(issuer_options) - authorizations = get_authorizations(acme_client, account_number, domains) + authorizations = get_authorizations(acme_client, account_number, domains, self.dns_provider) pem_certificate, pem_certificate_chain = request_certificate(acme_client, authorizations, csr) # TODO add external ID (if possible) return pem_certificate, pem_certificate_chain, None diff --git a/lemur/plugins/lemur_acme/route53.py b/lemur/plugins/lemur_acme/route53.py index f7a9c594..9e5b9688 100644 --- a/lemur/plugins/lemur_acme/route53.py +++ b/lemur/plugins/lemur_acme/route53.py @@ -3,7 +3,7 @@ from lemur.plugins.lemur_aws.sts import sts_client @sts_client('route53') -def wait_for_r53_change(change_id, client=None): +def wait_for_dns_change(change_id, client=None): _, change_id = change_id while True: diff --git a/setup.py b/setup.py index e353d43f..ef9bafd2 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ with open(os.path.join(ROOT, 'lemur', '__about__.py')) as f: install_requires = [ + 'CloudFlare==1.7.5', 'Flask==0.12', 'Flask-RESTful==0.3.6', 'Flask-SQLAlchemy==2.1',