├── .gitignore ├── requirements.txt ├── LICENCE ├── README.md └── hook.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | *.sw? 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | dnspython3==1.12.0 2 | future==0.15.2 3 | requests==2.9.1 4 | six==1.10.0 5 | tld==0.7.6 6 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008 kappataumu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dnsmadeeasy hook for letsencrypt.sh ACME client 2 | 3 | This a hook for the [Let's Encrypt](https://letsencrypt.org/) ACME client [dehydrated](https://github.com/lukas2511/dehydrated) (formerly letsencrypt.sh), that enables using DNS records on [dnsmadeeasy](https://www.dnsmadeeasy.com/) to respond to `dns-01` challenges. Requires Python 3 and your dnsmadeeasy account apikey and secretkey being set in the environment. 4 | 5 | ## Setup 6 | 7 | ``` 8 | $ git clone https://github.com/lukas2511/dehydrated 9 | $ cd dehydrated 10 | $ mkdir hooks 11 | $ git clone https://github.com/alisade/letsencrypt-dnsmadeeasy-hook hooks/dnsmadeeasy 12 | $ pip install -r hooks/dnsmadeeasy/requirements.txt 13 | $ export DME_API_KEY='52381b5f-a2e6-4158-bf2d-95537ce13477' 14 | $ export DME_SECRET_KEY='e6a44469-2a9b-4157-ae24-b8dfd2bf8053' 15 | ``` 16 | 17 | ## Usage 18 | 19 | ``` 20 | $ ./dehydrated -c -d example.com -t dns-01 -k 'hooks/dnsmadeeasy/hook.py' 21 | # 22 | # !! WARNING !! No main config file found, using default config! 23 | # 24 | Processing example.com 25 | + Signing domains... 26 | + Creating new directory /home/user/dehydrated/certs/example.com ... 27 | + Generating private key... 28 | + Generating signing request... 29 | + Requesting challenge for example.com... 30 | + dnsmadeeasy hook executing: deploy_challenge 31 | + DNS not propagated, waiting 30s... 32 | + Responding to challenge for example.com... 33 | + dnsmadeeasy hook executing: clean_challenge 34 | + Challenge is valid! 35 | + Requesting certificate... 36 | + Checking certificate... 37 | + Done! 38 | + Creating fullchain.pem... 39 | + dnsmadeeasy hook executing: deploy_cert 40 | + ssl_certificate: /home/user/dehydrated/certs/example.com/fullchain.pem 41 | + ssl_certificate_key: /home/user/dehydrated/certs/example.com/privkey.pem 42 | + Done! 43 | ``` 44 | -------------------------------------------------------------------------------- /hook.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # dnsmadeeasy hook for letsencrypt.sh 3 | # http://www.dnsmadeeasy.com/integration/pdf/API-Docv2.pdf 4 | 5 | from __future__ import absolute_import 6 | from __future__ import division 7 | from __future__ import print_function 8 | from __future__ import unicode_literals 9 | 10 | from builtins import str 11 | 12 | from future import standard_library 13 | standard_library.install_aliases() 14 | 15 | import dns.exception 16 | import dns.resolver 17 | import logging 18 | import os 19 | import requests 20 | import sys 21 | import time 22 | 23 | from email.utils import formatdate 24 | from datetime import datetime 25 | from time import mktime 26 | import hashlib, hmac 27 | 28 | logger = logging.getLogger(__name__) 29 | logger.addHandler(logging.StreamHandler()) 30 | logger.setLevel(logging.INFO) 31 | 32 | # Calculate RFC 1123 HTTP/1.1 date 33 | now = datetime.now() 34 | stamp = mktime(now.timetuple()) 35 | requestDate = formatdate( 36 | timeval = stamp, 37 | localtime = False, 38 | usegmt = True 39 | ) 40 | 41 | try: 42 | DME_HEADERS = { 43 | 'x-dnsme-apiKey': os.environ['DME_API_KEY'], 44 | 'x-dnsme-hmac': hmac.new(os.environ['DME_SECRET_KEY'].encode('ascii'), requestDate.encode('ascii'), hashlib.sha1).hexdigest(), 45 | 'x-dnsme-requestDate': requestDate, 46 | 'Content-Type': 'application/json', 47 | } 48 | except KeyError: 49 | logger.error(" + Unable to locate dnsmadeeasy credentials in environment!") 50 | sys.exit(1) 51 | 52 | try: 53 | DME_SERVER=os.environ['DME_SERVER'] 54 | except: 55 | DME_SERVER='production' 56 | 57 | DME_API_BASE_URL = { 58 | 'production': 'https://api.dnsmadeeasy.com/V2.0/dns/managed', 59 | 'staging': 'http://api.sandbox.dnsmadeeasy.com/V2.0/dns/managed' 60 | } 61 | 62 | try: 63 | dns_servers = os.environ['QUERY_DNS_SERVERS'] 64 | dns_servers = dns_servers.split() 65 | except KeyError: 66 | dns_servers = False 67 | 68 | def _has_dns_propagated(name, token): 69 | txt_records = [] 70 | try: 71 | if dns_servers: 72 | custom_resolver = dns.resolver.Resolver(configure=False) 73 | custom_resolver.nameservers = dns_servers 74 | dns_response = custom_resolver.query(name, 'TXT') 75 | else: 76 | dns_response = dns.resolver.query(name, 'TXT') 77 | for rdata in dns_response: 78 | for txt_record in rdata.strings: 79 | txt_records.append(txt_record.decode()) 80 | except dns.exception.DNSException as error: 81 | return False 82 | 83 | for txt_record in txt_records: 84 | if txt_record == bytearray(token, 'ascii'): 85 | return True 86 | 87 | return False 88 | 89 | # http://api.dnsmadeeasy.com/V2.0/dns/managed/id/{domainname} 90 | def _get_zone_id(domain): 91 | # allow both tlds and subdomains hosted on DNSMadeEasy 92 | tld = domain[domain.find('.')+1:] 93 | url = DME_API_BASE_URL[DME_SERVER] 94 | r = requests.get(url, headers=DME_HEADERS) 95 | r.raise_for_status() 96 | for record in r.json()['data']: 97 | if (record['name'] == tld) or ("." + record['name'] in tld): 98 | return record['id'] 99 | logger.error(" + Unable to locate zone for {0}".format(tld)) 100 | sys.exit(1) 101 | 102 | # http://api.dnsmadeeasy.com/V2.0/dns/managed/id/{domainname} 103 | def _get_zone_name(domain): 104 | # allow both tlds and subdomains hosted on DNSMadeEasy 105 | tld = domain[domain.find('.')+1:] 106 | url = DME_API_BASE_URL[DME_SERVER] 107 | r = requests.get(url, headers=DME_HEADERS) 108 | r.raise_for_status() 109 | for record in r.json()['data']: 110 | if (record['name'] == tld) or ("." + record['name'] in tld): 111 | return record['name'] 112 | logger.error(" + Unable to locate zone for {0}".format(tld)) 113 | sys.exit(1) 114 | 115 | 116 | # http://api.dnsmadeeasy.com/V2.0/dns/managed/{domain_id}/records?type=TXT&recordName={name} 117 | def _get_txt_record_id(zone_id, name): 118 | url = DME_API_BASE_URL[DME_SERVER] + "/{0}/records?type=TXT&recordName={1}".format(zone_id, name) 119 | r = requests.get(url, headers=DME_HEADERS) 120 | r.raise_for_status() 121 | try: 122 | record_id = r.json()['data'][0]['id'] 123 | except IndexError: 124 | logger.info(" + Unable to locate record named {0}".format(name)) 125 | return 126 | 127 | return record_id 128 | 129 | 130 | # http://api.dnsmadeeasy.com/V2.0/dns/managed/{domain_id}/records 131 | def create_txt_record(args): 132 | domain, token = args[0], args[2] 133 | zone_id = _get_zone_id(domain) 134 | name = "{0}.{1}".format('_acme-challenge', domain) 135 | short_name = "{0}.{1}".format('_acme-challenge', domain[0:-(len(_get_zone_name(domain))+1)]) 136 | url = DME_API_BASE_URL[DME_SERVER] + "/{0}/records".format(zone_id) 137 | payload = { 138 | 'type': 'TXT', 139 | 'name': short_name, 140 | 'value': token, 141 | 'ttl': 5, 142 | } 143 | r = requests.post(url, headers=DME_HEADERS, json=payload) 144 | r.raise_for_status() 145 | record_id = r.json()['id'] 146 | logger.debug(" + TXT record created, ID: {0}".format(record_id)) 147 | 148 | # give it 10 seconds to settle down and avoid nxdomain caching 149 | logger.info(" + Settling down for 10s...") 150 | time.sleep(10) 151 | 152 | retries=2 153 | while(_has_dns_propagated(name, token) == False and retries > 0): 154 | logger.info(" + DNS not propagated, waiting 30s...") 155 | retries-=1 156 | time.sleep(30) 157 | 158 | if retries <= 0: 159 | logger.error("Error resolving TXT record in domain {0}".format(domain)) 160 | sys.exit(1) 161 | 162 | # http://api.dnsmadeeasy.com/V2.0/dns/managed/{domain_id}/records 163 | def delete_txt_record(args): 164 | domain, token = args[0], args[2] 165 | if not domain: 166 | logger.info(" + http_request() error in letsencrypt.sh?") 167 | return 168 | 169 | zone_id = _get_zone_id(domain) 170 | name = "{0}.{1}".format('_acme-challenge', domain) 171 | short_name = "{0}.{1}".format('_acme-challenge', domain[0:-(len(_get_zone_name(domain))+1)]) 172 | record_id = _get_txt_record_id(zone_id, short_name) 173 | 174 | logger.debug(" + Deleting TXT record name: {0}".format(name)) 175 | url = DME_API_BASE_URL[DME_SERVER] + "/{0}/records/{1}".format(zone_id, record_id) 176 | r = requests.delete(url, headers=DME_HEADERS) 177 | r.raise_for_status() 178 | 179 | 180 | def deploy_cert(args): 181 | domain, privkey_pem, cert_pem, fullchain_pem, chain_pem, timestamp = args 182 | logger.info(' + ssl_certificate: {0}'.format(fullchain_pem)) 183 | logger.info(' + ssl_certificate_key: {0}'.format(privkey_pem)) 184 | return 185 | 186 | def main(argv): 187 | hook_name, args = argv[0], argv[1:] 188 | 189 | ops = { 190 | 'deploy_challenge': create_txt_record, 191 | 'clean_challenge' : delete_txt_record, 192 | 'deploy_cert' : deploy_cert, 193 | } 194 | 195 | if hook_name in ops.keys(): 196 | logger.info(' + dnsmadeeasy hook executing: %s', hook_name) 197 | ops[hook_name](args) 198 | else: 199 | logger.debug(' + dnsmadeeasy hook not executing: %s', hook_name) 200 | 201 | 202 | if __name__ == '__main__': 203 | main(sys.argv[1:]) 204 | --------------------------------------------------------------------------------