From 7d0283ca2cced9d6eb9985e36de96d15e902b556 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 10:26:40 +0800 Subject: [PATCH 01/31] dns_zonomi: make the API endpoint configurable via ZM_Api RimuHosting (which owns zonomi) exposes the identical API at https://rimuhosting.com/dns/dyndns.jsp; an overridable endpoint serves both providers with one hook. The default stays zonomi.com and is not written to the account conf. https://github.com/acmesh-official/acme.sh/issues/6475 --- dnsapi/dns_zonomi.sh | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_zonomi.sh b/dnsapi/dns_zonomi.sh index ee817381..c783a221 100644 --- a/dnsapi/dns_zonomi.sh +++ b/dnsapi/dns_zonomi.sh @@ -5,9 +5,11 @@ Site: zonomi.com Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_zonomi Options: ZM_Key API Key +OptionsAlt: + ZM_Api API endpoint. Default: "https://zonomi.com/app/dns/dyndns.jsp". For RimuHosting use "https://rimuhosting.com/dns/dyndns.jsp". ' -ZM_Api="https://zonomi.com/app/dns/dyndns.jsp" +ZM_Api_Default="https://zonomi.com/app/dns/dyndns.jsp" ######## Public functions ##################### @@ -28,6 +30,8 @@ dns_zonomi_add() { #save the api key to the account conf file. _saveaccountconf_mutable ZM_Key "$ZM_Key" + _zm_init_api + _info "Get existing txt records for $fulldomain" if ! _zm_request "action=QUERY&name=$fulldomain"; then _err "error" @@ -64,11 +68,27 @@ dns_zonomi_rm() { return 1 fi + _zm_init_api + _zm_request "action=DELETE&type=TXT&name=$fulldomain" } #################### Private functions below ################################## + +# resolve the API endpoint: zonomi by default, overridable for providers +# sharing the same API on another host (e.g. RimuHosting) +_zm_init_api() { + ZM_Api="${ZM_Api:-$(_readaccountconf_mutable ZM_Api)}" + if [ -z "$ZM_Api" ]; then + ZM_Api="$ZM_Api_Default" + fi + _debug2 ZM_Api "$ZM_Api" + if [ "$ZM_Api" != "$ZM_Api_Default" ]; then + _saveaccountconf_mutable ZM_Api "$ZM_Api" + fi +} + #qstr _zm_request() { qstr="$1" From a49f8c1992c26adb6c33a5e7425601297709472f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 10:49:49 +0800 Subject: [PATCH 02/31] mydevil: replace BSD-only cut -w with tr + plain cut cut -w (split on whitespace) is a FreeBSD extension unknown to GNU coreutils; squeeze blanks into tabs with tr first so the field extraction is POSIX. https://github.com/acmesh-official/acme.sh/issues/6452 --- deploy/mydevil.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/deploy/mydevil.sh b/deploy/mydevil.sh index bd9868aa..8954f822 100755 --- a/deploy/mydevil.sh +++ b/deploy/mydevil.sh @@ -54,6 +54,8 @@ mydevil_deploy() { # Usage: ip=$(mydevil_get_ip domain.com) # echo $ip mydevil_get_ip() { - devil dns list "$1" | cut -w -s -f 3,7 | grep "^A$(printf '\t')" | cut -w -s -f 2 || return 1 + # tr squeezes runs of blanks into one tab so plain cut works everywhere; + # cut -w is BSD-only and unknown to GNU coreutils + devil dns list "$1" | tr -s ' \t' '\t' | cut -s -f 3,7 | grep "^A$(printf '\t')" | cut -s -f 2 || return 1 return 0 } From e94631de44e08a826c569e149ed45f7edc70e720 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:20:53 +0800 Subject: [PATCH 03/31] dns_pdns: probe zones with the server-side name filter in _get_root The unfiltered GET /zones lists every zone on the server; with large installations (100k zones) root-zone detection took minutes per domain. Probe each walk-up candidate with ?zone= instead (exact match per the PowerDNS API docs); servers that ignore the parameter return the full list, which the existing check still handles. https://github.com/acmesh-official/acme.sh/issues/6382 --- dnsapi/dns_pdns.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/dnsapi/dns_pdns.sh b/dnsapi/dns_pdns.sh index 847a1af1..72a58af0 100755 --- a/dnsapi/dns_pdns.sh +++ b/dnsapi/dns_pdns.sh @@ -189,19 +189,23 @@ _get_root() { domain=$1 i=1 - if _pdns_rest "GET" "/api/v1/servers/$PDNS_ServerId/zones"; then - _zones_response=$(echo "$response" | _normalizeJson) - fi - while true; do h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - if _contains "$_zones_response" "\"name\":\"$h.\""; then - _domain="$h." - if [ -z "$h" ]; then - _domain="=2E" + # Probe each candidate zone with the server-side name filter instead of + # listing every zone: with large installations (100k zones) the + # unfiltered list takes minutes. Servers that ignore the parameter + # return the full list, which the check below still handles. + # https://doc.powerdns.com/authoritative/http-api/zone.html + if _pdns_rest "GET" "/api/v1/servers/$PDNS_ServerId/zones?zone=$h."; then + _zones_response=$(echo "$response" | _normalizeJson) + if _contains "$_zones_response" "\"name\":\"$h.\""; then + _domain="$h." + if [ -z "$h" ]; then + _domain="=2E" + fi + return 0 fi - return 0 fi if [ -z "$h" ]; then From 44c045b0569b2ec3a7b3b65451a88eaba5f05394 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:36:45 +0800 Subject: [PATCH 04/31] notify: add the customscript hook The wiki has documented "21. Set notification for customscript" since 2022 but the implementation (#4193) was never merged, so following the wiki failed with "Cannot find the hook file". Same interface as documented: the script gets subject, content and status code as three arguments. Unlike #4193, the target script is invoked directly instead of through eval -- the subject/content contain domain names and CA messages, and eval would allow command injection through them. https://github.com/acmesh-official/acme.sh/issues/6377 --- notify/customscript.sh | 44 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 notify/customscript.sh diff --git a/notify/customscript.sh b/notify/customscript.sh new file mode 100644 index 00000000..ba8b07cb --- /dev/null +++ b/notify/customscript.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env sh + +# Support calling a custom script for notifications +# +# export CUSTOMSCRIPT_PATH="/usr/local/bin/acme-notification.sh" +# +# The script is called with three arguments: +# $1 subject +# $2 content +# $3 status code (0: success, 1: error, 2: skipped) + +customscript_send() { + _subject="$1" + _content="$2" + _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped + _debug "_subject" "$_subject" + _debug "_content" "$_content" + _debug "_statusCode" "$_statusCode" + + CUSTOMSCRIPT_PATH="${CUSTOMSCRIPT_PATH:-$(_readaccountconf_mutable CUSTOMSCRIPT_PATH)}" + if [ -z "$CUSTOMSCRIPT_PATH" ]; then + _err "You didn't specify the custom script path CUSTOMSCRIPT_PATH yet." + return 1 + fi + if ! _exists "$CUSTOMSCRIPT_PATH"; then + _err "The custom script $CUSTOMSCRIPT_PATH does not exist or is not executable." + return 1 + fi + _saveaccountconf_mutable CUSTOMSCRIPT_PATH "$CUSTOMSCRIPT_PATH" + + # Invoke directly, never through eval: the subject and content contain + # domain names and CA messages, eval would allow command injection. + _customscript_result="$("$CUSTOMSCRIPT_PATH" "$_subject" "$_content" "$_statusCode" 2>&1)" + _customscript_rc="$?" + _debug2 "_customscript_result" "$_customscript_result" + + if [ "$_customscript_rc" != "0" ]; then + _err "custom script execution error ($_customscript_rc): $_customscript_result" + return 1 + fi + + _info "custom script executed successfully." + return 0 +} From c0d62eb9342e592523b1ee98090f1150dd76cc32 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:43:07 +0800 Subject: [PATCH 05/31] wiki-guard: skip on forks (no .wiki repo to check out) --- .github/workflows/wiki-guard.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml index 709fb8ea..dbe3dd8d 100644 --- a/.github/workflows/wiki-guard.yml +++ b/.github/workflows/wiki-guard.yml @@ -37,6 +37,9 @@ concurrency: jobs: guard: + # Forks have no .wiki repository, so the checkout below would + # fail there -- run only in the upstream repository. + if: github.repository == 'acmesh-official/acme.sh' runs-on: ubuntu-latest steps: - name: Checkout wiki repository From a9590c5bd7d9cdd32b8dda68bb00002feb43ca2a Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 11:49:53 +0800 Subject: [PATCH 06/31] workflows: run issue/wiki automation only in the upstream repo --- .github/workflows/blacklist-command.yml | 3 ++- .github/workflows/revert-command.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/blacklist-command.yml b/.github/workflows/blacklist-command.yml index 4d0ff5f3..35e53677 100644 --- a/.github/workflows/blacklist-command.yml +++ b/.github/workflows/blacklist-command.yml @@ -21,7 +21,8 @@ concurrency: jobs: blacklist: - if: startsWith(github.event.issue.title, 'blacklist:') + # Upstream only: forks have no .wiki repository to push to. + if: github.repository == 'acmesh-official/acme.sh' && startsWith(github.event.issue.title, 'blacklist:') runs-on: ubuntu-latest steps: - name: Check authorization diff --git a/.github/workflows/revert-command.yml b/.github/workflows/revert-command.yml index 7a792bf4..03161bbb 100644 --- a/.github/workflows/revert-command.yml +++ b/.github/workflows/revert-command.yml @@ -21,7 +21,8 @@ concurrency: jobs: revert: - if: startsWith(github.event.issue.title, 'revert:') + # Upstream only: forks have no .wiki repository to push to. + if: github.repository == 'acmesh-official/acme.sh' && startsWith(github.event.issue.title, 'revert:') runs-on: ubuntu-latest steps: - name: Check authorization From 897e2197433090c24bf55266d35a48987f1a60b0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:12:18 +0800 Subject: [PATCH 07/31] issue: never schedule the default renewal past the cert expiry The default schedule was a fixed CertCreateTime + RenewalDays - 1 day, which passes notAfter entirely for short-lived certs (internal CAs today, the CA/B SC-081 47-day maximum later) and leaves an expired cert in place for weeks. Extract the arithmetic into _calc_next_renew_time and cap it at one day before expiry (one hour for lifetimes of 24h or less, mirroring --valid-to scheduling). CAs with ARI are unaffected -- the ARI window still overrides afterwards. https://github.com/acmesh-official/acme.sh/issues/6305 --- acme.sh | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 04002193..00ec7ef4 100755 --- a/acme.sh +++ b/acme.sh @@ -1968,6 +1968,33 @@ _utc_date() { date -u "+%Y-%m-%d %H:%M:%S" } +#Usage: _calc_next_renew_time createtime renewaldays [endtime] +#Prints createtime + renewaldays*86400 - 86400, capped so it never passes +#the certificate expiry: with short-lived certs (internal CAs, upcoming +#CA/B SC-081 47-day maximum) a fixed RenewalDays would otherwise schedule +#the renewal after notAfter and leave an expired cert in place. +#The cap is one day before endtime, or one hour before for certs whose +#lifetime is 24 hours or less, mirroring the --valid-to scheduling. +_calc_next_renew_time() { + _cnrt_create="$1" + _cnrt_days="$2" + _cnrt_end="$3" + _cnrt_next=$(_math "$_cnrt_create" + "$_cnrt_days" \* 24 \* 60 \* 60 - 86400) + if [ -z "$_cnrt_end" ]; then + printf "%s" "$_cnrt_next" + return 0 + fi + if [ "$(_math "$_cnrt_end" - "$_cnrt_create")" -gt 86400 ]; then + _cnrt_cap=$(_math "$_cnrt_end" - 86400) + else + _cnrt_cap=$(_math "$_cnrt_end" - 3600) + fi + if [ "$_cnrt_next" -gt "$_cnrt_cap" ]; then + _cnrt_next="$_cnrt_cap" + fi + printf "%s" "$_cnrt_next" +} + _mktemp() { if _exists mktemp; then if mktemp 2>/dev/null; then @@ -5946,8 +5973,12 @@ $_authorizations_map" Le_NextRenewTime=$(_math "$_endtime" + "$Le_RenewalDays" \* 24 \* 60 \* 60) Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") else - Le_NextRenewTime=$(_math "$Le_CertCreateTime" + "$Le_RenewalDays" \* 24 \* 60 \* 60) - Le_NextRenewTime=$(_math "$Le_NextRenewTime" - 86400) + _endtime_for_cap="" + _enddate_value=$(_enddate "$CERT_PATH") + if [ "$?" = "0" ] && [ "$_enddate_value" ]; then + _endtime_for_cap=$(_ssldate2time "$_enddate_value") + fi + Le_NextRenewTime=$(_calc_next_renew_time "$Le_CertCreateTime" "$Le_RenewalDays" "$_endtime_for_cap") Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi From 099d88e6a99714f0c8cd87d5eeaaed9e2c871d9f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:22:28 +0800 Subject: [PATCH 08/31] dns_knot: KNOT_KEY takes the TSIG key data, not a file path The knsupdate "key" statement wants "[alg:]name secret"; the info block wrongly described the option as a file path. https://github.com/acmesh-official/acme.sh/issues/6293 --- dnsapi/dns_knot.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_knot.sh b/dnsapi/dns_knot.sh index 5636804a..b5ba32f0 100644 --- a/dnsapi/dns_knot.sh +++ b/dnsapi/dns_knot.sh @@ -5,7 +5,7 @@ Site: www.knot-dns.cz/docs/2.5/html/man_knsupdate.html Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_knot Options: KNOT_SERVER Server hostname. Default: "localhost". - KNOT_KEY File path to TSIG key + KNOT_KEY TSIG key data, not a file path. knsupdate "key" statement format: "[alg:]name secret". E.g. "hmac-sha256:acme_key BASE64SECRET=" ' # See also dns_nsupdate.sh From eacf0d6a873798753dbdfee48347b7b917f373ba Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:32:54 +0800 Subject: [PATCH 09/31] issue bot: tell reporters to redact secrets before posting logs Debug logs occasionally contain private keys or tokens (issue 6267); the code-side leak in the haproxy hook was fixed by #6268, this adds the missing warning to the auto-comment that asks for logs. --- .github/workflows/issue.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index f72a1f06..00e9ddc5 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -82,5 +82,5 @@ jobs: issue_number: issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: "Please upgrade to the latest code and try again first. Maybe it's already fixed. ```acme.sh --upgrade``` If it's still not working, please provide the log with `--debug 2`, otherwise, nobody can help you." + body: "Please upgrade to the latest code and try again first. Maybe it's already fixed. ```acme.sh --upgrade``` If it's still not working, please provide the log with `--debug 2`, otherwise, nobody can help you. Before posting the log, review it and REDACT any secrets: private keys (`-----BEGIN ... PRIVATE KEY-----` blocks), API tokens and passwords." }) \ No newline at end of file From 2c51ac1c277b691658f57605d83664731fc77170 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 12:45:10 +0800 Subject: [PATCH 10/31] dns_dynu: include the server response in the authentication error Same as the dns_cloudns change: a bare "Authentication failed" hides the actual API error and makes reports undiagnosable. --- dnsapi/dns_dynu.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_dynu.sh b/dnsapi/dns_dynu.sh index 1d1fc311..612756c5 100644 --- a/dnsapi/dns_dynu.sh +++ b/dnsapi/dns_dynu.sh @@ -214,11 +214,11 @@ _dynu_authentication() { response="$(_get "$Dynu_EndPoint/oauth2/token")" if [ "$?" != "0" ]; then - _err "Authentication failed." + _err "Authentication failed: no response from $Dynu_EndPoint/oauth2/token" return 1 fi if _contains "$response" "Authentication Exception"; then - _err "Authentication failed." + _err "Authentication failed. Server response: $response" return 1 fi if _contains "$response" "access_token"; then From 3989eef5e26d9eb84c9abd0a35d7d3ad955f4e0d Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 13:00:12 +0800 Subject: [PATCH 11/31] cpanel_uapi: don't spill a redirection error when the key file is absent With --signcsr the private key never exists in the cert home, so every renewal printed ".../domain.key: No such file or directory" from the shell redirection. Skip the key read in that case; the install_ssl call already ran with an empty key there and cPanel keeps the installed one. https://github.com/acmesh-official/acme.sh/issues/6228 --- deploy/cpanel_uapi.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index 16b622bb..156044eb 100644 --- a/deploy/cpanel_uapi.sh +++ b/deploy/cpanel_uapi.sh @@ -52,7 +52,15 @@ cpanel_uapi_deploy() { # read cert and key files and urlencode both _cert=$(_url_encode <"$_ccert") - _key=$(_url_encode <"$_ckey") + # with --signcsr the private key was never handed to acme.sh, so the key + # file does not exist; skip it instead of spilling a shell redirection + # error on every renewal (cPanel keeps using the already-installed key) + if [ -f "$_ckey" ]; then + _key=$(_url_encode <"$_ckey") + else + _debug "Key file $_ckey does not exist (csr mode), not sending a key." + _key="" + fi _debug2 _cert "$_cert" _debug2 _key "$_key" From ebb5cc4981ac38994b124441ef38b961ef565f27 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 16:04:44 +0800 Subject: [PATCH 12/31] deploy/synology_dsm.sh: remove bashisms, keep the hook POSIX sh The hook is sourced by acme.sh, so the bash shebang never takes effect: under dash, `[ x == y ]` fails with "unexpected operator", the 403 branch never triggers and 2FA-OTP login is skipped. Replace `==` with `=` and use the standard sh shebang. --- deploy/synology_dsm.sh | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 75497671..d05e503a 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -1,4 +1,4 @@ -#!/bin/bash +#!/usr/bin/env sh ################################################################################ # ACME.sh 3rd party deploy plugin for Synology DSM @@ -238,7 +238,7 @@ synology_dsm_deploy() { _debug2 error_code "$error_code" # Account has 2FA-OTP enabled, since error 403 reported. # https://global.download.synology.com/download/Document/Software/DeveloperGuide/Os/DSM/All/enu/DSM_Login_Web_API_Guide_enu.pdf - if [ "$error_code" == "403" ]; then + if [ "$error_code" = "403" ]; then if [ -z "$SYNO_DEVICE_NAME" ]; then printf "Enter device name or leave empty for default (CertRenewal): " read -r SYNO_DEVICE_NAME @@ -274,22 +274,22 @@ synology_dsm_deploy() { fi if [ -n "$error_code" ]; then - if [ "$error_code" == "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then + if [ "$error_code" = "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then _cleardeployconf SYNO_DEVICE_ID _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 + 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 + elif [ "$error_code" = "406" ]; then if [ -n "$SYNO_USE_TEMP_ADMIN" ]; then _err "Failed with unexcepted error, please report this by providing full log with '--debug 3'." else _err "Enforce auth with 2FA-OTP enabled, please configure the user to enable 2FA-OTP to continue." fi - elif [ "$error_code" == "400" ]; then + elif [ "$error_code" = "400" ]; then _err "Failed to authenticate, no such account or incorrect password." - elif [ "$error_code" == "401" ]; then + elif [ "$error_code" = "401" ]; then _err "Failed to authenticate with a non-existent account." - elif [ "$error_code" == "408" ] || [ "$error_code" == "409" ] || [ "$error_code" == "410" ]; then + elif [ "$error_code" = "408" ] || [ "$error_code" = "409" ] || [ "$error_code" = "410" ]; then _err "Failed to authenticate, the account password has expired or must be changed." else _err "Failed to authenticate with error: $error_code." From f5e7e6b2259e415b22e0098b5aec3a84ee681478 Mon Sep 17 00:00:00 2001 From: ACHMAD ALIF NASRULLOH <106044706+achmadalifn4@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:10:21 +0700 Subject: [PATCH 13/31] Merge pull request #7114 from achmadalifn4/master Add notify waha support --- notify/waha.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 notify/waha.sh diff --git a/notify/waha.sh b/notify/waha.sh new file mode 100755 index 00000000..989f57ac --- /dev/null +++ b/notify/waha.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env sh + +#Support WAHA (WhatsApp HTTP API) - free, self-hosted WhatsApp API +#https://waha.devlike.pro/ + +#Required: +#WAHA_URL="http://localhost:3000" +#WAHA_CHAT_ID="1234567890@c.us" + +#Optional: +#WAHA_API_KEY="" +#WAHA_SESSION="default" + +waha_send() { + _subject="$1" + _content="$2" + _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped + _debug "_subject" "$_subject" + _debug "_content" "$_content" + _debug "_statusCode" "$_statusCode" + + WAHA_URL="${WAHA_URL:-$(_readaccountconf_mutable WAHA_URL)}" + if [ -z "$WAHA_URL" ]; then + WAHA_URL="" + _err "You didn't specify the WAHA server url WAHA_URL yet." + _err "Example: export WAHA_URL=\"http://localhost:3000\"" + return 1 + fi + _saveaccountconf_mutable WAHA_URL "$WAHA_URL" + + WAHA_CHAT_ID="${WAHA_CHAT_ID:-$(_readaccountconf_mutable WAHA_CHAT_ID)}" + if [ -z "$WAHA_CHAT_ID" ]; then + WAHA_CHAT_ID="" + _err "You didn't specify the WhatsApp chat id WAHA_CHAT_ID yet." + _err "Example: export WAHA_CHAT_ID=\"1234567890@c.us\"" + return 1 + fi + _saveaccountconf_mutable WAHA_CHAT_ID "$WAHA_CHAT_ID" + + WAHA_API_KEY="${WAHA_API_KEY:-$(_readaccountconf_mutable WAHA_API_KEY)}" + if [ "$WAHA_API_KEY" ]; then + _saveaccountconf_mutable WAHA_API_KEY "$WAHA_API_KEY" + fi + + WAHA_SESSION="${WAHA_SESSION:-$(_readaccountconf_mutable WAHA_SESSION)}" + if [ -z "$WAHA_SESSION" ]; then + WAHA_SESSION="default" + else + _saveaccountconf_mutable WAHA_SESSION "$WAHA_SESSION" + fi + + _content=$(printf "*%s*\n%s" "$_subject" "$_content" | _json_encode) + + _data="{\"chatId\": \"$WAHA_CHAT_ID\", " + _data="$_data\"text\": \"$_content\", " + _data="$_data\"session\": \"$WAHA_SESSION\"}" + + _debug "_data" "$_data" + + export _H1="Content-Type: application/json" + if [ "$WAHA_API_KEY" ]; then + export _H2="X-Api-Key: $WAHA_API_KEY" + fi + + _waha_url="${WAHA_URL}/api/sendText" + response="$(_post "$_data" "$_waha_url" "" "POST" "application/json")" + + if [ "$?" = "0" ] && _contains "$response" "\"id\""; then + _info "waha send success." + return 0 + fi + _err "waha send error." + _err "$response" + return 1 +} From e828b285ad0c10fb811c7c77059ba233df372139 Mon Sep 17 00:00:00 2001 From: matthias-matze Date: Sun, 12 Jul 2026 10:13:42 +0200 Subject: [PATCH 14/31] Matthiasvpfr patch 1 (#7108) * Add files via upload * New Banner Updated README to include responsive images for dark and light modes. * Add files via upload Remove usage of jq and curl should be compliant with acme.sh api dev guide * Add files via upload * Add files via upload change CR LF to LF * Add files via upload missing CR * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload correct auth * Add files via upload better manage group_id * Add files via upload manage wrong / missing domain * Add files via upload strip domain to manage subdomain requests * Add files via upload fix wrong id selection in get root * Add files via upload fix parsing of IDs * Add files via upload correct get_root to better handle unexisting domaines (acmetest) * Add files via upload correct token incorrect in auth * Add files via upload manage case web api reply is empty * Add files via upload try to resolve error when adding unexisting subdmain txt entry * Add files via upload correct domain parsing * Add files via upload revert changes when adding record (name) change rm to better handle complex urls * Add files via upload correct rm function to better manage records deletion * Add files via upload ensure auth variable arent lost during calls * Add files via upload try to keep autj variables accross executions * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload * Add files via upload fix stripping subdomains * Add files via upload * Add files via upload keep auth token instead of requesting it each time * Add files via upload debug * Add files via upload * Add files via upload * Add files via upload better manage record deletion to avoid orphans added some debug and checks * Add files via upload paginated api support for rm function * Add files via upload * Add files via upload delay to manage api 404 * Add files via upload enhance parsing of records in rm * Add files via upload fix incorrect record detection in rm * Add files via upload better manage filters on api to limit errors * Add files via upload try to handle 404 when requesting API too frequently * Add files via upload * Add files via upload sleep during auth * Add files via upload manage 404 errors in get_root * Add files via upload correct variable overide translate comments prefix all variables * Add files via upload correct variable * Add files via upload typo * Add files via upload * Add files via upload * Add files via upload * Add files via upload correct variable naming (_comlaude prefix missing) correct rm return code when non existing record typo * Add files via upload log an info instead of an error if no dns record found in RM function. --------- Co-authored-by: neil Co-authored-by: Matthiasvpfr Co-authored-by: ZeroSSL-Andreas --- dnsapi/dns_comlaude.sh | 248 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 dnsapi/dns_comlaude.sh diff --git a/dnsapi/dns_comlaude.sh b/dnsapi/dns_comlaude.sh new file mode 100644 index 00000000..2aa2dba9 --- /dev/null +++ b/dnsapi/dns_comlaude.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env sh + +# shellcheck disable=SC2034 +dns_comlaude_info='comlaude.com +Site: comlaude.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_comlaude +Options: + COMLAUDE_USERNAME User account + COMLAUDE_PASSWORD User password + COMLAUDE_API_KEY generated API key + COMLAUDE_GROUP_ID Group ID in comlaude user profile + Get it from the https://www.comlaude.com +Issues: github.com/acmesh-official/acme.sh/issues/7112 +' +# ===== CONFIG ===== +COMLAUDE_API="https://api.comlaude.com" + +########## AUTH ########## + +_comlaude_auth() { + _debug "Checking cached ComLaude token" + + # Try to get token from account.conf + if [ -z "$COMLAUDE_ACCESS_TOKEN" ]; then + COMLAUDE_ACCESS_TOKEN="$(_readaccountconf_mutable COMLAUDE_ACCESS_TOKEN)" + COMLAUDE_TOKEN_EXPIRY="$(_readaccountconf_mutable COMLAUDE_TOKEN_EXPIRY)" + fi + + _now=$(_time) + if [ -n "$COMLAUDE_ACCESS_TOKEN" ] && [ -n "$COMLAUDE_TOKEN_EXPIRY" ] && [ "$_now" -lt "$COMLAUDE_TOKEN_EXPIRY" ]; then + _debug "Using cached ComLaude token (valid ${COMLAUDE_TOKEN_EXPIRY} > ${_now})" + return 0 + fi + + _info "ComLaude auth..." + _comlaude_body="{\"username\":\"$COMLAUDE_USERNAME\",\"password\":\"$COMLAUDE_PASSWORD\",\"api_key\":\"$COMLAUDE_API_KEY\"}" + _comlaude_response="$(_post "$_comlaude_body" "$COMLAUDE_API/api_login" "" "POST" "application/json")" + + if ! _contains "$_comlaude_response" "access_token"; then + _err "Auth failed: $_comlaude_response" + return 1 + fi + + COMLAUDE_ACCESS_TOKEN=$(echo "$_comlaude_response" | _egrep_o '"access_token":"[^"]*"' | cut -d'"' -f4) + # store expiracy from api reply l'API ("expires_in" in seconds) + _comlaude_expires_in=$(echo "$_comlaude_response" | _egrep_o '"expires_in":[0-9]*' | cut -d: -f2) + [ -z "$_comlaude_expires_in" ] && _comlaude_expires_in=3000 # fallback if no info + + COMLAUDE_TOKEN_EXPIRY=$(($(_time) + _comlaude_expires_in - 60)) # margin of 60s to secure renew + + _saveaccountconf_mutable COMLAUDE_ACCESS_TOKEN "$COMLAUDE_ACCESS_TOKEN" + _saveaccountconf_mutable COMLAUDE_TOKEN_EXPIRY "$COMLAUDE_TOKEN_EXPIRY" + + return 0 +} + +########## DOMAIN RESOLUTION ########## + +_comlaude_get_root() { + COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}" + if [ -z "$COMLAUDE_GROUP_ID" ]; then + _err "Missing COMLAUDE_GROUP_ID" + return 1 + fi + + _comlaude_input_domain="$1" + _comlaude_input_domain="${_comlaude_input_domain#_acme-challenge.}" + case "$_comlaude_input_domain" in + \*.*) _comlaude_input_domain="${_comlaude_input_domain#*.}" ;; + esac + + _debug "Normalized domain: $_comlaude_input_domain" + + _comlaude_i=1 + while true; do + _comlaude_d=$(printf "%s" "$_comlaude_input_domain" | cut -d . -f "$_comlaude_i-") + [ -z "$_comlaude_d" ] && { + _debug "No matching domain found for $_comlaude_input_domain" + return 1 + } + + # don't test unnecessary levels + # registered domain : TLD only (no dot after cut). + case "$_comlaude_d" in + *.*) : ;; + *) + _debug "Skipping bare TLD candidate: $_comlaude_d" + _comlaude_i=$((_comlaude_i + 1)) + continue + ;; + esac + + _debug "Checking domain: $_comlaude_d" + + _comlaude_retry=0 + _comlaude_max_retry=3 # to avoid network errors + _comlaude_DOM_ID="" + _comlaude_Z_ID="" + + while [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ]; do + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + _debug "Full URL: $COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/domains?filter[name]=$_comlaude_d&fields=id,name,active_zone" + _comlaude_response="$(_get "$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/domains?filter[name]=$_comlaude_d&fields=id,name,active_zone")" + _H1="" + + _debug "RAW response for $_comlaude_d (try $((_comlaude_retry + 1))): $_comlaude_response" + + # If empty -> true network issue, we retry + if [ -z "$_comlaude_response" ]; then + _comlaude_retry=$((_comlaude_retry + 1)) + [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ] && sleep 2 + continue + fi + + # 404 -> domain not found in that level. no retry : continue + if echo "$_comlaude_response" | grep -q '"status_code":404'; then + _debug "404 for $_comlaude_d, moving to next level (not retrying)" + break + fi + + # Domain missing (200 reply, data empty) -> continue + if echo "$_comlaude_response" | grep -q '"data":\[\]'; then + _debug "Empty data for $_comlaude_d, moving to next level" + break + fi + + # Extraction via _egrep_o + _comlaude_DOM_ID="$(echo "$_comlaude_response" | _egrep_o '"id":"[^"]*"' | head -n1 | cut -d':' -f2 | tr -d '"')" + _comlaude_Z_ID="$(echo "$_comlaude_response" | _egrep_o '"active_zone":\{"id":"[^"]*"' | _egrep_o '"id":"[^"]*"$' | cut -d':' -f2 | tr -d '"')" + + if [ -n "$_comlaude_DOM_ID" ] && [ -n "$_comlaude_Z_ID" ]; then + break + fi + + # 200 reply but malformed data / noid -> retry transport + _comlaude_retry=$((_comlaude_retry + 1)) + [ "$_comlaude_retry" -lt "$_comlaude_max_retry" ] && sleep 2 + done + + _debug "_comlaude_DOM_ID=$_comlaude_DOM_ID" + _debug "_comlaude_Z_ID=$_comlaude_Z_ID" + + if [ -n "$_comlaude_DOM_ID" ] && [ -n "$_comlaude_Z_ID" ]; then + _comlaude_domain="$_comlaude_d" + _comlaude_domain_id="$_comlaude_DOM_ID" + _comlaude_zone_id="$_comlaude_Z_ID" + return 0 + fi + + _comlaude_i=$((_comlaude_i + 1)) + done +} +########## ADD TXT ########## + +dns_comlaude_add() { + fulldomain="$1" + txtvalue="$2" + + COMLAUDE_USERNAME="${COMLAUDE_USERNAME:-$(_readaccountconf_mutable COMLAUDE_USERNAME)}" + COMLAUDE_PASSWORD="${COMLAUDE_PASSWORD:-$(_readaccountconf_mutable COMLAUDE_PASSWORD)}" + COMLAUDE_API_KEY="${COMLAUDE_API_KEY:-$(_readaccountconf_mutable COMLAUDE_API_KEY)}" + COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}" + + if [ -z "$COMLAUDE_USERNAME" ] || [ -z "$COMLAUDE_PASSWORD" ] || [ -z "$COMLAUDE_API_KEY" ]; then + _err "You didn't specify ComLaude credentials (COMLAUDE_USERNAME, COMLAUDE_PASSWORD, COMLAUDE_API_KEY)." + return 1 + fi + + # Backup variable after validation + _saveaccountconf_mutable COMLAUDE_USERNAME "$COMLAUDE_USERNAME" + _saveaccountconf_mutable COMLAUDE_PASSWORD "$COMLAUDE_PASSWORD" + _saveaccountconf_mutable COMLAUDE_API_KEY "$COMLAUDE_API_KEY" + _saveaccountconf_mutable COMLAUDE_GROUP_ID "$COMLAUDE_GROUP_ID" + + _info "Adding TXT: $fulldomain" + _comlaude_auth || return 1 + _comlaude_get_root "$fulldomain" || return 1 + + _debug "Root: $_comlaude_domain" + + _comlaude_data="{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"value\":\"$txtvalue\",\"ttl\":60}" + + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + export _H2="Content-Type: application/json" + + _comlaude_response="$(_post "$_comlaude_data" "$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records")" + + _H1="" + _H2="" + if ! echo "$_comlaude_response" | grep -q '"id"'; then + _err "Failed to create TXT" + _debug "$_comlaude_response" + return 1 + fi + + return 0 +} + +########## REMOVE TXT ########## + +dns_comlaude_rm() { + fulldomain="$1" + txtvalue="$2" + + COMLAUDE_USERNAME="${COMLAUDE_USERNAME:-$(_readaccountconf_mutable COMLAUDE_USERNAME)}" + COMLAUDE_PASSWORD="${COMLAUDE_PASSWORD:-$(_readaccountconf_mutable COMLAUDE_PASSWORD)}" + COMLAUDE_API_KEY="${COMLAUDE_API_KEY:-$(_readaccountconf_mutable COMLAUDE_API_KEY)}" + COMLAUDE_GROUP_ID="${COMLAUDE_GROUP_ID:-$(_readaccountconf_mutable COMLAUDE_GROUP_ID)}" + + _info "Removing TXT: $fulldomain" + + _comlaude_auth || return 1 + _comlaude_get_root "$fulldomain" || return 1 + + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + _comlaude_encoded_name="$(printf '%s' "$fulldomain" | _url_encode)" + _comlaude_encoded_value="$(printf '%s' "$txtvalue" | _url_encode)" + _comlaude_url="$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records?filter[type]=TXT&filter[name]=$_comlaude_encoded_name&filter[value]=$_comlaude_encoded_value" + _comlaude_response="$(_get "$_comlaude_url")" + _H1="" + + _debug "Filtered records response: $_comlaude_response" + + # first "id" top-level of reply (record itself, + # always on first position of each data[] object) + _comlaude_record_id="$(echo "$_comlaude_response" | _egrep_o '"data":\[\{"id":"[^"]*"' | _egrep_o '"[^"]*"$' | tr -d '"')" + + if [ -z "$_comlaude_record_id" ]; then + _info "No matching TXT record found to delete for $fulldomain / $txtvalue" + return 0 + fi + + _debug "Deleting record $_comlaude_record_id" + + export _H1="Authorization: Bearer $COMLAUDE_ACCESS_TOKEN" + _comlaude_del_url="$COMLAUDE_API/groups/$COMLAUDE_GROUP_ID/zones/$_comlaude_zone_id/records/$_comlaude_record_id" + _comlaude_del_resp="$(_post "" "$_comlaude_del_url" "" "DELETE")" + _H1="" + + if echo "$_comlaude_del_resp" | grep -q '"error"'; then + _err "Delete failed for $_comlaude_record_id" + _debug "$_comlaude_del_resp" + return 1 + fi + + _info "Deleted record $_comlaude_record_id" + return 0 +} From cd486cfbb9d88a4ca4d173ba90f012d41e7a0564 Mon Sep 17 00:00:00 2001 From: Steven Qiu Date: Sun, 12 Jul 2026 16:15:59 +0800 Subject: [PATCH 15/31] Add Baidu Cloud CDN deploy hook (#6951) * add Baidu Cloud CDN deploy hook Code generated by GitHub Copilot with Claude Sonnet 4.6. Tested with local environment by human. * inline functions Code generated by OpenAI Codex with GPT-5.5 Sol. Tested with local environment by human. --- deploy/baidu_cdn.sh | 222 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 deploy/baidu_cdn.sh diff --git a/deploy/baidu_cdn.sh b/deploy/baidu_cdn.sh new file mode 100644 index 00000000..7fe31f9b --- /dev/null +++ b/deploy/baidu_cdn.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034,SC2154 + +# Deploy hook: Baidu Cloud CDN +# +# Code generated by GitHub Copilot with Claude Sonnet 4.6 and OpenAI Codex with GPT-5.6 Sol +# +# API Doc: https://cloud.baidu.com/doc/CDN/s/Zkna2r57w +# +# Uses the same credential variables as dnsapi/dns_baidu.sh: +# export Baidu_AK="your-access-key-id" +# export Baidu_SK="your-secret-access-key" +# +# To deploy to a CDN domain different from the certificate CN +# (e.g. wildcard or multi-domain certs): +# export DEPLOY_BAIDU_CDN_DOMAIN="cdn.example.com" +# +# Multiple CDN domains sharing the same certificate: +# export DEPLOY_BAIDU_CDN_DOMAIN="cdn1.example.com cdn2.example.com" + +BAIDU_CDN_HOST="cdn.baidubce.com" +_BAIDU_CDN_BCE_AUTH_RESULT="" + +baidu_cdn_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + if ! _baidu_cdn_load_credentials; then + return 1 + fi + + _getdeployconf DEPLOY_BAIDU_CDN_DOMAIN + if [ "$DEPLOY_BAIDU_CDN_DOMAIN" ]; then + _savedeployconf DEPLOY_BAIDU_CDN_DOMAIN "$DEPLOY_BAIDU_CDN_DOMAIN" + else + DEPLOY_BAIDU_CDN_DOMAIN="$_cdomain" + fi + + # Build JSON "domains" array from space-separated domain list + _domains_json="" + for _d in $DEPLOY_BAIDU_CDN_DOMAIN; do + _d_e="$(_baidu_cdn_json_escape "$_d")" + if [ -z "$_domains_json" ]; then + _domains_json="\"${_d_e}\"" + else + _domains_json="${_domains_json},\"${_d_e}\"" + fi + done + + # Build a valid cert name: must start with a letter, allow [A-Za-z0-9-/.], max 65 chars + _cert_name="$(printf "%s" "$_cdomain" | sed 's/\*\./wildcard./g;s/[^A-Za-z0-9./]/-/g' | cut -c 1-65)" + case "$_cert_name" in + [A-Za-z]*) ;; + *) _cert_name="c${_cert_name}" ;; + esac + + # PEM content is already Base64 inside the -----BEGIN/END----- wrappers. + # The API expects the raw PEM as a JSON string, so newlines must be escaped as \n. + _cert_pem="$(sed 's/$/\\n/' "$_cfullchain" | tr -d '\n')" + _key_pem="$(sed 's/$/\\n/' "$_ckey" | tr -d '\n')" + + _debug2 _cert_name "$_cert_name" + _debug2 _domains_json "[$_domains_json]" + + # Build JSON payload + _payload="{\"domains\":[${_domains_json}],\"certificate\":{\"certName\":\"${_cert_name}\",\"certServerData\":\"${_cert_pem}\",\"certPrivateData\":\"${_key_pem}\"}}" + + # Generate BCE v1 authorization header (query string included in canonical request) + _cdn_path="/v2/domain/certificate" + _cdn_query="action=put" + _ts="$(_utc_date | sed 's/ /T/')Z" + _content_type="application/json; charset=utf-8" + _payload_hash="$(printf "%s" "$_payload" | _digest sha256 hex)" + + if ! _baidu_cdn_bce_auth "POST" "$_cdn_path" "$_cdn_query" "$BAIDU_CDN_HOST" "$_ts" "3600" "$_content_type" "$_payload_hash"; then + _err "Failed to sign request" + return 1 + fi + + _H1="Authorization: $_BAIDU_CDN_BCE_AUTH_RESULT" + _H2="x-bce-date: $_ts" + _H3="x-bce-content-sha256: $_payload_hash" + _H4="Host: $BAIDU_CDN_HOST" + _H5="" + + _url="https://${BAIDU_CDN_HOST}${_cdn_path}?${_cdn_query}" + response="$(_post "$_payload" "$_url" "" "POST" "$_content_type")" + if [ "$?" != "0" ]; then + _err "Failed to call Baidu Cloud CDN API" + return 1 + fi + + _debug2 response "$response" + + if _contains "$response" "\"certId\""; then + _info "Certificate deployed to Baidu Cloud CDN for: $DEPLOY_BAIDU_CDN_DOMAIN" + return 0 + fi + + _err "Failed to deploy certificate to Baidu Cloud CDN: $response" + return 1 +} + +# BCE v1 signing with canonical query string support. +# The CDN endpoint uses ?action=put so it must be included in the canonical request. +_baidu_cdn_bce_auth() { + _method="$1" + _uri="$2" + _query="$3" + _host="$4" + _ts="$5" + _expire="$6" + _ct="$7" + _payload_hash="$8" + + _BAIDU_CDN_BCE_AUTH_RESULT="" + + _auth_prefix="bce-auth-v1/${Baidu_AK}/${_ts}/${_expire}" + _signed_headers="content-type;host;x-bce-content-sha256;x-bce-date" + _canonical_uri="$(_baidu_cdn_bce_encode_path "$_uri")" + + _host_e="$(printf "%s" "$_host" | _url_encode upper-hex)" + _date_e="$(printf "%s" "$_ts" | _url_encode upper-hex)" + _ct_e="$(printf "%s" "$_ct" | _url_encode upper-hex)" + _hash_e="$(printf "%s" "$_payload_hash" | _url_encode upper-hex)" + + _canonical_headers="content-type:${_ct_e} +host:${_host_e} +x-bce-content-sha256:${_hash_e} +x-bce-date:${_date_e}" + + _canonical_request="${_method} +${_canonical_uri} +${_query} +${_canonical_headers}" + + _sk_hex="$(printf "%s" "$Baidu_SK" | _hex_dump | tr -d " ")" + _signing_key="$(_baidu_cdn_hmac_sha256_hexkey "$_sk_hex" "$_auth_prefix")" + _signing_key_hex="$(printf "%s" "$_signing_key" | _hex_dump | tr -d " ")" + _signature="$(_baidu_cdn_hmac_sha256_hexkey "$_signing_key_hex" "$_canonical_request")" + + _BAIDU_CDN_BCE_AUTH_RESULT="${_auth_prefix}/${_signed_headers}/${_signature}" +} + +_baidu_cdn_load_credentials() { + Baidu_AK="${Baidu_AK:-$(_readaccountconf_mutable Baidu_AK)}" + Baidu_SK="${Baidu_SK:-$(_readaccountconf_mutable Baidu_SK)}" + + Baidu_AK="$(_baidu_cdn_trim_ws "$Baidu_AK")" + Baidu_SK="$(_baidu_cdn_trim_ws "$Baidu_SK")" + + if [ -z "$Baidu_AK" ] || [ -z "$Baidu_SK" ]; then + _err "Baidu_AK and Baidu_SK are required" + return 1 + fi + + _saveaccountconf_mutable Baidu_AK "$Baidu_AK" + _saveaccountconf_mutable Baidu_SK "$Baidu_SK" + + return 0 +} + +_baidu_cdn_bce_encode_path() { + _p="$1" + _out="" + if [ "${_p#"/"}" != "$_p" ]; then + _out="/" + fi + + _rest="${_p#/}" + while [ -n "$_rest" ]; do + _seg="${_rest%%/*}" + if [ "$_seg" ]; then + if [ -z "$_out" ] || [ "$_out" = "/" ]; then + _out="${_out}$(printf "%s" "$_seg" | _url_encode upper-hex)" + else + _out="${_out}/$(printf "%s" "$_seg" | _url_encode upper-hex)" + fi + fi + if [ "${_rest#*/}" = "$_rest" ]; then + break + fi + _rest="${_rest#*/}" + done + + if [ -z "$_out" ]; then + _out="/" + fi + printf "%s" "$_out" +} + +_baidu_cdn_trim_ws() { + printf "%s" "$1" | tr '\r\n\t' ' ' | tr -s ' ' | sed 's/^ *//;s/ *$//' +} + +_baidu_cdn_json_escape() { + _s="$1" + _s="$(printf "%s" "$_s" | tr -d '\r\n')" + printf "%s" "$_s" | + sed 's/\\/\\\\/g; s/ /\\t/g' | + _baidu_cdn_json_encode +} + +_baidu_cdn_json_encode() { + _j_str="$(sed 's/"/\\"/g' | sed "s/\r/\\r/g")" + printf "%s" "$_j_str" | _hex_dump | _lower_case | sed 's/0a/5c 6e/g' | tr -d ' ' | _h2b | tr -d "\r\n" +} + +_baidu_cdn_hmac_sha256_hexkey() { + _key_hex="$1" + _msg="$2" + printf "%s" "$_msg" | _hmac sha256 "$_key_hex" hex +} From dd6540ce46a52f94d4ec37fa4e46cf5e717e3ad2 Mon Sep 17 00:00:00 2001 From: amk1969 <32023825+amk1969@users.noreply.github.com> Date: Sun, 12 Jul 2026 10:21:00 +0200 Subject: [PATCH 16/31] dns_ovh specific permission for record removal (#6386) Co-authored-by: amk --- dnsapi/dns_ovh.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_ovh.sh b/dnsapi/dns_ovh.sh index 9f2cd23f..df2b184d 100755 --- a/dnsapi/dns_ovh.sh +++ b/dnsapi/dns_ovh.sh @@ -224,7 +224,7 @@ _ovh_authentication() { _H3="" _H4="" - _ovhdata='{"accessRules": [{"method": "GET","path": "/auth/time"},{"method": "GET","path": "/domain"},{"method": "GET","path": "/domain/zone/*"},{"method": "GET","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/refresh"},{"method": "PUT","path": "/domain/zone/*/record/*"},{"method": "DELETE","path": "/domain/zone/*/record/*"}],"redirection":"'$ovh_success'"}' + _ovhdata='{"accessRules": [{"method": "GET","path": "/auth/time"},{"method": "GET","path": "/domain"},{"method": "GET","path": "/domain/zone/*"},{"method": "GET","path": "/domain/zone/*/record"},{"method": "GET","path": "/domain/zone/*/record/*"},{"method": "POST","path": "/domain/zone/*/record"},{"method": "POST","path": "/domain/zone/*/refresh"},{"method": "PUT","path": "/domain/zone/*/record/*"},{"method": "DELETE","path": "/domain/zone/*/record/*"}],"redirection":"'$ovh_success'"}' response="$(_post "$_ovhdata" "$OVH_API/auth/credential")" _debug3 response "$response" From 5a8c685fd3e39b958cad84004ba495467b4f53ca Mon Sep 17 00:00:00 2001 From: Kat Crichton-Seager Date: Sun, 12 Jul 2026 09:37:21 +0100 Subject: [PATCH 17/31] fix: dnsexit api rejects a TTL of zero, changed to 1 (minute) (#7107) --- dnsapi/dns_dnsexit.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh index 6b10891c..b92482cf 100644 --- a/dnsapi/dns_dnsexit.sh +++ b/dnsapi/dns_dnsexit.sh @@ -25,7 +25,7 @@ dns_dnsexit_add() { return 1 fi - _dnsexit_zone_op add ',"ttl":0,"overwrite":false' + _dnsexit_zone_op add ',"ttl":1,"overwrite":false' } #Usage: fulldomain txtvalue From 447dc3c7e7e8fa45337980e2ad13e0185c1be4ff Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 17:21:03 +0800 Subject: [PATCH 18/31] dns_ali: convert IDN domains to punycode The Aliyun API only accepts punycode domain names, and a raw UTF-8 domain also breaks the request signature. Same _idn pattern as dns_namecom. https://github.com/acmesh-official/acme.sh/issues/4733 --- dnsapi/dns_ali.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh index 62e54e0c..b8ca9169 100755 --- a/dnsapi/dns_ali.sh +++ b/dnsapi/dns_ali.sh @@ -18,7 +18,9 @@ Ali_DNS_API="https://alidns.aliyuncs.com/" #Usage: dns_ali_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" dns_ali_add() { - fulldomain=$1 + # the API only accepts punycode for IDN domains, and a raw UTF-8 domain + # also breaks the request signature (issue 4733) + fulldomain=$(_idn "$1") txtvalue=$2 _prepare_ali_credentials || return 1 @@ -33,7 +35,7 @@ dns_ali_add() { } dns_ali_rm() { - fulldomain=$1 + fulldomain=$(_idn "$1") txtvalue=$2 Ali_Key="${Ali_Key:-$(_readaccountconf_mutable Ali_Key)}" Ali_Secret="${Ali_Secret:-$(_readaccountconf_mutable Ali_Secret)}" From 15a1067f1b0bc36c803c11e80ca965cf0e3e15d0 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 17:34:37 +0800 Subject: [PATCH 19/31] update-account: persist the new email into the CA conf "--update-account -m new@example.com" updated the contact on the CA but never saved it locally, so CA_EMAIL kept showing the old address on every later run. Save it in the success path like _regAccount does. https://github.com/acmesh-official/acme.sh/issues/4673 --- acme.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/acme.sh b/acme.sh index 00ec7ef4..85dccda6 100755 --- a/acme.sh +++ b/acme.sh @@ -4179,6 +4179,12 @@ updateaccount() { if [ "$code" = '200' ]; then echo "$response" >"$ACCOUNT_JSON_PATH" _info "Account update success for $_accUri." + # persist the effective mailbox like _regAccount does; otherwise + # "--update-account -m new@..." updates the CA but the local conf + # keeps showing the old address (issue 4673) + if [ "$_email" ]; then + _savecaconf "CA_EMAIL" "$_email" + fi ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)" _info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT" From b9ce911eb16de4648670ad7947a8f565e24479d8 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:21:40 +0800 Subject: [PATCH 20/31] installcronjob: never wipe existing cron jobs when crontab -l fails Piping a failed 'crontab -l' straight back into 'crontab -' replaced the whole crontab with just the acme.sh entry when the listing failed while jobs existed (seen on cPanel/CloudLinux jailshell). Capture the listing first and refuse to write unless the failure is the normal "no crontab for user" case. https://github.com/acmesh-official/acme.sh/issues/3079 --- acme.sh | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/acme.sh b/acme.sh index 85dccda6..0b0f3ce9 100755 --- a/acme.sh +++ b/acme.sh @@ -7003,15 +7003,35 @@ installcronjob() { return 1 fi _info "Installing cron job" - if ! $_CRONTAB -l 2>/dev/null | grep "$PROJECT_ENTRY --cron"; then + _cron_entry="$random_minute $random_hour,$(_math "$random_hour" + 6),$(_math "$random_hour" + 12),$(_math "$random_hour" + 18) * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" + _cron_entries="$($_CRONTAB -l 2>/dev/null)" + if [ "$?" != "0" ]; then + #when the user has no crontab yet, crontab -l also exits non-zero; + #only that case may proceed with an empty list. Any other listing + #failure must abort: piping an incomplete list back into 'crontab -' + #would wipe the user's existing cron jobs (issue 3079) + _cron_list_err="$($_CRONTAB -l 2>&1 >/dev/null)" + if echo "$_cron_list_err" | grep -i "no crontab\|no fcrontab\|can't open" >/dev/null; then + _cron_entries="" + else + _err "Can not list the current cron jobs: $_cron_list_err" + _err "Refusing to install the cron job, that could wipe your existing cron jobs." + _err "Please add this cron job manually:" + _err "$_cron_entry" + return 1 + fi + fi + if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron"; then if _exists uname && uname -a | grep SunOS >/dev/null; then _CRONTAB_STDIN="$_CRONTAB --" else _CRONTAB_STDIN="$_CRONTAB -" fi - $_CRONTAB -l 2>/dev/null | { - cat - echo "$random_minute $random_hour,$(_math $random_hour + 6),$(_math $random_hour + 12),$(_math $random_hour + 18) * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" + { + if [ "$_cron_entries" ]; then + echo "$_cron_entries" + fi + echo "$_cron_entry" } | $_CRONTAB_STDIN fi if [ "$?" != "0" ]; then From a6766d41868ce4aa991860a93f077eb8d460b9a9 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:21:48 +0800 Subject: [PATCH 21/31] dns_dnsimple: use mutable conf storage so a newly exported token wins The legacy plain _saveaccountconf copy in account.conf is sourced at startup and silently overrides a newly exported DNSimple_OAUTH_TOKEN, so rotated tokens never took effect. fixes https://github.com/acmesh-official/acme.sh/issues/3422 --- dnsapi/dns_dnsimple.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh index e262239a..257549b4 100644 --- a/dnsapi/dns_dnsimple.sh +++ b/dnsapi/dns_dnsimple.sh @@ -18,6 +18,7 @@ dns_dnsimple_add() { fulldomain=$1 txtvalue=$2 + DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}" if [ -z "$DNSimple_OAUTH_TOKEN" ]; then DNSimple_OAUTH_TOKEN="" _err "You have not set the dnsimple oauth token yet." @@ -26,7 +27,7 @@ dns_dnsimple_add() { fi # save the oauth token for later - _saveaccountconf DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN" + _saveaccountconf_mutable DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN" if ! _get_account_id; then _err "failed to retrieve account id" @@ -57,6 +58,12 @@ dns_dnsimple_add() { dns_dnsimple_rm() { fulldomain=$1 + DNSimple_OAUTH_TOKEN="${DNSimple_OAUTH_TOKEN:-$(_readaccountconf_mutable DNSimple_OAUTH_TOKEN)}" + if [ -z "$DNSimple_OAUTH_TOKEN" ]; then + _err "You have not set the dnsimple oauth token yet." + return 1 + fi + if ! _get_account_id; then _err "failed to retrieve account id" return 1 @@ -123,9 +130,9 @@ _get_root() { # returns _account_id _get_account_id() { - DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf DNSimple_ACCOUNT_ID)}" + DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf_mutable DNSimple_ACCOUNT_ID)}" if [ "$DNSimple_ACCOUNT_ID" ]; then - _saveaccountconf DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID" + _saveaccountconf_mutable DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID" _account_id="$DNSimple_ACCOUNT_ID" _debug _account_id "$_account_id" return 0 From 3fddea296207de9754727ebb5349ec50b0e8b176 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:21:56 +0800 Subject: [PATCH 22/31] dns_dynu: use mutable conf storage so newly exported credentials win Same stale-account.conf override as dns_dnsimple: the sourced plain copy shadowed newly exported Dynu_ClientId/Dynu_Secret. https://github.com/acmesh-official/acme.sh/issues/3216 --- dnsapi/dns_dynu.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/dnsapi/dns_dynu.sh b/dnsapi/dns_dynu.sh index 612756c5..3ac5c2f1 100644 --- a/dnsapi/dns_dynu.sh +++ b/dnsapi/dns_dynu.sh @@ -23,6 +23,8 @@ dns_dynu_add() { fulldomain=$1 txtvalue=$2 + Dynu_ClientId="${Dynu_ClientId:-$(_readaccountconf_mutable Dynu_ClientId)}" + Dynu_Secret="${Dynu_Secret:-$(_readaccountconf_mutable Dynu_Secret)}" if [ -z "$Dynu_ClientId" ] || [ -z "$Dynu_Secret" ]; then Dynu_ClientId="" Dynu_Secret="" @@ -32,8 +34,8 @@ dns_dynu_add() { fi #save the client id and secret to the account conf file. - _saveaccountconf Dynu_ClientId "$Dynu_ClientId" - _saveaccountconf Dynu_Secret "$Dynu_Secret" + _saveaccountconf_mutable Dynu_ClientId "$Dynu_ClientId" + _saveaccountconf_mutable Dynu_Secret "$Dynu_Secret" if [ -z "$Dynu_Token" ]; then _info "Getting Dynu token." @@ -69,6 +71,8 @@ dns_dynu_rm() { fulldomain=$1 txtvalue=$2 + Dynu_ClientId="${Dynu_ClientId:-$(_readaccountconf_mutable Dynu_ClientId)}" + Dynu_Secret="${Dynu_Secret:-$(_readaccountconf_mutable Dynu_Secret)}" if [ -z "$Dynu_ClientId" ] || [ -z "$Dynu_Secret" ]; then Dynu_ClientId="" Dynu_Secret="" @@ -78,8 +82,8 @@ dns_dynu_rm() { fi #save the client id and secret to the account conf file. - _saveaccountconf Dynu_ClientId "$Dynu_ClientId" - _saveaccountconf Dynu_Secret "$Dynu_Secret" + _saveaccountconf_mutable Dynu_ClientId "$Dynu_ClientId" + _saveaccountconf_mutable Dynu_Secret "$Dynu_Secret" if [ -z "$Dynu_Token" ]; then _info "Getting Dynu token." From 5e33e9f5f170a49a269ddcff9c50ab226ae88d4f Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 19:22:03 +0800 Subject: [PATCH 23/31] dns_knot: add KNOT_ZONE for delegated subdomain zones The zone cannot be derived from the record name when the Knot server is only authoritative for a delegated subdomain; let the user name it explicitly, like NSUPDATE_ZONE. fixes https://github.com/acmesh-official/acme.sh/issues/2881 --- dnsapi/dns_knot.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/dnsapi/dns_knot.sh b/dnsapi/dns_knot.sh index b5ba32f0..2b6d8ef4 100644 --- a/dnsapi/dns_knot.sh +++ b/dnsapi/dns_knot.sh @@ -6,6 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_knot Options: KNOT_SERVER Server hostname. Default: "localhost". KNOT_KEY TSIG key data, not a file path. knsupdate "key" statement format: "[alg:]name secret". E.g. "hmac-sha256:acme_key BASE64SECRET=" + KNOT_ZONE Zone name. Optional, set it when the challenge record lives in a delegated subdomain zone. Default: the parent domain of the challenge record. ' # See also dns_nsupdate.sh @@ -21,6 +22,9 @@ dns_knot_add() { # save the dns server and key to the account.conf file. _saveaccountconf KNOT_SERVER "${KNOT_SERVER}" _saveaccountconf KNOT_KEY "${KNOT_KEY}" + if [ -n "${KNOT_ZONE}" ]; then + _saveaccountconf KNOT_ZONE "${KNOT_ZONE}" + fi if ! _get_root "$fulldomain"; then _err "Domain does not exist." @@ -84,6 +88,13 @@ EOF # _domain=domain.com _get_root() { domain=$1 + # a delegated subdomain zone cannot be derived from the record name; + # let the user name the zone explicitly (issue 2881) + if [ -n "${KNOT_ZONE}" ]; then + _domain="${KNOT_ZONE%.}" + _debug "Using KNOT_ZONE zone" "${_domain}" + return 0 + fi i="$(echo "$fulldomain" | tr '.' ' ' | wc -w)" i=$(_math "$i" - 1) From d621d6952aa1c815658bacd0aa2af44b4136d708 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 20:18:36 +0800 Subject: [PATCH 24/31] fix --- acme.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 0b0f3ce9..ecea7bc2 100755 --- a/acme.sh +++ b/acme.sh @@ -7011,7 +7011,9 @@ installcronjob() { #failure must abort: piping an incomplete list back into 'crontab -' #would wipe the user's existing cron jobs (issue 3079) _cron_list_err="$($_CRONTAB -l 2>&1 >/dev/null)" - if echo "$_cron_list_err" | grep -i "no crontab\|no fcrontab\|can't open" >/dev/null; then + #multiple -e instead of \| : BRE alternation is a GNU extension that + #BSD grep does not support + if echo "$_cron_list_err" | grep -i -e "no crontab" -e "no fcrontab" -e "can't open" >/dev/null; then _cron_entries="" else _err "Can not list the current cron jobs: $_cron_list_err" From 17964cfd6ef177988e07bd34a9f0c0388cd597db Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 12 Jul 2026 22:28:56 +0800 Subject: [PATCH 25/31] installcronjob: Solaris grep takes only one -e, use separate greps The "no crontab" whitelist used multiple -e patterns, but Solaris /usr/bin/grep honors only a single -e, so a fresh install was refused there. Use one plain grep per message pattern, which every grep implementation supports (caught by le_test_installcronjob_no_wipe on the Solaris CI). --- acme.sh | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index ecea7bc2..4615543b 100755 --- a/acme.sh +++ b/acme.sh @@ -7011,9 +7011,11 @@ installcronjob() { #failure must abort: piping an incomplete list back into 'crontab -' #would wipe the user's existing cron jobs (issue 3079) _cron_list_err="$($_CRONTAB -l 2>&1 >/dev/null)" - #multiple -e instead of \| : BRE alternation is a GNU extension that - #BSD grep does not support - if echo "$_cron_list_err" | grep -i -e "no crontab" -e "no fcrontab" -e "can't open" >/dev/null; then + #separate greps: BRE alternation \| is a GNU extension and Solaris + #grep takes only a single -e pattern + if echo "$_cron_list_err" | grep -i "no crontab" >/dev/null || + echo "$_cron_list_err" | grep -i "no fcrontab" >/dev/null || + echo "$_cron_list_err" | grep -i "can't open" >/dev/null; then _cron_entries="" else _err "Can not list the current cron jobs: $_cron_list_err" From 020123d812e15fcce07d174e1ad74637c7c56c75 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 13 Jul 2026 08:30:43 +0800 Subject: [PATCH 26/31] dns_infomaniak: log zones response and fail early in _get_zone The sed in _get_zone passed the raw JSON through when the response contained no [{"fqdn":", so an API error turned the zone into "{" and the failure only surfaced later as POST /2/zones/{/records "method_not_found". Log the response at debug2, error out on non-success results, and parse fqdn position-independently. https://github.com/acmesh-official/acme.sh/issues/6851 --- dnsapi/dns_infomaniak.sh | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh index 43ade8ce..6cc8a4a0 100755 --- a/dnsapi/dns_infomaniak.sh +++ b/dnsapi/dns_infomaniak.sh @@ -182,7 +182,11 @@ dns_infomaniak_rm() { _get_zone() { domain="$1" # Whatever the domain is, you can get the fqdn with the following. - # shellcheck disable=SC1004 - response=$(_get "${INFOMANIAK_API_URL}/2/domains/${domain}/zones" | sed 's/.*\[{"fqdn"\:"\(.*\)/\1/') - echo "${response%%\"*}" + response=$(_get "${INFOMANIAK_API_URL}/2/domains/${domain}/zones") + _debug2 "_get_zone response" "$response" + if ! _contains "$response" '"result":"success"'; then + _err "cannot get zones for ${domain}, response: ${response}" + return 1 + fi + echo "$response" | _egrep_o '"fqdn" *: *"[^"]*"' | _head_n 1 | cut -d '"' -f 4 } From 1a54307dbf3e8a1bebb3ddf3bf4efcf964b7557c Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 13 Jul 2026 10:43:08 +0800 Subject: [PATCH 27/31] account: keep restored ACCOUNT_URL in the EAB-already-bound path When re-registering an already-bound EAB account (HTTP 400 "not awaiting external account binding"), the else branch restored ACCOUNT_URL from ca.conf but the following unconditional `export ACCOUNT_URL="$_accUri"` clobbered it with an empty _accUri (never set on that path), so later signed requests failed with "A Key ID MUST be specified" / "account URL is empty". Assign the restored value to _accUri so the shared export uses it. https://github.com/acmesh-official/acme.sh/issues/3382 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 4615543b..b981b54e 100755 --- a/acme.sh +++ b/acme.sh @@ -4125,7 +4125,7 @@ _regAccount() { fi _savecaconf "ACCOUNT_URL" "$_accUri" else - ACCOUNT_URL="$(_readcaconf ACCOUNT_URL)" + _accUri="$(_readcaconf ACCOUNT_URL)" fi export ACCOUNT_URL="$_accUri" From 4558a8aa98d444db2dbbffdef38ab478b4229d09 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 13 Jul 2026 11:59:35 +0800 Subject: [PATCH 28/31] challenge: use portable [{] literal-brace in _egrep_o patterns (#968) _egrep_o falls back to a BRE sed expression on shells without egrep -o (Solaris, DD-WRT busybox). A bare "\{" there is a BRE interval operator and aborts ("sed: command garbled" / "Invalid content of \{\}"), so the challenge-status-invalid path extracted an empty error object and the CA's failure reason was lost. Replace the escaped braces with "[{]"/"[}]" bracket expressions, unambiguous literal braces in both BRE and ERE, at all four call sites (challenge type/error extraction and profiles). --- acme.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/acme.sh b/acme.sh index b981b54e..b53601ee 100755 --- a/acme.sh +++ b/acme.sh @@ -5304,7 +5304,7 @@ $_authorizations_map" fi # Fix for empty error objects in response which mess up the original code, adapted from fix suggested here: https://github.com/acmesh-official/acme.sh/issues/4933#issuecomment-1870499018 - entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')" + entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^{]*"type":"'$vtype'"[^}]*')" _debug entry "$entry" if [ -z "$keyauthorization" -a -z "$entry" ]; then @@ -5636,7 +5636,7 @@ $_authorizations_map" status=$(echo "$response" | _egrep_o '"status":"[^"]*' | cut -d : -f 2 | tr -d '"') _debug2 status "$status" if _contains "$status" "invalid"; then - error="$(echo "$response" | _egrep_o '"error":\{[^\}]*')" + error="$(echo "$response" | _egrep_o '"error":[{][^}]*')" _debug2 error "$error" errordetail="$(echo "$error" | _egrep_o '"detail": *"[^"]*' | cut -d '"' -f 4)" _debug2 errordetail "$errordetail" @@ -6581,7 +6581,7 @@ list_profiles() { fi normalized_response=$(echo "$response" | _normalizeJson) - profiles_json=$(echo "$normalized_response" | _egrep_o '"profiles" *: *\{[^\}]*\}') + profiles_json=$(echo "$normalized_response" | _egrep_o '"profiles" *: *[{][^}]*[}]') if [ -z "$profiles_json" ]; then _info "The CA '$_l_server_name' does not publish certificate profiles via its directory endpoint." @@ -7264,7 +7264,7 @@ _deactivate() { _debug "Trigger validation." vtype="$(_getIdType "$_d_domain")" # Fix for empty error objects in response which mess up the original code, adapted from fix suggested here: https://github.com/acmesh-official/acme.sh/issues/4933#issuecomment-1870499018 - entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^\{]*"type":"'$vtype'"[^\}]*')" + entry="$(echo "$response" | sed s/'"error":{}'/'"error":null'/ | _egrep_o '[^{]*"type":"'$vtype'"[^}]*')" _debug entry "$entry" if [ -z "$entry" ]; then _err "$d: Cannot get domain token" From 1a746d98b8d282435450446aae424df53616c39a Mon Sep 17 00:00:00 2001 From: NotAnotherHelloWorld Date: Tue, 14 Jul 2026 03:02:34 +0200 Subject: [PATCH 29/31] Deploy certificate to FortiGate firewall using API (#6236) * Deploy certificate to FortiGate firewall using API * Refactor FortiGate deployment functions Prefix private functions and working variables and use a timestamped certificate name. * Replace grep -o for POSIX compatibility --- deploy/fortigate.sh | 175 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 deploy/fortigate.sh diff --git a/deploy/fortigate.sh b/deploy/fortigate.sh new file mode 100644 index 00000000..f00ca1cb --- /dev/null +++ b/deploy/fortigate.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env sh +# Script to deploy a certificate to FortiGate via API and set it as the current web GUI certificate. +# +# FortiGate's native ACME integration does not support wildcard certificates or domain validation, +# and is not supported if you have a custom management web port (eg. DNAT web traffic). +# +# REQUIRED: +# export FGT_HOST="fortigate_hostname-or-ip" +# export FGT_TOKEN="fortigate_api_token" +# +# OPTIONAL: +# export FGT_PORT="10443" # Custom HTTPS port (defaults to 443 if not set) +# +# Run `acme.sh --deploy -d example.com --deploy-hook fortigate --insecure` to use this script. +# `--insecure` is required on first run if not already using a valid SSL certificate on firewall. + +# Function to parse a FortiGate API response +_fortigate_parse_response() { + _fortigate_response="$1" + _fortigate_func="$2" + _fortigate_status=$(echo "$_fortigate_response" | _egrep_o '"status":[ ]*"[^"]*"' | cut -d '"' -f 4) + + if [ "$_fortigate_status" != "success" ]; then + _err "[$_fortigate_func] Operation failed. Deploy with --insecure if current certificate is invalid. Try deploying with --debug to troubleshoot." + return 1 + fi + + _debug "[$_fortigate_func] Operation successful." + return 0 +} + +# Function to deploy a base64-encoded certificate to the firewall +_fortigate_deployer() { + _fortigate_cert_base64=$(_base64 <"$_fortigate_cfullchain" | tr -d '\n') + _fortigate_key_base64=$(_base64 <"$_fortigate_ckey" | tr -d '\n') + _fortigate_payload=$( + cat < Date: Tue, 14 Jul 2026 22:58:47 +1000 Subject: [PATCH 30/31] fix(dns_oci): read ~/.oci/config before cached account.conf values (#7124) The OCI DNS plugin cached the tenancy, user, region and signing key into acme.sh's account.conf at issuance and then, on subsequent runs, read those cached values before consulting ~/.oci/config. A value cached at issuance therefore permanently shadowed the config file, so editing ~/.oci/config afterwards (most visibly rotating the API signing key) had no effect and renewals kept using stale credentials, failing authentication. Resolve each field in the order: explicit environment variable, then ~/.oci/config when it exists, then the cached account.conf value as a fallback for env-only installs that have no config file. The signing key likewise prefers the key_file resolved from the environment or ~/.oci/config over any cached key. Signed-off-by: Avi Miller --- dnsapi/dns_oci.sh | 53 +++++++++++++++++++++++++++-------------------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/dnsapi/dns_oci.sh b/dnsapi/dns_oci.sh index c76a4565..e1aa3dd9 100644 --- a/dnsapi/dns_oci.sh +++ b/dnsapi/dns_oci.sh @@ -115,12 +115,15 @@ _oci_config() { _clearaccountconf_mutable OCI_CLI_PROFILE fi - OCI_CLI_TENANCY="${OCI_CLI_TENANCY:-$(_readaccountconf_mutable OCI_CLI_TENANCY)}" + if [ -z "$OCI_CLI_TENANCY" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + _debug "Reading OCI_CLI_TENANCY value from: $OCI_CLI_CONFIG_FILE" + OCI_CLI_TENANCY=$(_readini "$OCI_CLI_CONFIG_FILE" tenancy "$OCI_CLI_PROFILE") + fi + if [ -z "$OCI_CLI_TENANCY" ]; then + OCI_CLI_TENANCY=$(_readaccountconf_mutable OCI_CLI_TENANCY) + fi if [ "$OCI_CLI_TENANCY" ]; then _saveaccountconf_mutable OCI_CLI_TENANCY "$OCI_CLI_TENANCY" - elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then - _debug "Reading OCI_CLI_TENANCY value from: $OCI_CLI_CONFIG_FILE" - OCI_CLI_TENANCY="${OCI_CLI_TENANCY:-$(_readini "$OCI_CLI_CONFIG_FILE" tenancy "$OCI_CLI_PROFILE")}" fi if [ -z "$OCI_CLI_TENANCY" ]; then @@ -128,41 +131,47 @@ _oci_config() { return 1 fi - OCI_CLI_USER="${OCI_CLI_USER:-$(_readaccountconf_mutable OCI_CLI_USER)}" + if [ -z "$OCI_CLI_USER" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + _debug "Reading OCI_CLI_USER value from: $OCI_CLI_CONFIG_FILE" + OCI_CLI_USER=$(_readini "$OCI_CLI_CONFIG_FILE" user "$OCI_CLI_PROFILE") + fi + if [ -z "$OCI_CLI_USER" ]; then + OCI_CLI_USER=$(_readaccountconf_mutable OCI_CLI_USER) + fi if [ "$OCI_CLI_USER" ]; then _saveaccountconf_mutable OCI_CLI_USER "$OCI_CLI_USER" - elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then - _debug "Reading OCI_CLI_USER value from: $OCI_CLI_CONFIG_FILE" - OCI_CLI_USER="${OCI_CLI_USER:-$(_readini "$OCI_CLI_CONFIG_FILE" user "$OCI_CLI_PROFILE")}" fi if [ -z "$OCI_CLI_USER" ]; then _err "Error: unable to read OCI_CLI_USER from config file or environment variable." return 1 fi - OCI_CLI_REGION="${OCI_CLI_REGION:-$(_readaccountconf_mutable OCI_CLI_REGION)}" + if [ -z "$OCI_CLI_REGION" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + _debug "Reading OCI_CLI_REGION value from: $OCI_CLI_CONFIG_FILE" + OCI_CLI_REGION=$(_readini "$OCI_CLI_CONFIG_FILE" region "$OCI_CLI_PROFILE") + fi + if [ -z "$OCI_CLI_REGION" ]; then + OCI_CLI_REGION=$(_readaccountconf_mutable OCI_CLI_REGION) + fi if [ "$OCI_CLI_REGION" ]; then _saveaccountconf_mutable OCI_CLI_REGION "$OCI_CLI_REGION" - elif [ -f "$OCI_CLI_CONFIG_FILE" ]; then - _debug "Reading OCI_CLI_REGION value from: $OCI_CLI_CONFIG_FILE" - OCI_CLI_REGION="${OCI_CLI_REGION:-$(_readini "$OCI_CLI_CONFIG_FILE" region "$OCI_CLI_PROFILE")}" fi if [ -z "$OCI_CLI_REGION" ]; then _err "Error: unable to read OCI_CLI_REGION from config file or environment variable." return 1 fi - OCI_CLI_KEY="${OCI_CLI_KEY:-$(_readaccountconf_mutable OCI_CLI_KEY)}" - if [ -z "$OCI_CLI_KEY" ]; then - _clearaccountconf_mutable OCI_CLI_KEY - OCI_CLI_KEY_FILE="${OCI_CLI_KEY_FILE:-$(_readini "$OCI_CLI_CONFIG_FILE" key_file "$OCI_CLI_PROFILE")}" - if [ "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_KEY_FILE" ]; then - _debug "Reading OCI_CLI_KEY value from: $OCI_CLI_KEY_FILE" - OCI_CLI_KEY=$(_base64 <"$OCI_CLI_KEY_FILE") - _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY" - fi - else + if [ -z "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_CONFIG_FILE" ]; then + OCI_CLI_KEY_FILE=$(_readini "$OCI_CLI_CONFIG_FILE" key_file "$OCI_CLI_PROFILE") + fi + if [ "$OCI_CLI_KEY" ]; then _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY" + elif [ "$OCI_CLI_KEY_FILE" ] && [ -f "$OCI_CLI_KEY_FILE" ]; then + _debug "Reading OCI_CLI_KEY value from: $OCI_CLI_KEY_FILE" + OCI_CLI_KEY=$(_base64 <"$OCI_CLI_KEY_FILE") + _saveaccountconf_mutable OCI_CLI_KEY "$OCI_CLI_KEY" + else + OCI_CLI_KEY=$(_readaccountconf_mutable OCI_CLI_KEY) fi if [ -z "$OCI_CLI_KEY_FILE" ] && [ -z "$OCI_CLI_KEY" ]; then From 1dbabf0da95c8803012b3d006ed7094962ac9761 Mon Sep 17 00:00:00 2001 From: neil Date: Tue, 14 Jul 2026 21:12:43 +0800 Subject: [PATCH 31/31] valid-to: allow a negative --days to set the renewal margin A negative --days is anchored to the expiry, so it composes with a relative --valid-to: "--valid-to +30d --days -7" renews 7 days before the expiry instead of the hardcoded 1 day. A positive --days and any --days with a fixed-date --valid-to are still rejected. https://github.com/acmesh-official/acme.sh/issues/6570 --- acme.sh | 55 ++++++++++++++++++++++++++++++++++++------------------- 1 file changed, 36 insertions(+), 19 deletions(-) diff --git a/acme.sh b/acme.sh index b53601ee..3a73cd77 100755 --- a/acme.sh +++ b/acme.sh @@ -1995,6 +1995,26 @@ _calc_next_renew_time() { printf "%s" "$_cnrt_next" } +#Usage: _calc_validto_renew_time notaftertime renewaldays now +#Prints the next renew time for a cert issued with a relative --valid-to. +#A negative renewaldays is anchored to the expiry: notaftertime + +#renewaldays*86400. Otherwise the cert renews one day before the expiry, +#or one hour before for certs whose lifetime is 24 hours or less. +_calc_validto_renew_time() { + _cvrt_end="$1" + _cvrt_days="$2" + _cvrt_now="$3" + if [ "$_cvrt_days" ] && [ "$_cvrt_days" -lt 0 ]; then + _math "$_cvrt_end" + "$_cvrt_days" \* 24 \* 60 \* 60 + return 0 + fi + if [ "$(_math "$_cvrt_end" - "$_cvrt_now")" -gt 86400 ]; then + _math "$_cvrt_end" - 86400 + else + _math "$_cvrt_end" - 3600 + fi +} + _mktemp() { if _exists mktemp; then if mktemp 2>/dev/null; then @@ -5950,19 +5970,8 @@ $_authorizations_map" _info "It cannot be renewed automatically" _info "See: $_VALIDITY_WIKI" else - _now=$(_time) - _debug2 "_now" "$_now" - _lifetime=$(_math $Le_NextRenewTime - $_now) - _debug2 "_lifetime" "$_lifetime" - if [ $_lifetime -gt 86400 ]; then - #if lifetime is logner than one day, it will renew one day before - Le_NextRenewTime=$(_math $Le_NextRenewTime - 86400) - Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") - else - #if lifetime is less than 24 hours, it will renew one hour before - Le_NextRenewTime=$(_math $Le_NextRenewTime - 3600) - Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") - fi + Le_NextRenewTime=$(_calc_validto_renew_time "$Le_NextRenewTime" "$Le_RenewalDays" "$(_time)") + Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime") fi elif [ "$Le_RenewalDays" -lt "0" ]; then _enddate_value=$(_enddate "$CERT_PATH") @@ -8050,6 +8059,7 @@ Parameters: 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. + A negative value renews that many days before the cert expiry. Negative values could be used to specify a number of days relative to the expiration date of the certificate. --httpport Specifies the standalone listening port. Only valid if the server is behind a reverse proxy or load balancer. --tlsport Specifies the standalone tls listening port. Only valid if the server is behind a reverse proxy or load balancer. @@ -9044,13 +9054,20 @@ _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. + # --valid-to pins the cert lifetime, so a creation-anchored (positive) + # --days schedule can not apply and is rejected. A negative --days is + # anchored to the expiry and composes with a relative --valid-to: the + # cert renews that many days before the expiry. 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 + if ! _startswith "$_valid_to" "+"; then + _err "--days can not be used together with a fixed-date --valid-to: such a cert can not be renewed automatically." + return 1 + fi + if ! _startswith "$_days" "-"; then + _err "A positive --days can not be used together with --valid-to, the renewal time is derived from the expiry time." + _err "Use a negative --days to renew that many days before the expiry, or omit --days to renew 1 day before the expiry." + return 1 + fi fi if [ "$DEBUG" ]; then