From ca666aa47469e75e6bedda40ea01ef834108c859 Mon Sep 17 00:00:00 2001 From: Tim Kimber Date: Wed, 18 Nov 2020 21:33:39 +0000 Subject: [PATCH] Wildcard support in ACMEv2 Fixes #347 and #400 --- getssl | 273 +++++++++++++++++++++++++++------------------------------ 1 file changed, 128 insertions(+), 145 deletions(-) diff --git a/getssl b/getssl index 2fc8275..f62174e 100755 --- a/getssl +++ b/getssl @@ -469,7 +469,7 @@ check_challenge_completion() { # checks with the ACME server if our challenge is # if ACME response is that their check gave an invalid response, error exit if [[ "$status" == "invalid" ]] ; then err_detail=$(echo "$response" | grep "detail") - #! FIXME need to check for "DNS problem: SERVFAIL looking up CAA ..." and retry + # TODO need to check for "DNS problem: SERVFAIL looking up CAA ..." and retry error_exit "$domain:Verify error:$err_detail" fi @@ -491,6 +491,84 @@ check_challenge_completion() { # checks with the ACME server if our challenge is fi } +check_challenge_completion_dns() { # perform validation via DNS challenge + token=$1 + uri=$2 + keyauthorization=$3 + d=$4 + primary_ns=$5 + auth_key=$6 + + # Always use lowercase domain name when querying DNS servers + # shellcheck disable=SC2018,SC2019 + lower_d=$(echo "${d##\*.}" | tr A-Z a-z) + + # check for token at public dns server, waiting for a valid response. + for ns in $primary_ns; do + debug "checking dns at $ns" + ntries=0 + check_dns="fail" + while [[ "$check_dns" == "fail" ]]; do + if [[ "$os" == "cygwin" ]]; then + check_result=$(nslookup -type=txt "_acme-challenge.${lower_d}" "${ns}" \ + | grep ^_acme -A2\ + | grep '"'|awk -F'"' '{ print $2}') + elif [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then + debug "$DNS_CHECK_FUNC" TXT "_acme-challenge.${lower_d}" "@${ns}" + check_result=$($DNS_CHECK_FUNC TXT "_acme-challenge.${lower_d}" "@${ns}" \ + | grep -i "^_acme-challenge.${lower_d}" \ + | grep 'IN\WTXT'|awk -F'"' '{ print $2}') + elif [[ "$DNS_CHECK_FUNC" == "host" ]]; then + check_result=$($DNS_CHECK_FUNC -t TXT "_acme-challenge.${lower_d}" "${ns}" \ + | grep 'descriptive text'|awk -F'"' '{ print $2}') + else + check_result=$(nslookup -type=txt "_acme-challenge.${lower_d}" "${ns}" \ + | grep 'text ='|awk -F'"' '{ print $2}') + fi + debug "expecting $auth_key" + debug "${ns} gave ... $check_result" + + if [[ "$check_result" == *"$auth_key"* ]]; then + check_dns="success" + else + if [[ $ntries -lt $DNS_WAIT_COUNT ]]; then + ntries=$(( ntries + 1 )) + + if [[ $DNS_WAIT_RETRY_ADD == "true" && $(( ntries % 10 )) == 0 ]]; then + debug "Retrying adding dns via command: $DNS_ADD_COMMAND $lower_d $auth_key" + eval "$DNS_DEL_COMMAND" "$lower_d" "$auth_key" + if ! eval "$DNS_ADD_COMMAND" "$lower_d" "$auth_key" ; then + error_exit "DNS_ADD_COMMAND failed for domain $d" + fi + + fi + info "checking DNS at ${ns} for ${lower_d}. Attempt $ntries/${DNS_WAIT_COUNT} gave wrong result, "\ + "waiting $DNS_WAIT secs before checking again" + sleep $DNS_WAIT + else + debug "dns check failed - removing existing value" + eval "$DNS_DEL_COMMAND" "$lower_d" "$auth_key" + + error_exit "checking _acme-challenge.${lower_d} gave $check_result not $auth_key" + fi + fi + done + done + + if [[ "$DNS_EXTRA_WAIT" -gt 0 && "$PREVIOUSLY_VALIDATED" != "true" ]]; then + info "sleeping $DNS_EXTRA_WAIT seconds before asking the ACME server to check the dns" + sleep "$DNS_EXTRA_WAIT" + fi + + check_challenge_completion "$uri" "$d" "$keyauthorization" + + debug "remove DNS entry" + # shellcheck disable=SC2018,SC2019 + lower_d=$(echo "${d##\*.}" | tr A-Z a-z) + eval "$DNS_DEL_COMMAND" "$lower_d" "$auth_key" +} +# end of ... perform validation if via DNS challenge + check_config() { # check the config files for all obvious errors debug "checking config" @@ -518,13 +596,13 @@ check_config() { # check the config files for all obvious errors config_errors=true fi - # get all domains + # get all domains into an array if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then - alldomains=${SANS//[, ]/ } + read -r -a alldomains <<< "${SANS//[, ]/ }" else - alldomains=$(echo "$DOMAIN,$SANS" | sed "s/,/ /g") + read -r -a alldomains <<< "$(echo "$DOMAIN,$SANS" | sed "s/,/ /g")" fi - if [[ -z "$alldomains" ]]; then + if [[ -z "${alldomains[*]}" ]]; then info "${DOMAIN}: no domains specified" config_errors=true fi @@ -542,11 +620,14 @@ check_config() { # check the config files for all obvious errors dn=0 tmplist=$(mktemp 2>/dev/null || mktemp -t getssl) - for d in $alldomains; do # loop over domains (dn is domain number) + for d in "${alldomains[@]}"; do # loop over domains (dn is domain number) debug "checking domain $d" if [[ "$(grep "^${d}$" "$tmplist")" = "$d" ]]; then info "${DOMAIN}: $d appears to be duplicated in domain, SAN list" config_errors=true + elif [[ "$d" != "${d##\*.}" ]] && [[ "$VALIDATE_VIA_DNS" != "true" ]]; then + info "${DOMAIN}: cannot use http-01 validation for wildcard domains" + config_errors=true else echo "$d" >> "$tmplist" fi @@ -682,7 +763,7 @@ clean_up() { # Perform pre-exit housekeeping # shellcheck source=/dev/null . "$dnsfile" debug "attempting to clean up DNS entry for $d" - eval "$DNS_DEL_COMMAND" "$d" "$auth_key" + eval "$DNS_DEL_COMMAND" "${d##\*.}" "$auth_key" done shopt -u nullglob fi @@ -809,14 +890,14 @@ create_csr() { # create a csr using a given key (if it doesn't already exist) debug "domain csr exists at - $csr_file" # check all domains in config are in csr if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then - alldomains=$(echo "$SANS" | sed -e 's/ //g; s/,$//; y/,/\n/' | sort -u) + read -r -a alldomains <<< "$(echo "$SANS" | sed -e 's/ //g; s/,$//; y/,/\n/' | sort -u)" else - alldomains=$(echo "$DOMAIN,$SANS" | sed -e 's/,/ /g; s/ $//; y/ /\n/' | sort -u) + read -r -a alldomains <<< "$(echo "$DOMAIN,$SANS" | sed -e 's/,/ /g; s/ $//; y/ /\n/' | sort -u)" fi domains_in_csr=$(openssl req -text -noout -in "$csr_file" \ | sed -n -e 's/^ *Subject: .* CN=\([A-Za-z0-9.-]*\).*$/\1/p; /^ *DNS:.../ { s/ *DNS://g; y/,/\n/; p; }' \ | sort -u) - for d in $alldomains; do + for d in "${alldomains[@]}"; do if [[ "$(echo "${domains_in_csr}"| grep "^${d}$")" != "${d}" ]]; then info "existing csr at $csr_file does not contain ${d} - re-create-csr"\ ".... $(echo "${domains_in_csr}"| grep "^${d}$")" @@ -824,7 +905,7 @@ create_csr() { # create a csr using a given key (if it doesn't already exist) fi done # check all domains in csr are in config - if [[ "$alldomains" != "$domains_in_csr" ]]; then + if [[ "${alldomains[*]}" != "$domains_in_csr" ]]; then info "existing csr at $csr_file does not have the same domains as the config - re-create-csr" _RECREATE_CSR=1 fi @@ -877,7 +958,7 @@ create_key() { # create a domain key (if it doesn't already exist) create_order() { dstring="[" - for d in $alldomains; do + for d in "${alldomains[@]}"; do dstring="${dstring}{\"type\":\"dns\",\"value\":\"$d\"}," done dstring="${dstring::${#dstring}-1}]" @@ -893,9 +974,9 @@ create_order() { if [[ $API -eq 1 ]]; then dn=0 - for d in $alldomains; do + for d in "${alldomains[@]}"; do # get authorizations link - AuthLink[$dn]=$(json_get "$response" "identifiers" "value" "$d" "authorizations" "x") + AuthLink[$dn]=$(json_get "$response" "identifiers" "value" "${d##\*.}" "authorizations" "x") debug "authorizations link for $d - ${AuthLink[$dn]}" ((dn++)) done @@ -909,12 +990,14 @@ create_order() { send_signed_request "$l" "" # Get domain from response authdomain=$(json_get "$response" "identifier" "value") - # find array position (This is O(n2) but that doubt we'll see performance issues) + wildcard=$(json_get "$response" "wildcard") + debug wildcard="$wildcard" + # find array position (This is O(n2) but doubt that we'll see performance issues) dn=0 - for d in $alldomains; do + for d in "${alldomains[@]}"; do # Convert domain to lowercase as response from server will be in lowercase - d=$(echo "$d" | tr "[:upper:]" "[:lower:]") - if [ "$d" == "$authdomain" ]; then + lower_d=$(echo "$d" | tr "[:upper:]" "[:lower:]") + if [[ ( "$lower_d" == "$authdomain" && -z "$wildcard" ) || ( "$lower_d" == "*.${authdomain}" && -n "$wildcard" ) ]]; then debug "Saving authorization response for $authdomain for domain alldomains[$dn]" debug "Response = ${response//[$'\t\r\n']}" AuthLinkResponse[$dn]=$response @@ -1004,7 +1087,7 @@ find_dns_utils() { fulfill_challenges() { dn=0 -for d in $alldomains; do +for d in "${alldomains[@]}"; do # $d is domain in current loop, which is number $dn for ACL info "Verifying $d" if [[ "$USE_SINGLE_ACL" == "true" ]]; then @@ -1015,7 +1098,7 @@ for d in $alldomains; do # request a challenge token from ACME server if [[ $API -eq 1 ]]; then - request="{\"resource\":\"new-authz\",\"identifier\":{\"type\":\"dns\",\"value\":\"$d\"}}" + request="{\"resource\":\"new-authz\",\"identifier\":{\"type\":\"dns\",\"value\":\"${d##\*.}\"}}" send_signed_request "$URL_new_authz" "$request" debug "completed send_signed_request" @@ -1068,7 +1151,7 @@ for d in $alldomains; do debug auth_key "$auth_key" # shellcheck disable=SC2018,SC2019 - lower_d=$(echo "$d" | tr A-Z a-z) + lower_d=$(echo "${d##\*.}" | tr A-Z a-z) debug "adding dns via command: $DNS_ADD_COMMAND $lower_d $auth_key" if ! eval "$DNS_ADD_COMMAND" "$lower_d" "$auth_key" ; then error_exit "DNS_ADD_COMMAND failed for domain $d" @@ -1082,21 +1165,7 @@ for d in $alldomains; do fi debug primary_ns "$primary_ns" - # make a directory to hold pending dns-challenges - if [[ ! -d "$TEMP_DIR/dns_verify" ]]; then - mkdir "$TEMP_DIR/dns_verify" - fi - - # generate a file with the current variables for the dns-challenge - cat > "$TEMP_DIR/dns_verify/$d" <<- _EOF_ - token="${token}" - uri="${uri}" - keyauthorization="${keyauthorization}" - d="${d}" - primary_ns="${primary_ns}" - auth_key="${auth_key}" - _EOF_ - + check_challenge_completion_dns "${token}" "${uri}" "${keyauthorization}" "${d}" "${primary_ns}" "${auth_key}" else # set up the correct http token for verification if [[ $API -eq 1 ]]; then # get the token from the http component @@ -1179,101 +1248,7 @@ for d in $alldomains; do ((dn++)) fi done # end of ... loop through domains for cert ( from SANS list) - -# perform validation if via DNS challenge -if [[ $VALIDATE_VIA_DNS == "true" ]]; then - # loop through dns-variable files to check if dns has been changed - for dnsfile in "$TEMP_DIR"/dns_verify/*; do - if [[ -e "$dnsfile" ]]; then - debug "loading DNSfile: $dnsfile" - # shellcheck source=/dev/null - . "$dnsfile" - - # Always use lowercase domain name when querying DNS servers - # shellcheck disable=SC2018,SC2019 - lower_d=$(echo "$d" | tr A-Z a-z) - - # check for token at public dns server, waiting for a valid response. - for ns in $primary_ns; do - debug "checking dns at $ns" - ntries=0 - check_dns="fail" - while [[ "$check_dns" == "fail" ]]; do - if [[ "$os" == "cygwin" ]]; then - check_result=$(nslookup -type=txt "_acme-challenge.${lower_d}" "${ns}" \ - | grep ^_acme -A2\ - | grep '"'|awk -F'"' '{ print $2}') - elif [[ "$DNS_CHECK_FUNC" == "drill" ]] || [[ "$DNS_CHECK_FUNC" == "dig" ]]; then - debug "$DNS_CHECK_FUNC" TXT "_acme-challenge.${lower_d}" "@${ns}" - check_result=$($DNS_CHECK_FUNC TXT "_acme-challenge.${lower_d}" "@${ns}" \ - | grep -i "^_acme-challenge.${lower_d}" \ - | grep 'IN\WTXT'|awk -F'"' '{ print $2}') - elif [[ "$DNS_CHECK_FUNC" == "host" ]]; then - check_result=$($DNS_CHECK_FUNC -t TXT "_acme-challenge.${lower_d}" "${ns}" \ - | grep 'descriptive text'|awk -F'"' '{ print $2}') - else - check_result=$(nslookup -type=txt "_acme-challenge.${lower_d}" "${ns}" \ - | grep 'text ='|awk -F'"' '{ print $2}') - fi - debug "expecting $auth_key" - debug "${ns} gave ... $check_result" - - if [[ "$check_result" == *"$auth_key"* ]]; then - check_dns="success" - else - if [[ $ntries -lt $DNS_WAIT_COUNT ]]; then - ntries=$(( ntries + 1 )) - - if [[ $DNS_WAIT_RETRY_ADD == "true" && $(( ntries % 10 )) == 0 ]]; then - debug "Retrying adding dns via command: $DNS_ADD_COMMAND $lower_d $auth_key" - eval "$DNS_DEL_COMMAND" "$lower_d" "$auth_key" - if ! eval "$DNS_ADD_COMMAND" "$lower_d" "$auth_key" ; then - error_exit "DNS_ADD_COMMAND failed for domain $d" - fi - - fi - info "checking DNS at ${ns} for ${lower_d}. Attempt $ntries/${DNS_WAIT_COUNT} gave wrong result, "\ - "waiting $DNS_WAIT secs before checking again" - sleep $DNS_WAIT - else - debug "dns check failed - removing existing value" - eval "$DNS_DEL_COMMAND" "$lower_d" "$auth_key" - # remove $dnsfile after each loop. - rm -f "$dnsfile" - - error_exit "checking _acme-challenge.${lower_d} gave $check_result not $auth_key" - fi - fi - done - done - fi - done - - if [[ "$DNS_EXTRA_WAIT" -gt 0 && "$PREVIOUSLY_VALIDATED" != "true" ]]; then - info "sleeping $DNS_EXTRA_WAIT seconds before asking the ACME server to check the dns" - sleep "$DNS_EXTRA_WAIT" - fi - - # loop through dns-variable files to let the ACME server check the challenges - for dnsfile in "$TEMP_DIR"/dns_verify/*; do - if [[ -e "$dnsfile" ]]; then - debug "loading DNSfile: $dnsfile" - # shellcheck source=/dev/null - . "$dnsfile" - - check_challenge_completion "$uri" "$d" "$keyauthorization" - - debug "remove DNS entry" - # shellcheck disable=SC2018,SC2019 - lower_d=$(echo "$d" | tr A-Z a-z) - eval "$DNS_DEL_COMMAND" "$lower_d" "$auth_key" - # remove $dnsfile after each loop. - rm -f "$dnsfile" - fi - done -fi -# end of ... perform validation if via DNS challenge -#end of varify each domain. +#end of verify each domain. } get_auth_dns() { # get the authoritative dns server for a domain (sets primary_ns ) @@ -2557,7 +2532,7 @@ if [[ ${_CHECK_ALL} -eq 1 ]]; then fi # check if $dir is a directory with a getssl.cfg in it if [[ -f "$dir/getssl.cfg" ]]; then - cmd="$cmd -w $WORKING_DIR $(basename "$dir")" + cmd="$cmd -w $WORKING_DIR \"$(basename "$dir")\"" debug "CMD: $cmd" eval "$cmd" fi @@ -2590,15 +2565,20 @@ if [[ ${_CREATE_CONFIG} -eq 1 ]]; then info "creating domain config file in $DOMAIN_DIR/getssl.cfg" # if domain has an existing cert, copy from domain and use to create defaults. EX_CERT=$(echo \ - | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:443" 2>/dev/null \ + | openssl s_client -servername "${DOMAIN##\*.}" -connect "${DOMAIN##\*.}:443" 2>/dev/null \ | openssl x509 2>/dev/null) - EX_SANS="www.${DOMAIN}" + EX_SANS="www.${DOMAIN##\*.}" if [[ -n "${EX_CERT}" ]]; then + # Putting this inside the EX_SANS line below doesn't work on Centos7 + escaped_d=${DOMAIN/\*/\\\*} EX_SANS=$(echo "$EX_CERT" \ | openssl x509 -noout -text 2>/dev/null| grep "Subject Alternative Name" -A2 \ - | grep -Eo "DNS:[a-zA-Z 0-9.-]*" | sed "s@DNS:$DOMAIN@@g" | grep -v '^$' | cut -c 5-) + | grep -Eo "DNS:[a-zA-Z 0-9.-\*]*" | sed "s@DNS:${escaped_d}@@g" | grep -v '^$' | cut -c 5-) EX_SANS=${EX_SANS//$'\n'/','} fi + if [[ -n "${EX_SANS}" ]]; then + info "Adding SANS=$EX_SANS from certificate installed on ${DOMAIN##\*.} to new configuration file" + fi write_domain_template "$DOMAIN_DIR/getssl.cfg" fi TEMP_DIR="$DOMAIN_DIR/tmp" @@ -2674,11 +2654,12 @@ fi # if check_remote is true then connect and obtain the current certificate (if not forcing renewal) if [[ "${CHECK_REMOTE}" == "true" ]] && [[ $_FORCE_RENEW -eq 0 ]]; then - debug "getting certificate for $DOMAIN from remote server" + real_d=${DOMAIN##\*.} + debug "getting certificate for $DOMAIN from remote server ($real_d)" if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then # shellcheck disable=SC2086 # check if openssl supports RSA-PSS - if [[ $(echo | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} -sigalgs RSA-PSS+SHA256 2>/dev/null) ]]; then + if [[ $(echo | openssl s_client -servername "${real_d}" -connect "${real_d}:${REMOTE_PORT}" ${REMOTE_EXTRA} -sigalgs RSA-PSS+SHA256 2>/dev/null) ]]; then CIPHER="-sigalgs RSA+SHA256:RSA+SHA384:RSA+SHA512:RSA-PSS+SHA256:RSA-PSS+SHA512" else CIPHER="-sigalgs RSA+SHA256:RSA+SHA384:RSA+SHA512" @@ -2688,7 +2669,7 @@ if [[ "${CHECK_REMOTE}" == "true" ]] && [[ $_FORCE_RENEW -eq 0 ]]; then fi # shellcheck disable=SC2086 EX_CERT=$(echo \ - | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} ${CIPHER} 2>/dev/null \ + | openssl s_client -servername "${real_d}" -connect "${real_d}:${REMOTE_PORT}" ${REMOTE_EXTRA} ${CIPHER} 2>/dev/null \ | openssl x509 2>/dev/null) if [[ -n "$EX_CERT" ]]; then # if obtained a cert if [[ -s "$CERT_FILE" ]]; then # if local exists @@ -2877,9 +2858,9 @@ info "Verify each domain" # loop through domains for cert ( from SANS list) if [[ "$IGNORE_DIRECTORY_DOMAIN" == "true" ]]; then - alldomains=${SANS//[, ]/ } + read -r -a alldomains <<< "${SANS//[, ]/ }" else - alldomains=$(echo "$DOMAIN,$SANS" | sed "s/,/ /g") + read -r -a alldomains <<< "$(echo "$DOMAIN,$SANS" | sed "s/,/ /g")" fi if [[ $API -eq 2 ]]; then @@ -2941,11 +2922,12 @@ fi # Check if the certificate is installed correctly if [[ ${CHECK_REMOTE} == "true" ]]; then + real_d=${DOMAIN##\*.} sleep "$CHECK_REMOTE_WAIT" if [[ "$DUAL_RSA_ECDSA" == "true" ]]; then # shellcheck disable=SC2086 # check if openssl supports RSA-PSS - if [[ $(echo | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} -sigalgs RSA-PSS+SHA256 2>/dev/null) ]]; then + if [[ $(echo | openssl s_client -servername "${real_d}" -connect "${real_d}:${REMOTE_PORT}" ${REMOTE_EXTRA} -sigalgs RSA-PSS+SHA256 2>/dev/null) ]]; then PARAMS=("-sigalgs RSA-PSS+SHA256:RSA-PSS+SHA512:RSA+SHA256:RSA+SHA384:RSA+SHA512" "-sigalgs ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512") else PARAMS=("-sigalgs RSA+SHA256:RSA+SHA384:RSA+SHA512" "-sigalgs ECDSA+SHA256:ECDSA+SHA384:ECDSA+SHA512") @@ -2962,20 +2944,21 @@ if [[ ${CHECK_REMOTE} == "true" ]]; then for ((i=0; i<${#PARAMS[@]};++i)); do debug "Checking ${CERTS[i]}" # shellcheck disable=SC2086 + debug openssl s_client -servername "${real_d}" -connect "${real_d}:${REMOTE_PORT}" ${REMOTE_EXTRA} ${PARAMS[i]} CERT_REMOTE=$(echo \ - | openssl s_client -servername "${DOMAIN}" -connect "${DOMAIN}:${REMOTE_PORT}" ${REMOTE_EXTRA} ${PARAMS[i]} 2>/dev/null \ + | openssl s_client -servername "${real_d}" -connect "${real_d}:${REMOTE_PORT}" ${REMOTE_EXTRA} ${PARAMS[i]} 2>/dev/null \ | openssl x509 -noout -fingerprint 2>/dev/null) CERT_LOCAL=$(openssl x509 -noout -fingerprint < "${CERTS[i]}" 2>/dev/null) debug CERT_LOCAL="${CERT_LOCAL}" debug CERT_REMOTE="${CERT_REMOTE}" if [[ "$CERT_LOCAL" == "$CERT_REMOTE" ]]; then - info "${DOMAIN} - ${TYPES[i]} certificate installed OK on server" + info "${real_d} - ${TYPES[i]} certificate installed OK on server" elif [[ "$CERT_REMOTE" == "" ]]; then info "${CERTS[i]} not returned by server" - error_exit "${DOMAIN} - ${TYPES[i]} certificate obtained but not installed on server" + error_exit "${real_d} - ${TYPES[i]} certificate obtained but not installed on server" else info "${CERTS[i]} didn't match server" - error_exit "${DOMAIN} - ${TYPES[i]} certificate obtained but certificate on server is different from the new certificate" + error_exit "${real_d} - ${TYPES[i]} certificate obtained but certificate on server is different from the new certificate" fi done fi