Let's Encrypt with DNS-validation (ACME v2)
This article assumes the following:
- You have a PowerDNS 4.1 Authoritive server running with the webserver/HTTP API enabled.
- You are using dehydrated version 0.5.0 or higher (0.6.0+ for wildcard certificates / ACME v2).
If you will have direct access to zones through the HTTP API, you will only need to enable access to it for the script.
However, if you don't or do not want to touch your existing DNS-infrastructure, but have a spare public (virtual) machine to run PowerDNS on, fear not..
Using CNAMEs and a seperate PowerDNS instance.
If you do not wish to touch your existing infrastructure, but would still like to use DNS-validation, you can do the following:
- Set up a new (virtual) machine with PowerDNS 4.1 Authoritive, and name it
ca-dns.example.com
. - Set up the
sqlite
-backend for PowerDNS (or any other backend that is supported by the HTTP API). - Create a new zone there, with:
pdnsutil create-zone _acme-challenges.example.com
- Optionally enable DNSSEC:
a. Secure the new zone, with:
pdnsutil secure-zone _acme-challenges.example.com
b. Look up the DS-records for it, with:pdnsutil show-zone _acme-challenges.example.com
- Add
_acme-challenges.example.com IN NS ca-dns.example.com
toexample.com
. - Optionally add the DS-records you got earlier to the same place.
Now all you need to do is CNAME _acme-challenge.$hostname
to $hostname._acme-challenges.example.com
, and the hook-script (further down) will take care of the rest.
Hook script for dehydrated.
Sorry, work in progress. This script is very, very rough around the edges and doesn't do a lot of error checking. It does what it's supposed to, and is ACME v2 (read: wildcard) compatible. You can use this script with dehydrated
version 0.5 or higher, and is compatible with the changes made in 0.6.
Make sure you set the following in the dehydrated
config-file:
HOOK="/path/to/hook.py"
HOOK_CHAIN="yes" # Required for wildcard-certificates! Gives all challenges to the hook-script in one go.
So here's the actual script, it's written for Python 3, and depends on the requests
-library:
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Dehydrated+PowerDNS HTTP API hook script for Let's Encrypt by Daniƫl "FinalX" Mostertman.
# Note: Yes, some things still suck. Does what it must for now.
# PowerDNS HTTP API settings
base_url = "http://ca-dns.example.com:8081" # Should perhaps be proxied with SSL/TLS termination (Apache/nginx)?
api_key = "set_this_in_your_powerdns_config"
alt_zone = "_acme-challenges.example.com" # Fallback-zone for zones not hosted by the PowerDNS instance itself.
import sys
import json
import requests
headers = {
'Content-type': 'application/json',
'X-API-Key': api_key,
}
def where_to(host):
zones = []
# Wildcard removal
if host.startswith('*.'):
host = host[2:]
url = base_url + "/api/v1/servers/localhost/zones"
r = requests.get(url, headers=headers)
if r.encoding is None:
r.encoding = 'latin1'
if not r.status_code == 200:
return False
# requests-foo. Some versions return a string, some have a callable that returns the string.
if callable(r.json):
output = r.json()
else:
output = r.json
for zonerr in output:
name = zonerr['name'][:-1]
if name.endswith('.example') or name.endswith('example.com') or name.endswith('example.net') or name.endswith('example.org'):
continue
if name.endswith('ip6.arpa') or name.endswith('.invalid') or name.endswith('.test') or name.endswith('.localhost'):
continue
zones.append(name)
revhost = host[::-1]
revzones = []
zones = sorted(zones)
for zone in zones:
revzones.append(zone[::-1])
revzones = sorted(revzones)
closest = False
for zone in revzones:
if revhost.startswith(zone):
closest = zone[::-1]
if closest == False:
return (alt_zone, host + '.' + alt_zone + '.')
else:
return (closest, '_acme-challenge.' + host + '.')
def deploy_challenge():
domains = {}
while True:
try:
(DOMAIN, TOKEN_FILENAME, TOKEN_VALUE) = (sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0))
if not DOMAIN in domains:
domains[DOMAIN] = []
domains[DOMAIN].append(TOKEN_VALUE)
except:
break
for domain in domains.keys():
payload = {'rrsets': []}
tokens = []
(zone, record) = where_to(domain)
for TOKEN in domains[domain]:
tokens.append({
'content': '"' + TOKEN + '"',
'disabled': False,
})
payload['rrsets'].append({
'name': record,
'type': 'TXT',
'ttl': int(60),
'changetype': 'REPLACE',
'records': tokens,
})
url = base_url + "/api/v1/servers/localhost/zones/" + zone
print(" * Deploying token for '" + domain + "' to " + url + "' ... ")
r = requests.patch(url, data=json.dumps(payload), headers=headers)
if r.status_code == 204:
continue
else:
print("DEPLOY CHALLENGE: Something went wrong while deploying the challenge to PowerDNS, got status:", str(r.status_code))
print(r.text)
sys.exit(1)
def clean_challenge():
domains = {}
while True:
try:
(DOMAIN, TOKEN_FILENAME, TOKEN_VALUE) = (sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0))
if not DOMAIN in domains:
domains[DOMAIN] = []
domains[DOMAIN].append(TOKEN_VALUE)
except:
break
for domain in domains.keys():
payload = {'rrsets': []}
tokens = []
(zone, record) = where_to(domain)
for TOKEN in domains[domain]:
tokens.append({
'content': '"' + TOKEN + '"',
'disabled': False,
})
payload['rrsets'].append({
'name': record,
'type': 'TXT',
'changetype': 'DELETE',
'records': tokens,
})
url = base_url + "/api/v1/servers/localhost/zones/" + zone
print(" * Cleaning token for '" + domain + "' from " + url + "' ... ")
r = requests.patch(url, data=json.dumps(payload), headers=headers)
if r.status_code == 204:
continue
else:
print("DEPLOY CHALLENGE: Something went wrong while deploying the challenge to PowerDNS, got status:", str(r.status_code))
print(r.text)
sys.exit(1)
def deploy_cert():
(DOMAIN, KEYFILE, CERTFILE, FULLCHAINFILE, CHAINFILE, TIMESTAMP) = (sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0))
def unchanged_cert():
(DOMAIN, KEYFILE, CERTFILE, FULLCHAINFILE, CHAINFILE) = (sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0))
def invalid_challenge():
(DOMAIN, RESPONSE) = (sys.argv.pop(0), sys.argv.pop(0))
def request_failure():
(STATUSCODE, REASON, REQTYPE) = (sys.argv.pop(0), sys.argv.pop(0), sys.argv.pop(0))
def startup_hook(): pass
def exit_hook(): pass
if __name__ == '__main__':
# ['/opt/dehydrated/hook.py', 'deploy_challenge', 'finalx.org', 'ZwtczbTpDsDcQ7I6KpxMqv4BtCaf5cT8BO_l5gJPN9g', 'dBeKE2x31ruGjctqkpt2qQC5gAarsXAIV0AIOHbb2vI']
# ['/opt/dehydrated/hook.py', 'clean_challenge', 'finalx.org', 'ZwtczbTpDsDcQ7I6KpxMqv4BtCaf5cT8BO_l5gJPN9g', 'dBeKE2x31ruGjctqkpt2qQC5gAarsXAIV0AIOHbb2vI']
actions = {
'deploy_challenge': deploy_challenge,
'clean_challenge': clean_challenge,
'deploy_cert': deploy_cert,
'unchanged_cert': unchanged_cert,
'invalid_challenge': invalid_challenge,
'request_failure': request_failure,
'startup_hook': startup_hook,
'exit_hook': exit_hook,
}
del sys.argv[0]
try:
action = sys.argv.pop(0)
except:
action = None
if action == None or not action in actions:
# Dehydrated 0.6 relies on an exit(0) for invalid actions. I used to return exit(1) but that's not compatible with Dehydrated 0.6.
sys.exit(0)
actions[action]()