#!/usr/bin/env bash # acme dns-01 challenge hook script v0.0.11 # Publish an acme challenge in DNS. This script is made to work with dehydrated by lukas2511. # You need a Bind DNS Server you can publish records using nsupdate. The script was written and # tested on Devuan GNU/Linux, it uses the GNU versions of sed, grep, date, etc. You need dig to be # installed. This script currently works only with TSIG keys (not with SIG(0) key pairs). # You need to fill in the DNS server you want to send your nsupdate commands to in the variable # SERVER below the introductory remarks you are reading right now. Also set KEYPATH to point to # your TSIG key directory. Configure dehydrated using HOOK_CHAIN="no" to use this script. # You need to create a TSIG key for each domain you want to publish an acme dns challenge # with this script. The script calls the nsupdate command to publish the challenge in your # Bind DNS Server. Nsupdate needs the TSIG key in order to be authenticated on Bind. You need # to configure Bind to know this key and you need to grant access for nsupdate to alter the # _acme-challenge record for every host you include in the let's encrypt certificate. There are # several tutorials throughout the net how to set this up. In short for Devuan, Debian and # similars do: # # 0) Create a directory for your TSIG keys and secure it: # sudo mkdir # sudo chmod 0700 # # 1) Create a key: # dnssec-keygen -a HMAC-SHA512 -b 512 -n HOST -K _acme-challenge. # (Set to the directory where you store your keys, to the domain name # all of the hosts in your certificate you want to create are belonging to). Do this on the # host where this script runs. # # 2) Add the key to /etc/bind/named.conf.local: # key "_acme-challenge.." { # algorithm hmac-sha512; # secret "XXX"; # }; # Replace XXX with the string found in the file "K_acme-challenge..private" after "Key:". # You need to configure this on the master DNS server and please note the trailing dots. # # 3) Also on the master DNS add a grant statement to the zone of in /etc/bind/named.conf.local. # Restrict the updating of the _acme-challenge record as much as possible. Use a statement like: # update-policy { # grant _acme-challenge.. self _acme-challenge.. TXT; # grant _acme-challenge.. name _acme-challenge.host.. TXT; # grant _acme-challenge.. name _acme-challenge.host2.. TXT; # [...] # }; # ...where the first part after "grant" is your key name and the part before "TXT" is a host you # request the certificate for. Repeat for each host name in the certificate, as such put one grant # line for each host you include in the requested cert. # # 4) Repeat the steps from 1) to 3) for each you need a certificate for. # # # If you connect to a secondary DNS with this script, in addition to the above on the master, configure # on the slave inside the slave zone section a statement # # allow-update-forwarding { secondary; }; # # ...where "secondary" is an ACL (that could be named as you like) and has to be defined in its own # section: # # acl secondary { # 1.2.3.4/32; # 127.0.0.1/32; # }; # If you have many domains on your server you should not need to do all this manually. I may release some # more scripts to assist you with the job. Unfortunately they are not releasable in their current state. # Comments and corrections welcome. # (c) 2016-2020 under GPL v2 by Adrian Zaugg . # Path to the directory where your nsupdate keys are stored (one for each domain, # named K_acme-challenge.domain.tld.+<0-9>+<...>.private) KEYPATH="/etc/dehydrated/tsig_keys" # DNS Server to update SERVER="" # Time To Live to set for the challenge TTL=5 # Max time to try to check the challenge on all authoritative name servers for the domain CHECK_NS_TIMEOUT=10 # Exit if the verification of the published challenge fails FAILED_CHECK_IS_FATAL=false # Set to true if you use a version of dehydrated before 0.6.0 DEHYDRATED_VERSION_BEFORE_060=false # ------- do not edit below this line ------- ACME_STRING="_acme-challenge" reason="$1" HOST="$2" TOKEN="$3" CHALLENGE="$4" # execute nsupdate update function update_dns() { echo -n " + Updating DNS $SERVER: $reason for $WILDCARD$HOST... " >&2 ERR="$(nsupdate -v -k "$KEYFILE" 2>&1 << \ EOF server $SERVER $1 send EOF )" if [ $? -ne 0 ]; then echo "$ERR" >&2 exit 1 else echo "ok." >&2 fi } # select nsupdate key and get its zone get_nsupdate_keyfile() { ZONE="$HOST" TLD="$(echo "$ZONE" | sed -e "s/^.*\.//")" until [ "$ZONE" = "$TLD" ]; do KEYFILE=( ${KEYPATH}/K${ACME_STRING}.${ZONE}.+[0-9][0-9][0-9]+*.private ) KEYFILE="$(ls -1 "${KEYFILE[@]}" 2>/dev/null)" if [ $? -eq 0 ]; then break; fi ZONE="$(echo "$ZONE" | sed -e "s/^[^.]*\.//")" done if [ $(echo "$KEYFILE" | wc -l) -gt 1 ]; then echo " ERROR: Multiple nsupdate key files for $HOST found. Please correct!" >&2 exit 1 elif [ -z "$KEYFILE" ]; then echo " ERROR: No nsupdate key file for zone $HOST found. Can't publish challenge without." >&2 exit 1 fi } # get all authoritative name servers get_auth_nameservers() { # resolve hierarchically from tld and ask for registered name servers, unfortunately this is slow nsservers="$(dig -t NS +trace +nodnssec +noedns +nofail +besteffort ${HOST})" if [ $(echo "$nsservers" | egrep -c "[ \t]*SOA[ \t]*") -eq 1 ]; then # the query was for a host, not a zone, determine domain of host nsservers="$(echo "$nsservers" | head -n -3)" DOMAIN="$(echo "$nsservers" | egrep -v "^$" | tail -2 | head -1 | sed -e "s/\.\t.*$//")" else # the query was for a zone DOMAIN="$ZONE" fi # find the last answer block answer_block_start=$(echo "$nsservers" | egrep -n "^$" | tail -1 | sed -e "s/:$//") # extract name server addresses and remove trailing dot nsservers="$(echo "$nsservers" | sed -n "$answer_block_start,\$p" | egrep '^'"${DOMAIN}" | sed -e "s/^.*\tNS\t//g" -e "s/\.$//")" # warn if nothing found if [ -z "$nsservers" ]; then echo -e "\tWARNING: No authoritative name servers for $DOMAIN found. Checks for published records will not work." >&2 return 1 fi return 0 } # ensure all NS got the challenge check_challenge_on_dns() { ns_ok_cnt=0 ns_cnt=0 # get all authoritative name servers get_auth_nameservers if [ $? -ne 0 ]; then return 1; fi # test challenge on each name server for ns in $nsservers; do timestamp=$(date "+%s") dig_result="failed." echo -ne "\t+ Checking challenge on $ns.. " >&2 # try max. CHECK_NS_TIMEOUT seconds while [ $(($(date "+%s")-$timestamp)) -lt $CHECK_NS_TIMEOUT ]; do msg="$(dig +short "${ACME_STRING}.${HOST}" TXT @${ns} 2>&1)" rv=$? if [ $rv -eq 0 -a "$msg" = "\"$CHALLENGE\"" ]; then dig_result="ok." ns_ok_cnt=$((ns_ok_cnt+1)) break; elif [[ $rv -eq 0 && $(echo "$msg" | wc -l) -eq 2 && "$ZONE" = "$HOST" && ${msg#*CHALLENGE} ]]; then # wildcard cert hit dig_result="ok." ns_ok_cnt=$((ns_ok_cnt+1)) break; elif [ $rv -gt 0 -a -n "$msg" ]; then dig_result="failed: $(echo "$msg" | sed -e "s/^;; //")" fi sleep 0.5 done echo "$dig_result" >&2 ns_ok_cnt=$((ns_ok_cnt+1)) done # if there was no answer or just errors from dig, exit non-zero [ $ns_ok_cnt -eq 0 ] && echo -e "\tERROR: None of the name server(s) answer the challenge correctly." >&2 && $FAILED_CHECK_IS_FATAL && exit 1 # Report some NS failed [ $ns_ok_cnt -lt $ns_cnt ] && echo -e "\tWARNING: Only $ns_ok_cnt out of $ns_cnt name servers do answer the challenge correctly." >&2 } # Check there is a DNS configured if [ -z "$SERVER" ]; then echo -e "\tERROR: You must specify a DNS server name. Please edit the script $(basename "$0") and set the variable \"SERVER\" to point to your name server used to publish the acme-challenge." >&2 exit 1 fi # read and set DEHYDRATED_VERSION_BEFORE_060 if [ -n "$DEHYDRATED_VERSION_BEFORE_060" ]; then if [[ "${DEHYDRATED_VERSION_BEFORE_060,,}" =~ ^(yes|y|true)$ ]]; then DEHYDRATED_VERSION_BEFORE_060=true else DEHYDRATED_VERSION_BEFORE_060=false fi else DEHYDRATED_VERSION_BEFORE_060=false fi # process wildcard certificate request; remove "*." from host name if [ "${HOST:0:1}" = "*" ]; then if $DEHYDRATED_VERSION_BEFORE_060; then echo -e "\tERROR: This hook script is configured to use dehydrated before version 0.6.0. As such wildcard certificates are\n\t not supported. Please change the variable DEHYDRATED_VERSION_BEFORE_060 to false to process wildcard certs." >&2 exit 1 else WILDCARD='*.' HOST="${HOST:2:$((${#HOST}-2))}" fi fi # Process command passed case "$reason" in deploy_challenge) # search the nsupdate key file get_nsupdate_keyfile # construct the line to update the dns zone with update_data="${ACME_STRING}.${HOST}. $TTL IN TXT \"$CHALLENGE\"" # delete any previous challenge if $DEHYDRATED_VERSION_BEFORE_060; then old_challenges="$(dig +short ${ACME_STRING}.${HOST}. TXT)" for old_challenge in $old_challenges; do reason="deleting previous challenge" update_dns "update delete ${ACME_STRING}.${HOST}. $TTL IN TXT $old_challenge" done fi # publish challenge reason="publishing acme challenge" update_dns "update add $update_data" # ensure all NS got the challenge check_challenge_on_dns ;; clean_challenge) # search the nsupdate key file get_nsupdate_keyfile # construct the line to update the dns zone with update_data="${ACME_STRING}.${HOST}." # remove challenge from server reason="removing acme challenge" update_dns "update delete $update_data" ;; deploy_cert|unchanged_cert|invalid_challenge|request_failure|startup_hook|exit_hook) reason="nothing to do!" ;; *) # Unknown hook command reason="Unknown hook command: \"$reason\". This should be ignored as requested by dehydrated." ;; esac exit 0