#!/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" RELOAD_SH="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )/reload.sh" if [ -f $RELOAD_SH -a -x $RELOAD_SH ]; then $RELOAD_SH "$@" else echo " * missing script for services reload, ignoring" fi # 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 $?