#!/usr/bin/env python # # Copyright 2016 Major Hayden # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # """Rackspace DNS API hook for letsencrypt.sh.""" import logging import os import pyrax import sys import time import dns.resolver import dns.exception from tld import get_tld # Configure some basic logging logger = logging.getLogger(__name__) logger.addHandler(logging.StreamHandler()) logger.setLevel(logging.INFO) # Ensure that the environment variable PYRAX_CREDS is set and it contains the # path to your pyrax credentials file. pyrax.set_setting("identity_type", "rackspace") try: pyrax.set_credential_file(os.environ['PYRAX_CREDS']) rax_dns = pyrax.cloud_dns except KeyError: logger.error(" + Missing pyrax credentials file (export PYRAX_CREDS)") sys.exit(1) # Determine the IP addresses for Rackspace's public nameservers. We will query # these servers later to determine when our challenge record is published. dns_servers = ["ns1.rackspace.com", "ns2.rackspace.com"] resolver = dns.resolver.Resolver() rackspace_dns_servers = [item.address for server in dns_servers for item in resolver.query(server)] def _has_dns_propagated(name, token): """ Verify that the challenge DNS record exists on Rackspace's nameservers. Keyword arguments: name -- domain name that needs a challenge record token -- the challenge text that LetsEncrypt expects to find in DNS """ successes = 0 for dns_server in rackspace_dns_servers: # We want to query Rackspace's DNS servers directly resolver.nameservers = [dns_server] # Retrieve all available TXT records that match our query try: dns_response = resolver.query(name, 'txt') except dns.exception.DNSException as error: return False # Loop through the TXT records to find one that matches our challenge text_records = [record.strings[0] for record in dns_response] for text_record in text_records: if text_record == token: successes += 1 # We need a successful check from BOTH DNS servers to move forward if successes == 2: logger.info(" + Challenge record found!") return True else: return False def get_domain(domain_name): """ Query the Rackspace DNS API to get a domain object for the domain name. Keyword arguments: domain_name -- the domain name that needs a challenge record """ base_domain_name = get_tld("http://{0}".format(domain_name)) domain = rax_dns.find(name=base_domain_name) return domain def create_txt_record(args): """ Create a TXT DNS record via Rackspace's DNS API. Keyword arguments: args -- passed from letsencrypt.sh """ domain_name, token = args[0], args[2] domain = get_domain(domain_name) # Assemble the parts of our record and create it name = "{0}.{1}".format('_acme-challenge', domain_name) record = { 'type': 'TXT', 'name': name, 'data': token, } domain.add_records(record) logger.info(" + TXT record created: {0} => {1}".format(name, token)) # Loop over a DNS query until the challenge record is published logger.info(" + Waiting for challenge DNS record to appear on the DNS " "server (this usually takes 30-60 seconds)") while True: if _has_dns_propagated(name, token) is False: time.sleep(5) else: break def delete_txt_record(args): """ Clean up the TXT record when it is no longer needed. Keyword arguments args -- passed from letsencrypt.sh """ domain_name = args[0] base_domain_name = get_tld("http://{0}".format(domain_name)) domain = get_domain(base_domain_name) # Get the DNS record object(s) for our challenge record(s) name = "{0}.{1}".format('_acme-challenge', domain_name) dns_records = list(rax_dns.get_record_iterator(domain)) text_records = [x for x in dns_records if x.type == 'TXT'] # Delete any matching records we find for text_record in text_records: if text_record.name == name: text_record.delete() return True def deploy_cert(args): """ Display a message about the location of the cert/key/chain files. Keyword arguments: args -- passed from letsencrypt.sh """ # Args = domain_name, privkey, cert, fullchain, chain_pem, timestamp logger.info(' + Certificate issued for {0}! Awesome!'.format(args[0])) logger.info(' + Private key: {0}'.format(args[1])) logger.info(' + Certificate: {0}'.format(args[2])) logger.info(' + Certificate w/chain: {0}'.format(args[3])) logger.info(' + CA chain: {0}'.format(args[4])) return def unchanged_cert(args): """ Display a message that the certificate is unchanged. """ logger.info(' + Certificate is up to date. No changes are needed.') def main(argv): """ The main logic of the hook. letsencrypt.sh will pass different arguments for different types of operations. The hook calls different functions based on the arguments passed. """ ops = { 'deploy_challenge': create_txt_record, 'clean_challenge': delete_txt_record, 'deploy_cert': deploy_cert, 'unchanged_cert': unchanged_cert, } logger.info(" + Rackspace hook executing: {0}".format(argv[0])) ops[argv[0]](argv[1:]) if __name__ == '__main__': main(sys.argv[1:])