commit c7e44146abded2860092429e308b784f41df9d8f Author: Alex 'AdUser' Z Date: Fri Apr 8 11:02:37 2022 +1000 + initial diff --git a/config.sample b/config.sample new file mode 100644 index 0000000..d7b261f --- /dev/null +++ b/config.sample @@ -0,0 +1,36 @@ +######################################################## +# This is the main config file for the Dehydrated # +# AlibabaCloud DNS-01 challenge Hook script # +# # +# Default values of this config are in comments # +######################################################## + +# requirements: openssl, curl, jq, dig + +# Alicloud Public API Credentials +API_KEY_ID='' # required +API_KEY_SECRET='' # required + +# Debugging (yes/no) +DEBUG="no" + +# Extra options passed to the curl binary (default: ) +CURL_OPTS="" + +# Configure notifications +MAIL_FROM="" +MAIL_RCPT="ad_user@runbox.com" + +# Configure certificate and key locations for deployment +DEPLOYED_CERTDIR=/etc/ssl/certs +DEPLOYED_KEYDIR=/etc/ssl/private + +# Email Sending Method Options +# + SENDMAIL (default) +# + SMTP +MAIL_METHOD=SENDMAIL + +# SMTP options +#SMTP_DOMAIN=localhost +#SMTP_SERVER= +#SMTP_PORT=25 diff --git a/hook.sh b/hook.sh new file mode 100755 index 0000000..a1625c9 --- /dev/null +++ b/hook.sh @@ -0,0 +1,458 @@ +#!/usr/bin/env bash + +# See these links for docs: +# - https://www.alibabacloud.com/help/en/doc-detail/29774.htm +# - https://docs-aliyun.cn-hangzhou.oss.aliyun-inc.com/pdf/alidns-intl-api-intl-en-2018-06-22.pdf + +rawurlencode() { + local string="${1}" + local strlen=${#string} + local encoded="" + local pos c o + + for (( pos=0 ; pos= 2 )); do + PARAM=${1}; shift + VALUE=${1}; shift + [ "x$PARAM" = "x" ] && break + QUERY[$PARAM]=$(rawurlencode "$VALUE") + done + + # sort query array + readarray -td '' SORTED < <(printf '%s\0' "${!QUERY[@]}" | sort -z) + + # gen signature + local QUERYSTR="" + for P in "${SORTED[@]}"; do + if [ ${#QUERYSTR} -gt 0 ]; then QUERYSTR="$QUERYSTR&"; fi + QUERYSTR="${QUERYSTR}${P}=${QUERY[$P]}" + done + local SIGNSTR=$(printf "GET&%%2F&%s" $(rawurlencode "$QUERYSTR")) + local SIGN=$(echo -n "$SIGNSTR" | openssl dgst -sha1 -hmac "${API_KEY_SECRET}&" -binary | openssl base64) + local URL="$API_BASEURL?${QUERYSTR}&Signature=${SIGN}" + if [ "x$DEBUG" = "xyes" ]; then + echo "QUERY: ${QUERY[@]}" + echo "SORTED: ${SORTED[@]}" + echo "SIGNSTR: $SIGNSTR" + echo "SIGN: $SIGN" + echo "NONCE: $NONCE" + echo "URL: $URL" + fi + # make request + RES=$(curl --silent --show-error $CURL_OPTS "$URL") + echo "$RES" +} + +function deploy_challenge { + local FIRSTDOMAIN="${1}" + local SLD=`sed -E 's/(.*\.)*([^.]+)\..*/\2/' <<< "${FIRSTDOMAIN}"` + local TLD=`sed -E 's/.*\.([^.]+)/\1/' <<< "${FIRSTDOMAIN}"` + + # add challenge records to post data + local count=0 + while (( "$#" >= 3 )); do + # DOMAIN + # The domain name (CN or subject alternative name) being validated. + DOMAIN="${1}"; shift + # 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_FILENAME="${1}"; shift + # 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. + TOKEN_VALUE[$count]="${1}"; shift + + SUB[$count]=`sed -E "s/$SLD.$TLD//" <<< "${DOMAIN}"` + CHALLENGE_HOSTNAME=`sed -E "s/\.$//" <<< "${SUB[$count]}"` + + local RES=$(make_api_request \ + "Action" "AddDomainRecord" \ + "DomainName" "$DOMAIN" \ + "Type" "TXT" \ + "RR" "_acme-challenge${CHALLENGE_HOSTNAME:+.$CHALLENGE_HOSTNAME}" \ + "Value" "${TOKEN_VALUE[$count]}") + echo "$RES" | grep -q '"RecordId":' + if [[ $? != 0 ]]; then + echo "API REQ ERROR: $RES" + return 1 + fi + echo "Deployed challenge for $CHALLENGE_HOSTNAME -- ${TOKEN_VALUE[$count]}" + (( count++ )) + done + local items=$count + + # get nameservers for domain + IFS=$'\n' read -r -d '' -a nameservers < <( dig @1.1.1.1 +short ns $SLD.$TLD && printf '\0' ) + + # wait up to 2 minutes for DNS updates to be provisioned (check at 15 second intervals) + timer=0 + count=0 + while [ $count -lt $items ]; do + # check for DNS propagation + while true; do + for ns in ${nameservers[@]}; do + dig @${ns} txt "_acme-challenge${SUB[$count]:+.${SUB[$count]%.}}.$SLD.$TLD" | grep -qe "${TOKEN_VALUE[$count]}" + if [[ $? == 0 ]]; then + break 2 + fi + done + if [[ "$timer" -ge 120 ]]; then + # time has exceeded 2 minutes + send_error $FIRSTDOMAIN + return 1 + else + echo " + DNS not propagated. Waiting 15s for record creation and replication... Total time elapsed has been $timer seconds." + ((timer+=15)) + sleep 15 + fi + done + ((count++)) + done + return 0 +} + +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. + + local FIRSTDOMAIN="${1}" + local SLD=`sed -E 's/(.*\.)*([^.]+)\..*/\2/' <<< "${FIRSTDOMAIN}"` + local TLD=`sed -E 's/.*\.([^.]+)/\1/' <<< "${FIRSTDOMAIN}"` + + local RES=$(make_api_request \ + "Action" "DescribeDomainRecords" \ + "Domain" "$FIRSTDOMAIN" \ + "SearchMode" "ADVANCED" \ + "Type" "TXT") + echo "$RES" | grep -q '"RecordId":' + if [[ $? != 0 ]]; then + echo "API REQ ERROR: $RES" + return 1 + fi + + declare -A RECORDS + echo "$RES" | jq -r -c '.DomainRecords.Record | .[] | [ .RecordId, .Value ] | join(" ")' | while read RECID VALUE; do + RECORDS[$RECID]=$VALUE + done + + # drop expired records + while (( "$#" >= 3 )); do + # DOMAIN + # The domain name (CN or subject alternative name) being validated. + DOMAIN="${1}"; shift + # 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_FILENAME="${1}"; shift + # 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. + TOKEN_VALUE[$count]="${1}"; shift + for RECID in "${!RECORDS[@]}"; do + [[ "x${RECORDS[$RECID]}" != "x$TOKEN_VALUE" ]] && continue + RES=$(make_api_request \ + "Action" "DeleteDomainRecord" \ + "RecordId" "$RECID" + ) + echo "$RES" | grep -q '"RecordId":' + if [[ $? != 0 ]]; then + echo "API REQ ERROR: $RES" + return 1 + fi + echo "Dropped record if $RECID -- $TOKEN_VALUE" + done # for + done # while +} + +function 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. + + echo "Deploying certificate for ${DOMAIN}..." + # copy new certificate to /etc/pki/tls/certs folder + cp "${CERTFILE}" "${DEPLOYED_CERTDIR}/${DOMAIN}.crt" + echo " + certificate copied" + + # copy new key to /etc/pki/tls/private folder + cp "${KEYFILE}" "${DEPLOYED_KEYDIR}/${DOMAIN}.key" + echo " + key copied" + + # copy new chain file which contains the intermediate certificate(s) + cp "${CHAINFILE}" "${DEPLOYED_CERTDIR}/letsencrypt-intermediate-certificates.pem" + echo " + intermediate certificate chain copied" + + # combine certificate and chain file (used by Nginx) + cat "${CERTFILE}" "${CHAINFILE}" > "${DEPLOYED_CERTDIR}/${DOMAIN}-chain.crt" + echo " + combine certificate and intermediate certificate chain" + + # reload services + echo " + Reloading Services" + "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/reload.sh" "$@" + + # send email notification + send_notification $DOMAIN + return 0 +} + +function 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). +} + +function 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 +} + +function 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...) +} + +function startup_hook() { + # This hook is called before the cron command to do some initial tasks + # (e.g. starting a webserver). + + : +} + +function 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. + + : +} + +# Setup default config values, load configuration file +function load_config() { + # Default values + API_KEY_ID="" + API_KEY_SECRET="" + DEBUG="no" + CURL_OPTS="" + MAIL_FROM="dehydrated@example.com" + MAIL_RCPT="admin@example.com" + DEPLOYED_CERTDIR=/etc/pki/tls/certs + DEPLOYED_KEYDIR=/etc/pki/tls/private + MAIL_METHOD=SENDMAIL + SMTP_DOMAIN=localhost + SMTP_SERVER="" + SMTP_PORT=25 + + # Check if config file exists + if [[ ! -f "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/config" ]]; then + echo "#" >&2 + echo "# !! WARNING !! No main config file found, using default config!" >&2 + echo "#" >&2 + else + . "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/config" + fi +} + +function send_error { + local DOMAIN="${1}" TODAYS_DATE=`date` + + # set message content + MSG_CONTENT=$(cat << EOF +Content-Type:text/html;charset='UTF-8' +Content-Transfer-Encoding:7bit +From:SSL Certificate Renewal Script<$MAIL_FROM> +To:<$MAIL_RCPT> +Subject: New Certificate Deployment Failed - $DOMAIN - $TODAYS_DATE + + +

A new certificate for the domain, ${FIRSTDOMAIN}, has failed.

+

DNS record did not propagate. Unable to verify domain is yours.

+ +EOF +) +IFS=' +' + + if [ "${MAIL_METHOD}" == "SENDMAIL" ]; then + # send notification email + cat << EOF | /usr/sbin/sendmail -t -f $MAIL_FROM +$MSG_CONTENT +EOF + else + # prepare notification email message + a=$(cat << EOF +HELO $SMTP_DOMAIN +MAIL FROM: <$MAIL_FROM> +RCPT TO: <$MAIL_RCPT> +DATA +$MSG_CONTENT +. +QUIT +. +EOF +) +IFS=' +' + + # send notification email + exec 1<>/dev/tcp/$SMTP_SERVER/$SMTP_PORT + declare -a b=($a) + for x in "${b[@]}" + do + echo $x + sleep .1 + done + fi +} + +function send_notification { + local DOMAIN="${1}" TODAYS_DATE=`date` + + # set message content + MSG_CONTENT=$(cat << EOF +Content-Type:text/html;charset='UTF-8' +Content-Transfer-Encoding:7bit +From:SSL Certificate Renewal Script<$MAIL_FROM> +To:<$MAIL_RCPT> +Subject: New Certificate Deployed - $TODAYS_DATE + + +

A new certificate for the domain, ${DOMAIN}, has been deployed.

+

Please confirm certificate is working as expected.

+ +EOF +) +IFS=' +' + + if [ "${MAIL_METHOD}" == "SENDMAIL" ]; then + # send notification email + cat << EOF | /usr/sbin/sendmail -t -f $MAIL_FROM +$MSG_CONTENT +EOF + else + # prepare notification email message + a=$(cat << EOF +HELO $SMTP_DOMAIN +MAIL FROM: <$MAIL_FROM> +RCPT TO: <$MAIL_RCPT> +DATA +$MSG_CONTENT +. +QUIT +. +EOF +) +IFS=' +' + + # send notification email + exec 1<>/dev/tcp/$SMTP_SERVER/$SMTP_PORT + declare -a b=($a) + for x in "${b[@]}" + do + echo $x + sleep .1 + done + fi +} + +# load config values +load_config + +CURL="curl ${CURL_OPTS} -s" +if [[ "${DEBUG}" == "yes" ]]; then + CURL="$CURL -v" +fi + +HANDLER="$1"; shift +if [[ "${HANDLER}" =~ ^(deploy_challenge|clean_challenge|deploy_cert|unchanged_cert|invalid_challenge|request_failure|startup_hook|exit_hook)$ ]]; then + "$HANDLER" "$@" +fi +exit $?