Let’s Encrypt with Dehydrated: DNS-01

In my previous guide on dehydrated, the bash client for let’s encrypt, I’ve only touched on the DNS-01 feature. Upon further investigation and usage of said feature I give you this guide.

DNS-01

DNS-01 is another type of verification of ownership of a domain using TXT DNS records. To use this validation you need to set a specific TXT record (_acme-challenge) on your domain to indicate the verification server that you own the domain.

Records

When generating a certificate for your main domain, you only need to set the TXT record with the given let’s encrypt challenge. However, if you want to generate a certificate for a subdomain, you need to set 2 TXT records: one for your main domain and one for your sub-domain since you still need to prove let’s encrypt that you have ownership to the main domain.

Example

For the sake of the example, I’m using BIND syntax.

I want to verify example.com I set the correct challenge on the domain with the TXT record where XXXXXXXX is the challenge.

_acme-challenge    IN    TXT    XXXXXXXXXXXXXXXXXX

Now if I want to do it for test.example.com

_acme-challenge           IN    TXT    XXXXXXXXXXXXXXX
_acme-challenge.test      IN    TXT    XXXXXXXXXXXXXXX

Tutorial

Now that I’ve covered the base about the DNS-01, we can dive into using Dehydrated, Cloudflare and lexicon to set DNS-01 as the verification method.

If you need more information about Dehydrated, don’t hesitate to read my previous article on it.

Lexicon

Lexicon is a great python script that takes care of DNS management for a lot of providers like Namecheap, CloudFlare, DigitalOcean, etc … the list is huge, and the software is easy to uses.

GitHub Reposidget for WordPress

AnalogJ / lexicon

Manipulate DNS records on various DNS providers in a standardized way.

Cloudflare

Since I used CloudFlare for my domain, I’m covering this part, but the lexicon repository has guides for all different providers and how to use their command line. You would only need to edit the lexicon command in my hooks.sh file to make it work with your desired provider.

To start, install lexicon using pip.

pip3 install dns-lexicon

For CloudFlare to work, you’ll need to fetch your API key; it’s in the settings of your CloudFlare account.

Command-line example

Taken from the lexicon repository

# setup provider environmental variables:
LEXICON_CLOUDFLARE_USERNAME="[email protected]"
LEXICON_CLOUDFLARE_TOKEN="cloudflare-api-token"

# list all TXT records on cloudflare
lexicon cloudflare list example.com TXT

# create a new TXT record on cloudflare
lexicon cloudflare create www.example.com TXT --name="_acme-challenge.www.example.com." --content="challenge token"

# delete a  TXT record on cloudflare
lexicon cloudflare delete www.example.com TXT --name="_acme-challenge.www.example.com." --content="challenge token"
lexicon cloudflare delete www.example.com TXT --identifier="cloudflare record id"

As you can see, the commands are straightforward.

Dehydrated

Now that we covered the commands to use in Lexicon, let’s continue with the configuration of dehydrated.

Hooks

We’ll create a hooks file to use Lexicon to setup the DNS records and remove them when they aren’t needed anymore. I advise creating a hooks.sh file that you’ll put alongside your configuration file.

#!/usr/bin/env bash

export LEXICON_CLOUDFLARE_USERNAME="[email protected]"
export LEXICON_CLOUDFLARE_TOKEN="cloud-flare-token"

tld_domain() {
    local DOMAIN="${1}" 
    
    echo $(echo "$DOMAIN" | awk -F'.' '{gsub("http://|/.*","")} NF>2{$1="";$0=substr($0, 2)}1' OFS='.')
}

deploy_challenge() {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    # This hook is called once for every domain that needs to be
    # validated, including any alternative names you may have listed.
    #
    # Parameters:
    # - DOMAIN
    #   The domain name (CN or subject alternative name) being
    #   validated.
    # - TOKEN_FILENAME
    #   The name of the file containing the token to be served for HTTP
    #   validation. Should be served by your web server as
    #   /.well-known/acme-challenge/${TOKEN_FILENAME}.
    # - TOKEN_VALUE
    #   The token value that needs to be served for validation. For DNS
    #   validation, this is what you want to put in the _acme-challenge
    #   TXT record. For HTTP validation it is the value that is expected
    #   be found in the $TOKEN_FILENAME file.

    TLD_DOMAIN=$(tld_domain $DOMAIN)
    
    #always create the record for the TLD
    lexicon cloudflare create ${TLD_DOMAIN} TXT --name="_acme-challenge.${TLD_DOMAIN}." --content="$TOKEN_VALUE"
    
    # If we're doing it for a subdomain
    if [ "$TLD_DOMAIN" != "$DOMAIN" ]
    then
        lexicon cloudflare create ${TLD_DOMAIN} TXT --name="_acme-challenge.${DOMAIN}." --content="$TOKEN_VALUE"
    fi    
}

clean_challenge() {
    local DOMAIN="${1}" TOKEN_FILENAME="${2}" TOKEN_VALUE="${3}"

    # This hook is called after attempting to validate each domain,
    # whether or not validation was successful. Here you can delete
    # files or DNS records that are no longer needed.
    #
    # The parameters are the same as for deploy_challenge.
    
    TLD_DOMAIN=$(tld_domain $DOMAIN)
    
    #always delete the record for the TLD
    lexicon cloudflare delete ${TLD_DOMAIN} TXT --name="_acme-challenge.${TLD_DOMAIN}."
    
    # If we're doing it for a subdomain
    if [ "$TLD_DOMAIN" != "$DOMAIN" ]
    then
        lexicon cloudflare delete ${TLD_DOMAIN} TXT --name="_acme-challenge.${DOMAIN}."
    fi 
}

deploy_cert() {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"

    # This hook is called once for each certificate that has been
    # produced. Here you might, for instance, copy your new certificates
    # to service-specific locations and reload the service.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - KEYFILE
    #   The path of the file containing the private key.
    # - CERTFILE
    #   The path of the file containing the signed certificate.
    # - FULLCHAINFILE
    #   The path of the file containing the full certificate chain.
    # - CHAINFILE
    #   The path of the file containing the intermediate certificate(s).
    # - TIMESTAMP
    #   Timestamp when the specified certificate was created.
}

unchanged_cert() {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"

    # This hook is called once for each certificate that is still
    # valid and therefore wasn't reissued.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - KEYFILE
    #   The path of the file containing the private key.
    # - CERTFILE
    #   The path of the file containing the signed certificate.
    # - FULLCHAINFILE
    #   The path of the file containing the full certificate chain.
    # - CHAINFILE
    #   The path of the file containing the intermediate certificate(s).
}

invalid_challenge() {
    local DOMAIN="${1}" RESPONSE="${2}"

    # This hook is called if the challenge response has failed, so domain
    # owners can be aware and act accordingly.
    #
    # Parameters:
    # - DOMAIN
    #   The primary domain name, i.e. the certificate common
    #   name (CN).
    # - RESPONSE
    #   The response that the verification server returned
}

request_failure() {
    local STATUSCODE="${1}" REASON="${2}" REQTYPE="${3}"

    # This hook is called when a HTTP request fails (e.g., when the ACME
    # server is busy, returns an error, etc). It will be called upon any
    # response code that does not start with '2'. Useful to alert admins
    # about problems with requests.
    #
    # Parameters:
    # - STATUSCODE
    #   The HTML status code that originated the error.
    # - REASON
    #   The specified reason for the error.
    # - REQTYPE
    #   The kind of request that was made (GET, POST...)
}

exit_hook() {
  # This hook is called at the end of a dehydrated command and can be used
  # to do some final (cleanup or other) tasks.

  :
}

HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert|invalid_challenge|request_failure|exit_hook)$ ]]; then
  "$HANDLER" "[email protected]"
fi

This hooks file will only make the DNS-01 validation possible. If you want to make it reload Nginx after deploying the certificate you’ll also need to set the deploy_cert function with the method you want to call like:

systemctl reload nginx

Configuration file

You need to set your contact email in this configuration file and as previously edit the domains.txt file that will be alongside this configuration file.

You also need to put the file hooks.sh  next to this configuration file.

########################################################
# This is the main config file for dehydrated          #
#                                                      #
# This file is looked for in the following locations:  #
# $SCRIPTDIR/config (next to this script)              #
# /usr/local/etc/dehydrated/config                     #
# /etc/dehydrated/config                               #
# ${PWD}/config (in current working-directory)         #
#                                                      #
# Default values of this config are in comments        #
########################################################

# Resolve names to addresses of IP version only. (curl)
# supported values: 4, 6
# default: <unset>
#IP_VERSION=

# Path to certificate authority (default: https://acme-v01.api.letsencrypt.org/directory)
#CA="https://acme-v01.api.letsencrypt.org/directory"

# Path to certificate authority license terms redirect (default: https://acme-v01.api.letsencrypt.org/terms)
#CA_TERMS="https://acme-v01.api.letsencrypt.org/terms"

# Path to license agreement (default: <unset>)
#LICENSE=""

# Which challenge should be used? Currently http-01 and dns-01 are supported
CHALLENGETYPE="dns-01"

# Path to a directory containing additional config files, allowing to override
# the defaults found in the main configuration file. Additional config files
# in this directory needs to be named with a '.sh' ending.
# default: <unset>
#CONFIG_D=

# Base directory for account key, generated certificates and list of domains (default: $SCRIPTDIR -- uses config directory if undefined)
#BASEDIR=$SCRIPTDIR

# File containing the list of domains to request certificates for (default: $BASEDIR/domains.txt)
#DOMAINS_TXT="${BASEDIR}/domains.txt"

# Output directory for generated certificates
#CERTDIR="${BASEDIR}/certs"

# Directory for account keys and registration information
#ACCOUNTDIR="${BASEDIR}/accounts"

# Output directory for challenge-tokens to be served by webserver or deployed in HOOK (default: /var/www/dehydrated)
#WELLKNOWN="/var/www/dehydrated"

# Default keysize for private keys (default: 4096)
#KEYSIZE="4096"

# Path to openssl config file (default: <unset> - tries to figure out system default)
#OPENSSL_CNF=

# Program or function called in certain situations
#
# After generating the challenge-response, or after failed challenge (in this case altname is empty)
# Given arguments: clean_challenge|deploy_challenge altname token-filename token-content
#
# After successfully signing certificate
# Given arguments: deploy_cert domain path/to/privkey.pem path/to/cert.pem path/to/fullchain.pem
#
# BASEDIR and WELLKNOWN variables are exported and can be used in an external program
# default: <unset>
HOOK="${BASEDIR}/hooks.sh"

# Chain clean_challenge|deploy_challenge arguments together into one hook call per certificate (default: no)
#HOOK_CHAIN="no"

# Minimum days before expiration to automatically renew certificate (default: 30)
#RENEW_DAYS="30"

# Regenerate private keys instead of just signing new certificates on renewal (default: yes)
#PRIVATE_KEY_RENEW="yes"

# Create an extra private key for rollover (default: no)
#PRIVATE_KEY_ROLLOVER="no"

# Which public key algorithm should be used? Supported: rsa, prime256v1 and secp384r1
#KEY_ALGO=rsa

# E-mail to use during the registration (default: <unset>)
CONTACT_EMAIL=YOUR EMAIL

# Lockfile location, to prevent concurrent access (default: $BASEDIR/lock)
#LOCKFILE="${BASEDIR}/lock"

# Option to add CSR-flag indicating OCSP stapling to be mandatory (default: no)
#OCSP_MUST_STAPLE="no"

You still need to edit your domains.txt to put the domain you want to generate a certificate for.

Command

Now you just need to launch dehydrated with the set configuration file to try your DNS-01 setup.

If like me you put dehydrated in /usr/local/sbin and your config in /etc/dehydrated.

/usr/local/sbin/dehydrated -c -f /etc/dehydrated/config

 

End words

Lexicon combined with Dehydrated make the DNS-01 validation easy-peasy. With the number of DNS provider Lexicon support, you should be able to adapt it to your needs if you don’t want to use CloudFlare.

 

Antoine Aflalo Written by:

One Comment

Leave a Reply

Your email address will not be published. Required fields are marked *