Certificate management Method 1

Background

At Sinodun we use dehydrated https://github.com/lukas2511/dehydrated to manage our certificates. Also we use the dns-01 challenge to renew them.

Since we run multiple DNS-over-TLS servers, the method used here employs a single ‘certificate management’ server to renew the certificates, update the zone with the dns-01 challenge and make the renewed certificates available via ftp. A script is then run on each DNS server to download any new certificates using ftp to each server and restart the DNS service. In this example we use TLS proxies in front of BIND, or Knot resolver.

DNS setup

You could put the dns-01 challenge responses in your organisations main zone. However it is more flexible to dedicate a separate zone for that, especially if you are responsible for running the servers but someone else or some other organisation is responsible for the DNS.

For authoritative DNS we use knot https://www.knot-dns.cz/ because it has a nice interface for managing zones. The same effect could be obtained using dynamic updates.

It is a good idea to sign your zone if you are going to be putting challenge response records in it. However, the nameserver must be able to sign updates as they are applied.

CNAMES

Lets assume that we are managing certificates for two servers dnsovertls1.<YOURDOMAIN>. and dnsovertls.<YOURDOMAIN>.

Add CNAME RRs to the <YOURDOMAIN> zone like these. These will redirect queries for the dns-01 challenge to a dedicated zone which can exist on and be served by a dedicated certificate management server

_acme-challenge.dnsovertls1.<YOURDOMAIN>. 300 CNAME dnsovertls1.<YOURDOMAIN>.acme.<YOURDOMAIN>.
_acme-challenge.dnsovertls.<YOURDOMAIN>. 300 CNAME dnsovertls.<YOURDOMAIN>.acme.<YOURDOMAIN>.

Delegate acme.<YOURDOMAIN> to the certificate management server by adding NS and DS RRs to the <YOURDOMAIN> zone. Lets assume the server is called ns1.acme.<YOURDOMAIN>

Create an empty zone for acme.<YOURDOMAIN> (SOA and NS and A/AAAA RRs). Configure knot to sign the domain

server:
  listen: <YOURIP4>@53
  listen: <YOURIP6>@53
  user: knot:knot

log:
  - target: syslog
    any: info
        
policy:
  - id: dnssec
    algorithm: ECDSAP256SHA256

zone:
  - domain: acme.<YOURDOMAIN>
    file: "acme.<YOURDOMAIN>.zone"
    dnssec-signing: on
    dnssec-policy: dnssec
acme.<YOURDOMAIN>.       3600    SOA     ns1.acme.<YOURDOMAIN>. user.<YOURDOMAIN>. 2016120927 30000 300 604800 300
acme.<YOURDOMAIN>.       3600    NS      ns1.acme.<YOURDOMAIN>.
ns1.acme.<YOURDOMAIN>.   3600    A       <YOURIP4>
ns1.acme.<YOURDOMAIN>.   3600    AAAA    <YOURIP6>

Configure dehydrated

Get dehydrated https://github.com/lukas2511/dehydrated and create some config.

Uncomment the first two lines until you are sure everything is working. This will allow you to run the scripts for debugging without exceeding the production Let’s Encrypt limits

## CA="https://acme-staging.api.letsencrypt.org/directory"
## CA_TERMS="https://acme-staging.api.letsencrypt.org/terms"
LICENSE="https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf"
CERTDIR=/<PATH>/dehydrated/certs
DOMAINS_TXT=/<PATH>/dehydrated/dehydrated.domains
CHALLENGETYPE="dns-01"
HOOK=/<PATH>/dehydrated/hook.sh
PRIVATE_KEY_RENEW="no"
PRIVATE_KEY_ROLLOVER="no"
CONTACT_EMAIL=you AT email

The two PRIVATE_KEY lines ensure that the key is not replaced and so SPKI pinning will work.

Create a domains file

Create a dehydrated.domains file (/<PATH>/dehydrated/dehydrated.domains)

dnsovertls.<YOURDOMAIN>
dnsovertls1.<YOURDOMAIN>

Write a hook script

Create a hook.sh script (/<PATH>/dehydrated/hook.sh). 

#!/usr/bin/env bash

set -e
set -u
set -o pipefail

check_knotc_exit_code() {
  if [[ ! $1 -eq 0 ]] ; then
    /usr/sbin/knotc zone-abort acme.<YOURDOMAIN>.
    exit 1
  fi
}

deploy_challenge() {
  local DOMAIN="${1}.acme.<YOURDOMAIN>." RDATA="${3}"
  if [[ "${1}" == "ns1.acme.<YOURDOMAIN>" ]] ; then
    DOMAIN="_acme-challenge.${1}."
  fi
  echo "Adding $DOMAIN 10 TXT \"$RDATA\""
  echo "zone-begin acme.<YOURDOMAIN>."
  /usr/sbin/knotc zone-begin acme.<YOURDOMAIN>.
  check_knotc_exit_code $?
  echo "zone-set acme.<YOURDOMAIN>. $DOMAIN 10 TXT \"$RDATA\""
  /usr/sbin/knotc zone-set acme.<YOURDOMAIN>. $DOMAIN 10 TXT \"$RDATA\"
  check_knotc_exit_code $?
  echo "zone-commit acme.<YOURDOMAIN>."
  /usr/sbin/knotc zone-commit acme.<YOURDOMAIN>.
  check_knotc_exit_code $?
}
 
clean_challenge() {
   local DOMAIN="${1}.acme.<YOURDOMAIN>." RDATA="${3}"
   if [[ "${1}" == "ns1.acme.<YOURDOMAIN>" ]] ; then
     DOMAIN="_acme-challenge.${1}."
   fi
   echo "Removing $DOMAIN 10 TXT \"$RDATA\""
   echo "zone-begin acme.<YOURDOMAIN>."
   /usr/sbin/knotc zone-begin acme.<YOURDOMAIN>.
   check_knotc_exit_code $?
   echo "zone-unset acme.<YOURDOMAIN>. $DOMAIN 10 TXT \"$RDATA\""
   /usr/sbin/knotc zone-unset acme.<YOURDOMAIN>. $DOMAIN TXT
   check_knotc_exit_code $?
   echo "zone-commit acme.<YOURDOMAIN>."
   /usr/sbin/knotc zone-commit acme.<YOURDOMAIN>.
   check_knotc_exit_code $?
}
 
deploy_cert() {
   local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}" TIMESTAMP="${6}"
   ## Copy cert files (not the keys) to your download site of choice - web, ftp etc...
    echo "Deploying certs to ftp server"
    echo "Deploy for domain ${DOMAIN}: ${CERTFILE} ${FULLCHAINFILE} ${CHAINFILE}"
    cp ${CERTFILE} /srv/ftp/certs/${DOMAIN}/
    cp ${FULLCHAINFILE} /srv/ftp/certs/${DOMAIN}/
    cp ${CHAINFILE} /srv/ftp/certs/${DOMAIN}/
    chmod 644 /srv/ftp/certs/${DOMAIN}/*
}
 
unchanged_cert() {
   local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" FULLCHAINFILE="${4}" CHAINFILE="${5}"
   ## nothing yet..
}

startup_hook() {
  ## This hook is called before the cron command to do some initial tasks
  ## (e.g. starting a webserver).
   for i in $(awk ' { print $1 } ' /<PATH>/dehydrated/dehydrated.domains ) ; do
      CERTPATH=/srv/ftp/certs/$i
      if [[ ! -d ${CERTPATH} ]] || \
         [[ $(stat -c %a ${CERTPATH}) -ne 770 ]] || \
         [[ $(stat -c %U ${CERTPATH}) != "<USER THE CERTS ARE CREATED BY>" ]] || \
         [[ $(stat -c %G ${CERTPATH}) != "ftp" ]] ; then 
           echo "Creating, chmod, chown FTP directory for $i"
           mkdir -p ${CERTPATH}
           chown <USER THE CERTS ARE CREATED BY>:ftp ${CERTPATH}
           chmod 770 ${CERTPATH}
      fi
   done
   for i in $(cat /<PATH>/dehydrated/dehydrated.domains) ; do
      ## Now check that the zone owner has a CNAME pointing to us for this domain
      CNAME=$(dig _acme-challenge.$i CNAME +short)
      if [[ -z ${CNAME} ]] ; then
        echo "There is no CNAME pointing here for $i in the DNS"
        exit 1
      fi
  done
  
}

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

  :
}

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 an 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...)
}

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

Write a script to download the certificates to your DNS servers

Do something like the following on each servers to download the certificates from your main certificate server. 

#!/usr/bin/env bash

set -e
set -u
set -o pipefail

if [[ ${LOGNAME} != "root" ]] ; then
  echo "Must be root. Exiting..."
  exit 1
fi

## depending on what you are using to terminate the TLS connections
## you might need to ensure the correct user can access the certificate files
SERVICEAPP=$1
if [[ ${SERVICEAPP} == "haproxy" ]] || \
   [[ ${SERVICEAPP} == "knot-resolver" ]] ; then
  GROUPUSER=${SERVICEAPP}
elif [[ ${SERVICEAPP} == "apache2" ]] || \
     [[ ${SERVICEAPP} == "apache" ]] || \
     [[ ${SERVICEAPP} == "nginx" ]] ; then
  GROUPUSER="www-data"
else
  echo "Unsupported service application. Exiting..."
  exit 1
fi

CERTPATH="/etc/certs"

if [[ ! -d ${CERTPATH} ]] || \
   [[ $(stat -c %a ${CERTPATH}) -ne 750 ]] || \
   [[ $(stat -c %U ${CERTPATH}) != "root" ]] || \
   [[ $(stat -c %G ${CERTPATH}) != ${GROUPUSER} ]] ; then
  echo "No ${CERTPATH} directory or permissions not good."
  echo "Do something like:"
  echo "  sudo mkdir -p ${CERTPATH}"
  echo "  sudo chmod 750 ${CERTPATH}"
  echo "  sudo chown root:${GROUPUSER} ${CERTPATH}"
  echo "Exiting..."
  exit 1
fi

cd ${CERTPATH}

if [[ ! -f privkey.pem ]] || \
   [[ $(stat -c %a privkey.pem) -ne 640 ]] || \
   [[ $(stat -c %U privkey.pem) != "root" ]] || \
   [[ $(stat -c %G ${CERTPATH}/privkey.pem) != ${GROUPUSER} ]] || \
   [[ ! -s privkey.pem ]] ; then
  echo "Private key not found or permissions not good. Exiting..."
  exit 1
fi

## Use the hostname to figure out the cert CN
CN=$(hostname -f)
if [[ "${CN}" == "test1.dnsovertls.nl" ]] ; then
  CN="dnsovertls.<YOURDOMAIN>"
elif [[ "${CN}" == "test2.dnsovertls.nl" ]] ; then
  CN="dnsovertls1.<YOURDOMAIN>"
fi

tar -cf backup-$(date +%s).tar *.pem


curl -s --ssl-reqd --tlsv1 -O ftp://ns1.acme.<YOURDOMAIN>:21/certs/${CN}/cert.pem
curl -s --ssl-reqd --tlsv1 -O ftp://ns1.acme.<YOURDOMAIN>:21/certs/${CN}/chain.pem
curl -s --ssl-reqd --tlsv1 -O ftp://ns1.acme.<YOURDOMAIN>:21/certs/${CN}/fullchain.pem
if [[ ! -s cert.pem ]] || \
   [[ ! -s chain.pem ]] || \
   [[ ! -s fullchain.pem ]] ; then
  echo "At least one of the certificate files is empty or missing. Exiting..."
  exit 1
fi

cat fullchain.pem > keycert.pem
cat privkey.pem >> keycert.pem
chmod 640 cert.pem chain.pem fullchain.pem keycert.pem
chown root:${GROUPUSER} cert.pem chain.pem fullchain.pem keycert.pem

if [[ ${SERVICEAPP} == "haproxy" ]] || \
   [[ ${SERVICEAPP} == "apache2" ]] || \
   [[ ${SERVICEAPP} == "nginx" ]] ; then
  INITNAME=${SERVICEAPP}
elif [[ ${SERVICEAPP} == "apache" ]] ; then
  INITNAME="apache2"
else
  echo "Unsupported service application. Exiting..."
  exit 1
fi
systemctl reload-or-restart ${INITNAME}