Letsencrypt using DNS verification with CFEngine

To use this bundle, you will need to set up dynamic DNS, for instance as I explained in https://haavard.name/2016/03/15/setting-up-key-based-dynamic-dns-updates-with-cfengine/.

I’m using a file repository in $(def.dir_files) where you will need to download letsencrypt.sh in $(def.dir_files)/usr/local/bin. You also need to setup config.sh in $(def.dir_files)/etc/letsencrypt.sh/config.sh. Use the default config.sh and modify as you see fit, the important settings is

CHALLENGETYPE="dns-01"
BASEDIR=/etc/letsencrypt.sh
HOOK=/etc/letsencrypt.sh/hook-dns.sh

Then install this hook script for updating dns when needed to verify ownership

#!/bin/bash

function 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.
    modify_key $DOMAIN
    find_master_and_zone "$DOMAIN"
    cat <<EOD | nsupdate -k "$key"
server ${MASTER}
zone ${ZONE}
update delete _acme-challenge.${DOMAIN}.
update add _acme-challenge.${DOMAIN}. 60 IN TXT ${TOKEN_VALUE}
send
EOD
  #add sleep to allow update to propagate out to all slaves and such
  sleep 61
}

function 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.
    modify_key $DOMAIN
    find_master_and_zone "$DOMAIN"
    cat <<EOD | nsupdate -k "$key"
server ${MASTER}
zone ${ZONE}
update delete _acme-challenge.${DOMAIN}.
send
EOD
}

function deploy_cert {
    local DOMAIN="${1}" KEYFILE="${2}" CERTFILE="${3}" CHAINFILE="${4}"

    # 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.
    # - CHAINFILE
    #   The path of the file containing the full certificate chain.
}

function modify_key {
  local DOMAIN="${1}"
  if ! egrep -q "^${DOMAIN}" "${key}"
  then
    newkey="${tmpdir}/$(basename ${key})"
    sed -r "s/^[^ ]+/${DOMAIN}./" "${key}" > "${newkey}"
    cp /etc/ssl/private/dnskeys/"$(basename ${key} .key)".private "${tmpdir}/"
    key="${newkey}"
  fi
}

function find_master_and_zone {
  local name=${1}
  while [ "x$name" != "x" ]
  do
    soa=$(dig +short soa $name)
    if [ "x$soa" != "x" ]
    then
      MASTER=${soa/. *}
      ZONE=$name
      break
    fi
    name=${name#*.}
  done
}

key=$(find /etc/ssl/private/dnskeys -name '*key' | head -1)
if [ ! -f "$key" ]
then
  exit 1
fi
tmpdir=$(mktemp -d) || exit 1

HANDLER=$1; shift; $HANDLER $@
rm -rf -- "$tmpdir"

Then finally the CFEngine bundle

bundle agent letsencrypt {
  vars:
    any::
      "letsencrypt_config_dir" string => "/etc/letsencrypt.sh";
      "letsencrypt_domains_text" string => "$(letsencrypt_config_dir)/domains.txt";
      "letsencrypt_binary" string => "/usr/local/bin/letsencrypt.sh";

    server1::
      "domains" slist => { "server1.example.com" };
    server2::
      "domains" slist => { "server2.example.com www.example.com", "foo.example.com" };

  classes:
    Q1::
      "has_domains" expression => isvariable("domains");

  files:
    has_domains::
      "$(letsencrypt_config_dir)"
        copy_from => no_backup_dcp("$(def.dir_files)$(letsencrypt_config_dir)"),
        depth_search => recurse("1");

      "$(letsencrypt_config_dir)/certs/."
        depth_search => recurse_with_base("3"),
        file_select => dirs,
        perms => mog("0710", "root", "ssl-cert");

      "$(letsencrypt_config_dir)/certs/."
        depth_search => recurse("3"),
        file_select => plain,
        perms => mog("0640", "root", "ssl-cert");

      "$(letsencrypt_config_dir)/hook.*"
        perms => mog("0755", "root", "root");

      "$(letsencrypt_domains_text)"
        create => "true",
        perms => mog("0640", "root", "ssl-cert"),
        edit_line => converge(".*", "@(domains)");

      "$(letsencrypt_binary)"
        copy_from => no_backup_dcp("$(def.dir_files)$(letsencrypt_binary)"),
        perms => mog("0755", "root", "root");

  commands:
    Hr01.Q2::
      "$(letsencrypt_binary) --cron >/dev/null"
        contain => in_shell;
}

You set up the list of domains in the vars section, if you have an entry with several names seperated by space, you will get a SAN certificate. Remember that to insert the KEY record for all entries using the private dns key of the server.

CC BY-NC-SA 4.0 This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

Leave a Reply

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