Let's Encrypt with DNS-validation (ACME v2)

This article assumes the following:

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:

  1. Set up a new (virtual) machine with PowerDNS 4.1 Authoritive, and name it ca-dns.example.com.
  2. Set up the sqlite-backend for PowerDNS (or any other backend that is supported by the HTTP API).
  3. Create a new zone there, with: pdnsutil create-zone _acme-challenges.example.com
  4. 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
  5. Add _acme-challenges.example.com IN NS ca-dns.example.com to example.com.
  6. 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]()