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 01/20] 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 02/20] 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 03/20] 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 04/20] 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 05/20] 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 06/20] 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 07/20] 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 08/20] 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 09/20] 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 10/20] 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 11/20] 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 12/20] 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 13/20] 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 14/20] 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 15/20] 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 16/20] 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 17/20] 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 18/20] 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 19/20] 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 From 3faf65c46d7c35fde3f0a35f64092f1d9b0f6967 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 15 Jul 2026 20:41:35 +0800 Subject: [PATCH 20/20] fix https://github.com/acmesh-official/acme.sh/issues/1940#issuecomment-4971257867 --- acme.sh | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 3a73cd77..a5f53469 100755 --- a/acme.sh +++ b/acme.sh @@ -5838,7 +5838,7 @@ $_authorizations_map" return 1 fi - echo "$response" >"$CERT_PATH" + echo "$response" | _strip_blank_lines >"$CERT_PATH" _split_cert_chain "$CERT_PATH" "$CERT_FULLCHAIN_PATH" "$CA_CERT_PATH" if [ -z "$_preferred_chain" ]; then _preferred_chain=$(_readcaconf DEFAULT_PREFERRED_CHAIN) @@ -5865,7 +5865,7 @@ $_authorizations_map" _relcert="$CERT_PATH.alt" _relfullchain="$CERT_FULLCHAIN_PATH.alt" _relca="$CA_CERT_PATH.alt" - echo "$response" >"$_relcert" + echo "$response" | _strip_blank_lines >"$_relcert" _split_cert_chain "$_relcert" "$_relfullchain" "$_relca" if [ "$DEBUG" ]; then _debug "rel chain issuers: " "$(_get_chain_issuers "$_relfullchain")" @@ -6072,6 +6072,15 @@ $_authorizations_map" } #in_out_cert out_fullchain out_ca +#Reads a PEM chain from stdin, prints it without the blank lines. +#Some CAs (Let's Encrypt) separate the certificates of a chain with a blank +#line, others (ZeroSSL) don't. The blank lines are valid PEM (RFC 7468), but +#some devices and APIs reject them, so the certs are stored back to back. +#https://github.com/acmesh-official/acme.sh/issues/1940 +_strip_blank_lines() { + sed '/^[[:space:]]*$/d' +} + _split_cert_chain() { _certf="$1" _fullchainf="$2"