Skip to content

Instantly share code, notes, and snippets.

@Aikhjarto
Created October 19, 2025 11:23
Show Gist options
  • Save Aikhjarto/b4cb9acc5cbb7a3aeb4a1038153ca357 to your computer and use it in GitHub Desktop.
Save Aikhjarto/b4cb9acc5cbb7a3aeb4a1038153ca357 to your computer and use it in GitHub Desktop.
Hook for dehydrated using DNS challenge with desec.io
#!/usr/bin/env bash
# This script requires the following credentials to hosteurope as environment variable
# export DESEC_DOMAIN="my.domain.org"
# export DESEC_TOKEN="abcdef"
function waitns {
local ns="$1"
local DNS_SYNC_TIMEOUT=900
logger "Waiting up to $DNS_SYNC_TIMEOUT second for challenge "_acme-challenge.${DOMAIN}." to appear with ${TOKEN_VALUE} on ${ns}"
for ctr in $(seq 1 "$DNS_SYNC_TIMEOUT"); do
if [ "$(dig +short "@${ns}" TXT "_acme-challenge.${DOMAIN}." | grep "${TOKEN_VALUE}" | wc -l)" == "1" ]; then
logger "Found challenge on ${ns} after ${ctr} trys."
return 0
fi
sleep 1
done
logger "Can't find challenge on ${ns}"
return 1
}
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.
logger "Deploy challenge $*"
[ -z "$DESEC_DOMAIN" ] && logger -p error "DESEC_DOMAIN variable is empty!"
[ -z "$DESEC_TOKEN" ] && logger -p error "DESEC_TOKEN variable is empty!"
while (( "$#" )); do
# Hint: ${DOMAIN} is like www.my.domain.org
# you have to split it up in name=www and domain=my.domain.org
CHALLENGE_NAME=$(sed "s/.$DESEC_DOMAIN//" <<<"${1}")
# deSEC only allows one request per seconds, thus sleep two seconds to be on the save side
sleep 2
RES=$(curl -s https://desec.io/api/v1/domains/${DESEC_DOMAIN}/rrsets/\?subname=_acme-challenge.${CHALLENGE_NAME} \
--header "Authorization: Token ${DESEC_TOKEN}")
EX=$?
if [ $EX -ne 0 ]; then
logger -p error "Curl exited with $EX while checking if _acme-challenge.$CHALLENGE_NAME exists"
return 1
fi
# deSEC only allows one request per seconds, thus sleep two seconds to be on the save side
sleep 2
if [ "$RES" = "[]" ]; then
logger "Token value ${3} goes to TXT entry _acme-challenge.${CHALLENGE_NAME} using HTTP PUT"
RES=$(curl -s -X POST https://desec.io/api/v1/domains/${DESEC_DOMAIN}/rrsets/ \
--header "Authorization: Token ${DESEC_TOKEN}" \
--header "Content-Type: application/json" \
--data @- <<< '{"subname": "_acme-challenge.'$CHALLENGE_NAME'", "type": "TXT", "ttl": 3600, "records": ["\"'$3'\""]}')
else
# TXT entry already exists, probably due to unclean exit in last run
logger "Token value ${3} goes to TXT entry _acme-challenge.${CHALLENGE_NAME} using PATCH"
RES=$(curl -s -X PATCH https://desec.io/api/v1/domains/${DESEC_DOMAIN}/rrsets/_acme-challenge.$CHALLENGE_NAME}/TXT/ \
--header "Authorization: Token ${DESEC_TOKEN}" \
--header "Content-Type: application/json" \
--data @- <<< '{"records": ["\"'$3'\""]}')
fi
EX=$?
if [ $EX -ne 0 ]; then
logger -p error "Token value was not set, curl exited with error $EX, and message \'$RES\'"
return 1
else
logger "Token value was set, curl exited with error $EX, and message \'$RES\'"
fi
shift 3
done
# give name server time to sync the changes from the API calls
sleep 10
# Wait for all name servers to update each other
for ns in $(dig +short NS "${DESEC_DOMAIN}."); do
waitns "$ns"
if [ $? -ne 0 ]; then
clean_challenge ${DOMAIN} ${TOKEN_FILENAME} ${TOKEN_VALUE}
return 1
fi
done
# Another pause is needed here (no idea why). Otherwise challenge fails quite often (>50%)
sleep 30
}
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.
#logger "Clean challenge DOMAIN=${DOMAIN}, TOKEN_FILENAME=${TOKEN_FILENAME}, TOKEN_VALUE=${TOKEN_VALUE}"
logger "Clean challenge $*"
while (( "$#" )); do
CHALLENGE_NAME=$(echo ${1} | sed "s/.$DESEC_DOMAIN//")
RES=$(curl -s -X DELETE https://desec.io/api/v1/domains/${DESEC_DOMAIN}/rrsets/_acme-challenge.${CHALLENGE_NAME}/TXT/ \
--header "Authorization: Token ${DESEC_TOKEN}")
EX=$?
if [ $EX -ne 0 ]; then
logger -p error "Token value for ${CHALLENGE_NAME} was not deleted, curl exited with $EX and message \'${RES}\'"
return 1
else
logger "Token value for ${CHALLENGE_NAME} was deleted, curl exited with $EX and message \'${RES}\'"
fi
shift 3
done
}
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.
logger "Deploy challenge DOMAIN=${DOMAIN}, KEYFILE=${KEYFILE}, CERTFILE=${CERTFILE}, FULLCHAINFILE=${FULLCHAINFILE}, CHAINFILE=${CHAINFILE}, TIMESTAMP=${TIMESTAMP}"
sudo /usr/bin/systemctl reload apache2.service
}
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).
logger "Unchanged Cert DOMAIN=${DOMAIN}"
}
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.
sudo /usr/bin/systemctl reload apache2.service
}
HANDLER="$1"; shift
if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert|invalid_challenge|request_failure|exit_hook)$ ]]; then
"$HANDLER" "$@"
fi
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment