From 13d5387c8e26d9f92139087c56c256440451d46f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stephan=20D=C3=BCsterhaupt?= Date: Sun, 1 Nov 2026 13:04:38 +0100 Subject: [PATCH] fix(acme): wait for DNS propagation and clean up challenge tokens individually --- CHANGELOG.md | 8 +- contrib/acme/letsencrypt_master_local.sh | 127 ++++++++++++++++++++++- 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc1cbcd..a00c792 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,13 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 1.1.0 - 2026-11-01 + ### Added -- Added an early dns-01 prerequisite check in certificate creation for wildcard certificates. +- Added an early DNS-01 prerequisite check in certificate creation for wildcard certificates. ### Fixed -- Fixed silent failures for wildcard dns-01 runs by aborting certificate creation and logging a clear error when the configured TSIG/RNDC key file is missing. +- Fixed silent failures for wildcard DNS-01 runs by aborting the current certificate creation step and logging a clear error when the configured TSIG/RNDC key file is missing or unreadable. +- Fixed premature DNS-01 validation requests by checking all pending challenge TXT values on dynamically discovered authoritative nameservers before requesting ACME validation. The check performs up to ten rounds, with five-second delays between unsuccessful rounds, and aborts issuance with cleanup if the values remain unavailable. +- Fixed DNS-01 cleanup to remove only the TXT value belonging to each challenge, preserving other challenge tokens at the same record name when validating base domains and wildcards together. ## 1.0.0 - 2026-06-28 diff --git a/contrib/acme/letsencrypt_master_local.sh b/contrib/acme/letsencrypt_master_local.sh index c6113e2..c66a002 100644 --- a/contrib/acme/letsencrypt_master_local.sh +++ b/contrib/acme/letsencrypt_master_local.sh @@ -867,7 +867,8 @@ domain_dns_challenge() { MyDNSChallengeContent="${MyDNSChallengeContent}update add _acme-challenge.${DOMAIN}. 300 IN TXT \"${DNS_CHALLENGE}\"\n" ;; delete) - MyDNSChallengeContent="${MyDNSChallengeContent}update delete _acme-challenge.${DOMAIN}. TXT\n" + # Remove only the TXT value belonging to this challenge. + MyDNSChallengeContent="${MyDNSChallengeContent}update delete _acme-challenge.${DOMAIN}. TXT \"${DNS_CHALLENGE}\"\n" ;; *) err_exit "Unsupported dns-01 action '$1'" @@ -980,6 +981,103 @@ request_domain_verification() { fi } +# Wait for all pending DNS-01 challenge values on all configured nameservers. +# Requires DOMAIN_DATA groups: domain, challenge URL, token, authorization URL. +# Returns 0 when ready, or 1 after ten unsuccessful check rounds. +wait_dns_response() ( + [ "$CHALLENGE_TYPE" = "dns-01" ] || return 0 + + if [ -z "$DOMAIN_DATA" ]; then + log "No pending DNS challenges; skipping DNS propagation check" + return 0 + fi + + command -v dig >/dev/null 2>&1 || { + errmsg "DNS propagation check requires dig" + return 1 + } + + # Public authoritative nameservers for this deployment. + # Discover authoritative nameservers for the configured DNS zone. + [ -n "$DNS_ZONE" ] || { + errmsg "DNS zone is empty; cannot discover authoritative nameservers" + return 1 + } + + DNS_CHECK_SERVERS=$( + dig "$DNS_ZONE" NS +short +time=2 +tries=1 2>/dev/null | + awk '/^[[:alnum:]_.-]+\.$/ { print }' + ) + + [ -n "$DNS_CHECK_SERVERS" ] || { + errmsg "Could not discover authoritative nameservers for $DNS_ZONE" + return 1 + } + + log "Checking DNS propagation on authoritative nameservers: $DNS_CHECK_SERVERS" + + # Disable pathname expansion when splitting DOMAIN_DATA and server lists. + set -f + + attempt=1 + while [ "$attempt" -le 10 ]; do + log "Checking DNS propagation (attempt $attempt/10)" + ready=yes + + # shellcheck disable=SC2086 # Intentional splitting into four-field challenge groups. + set -- $DOMAIN_DATA + while [ "$#" -gt 0 ]; do + [ "$#" -ge 4 ] || { + errmsg "Invalid DOMAIN_DATA structure" + return 1 + } + + domain=$1 + token=$3 + shift 4 + + expected=$( + printf '%s' "$token.$ACCOUNT_THUMB" | + openssl dgst -sha256 -binary | + base64url + ) + + [ -n "$expected" ] || { + errmsg "Could not calculate DNS challenge value for $domain" + return 1 + } + + for ns in $DNS_CHECK_SERVERS; do + answer=$( + dig @"$ns" "_acme-challenge.$domain." TXT \ + +short +norecurse +time=1 +tries=1 2>/dev/null + ) + query_status=$? + + if [ "$query_status" -ne 0 ] || + ! printf '%s\n' "$answer" | + grep -F -x -q "\"$expected\""; then + ready=no + log "DNS challenge value not yet available for $domain on $ns" + fi + done + done + + if [ "$ready" = yes ]; then + log "All pending DNS challenge values are available" + return 0 + fi + + [ "$attempt" -ge 10 ] && break + + sleep 5 + attempt=$((attempt + 1)) + done + + errmsg "DNS propagation check failed after 10 attempts; skipping ACME validation" + return 1 +) + request_verification() { set -- $DOMAIN_DATA @@ -1594,6 +1692,33 @@ fi request_challenge push_response + +if ! wait_dns_response; then + # Remove published tokens using the existing cleanup function. + ( + set -f + + # shellcheck disable=SC2086 # Intentional splitting into four-field challenge groups. + set -- $DOMAIN_DATA + + while [ "$#" -ge 4 ]; do + DOMAIN=$1 + + # shellcheck disable=SC2034 # Consumed by domain_dns_challenge via remove_domain_response. + DOMAIN_TOKEN=$3 + + shift 4 + + # Isolate cleanup failures so other tokens can still be removed. + (remove_domain_response) || true + done + + domain_commit + ) || true + + err_exit "DNS challenge values are not ready; certificate issuance aborted" 1 +fi + request_verification check_verification if [ "$ACTION" = "revoke" ] ;then