Certbot

Last modified by Sebastian Marsching on 2022/05/29 13:35

Certbot is probably the most widely used client for Let’s Encrypt.

DNS challenge

When a system is behind a firewall that does not allow HTTP traffic, the DNS challenge can be used as an alternative way of authenticating with the Let's Encrypt CA. When the DNS server for the domain allows dynamic updates, the DNS record for the challenge can be created automatically, by supplying the following script as a hook for Certbot:

#!/bin/bash

# Certbot manual hook script for perfoming the DNS challenge.
#
# Copyright 2017 Sebastian Marsching
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# This script was inspired by the script from
# https://ente.limmat.ch/ftp/pub/software/bash/letsencrypt/.
#
# In order to use this script, call Certbot in the following way:
# certbot certonly --manual --manual-auth-hook /path/to/this/script \
#   --manual-cleanup-hook /path/to/this/script --preferred-challenges dns \
#   --manual-public-ip-logging-ok -d example.com
#
# In order for this script to work, dig and nsupdate have to be in the PATH.
#
# The authentication key for the DNS updates must be in
# /etc/letsencrypt/ddns-keys and must conform to the format generated by
# tsig-keygen. The filename must have the form ddns-key.example.com, where
# example.com is the domain for which the certificate is requested or one of its
# parent domains. If there is no domain-specific key file, this script falls
# back to using the key in the file /etc/letsencrypt/ddns-keys/ddns-key (if it
# exists).

# Directory containing the DDNS authentication keys.
KEYDIR="/etc/letsencrypt/ddns-keys"

# DNS server to be updated. If empty, the DNS server that is responsible for the
# zone is selected.
SERVER=""

# TTL of the TXT record created for the challenge.
TTL=10

# Maximum time to wait for all authoritative DNS servers to provide the updated
# record.
CHECK_NS_TIMEOUT=10

# Usually, you should not have to make any changes below this line.

# If there is an error, we want to fail immediately.
set -e

# Calls nsupdate and sends the update commands provided via stdin.
call_nsupdate() {
 local keyfile
 local rc
 keyfile="$(find_ddns_keyfile)"
 {
   if [ -n "${SERVER}" ]; then
     echo "server ${SERVER}"
   fi
    cat
   echo "send"
 } | nsupdate -v -k "${keyfile}" >&2; rc="$?"; true
 if [ "${rc}" -ne 0 ]; then
    print_info "ERROR: DNS update failed."
   exit 2;
 fi
}

# Cleans the DNS records created earlier.
clean_challenge() {
 # When cleaning the challenge, we only delete the record that we created
 # earlier.
 call_nsupdate <<EOF
update delete ${DNS_RECORD}
EOF

}

# Finds the DDNS keyfile that is supposed to be used for the domain.
find_ddns_keyfile() {
 local current_domain
 local next_domain
 local keyfile
 current_domain="${CERTBOT_DOMAIN}"
 while [ -n "${current_domain}" ]; do
   keyfile="${KEYDIR}/ddns-key.${current_domain}"
   if [ -e "${keyfile}" ]; then
     echo "${keyfile}"
     return 0
   fi
   next_domain="${current_domain#*.}"
   if [ "${next_domain}" = "${current_domain}" ]; then
     # We have reached the point where the domain does not contain a dot, so
     # there are no more key files that we could try.
     next_domain=""
   fi
   current_domain="${next_domain}"
 done
 # If we have not found a key file, we try the generic one.
 keyfile="${KEYDIR}/ddns-key"
 if [ -e "${keyfile}" ]; then
   echo "${keyfile}"
   return 0
 fi
  print_info "ERROR: Could not find a key file for domain ${CERTBOT_DOMAIN}."
 exit 2
}

# Finds the DNS servers responsible for DNS name.
find_dns_servers() {
 local current_domain
 local next_domain
 local found_servers
 current_domain="${DNS_NAME}"
 while [ -n "${current_domain}" ]; do
   found_servers="$(dig +noall +answer "${current_domain}" ns | rev | cut -f 1 | rev)"
   if [ -n "${found_servers}" ]; then
     echo "${found_servers}"
     return 0
   fi
   next_domain="${current_domain#*.}"
   if [ "${next_domain}" = "${current_domain}" ]; then
     # We have reached the point where the domain does not contain a dot, so
     # there are no more parent domains that we could try.
     next_domain=""
   fi
   current_domain="${next_domain}"
 done
  print_info "ERROR: Could not determine the name servers responsible for ${DNS_NAME}."
 exit 2
}

# Gets the challenge currently registered in DNS on the specified server.
get_challenge_from_dns() {
 local dns_server
 dns_server="$1"
  dig +noall +answer "${DNS_NAME}" txt @"${dns_server}" | rev | cut -f 1 | rev
}

# Writes the current UNIX timestamp to stdout.
now() {
  date "+%s"
}

# Prepares the challenge, registering the DNS record and waiting until it has
# been distributed.
prepare_challenge() {
 local dns_server
 local dns_servers
 local deadline
 # When preparing the challenge, we want to delete all old records that might
 # be lingering and to add a new record for the challenge.
 call_nsupdate <<EOF
update delete ${DNS_NAME}. IN TXT
update add ${DNS_RECORD}
EOF

 # We wait some time for the DNS update to be distributed to all nameservers.
 dns_servers="$(find_dns_servers)"
 deadline=$(($(now) + ${CHECK_NS_TIMEOUT}))
 for dns_server in $dns_servers; do
   while [ "$(get_challenge_from_dns "${dns_server}")" != "\"${CERTBOT_VALIDATION}\"" ]; do
     if [ $(now) -gt ${deadline} ]; then
        print_info "ERROR: Timeout while waiting for DNS to be updated."
       exit 2
     fi
   done
 done
 # We have to generate some output so that CERTBOT_AUTH_OUTPUT is not empty
 # when this script is called again.
 echo "DNS update succeeded."
}

# Prints a message to stderr.
print_info() {
 echo "$@" >&2
}

# Before starting the actual work, we verify the environment.
if [ -z "${CERTBOT_DOMAIN}" ]; then
  print_info "ERROR: The CERTBOT_DOMAIN environment variable is empty."
 exit 1
fi

# We expect the CERTBOT_DOMAIN to have no trailing dot, but if it has one, we
# remove it in order to avoid problems.
CERTBOT_DOMAIN="${CERTBOT_DOMAIN%.}"

if [ -z "${CERTBOT_VALIDATION}" ]; then
  print_info "ERROR: The CERTBOT_VALIDATION environment variable is empty."
 exit 1
fi

DNS_NAME="_acme-challenge.${CERTBOT_DOMAIN}"
DNS_RECORD="${DNS_NAME}${TTL} IN TXT \"${CERTBOT_VALIDATION}\""

# If CERTBOT_AUTH_OUTPUT is empty, this script has been called in order to
# prepare the challenge, otherwise it has been called for cleanup.
if [ -z "${CERTBOT_AUTH_OUTPUT}" ]; then
  prepare_challenge
else
  clean_challenge
fi