From ad99628e50e4614c33082114856c92a417c76555 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 20:04:32 +0800 Subject: [PATCH 01/64] fix https://github.com/acmesh-official/acme.sh/issues/6917 --- acme.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/acme.sh b/acme.sh index 5529fe88..1c68845f 100755 --- a/acme.sh +++ b/acme.sh @@ -5751,6 +5751,17 @@ $_authorizations_map" fi fi + # Warn when the scheduled renewal falls after the cert has already expired, + # e.g. a 1-day cert from an internal CA combined with the default 60-day + # schedule, which computes from the creation date and never looks at + # notAfter. https://github.com/acmesh-official/acme.sh/issues/6917 + _renew_chk_enddate="$(_enddate "$CERT_PATH")" + _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")" + if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then + _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")" + _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")" + fi + _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" _savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime" From 92bd80c07daf03b4ed362184f1b4514826ffba0f Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 20:07:42 +0800 Subject: [PATCH 02/64] fix https://github.com/acmesh-official/acme.sh/issues/6914 --- dnsapi/dns_dnsexit.sh | 115 +++++++++--------------------------------- 1 file changed, 25 insertions(+), 90 deletions(-) diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh index ec3b07a4..6b10891c 100644 --- a/dnsapi/dns_dnsexit.sh +++ b/dnsapi/dns_dnsexit.sh @@ -5,14 +5,11 @@ Site: DNSExit.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsexit Options: DNSEXIT_API_KEY API Key - DNSEXIT_AUTH_USER Username - DNSEXIT_AUTH_PASS Password Issues: github.com/acmesh-official/acme.sh/issues/4719 Author: Samuel Jimenez ' DNSEXIT_API_URL="https://api.dnsexit.com/dns/" -DNSEXIT_HOSTS_URL="https://update.dnsexit.com/ipupdate/hosts.jsp" ######## Public functions ##################### #Usage: dns_dnsexit_add _acme-challenge.*.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" @@ -28,20 +25,7 @@ dns_dnsexit_add() { return 1 fi - _debug 'First detect the root zone' - if ! _get_root "$fulldomain"; then - return 1 - fi - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"add\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\",\"ttl\":0,\"overwrite\":false}}"; then - _err "$response" - return 1 - fi - - _debug2 _response "$response" - return 0 + _dnsexit_zone_op add ',"ttl":0,"overwrite":false' } #Usage: fulldomain txtvalue @@ -58,54 +42,43 @@ dns_dnsexit_rm() { return 1 fi - _debug 'First detect the root zone' - if ! _get_root "$fulldomain"; then - _err "$response" - return 1 - fi - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"delete\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"}}"; then - _err "$response" - return 1 - fi - - _debug2 _response "$response" - return 0 + _dnsexit_zone_op delete '' } #################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -_get_root() { - domain=$1 +# The legacy zone-detection endpoint (update.dnsexit.com/ipupdate/hosts.jsp) +# was shut down by DNSExit and now returns 503, and the JSON API offers no +# zone-list call. So find the root zone by attempting the actual operation at +# each domain level: the API answers "code":0 only when the domain matches a +# zone of the account. https://github.com/acmesh-official/acme.sh/issues/6914 +#Usage: _dnsexit_zone_op +_dnsexit_zone_op() { + _op="$1" + _extra="$2" i=1 while true; do - _domain=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$_domain" + _domain=$(printf "%s" "$fulldomain" | cut -d . -f "$i"-100) + _debug _domain "$_domain" if [ -z "$_domain" ]; then + _err "Could not find the root zone of $fulldomain in your DNSExit account" return 1 fi - _debug login "$DNSEXIT_AUTH_USER" - _debug password "$DNSEXIT_AUTH_PASS" - _debug domain "$_domain" + _sub_domain="$(printf "%s" "$fulldomain" | sed "s/\\.$_domain\$//")" + if [ "$_sub_domain" = "$fulldomain" ]; then + _sub_domain="" + fi + _debug _sub_domain "$_sub_domain" - _dnsexit_http "login=$DNSEXIT_AUTH_USER&password=$DNSEXIT_AUTH_PASS&domain=$_domain" - - if _contains "$response" "0=$_domain"; then - _sub_domain="$(echo "$fulldomain" | sed "s/\\.$_domain\$//")" - return 0 - else - _debug "Go to next level of $_domain" + if _dnsexit_rest "{\"domain\":\"$_domain\",\"$_op\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"$_extra}}"; then + if _contains "$response" "\"code\":0" || _contains "$response" "\"code\": 0"; then + _debug2 _response "$response" + return 0 + fi + _debug "Zone $_domain was not accepted, trying the next level" "$response" fi i=$(_math "$i" + 1) done - - return 1 } _dnsexit_rest() { @@ -136,27 +109,7 @@ _dnsexit_rest() { return 0 } -_dnsexit_http() { - m=GET - param="$1" - _debug param "$param" - _debug get "$DNSEXIT_HOSTS_URL?$param" - - response="$(_get "$DNSEXIT_HOSTS_URL?$param")" - - _debug response "$response" - - if [ "$?" != "0" ]; then - _err "Error $param" - return 1 - fi - - _debug2 response "$response" - return 0 -} - get_account_info() { - DNSEXIT_API_KEY="${DNSEXIT_API_KEY:-$(_readaccountconf_mutable DNSEXIT_API_KEY)}" if test -z "$DNSEXIT_API_KEY"; then DNSEXIT_API_KEY='' @@ -166,23 +119,5 @@ get_account_info() { _saveaccountconf_mutable DNSEXIT_API_KEY "$DNSEXIT_API_KEY" - DNSEXIT_AUTH_USER="${DNSEXIT_AUTH_USER:-$(_readaccountconf_mutable DNSEXIT_AUTH_USER)}" - if test -z "$DNSEXIT_AUTH_USER"; then - DNSEXIT_AUTH_USER="" - _err 'DNSEXIT_AUTH_USER was not exported' - return 1 - fi - - _saveaccountconf_mutable DNSEXIT_AUTH_USER "$DNSEXIT_AUTH_USER" - - DNSEXIT_AUTH_PASS="${DNSEXIT_AUTH_PASS:-$(_readaccountconf_mutable DNSEXIT_AUTH_PASS)}" - if test -z "$DNSEXIT_AUTH_PASS"; then - DNSEXIT_AUTH_PASS="" - _err 'DNSEXIT_AUTH_PASS was not exported' - return 1 - fi - - _saveaccountconf_mutable DNSEXIT_AUTH_PASS "$DNSEXIT_AUTH_PASS" - return 0 } From 7e7c0ee984bd2548453f436382c65de3345ecfe0 Mon Sep 17 00:00:00 2001 From: magyarsz <699745+magyarsz@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:42:52 +0200 Subject: [PATCH 03/64] Merge pull request #6720 from magyarsz/dev Fix a logical error in the `renew` function --- acme.sh | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/acme.sh b/acme.sh index 1c68845f..b65d41ef 100755 --- a/acme.sh +++ b/acme.sh @@ -5936,11 +5936,8 @@ renew() { fi issue "$Le_Webroot" "$Le_Domain" "$Le_Alt" "$Le_Keylength" "$Le_RealCertPath" "$Le_RealKeyPath" "$Le_RealCACertPath" "$Le_ReloadCmd" "$Le_RealFullChainPath" "$Le_PreHook" "$Le_PostHook" "$Le_RenewHook" "$Le_LocalAddress" "$Le_ChallengeAlias" "$Le_Preferred_Chain" "$Le_Valid_From" "$Le_Valid_To" "$Le_Certificate_Profile" "$Le_ExtKeyUse" res="$?" - if [ "$res" != "0" ]; then - return "$res" - fi - if [ "$Le_DeployHook" ]; then + if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then _deploy "$Le_Domain" "$Le_DeployHook" res="$?" fi From 5038d12d6278b1fc888f80373f68999bece3b688 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 21:55:23 +0800 Subject: [PATCH 04/64] forbid using --days together with --valid-to https://github.com/acmesh-official/acme.sh/pull/6572 --- acme.sh | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/acme.sh b/acme.sh index b65d41ef..affe7b78 100755 --- a/acme.sh +++ b/acme.sh @@ -5752,14 +5752,18 @@ $_authorizations_map" fi # Warn when the scheduled renewal falls after the cert has already expired, - # e.g. a 1-day cert from an internal CA combined with the default 60-day + # e.g. a 1-day cert from an internal CA combined with the default 30-day # schedule, which computes from the creation date and never looks at - # notAfter. https://github.com/acmesh-official/acme.sh/issues/6917 - _renew_chk_enddate="$(_enddate "$CERT_PATH")" - _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")" - if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then - _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")" - _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")" + # notAfter. Skip the warning for a fixed-date --valid-to: there + # Le_NextRenewTime equals the expiry by design and the non-renewable state + # was already reported above. https://github.com/acmesh-official/acme.sh/issues/6917 + if [ -z "$_valid_to" ] || _startswith "$_valid_to" "+"; then + _renew_chk_enddate="$(_enddate "$CERT_PATH")" + _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")" + if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then + _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")" + _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")" + fi fi _savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr" @@ -8568,6 +8572,15 @@ _process() { _debug2 LE_WORKING_DIR "$LE_WORKING_DIR" + # --days and --valid-to are mutually exclusive by design: --valid-to pins + # the cert lifetime and the renewal time follows the expiry, so a + # creation-based --days schedule can not apply. + if [ "$_days" ] && [ "$_valid_to" ]; then + _err "--days can not be used together with --valid-to." + _err "With --valid-to, the renewal time is derived from the expiry time automatically." + return 1 + fi + if [ "$DEBUG" ]; then version if [ "$_server" ]; then From 92a1b4710838866a4a409be593f4fb7df9ffdd21 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 22:26:50 +0800 Subject: [PATCH 05/64] forbid spaces in the --home/--config-home path https://github.com/acmesh-official/acme.sh/issues/2163 --- acme.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/acme.sh b/acme.sh index affe7b78..23adba93 100755 --- a/acme.sh +++ b/acme.sh @@ -2842,6 +2842,17 @@ __initHome() { _debug "Using config home: $LE_CONFIG_HOME" export LE_CONFIG_HOME + # Paths with whitespace break the unquoted $_CURL/$_WGET command expansion, + # so fail early with a clear error instead of a cryptic curl/wget failure. + # https://github.com/acmesh-official/acme.sh/issues/2163 + case "$LE_WORKING_DIR$LE_CONFIG_HOME" in + *" "*) + _err "The --home or --config-home path can not contain spaces: '$LE_WORKING_DIR'" + _err "Please install $PROJECT_NAME to a path without spaces." + exit 1 + ;; + esac + _DEFAULT_ACCOUNT_CONF_PATH="$LE_CONFIG_HOME/account.conf" if [ -z "$ACCOUNT_CONF_PATH" ]; then From 61400500e2ddcf400682aa9dacf27aa891aa9411 Mon Sep 17 00:00:00 2001 From: Trekky12 Date: Fri, 3 Jul 2026 16:31:31 +0200 Subject: [PATCH 06/64] Suppress 'signal process started' message when nginx config is restored (related to issue #4995) (#6747) --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 23adba93..3cb4e248 100755 --- a/acme.sh +++ b/acme.sh @@ -3524,7 +3524,7 @@ _restoreNginx() { done _info "Reloading nginx" - if ! nginx -s reload >/dev/null; then + if ! nginx -s reload >/dev/null 2>&1; then _err "An error occurred while reloading nginx, please open an issue on $PROJECT." return 1 fi From f4d2db64efb54175d57f0aaa9f4a97c9c1e2c2ab Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 22:49:23 +0800 Subject: [PATCH 07/64] dns_gd: skip readback check when GoDaddy API returns UNKNOWN_DOMAIN https://github.com/acmesh-official/acme.sh/issues/6517 --- dnsapi/dns_gd.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh index ee66ee19..e08f2a05 100755 --- a/dnsapi/dns_gd.sh +++ b/dnsapi/dns_gd.sh @@ -69,7 +69,12 @@ dns_gd_add() { return 1 fi - if ! _contains "$response" "$txtvalue"; then + if _contains "$response" "UNKNOWN_DOMAIN"; then + # GoDaddy sometimes returns UNKNOWN_DOMAIN when reading a record back even + # though the PUT above succeeded; skip the local readback check and let + # acme.sh's own DNS propagation check verify the record was published. + _info "GoDaddy API won't allow reading the record back; skipping local verification." + elif ! _contains "$response" "$txtvalue"; then _err "TXT record '${txtvalue}' for '${fulldomain}', value wasn't set!" return 1 fi From 20254cbaf0755175024b2a9e0aaa87f9d998fc2e Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:02:35 +0800 Subject: [PATCH 08/64] dns_dnsimple: support user tokens (dnsimple_u_*) https://github.com/acmesh-official/acme.sh/issues/6491 --- dnsapi/dns_dnsimple.sh | 36 +++++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh index 10a3821d..e262239a 100644 --- a/dnsapi/dns_dnsimple.sh +++ b/dnsapi/dns_dnsimple.sh @@ -5,6 +5,7 @@ Site: DNSimple.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_dnsimple Options: DNSimple_OAUTH_TOKEN OAuth Token + DNSimple_ACCOUNT_ID Account ID. Optional, only needed when the token can access multiple accounts. Issues: github.com/pho3nixf1re/acme.sh/issues ' @@ -28,7 +29,7 @@ dns_dnsimple_add() { _saveaccountconf DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN" if ! _get_account_id; then - _err "failed to retrive account id" + _err "failed to retrieve account id" return 1 fi @@ -57,7 +58,7 @@ dns_dnsimple_rm() { fulldomain=$1 if ! _get_account_id; then - _err "failed to retrive account id" + _err "failed to retrieve account id" return 1 fi @@ -122,13 +123,16 @@ _get_root() { # returns _account_id _get_account_id() { - _debug "retrive account id" - if ! _dnsimple_rest GET "whoami"; then - return 1 + DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf DNSimple_ACCOUNT_ID)}" + if [ "$DNSimple_ACCOUNT_ID" ]; then + _saveaccountconf DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID" + _account_id="$DNSimple_ACCOUNT_ID" + _debug _account_id "$_account_id" + return 0 fi - if _contains "$response" "\"account\":null"; then - _err "no account associated with this token" + _debug "retrieve account id" + if ! _dnsimple_rest GET "whoami"; then return 1 fi @@ -137,7 +141,25 @@ _get_account_id() { return 1 fi + if _contains "$response" "\"account\":null"; then + # the whoami of a user token (dnsimple_u_*) carries no account, + # so list the accounts the token can access instead + # https://github.com/acmesh-official/acme.sh/issues/6491 + if ! _dnsimple_rest GET "accounts"; then + return 1 + fi + fi + _account_id=$(printf "%s" "$response" | _egrep_o "\"id\":[^,]*,\"email\":" | cut -d: -f2 | cut -d, -f1) + if [ -z "$_account_id" ]; then + _err "no account associated with this token" + return 1 + fi + if [ "$(echo "$_account_id" | wc -l)" -gt 1 ]; then + _err "The token has access to multiple accounts, please pick one and set it explicitly:" + _err "export DNSimple_ACCOUNT_ID=" + return 1 + fi _debug _account_id "$_account_id" return 0 From 0ce8c24736971db8b1a73b07d0d010cbec3cb13e Mon Sep 17 00:00:00 2001 From: szakharchenko Date: Fri, 3 Jul 2026 18:25:38 +0300 Subject: [PATCH 09/64] dev_mythic_beasts: Fix header name: Accepts => Accept (#6428) --- dnsapi/dns_mythic_beasts.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_mythic_beasts.sh b/dnsapi/dns_mythic_beasts.sh index 1529e1e7..a49ab8ab 100755 --- a/dnsapi/dns_mythic_beasts.sh +++ b/dnsapi/dns_mythic_beasts.sh @@ -186,7 +186,7 @@ _oauth2() { _oauth2_std() { # HTTP Basic Authentication _H1="Authorization: Basic $(echo "$MB_AK:$MB_AS" | _base64)" - _H2="Accepts: application/json" + _H2="Accept: application/json" export _H1 _H2 body="grant_type=client_credentials" @@ -210,7 +210,7 @@ _oauth2_std() { } _oauth2_github() { - _H1="Accepts: application/json" + _H1="Accept: application/json" export _H1 body="{\"login\":{\"handle\":\"$MB_AK\",\"pass\":\"$MB_AS\",\"floating\":1}}" @@ -241,7 +241,7 @@ _mb_rest() { fi _H1="Authorization: Bearer $MB_TK" - _H2="Accepts: application/json" + _H2="Accept: application/json" export _H1 _H2 if [ "$data" ] || [ "$m" = "POST" ] || [ "$m" = "PUT" ] || [ "$m" = "DELETE" ]; then # body url [needbase64] [POST|PUT|DELETE] [ContentType] From b974bbd6d656e24126838f70bbcdc768db0220e6 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:28:19 +0800 Subject: [PATCH 10/64] fix upgrade with a relative --home path https://github.com/acmesh-official/acme.sh/issues/6477 --- acme.sh | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 3cb4e248..b345de8e 100755 --- a/acme.sh +++ b/acme.sh @@ -2834,11 +2834,31 @@ __initHome() { _debug "Using default home: $DEFAULT_INSTALL_HOME" LE_WORKING_DIR="$DEFAULT_INSTALL_HOME" fi + # Convert a relative --home to an absolute path: later code cd's around + # (e.g. installOnline extracts and enters the archive dir), where a + # relative path would point into the wrong directory. + # https://github.com/acmesh-official/acme.sh/issues/6477 + case "$LE_WORKING_DIR" in + /*) ;; + *) + if [ -d "$LE_WORKING_DIR" ]; then + LE_WORKING_DIR="$(cd "$LE_WORKING_DIR" && pwd)" + fi + ;; + esac export LE_WORKING_DIR if [ -z "$LE_CONFIG_HOME" ]; then LE_CONFIG_HOME="$LE_WORKING_DIR" fi + case "$LE_CONFIG_HOME" in + /*) ;; + *) + if [ -d "$LE_CONFIG_HOME" ]; then + LE_CONFIG_HOME="$(cd "$LE_CONFIG_HOME" && pwd)" + fi + ;; + esac _debug "Using config home: $LE_CONFIG_HOME" export LE_CONFIG_HOME @@ -7676,7 +7696,9 @@ installOnline() { cd "$PROJECT_NAME-$_branch" chmod +x $PROJECT_ENTRY - if ./$PROJECT_ENTRY --install "$@"; then + ./$PROJECT_ENTRY --install "$@" + _install_rc="$?" + if [ "$_install_rc" = "0" ]; then _info "Install success!" fi @@ -7684,6 +7706,9 @@ installOnline() { rm -rf "$PROJECT_NAME-$_branch" rm -f "$localname" + # Propagate the install result so a failed upgrade is not reported as + # success. https://github.com/acmesh-official/acme.sh/issues/6477 + exit "$_install_rc" ) } From ac5624536b1321c8ac47d1b2d25946b9a8032144 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:30:16 +0800 Subject: [PATCH 11/64] dns_gd: fix root zone detection for API-restricted accounts https://github.com/acmesh-official/acme.sh/issues/4487 --- dnsapi/dns_gd.sh | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh index e08f2a05..c92bdfa2 100755 --- a/dnsapi/dns_gd.sh +++ b/dnsapi/dns_gd.sh @@ -150,8 +150,8 @@ dns_gd_rm() { # _domain=domain.com _get_root() { domain=$1 - i=2 - p=1 + i=1 + p=0 while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) if [ -z "$h" ]; then @@ -159,17 +159,41 @@ _get_root() { return 1 fi - if ! _gd_rest GET "domains/$h"; then - return 1 + # The record name is whatever precedes the candidate zone. Do not assume + # _acme-challenge here: with DNS alias mode it can be any name, and the + # record may even sit at the zone apex (name "@"). + if [ "$p" = "0" ]; then + _probe_sub="@" + else + _probe_sub=$(printf "%s" "$domain" | cut -d . -f 1-"$p") fi - if _contains "$response" '"code":"NOT_FOUND"'; then - _debug "$h not found" - else - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + # Probe with the records endpoint instead of "GET domains/$h": since + # 2024-05 GoDaddy rejects the domain details call for accounts with + # fewer than 10 domains, while record-level calls keep working. + # https://github.com/acmesh-official/acme.sh/issues/4487 + if ! _gd_rest GET "domains/$h/records/TXT/$_probe_sub"; then + return 1 + fi + if _startswith "$response" '\['; then + _sub_domain="$_probe_sub" _domain="$h" return 0 fi + + # Some accounts get UNKNOWN_DOMAIN when reading records of a valid zone + # even though writes succeed (see issue #6517); fall back to the domain + # details call for them. + if ! _gd_rest GET "domains/$h"; then + return 1 + fi + if _contains "$response" '"domainId"'; then + _sub_domain="$_probe_sub" + _domain="$h" + return 0 + fi + + _debug "$h not found" p="$i" i=$(_math "$i" + 1) done From 780f2ad5dcf42b9b317192fc4031dd86b5d197d5 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:44:12 +0800 Subject: [PATCH 13/64] dns_ali: do not rely on "_url_encode upper-hex" so the signature works with older bundled libraries (e.g. Proxmox VE) https://github.com/acmesh-official/acme.sh/issues/6272 --- dnsapi/dns_ali.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh index 90196c69..62e54e0c 100755 --- a/dnsapi/dns_ali.sh +++ b/dnsapi/dns_ali.sh @@ -69,8 +69,8 @@ _ali_rest() { ign="$2" mtd="${3:-GET}" - signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _url_encode upper-hex)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64) - signature=$(printf "%s" "$signature" | _url_encode upper-hex) + signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _ali_urlencode_upper)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64) + signature=$(printf "%s" "$signature" | _ali_urlencode_upper) url="$endpoint?Signature=$signature" if [ "$mtd" = "GET" ]; then @@ -96,6 +96,20 @@ _ali_rest() { fi } +# stdin stdout +# The Aliyun signature requires percent-encoding with upper-case hex. +# Do not use "_url_encode upper-hex" here: this file is also bundled by +# third parties (e.g. Proxmox VE proxmox-acme) whose older copies of the +# acme.sh function library ignore the upper-hex argument and output +# lower-case hex, which invalidates the signature. +# https://github.com/acmesh-official/acme.sh/issues/6272 +_ali_urlencode_upper() { + { + _url_encode + echo + } | sed 's/%a/%A/g;s/%b/%B/g;s/%c/%C/g;s/%d/%D/g;s/%e/%E/g;s/%f/%F/g;s/%\(.\)a/%\1A/g;s/%\(.\)b/%\1B/g;s/%\(.\)c/%\1C/g;s/%\(.\)d/%\1D/g;s/%\(.\)e/%\1E/g;s/%\(.\)f/%\1F/g' +} + _ali_nonce() { if [ "$ACME_OPENSSL_BIN" ]; then "$ACME_OPENSSL_BIN" rand -hex 16 2>/dev/null && return 0 From 7fb40f0ccf32bc16589c63d7a478b42f3fcb0fe5 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 23:55:23 +0800 Subject: [PATCH 14/64] fix https://github.com/acmesh-official/acme.sh/issues/6609 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index b345de8e..b4861b4c 100755 --- a/acme.sh +++ b/acme.sh @@ -3888,7 +3888,7 @@ _regAccount() { mkdir -p "$CA_DIR" - if [ ! -f "$ACCOUNT_KEY_PATH" ]; then + if [ ! -s "$ACCOUNT_KEY_PATH" ]; then if ! _create_account_key "$_reg_length"; then _err "Error creating account key." return 1 From 0df051577ca1344b75fdce80840f642a640f3e0c Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 00:06:26 +0800 Subject: [PATCH 15/64] fix https://github.com/acmesh-official/acme.sh/issues/6609 --- acme.sh | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/acme.sh b/acme.sh index b4861b4c..dfc056b8 100755 --- a/acme.sh +++ b/acme.sh @@ -1179,6 +1179,11 @@ _createkey() { length="$1" f="$2" _debug2 "_createkey for file:$f" + if ! _exists "${ACME_OPENSSL_BIN:-openssl}"; then + _err "Please install openssl first. ACME_OPENSSL_BIN=$ACME_OPENSSL_BIN" + _err "We need openssl to generate keys." + return 1 + fi eccname="$length" if _startswith "$length" "ec-"; then length=$(printf "%s" "$length" | cut -d '-' -f 2-100) @@ -1201,6 +1206,7 @@ _createkey() { _debug "Using length $length" + _new_key_file="" if ! [ -e "$f" ]; then if ! touch "$f" >/dev/null 2>&1; then _f_path="$(dirname "$f")" @@ -1214,6 +1220,7 @@ _createkey() { return 1 fi chmod 600 "$f" + _new_key_file="1" fi if _isEccKey "$length"; then @@ -1222,6 +1229,10 @@ _createkey() { echo "$_opkey" >"$f" else _err "Error encountered for ECC key named $eccname" + #do not leave an empty file behind, or the next run would treat the key as existing + if [ "$_new_key_file" ]; then + rm -f "$f" + fi return 1 fi else @@ -1234,6 +1245,10 @@ _createkey() { echo "$_opkey" >"$f" else _err "Error encountered for RSA key of length $length" + #do not leave an empty file behind, or the next run would treat the key as existing + if [ "$_new_key_file" ]; then + rm -f "$f" + fi return 1 fi fi From 2229330c48bcf7c9cf866e595bc6588a9574bde0 Mon Sep 17 00:00:00 2001 From: Artur Klauser Date: Fri, 3 Jul 2026 09:32:45 -0700 Subject: [PATCH 16/64] Fix typo in synology_dsm.sh (#6406) Fix typo in an error message. --- deploy/synology_dsm.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index e28a4036..6fce1f19 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -276,7 +276,7 @@ synology_dsm_deploy() { if [ -n "$error_code" ]; then if [ "$error_code" == "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then _cleardeployconf SYNO_DEVICE_ID - _err "Failed to authenticate with SYNO_DEVICE_ID (may expired or invalid), please try again in a new terminal window." + _err "Failed to authenticate with SYNO_DEVICE_ID (may be expired or invalid), please try again in a new terminal window." elif [ "$error_code" == "404" ]; then _err "Failed to authenticate with provided 2FA-OTP code, please try again in a new terminal window." elif [ "$error_code" == "406" ]; then From f4dc9fd9d110a27870b78395ec1741434a82208c Mon Sep 17 00:00:00 2001 From: Laurent Grawet Date: Fri, 3 Jul 2026 18:35:02 +0200 Subject: [PATCH 17/64] haproxy.sh: allows certificate deployment to multiple hosts (#5180) * haproxy.sh: allows certificate deployment to multiple hosts * Update deploy/haproxy.sh Co-authored-by: Matt Simerson * Update deploy/haproxy.sh Co-authored-by: Matt Simerson --------- Co-authored-by: Matt Simerson --- deploy/haproxy.sh | 103 ++++++++++++++++++++++++---------------------- 1 file changed, 53 insertions(+), 50 deletions(-) diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index 19509e3b..b618a65b 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -43,7 +43,8 @@ # needing to reload HAProxy. Default is "no". # # Require the socat binary. DEPLOY_HAPROXY_STATS_SOCKET variable uses the socat -# address format. +# address format. The certificate can be deployed to a comma separated ',' list +# of hosts ("TCP4:10.0.0.1:1999,TCP4:10.0.0.2:1999") # # export DEPLOY_HAPROXY_MASTER_CLI="UNIX:/run/haproxy-master.sock" # @@ -193,7 +194,6 @@ haproxy_deploy() { _issuer="${_pem}.issuer" _ocsp="${_pem}.ocsp" _reload="${Le_Deploy_haproxy_reload}" - _statssock="${Le_Deploy_haproxy_stats_socket}" _info "Deploying PEM file" # Create a temporary PEM file @@ -327,62 +327,65 @@ haproxy_deploy() { # Update certificate over HAProxy stats socket or master CLI. if _exists socat; then - # look for the certificate on the stats socket, to chose between updating or creating one - _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'" - _debug _socat_cert_cmd "${_socat_cert_cmd}" - eval "${_socat_cert_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _newcert="1" - _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}." - # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate. - _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'" - _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}" - eval "${_socat_crtlist_show_cmd}" + IFS=',' + for _statssock in ${Le_Deploy_haproxy_stats_socket}; do + # look for the certificate on the stats socket, to choose between updating or creating one + _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'" + _debug _socat_cert_cmd "${_socat_cert_cmd}" + eval "${_socat_cert_cmd}" _ret=$? if [ "${_ret}" != "0" ]; then - _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'" - return "${_ret}" + _newcert="1" + _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}." + # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate. + _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'" + _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}" + eval "${_socat_crtlist_show_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'" + return "${_ret}" + fi + # create a new certificate + _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'" + _debug _socat_new_cmd "${_socat_new_cmd}" + eval "${_socat_new_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Couldn't create '${_pem}' in haproxy" + return "${_ret}" + fi + else + _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." fi - # create a new certificate - _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'" - _debug _socat_new_cmd "${_socat_new_cmd}" - eval "${_socat_new_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Couldn't create '${_pem}' in haproxy" - return "${_ret}" - fi - else - _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." - fi - _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'" - _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" - eval "${_socat_cert_set_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Can't update '${_pem}' in haproxy" - return "${_ret}" - fi - _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'" - _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}" - eval "${_socat_cert_commit_cmd}" - _ret=$? - if [ "${_ret}" != "0" ]; then - _err "Can't commit '${_pem}' in haproxy" - return ${_ret} - fi - if [ "${_newcert}" = "1" ]; then - # if this is a new certificate, it needs to be inserted into the crt-list` - _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'" - _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}" - eval "${_socat_cert_add_cmd}" + _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'" + _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" + eval "${_socat_cert_set_cmd}" _ret=$? if [ "${_ret}" != "0" ]; then _err "Can't update '${_pem}' in haproxy" return "${_ret}" fi - fi + _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'" + _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}" + eval "${_socat_cert_commit_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Can't commit '${_pem}' in haproxy" + return ${_ret} + fi + if [ "${_newcert}" = "1" ]; then + # if this is a new certificate, it needs to be inserted into the crt-list` + _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'" + _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}" + eval "${_socat_cert_add_cmd}" + _ret=$? + if [ "${_ret}" != "0" ]; then + _err "Can't update '${_pem}' in haproxy" + return "${_ret}" + fi + fi + done else _err "'socat' is not available, couldn't update over ${_socketname}" fi From 0a6abaf8a1336a2bc33c1847850176ef0cb7471d Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 00:39:35 +0800 Subject: [PATCH 18/64] fix https://github.com/acmesh-official/acme.sh/issues/6388 --- acme.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index dfc056b8..5a41f16b 100755 --- a/acme.sh +++ b/acme.sh @@ -6381,7 +6381,13 @@ deploy() { fi _debug2 DOMAIN_CONF "$DOMAIN_CONF" - . "$DOMAIN_CONF" + # The cert dir may exist without a domain conf (e.g. the conf was deleted, or + # the cert was placed here manually). Deploy can still proceed using env-provided + # settings, and _savedomainconf below will recreate the conf, so only source it + # when present instead of failing on a missing file. + if [ -f "$DOMAIN_CONF" ]; then + . "$DOMAIN_CONF" + fi _savedomainconf Le_DeployHook "$_hooks" From b4de9e86217281eff474a2af7b40620618c1f869 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 10:49:32 +0800 Subject: [PATCH 19/64] fix docker deploy hook on podman, check exec ExitCode instead of response body (#4977) --- deploy/docker.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/deploy/docker.sh b/deploy/docker.sh index 264963ae..7fdcf604 100755 --- a/deploy/docker.sh +++ b/deploy/docker.sh @@ -189,10 +189,22 @@ _docker_exec() { _debug2 cjson "$cjson" execid="$(echo "$cjson" | cut -d '"' -f 4)" _debug execid "$execid" - ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": false,\"Tty\": false}")" + #Detach:true is required for podman's docker-compatible API: with + #Detach:false it streams the command output on the connection, so the + #non-empty response was misread as an error (issue #4977). The real + #result is checked via the exec inspect ExitCode below instead. + ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": true,\"Tty\": false}")" _debug2 ejson "$ejson" - if [ "$ejson" ]; then - _err "$ejson" + _et=0 + ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")" + while _contains "$ijson" "\"Running\":true" && [ "$_et" -lt 10 ]; do + sleep 1 + _et="$(_math "$_et" + 1)" + ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")" + done + _debug2 ijson "$ijson" + if ! echo "$ijson" | _egrep_o "\"ExitCode\": *0[,}]" >/dev/null 2>&1; then + _err "docker exec error: $ijson" return 1 fi else From 6cd0c00a21ecea6b4f1f25c4b715c1f2282c4cff Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 11:23:11 +0800 Subject: [PATCH 20/64] extract authorizations parsing into _authorizations_from_order, fix IPv6 urls (#6326) --- acme.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 5a41f16b..a21bfd71 100755 --- a/acme.sh +++ b/acme.sh @@ -918,6 +918,15 @@ _json_decode() { echo "$_j_str" } +#extract the authorization URLs from an order response on stdin, as a +#comma-separated list. The entries are quoted URL strings and a quote cannot +#occur inside a URL, so the first '"]' is always the end of the array. A +#char-class scan would stop early on the brackets of an IPv6 host +#(https://[2001:db8::1]/...). Outputs nothing if the field is missing. +_authorizations_from_order() { + sed -n 's/.*"authorizations" *: *\[//p' | sed 's/" *\].*//' | tr -d '" ' +} + #options file _sed_i() { options="$1" @@ -4981,7 +4990,7 @@ issue() { #for dns manual mode _savedomainconf "Le_OrderFinalize" "$Le_OrderFinalize" - _authorizations_seg="$(echo "$response" | _json_decode | _egrep_o '"authorizations" *: *\[[^\[]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')" + _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)" _debug2 _authorizations_seg "$_authorizations_seg" if [ -z "$_authorizations_seg" ]; then _err "_authorizations_seg not found." @@ -6839,7 +6848,7 @@ _deactivate() { _err "Cannot get new order for domain." return 1 fi - _authorizations_seg="$(echo "$response" | _egrep_o '"authorizations" *: *\[[^\]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')" + _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)" _debug2 _authorizations_seg "$_authorizations_seg" if [ -z "$_authorizations_seg" ]; then _err "_authorizations_seg not found." From 7653eaab31e3519bf03cd46ff3a22f72f55e7651 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 11:44:32 +0800 Subject: [PATCH 21/64] fix https://github.com/acmesh-official/acme.sh/issues/4879#issuecomment-2942728895 --- dnsapi/dns_hostingde.sh | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/dnsapi/dns_hostingde.sh b/dnsapi/dns_hostingde.sh index 41ccab2b..ed675b42 100644 --- a/dnsapi/dns_hostingde.sh +++ b/dnsapi/dns_hostingde.sh @@ -40,6 +40,11 @@ _hostingde_apiKey() { return 1 fi + # The endpoint is the base URL only; the api path is appended below. + # hosting.de's own docs show the full api URL, so strip it if pasted in. + # https://github.com/acmesh-official/acme.sh/issues/6896 + HOSTINGDE_ENDPOINT="$(echo "$HOSTINGDE_ENDPOINT" | sed 's|/api/dns/v1/json||; s|/*$||')" + _saveaccountconf_mutable HOSTINGDE_APIKEY "$HOSTINGDE_APIKEY" _saveaccountconf_mutable HOSTINGDE_ENDPOINT "$HOSTINGDE_ENDPOINT" } From e64529ab501b217ba453ee43144c478ef3288ab7 Mon Sep 17 00:00:00 2001 From: "Simon V." <218359733+sim0n-v@users.noreply.github.com> Date: Sat, 4 Jul 2026 10:50:55 +0200 Subject: [PATCH 22/64] ARI - Add support for Mass Revocation (#6953) * ARI - Add support for Mass Revocation * feat: update ARI each time NextRenewTime is not within the suggestedWindow * Remove _ari_should_renew and add condition on Le_NextRenewTime * Add support for ARI explanationURL * Fix debug variable _d_ari * New Banner Updated README to include responsive images for dark and light modes. * multiple fix * fix * fix shfmt * Reset README --------- Co-authored-by: ZeroSSL-Andreas --- acme.sh | 47 ++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 38 insertions(+), 9 deletions(-) diff --git a/acme.sh b/acme.sh index a21bfd71..8e8186e0 100755 --- a/acme.sh +++ b/acme.sh @@ -5928,7 +5928,6 @@ renew() { # If the window has started, renew now even if Le_NextRenewTime is in the future. # Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and use only # Le_NextRenewTime for the renewal decision. - _ari_should_renew="" if [ "$NO_ARI" = "1" ]; then _debug "NO_ARI=1, skipping ARI suggestedWindow check" elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then @@ -5939,20 +5938,38 @@ renew() { _ari_end="$(echo "$_ari_resp" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')" _debug "ARI suggestedWindow.start" "$_ari_start" _debug "ARI suggestedWindow.end" "$_ari_end" - if [ "$_ari_start" ]; then + if [ "$_ari_start" ] && [ "$_ari_end" ]; then _ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")" + _ari_end_t="$(_date2time "$(echo "$_ari_end" | sed 's/\.[0-9]*//')")" + _ari_explanation_url="$(echo "$_ari_resp" | _egrep_o '"explanationURL" *: *"[^"]*' | sed 's/.*"//')" _debug "_ari_start_t" "$_ari_start_t" - if [ "$_ari_start_t" ] && [ "$(_time)" -ge "$_ari_start_t" ]; then - _info "ARI suggestedWindow has started ($(__green "$_ari_start")), proceeding with renewal." - _ari_should_renew="1" - else - _info "ARI suggestedWindow starts at: $(__green "$_ari_start")" + _debug "_ari_end_t" "$_ari_end_t" + _debug "_ari_explanation_url" "$_ari_explanation_url" + _debug "Le_NextRenewTime" "$Le_NextRenewTime" + # Update ARI if needed + if [ "$_ari_start_t" ] && [ "$_ari_end_t" ] && [ "$Le_NextRenewTime" ] && [ "$_ari_end_t" -gt "$_ari_start_t" ] && ([ "$Le_NextRenewTime" -lt "$_ari_start_t" ] || [ "$Le_NextRenewTime" -gt "$_ari_end_t" ]); then + _ari_old_time_str="$Le_NextRenewTimeStr" + _info "Current renewal time: $(__green "$_ari_old_time_str")" + _ari_window=$(_math "$_ari_end_t" - "$_ari_start_t") + _ari_offset=$(_math "$(_time)" % "$_ari_window") + Le_NextRenewTime=$(_math "$_ari_start_t" + "$_ari_offset") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") + _info "ARI suggestedWindow: $(__green "$_ari_start") to $(__green "$_ari_end")" + _info "Updating renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")" + _savedomainconf Le_NextRenewTime "$Le_NextRenewTime" + _savedomainconf Le_NextRenewTimeStr "$Le_NextRenewTimeStr" + fi + if [ "$Le_NextRenewTime" ] && [ "$(_time)" -ge "$Le_NextRenewTime" ]; then + _info "ARI suggested renewal has passed ($(__green "$Le_NextRenewTimeStr")), proceeding with renewal." + if [ "$_ari_explanation_url" ]; then + _info "For more information on this renewal: $(__green "$_ari_explanation_url")" + fi fi fi fi fi - if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -6044,12 +6061,19 @@ renewAll() { fi d=$(basename "$di") _debug d "$d" + _d_ari="$di.ari" + _debug _d_ari "$_d_ari" ( if _endswith "$d" "$ECC_SUFFIX"; then _isEcc=$(echo "$d" | cut -d "$ECC_SEP" -f 2) d=$(echo "$d" | cut -d "$ECC_SEP" -f 1) fi renew "$d" "$_isEcc" "$_server" + rc="$?" + if [ "$rc" = "0" ] && [ "$_ari_explanation_url" ]; then + echo "$_ari_explanation_url" >"$_d_ari" + fi + return $rc ) rc="$?" _debug "Return code: $rc" @@ -6064,8 +6088,13 @@ renewAll() { _send_notify "Renew $d success" "Good, the cert is renewed." "$NOTIFY_HOOK" 0 fi fi + _renewal_explanation="" + if [ -f "$_d_ari" ]; then + _renewal_explanation=" ($(cat "$_d_ari"))" + rm -f "$_d_ari" + fi - _success_msg="${_success_msg} $d + _success_msg="${_success_msg} $d$_renewal_explanation " elif [ "$rc" = "$RENEW_SKIP" ]; then if [ $_error_level -gt $NOTIFY_LEVEL_SKIP ]; then From 8f2a476d21c13cde3b0c97ed0074c8d6b259d3f4 Mon Sep 17 00:00:00 2001 From: wardhus Date: Sat, 4 Jul 2026 10:54:05 +0200 Subject: [PATCH 23/64] Add Calrissia.be API (#6811) Co-authored-by: Ward --- dnsapi/dns_calrissia.sh | 137 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 dnsapi/dns_calrissia.sh diff --git a/dnsapi/dns_calrissia.sh b/dnsapi/dns_calrissia.sh new file mode 100644 index 00000000..01ca3092 --- /dev/null +++ b/dnsapi/dns_calrissia.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_calrissia_info='Calrissia.be DNS API +Site: calrissia.be +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_calrissia +Options: + CALRISSIA_TOKEN Personal access token +Issues: github.com/acmesh-official/acme.sh/issues/6809 +Author: Ward Hus +' + +CALRISSIA_API="https://my.calrissia.com/api" + +dns_calrissia_add() { + fulldomain="$1" + txtvalue="$2" + + _calrissia_load_token || return 1 + + if ! _calrissia_get_root "$fulldomain"; then + _err "Unable to find domain in Calrissia account for: $fulldomain" + return 1 + fi + + _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'" + _info "Adding TXT record for $fulldomain" + + _body="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120,\"prio\":0}" + _response="$(_calrissia_request POST "/domain/$_domain_id/record" "$_body")" + + if ! _contains "$_response" '"id"'; then + _err "Failed to create TXT record: $_response" + return 1 + fi + + return 0 +} + +dns_calrissia_rm() { + fulldomain="$1" + txtvalue="$2" + + _calrissia_load_token || return 1 + + if ! _calrissia_get_root "$fulldomain"; then + _err "Unable to find domain in Calrissia account for: $fulldomain" + return 1 + fi + + _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'" + + # Look the record up from the API instead of relying on local state. + # The record list is embedded in the domain object. + _response="$(_calrissia_request GET "/domain/$_domain_id")" + _debug2 "Response: $_response" + + # Split the record objects onto separate lines, then match on both the + # subdomain name and the TXT value to find the record id to delete. + _record_id="$(printf "%s" "$_response" | + tr '{}' '\n' | + grep "\"name\" *: *\"$_sub_domain\"" | + grep "\"content\" *: *\"$txtvalue\"" | + _egrep_o '"id" *: *[0-9]+' | + _head_n 1 | + _egrep_o '[0-9]+')" + + if [ -z "$_record_id" ]; then + _info "No matching TXT record found for $fulldomain; nothing to remove" + return 0 + fi + + _info "Removing TXT record id=$_record_id from domain id=$_domain_id" + if ! _response="$(_calrissia_request DELETE "/domain/$_domain_id/record/$_record_id")" || _contains "$_response" '"error"'; then + _err "Failed to remove TXT record: $_response" + return 1 + fi + return 0 +} + +#################### +# Private helpers # +#################### + +_calrissia_load_token() { + CALRISSIA_TOKEN="${CALRISSIA_TOKEN:-$(_readaccountconf_mutable CALRISSIA_TOKEN)}" + if [ -z "$CALRISSIA_TOKEN" ]; then + _err "CALRISSIA_TOKEN is not set. Generate one at https://identity.calrissia.com under API Keys." + return 1 + fi + _saveaccountconf_mutable CALRISSIA_TOKEN "$CALRISSIA_TOKEN" +} + +# Sets _domain, _domain_id, _sub_domain for a given FQDN. +_calrissia_get_root() { + _fqdn="$1" + + i=1 + while true; do + _candidate="$(printf "%s" "$_fqdn" | cut -d . -f "$i"-)" + [ -z "$_candidate" ] && return 1 + + _debug "Trying root domain: $_candidate" + _response="$(_calrissia_request GET "/domain?full_domain_name=$_candidate")" + _debug2 "Response: $_response" + + _domain_id="$(printf "%s" "$_response" | + _egrep_o '"id" *: *[0-9]+' | + _head_n 1 | + _egrep_o '[0-9]+')" + + if [ -n "$_domain_id" ]; then + if [ "$i" = "1" ]; then + # The FQDN itself is the zone apex, e.g. a challenge-alias domain. + _sub_domain="" + else + _sub_domain="$(printf "%s" "$_fqdn" | cut -d . -f "1-$((i - 1))")" + fi + _domain="$_candidate" + return 0 + fi + + i=$((i + 1)) + done +} + +_calrissia_request() { + _method="$1" + _path="$2" + _body="$3" + export _H1="Authorization: Bearer $CALRISSIA_TOKEN" + export _H2="Accept: application/json" + if [ "$_method" = "GET" ]; then + _get "$CALRISSIA_API$_path" + else + _post "$_body" "$CALRISSIA_API$_path" "" "$_method" "application/json" + fi +} From 1a3682346166989a02894b4fa401ec9127445938 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 16:44:07 +0800 Subject: [PATCH 24/64] https://github.com/acmesh-official/acme.sh/issues/3201 --- deploy/synology_dsm.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 6fce1f19..502bc59b 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -322,8 +322,8 @@ synology_dsm_deploy() { _savedeployconf SYNO_USE_TEMP_ADMIN "$SYNO_USE_TEMP_ADMIN" _savedeployconf SYNO_LOCAL_HOSTNAME "$SYNO_LOCAL_HOSTNAME" else - _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" - _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" + _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" "base64" + _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" "base64" _savedeployconf SYNO_DEVICE_ID "$SYNO_DEVICE_ID" _savedeployconf SYNO_DEVICE_NAME "$SYNO_DEVICE_NAME" fi From 6df2d9e451443ac3d935b7dfcb14b6bd146dea7e Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 18:19:45 +0800 Subject: [PATCH 25/64] dns_da: document that special characters in DA_Api credentials must be percent-encoded https://github.com/acmesh-official/acme.sh/issues/3468 --- dnsapi/dns_da.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_da.sh b/dnsapi/dns_da.sh index 36251b05..d9cf6247 100755 --- a/dnsapi/dns_da.sh +++ b/dnsapi/dns_da.sh @@ -4,7 +4,7 @@ dns_da_info='DirectAdmin Server API Site: DirectAdmin.com/api.php Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_da Options: - DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443" + DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443". Special characters in the user/password must be percent-encoded, e.g. "@" -> "%40". DA_Api_Insecure Insecure TLS. 0: check for cert validity, 1: always accept Issues: github.com/TigerP/acme.sh/issues ' From bbfb6f50aec5bddb397b5f852c5d0d1f3d5690df Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 18:46:59 +0800 Subject: [PATCH 26/64] deploy/cpanel_uapi: strip YAML double quotes around wildcard domains in list_domains output fix https://github.com/acmesh-official/acme.sh/issues/6115 --- deploy/cpanel_uapi.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index e5381b61..16b622bb 100644 --- a/deploy/cpanel_uapi.sh +++ b/deploy/cpanel_uapi.sh @@ -194,7 +194,8 @@ __cpanel_parse_response() { printf("%s%s=%s\n", prefix, $2, $3); } }' | - sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' + sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' | + sed -e 's/^"//' -e 's/"$//' # YAML double-quotes values starting with '*' (wildcard subdomains) } # Load parameter by prefix+name - fallback to default if not set, and save to config From bcbfe25d08b90a133ac9da87c832889eb1975fe0 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 18:50:29 +0800 Subject: [PATCH 27/64] haproxy.sh: use two-argument -header form for LibreSSL (#3438) --- deploy/haproxy.sh | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index b618a65b..66a2e83e 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -272,12 +272,18 @@ haproxy_deploy() { _cafile_argument="" fi _debug _cafile_argument "${_cafile_argument}" - # if OpenSSL/LibreSSL is v1.1 or above, the format for the -header option has changed + # OpenSSL 1.1+ expects -header Host=value (one argument), while + # LibreSSL keeps the old two-argument form -header Host value at any + # version (3.x/4.x), so it must be detected by name, not by number. + _openssl_name=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f1) _openssl_version=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f2) + _debug _openssl_name "${_openssl_name}" _debug _openssl_version "${_openssl_version}" _openssl_major=$(echo "${_openssl_version}" | cut -d '.' -f1) _openssl_minor=$(echo "${_openssl_version}" | cut -d '.' -f2) - if [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then + if [ "${_openssl_name}" = "LibreSSL" ]; then + _header_sep=" " + elif [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then _header_sep="=" else _header_sep=" " From 33704fc27479a86d8aa6e6b14c27d9b3ab82b87d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Gouin?= Date: Sat, 4 Jul 2026 12:54:07 +0200 Subject: [PATCH 28/64] Allow creation of ACME account with EAB directly from `--issue` command (#5087) * formalized _eab_id and _eab_kid and added EAB parameters to _regAccount on --issue * Update acme.sh * Update acme.sh --- acme.sh | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/acme.sh b/acme.sh index 8e8186e0..6bf59fbb 100755 --- a/acme.sh +++ b/acme.sh @@ -3872,10 +3872,10 @@ _on_issue_success() { #account_key_length eab-kid eab-hmac-key registeraccount() { _account_key_length="$1" - _eab_id="$2" + _eab_kid="$2" _eab_hmac_key="$3" _initpath - _regAccount "$_account_key_length" "$_eab_id" "$_eab_hmac_key" + _regAccount "$_account_key_length" "$_eab_kid" "$_eab_hmac_key" } __calcAccountKeyHash() { @@ -3905,7 +3905,7 @@ _getAccountEmail() { _regAccount() { _initpath _reg_length="$1" - _eab_id="$2" + _eab_kid="$2" _eab_hmac_key="$3" _debug3 _regAccount "$_regAccount" _initAPI @@ -3922,13 +3922,13 @@ _regAccount() { if ! _calcjwk "$ACCOUNT_KEY_PATH"; then return 1 fi - if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then - _savecaconf CA_EAB_KEY_ID "$_eab_id" + if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then + _savecaconf CA_EAB_KEY_ID "$_eab_kid" _savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key" fi - _eab_id=$(_readcaconf "CA_EAB_KEY_ID") + _eab_kid=$(_readcaconf "CA_EAB_KEY_ID") _eab_hmac_key=$(_readcaconf "CA_EAB_HMAC_KEY") - _secure_debug3 _eab_id "$_eab_id" + _secure_debug3 _eab_kid "$_eab_kid" _secure_debug3 _eab_hmac_key "$_eab_hmac_key" _email="$(_getAccountEmail)" if [ "$_email" ]; then @@ -3936,7 +3936,7 @@ _regAccount() { fi if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then - if [ -z "$_eab_id" ] || [ -z "$_eab_hmac_key" ]; then + if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then _info "No EAB credentials found for ZeroSSL, let's obtain them" if [ -z "$_email" ]; then _info "$(__green "$PROJECT_NAME is using ZeroSSL as default CA now.")" @@ -3952,10 +3952,10 @@ _regAccount() { return 1 fi _secure_debug2 _eabresp "$_eabresp" - _eab_id="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')" - _secure_debug2 _eab_id "$_eab_id" - if [ -z "$_eab_id" ]; then - _err "Cannot resolve _eab_id" + _eab_kid="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')" + _secure_debug2 _eab_kid "$_eab_kid" + if [ -z "$_eab_kid" ]; then + _err "Cannot resolve _eab_kid" return 1 fi _eab_hmac_key="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_hmac_key"' | cut -d : -f 2 | tr -d '"')" @@ -3964,12 +3964,12 @@ _regAccount() { _err "Cannot resolve _eab_hmac_key" return 1 fi - _savecaconf CA_EAB_KEY_ID "$_eab_id" + _savecaconf CA_EAB_KEY_ID "$_eab_kid" _savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key" fi fi - if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then - eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_id\",\"url\":\"${ACME_NEW_ACCOUNT}\"}" + if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then + eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_kid\",\"url\":\"${ACME_NEW_ACCOUNT}\"}" _debug3 eab_protected "$eab_protected" eab_protected64=$(printf "%s" "$eab_protected" | _base64 | _url_replace) @@ -4798,7 +4798,7 @@ issue() { _debug2 _saved_account_key_hash "$_saved_account_key_hash" if [ -z "$ACCOUNT_URL" ] || [ -z "$_saved_account_key_hash" ] || [ "$_saved_account_key_hash" != "$(__calcAccountKeyHash)" ]; then - if ! _regAccount "$_accountkeylength"; then + if ! _regAccount "$_accountkeylength" "$_eab_kid" "$_eab_hmac_key"; then _on_issue_err "$_post_hook" return 1 fi From 917bebd46033fad6a167d768e9c04cc82520bff3 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 19:06:26 +0800 Subject: [PATCH 29/64] dns_huaweicloud: add optional HUAWEICLOUD_Region (default ap-southeast-1) The DNS endpoint and IAM token scope project were hardcoded to ap-southeast-1, which fails for accounts without that region enabled. fix https://github.com/acmesh-official/acme.sh/issues/5302 --- dnsapi/dns_huaweicloud.sh | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/dnsapi/dns_huaweicloud.sh b/dnsapi/dns_huaweicloud.sh index ee2d2b8e..83fcc625 100644 --- a/dnsapi/dns_huaweicloud.sh +++ b/dnsapi/dns_huaweicloud.sh @@ -7,11 +7,11 @@ Options: HUAWEICLOUD_Username Username HUAWEICLOUD_Password Password HUAWEICLOUD_DomainName DomainName + HUAWEICLOUD_Region Region. E.g. "cn-north-4". Optional, defaults to "ap-southeast-1". Issues: github.com/acmesh-official/acme.sh/issues/3265 ' iam_api="https://iam.myhuaweicloud.com" -dns_api="https://dns.ap-southeast-1.myhuaweicloud.com" # Should work ######## Public functions ##################### @@ -30,6 +30,7 @@ dns_huaweicloud_add() { HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}" HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}" HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}" + HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}" # Check information if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then @@ -37,8 +38,11 @@ dns_huaweicloud_add() { return 1 fi + _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}" + dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com" + unset token # Clear token - token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")" + token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")" if [ -z "${token}" ]; then # Check token _err "dns_api(dns_huaweicloud): Error getting token." return 1 @@ -65,6 +69,9 @@ dns_huaweicloud_add() { _saveaccountconf_mutable HUAWEICLOUD_Username "${HUAWEICLOUD_Username}" _saveaccountconf_mutable HUAWEICLOUD_Password "${HUAWEICLOUD_Password}" _saveaccountconf_mutable HUAWEICLOUD_DomainName "${HUAWEICLOUD_DomainName}" + if [ -n "${HUAWEICLOUD_Region}" ]; then + _saveaccountconf_mutable HUAWEICLOUD_Region "${HUAWEICLOUD_Region}" + fi return 0 } @@ -81,6 +88,7 @@ dns_huaweicloud_rm() { HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}" HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}" HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}" + HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}" # Check information if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then @@ -88,8 +96,11 @@ dns_huaweicloud_rm() { return 1 fi + _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}" + dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com" + unset token # Clear token - token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")" + token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")" if [ -z "${token}" ]; then # Check token _err "dns_api(dns_huaweicloud): Error getting token." return 1 @@ -298,6 +309,7 @@ _get_token() { _username=$1 _password=$2 _domain_name=$3 + _region_name=$4 _debug "Getting Token" body="{ @@ -318,7 +330,7 @@ _get_token() { }, \"scope\": { \"project\": { - \"name\": \"ap-southeast-1\" + \"name\": \"${_region_name}\" } } } From a1051260635487670f28188156caec4f6dfe40fe Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 19:14:36 +0800 Subject: [PATCH 30/64] _date2time: pass date via argv to python to prevent code injection (#6463) https://github.com/acmesh-official/acme.sh/issues/6463 --- acme.sh | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 6bf59fbb..148945b9 100755 --- a/acme.sh +++ b/acme.sh @@ -1895,12 +1895,13 @@ _date2time() { if gdate -u -d "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then return fi - #Omnios - if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then + #Omnios. Pass the date as argv (sys.argv[1]) instead of interpolating it into + #the -c program text, so a quote in the input cannot inject Python code. + if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then return fi #Omnios - if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then + if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then return fi _err "Cannot parse _date2time $1" @@ -2992,6 +2993,11 @@ _initAPI() { return 0 fi _err "Cannot init API for $_api_server" + if [ "$_api_server" = "$CA_ZEROSSL" ]; then + _info "$(__green "If this host is IPv6-only: ZeroSSL currently has no IPv6 endpoint.")" + _info "$(__green "Try another CA, e.g.: $PROJECT_ENTRY --set-default-ca --server letsencrypt")" + _info "See: $(__green "https://github.com/acmesh-official/acme.sh/issues/6872")" + fi return 1 } From d3e12694b9a945664c963b3336270273d309e991 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 19:56:37 +0800 Subject: [PATCH 31/64] fix "identifiers are duplicated" when signing a CSR with a wildcard CN also present in SAN _contains matches with grep regex, so the '*' in "DNS:*.example.com," never matched and the subject was appended to the identifiers a second time. Escape the wildcard before the check, the same way the sed removal already does. fix https://github.com/acmesh-official/acme.sh/issues/5251 --- acme.sh | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/acme.sh b/acme.sh index 148945b9..8256d5f0 100755 --- a/acme.sh +++ b/acme.sh @@ -1423,12 +1423,13 @@ _readSubjectAltNamesFromCSR() { _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | grep "^ *DNS:.*" | tr -d ' \n')" _debug _dnsAltnames "$_dnsAltnames" - if _contains "$_dnsAltnames," "DNS:$_csrsubj,"; then + # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below + _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')" + _debug _excapedAlgnames "$_excapedAlgnames" + _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" + _debug _escapedSubject "$_escapedSubject" + if _contains "$_excapedAlgnames," "DNS:$_escapedSubject,"; then _debug "AltNames contains subject" - _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')" - _debug _excapedAlgnames "$_excapedAlgnames" - _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')" - _debug _escapedSubject "$_escapedSubject" _dnsAltnames="$(echo "$_excapedAlgnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")" _debug _dnsAltnames "$_dnsAltnames" else From 843a7efa7ddd2b069c96249de6261100e08b6af5 Mon Sep 17 00:00:00 2001 From: xiaopc Date: Sat, 4 Jul 2026 20:14:32 +0800 Subject: [PATCH 32/64] fix(gcore_cdn): renew login api url (#5143) https://api.gcore.com/docs/iam#tag/Account --- deploy/gcore_cdn.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/gcore_cdn.sh b/deploy/gcore_cdn.sh index fd17cc25..93e9e32c 100644 --- a/deploy/gcore_cdn.sh +++ b/deploy/gcore_cdn.sh @@ -57,7 +57,7 @@ gcore_cdn_deploy() { _request="{\"username\":\"$Le_Deploy_gcore_cdn_username\",\"password\":\"$Le_Deploy_gcore_cdn_password\"}" _debug _request "$_request" export _H1="Content-Type:application/json" - _response=$(_post "$_request" "https://api.gcore.com/auth/jwt/login") + _response=$(_post "$_request" "https://api.gcore.com/iam/auth/jwt/login") _debug _response "$_response" _regex=".*\"access\":\"\([-._0-9A-Za-z]*\)\".*$" _debug _regex "$_regex" From ede9a86d46d93f56d5b36ce2873c0d19489604bc Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 20:21:17 +0800 Subject: [PATCH 33/64] Accept both 401 and 403 for deactivated account detection RFC 8555 sec 7.3.6 requires 401 (Unauthorized) when a request is signed by a deactivated account, which ZeroSSL follows, while Boulder (Let's Encrypt) historically returns 403. Check both codes in _regAccount and deactivateaccount. fix https://github.com/acmesh-official/acme.sh/issues/5138 --- acme.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 8256d5f0..e7e9eb66 100755 --- a/acme.sh +++ b/acme.sh @@ -4042,7 +4042,9 @@ _regAccount() { _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH" _savecaconf CA_KEY_HASH "$CA_KEY_HASH" - if [ "$code" = '403' ]; then + #RFC 8555 sec 7.3.6 requires 401 for requests from a deactivated account, + #but Boulder (Let's Encrypt) historically returns 403. Accept both. + if [ "$code" = '403' ] || [ "$code" = '401' ]; then _err "It seems that the account key has been deactivated, please use a new account key." return 1 fi @@ -4122,7 +4124,8 @@ deactivateaccount() { if _send_signed_request "$_accUri" "$_djson" && _contains "$response" '"deactivated"'; then _info "Successfully deactivated account $_accUri." _accid=$(echo "$response" | _egrep_o "\"id\" *: *[^,]*," | cut -d : -f 2 | tr -d ' ,') - elif [ "$code" = "403" ]; then + elif [ "$code" = "403" ] || [ "$code" = "401" ]; then + #RFC 8555 sec 7.3.6: 401 from a deactivated account; Boulder returns 403 _info "The account is already deactivated." _accid=$(_getfield "$_accUri" "999" "/") else From fbf3b41c541694348584024b5d6703b3ddfbde95 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 20:28:56 +0800 Subject: [PATCH 34/64] dns_inwx: fix _get_root false zone match for single-letter subdomains _get_root matched the candidate zone with _contains (grep), which treats the domain as a regex. For "-d g." the candidate "g." matched "..." because '.' matches the '>' after "string" and the 'g' comes from the "" tag, so "g." was wrongly taken as the root zone (sub=_acme-challenge instead of _acme-challenge.g). Anchor the match to $h and escape dots so the zone is compared literally. Fixes #5129 --- dnsapi/dns_inwx.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_inwx.sh b/dnsapi/dns_inwx.sh index dba23846..460d4d28 100755 --- a/dnsapi/dns_inwx.sh +++ b/dnsapi/dns_inwx.sh @@ -307,13 +307,18 @@ _get_root() { return 1 fi - if _contains "$response" "$h"; then + # Anchor the match to the XML tag and escape dots so $h is compared + # literally: _contains uses grep, which treats "$h" as a regex, and a + # bare "g.berlight.de" would match "berlight.de" (the 'g' from + # "" plus '.' matching '>'). See issue #5129. + _hregex=$(printf "%s" "$h" | sed 's/\./\\./g') + if _contains "$response" "$_hregex"; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _domain="$h" return 0 fi # IDN fallback: INWX returns Unicode zone names; when $h is ACE/punycode, - # encode each zone name via _idn() and compare — no python dependency. + # encode each zone name via _idn() and compare -- no python dependency. if _contains "$h" "xn--"; then _zone_unicode=$(printf "%s" "$response" | _egrep_o '[^<]*' | sed 's/<[^>]*>//g' | while IFS= read -r _z; do From b92516f79edef6a286dd8ba52305b8d541830410 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sat, 4 Jul 2026 15:07:18 +0200 Subject: [PATCH 35/64] add application/json to acmedns (#5066) --- dnsapi/dns_acmedns.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh index f3f50233..b109a4e5 100755 --- a/dnsapi/dns_acmedns.sh +++ b/dnsapi/dns_acmedns.sh @@ -71,7 +71,7 @@ dns_acmedns_add() { data="{\"subdomain\":\"$ACMEDNS_SUBDOMAIN\", \"txt\": \"$txtvalue\"}" _debug data "$data" - response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST")" + response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST" "application/json")" _debug response "$response" if ! echo "$response" | grep "\"$txtvalue\"" >/dev/null; then From 988afd0f59545ca6cc57b9317701c3156934e261 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 21:34:35 +0800 Subject: [PATCH 36/64] _isIPv4: do not glob segments, require exactly 4 octets The unquoted splitting let a "*" segment expand against files in the current directory, so "*.*.*.*" could pass as a valid IPv4 address (issue 4971). The old code also accepted "", "1.2.3", "1.2.3.4.5", "1..2.3" and bare numbers. Split with IFS under set -f, require 4 octets, and validate each as a 1-3 digit number <= 255. Based on https://github.com/acmesh-official/acme.sh/pull/4974 fix https://github.com/acmesh-official/acme.sh/issues/4971 --- acme.sh | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/acme.sh b/acme.sh index e7e9eb66..b62b19cf 100755 --- a/acme.sh +++ b/acme.sh @@ -4598,16 +4598,25 @@ _match_issuer() { #ip _isIPv4() { - for seg in $(echo "$1" | tr '.' ' '); do - _debug2 seg "$seg" - if [ "$(echo "$seg" | tr -d '[0-9]')" ]; then - #not all number + #splitting must not glob: a "*" segment would match files in cwd + set -f + _ipv4_saved_ifs="$IFS" + IFS='.' + # shellcheck disable=SC2086 + set -- $1 + IFS="$_ipv4_saved_ifs" + set +f + if [ $# -ne 4 ]; then + return 1 + fi + for _ipv4_seg in "$@"; do + _debug2 _ipv4_seg "$_ipv4_seg" + case "$_ipv4_seg" in + *[!0-9]* | "") return 1 ;; + esac + if [ "${#_ipv4_seg}" -gt 3 ] || [ "$_ipv4_seg" -gt 255 ]; then return 1 fi - if [ $seg -ge 0 ] && [ $seg -lt 256 ]; then - continue - fi - return 1 done return 0 } From 9764f67619065a5aaa6869a595eac30c9286357c Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 21:58:57 +0800 Subject: [PATCH 37/64] dns_cn: convert IDN domain to punycode before API calls Core-Networks' API rejects Unicode domain names with "invalid domain"; it requires punycode. dns_cn_add / dns_cn_rm passed the raw challenge domain straight through, so IDN certs failed at the TXT add step (issue #4804). Run fulldomain through _idn() in both functions. For ASCII/punycode input _idn() is a pass-through, so non-IDN domains are unaffected. Fixes #4804 --- dnsapi/dns_cn.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_cn.sh b/dnsapi/dns_cn.sh index 79698e88..e06a2be6 100644 --- a/dnsapi/dns_cn.sh +++ b/dnsapi/dns_cn.sh @@ -15,7 +15,8 @@ CN_API="https://beta.api.core-networks.de" ######## Public functions ##################### dns_cn_add() { - fulldomain=$1 + # Core-Networks API requires punycode for IDN domains + fulldomain=$(_idn "$1") txtvalue=$2 if ! _cn_login; then @@ -58,7 +59,8 @@ dns_cn_add() { } dns_cn_rm() { - fulldomain=$1 + # Core-Networks API requires punycode for IDN domains + fulldomain=$(_idn "$1") txtvalue=$2 if ! _cn_login; then From 4978782fb8df3a72d2f79a8360123f872be8a3df Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 23:40:31 +0800 Subject: [PATCH 38/64] renewAll: error out if CERT_HOME is not a directory With a misconfigured $HOME / CERT_HOME the glob over "$CERT_HOME"/*.* matches nothing, so renewAll silently does nothing and returns success -- --renew-all / --cron appears to work while renewing no certificates. Check that CERT_HOME is a directory up front and return 1 with a clear error instead. Closes #4508 --- acme.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/acme.sh b/acme.sh index b62b19cf..4ac94da3 100755 --- a/acme.sh +++ b/acme.sh @@ -6072,6 +6072,10 @@ renewAll() { _set_level=${NOTIFY_LEVEL:-$NOTIFY_LEVEL_DEFAULT} _debug "_set_level" "$_set_level" export _ACME_IN_RENEWALL=1 + if ! [ -d "$CERT_HOME" ]; then + _err "$CERT_HOME is not a directory, please check your configuration." + return 1 + fi for di in "${CERT_HOME}"/*.* "${CERT_HOME}"/*:*; do _debug di "$di" if ! [ -d "$di" ]; then From 77047eb0efaf21b0019b8389891b00388f830eee Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 4 Jul 2026 23:55:16 +0800 Subject: [PATCH 39/64] fix CSR reading on systems without a default openssl.cnf (e.g. NetBSD) "openssl req -noout -in" aborts when the default config file is missing; reading a CSR needs no config, so pass -config /dev/null explicitly. Stock NetBSD does not install /etc/openssl/openssl.cnf, so --signcsr never worked there. --- acme.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 4ac94da3..4bc4b2ba 100755 --- a/acme.sh +++ b/acme.sh @@ -1405,7 +1405,9 @@ _readSubjectFromCSR() { _usage "_readSubjectFromCSR mycsr.csr" return 1 fi - ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n' + # -config /dev/null: reading a CSR needs no config, but a missing default + # openssl.cnf is fatal on some systems (e.g. NetBSD does not install one) + ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject -config /dev/null | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n' } #_csrfile @@ -1420,7 +1422,7 @@ _readSubjectAltNamesFromCSR() { _csrsubj="$(_readSubjectFromCSR "$_csrfile")" _debug _csrsubj "$_csrsubj" - _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | grep "^ *DNS:.*" | tr -d ' \n')" + _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null | grep "^ *DNS:.*" | tr -d ' \n')" _debug _dnsAltnames "$_dnsAltnames" # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below @@ -1447,7 +1449,7 @@ _readKeyLengthFromCSR() { return 1 fi - _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile")" + _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null)" _debug2 _outcsr "$_outcsr" if _contains "$_outcsr" "Public Key Algorithm: id-ecPublicKey"; then _debug "ECC CSR" From 0eb5cc8384c306fc3adcbbe8d00bb85608751749 Mon Sep 17 00:00:00 2001 From: Foster Snowhill Date: Sun, 5 Jul 2026 06:03:48 +0200 Subject: [PATCH 40/64] dns_desec: fix advertised token variable name (#7081) This must've been a copy-paste error from `dns_ddnss`. Fixes: 6b7b5caf54ea ("DNS provider API: structured description") --- dnsapi/dns_desec.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh index 275babea..e5e4809a 100644 --- a/dnsapi/dns_desec.sh +++ b/dnsapi/dns_desec.sh @@ -4,7 +4,7 @@ dns_desec_info='deSEC.io Site: desec.readthedocs.io/en/latest/ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_desec Options: - DDNSS_Token API Token + DEDYN_TOKEN API Token Issues: github.com/acmesh-official/acme.sh/issues/2180 Author: Zheng Qian ' From 524d96a3a8004eb7674bd74b1ba7b6f67f4e808a Mon Sep 17 00:00:00 2001 From: Jan Forman <47356271+jforman96@users.noreply.github.com> Date: Sun, 5 Jul 2026 06:44:07 +0200 Subject: [PATCH 41/64] Add WEDOS WAPI DNS API (dns_wedos) (#7072) * Add WEDOS WAPI DNS API (dns_wedos) * dns_wedos: fix response parsing on systems without egrep -o * dns_wedos: report WAPI auth errors, UTC fallback for hosts ignoring TZ --- dnsapi/dns_wedos.sh | 217 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) create mode 100644 dnsapi/dns_wedos.sh diff --git a/dnsapi/dns_wedos.sh b/dnsapi/dns_wedos.sh new file mode 100644 index 00000000..d1f353e2 --- /dev/null +++ b/dnsapi/dns_wedos.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_wedos_info='WEDOS.com +Site: wedos.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_wedos +Options: + WEDOS_Username WAPI login (account email) + WEDOS_Wapipass WAPI password +Issues: github.com/acmesh-official/acme.sh/issues/7071 +Author: Jan Forman +' + +WEDOS_Api="https://api.wedos.com/wapi/json" + +######## Public functions ##################### + +#Usage: dns_wedos_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_wedos_add() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + if ! _wedos_init; then + return 1 + fi + + _debug "Detecting root zone for $fulldomain" + if ! _get_root "$fulldomain"; then + _err "Cannot determine root zone for: $fulldomain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + _info "Adding TXT record: $_sub_domain.$_domain" + if ! _wedos_request "dns-row-add" "{\"domain\":\"$_domain\",\"name\":\"$_sub_domain\",\"ttl\":\"300\",\"type\":\"TXT\",\"rdata\":\"$txtvalue\"}"; then + _err "Failed to add TXT record" + return 1 + fi + + _info "Committing DNS changes for $_domain" + if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then + _err "Failed to commit DNS changes" + return 1 + fi + + return 0 +} + +#Usage: dns_wedos_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_wedos_rm() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + if ! _wedos_init; then + return 1 + fi + + _debug "Detecting root zone for $fulldomain" + if ! _get_root "$fulldomain"; then + _err "Cannot determine root zone for: $fulldomain" + return 1 + fi + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + # _get_root leaves the dns-rows-list response for $_domain in $response + _debug "Looking up row IDs for TXT value: $txtvalue" + _row_ids=$(echo "$response" | tr '{' '\n' | grep -F -- "\"rdata\":\"$txtvalue\"" | grep -F -- "\"name\":\"$_sub_domain\"" | _egrep_o '"ID": *"[0-9]*"' | tr -dc '0-9\n') + _debug _row_ids "$_row_ids" + + if [ -z "$_row_ids" ]; then + _info "TXT record not found, nothing to remove" + return 0 + fi + + for _row_id in $_row_ids; do + _info "Removing TXT record ID $_row_id from $_domain" + if ! _wedos_request "dns-row-delete" "{\"domain\":\"$_domain\",\"row_id\":\"$_row_id\"}"; then + _err "Failed to delete TXT record" + return 1 + fi + done + + _info "Committing DNS changes for $_domain" + if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then + _err "Failed to commit DNS changes" + return 1 + fi + + return 0 +} + +#################### Private functions below ################################## + +_wedos_init() { + WEDOS_Username="${WEDOS_Username:-$(_readaccountconf_mutable WEDOS_Username)}" + WEDOS_Wapipass="${WEDOS_Wapipass:-$(_readaccountconf_mutable WEDOS_Wapipass)}" + + if [ -z "$WEDOS_Username" ] || [ -z "$WEDOS_Wapipass" ]; then + WEDOS_Username="" + WEDOS_Wapipass="" + _err "You didn't specify the WEDOS WAPI credentials yet." + _err "Please export WEDOS_Username and WEDOS_Wapipass and try again." + return 1 + fi + + _saveaccountconf_mutable WEDOS_Username "$WEDOS_Username" + _saveaccountconf_mutable WEDOS_Wapipass "$WEDOS_Wapipass" + return 0 +} + +# WAPI auth token: sha1(login + sha1(password) + hour), where the hour is +# the current hour on the WEDOS servers (Europe/Prague timezone). +# The POSIX TZ string is used so no tzdata is required on the client. +_wedos_auth() { + if [ "$_wedos_utc" ]; then + # fallback: WAPI accepts 1 hour of skew, UTC+1 fits both CET and CEST + _wedos_hour=$(date -u +%H) + _wedos_hour=$(printf '%02d' "$(((${_wedos_hour#0} + 1) % 24))") + else + _wedos_hour=$(TZ='CET-1CEST,M3.5.0,M10.5.0/3' date +%H) + fi + _wedos_phash=$(printf '%s' "$WEDOS_Wapipass" | _digest sha1 hex) + printf '%s' "${WEDOS_Username}${_wedos_phash}${_wedos_hour}" | _digest sha1 hex +} + +#Usage: _wedos_request +#Returns 0 and sets $response on WAPI code 1000, returns 1 otherwise. +_wedos_request() { + _wedos_cmd="$1" + _wedos_data="$2" + + _wedos_token=$(_wedos_auth) + _secure_debug _wedos_token "$_wedos_token" + + _wedos_json="{\"request\":{\"user\":\"$WEDOS_Username\",\"auth\":\"$_wedos_token\",\"command\":\"$_wedos_cmd\",\"data\":$_wedos_data}}" + _debug2 "WAPI command: $_wedos_cmd" + _debug2 "WAPI data: $_wedos_data" + + # _post sends the global _H1.._H5 headers with every request; clear them so + # headers from earlier API calls are not leaked to the WAPI endpoint. + export _H1="" + export _H2="" + export _H3="" + export _H4="" + export _H5="" + + _wedos_body="request=$(printf '%s' "$_wedos_json" | _url_encode)" + response=$(_post "$_wedos_body" "$WEDOS_Api" "" "POST" "application/x-www-form-urlencoded") + if [ "$?" != "0" ]; then + _err "WAPI request failed for command '$_wedos_cmd'" + return 1 + fi + _debug2 "WAPI response: $response" + + _wedos_code=$(echo "$response" | _egrep_o '"code": *[0-9]*' | _head_n 1 | tr -dc '0-9') + _debug2 "WAPI result code: $_wedos_code" + if [ "$_wedos_code" = "1000" ]; then + return 0 + fi + + # some systems ignore the TZ variable (Haiku), sending a wrong auth hour; + # retry once with the UTC fallback in _wedos_auth + if [ "$_wedos_code" = "2050" ] && [ -z "$_wedos_utc" ]; then + _wedos_utc=1 + _wedos_request "$_wedos_cmd" "$_wedos_data" + return $? + fi + + # 2050 = bad credentials, 2051 = IP not whitelisted, 2052 = IP blocked + if [ "$_wedos_code" = "2050" ] || [ "$_wedos_code" = "2051" ] || [ "$_wedos_code" = "2052" ]; then + _wedos_result=$(echo "$response" | _egrep_o '"result": *"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _err "WAPI authentication error $_wedos_code: $_wedos_result" + _err "Check WEDOS_Username, WEDOS_Wapipass and the WAPI IP whitelist." + _wedos_autherr=1 + return 1 + fi + + _debug "WAPI error for command '$_wedos_cmd': $response" + return 1 +} + +# Determine the registered domain (_domain) and subdomain prefix (_sub_domain) +# by walking up the labels and calling dns-rows-list until WAPI accepts one. +# _acme-challenge.www.example.co.uk +# -> _sub_domain=_acme-challenge.www _domain=example.co.uk +# The full domain itself is tried first, so a zone apex (e.g. DNS alias mode +# pointing at the registered domain) resolves to an empty _sub_domain. +_get_root() { + _gr_full="$1" + _gr_i=1 + _wedos_autherr="" + while true; do + _gr_candidate=$(printf '%s' "$_gr_full" | cut -d . -f "${_gr_i}"-100) + _debug2 "Checking zone candidate: $_gr_candidate" + if [ -z "$_gr_candidate" ]; then + return 1 + fi + + if _wedos_request "dns-rows-list" "{\"domain\":\"$_gr_candidate\"}"; then + _domain="$_gr_candidate" + if [ "$_gr_i" = "1" ]; then + _sub_domain="" + else + _sub_domain=$(printf '%s' "$_gr_full" | cut -d . -f 1-"$((_gr_i - 1))") + fi + return 0 + fi + + # auth error hits every candidate, stop the walk + if [ "$_wedos_autherr" ]; then + return 1 + fi + + _gr_i=$((_gr_i + 1)) + done +} From 1e2cd50fc9759d046ef16d8dd7ada263fe257078 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 15:44:12 +0800 Subject: [PATCH 42/64] deploy/haproxy: use printf instead of "echo -e" for the stats socket payload dash's echo has no -e flag and sends a literal "-e " prefix to the socket, so haproxy rejects the command and the hot update always fails on Debian/Ubuntu (/bin/sh = dash). Also accept "Transaction updated", which haproxy replies when an uncommitted transaction already exists. fix https://github.com/acmesh-official/acme.sh/issues/6165 --- deploy/haproxy.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh index 66a2e83e..9736e6ff 100644 --- a/deploy/haproxy.sh +++ b/deploy/haproxy.sh @@ -364,7 +364,9 @@ haproxy_deploy() { else _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}." fi - _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'" + # printf %b, not "echo -e": dash's echo has no -e and sends a literal "-e " to the socket. + # "Transaction updated" is replied instead of "created" when an uncommitted transaction exists. + _socat_cert_set_cmd="printf '%b\n' '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -qE 'Transaction (created|updated)'" _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}" eval "${_socat_cert_set_cmd}" _ret=$? From 31b13caf8bfe8f95a49b19662d507280368eeba0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 15:51:38 +0800 Subject: [PATCH 43/64] DNS.yml: fix workflow warnings - replace deprecated set-output with GITHUB_OUTPUT - untap aws/tap before brew install to silence tap trust warning - inject safe.directory=* for cygwin git so the checkout post step no longer fails with dubious ownership (exit 128) --- .github/workflows/DNS.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index a972ae1a..5417068f 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -26,9 +26,9 @@ jobs: id: step_one run: | if [ "${{secrets.TokenName1}}" ] ; then - echo "::set-output name=hasToken::true" + echo "hasToken=true" >> "$GITHUB_OUTPUT" else - echo "::set-output name=hasToken::false" + echo "hasToken=false" >> "$GITHUB_OUTPUT" fi - name: Check the value run: echo ${{ steps.step_one.outputs.hasToken }} @@ -116,7 +116,9 @@ jobs: steps: - uses: actions/checkout@v6 - name: Install tools - run: brew install socat + run: | + brew untap aws/tap || true + brew install socat - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest @@ -176,9 +178,14 @@ jobs: C:\tools\cygwin\cygwinsetup.exe -qgnNdO -R C:/tools/cygwin -s https://mirrors.kernel.org/sourceware/cygwin/ -P socat,curl,cron,unzip,git shell: cmd - name: Set ENV - shell: cmd + shell: bash run: | - echo PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin >> %GITHUB_ENV% + echo 'PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin' >> "$GITHUB_ENV" + # cygwin git sees the runner workspace as owned by another user and + # fails with "dubious ownership" (exit 128) in the checkout post step + echo 'GIT_CONFIG_COUNT=1' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_KEY_0=safe.directory' >> "$GITHUB_ENV" + echo 'GIT_CONFIG_VALUE_0=*' >> "$GITHUB_ENV" - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Run acmetest From 1cd63e1480bbb692a937e1e04709971b5e2e9382 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:02:52 +0800 Subject: [PATCH 44/64] _createcsr: omit CN from the CSR subject when it exceeds 64 characters (#4867) --- acme.sh | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/acme.sh b/acme.sh index 4bc4b2ba..98f237a7 100755 --- a/acme.sh +++ b/acme.sh @@ -1307,6 +1307,23 @@ _idn() { } #_createcsr cn san_list keyfile csrfile conf acmeValidationv1 extendedUsage +#cn +#The x509 Common Name is limited to 64 characters (RFC 5280 ub-common-name, +#enforced by openssl in ASN1_mbstring_ncopy), and an IP address or an empty +#name is not usable as CN either. When this rejects the name, _createcsr +#omits CN from the CSR subject and the CA takes the identifiers from the +#subjectAltName extension (issue 4867). +_is_valid_cn() { + _cn_v="$1" + if [ -z "$_cn_v" ] || [ "${#_cn_v}" -gt 64 ]; then + return 1 + fi + if _isIP "$_cn_v"; then + return 1 + fi + return 0 +} + _createcsr() { _debug _createcsr domain="$1" @@ -1370,16 +1387,16 @@ _createcsr() { _csr_cn="$(_idn "$domain")" _debug2 _csr_cn "$_csr_cn" if _contains "$(uname -a)" "MINGW"; then - if _isIP "$_csr_cn"; then - ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr" - else + if _is_valid_cn "$_csr_cn"; then ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//CN=$_csr_cn" -config "$csrconf" -out "$csr" + else + ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr" fi else - if _isIP "$_csr_cn"; then - ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr" - else + if _is_valid_cn "$_csr_cn"; then ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/CN=$_csr_cn" -config "$csrconf" -out "$csr" + else + ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr" fi fi } From 24895a15c864adfb461bfe6026a2b934da78060f Mon Sep 17 00:00:00 2001 From: laineus Date: Sun, 5 Jul 2026 17:05:33 +0900 Subject: [PATCH 45/64] Add dns_muumuu: muumuu-domain.com DNS API (#7012) * Add dns_muumuu: muumuu-domain.com DNS API * Fix: remove local keyword for POSIX sh compatibility * Fix: lowercase fulldomain for API compatibility * Style: use echo instead of printf for lower_case (consistent with other plugins) * Fix: prefix rest vars, clear _H4/_H5, guard record_id, update Issues URL --- dnsapi/dns_muumuu.sh | 167 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100755 dnsapi/dns_muumuu.sh diff --git a/dnsapi/dns_muumuu.sh b/dnsapi/dns_muumuu.sh new file mode 100755 index 00000000..8ef0b8c8 --- /dev/null +++ b/dnsapi/dns_muumuu.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_muumuu_info='muumuu-domain.com +Site: muumuu-domain.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_muumuu +Options: + MUUMUU_PAT Personal Access Token (scopes: domains:read, dns:read, dns:write) +Issues: github.com/acmesh-official/acme.sh/issues/7011 +' + +MUUMUU_API="https://muumuu-domain.com/api/v2" + +######## Public functions ##################### + +dns_muumuu_add() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" + + _info "Using muumuu-domain.com DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}" + if [ -z "$MUUMUU_PAT" ]; then + _err "MUUMUU_PAT is not set." + _err "Please create a Personal Access Token at https://muumuu-domain.com" + _err "with scopes: domains:read, dns:read, dns:write" + return 1 + fi + _saveaccountconf_mutable MUUMUU_PAT "$MUUMUU_PAT" + + if ! _muumuu_get_root "$fulldomain"; then + _err "Unable to find the root domain for $fulldomain" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Adding TXT record for ${fulldomain}" + body="{\"fqdn\":\"${fulldomain}.\",\"type\":\"TXT\",\"value\":\"${txtvalue}\",\"ttl\":3600}" + if _muumuu_rest POST "/me/domains/${_domain_id}/dns-records" "$body"; then + if [ "$_muumuu_code" = "201" ]; then + _info "TXT record added successfully" + return 0 + fi + fi + + _err "Failed to add TXT record (HTTP ${_muumuu_code})" + return 1 +} + +dns_muumuu_rm() { + fulldomain="$(echo "$1" | _lower_case)" + txtvalue="$2" + + _info "Using muumuu-domain.com DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}" + if [ -z "$MUUMUU_PAT" ]; then + _err "MUUMUU_PAT is not set." + return 1 + fi + + if ! _muumuu_get_root "$fulldomain"; then + _err "Unable to find the root domain for $fulldomain" + return 1 + fi + _debug _domain_id "$_domain_id" + + _info "Looking up TXT record for ${fulldomain}" + if ! _muumuu_rest GET "/me/domains/${_domain_id}/dns-records?type=TXT&fqdn=${fulldomain}."; then + _err "Failed to list TXT records" + return 1 + fi + + record_id=$(echo "$response" | _egrep_o "\"id\":[0-9]+[^}]*\"value\":\"${txtvalue}\"" | _egrep_o "\"id\":[0-9]+" | _head_n 1 | cut -d: -f2) + if [ -z "$record_id" ]; then + _info "TXT record not found, nothing to remove" + return 0 + fi + _debug record_id "$record_id" + + if _muumuu_rest DELETE "/me/domains/${_domain_id}/dns-records/${record_id}"; then + if [ "$_muumuu_code" = "204" ]; then + _info "TXT record deleted successfully" + return 0 + fi + fi + + _err "Failed to delete TXT record (HTTP ${_muumuu_code})" + return 1 +} + +#################### Private functions below ################################## + +# _acme-challenge.www.example.com +# sets: +# _domain_id MU00000001 +# _sub_domain _acme-challenge.www +# _domain example.com +_muumuu_get_root() { + domain="$1" + i=1 + p=0 + h="" + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + return 1 + fi + if ! _muumuu_rest GET "/me/domains?fqdn=${h}&page-size=1"; then + return 1 + fi + if [ "$_muumuu_code" = "401" ] || [ "$_muumuu_code" = "403" ]; then + _err "Authentication failed (HTTP ${_muumuu_code}). Check MUUMUU_PAT." + return 1 + fi + if _contains "$response" "\"fqdn\":\"${h}\""; then + _domain_id=$(echo "$response" | _egrep_o "\"id\":\"MU[0-9]+\"" | _head_n 1 | cut -d: -f2 | tr -d '"') + _domain="$h" + if [ "$p" = "0" ]; then + _sub_domain="" + else + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + fi + return 0 + fi + p="$i" + i=$(_math "$i" + 1) + done +} + +_muumuu_rest() { + _muumuu_method="$1" + _muumuu_path="$2" + _muumuu_data="$3" + _muumuu_url="${MUUMUU_API}${_muumuu_path}" + + export _H1="Authorization: Bearer ${MUUMUU_PAT}" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + export _H4="" + export _H5="" + + _secure_debug2 data "$_muumuu_data" + + if [ "$_muumuu_method" = "GET" ]; then + response="$(_get "$_muumuu_url")" + else + response="$(_post "$_muumuu_data" "$_muumuu_url" "" "$_muumuu_method")" + fi + _muumuu_ret="$?" + _muumuu_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + _debug "HTTP code: ${_muumuu_code}" + _secure_debug2 response "$response" + + if [ "$_muumuu_ret" != "0" ]; then + _err "Error accessing ${_muumuu_url}" + return 1 + fi + + response="$(printf "%s" "$response" | _normalizeJson)" + return 0 +} From cacafc9c23947b5a5d4c2b2ddad56eca9e56d1da Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:18:27 +0800 Subject: [PATCH 46/64] add Nginx workflow to test the --nginx mode Runs le_test_nginx from acmetest against Pebble: nginx listens on Pebble's HTTP-01 validation port with an aaPanel/BT style "location ^~ /" reverse proxy block, the regression case of #6125. --- .github/workflows/Nginx.yml | 66 +++++++++++++++++++++++++++++++++++++ acme.sh | 11 +++++-- 2 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/Nginx.yml diff --git a/.github/workflows/Nginx.yml b/.github/workflows/Nginx.yml new file mode 100644 index 00000000..2ca9d64a --- /dev/null +++ b/.github/workflows/Nginx.yml @@ -0,0 +1,66 @@ +name: Nginx +on: + push: + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Nginx.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Nginx: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_NGINX: 1 + CASE: le_test_nginx + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat nginx + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up nginx + # a backend on 8081 plus a site with an aaPanel/BT style + # "location ^~ /" proxy block that shadows plain regex locations + # (regression for #6125); the site listens on 5002, which is the + # HTTP-01 validation port in Pebble's default config + run: | + sudo tee /etc/nginx/sites-available/default >/dev/null <<'EOF' + server { + listen 127.0.0.1:8081; + location / { + default_type text/plain; + return 200 "backend"; + } + } + server { + listen 5002 default_server; + server_name example.com; + location ^~ / { + proxy_pass http://127.0.0.1:8081; + proxy_set_header Host $http_host; + } + } + EOF + sudo nginx -t + sudo systemctl restart nginx + curl -s -H "Host: example.com" http://127.0.0.1:5002/ | grep backend + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh diff --git a/acme.sh b/acme.sh index 98f237a7..1b9c0b89 100755 --- a/acme.sh +++ b/acme.sh @@ -3448,9 +3448,14 @@ _setNginx() { fi echo "$NGINX_START -location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" { - default_type text/plain; - return 200 \"\$1.$_thumbpt\"; +location ^~ /.well-known/acme-challenge/ { + # the ^~ prefix wins over regex-skipping blocks like \"location ^~ /\", + # the nested regex location still captures the token as \$1 + location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" { + default_type text/plain; + return 200 \"\$1.$_thumbpt\"; + } + return 404; } #NGINX_START " >>"$FOUND_REAL_NGINX_CONF" From 504540e67ccf564dcdfd8cd369a26ff12127748e Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:29:58 +0800 Subject: [PATCH 47/64] dnsapi/dns_autodns: escape XML special characters in credentials (#5317) --- dnsapi/dns_autodns.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_autodns.sh b/dnsapi/dns_autodns.sh index ce566978..e26d699b 100644 --- a/dnsapi/dns_autodns.sh +++ b/dnsapi/dns_autodns.sh @@ -139,12 +139,21 @@ _get_autodns_zone() { return 1 } +# Escape the XML special characters (& < > ' ") so that credentials +# containing them do not break the request document (issue 5317). +_autodns_xml_encode() { + sed "s/&/\&/g;s//\>/g;s/'/\'/g;s/\"/\"/g" +} + _build_request_auth_xml() { + _autodns_user_xml="$(printf "%s" "$AUTODNS_USER" | _autodns_xml_encode)" + _autodns_password_xml="$(printf "%s" "$AUTODNS_PASSWORD" | _autodns_xml_encode)" + _autodns_context_xml="$(printf "%s" "$AUTODNS_CONTEXT" | _autodns_xml_encode)" printf " %s %s %s - " "$AUTODNS_USER" "$AUTODNS_PASSWORD" "$AUTODNS_CONTEXT" + " "$_autodns_user_xml" "$_autodns_password_xml" "$_autodns_context_xml" } # Arguments: From 507baff2ef8b4391c32b68d4b818f8fe439214dc Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:30:46 +0800 Subject: [PATCH 48/64] deploy/docker: allow setting key file mode and owner in the container The docker deploy hook copied the key file preserving the source mode (root:root 0600), so a non-root container service (uid >= 1000) could not read it. Add DEPLOY_DOCKER_CONTAINER_KEY_MODE and DEPLOY_DOCKER_CONTAINER_KEY_OWNER, applied via chmod/chown inside the container after the key is copied and before the reload command. Closes #5333 --- deploy/docker.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/deploy/docker.sh b/deploy/docker.sh index 7fdcf604..276172aa 100755 --- a/deploy/docker.sh +++ b/deploy/docker.sh @@ -3,6 +3,8 @@ #DEPLOY_DOCKER_CONTAINER_LABEL="xxxxxxx" #DEPLOY_DOCKER_CONTAINER_KEY_FILE="/path/to/key.pem" +#DEPLOY_DOCKER_CONTAINER_KEY_MODE="0640" +#DEPLOY_DOCKER_CONTAINER_KEY_OWNER="1000:1000" #DEPLOY_DOCKER_CONTAINER_CERT_FILE="/path/to/cert.pem" #DEPLOY_DOCKER_CONTAINER_CA_FILE="/path/to/ca.pem" #DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/path/to/fullchain.pem" @@ -71,6 +73,18 @@ docker_deploy() { _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_FILE "$DEPLOY_DOCKER_CONTAINER_KEY_FILE" fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE + _debug2 DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then + _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" + fi + + _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER + _debug2 DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then + _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + fi + _getdeployconf DEPLOY_DOCKER_CONTAINER_CERT_FILE _debug2 DEPLOY_DOCKER_CONTAINER_CERT_FILE "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then @@ -112,6 +126,20 @@ docker_deploy() { if ! _docker_cp "$_cid" "$_ckey" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then return 1 fi + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then + _info "Setting key file owner to $DEPLOY_DOCKER_CONTAINER_KEY_OWNER" + if ! _docker_exec "$_cid" chown "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then + _err "Can not change owner of key file in container" + return 1 + fi + fi + if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then + _info "Setting key file mode to $DEPLOY_DOCKER_CONTAINER_KEY_MODE" + if ! _docker_exec "$_cid" chmod "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then + _err "Can not change mode of key file in container" + return 1 + fi + fi fi if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then From 1f94fd7fd596f0bee2e11fbfd033c43a0eeb3d78 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:31:51 +0800 Subject: [PATCH 49/64] add Apache workflow to test the --apache mode Runs le_test_apache from acmetest against Pebble, with Apache listening on Pebble's HTTP-01 validation port. --- .github/workflows/Apache.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/Apache.yml diff --git a/.github/workflows/Apache.yml b/.github/workflows/Apache.yml new file mode 100644 index 00000000..b17abbd1 --- /dev/null +++ b/.github/workflows/Apache.yml @@ -0,0 +1,50 @@ +name: Apache +on: + push: + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Apache.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + Apache: + runs-on: ubuntu-latest + env: + TestingDomain: example.com + TEST_ACME_Server: https://localhost:14000/dir + HTTPS_INSECURE: 1 + TEST_LOCAL: 1 + TEST_CA: "Pebble Intermediate CA" + TEST_APACHE: 1 + CASE: le_test_apache + steps: + - uses: actions/checkout@v6 + - name: Install tools + run: sudo apt-get install -y socat apache2 + - name: Run Pebble + run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d + - name: Set up Pebble + run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4 + - name: Set up Apache + # Apache serves on 5002, which is the HTTP-01 validation port in + # Pebble's default config; acme.sh appends the challenge Alias to + # the main config itself + run: | + echo "Listen 5002" | sudo tee /etc/apache2/ports.conf + sudo sed -i "s/\*:80/*:5002/" /etc/apache2/sites-available/000-default.conf + sudo apache2ctl configtest + sudo systemctl restart apache2 + curl -s -o /dev/null -w "%{http_code}" -H "Host: example.com" http://127.0.0.1:5002/ | grep -E "200|403|404" + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - name: Run acmetest + run: cd ../acmetest && sudo --preserve-env ./letest.sh From 4256e3532b37b5ff27ad5a534e50aef01003c82a Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:32:57 +0800 Subject: [PATCH 50/64] _regAccount: error out clearly when the eab-hmac-key cannot be base64-decoded An undecodable key (e.g. broken LibreSSL base64 -d -A) used to produce the cryptic "Usage: _hmac hashalg secret [outputhex]" and an empty EAB signature that the CA rejects with 403. https://github.com/acmesh-official/acme.sh/issues/4082 --- acme.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/acme.sh b/acme.sh index 1b9c0b89..42d94f79 100755 --- a/acme.sh +++ b/acme.sh @@ -4014,6 +4014,10 @@ _regAccount() { key_hex="$(_durl_replace_base64 "$_eab_hmac_key" | _dbase64 | _hex_dump | tr -d ' ')" _debug3 key_hex "$key_hex" + if [ -z "$key_hex" ]; then + _err "Cannot base64-decode the eab-hmac-key. Please check the value, and your openssl version." + return 1 + fi eab_signature=$(printf "%s" "$eab_sign_t" | _hmac sha256 $key_hex | _base64 | _url_replace) _debug3 eab_signature "$eab_signature" From defd64022d8864153d8a6caeb18c76066be3dca3 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:53:14 +0800 Subject: [PATCH 51/64] dnsapi/dns_namecom: probe the root zone with GetDomain instead of listing all domains The domain list is paginated at 1000 entries per page and only the first page was fetched, so accounts with more than 1000 domains never found the root zone. fix https://github.com/acmesh-official/acme.sh/issues/5051 --- dnsapi/dns_namecom.sh | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh index 1062c849..1ba6a6e5 100755 --- a/dnsapi/dns_namecom.sh +++ b/dnsapi/dns_namecom.sh @@ -153,10 +153,9 @@ _namecom_get_root() { i=2 p=1 - if ! _namecom_rest GET "domains"; then - return 1 - fi - + # Probe each candidate with GetDomain (GET /v4/domains/{domainName}) instead + # of listing all domains: the list is paginated at 1000 domains per page, so + # larger accounts never found their domain on the first page. # Need to exclude the last field (tld) numfields=$(echo "$domain" | _egrep_o "\." | wc -l) while [ "$i" -le "$numfields" ]; do @@ -166,7 +165,7 @@ _namecom_get_root() { return 1 fi - if _contains "$response" "$host"; then + if _namecom_rest GET "domains/$host" && _contains "$response" "\"domainName\":\"$host\""; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") _domain="$host" return 0 From 7def43481a41f35c30e45b5407878145e8ffa53c Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 16:57:59 +0800 Subject: [PATCH 52/64] dns_regru: require a dot boundary in root zone matching _get_root matched a registered domain anywhere as a substring of the challenge domain, so with both "test.com.ru" and "subtest.com.ru" in the account, issuing for subtest.com.ru wrongly picked test.com.ru as the root (it is a substring of "sub-test.com.ru"). Anchor the match to a '.' boundary so a shorter domain no longer matches a longer subdomain label. Fixes the issue reported in #5036 (thanks @koledas) Closes #5036 --- dnsapi/dns_regru.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_regru.sh b/dnsapi/dns_regru.sh index be5ae117..edf8b464 100644 --- a/dnsapi/dns_regru.sh +++ b/dnsapi/dns_regru.sh @@ -96,8 +96,8 @@ _get_root() { for ITEM in ${domains_list}; do IDN_ITEM=${ITEM} - case "${domain}" in - *${IDN_ITEM}*) + case ".${domain}" in + *.${IDN_ITEM}*) _domain="$(_idn "${ITEM}")" _debug _domain "${_domain}" return 0 From 934711e51d10c765f67900892e2759ae3dd37dae Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:09:51 +0800 Subject: [PATCH 53/64] notify/aws_ses: add container/instance IAM role auth (IMDSv2) aws_ses_send calls `_use_container_role || _use_instance_role` when no static AWS keys are set, but those functions were never defined -- only _use_metadata was -- so role-based auth silently fell through to the "no api key" error. Add both, using the current IMDSv2-capable versions from dns_aws.sh, and set the IMDSv2 token header in _use_metadata so the credential fetch works on IMDSv2-only instances. Closes #4742 --- notify/aws_ses.sh | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/notify/aws_ses.sh b/notify/aws_ses.sh index 07e0c48c..735e6204 100644 --- a/notify/aws_ses.sh +++ b/notify/aws_ses.sh @@ -83,7 +83,43 @@ aws_ses_send() { response="$(aws_rest POST "" "" "$_data")" } +_use_container_role() { + # automatically set if running inside ECS + if [ -z "$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" ]; then + _debug "No ECS environment variable detected" + return 1 + fi + _use_metadata "169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" +} + +_use_instance_role() { + _instance_role_name_url="http://169.254.169.254/latest/meta-data/iam/security-credentials/" + + if _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 401; then + _debug "Using IMDSv2" + _token_url="http://169.254.169.254/latest/api/token" + export _H1="X-aws-ec2-metadata-token-ttl-seconds: 21600" + _token="$(_post "" "$_token_url" "" "PUT")" + _secure_debug3 "_token" "$_token" + if [ -z "$_token" ]; then + _debug "Unable to fetch IMDSv2 token from instance metadata" + return 1 + fi + export _H1="X-aws-ec2-metadata-token: $_token" + fi + + if ! _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 200; then + _debug "Unable to fetch IAM role from instance metadata" + return 1 + fi + + _instance_role_name=$(_get "$_instance_role_name_url" "" 1) + _debug "_instance_role_name" "$_instance_role_name" + _use_metadata "$_instance_role_name_url$_instance_role_name" "$_token" +} + _use_metadata() { + export _H1="X-aws-ec2-metadata-token: $2" _aws_creds="$( _get "$1" "" 1 | _normalizeJson | From e964157bffedb865b532e24fcf8abe6b1e0fe1ab Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:13:24 +0800 Subject: [PATCH 54/64] _install_win_taskscheduler: zero-pad the minute in the schtasks /ST value (#4950) --- acme.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 42d94f79..d3e364bb 100755 --- a/acme.sh +++ b/acme.sh @@ -6658,8 +6658,10 @@ _install_win_taskscheduler() { _info "$PROJECT_NAME will not save your password." _info "Please input your Windows password for: $(__green "$_myname")" _password="$(__read_password)" - #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null - echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null + #schtasks.exe /ST requires the HH:mm format, so the minute must be zero-padded (issue 4950) + _st_minute="$(printf "%02d" "$_randomminute")" + #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null + echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null echo } From d2b3772631f7055f27a267ae94b83e0f4c99962f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:13:59 +0800 Subject: [PATCH 55/64] deploy/panos: do not commit when the cert or key import failed (#4716) Committing after a failed import leaves a mismatched cert/key pair on the firewall (PAN-OS does not validate the pair at commit time), which can lock the admin out of the https management interface. --- deploy/panos.sh | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/deploy/panos.sh b/deploy/panos.sh index 00badffc..fcfd6fb5 100644 --- a/deploy/panos.sh +++ b/deploy/panos.sh @@ -296,9 +296,20 @@ panos_deploy() { _err "Unable to generate an API key. The user and pass may be invalid or not authorized to generate a new key. Please check the PANOS_USER and PANOS_PASS credentials and try again" return 1 else - deployer cert - deployer key - deployer commit + # A commit of a failed import would leave a mismatched cert/key pair + # on the firewall and can lock the admin out of the management + # interface, see https://github.com/acmesh-official/acme.sh/issues/4716 + if ! deployer cert; then + _err "Cert import failed. Aborting without committing." + return 1 + fi + if ! deployer key; then + _err "Key import failed. Aborting without committing. Warning: the firewall now has an uncommitted mismatched cert/key pair in its candidate config." + return 1 + fi + if ! deployer commit; then + return 1 + fi if [ "$_panos_template_stack" ]; then # try to get job status for 20 times in 30 sec interval i=0 From 1746fbdb255204bbb417e1ca3298699863630eb0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:37:40 +0800 Subject: [PATCH 56/64] support multiple account emails (#1309) ACCOUNT_EMAIL / --email now accepts a comma- or space-separated list and registers all of them as ACME contact entries. The ZeroSSL EAB endpoint takes a single address, so the first one is used there. --- acme.sh | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index d3e364bb..931ab750 100755 --- a/acme.sh +++ b/acme.sh @@ -3917,6 +3917,16 @@ __calc_account_thumbprint() { printf "%s" "$jwk" | tr -d ' ' | _digest "sha256" | _url_replace } +#Reads a comma- or space-separated email list from stdin and prints +#the ACME contact list items: "mailto:a@example.com","mailto:b@example.com" +_mailto_contacts() { + _mc_out="" + for _mc_m in $(tr ',' ' '); do + _mc_out="$_mc_out,\"mailto:$_mc_m\"" + done + echo "$_mc_out" | cut -c 2- +} + _getAccountEmail() { if [ "$ACCOUNT_EMAIL" ]; then echo "$ACCOUNT_EMAIL" @@ -3976,7 +3986,9 @@ _regAccount() { _info "See: $(__green "$_ZEROSSL_WIKI")" return 1 fi - _eabresp=$(_post "email=$_email" $_ZERO_EAB_ENDPOINT) + #the ZeroSSL EAB endpoint takes a single address, use the first one + _eab_email="$(echo "$_email" | tr ',' ' ' | awk '{print $1}')" + _eabresp=$(_post "email=$_eab_email" $_ZERO_EAB_ENDPOINT) if [ "$?" != "0" ]; then _debug2 "$_eabresp" _err "Cannot get EAB credentials from ZeroSSL." @@ -4026,7 +4038,7 @@ _regAccount() { _debug3 externalBinding "$externalBinding" fi if [ "$_email" ]; then - email_sg="\"contact\": [\"mailto:$_email\"], " + email_sg="\"contact\": [$(echo "$_email" | _mailto_contacts)], " fi regjson="{$email_sg\"termsOfServiceAgreed\": true$externalBinding}" @@ -4106,7 +4118,7 @@ updateaccount() { _email="$(_getAccountEmail)" if [ "$_email" ]; then - updjson='{"contact": ["mailto:'$_email'"]}' + updjson='{"contact": ['$(echo "$_email" | _mailto_contacts)']}' else updjson='{"contact": []}' fi From cabe432539f0acbdac954c48175d20d259ab9aaf Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:39:50 +0800 Subject: [PATCH 57/64] add bash completion for commands and parameters, installed via --install (#307) --- acme.sh | 11 ++ acme.sh.completion | 341 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 acme.sh.completion diff --git a/acme.sh b/acme.sh index 931ab750..4735b830 100755 --- a/acme.sh +++ b/acme.sh @@ -7236,6 +7236,10 @@ _installalias() { _sed_i "/^export LE_CONFIG_HOME/d" "$_envfile" fi _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then + #the completion file does nothing when sourced by a non-bash shell + _setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\"" + fi _profile="$(_detect_profile)" if [ "$_profile" ]; then @@ -7353,6 +7357,11 @@ install() { _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY" + if [ -f "$PROJECT_ENTRY.completion" ]; then + cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/" + _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion" + fi + if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then _installalias "$_c_home" fi @@ -7430,6 +7439,7 @@ uninstall() { _uninstallalias rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" + rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" _info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself." } @@ -7739,6 +7749,7 @@ Parameters: --config-home Specifies the home dir to save all the configurations. --useragent Specifies the user agent string. it will be saved for future use too. -m, --email Specifies the account email, only valid for the '--install' and '--update-account' command. + Multiple emails can be given as a comma-separated list: 'a@example.com,b@example.com' --accountkey Specifies the account key path, only valid for the '--install' command. --days Specifies the days to renew the cert when using '--issue' command. The default value is $DEFAULT_RENEW days. Negative values could be used to specify a number of days relative to the expiration date of the certificate. diff --git a/acme.sh.completion b/acme.sh.completion new file mode 100644 index 00000000..26cb88da --- /dev/null +++ b/acme.sh.completion @@ -0,0 +1,341 @@ +# Bash completion for acme.sh: https://github.com/acmesh-official/acme.sh +# +# "acme.sh --install" copies this file to the acme.sh home dir and wires +# it into acme.sh.env, so the completion is loaded automatically in new +# bash sessions after installation. +# +# To use it without installing acme.sh, source it from ~/.bashrc, or copy +# it to /usr/share/bash-completion/completions/acme.sh +# +# Zsh users can load it with: +# autoload -U +X bashcompinit && bashcompinit +# . /path/to/acme.sh.completion + +# This file may also be sourced by non-bash shells via acme.sh.env, +# so silently do nothing if the "complete" builtin is not available. +if ! command -v complete >/dev/null 2>&1; then + return 0 2>/dev/null || exit 0 +fi + +# Add each word of $1 that starts with $cur to COMPREPLY. +# The words are read line by line, so that candidates like a wildcard +# domain "*.example.com" are never glob-expanded against the cwd. +_acme_sh_add_matches() { + local _word + while read -r _word; do + [ -n "$_word" ] || continue + case "$_word" in + "$cur"*) COMPREPLY=("${COMPREPLY[@]}" "$_word") ;; + esac + done </dev/null 2>&1; then + compopt -o filenames 2>/dev/null + fi + return 0 +} + +_acme_sh_dirs() { + local _dir + while IFS= read -r _dir; do + [ -n "$_dir" ] || continue + COMPREPLY=("${COMPREPLY[@]}" "$_dir") + done </dev/null 2>&1; then + compopt -o filenames 2>/dev/null + fi + return 0 +} + +# Complete the domains that already have a cert: every directory in the +# config home that contains a ".conf" file ("_ecc" suffix stripped). +_acme_sh_domains() { + local _dir _name _domains="" + [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null + for _dir in "$_acme_conf_home"/*/; do + [ -d "$_dir" ] || continue + _name="${_dir%/}" + _name="${_name##*/}" + _name="${_name%_ecc}" + if [ -f "${_dir}${_name}.conf" ]; then + case " $_domains " in + *" $_name "*) ;; + *) _domains="$_domains $_name" ;; + esac + fi + done + _acme_sh_add_matches "$_domains" +} + +# Complete hook names from a subfolder of the acme.sh home dir. +# $1: subfolder (dnsapi/deploy/notify), $2: file name prefix or empty. +_acme_sh_hooks() { + local _file _hooks="" + [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null + for _file in "$_acme_home/$1/$2"*.sh; do + [ -f "$_file" ] || continue + _file="${_file##*/}" + _hooks="$_hooks ${_file%.sh}" + done + _acme_sh_add_matches "$_hooks" +} + +_acme_sh_completion() { + local cur prev _acme_home _acme_conf_home + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + prev="" + if [ "$COMP_CWORD" -gt 0 ]; then + prev="${COMP_WORDS[COMP_CWORD - 1]}" + fi + _acme_home="${LE_WORKING_DIR:-$HOME/.acme.sh}" + _acme_conf_home="${LE_CONFIG_HOME:-$_acme_home}" + + # The first argument is the command. + if [ "$COMP_CWORD" -eq 1 ]; then + _acme_sh_add_matches " + --help + --version + --install + --install-online + --uninstall + --upgrade + --issue + --deploy + --sign-csr + --show-csr + --install-cert + --renew + --renew-all + --revoke + --remove + --list + --list-profiles + --info + --to-pkcs12 + --to-pkcs8 + --create-account-key + --create-domain-key + --create-csr + --deactivate + --update-account + --register-account + --deactivate-account + --make-dns-persist-value + --install-cronjob + --uninstall-cronjob + --cron + --set-notify + --set-default-ca + --set-default-chain + " + return 0 + fi + + # Complete the value of the previous option. + case "$prev" in + -d | --domain | --challenge-alias | --domain-alias) + _acme_sh_domains + return 0 + ;; + --dns) + # The dns hook argument is optional, keep completing options if the + # current word already looks like one. + case "$cur" in + -*) ;; + *) + _acme_sh_hooks "dnsapi" "dns_" + return 0 + ;; + esac + ;; + --deploy-hook) + _acme_sh_hooks "deploy" "" + return 0 + ;; + --notify-hook) + _acme_sh_hooks "notify" "" + return 0 + ;; + --server) + _acme_sh_add_matches "letsencrypt letsencrypt_test zerossl sslcom google google_test actalis" + return 0 + ;; + -k | --keylength | -ak | --accountkeylength) + _acme_sh_add_matches "2048 3072 4096 8192 ec-256 ec-384 ec-521" + return 0 + ;; + --debug) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_add_matches "0 1 2 3" + return 0 + ;; + esac + ;; + --log) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_files + return 0 + ;; + esac + ;; + --nginx) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_files + return 0 + ;; + esac + ;; + --auto-upgrade | --always-force-new-domain-key) + # Optional argument. + case "$cur" in + -*) ;; + *) + _acme_sh_add_matches "0 1" + return 0 + ;; + esac + ;; + --log-level) + _acme_sh_add_matches "1 2" + return 0 + ;; + --syslog) + _acme_sh_add_matches "0 3 6 7" + return 0 + ;; + --notify-level) + _acme_sh_add_matches "0 1 2 3" + return 0 + ;; + --notify-mode) + _acme_sh_add_matches "0 1" + return 0 + ;; + --revoke-reason) + _acme_sh_add_matches "0 1 2 3 4 5 6 7 8 9 10" + return 0 + ;; + --cert-file | --key-file | --ca-file | --fullchain-file | --csr | --accountconf | --accountkey | --ca-bundle | --openssl-bin) + _acme_sh_files + return 0 + ;; + -w | --webroot | --home | --cert-home | --config-home | --ca-path) + _acme_sh_dirs + return 0 + ;; + -m | --email | --password | --useragent | --days | --valid-from | --valid-to | --httpport | --tlsport | --local-address | --dnssleep | --pre-hook | --post-hook | --renew-hook | --reloadcmd | --extended-key-usage | -b | --branch | --notify-source | --eab-kid | --eab-hmac-key | --preferred-chain | --cert-profile | --certificate-profile | --dns-persist-ca-name | --dns-persist-days) + # These options take a free-form value, offer nothing. + return 0 + ;; + esac + + # Complete the parameters. + _acme_sh_add_matches " + --accountconf + --accountkey + --accountkeylength + --alpn + --always-force-new-domain-key + --apache + --auto-upgrade + --branch + --ca-bundle + --ca-file + --ca-path + --cert-file + --cert-home + --cert-profile + --challenge-alias + --config-home + --csr + --days + --debug + --deploy-hook + --dns + --dns-persist + --dns-persist-ca-name + --dns-persist-days + --dns-persist-wildcard + --dnssleep + --domain + --domain-alias + --eab-hmac-key + --eab-kid + --ecc + --email + --extended-key-usage + --force + --force-color + --fullchain-file + --home + --httpport + --insecure + --key-file + --keylength + --listen-v4 + --listen-v6 + --listraw + --local-address + --log + --log-level + --nginx + --no-color + --no-cron + --no-profile + --notify-hook + --notify-level + --notify-mode + --notify-source + --ocsp-must-staple + --openssl-bin + --output-insecure + --password + --post-hook + --pre-hook + --preferred-chain + --reloadcmd + --renew-hook + --revoke-reason + --server + --staging + --standalone + --stateless + --stop-renew-on-error + --syslog + --tlsport + --treat-skip-as-success + --use-wget + --useragent + --valid-from + --valid-to + --webroot + --yes-I-know-dns-manual-mode-enough-go-ahead-please + " + return 0 +} + +complete -F _acme_sh_completion acme.sh From 7b6d96387c7eae778ab3e630950d14f8a434785e Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 17:48:33 +0800 Subject: [PATCH 58/64] migrate the legacy ACMEDNS_UPDATE_URL from the account conf (#3899) --- dnsapi/dns_acmedns.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh index b109a4e5..a21f8ef0 100755 --- a/dnsapi/dns_acmedns.sh +++ b/dnsapi/dns_acmedns.sh @@ -37,6 +37,16 @@ dns_acmedns_add() { ACMEDNS_PASSWORD="${ACMEDNS_PASSWORD:-$(_readdomainconf ACMEDNS_PASSWORD)}" ACMEDNS_SUBDOMAIN="${ACMEDNS_SUBDOMAIN:-$(_readdomainconf ACMEDNS_SUBDOMAIN)}" + #for compatibility: old versions stored ACMEDNS_UPDATE_URL in the account + #conf (issue 3899). Do not clear it here: it must stay available for the + #other domains that have not migrated to their domain conf yet. + if [ -z "$ACMEDNS_BASE_URL" ]; then + _acmedns_update_url="$(_readaccountconf_mutable ACMEDNS_UPDATE_URL)" + if [ "$_acmedns_update_url" ]; then + ACMEDNS_BASE_URL="$(echo "$_acmedns_update_url" | sed 's#/update$##')" + fi + fi + if [ "$ACMEDNS_BASE_URL" = "" ]; then ACMEDNS_BASE_URL="https://auth.acme-dns.io" fi From d3af3315da2a8d9a0080bc62c348e728db7feefc Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 18:02:34 +0800 Subject: [PATCH 59/64] dnsapi/dns_edgedns: use the system clock for the request timestamp (#3973) --- dnsapi/dns_edgedns.sh | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/dnsapi/dns_edgedns.sh b/dnsapi/dns_edgedns.sh index e88a1483..9ff1cc06 100755 --- a/dnsapi/dns_edgedns.sh +++ b/dnsapi/dns_edgedns.sh @@ -363,17 +363,12 @@ _edgedns_rest() { _edgedns_eg_timestamp() { _debug "Generating signature Timestamp" - _debug3 "Retriving ntp time" - _timeheaders="$(_get "https://www.ntp.org" "onlyheader")" - _debug3 "_timeheaders" "$_timeheaders" - _ntpdate="$(echo "$_timeheaders" | grep -i "Date:" | _head_n 1 | cut -d ':' -f 2- | tr -d "\r\n")" - _debug3 "_ntpdate" "$_ntpdate" - _ntpdate="$(echo "${_ntpdate}" | sed -e 's/^[[:space:]]*//')" - _debug3 "_NTPDATE" "$_ntpdate" - _ntptime="$(echo "${_ntpdate}" | _head_n 1 | cut -d " " -f 5 | tr -d "\r\n")" - _debug3 "_ntptime" "$_ntptime" - _eg_timestamp=$(date -u "+%Y%m%dT") - _eg_timestamp="$(printf "%s%s+0000" "$_eg_timestamp" "$_ntptime")" + #Akamai accepts a clock skew of +/-30s, so use the system clock directly. + #The previous code fetched the Date header from www.ntp.org, which is not + #a reliable time source (it served a wrong time for hours, issue 3973), + #cost an extra https round-trip for every API request, and combined the + #remote time of day with the LOCAL date, breaking around UTC midnight. + _eg_timestamp="$(date -u "+%Y%m%dT%H:%M:%S+0000")" _debug "_eg_timestamp" "$_eg_timestamp" } From 2a175f97e87890e47f87eb09198155cc5a94a668 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 5 Jul 2026 18:04:24 +0800 Subject: [PATCH 60/64] toPkcs8: support --password and re-export the pkcs8 file on renewal (#4134) --- acme.sh | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/acme.sh b/acme.sh index 4735b830..f09436b9 100755 --- a/acme.sh +++ b/acme.sh @@ -1547,6 +1547,22 @@ _toPkcs() { } +_toPkcs8() { + _cpkcs8="$1" + _ckey="$2" + pkcs8Password="$3" + + if [ "$pkcs8Password" ]; then + ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -v2 aes256 -passout "pass:$pkcs8Password" -in "$_ckey" -out "$_cpkcs8" + else + ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$_ckey" -out "$_cpkcs8" + fi + if [ "$?" = "0" ]; then + _savedomainconf "Le_PKCS8Password" "$pkcs8Password" "base64" + fi + +} + #domain [password] [isEcc] toPkcs() { domain="$1" @@ -1568,20 +1584,21 @@ toPkcs() { } -#domain [isEcc] +#domain [password] [isEcc] toPkcs8() { domain="$1" + pkcs8Password="$2" if [ -z "$domain" ]; then - _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--ecc]" + _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--password ] [--ecc]" return 1 fi - _isEcc="$2" + _isEcc="$3" _initpath "$domain" "$_isEcc" - ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$CERT_KEY_PATH" -out "$CERT_PKCS8_PATH" + _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$pkcs8Password" if [ "$?" = "0" ]; then _info "Success, $CERT_PKCS8_PATH" @@ -5889,6 +5906,12 @@ $_authorizations_map" _toPkcs "$CERT_PFX_PATH" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$Le_PFXPassword" fi + #convert to pkcs8 + Le_PKCS8Password="$(_readdomainconf Le_PKCS8Password)" + if [ "$Le_PKCS8Password" ]; then + _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$Le_PKCS8Password" + fi + if [ "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then _savedomainconf "Le_RealCertPath" "$_real_cert" _savedomainconf "Le_RealCACertPath" "$_real_ca" @@ -7801,7 +7824,7 @@ Parameters: --revoke-reason <0-10> The reason for revocation, can be used in conjunction with the '--revoke' command. See: $_REVOKE_WIKI - --password Add a password to exported pfx file. Use with --to-pkcs12. + --password Add a password to the exported pfx or pkcs8 file. Use with '--to-pkcs12' or '--to-pkcs8'. " @@ -8818,7 +8841,7 @@ _process() { toPkcs "$_domain" "$_password" "$_ecc" ;; toPkcs8) - toPkcs8 "$_domain" "$_ecc" + toPkcs8 "$_domain" "$_password" "$_ecc" ;; createAccountKey) createAccountKey "$_accountkeylength" From 8f3c1701f396887bf242e912dcbe3a50419af8c1 Mon Sep 17 00:00:00 2001 From: LaoDC Date: Sun, 5 Jul 2026 22:30:26 +0700 Subject: [PATCH 61/64] Add LaoDC DNS API (dns_laodc) (#6974) * Added LaoDC API Module * Cleaned up debug and info revised get subdomain to filter by TXT records. * Added commet to _get_root * Removed PATCH logic of updating acme records as this doesn't work for wildcard DNS. Revised rm() function to do explicit record matching. * Revised _get_root() to handle different scenarios. Fixed _laodc_api() function to check if query failed to run. added basic json sanitation to handle \ and " in $value unset _H2 _H3 after call as per request from copilot. * fixed indentation of case statement block * fixed condition checking. _get_root should start at 1 so full fqdn can be tested $? was being reference after export command failing response checks removed export txtvalue fixed docs link and issues link * Verify key for both add and rm * fixed dns alias condition check validate key returns 1 if failed. --------- Co-authored-by: neil Co-authored-by: LaoDC --- dnsapi/dns_laodc.sh | 197 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 dnsapi/dns_laodc.sh diff --git a/dnsapi/dns_laodc.sh b/dnsapi/dns_laodc.sh new file mode 100644 index 00000000..9f2103b3 --- /dev/null +++ b/dnsapi/dns_laodc.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_laodc_info='LaoDC DNS API Server +Site: laodc.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_laodc +Options: + LaoDC_Key API Key +Issues: github.com/acmesh-official/acme.sh/issues/6973 +Author: @laodc +' + +# Usage: +# export LaoDC_Key="your-api-key" +# acme.sh --issue --dns dns_laodc -d example.la -d *.example.la --dnssleep 120 +# +# The credentials will be saved in ~/.acme.sh/account.conf + +LAODC_VER="0.1.2" +LAODC_API_ENDPOINT="https://dns.laodc.com/v1" + +######## Public functions ##################### + +# Usage: dns_laodc_add _acme-challenge.example.la ZPXvna6tBhq7XQMH7_t2WC2sg0F-BdmtmmpUJiK6Ho +dns_laodc_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using LaoDC DNS API" + + _laodc_validate_key || return 1 + + _debug "Checking root zone exists for [$fulldomain]" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + _debug _domain_hash "$domain_hash" + + _info "Adding acme record" + if _laodc_api "POST" "$domain_hash" "$_sub_domain" "$txtvalue"; then + if [ "$_code" = "201" ]; then + _info "Added, OK" + return 0 + else + _err "Add TXT record error, invalid code. Code: $_code" + return 1 + fi + fi + + _err "Add TXT record error." + return 1 +} + +dns_laodc_rm() { + fulldomain=$1 + txtvalue=$2 + + _laodc_validate_key || return 1 + + _debug "Checking root zone exists for [$fulldomain]" + if ! _get_root "$fulldomain"; then + _err "Invalid domain" + return 1 + fi + + domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \") + _debug _root_domain "$_domain" + _debug _sub_domain "$_sub_domain" + _debug _domain_hash "$domain_hash" + + _info "Deleting acme record" + if _laodc_api "DELETE" "$domain_hash" "$_sub_domain" "$txtvalue"; then + if [ "$_code" = "204" ]; then + _info "Deleted, OK" + return 0 + else + _err "Delete TXT record error, invalid code. Code: $_code" + return 1 + fi + fi + + _err "Delete TXT record error." + return 1 +} + +#################### Private functions below ################################## +# _acme-challenge.www.domain.com +# returns +# _domain=domain.com +# _sub_domain=www +_get_root() { + fqdn=$1 + p=1 + i=1 + + while true; do + h=$(printf "%s" "$fqdn" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + return 1 # not valid domain + fi + + # Check API if domain exists + if _laodc_api "GET" "$h"; then + if [ "$_code" = "200" ]; then + _domain="$h" + + # DNS alias mode - @ is alias for fqdn + _sub_domain=$(printf "%s" "$fqdn" | cut -d . -f 1-"$p") + if [ "$i" = "1" ]; then + _sub_domain="@" + fi + + return 0 + fi + fi + + p="$i" + i=$(_math "$i" + 1) + done + + return 1 +} + +_laodc_validate_key() { + LaoDC_Key="${LaoDC_Key:-$(_readaccountconf_mutable LaoDC_Key)}" + + if [ -z "$LaoDC_Key" ]; then + LaoDC_Key="" + _err "You didn't specify a LaoDC API Key yet." + _err "Please export LaoDC_Key and try again." + return 1 + fi + + # Save the api key to the account conf file. + _saveaccountconf_mutable LaoDC_Key "$LaoDC_Key" +} + +_laodc_api() { + method=$1 + domain=$2 + subdomain=$3 + value=$4 + + export _H1="Content-Type: application/json" + export _H2="User-Agent: acme.sh/$VER laodc-dns-acme-sh/$LAODC_VER" + export _H3="Authorization: Bearer $LaoDC_Key" + + case $method in + GET) + if [ -n "$subdomain" ]; then + response="$(_get "$LAODC_API_ENDPOINT/$domain/$subdomain?type=TXT")" + else + response="$(_get "$LAODC_API_ENDPOINT/$domain")" + fi + ;; + POST) + # Sanitize value input + value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + data="{ \"type\": \"TXT\", \"value\": \"$value\", \"ttl\": \"60\" }" + response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "POST" "application/json")" + ;; + DELETE) + # Sanitize value input + value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g') + data="{ \"type\": \"TXT\", \"value\": \"$value\" }" + response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "DELETE" "application/json")" + ;; + esac + + _ret=$? + + # Unset immediately after request to prevent leaks + export _H1= + export _H2= + export _H3= + + if [ "$_ret" != "0" ]; then + _err "Error $domain" + return 1 + fi + + responseHeaders="$(cat "$HTTP_HEADER")" + + if echo "$responseHeaders" | grep -i "Content-Type: *application/json" >/dev/null 2>&1; then + response="$(echo "$response" | _json_decode | _normalizeJson)" + fi + + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + + _debug "http response code $_code" + _debug response "$response" + return 0 +} From 58423df3e82b181946c1d57f821f20405715c52d Mon Sep 17 00:00:00 2001 From: PM Extra Date: Sun, 5 Jul 2026 23:33:57 +0800 Subject: [PATCH 62/64] retry failed install and deploy on renew (#7083) * retry failed install and deploy on renew * fix notify level for renew retry failures --- acme.sh | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/acme.sh b/acme.sh index f09436b9..5b8f9566 100755 --- a/acme.sh +++ b/acme.sh @@ -6052,6 +6052,31 @@ renew() { fi if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then + _renew_retry_fixed="" + res="0" + _ensure_install "$Le_Domain" + res="$?" + if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then + _ensure_deploy "$Le_Domain" + res="$?" + fi + if [ "$res" != "0" ]; then + if [ -z "$_ACME_IN_RENEWALL" ]; then + if [ $_set_level -ge $NOTIFY_LEVEL_ERROR ]; then + _send_notify "Renew $Le_Domain error" "There is an error." "$NOTIFY_HOOK" 1 + fi + fi + return 1 + fi + if [ "$_renew_retry_fixed" ]; then + _info "Install/deploy retry succeeded, no renewal is needed." + if [ -z "$_ACME_IN_RENEWALL" ]; then + if [ $_set_level -ge $NOTIFY_LEVEL_RENEW ]; then + _send_notify "Renew $Le_Domain success" "Good, the cert install/deploy retry succeeded." "$NOTIFY_HOOK" 0 + fi + fi + return 0 + fi _info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")" _info "Add '$(__red '--force')' to force renewal." if [ -z "$_ACME_IN_RENEWALL" ]; then @@ -6485,6 +6510,45 @@ _deploy() { _info "$(__green Success)" fi done + + _deploy_success_time="$(_time)" + _savedomainconf "Le_DeploySuccessTime" "$_deploy_success_time" + _savedomainconf "Le_DeploySuccessTimeStr" "$(_time2str "$_deploy_success_time")" +} + +_ensure_deploy() { + _d="$1" + if [ -z "$Le_DeployHook" ]; then + return 0 + fi + if [ -z "$Le_CertCreateTime" ]; then + return 0 + fi + + _deploy_success_time="$(_readdomainconf Le_DeploySuccessTime)" + if [ -z "$_deploy_success_time" ]; then + _debug "Le_DeploySuccessTime is empty, skip deploy retry check." + return 0 + fi + case "$_deploy_success_time$Le_CertCreateTime" in + *[!0-9]*) + _debug "Le_DeploySuccessTime or Le_CertCreateTime is not a number, skip deploy retry check." + return 0 + ;; + esac + + if [ "$_deploy_success_time" -lt "$Le_CertCreateTime" ]; then + _info "The cert was created after the last successful deploy, retrying deploy hooks." + if _deploy "$_d" "$Le_DeployHook"; then + _info "Deploy retry succeeded." + _renew_retry_fixed=1 + return 0 + fi + _err "Deploy retry failed." + return 1 + fi + + return 0 } #domain hooks @@ -6644,9 +6708,54 @@ _installcert() { _info "$(__green "Reload successful")" else _err "Reload error for: $_main_domain" + return 1 fi fi + _installcert_success_time="$(_time)" + _savedomainconf "Le_InstallCertSuccessTime" "$_installcert_success_time" + _savedomainconf "Le_InstallCertSuccessTimeStr" "$(_time2str "$_installcert_success_time")" +} + +_ensure_install() { + _d="$1" + if [ -z "$Le_CertCreateTime" ]; then + return 0 + fi + + _real_cert="$(_readdomainconf Le_RealCertPath)" + _real_key="$(_readdomainconf Le_RealKeyPath)" + _real_ca="$(_readdomainconf Le_RealCACertPath)" + _reload_cmd="$(_readdomainconf Le_ReloadCmd)" + _real_fullchain="$(_readdomainconf Le_RealFullChainPath)" + if [ -z "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then + return 0 + fi + + _installcert_success_time="$(_readdomainconf Le_InstallCertSuccessTime)" + if [ -z "$_installcert_success_time" ]; then + _debug "Le_InstallCertSuccessTime is empty, skip install retry check." + return 0 + fi + case "$_installcert_success_time$Le_CertCreateTime" in + *[!0-9]*) + _debug "Le_InstallCertSuccessTime or Le_CertCreateTime is not a number, skip install retry check." + return 0 + ;; + esac + + if [ "$_installcert_success_time" -lt "$Le_CertCreateTime" ]; then + _info "The cert was created after the last successful install, retrying install cert." + if _installcert "$_d" "$_real_cert" "$_real_key" "$_real_ca" "$_real_fullchain" "$_reload_cmd"; then + _info "Install cert retry succeeded." + _renew_retry_fixed=1 + return 0 + fi + _err "Install cert retry failed." + return 1 + fi + + return 0 } __read_password() { From 1f778e6ef1c08d089413da8777359ef9ec87f9d3 Mon Sep 17 00:00:00 2001 From: Oliver Mueller Date: Mon, 6 Jul 2026 04:11:58 +0200 Subject: [PATCH 63/64] deploy/ssh: return non-zero when a server deployment fails (#6795) ssh_deploy() ignored the result of _ssh_deploy and always returned success, so a failed transfer to one (or all) of the servers in DEPLOY_SSH_SERVER was silently swallowed. Track the return code across the loop and return non-zero if any server failed, letting the caller handle notification. Co-authored-by: Claude Opus 4.8 (1M context) --- deploy/ssh.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/deploy/ssh.sh b/deploy/ssh.sh index 848380a5..82b0382c 100644 --- a/deploy/ssh.sh +++ b/deploy/ssh.sh @@ -170,10 +170,16 @@ ssh_deploy() { _info "Required commands batched and sent in single call to remote host" fi + _returnCode=0 _deploy_ssh_servers="$DEPLOY_SSH_SERVER" for DEPLOY_SSH_SERVER in $_deploy_ssh_servers; do - _ssh_deploy + if ! _ssh_deploy; then + # in case of an error, remember it, but keep going for the remaining servers + _returnCode=1 + fi done + + return $_returnCode } _ssh_deploy() { From ff9b969bdb04065f0ae6afae0449bbe5f02d3d9d Mon Sep 17 00:00:00 2001 From: "Simon V." <218359733+sim0n-v@users.noreply.github.com> Date: Mon, 6 Jul 2026 04:22:36 +0200 Subject: [PATCH 64/64] Add support for Account Key Rollover (#7080) * add wiki * feat: add support for account key rollover * Place --update-account-key next to --update-account * fix shfmt * fix shfmt * fix shfmt * Fix from review * fix shfmt * fix from review * fix review --------- Co-authored-by: neil --- acme.sh | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/acme.sh b/acme.sh index 5b8f9566..4f1c0336 100755 --- a/acme.sh +++ b/acme.sh @@ -4154,6 +4154,93 @@ updateaccount() { fi } +#Implement account key rollover +updateaccountkey() { + _length="$1" + _initpath + + if [ ! -f "$ACCOUNT_KEY_PATH" ]; then + _err "Account key not found at: $ACCOUNT_KEY_PATH" + return 1 + fi + ACCOUNT_KEY_PATH_NEW="$ACCOUNT_KEY_PATH.new" + + _accUri=$(_readcaconf "ACCOUNT_URL") + _debug _accUri "$_accUri" + + if [ -z "$_accUri" ]; then + _err "The account URL is empty, please run '--update-account' first to update the account info, then try again." + return 1 + fi + if ! _calcjwk "$ACCOUNT_KEY_PATH"; then + return 1 + fi + _inner_payload="{\"account\": \"$_accUri\", \"oldKey\": $jwk}" + + _initAPI + if [ -z "$ACME_KEY_CHANGE" ]; then + _err "Server does not expose keyChange url." + return 1 + fi + + _url="$ACME_KEY_CHANGE" + if _createkey "$_length" "$ACCOUNT_KEY_PATH_NEW"; then + _info "New account key creation OK." + else + _err "New account key creation error." + return 1 + fi + + if ! _calcjwk "$ACCOUNT_KEY_PATH_NEW"; then + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + _inner_protected="{\"url\": \"${_url}$JWK_HEADERPLACE_PART2, \"jwk\": $jwk"'}' + _inner_protected64="$(printf "%s" "$_inner_protected" | _base64 | _url_replace)" + _inner_payload64="$(printf "%s" "$_inner_payload" | _base64 | _url_replace)" + if ! _inner_sig_t="$(printf "%s" "$_inner_protected64.$_inner_payload64" | _sign "$ACCOUNT_KEY_PATH_NEW" "sha256")"; then + _err "Sign request failed." + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + _debug3 _inner_sig_t "$_inner_sig_t" + + _inner_sig="$(printf "%s" "$_inner_sig_t" | _url_replace)" + _debug3 _inner_sig "$_inner_sig" + + _body="{\"protected\": \"$_inner_protected64\", \"payload\": \"$_inner_payload64\", \"signature\": \"$_inner_sig\"}" + + if ! _send_signed_request "$_url" "$_body" "" "$ACCOUNT_KEY_PATH"; then + _err "Error rotating account key: $response." + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + + if [ "$code" = '200' ]; then + echo "$response" >"$ACCOUNT_JSON_PATH" + mv -f "$ACCOUNT_KEY_PATH_NEW" "$ACCOUNT_KEY_PATH" + _info "Account key rotation success for $_accUri." + elif [ "$code" = "409" ]; then + _err "An existing account is using the new key" + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + else + _err "Account key rollover error: $response" + rm -f "$ACCOUNT_KEY_PATH_NEW" + return 1 + fi + + __CACHED_JWK_KEY_FILE="" + _calcjwk "$ACCOUNT_KEY_PATH" + + ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)" + _info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT" + + CA_KEY_HASH="$(__calcAccountKeyHash)" + _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH" + _savecaconf CA_KEY_HASH "$CA_KEY_HASH" +} + #Implement deactivate account deactivateaccount() { _initpath @@ -7786,6 +7873,7 @@ Commands: -ccr, --create-csr Create CSR, professional use. --create-domain-key Create an domain private key, professional use. --update-account Update account info. + --update-account-key Rotate account key. --register-account Register account key. --deactivate-account Deactivate the account. --make-dns-persist-value Print the DNS TXT record(s) to enable persistent DNS validation @@ -8338,6 +8426,9 @@ _process() { --update-account | --updateaccount) _CMD="updateaccount" ;; + --update-account-key | --updateaccountkey) + _CMD="updateaccountkey" + ;; --register-account | --registeraccount) _CMD="registeraccount" ;; @@ -8931,6 +9022,9 @@ _process() { updateaccount) updateaccount ;; + updateaccountkey) + updateaccountkey "$_accountkeylength" + ;; deactivateaccount) deactivateaccount ;;