From 10e7e458af9618004b9fd9f37560706b9e4d3061 Mon Sep 17 00:00:00 2001
From: firestormisp <53045434+firestormisp@users.noreply.github.com>
Date: Fri, 1 May 2026 10:43:51 +0200
Subject: [PATCH 001/224] Add dns_firestorm.sh plugin for Firestorm.ch DNS API
(#6829)
Firestorm.ch is a Swiss hosting provider with managed DNS (PowerDNS).
This plugin allows customers to automate Let's Encrypt DNS-01 challenges
via the Firestorm DNS API.
---
dnsapi/dns_firestorm.sh | 110 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 110 insertions(+)
create mode 100644 dnsapi/dns_firestorm.sh
diff --git a/dnsapi/dns_firestorm.sh b/dnsapi/dns_firestorm.sh
new file mode 100644
index 00000000..808c2b89
--- /dev/null
+++ b/dnsapi/dns_firestorm.sh
@@ -0,0 +1,110 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_firestorm_info='Firestorm.ch
+Site: firestorm.ch
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_firestorm
+Options:
+ FST_Key Customer ID
+ FST_Secret API Secret
+ FST_Url API URL. Optional. Default "https://api.firestorm.ch/acme-dns".
+Issues: github.com/acmesh-official/acme.sh/issues/6839
+Author: FireStorm GmbH
+'
+
+FST_Url_DEFAULT="https://api.firestorm.ch/acme-dns"
+
+######## Public functions #####################
+
+# Usage: dns_firestorm_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_firestorm_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}"
+ FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}"
+ FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}"
+
+ if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then
+ _err "FST_Key and FST_Secret must be set"
+ _err "Get your API credentials at https://admin.firestorm.ch"
+ return 1
+ fi
+
+ FST_Url="${FST_Url:-$FST_Url_DEFAULT}"
+
+ _saveaccountconf_mutable FST_Key "$FST_Key"
+ _saveaccountconf_mutable FST_Secret "$FST_Secret"
+ if [ "$FST_Url" != "$FST_Url_DEFAULT" ]; then
+ _saveaccountconf_mutable FST_Url "$FST_Url"
+ else
+ _clearaccountconf_mutable FST_Url
+ fi
+
+ subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//')
+
+ _info "Adding TXT record for $fulldomain"
+ _debug "Subdomain" "$subdomain"
+ _debug "TXT value" "$txtvalue"
+
+ body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}"
+
+ response="$(_firestorm_api "update" "$body")"
+
+ if _contains "$response" "$txtvalue"; then
+ _info "TXT record added successfully"
+ return 0
+ fi
+
+ _err "Failed to add TXT record: $response"
+ return 1
+}
+
+# Usage: dns_firestorm_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_firestorm_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ FST_Key="${FST_Key:-$(_readaccountconf_mutable FST_Key)}"
+ FST_Secret="${FST_Secret:-$(_readaccountconf_mutable FST_Secret)}"
+ FST_Url="${FST_Url:-$(_readaccountconf_mutable FST_Url)}"
+ FST_Url="${FST_Url:-$FST_Url_DEFAULT}"
+
+ if [ -z "$FST_Key" ] || [ -z "$FST_Secret" ]; then
+ _err "FST_Key and FST_Secret must be set"
+ return 1
+ fi
+
+ subdomain=$(printf "%s" "$fulldomain" | sed 's/^_acme-challenge\.//')
+
+ _info "Removing TXT record for $fulldomain"
+
+ body="{\"subdomain\":\"$(_json_safe "$subdomain")\",\"txt\":\"$(_json_safe "$txtvalue")\"}"
+
+ response="$(_firestorm_api "remove" "$body")"
+
+ if _contains "$response" "removed"; then
+ _info "TXT record removed"
+ return 0
+ fi
+
+ _err "Failed to remove TXT record: $response"
+ return 1
+}
+
+#################### Private functions below ##################################
+
+# Escape special characters for safe JSON string interpolation
+_json_safe() {
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
+}
+
+_firestorm_api() {
+ action=$1
+ data=$2
+
+ export _H1="X-Api-User: $FST_Key"
+ export _H2="X-Api-Key: $FST_Secret"
+ export _H3="Content-Type: application/json"
+
+ _post "$data" "$FST_Url/$action" "" "POST"
+}
From 8e71268d039a06325d63cf6571598e6007f6b48d Mon Sep 17 00:00:00 2001
From: Alexey Pakhomov
+
300")
if [ "$retcode" ]; then
- _record_id=$(echo "$response" | _egrep_o "([^<]*) TXT $fulldomain " | _egrep_o "([^<]*) " | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1)
+ _record_id=$(echo "$response" | _egrep_o "([^<]*) TXT $_sub_domain $txtvalue " | _egrep_o "([^<]*) " | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1)
_debug _record_id "$_record_id"
if [ "$_record_id" ]; then
_info "Successfully retrieved the record id for ACME challenge."
From d60c75b4e30a26addd8b9d0bfdeeed39c84ed51e Mon Sep 17 00:00:00 2001
From: ZeroSSL-Andreas
Date: Fri, 19 Jun 2026 17:06:57 +0200
Subject: [PATCH 037/224] New Banner
Updated README to include responsive images for dark and light modes.
---
README.md | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 22700b4c..792ef979 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,21 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
🔐 acme.sh
From 0dc97187e129d90bb42168f8cca9d9b425ad8c26 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jakub=20Ko=C5=82odziejczak?=
<31549762+mrl5@users.noreply.github.com>
Date: Sun, 28 Jun 2026 18:41:15 +0200
Subject: [PATCH 038/224] docs: introduce contributing doc (#7052)
prevents friction and frustrations like in issue #7050
closes #7050
---
CONTRIBUTING.md | 8 ++++++++
README.md | 2 ++
2 files changed, 10 insertions(+)
create mode 100644 CONTRIBUTING.md
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..33294ce7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,8 @@
+# Contributing
+
+1. Do NOT send pull request to `master` branch.
+Please send to `dev` branch instead.
+Any PR to `master` branch will NOT be merged.
+
+2. For dns api support, read this guide first: https://github.com/acmesh-official/acme.sh/wiki/DNS-API-Dev-Guide
+You will NOT get any review without passing this guide. You also need to fix the CI errors.
diff --git a/README.md b/README.md
index 3a697691..44a73e83 100644
--- a/README.md
+++ b/README.md
@@ -615,6 +615,8 @@ This project exists thanks to all the people who contribute.
+If you want to become a contributor make sure to read [CONTRIBUTING.md](./CONTRIBUTING.md).
+
### 💰 Financial Contributors
Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/acmesh/contribute)]
From b3579ff18dcf3c530293b096e232fab4b4b87216 Mon Sep 17 00:00:00 2001
From: Jeroen Moors
Date: Sun, 28 Jun 2026 19:27:41 +0200
Subject: [PATCH 039/224] Implement support for DNS Level27 (#7043)
* Add Level27 DNS API support
Implements dns_level27_add and dns_level27_rm for the Level27 (level27.eu) DNS API, used for ACME dns-01 challenges.
- Authenticates with a persistent API key via the Authorization header.
- Resolves the registered zone with domains?filter and exact fullname match (supports DNS alias mode).
- Removes the challenge record by its exact TXT value, leaving other records intact (wildcard-safe).
- Optional LEVEL27_API override for non-default/staging endpoints.
* A little better documentation
---------
Co-authored-by: Jeroen Moors
---
dnsapi/dns_level27.sh | 197 ++++++++++++++++++++++++++++++++++++++++++
1 file changed, 197 insertions(+)
create mode 100644 dnsapi/dns_level27.sh
diff --git a/dnsapi/dns_level27.sh b/dnsapi/dns_level27.sh
new file mode 100644
index 00000000..3fbaf810
--- /dev/null
+++ b/dnsapi/dns_level27.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_level27_info='Level27
+Site: Level27.be
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_level27
+Options:
+ LEVEL27_API_KEY API key. Get one from the Level27 control panel (https://app.level27.eu/account/profile/security).
+OptionsAlt:
+ LEVEL27_API API base URL. Optional. Default "https://api.level27.eu/v1".
+Issues: github.com/acmesh-official/acme.sh/issues
+Author: Jeroen Moors
+'
+
+LEVEL27_API_DEFAULT="https://api.level27.eu/v1"
+
+######## Public functions #####################
+
+# Usage: dns_level27_add _acme-challenge.www.example.com "TXT-value"
+dns_level27_add() {
+ fulldomain="$(_idn "$1")"
+ txtvalue="$2"
+
+ _info "Using Level27 to add a TXT record for $fulldomain"
+
+ if ! _level27_init; then
+ return 1
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Could not determine the root zone for $fulldomain at Level27."
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _level27_data="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\"}"
+ if ! _level27_rest POST "domains/$_domain_id/records" "$_level27_data"; then
+ _err "Could not add the TXT record."
+ return 1
+ fi
+
+ if _contains "$response" "\"id\":"; then
+ _info "TXT record added."
+ return 0
+ fi
+
+ _err "Unexpected response while adding the TXT record."
+ return 1
+}
+
+# Usage: dns_level27_rm _acme-challenge.www.example.com "TXT-value"
+dns_level27_rm() {
+ fulldomain="$(_idn "$1")"
+ txtvalue="$2"
+
+ _info "Using Level27 to remove the TXT record for $fulldomain"
+
+ if ! _level27_init; then
+ return 1
+ fi
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "Could not determine the root zone for $fulldomain at Level27."
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ if ! _level27_rest GET "domains/$_domain_id/records?type=TXT"; then
+ _err "Could not list the existing TXT records."
+ return 1
+ fi
+
+ _record_id="$(_level27_find_record_id "$response" "$txtvalue")"
+ if [ -z "$_record_id" ]; then
+ _info "No matching TXT record found; nothing to remove."
+ return 0
+ fi
+ _debug _record_id "$_record_id"
+
+ if ! _level27_rest DELETE "domains/$_domain_id/records/$_record_id"; then
+ _err "Could not remove the TXT record."
+ return 1
+ fi
+
+ _info "TXT record removed."
+ return 0
+}
+
+#################### Private functions below ##################################
+
+# Reads and validates the API credentials and endpoint, and stores them for renewals.
+_level27_init() {
+ LEVEL27_API_KEY="${LEVEL27_API_KEY:-$(_readaccountconf_mutable LEVEL27_API_KEY)}"
+ if [ -z "$LEVEL27_API_KEY" ]; then
+ LEVEL27_API_KEY=""
+ _err "You must export the variable LEVEL27_API_KEY before using the Level27 DNS API."
+ _err "Get an API key from the Level27 control panel (https://app.level27.eu/account/profile/security)."
+ return 1
+ fi
+ LEVEL27_API_KEY="$(echo "$LEVEL27_API_KEY" | tr -d '"')"
+ _saveaccountconf_mutable LEVEL27_API_KEY "$LEVEL27_API_KEY"
+
+ LEVEL27_API="${LEVEL27_API:-$(_readaccountconf_mutable LEVEL27_API)}"
+ if [ -z "$LEVEL27_API" ]; then
+ LEVEL27_API="$LEVEL27_API_DEFAULT"
+ fi
+ _saveaccountconf_mutable LEVEL27_API "$LEVEL27_API"
+
+ # Remove a trailing slash so endpoints can be appended consistently.
+ LEVEL27_API="$(echo "$LEVEL27_API" | sed 's#/$##')"
+ return 0
+}
+
+# Usage: _get_root _acme-challenge.www.example.com
+# Splits the full domain into the registered zone and the subdomain part.
+# Sets: _domain, _domain_id, _sub_domain
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ _debug h "$h"
+ if [ -z "$h" ]; then
+ # not valid
+ return 1
+ fi
+
+ if ! _level27_rest GET "domains?filter=$h"; then
+ return 1
+ fi
+
+ _level27_zones="$(echo "$response" | _normalizeJson)"
+ if _contains "$_level27_zones" "\"fullname\":\"$h\""; then
+ _domain_line="$(echo "$_level27_zones" | sed 's/},{/}\n{/g' | grep "\"fullname\":\"$h\"" | _head_n 1)"
+ _domain_id="$(echo "$_domain_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2)"
+ if [ "$_domain_id" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+ return 1
+ fi
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+ return 1
+}
+
+# Usage: _level27_find_record_id "" ""
+# Prints the id of the TXT record whose content matches the value, or nothing.
+_level27_find_record_id() {
+ _records="$(echo "$1" | _normalizeJson | sed 's/},{/}\n{/g')"
+ _wanted="$2"
+ _record_line="$(echo "$_records" | grep "\"content\":\"$_wanted\"" | _head_n 1)"
+ if [ -z "$_record_line" ]; then
+ # Some APIs store TXT content wrapped in quotes.
+ _record_line="$(echo "$_records" | grep "\"content\":\"\\\\\"$_wanted\\\\\"\"" | _head_n 1)"
+ fi
+ if [ -z "$_record_line" ]; then
+ return 0
+ fi
+ echo "$_record_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2
+}
+
+# Usage: _level27_rest [data]
+# Performs an authenticated API call and stores the body in $response.
+_level27_rest() {
+ m="$1"
+ ep="$2"
+ data="$3"
+ _debug "$ep"
+
+ export _H1="Authorization: $LEVEL27_API_KEY"
+ export _H2="Content-Type: application/json"
+ export _H3="Accept: application/json"
+
+ if [ "$m" != "GET" ]; then
+ _debug2 data "$data"
+ response="$(_post "$data" "$LEVEL27_API/$ep" "" "$m")"
+ else
+ response="$(_get "$LEVEL27_API/$ep")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "Error querying the Level27 API endpoint: $ep"
+ return 1
+ fi
+ _debug2 response "$response"
+ return 0
+}
From 2998106bd1cebebf681bafec23b19113bb6e21ac Mon Sep 17 00:00:00 2001
From: Alexander Stehlik
Date: Wed, 1 Jul 2026 12:54:24 +0200
Subject: [PATCH 040/224] fix(dns_desec): fix rate limit and compatibility
issues (#7027)
* fix(dns_desec): sleep after DNS record change to prevent rate limit issues
Also: make sure the subname is lowercase to fix tests where
the acmetestXyzRandomName subdomain is used.
* fix: make regexes POSIX-compatible (for OpenBSD)
* chore: use _sleep instead of sleep to follow acme.sh standards
---
dnsapi/dns_desec.sh | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh
index d6b9c355..275babea 100644
--- a/dnsapi/dns_desec.sh
+++ b/dnsapi/dns_desec.sh
@@ -39,6 +39,7 @@ dns_desec_add() {
_err "invalid domain"
return 1
fi
+ _sub_domain=$(echo "$_sub_domain" | _lower_case)
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
@@ -48,7 +49,7 @@ dns_desec_add() {
_desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/"
if [ "$_code" = "200" ]; then
- oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
+ oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
_debug "existing TXT found"
_debug oldtxtvalues "$oldtxtvalues"
if [ -n "$oldtxtvalues" ]; then
@@ -100,7 +101,7 @@ dns_desec_rm() {
_err "invalid domain"
return 1
fi
-
+ _sub_domain=$(echo "$_sub_domain" | _lower_case)
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
@@ -110,7 +111,7 @@ dns_desec_rm() {
_desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/"
if [ "$_code" = "200" ]; then
- oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
+ oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")"
_debug "existing TXT found"
_debug oldtxtvalues "$oldtxtvalues"
if [ -n "$oldtxtvalues" ]; then
@@ -150,6 +151,8 @@ _desec_rest() {
if [ "$m" != "GET" ]; then
_secure_debug2 data "$data"
response="$(_post "$data" "$ep" "" "$m")"
+ _info "Sleeping 1s to respect deSEC write rate limit"
+ _sleep 1
else
response="$(_get "$ep")"
fi
From c38182897d55e811aa8e09c00f68890afbc8360f Mon Sep 17 00:00:00 2001
From: neil
Date: Wed, 1 Jul 2026 18:59:14 +0800
Subject: [PATCH 041/224] fix ghostbsd
---
.github/workflows/DNS.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml
index 06dd29ac..a972ae1a 100644
--- a/.github/workflows/DNS.yml
+++ b/.github/workflows/DNS.yml
@@ -263,6 +263,8 @@ jobs:
GhostBSD:
runs-on: ubuntu-latest
needs: FreeBSD
+ # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run
+ continue-on-error: true
env:
TEST_DNS : ${{ secrets.TEST_DNS }}
TestingDomain: ${{ secrets.TestingDomain }}
From 0d53d29f7efa6b40c6193c1b9b338da180cd24f7 Mon Sep 17 00:00:00 2001
From: hostup <52465293+hostup@users.noreply.github.com>
Date: Wed, 1 Jul 2026 14:44:21 +0200
Subject: [PATCH 042/224] Update dns_hostup.sh to v2 API (#7014)
* Update dns_hostup.sh
Update to v2 api support; developer.hostup.se
* Update dns_hostup.sh
---
dnsapi/dns_hostup.sh | 326 ++++++++++++++++++++++++++-----------------
1 file changed, 201 insertions(+), 125 deletions(-)
diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh
index b3211069..73189189 100644
--- a/dnsapi/dns_hostup.sh
+++ b/dnsapi/dns_hostup.sh
@@ -6,13 +6,13 @@ Site: hostup.se
Docs: https://developer.hostup.se/
Options:
HOSTUP_API_KEY Required. HostUp API key with read:dns + write:dns + read:domains scopes.
- HOSTUP_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api).
+ HOSTUP_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api/v2).
HOSTUP_TTL Optional. TTL for TXT records (default: 60 seconds).
- HOSTUP_ZONE_ID Optional. Force a specific zone ID (skip auto-detection).
+ HOSTUP_ZONE_ID Optional. Force a specific v2 zone ID (zone_...) and skip auto-detection.
Author: HostUp (https://cloud.hostup.se/contact/en)
'
-HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api"
+HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api/v2"
HOSTUP_DEFAULT_TTL=60
# Public: add TXT record
@@ -20,6 +20,7 @@ HOSTUP_DEFAULT_TTL=60
dns_hostup_add() {
fulldomain="$1"
txtvalue="$2"
+ hostup_add_txtvalue="$2"
_info "Using HostUp DNS API"
@@ -34,31 +35,34 @@ dns_hostup_add() {
record_name="$(_hostup_record_name "$fulldomain" "$HOSTUP_ZONE_DOMAIN")"
record_name="$(_hostup_sanitize_name "$record_name")"
- record_value="$(_hostup_json_escape "$txtvalue")"
+ hostup_add_record_value="$(_hostup_json_escape "$hostup_add_txtvalue")"
- ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}"
+ raw_ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}"
+ ttl="$(_hostup_normalize_ttl "$raw_ttl")"
+ if [ -z "$ttl" ]; then
+ _err "HOSTUP_TTL must be a whole number between 60 and 86400 seconds."
+ return 1
+ fi
+ if [ -n "$HOSTUP_TTL" ]; then
+ HOSTUP_TTL="$ttl"
+ _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL"
+ fi
_debug "zone_id" "$HOSTUP_ZONE_ID"
_debug "zone_domain" "$HOSTUP_ZONE_DOMAIN"
_debug "record_name" "$record_name"
_debug "ttl" "$ttl"
- request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$record_value\",\"ttl\":$ttl}"
-
- if ! _hostup_rest "POST" "/dns/zones/$HOSTUP_ZONE_ID/records" "$request_body"; then
- return 1
+ record_name_fqdn="$(_hostup_fqdn "$fulldomain")"
+ if _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$hostup_add_txtvalue"; then
+ _info "TXT record already exists for $fulldomain"
+ return 0
fi
- if ! _contains "$_hostup_response" '"success":true'; then
- _err "HostUp DNS API: failed to create TXT record for $fulldomain"
- _debug2 "_hostup_response" "$_hostup_response"
- return 1
- fi
+ request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$hostup_add_record_value\",\"ttl\":$ttl}"
- record_id="$(_hostup_extract_record_id "$_hostup_response")"
- if [ -n "$record_id" ]; then
- _hostup_save_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_id"
- _debug "hostup_saved_record_id" "$record_id"
+ if ! _hostup_rest "POST" "/dns-zones/$HOSTUP_ZONE_ID/records" "$request_body"; then
+ return 1
fi
_info "Added TXT record for $fulldomain"
@@ -85,20 +89,9 @@ dns_hostup_rm() {
record_name_fqdn="$(_hostup_fqdn "$fulldomain")"
record_value="$txtvalue"
- record_id_cached="$(_hostup_get_saved_record_id "$HOSTUP_ZONE_ID" "$fulldomain")"
- if [ -n "$record_id_cached" ]; then
- _debug "hostup_record_id_cached" "$record_id_cached"
- if _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$record_id_cached"; then
- _info "Deleted TXT record $record_id_cached"
- _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
- HOSTUP_ZONE_ID=""
- return 0
- fi
- fi
-
if ! _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$record_value"; then
_info "TXT record not found for $record_name_fqdn. Skipping removal."
- _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
+ _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value"
return 0
fi
@@ -109,7 +102,7 @@ dns_hostup_rm() {
fi
_info "Deleted TXT record $HOSTUP_RECORD_ID"
- _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain"
+ _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value"
HOSTUP_ZONE_ID=""
return 0
}
@@ -127,21 +120,18 @@ _hostup_init() {
if [ -z "$HOSTUP_API_BASE" ]; then
HOSTUP_API_BASE="$HOSTUP_API_BASE_DEFAULT"
fi
+ HOSTUP_API_BASE="$(_hostup_normalize_api_base "$HOSTUP_API_BASE")"
if [ -z "$HOSTUP_API_KEY" ]; then
HOSTUP_API_KEY=""
_err "HOSTUP_API_KEY is not set."
- _err "Please export your HostUp API key with read:dns and write:dns scopes."
+ _err "Please export your HostUp API key with read:dns, write:dns, and read:domains scopes."
return 1
fi
_saveaccountconf_mutable HOSTUP_API_KEY "$HOSTUP_API_KEY"
_saveaccountconf_mutable HOSTUP_API_BASE "$HOSTUP_API_BASE"
- if [ -n "$HOSTUP_TTL" ]; then
- _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL"
- fi
-
if [ -n "$HOSTUP_ZONE_ID" ]; then
_saveaccountconf_mutable HOSTUP_ZONE_ID "$HOSTUP_ZONE_ID"
fi
@@ -149,11 +139,80 @@ _hostup_init() {
return 0
}
+_hostup_normalize_api_base() {
+ api_base="${1%/}"
+
+ case "$api_base" in
+ */api/v2)
+ printf "%s" "$api_base"
+ ;;
+ */api)
+ printf "%s/v2" "$api_base"
+ ;;
+ *)
+ printf "%s" "$api_base"
+ ;;
+ esac
+}
+
+_hostup_normalize_ttl() {
+ ttl_value="$1"
+
+ case "$ttl_value" in
+ "" | *[!0-9]*)
+ return 1
+ ;;
+ esac
+
+ while [ "${ttl_value#0}" != "$ttl_value" ]; do
+ ttl_value="${ttl_value#0}"
+ done
+ [ -z "$ttl_value" ] && ttl_value=0
+
+ case "$ttl_value" in
+ ??????*)
+ return 1
+ ;;
+ esac
+
+ if [ "$ttl_value" -lt 60 ] || [ "$ttl_value" -gt 86400 ]; then
+ return 1
+ fi
+
+ printf "%s" "$ttl_value"
+}
+
+_hostup_domain_in_zone() {
+ host="$(printf "%s" "${1%.}" | _lower_case)"
+ zone="$(printf "%s" "${2%.}" | _lower_case)"
+
+ if [ -z "$host" ] || [ -z "$zone" ]; then
+ return 1
+ fi
+
+ if [ "$host" = "$zone" ]; then
+ return 0
+ fi
+
+ case "$host" in
+ *."$zone")
+ return 0
+ ;;
+ esac
+
+ return 1
+}
+
_hostup_detect_zone() {
fulldomain="$1"
if [ -n "$HOSTUP_ZONE_ID" ] && [ -n "$HOSTUP_ZONE_DOMAIN" ]; then
- return 0
+ if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then
+ return 0
+ fi
+ _debug "hostup_cached_zone_mismatch" "$HOSTUP_ZONE_DOMAIN"
+ HOSTUP_ZONE_ID=""
+ HOSTUP_ZONE_DOMAIN=""
fi
HOSTUP_ZONE_DOMAIN=""
@@ -162,16 +221,16 @@ _hostup_detect_zone() {
if [ -n "$HOSTUP_ZONE_ID" ] && [ -z "$HOSTUP_ZONE_DOMAIN" ]; then
# Attempt to fetch domain name for provided zone ID
if _hostup_fetch_zone_details "$HOSTUP_ZONE_ID"; then
- return 0
+ if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then
+ return 0
+ fi
+ _debug "hostup_forced_zone_mismatch" "$HOSTUP_ZONE_DOMAIN"
fi
HOSTUP_ZONE_ID=""
+ HOSTUP_ZONE_DOMAIN=""
fi
- if ! _hostup_load_zones; then
- return 1
- fi
-
- _domain_candidate="$(printf "%s" "$fulldomain" | _lower_case)"
+ _domain_candidate="$(printf "%s" "${fulldomain%.}" | _lower_case)"
_debug "hostup_initial_candidate" "$_domain_candidate"
while [ -n "$_domain_candidate" ]; do
@@ -240,11 +299,11 @@ _hostup_fqdn() {
_hostup_fetch_zone_details() {
zone_id="$1"
- if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then
+ if ! _hostup_rest "GET" "/dns-zones/$zone_id/records" ""; then
return 1
fi
- zonedomain="$(printf "%s" "$_hostup_response" | _egrep_o '"domain":"[^"]*"' | sed -n '1p' | cut -d ':' -f 2 | tr -d '"')"
+ zonedomain="$(_hostup_json_extract "name" "$_hostup_response")"
if [ -n "$zonedomain" ]; then
HOSTUP_ZONE_DOMAIN="$zonedomain"
return 0
@@ -254,7 +313,7 @@ _hostup_fetch_zone_details() {
}
_hostup_load_zones() {
- if ! _hostup_rest "GET" "/dns/zones" ""; then
+ if ! _hostup_rest "GET" "/dns-zones?limit=1000" ""; then
return 1
fi
@@ -263,9 +322,9 @@ _hostup_load_zones() {
while IFS= read -r line; do
case "$line" in
- *'"domain_id"'*'"domain"'*)
- zone_id="$(printf "%s" "$line" | _hostup_json_extract "domain_id")"
- zone_domain="$(printf "%s" "$line" | _hostup_json_extract "domain")"
+ *'"id"'*'"name"'*)
+ zone_id="$(_hostup_json_extract "id" "$line")"
+ zone_domain="$(_hostup_json_extract "name" "$line")"
if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then
HOSTUP_ZONES_CACHE="${HOSTUP_ZONES_CACHE}${zone_domain}|${zone_id}
"
@@ -290,9 +349,30 @@ _hostup_lookup_zone() {
_lookup_zone_id=""
_lookup_zone_domain=""
+ encoded_domain="$(printf "%s" "$lookup_domain" | _url_encode)"
+ if _hostup_rest "GET" "/dns-zones?name=$encoded_domain&limit=1" ""; then
+ zone_id="$(_hostup_json_extract "id" "$_hostup_response")"
+ zone_domain="$(_hostup_json_extract "name" "$_hostup_response")"
+ if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then
+ zone_domain_lower="$(printf "%s" "$zone_domain" | _lower_case)"
+ if [ "$zone_domain_lower" = "$lookup_domain" ]; then
+ _lookup_zone_domain="$zone_domain"
+ _lookup_zone_id="$zone_id"
+ HOSTUP_ZONE_DOMAIN="$zone_domain"
+ HOSTUP_ZONE_ID="$zone_id"
+ return 0
+ fi
+ fi
+ fi
+
+ if [ -z "$HOSTUP_ZONES_CACHE" ] && ! _hostup_load_zones; then
+ return 1
+ fi
+
while IFS='|' read -r domain zone_id; do
[ -z "$domain" ] && continue
- if [ "$domain" = "$lookup_domain" ]; then
+ domain_lower="$(printf "%s" "$domain" | _lower_case)"
+ if [ "$domain_lower" = "$lookup_domain" ]; then
_lookup_zone_domain="$domain"
_lookup_zone_id="$zone_id"
HOSTUP_ZONE_DOMAIN="$domain"
@@ -307,50 +387,50 @@ EOF
}
_hostup_find_record() {
- zone_id="$1"
- fqdn="$2"
- txtvalue="$3"
+ _hostup_find_zone_id="$1"
+ _hostup_find_fqdn="$2"
+ _hostup_find_txtvalue="$3"
- if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then
+ _hostup_find_encoded_name="$(printf "%s" "$_hostup_find_fqdn" | _url_encode)"
+ if ! _hostup_rest "GET" "/dns-zones/$_hostup_find_zone_id/records?type=TXT&name=$_hostup_find_encoded_name" ""; then
return 1
fi
HOSTUP_RECORD_ID=""
- records="$(printf "%s" "$_hostup_response" | tr '{' '\n')"
+ _hostup_find_records="$(printf "%s" "$_hostup_response" | tr '{' '\n')"
- while IFS= read -r line; do
+ while IFS= read -r _hostup_find_line; do
# Normalize line to make TXT value matching reliable
- line_clean="$(printf "%s" "$line" | tr -d '\r\n')"
- line_value_clean="$(printf "%s" "$line_clean" | sed 's/\\"//g')"
+ _hostup_find_line_clean="$(printf "%s" "$_hostup_find_line" | tr -d '\r\n')"
+ _hostup_find_line_value_clean="$(printf "%s" "$_hostup_find_line_clean" | sed 's/\\"//g')"
- case "$line_clean" in
- *'"type":"TXT"'*'"name"'*'"value"'*)
- name_value="$(_hostup_json_extract "name" "$line_clean")"
- record_value="$(_hostup_json_extract "value" "$line_value_clean")"
+ _hostup_find_record_type="$(_hostup_json_extract "type" "$_hostup_find_line_clean")"
+ [ "$_hostup_find_record_type" != "TXT" ] && continue
- _debug "hostup_record_raw" "$record_value"
- if [ "${record_value#\"}" != "$record_value" ] && [ "${record_value%\"}" != "$record_value" ]; then
- record_value="${record_value#\"}"
- record_value="${record_value%\"}"
- fi
- if [ "${record_value#\'}" != "$record_value" ] && [ "${record_value%\'}" != "$record_value" ]; then
- record_value="${record_value#\'}"
- record_value="${record_value%\'}"
- fi
- record_value="$(printf "%s" "$record_value" | tr -d '\r\n')"
- _debug "hostup_record_value" "$record_value"
+ _hostup_find_name_value="$(_hostup_json_extract "name" "$_hostup_find_line_clean")"
+ _hostup_find_record_value="$(_hostup_json_extract "value" "$_hostup_find_line_value_clean")"
- if [ "$name_value" = "$fqdn" ] && [ "$record_value" = "$txtvalue" ]; then
- record_id="$(_hostup_json_extract "id" "$line_clean")"
- if [ -n "$record_id" ]; then
- HOSTUP_RECORD_ID="$record_id"
- return 0
- fi
+ _debug "hostup_record_raw" "$_hostup_find_record_value"
+ if [ "${_hostup_find_record_value#\"}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\"}" != "$_hostup_find_record_value" ]; then
+ _hostup_find_record_value="${_hostup_find_record_value#\"}"
+ _hostup_find_record_value="${_hostup_find_record_value%\"}"
+ fi
+ if [ "${_hostup_find_record_value#\'}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\'}" != "$_hostup_find_record_value" ]; then
+ _hostup_find_record_value="${_hostup_find_record_value#\'}"
+ _hostup_find_record_value="${_hostup_find_record_value%\'}"
+ fi
+ _hostup_find_record_value="$(printf "%s" "$_hostup_find_record_value" | tr -d '\r\n')"
+ _debug "hostup_record_value" "$_hostup_find_record_value"
+
+ if [ "$_hostup_find_name_value" = "$_hostup_find_fqdn" ] && [ "$_hostup_find_record_value" = "$_hostup_find_txtvalue" ]; then
+ _hostup_find_record_id="$(_hostup_json_extract "id" "$_hostup_find_line_clean")"
+ if [ -n "$_hostup_find_record_id" ]; then
+ HOSTUP_RECORD_ID="$_hostup_find_record_id"
+ return 0
fi
- ;;
- esac
+ fi
done <
Date: Wed, 1 Jul 2026 14:55:55 +0200
Subject: [PATCH 043/224] Adding custom Port definitions for truenas (#7033)
* closing bracket and adding port for customer installations
* adding savedeployconfig
* fixing shfmt
* changeing
---------
Co-authored-by: neil
---
deploy/truenas_ws.sh | 30 +++++++++++++++++++++++++-----
1 file changed, 25 insertions(+), 5 deletions(-)
diff --git a/deploy/truenas_ws.sh b/deploy/truenas_ws.sh
index df34f927..33e3dfa0 100644
--- a/deploy/truenas_ws.sh
+++ b/deploy/truenas_ws.sh
@@ -16,7 +16,12 @@
#
# # API KEY
# # Use the folowing URL to create a new API token: /ui/apikeys
-# export DEPLOY_TRUENAS_APIKEY="
Date: Wed, 1 Jul 2026 15:05:07 +0200
Subject: [PATCH 044/224] fix(dns_infomaniak): correctly detect API errors
(#7048)
The add/rm success check never rejected anything: for any non-empty API
response it always reported "Record added"/"Record deleted" and returned
0, so the _err branch was dead code. A valid key looked fine only because
the API call genuinely created the record; an invalid key returning
{"result":"error"} produced the same "Record added" output even though
nothing was created.
Root cause, in:
if [ -n "$response" ]; then
if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then
- _contains() ignores stdin (it reads only $1 and $2), so the piped
"$response" was discarded.
- The pattern '"result":"success"' was passed as $1 (the haystack),
leaving $2 (the needle) empty, so it ran:
echo '"result":"success"' | grep -- "" >/dev/null 2>&1
grep with an empty pattern always matches.
- That grep output is redirected to /dev/null, so the command
substitution always captured "", making [ ! "" ] always true.
Fix: call _contains "$response" '"result":"success"' directly and branch
on its exit code, so error responses now correctly fail (return 1).
Co-authored-by: neil
---
dnsapi/dns_infomaniak.sh | 19 ++++++++-----------
1 file changed, 8 insertions(+), 11 deletions(-)
diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh
index 0ae32b47..52417fef 100755
--- a/dnsapi/dns_infomaniak.sh
+++ b/dnsapi/dns_infomaniak.sh
@@ -85,12 +85,10 @@ dns_infomaniak_add() {
# API call
response=$(_post "$data" "${INFOMANIAK_API_URL}/2/zones/${zone}/records")
- if [ -n "$response" ]; then
- if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then
- _info "Record added"
- _debug "response: $response"
- return 0
- fi
+ if _contains "$response" '"result":"success"'; then
+ _info "Record added"
+ _debug "response: $response"
+ return 0
fi
_err "Could not create record."
_debug "Response: $response"
@@ -169,11 +167,10 @@ dns_infomaniak_rm() {
# API call
response=$(_post "" "${INFOMANIAK_API_URL}/2/zones/${zone}/records/${record_id}" "" DELETE)
- if [ -n "$response" ]; then
- if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then
- _info "Record deleted"
- return 0
- fi
+ if _contains "$response" '"result":"success"'; then
+ _info "Record deleted"
+ _debug "response: $response"
+ return 0
fi
_err "Could not delete record."
_debug "Response: $response"
From d0fcafe29b157b727a34a8961137022b3bb11950 Mon Sep 17 00:00:00 2001
From: neil
Date: Wed, 1 Jul 2026 21:22:36 +0800
Subject: [PATCH 045/224] fix
https://github.com/acmesh-official/acme.sh/issues/7035
---
acme.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/acme.sh b/acme.sh
index a7397be1..59700442 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7512,8 +7512,8 @@ Parameters:
--dnssleep The time in seconds to wait for all the txt records to propagate in dns api mode.
It's not necessary to use this by default, $PROJECT_NAME polls dns status by DOH automatically.
- -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256, ec-384, ec-521.
- -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096
+ -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521.
+ -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521.
--log [file] Specifies the log file. Defaults to \"$DEFAULT_LOG_FILE\" if argument is omitted.
--log-level <1|2> Specifies the log level, default is $DEFAULT_LOG_LEVEL.
--syslog <0|3|6|7> Syslog level, 0: disable syslog, 3: error, 6: info, 7: debug.
From a7ccfcf91d5843840f113a60e65ed11af3d438e8 Mon Sep 17 00:00:00 2001
From: neil
Date: Wed, 1 Jul 2026 21:33:24 +0800
Subject: [PATCH 046/224] fix
---
.github/workflows/GhostBSD.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml
index 2dd2412b..c77fdf2e 100644
--- a/.github/workflows/GhostBSD.yml
+++ b/.github/workflows/GhostBSD.yml
@@ -42,6 +42,8 @@ jobs:
# CA_EMAIL: "githubtest@acme.sh"
# TEST_PREFERRED_CHAIN: ""
runs-on: ubuntu-latest
+ # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run
+ continue-on-error: true
env:
TEST_LOCAL: 1
TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
From 81100db2f3f4ead0c6e28a48a939aaee7f401a08 Mon Sep 17 00:00:00 2001
From: neil
Date: Wed, 1 Jul 2026 21:59:41 +0800
Subject: [PATCH 047/224] fix
https://github.com/acmesh-official/acme.sh/issues/6498
---
dnsapi/dns_joker.sh | 51 ++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 48 insertions(+), 3 deletions(-)
diff --git a/dnsapi/dns_joker.sh b/dnsapi/dns_joker.sh
index 401471be..0ad80327 100644
--- a/dnsapi/dns_joker.sh
+++ b/dnsapi/dns_joker.sh
@@ -35,9 +35,28 @@ dns_joker_add() {
return 1
fi
+ # Joker's /nic/replace overwrites all TXT records at the label on every call,
+ # and the API is not readable, so accumulate the values locally (keyed by the
+ # full record name) and re-send the whole set each time. This is required so a
+ # wildcard cert (base + *.domain both validating under the same
+ # _acme-challenge label) does not overwrite its own first challenge value.
+ _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_')
+ _joker_values=$(_readdomainconf "$_joker_conf_key")
+ if [ -z "$_joker_values" ]; then
+ _joker_values="$txtvalue"
+ elif ! _contains " $_joker_values " " $txtvalue "; then
+ _joker_values="$_joker_values $txtvalue"
+ fi
+
+ _joker_value_params=""
+ for _joker_v in $_joker_values; do
+ _joker_value_params="$_joker_value_params&value=$_joker_v"
+ done
+
_info "Adding TXT record"
- if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value=$txtvalue"; then
+ if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then
if _startswith "$response" "OK"; then
+ _savedomainconf "$_joker_conf_key" "$_joker_values"
_info "Added, OK"
return 0
fi
@@ -59,10 +78,36 @@ dns_joker_rm() {
return 1
fi
+ # Remove only this value from the accumulated set and replace the label with
+ # whatever remains (an empty value clears the label's TXT records entirely).
+ _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_')
+ _joker_values=$(_readdomainconf "$_joker_conf_key")
+ _joker_remaining=""
+ for _joker_v in $_joker_values; do
+ if [ "$_joker_v" != "$txtvalue" ]; then
+ _joker_remaining="$_joker_remaining $_joker_v"
+ fi
+ done
+ _joker_remaining=$(printf "%s" "$_joker_remaining" | sed 's/^ *//')
+
+ _joker_value_params=""
+ for _joker_v in $_joker_remaining; do
+ _joker_value_params="$_joker_value_params&value=$_joker_v"
+ done
+ if [ -z "$_joker_value_params" ]; then
+ _joker_value_params="&value="
+ fi
+
_info "Removing TXT record"
- # TXT record is removed by setting its value to empty.
- if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value="; then
+ # TXT record is removed by replacing the label with the remaining values
+ # (or an empty value, which clears all TXT records at the label).
+ if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then
if _startswith "$response" "OK"; then
+ if [ -z "$_joker_remaining" ]; then
+ _cleardomainconf "$_joker_conf_key"
+ else
+ _savedomainconf "$_joker_conf_key" "$_joker_remaining"
+ fi
_info "Removed, OK"
return 0
fi
From c83eed499473861631d4316f9a6831db1251b5b9 Mon Sep 17 00:00:00 2001
From: bluenenschloss
Date: Thu, 2 Jul 2026 07:00:49 +0200
Subject: [PATCH 048/224] dns_inwx: fix IDN zone detection without python
dependency (#7056)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* dns_inwx: fix IDN zone detection without python dependency
INWX returns zone names in Unicode form (e.g. lünenschloß.de) even when
the domain was registered as an IDN. When acme.sh passes the SAN in
punycode (xn--lnenschlo-o1a42a.de), _contains never matches and
_get_root falls through to the TLD, placing the TXT record in the wrong
zone.
Previous fix used python3 which is not available in all environments
(BusyBox, BSD, minimal containers). Replace with _idn()-based approach:
extract values from the nameserver.list XML response, encode
each via _idn(), and compare to $h. When a match is found, use the
original Unicode zone name for createRecord.
Fixes #7038
* dns_inwx: fix shebang, use _egrep_o, shfmt cleanup
- Revert shebang to #!/usr/bin/env sh (POSIX sh, fixes ShellCheck)
- Replace grep -o with _egrep_o for portability
- shfmt -i 2: drop backslash continuation after pipe, fix indentation
Requested by @neilpang
* fix: drop closing from _egrep_o pattern to avoid sed delimiter collision
---------
Co-authored-by: bluenenschloss
---
dnsapi/dns_inwx.sh | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/dnsapi/dns_inwx.sh b/dnsapi/dns_inwx.sh
index 2cf91404..dba23846 100755
--- a/dnsapi/dns_inwx.sh
+++ b/dnsapi/dns_inwx.sh
@@ -312,6 +312,22 @@ _get_root() {
_domain="$h"
return 0
fi
+ # IDN fallback: INWX returns Unicode zone names; when $h is ACE/punycode,
+ # encode each zone name via _idn() and compare — no python dependency.
+ if _contains "$h" "xn--"; then
+ _zone_unicode=$(printf "%s" "$response" | _egrep_o '[^<]*' |
+ sed 's/<[^>]*>//g' | while IFS= read -r _z; do
+ if [ "$(_idn "$_z")" = "$h" ]; then
+ printf "%s" "$_z"
+ break
+ fi
+ done)
+ if [ -n "$_zone_unicode" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain="$_zone_unicode"
+ return 0
+ fi
+ fi
p=$i
i=$(_math "$i" + 1)
done
From b039ff3087e84ce664eedb048bb2e539f881f218 Mon Sep 17 00:00:00 2001
From: pxMan79
Date: Thu, 2 Jul 2026 13:02:30 +0800
Subject: [PATCH 049/224] fix(dns_baidu): prefer new Baidu DNS API with legacy
BCD fallback (#6992)
* fix(acme): prefer new Baidu DNS API with legacy BCD fallback
Keep the existing BCD implementation and add fallback support for the newer Baidu DNS record API. Prefer the new API by default, then fall back to the legacy BCD API to reduce compatibility risk.
* fix(dns_baidu): route through _get/_post + restore legacy BCD auth headers
Per review: _baidu_dns_call now uses _get/_post with _H1.._H5 (no raw curl, no __HTTP_STATUS__ parsing); _baidu_bcd_post restores _H1.._H5 so the legacy BCD path sends the Authorization signature again (fixes 401).
---------
Co-authored-by: neil
---
dnsapi/dns_baidu.sh | 329 +++++++++++++++++++++++++++++++++++++-------
1 file changed, 278 insertions(+), 51 deletions(-)
diff --git a/dnsapi/dns_baidu.sh b/dnsapi/dns_baidu.sh
index 8651deab..dfad8eeb 100644
--- a/dnsapi/dns_baidu.sh
+++ b/dnsapi/dns_baidu.sh
@@ -49,26 +49,95 @@ Options:
Baidu_SK SecretAccessKey
OptionsAlt:
Baidu_BCD_Host API host, default: bcd.baidubce.com
+ Baidu_DNS_Host New DNS API host, default: dns.baidubce.com
+ Baidu_API_Preference Engine preference, default: auto
Baidu_BCD_Version API version number, default: 1
Baidu_BCD_Expire Signature expiration seconds, default: 3600
Baidu_View Resolve view, default: DEFAULT
+ Baidu_Line New DNS line, default: default
Baidu_TTL Resolve ttl seconds, default: 300
Baidu_RM_Max Max records to delete in one run, default: 20
'
BAIDU_BCD_DEFAULT_HOST="bcd.baidubce.com"
+BAIDU_DNS_DEFAULT_HOST="dns.baidubce.com"
# --- Public API ---
dns_baidu_add() {
fulldomain=$(_idn "$1")
txtvalue=$2
- if ! _baidu_prepare_record "$fulldomain"; then
- _baidu_err "baidu_prepare_record failed for add: $fulldomain"
+ if ! _baidu_run_with_fallback "add" "$fulldomain" "$txtvalue"; then
+ _baidu_err "all baidu api engines failed for add: $fulldomain"
return 1
fi
- if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then
+ return 0
+}
+
+dns_baidu_rm() {
+ fulldomain=$(_idn "$1")
+ txtvalue=$2
+
+ if ! _baidu_run_with_fallback "rm" "$fulldomain" "$txtvalue"; then
+ _baidu_err "all baidu api engines failed for delete: $fulldomain"
+ return 1
+ fi
+
+ return 0
+}
+
+_baidu_run_with_fallback() {
+ _action="$1"
+ _fulldomain="$2"
+ _txtvalue="$3"
+
+ if ! _baidu_load_credentials; then
+ _baidu_err "baidu_load_credentials failed"
+ return 1
+ fi
+
+ for _baidu_api_engine in $(_baidu_engine_order); do
+ if ! _baidu_prepare_record "$_fulldomain"; then
+ _baidu_info "prepare failed for engine: $_baidu_api_engine"
+ continue
+ fi
+
+ if [ "$_action" = "add" ]; then
+ if _baidu_add_record "$_txtvalue"; then
+ return 0
+ fi
+ else
+ if _baidu_rm_record "$_txtvalue"; then
+ return 0
+ fi
+ fi
+
+ _baidu_info "engine failed, try next if available: $_baidu_api_engine"
+ done
+
+ return 1
+}
+
+_baidu_engine_order() {
+ _pref="$(_lower_case "$(_baidu_trim_ws "${Baidu_API_Preference:-auto}")")"
+ case "$_pref" in
+ legacy)
+ printf "%s" "legacy new"
+ ;;
+ new)
+ printf "%s" "new legacy"
+ ;;
+ *)
+ printf "%s" "new legacy"
+ ;;
+ esac
+}
+
+_baidu_add_record() {
+ _txtvalue="$1"
+
+ if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then
_baidu_err "baidu_find_record_ids failed for add: $_record_domain.$_zone_name"
return 1
fi
@@ -85,16 +154,28 @@ dns_baidu_add() {
_ttl="300"
;;
esac
- _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")"
- txtvalue="$(_baidu_trim_ws "$txtvalue")"
+
+ txtvalue="$(_baidu_trim_ws "$_txtvalue")"
_record_domain="$(_baidu_trim_ws "$_record_domain")"
_zone_name="$(_baidu_trim_ws "$_zone_name")"
- _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")"
-
- if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then
- _baidu_err "baidu_bcd_post failed: add record"
- return 1
+ if [ "$_baidu_api_engine" = "new" ]; then
+ _line="$(_baidu_trim_ws "${Baidu_Line:-default}")"
+ if [ -z "$_line" ]; then
+ _line="default"
+ fi
+ _body="$(_baidu_payload_add_txt_dns "$_record_domain" "$txtvalue" "$_ttl" "$_line")"
+ if ! _baidu_dns_call "POST" "/v1/dns/zone/${_zone_name}/record" "$_body"; then
+ _baidu_err "baidu_dns_call failed: add record"
+ return 1
+ fi
+ else
+ _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")"
+ _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")"
+ if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then
+ _baidu_err "baidu_bcd_post failed: add record"
+ return 1
+ fi
fi
if _baidu_is_api_error "$response"; then
@@ -105,16 +186,10 @@ dns_baidu_add() {
return 0
}
-dns_baidu_rm() {
- fulldomain=$(_idn "$1")
- txtvalue=$2
+_baidu_rm_record() {
+ _txtvalue="$1"
- if ! _baidu_prepare_record "$fulldomain"; then
- _baidu_err "baidu_prepare_record failed for delete: $fulldomain"
- return 1
- fi
-
- if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then
+ if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then
_baidu_err "baidu_find_record_ids failed for delete: $_record_domain.$_zone_name"
return 1
fi
@@ -138,28 +213,37 @@ dns_baidu_rm() {
fi
for _rid in $_ids; do
- _body="$(_baidu_payload_delete "$_zone_name" "$_rid")"
- if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then
- _baidu_err "baidu_bcd_post failed: delete recordId=$_rid"
- return 1
- fi
- if _baidu_is_api_error "$response"; then
- _baidu_err "$response"
- return 1
+ if [ "$_baidu_api_engine" = "new" ]; then
+ if ! _baidu_dns_call "DELETE" "/v1/dns/zone/${_zone_name}/record/${_rid}" ""; then
+ _baidu_err "baidu_dns_call failed: delete recordId=$_rid"
+ return 1
+ fi
+ else
+ _body="$(_baidu_payload_delete "$_zone_name" "$_rid")"
+ if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then
+ _baidu_err "baidu_bcd_post failed: delete recordId=$_rid"
+ return 1
+ fi
+ if _baidu_is_api_error "$response"; then
+ _baidu_err "$response"
+ return 1
+ fi
fi
done
- if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then
- _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name"
- return 1
- fi
- _left_ids="$_BAIDU_FIND_RESULT"
- if [ -z "$_left_ids" ]; then
- return 0
- fi
- if [ -n "$_left_ids" ]; then
- _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records"
- return 1
+ if [ "$_baidu_api_engine" = "legacy" ]; then
+ if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then
+ _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name"
+ return 1
+ fi
+ _left_ids="$_BAIDU_FIND_RESULT"
+ if [ -z "$_left_ids" ]; then
+ return 0
+ fi
+ if [ -n "$_left_ids" ]; then
+ _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records"
+ return 1
+ fi
fi
return 0
@@ -182,6 +266,7 @@ _baidu_load_credentials() {
_saveaccountconf_mutable Baidu_SK "$Baidu_SK"
BAIDU_BCD_HOST="${Baidu_BCD_Host:-$BAIDU_BCD_DEFAULT_HOST}"
+ BAIDU_DNS_HOST="${Baidu_DNS_Host:-$BAIDU_DNS_DEFAULT_HOST}"
BAIDU_BCD_VERSION="${Baidu_BCD_Version:-1}"
return 0
@@ -189,13 +274,16 @@ _baidu_load_credentials() {
_baidu_prepare_record() {
_fulldomain="$1"
- if ! _baidu_load_credentials; then
- _baidu_err "baidu_load_credentials failed"
- return 1
- fi
- if ! _baidu_get_root "$_fulldomain"; then
- _baidu_err "Could not find zone for $_fulldomain"
- return 1
+ if [ "$_baidu_api_engine" = "new" ]; then
+ if ! _baidu_get_root_dns "$_fulldomain"; then
+ _baidu_err "Could not find zone by new dns api for $_fulldomain"
+ return 1
+ fi
+ else
+ if ! _baidu_get_root "$_fulldomain"; then
+ _baidu_err "Could not find zone by legacy bcd api for $_fulldomain"
+ return 1
+ fi
fi
_record_domain="$_sub_domain"
_zone_name="$_domain"
@@ -234,6 +322,43 @@ _baidu_get_root() {
done
}
+_baidu_get_root_dns() {
+ domain=$1
+ i=1
+ p=1
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ if [ -z "$h" ]; then
+ _baidu_err "invalid domain: $domain"
+ return 1
+ fi
+
+ if ! _baidu_dns_call "GET" "/v1/dns/zone/${h}/record" ""; then
+ _baidu_info "baidu_dns_call failed: list zones"
+ elif ! _baidu_is_api_error "$response" && (_contains "$response" "\"records\"" || _contains "$response" "\"maxKeys\""); then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ if [ "$_sub_domain" = "$_domain" ]; then
+ _sub_domain="@"
+ fi
+ _baidu_info "zone matched by dns api: $_domain (host: $_sub_domain)"
+ return 0
+ fi
+
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+}
+
+_baidu_find_record_ids_current() {
+ if [ "$_baidu_api_engine" = "new" ]; then
+ _baidu_find_record_ids_dns "$@"
+ else
+ _baidu_find_record_ids "$@"
+ fi
+}
+
_baidu_find_record_ids() {
_zone_name="$1"
_record_domain="$2"
@@ -293,6 +418,39 @@ EOF
_BAIDU_FIND_RESULT="$_ids"
}
+_baidu_find_record_ids_dns() {
+ _zone_name="$1"
+ _record_domain="$2"
+ _rdtype="$3"
+ _rdata="$4"
+ _BAIDU_FIND_RESULT=""
+
+ if ! _baidu_dns_call "GET" "/v1/dns/zone/${_zone_name}/record" ""; then
+ _baidu_err "baidu_dns_call failed: list records"
+ return 1
+ fi
+
+ if _baidu_is_api_error "$response"; then
+ _baidu_err "baidu_dns error: $(_baidu_json_get_str "$response" "code") $(_baidu_json_get_str "$response" "message")"
+ return 1
+ fi
+
+ _normalized="$(printf "%s" "$response" | _normalizeJson)"
+ _records=$(printf "%s" "$_normalized" | sed 's/},{/}\n{/g')
+ _ids=""
+
+ while IFS= read -r _line; do
+ _id="$(_baidu_match_record_id_dns "$_line" "$_record_domain" "$_rdtype" "$_rdata")"
+ if [ "$_id" ]; then
+ _ids="$_ids $_id"
+ fi
+ done <
Date: Thu, 2 Jul 2026 07:06:41 +0200
Subject: [PATCH 050/224] Implemented support for Aruba Business DNS API
(#7042)
* Add support for arubabusiness api
* Fix formatting
* record names are always converted to lowercase
* Docs
* remove leftover unconditional authentication call
lowercase
urlencoded body + x-www-form-urlencoded content-type
cleanup header variables
cleanup typos
grammar
* Strengthen _ab_rest failure checks
Properly process parallel lists in _ab_dns_record_id
Remove hard fails when a txt record already exists
* fix json parsing
* Fix formatting
---------
Co-authored-by: Manwe-Sulimo
---
dnsapi/dns_arubabusiness.sh | 490 ++++++++++++++++++++++++++++++++++++
1 file changed, 490 insertions(+)
create mode 100644 dnsapi/dns_arubabusiness.sh
diff --git a/dnsapi/dns_arubabusiness.sh b/dnsapi/dns_arubabusiness.sh
new file mode 100644
index 00000000..90b3f18d
--- /dev/null
+++ b/dnsapi/dns_arubabusiness.sh
@@ -0,0 +1,490 @@
+#!/usr/bin/env sh
+
+# shellcheck disable=SC2034
+dns_arubabusiness_info='ArubaBusiness
+Site: business.aruba.it
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_arubabusiness
+Options:
+ AB_Key Your ArubaBusiness API Key
+ AB_User Your account user
+ AB_Pass Your account password
+'
+
+#
+# A word of warning: as of this writing, api.arubabusiness.it only supports oauth authentication using the "password" grant type.
+# If you are REALLY sure you want to use it, it would be wise set up a dedicated technical user without administrative privileges
+#
+
+ARUBABUSINESS_API='https://api.arubabusiness.it'
+
+######## Public functions ########
+
+#
+# Usage: dns_arubabusiness_add _acme-challenge.www.domain.com aaaabbbbcccc111122223333
+#
+# Add a new TXT record whose name and value match the given domain and value
+#
+# Variables
+# _full_domain: $1 - the name of the TXT record
+# _txt_value: $2 - the value of the TXT record
+# _body
+# dns_details
+# domain_id
+# dns_record_id
+# response
+#
+dns_arubabusiness_add() {
+ _full_domain=$1
+ _txt_value=$2
+
+ if ! _ab_authenticate; then
+ return 1
+ fi
+
+ if ! _ab_domain_id "$_full_domain"; then
+ return 1
+ fi
+
+ if _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then
+ # This is very unlikely, but allow the process to use the existing record
+ _info "A TXT record with name: $_full_domain and value: $_txt_value already exists (id: $dns_record_id)"
+ return 0
+ fi
+
+ _body="{ \"IdDomain\": $domain_id, \"Type\": \"TXT\", \"Name\": \"$_full_domain\", \"Content\": \"\\\"$_txt_value\\\"\" }"
+
+ _debug "Adding TXT record with name: $_full_domain and value: $_txt_value"
+
+ if ! _ab_rest POST "api/domains/dns/record" "$_body" || ! _contains "$response" "DomainId"; then
+ _err "Failed to add TXT record with name: $_full_domain"
+ return 1
+ fi
+
+ _info "Sleeping 10 seconds to let ArubaBusiness do its magic"
+ _sleep 10
+
+ # Refresh dns details and check that the record was really added
+ if ! _ab_dns_details "$root_domain"; then
+ return 1
+ fi
+
+ if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then
+ # This should never happen
+ _err "The TXT record with name: $_full_domain was not set"
+ _err "Please check that the dns records are clean"
+ return 1
+ fi
+
+ _info "Added TXT record with id: $dns_record_id"
+ return 0
+}
+
+#
+# Usage: dns_arubabusiness_rm _acme-challenge.www.domain.com aaaabbbbcccc111122223333
+#
+# Remove the TXT record whose name and value match the given domain and value
+#
+# Variables
+# _full_domain: $1 - the name of the TXT record
+# _txt_value: $2 - the value of the TXT record
+# dns_details
+# dns_record_id
+#
+dns_arubabusiness_rm() {
+ _full_domain=$1
+ _txt_value=$2
+
+ if ! _ab_authenticate; then
+ return 1
+ fi
+
+ if ! _ab_domain_id "$_full_domain"; then
+ return 1
+ fi
+
+ if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details" || [ -z "$dns_record_id" ]; then
+ _err "Could not retrieve the record id for: $_full_domain"
+ return 1
+ fi
+
+ _debug "Deleting TXT record: $dns_record_id"
+ if ! _ab_rest DELETE "api/domains/dns/record/$dns_record_id" || ! _contains "$response" "DomainId"; then
+ _err "Failed to delete TXT record: $dns_record_id"
+ return 1
+ fi
+
+ _info "Deleted TXT record: $dns_record_id"
+ return 0
+}
+
+######## Private functions ########
+
+#
+# Usage: _ab_domain_id _acme-challenge.www.domain.com
+#
+# Split the input domain into subdomain + root domain and get the id of the root domain
+#
+# Variables
+# _full_domain: $1 - the domain whose root needs to be extracted
+# _domain_sections
+# _current_index
+# _candidate_subdomain
+# _candidate_domain
+# sub_domain
+# root_domain
+# domain_id
+# dns_details: a json containing all dns records registered on the root domain
+#
+# Example
+# _get_root _acme-challenge.www.domain.com
+#
+# Should return
+# sub_domain=_acme-challenge.www
+# root_domain=domain.com
+# domain_id=123123123123
+# dns_details="{JSON_CONTENT}"
+#
+_ab_domain_id() {
+ _full_domain=$1
+
+ _info "Attempting to retrieve root domain details for: $_full_domain"
+
+ _domain_sections=$(_math "$(printf "%s" "$_full_domain" | tr '.' '\n' | wc -l)" + 1)
+
+ if [ "$_domain_sections" -lt 1 ]; then
+ _err "Invalid input $_full_domain"
+ return 1
+ fi
+
+ _current_index=1
+ while true; do
+ _candidate_subdomain=$(if [ "$_current_index" = "1" ]; then printf ""; else printf "%s" "$_full_domain" | cut -d . -f 1-"$(_math "$_current_index" - 1)"; fi)
+ _candidate_domain=$(printf "%s" "$_full_domain" | cut -d . -f "$_current_index"-"$_domain_sections")
+
+ if ! _ab_dns_details "$_candidate_domain"; then
+ _debug2 "Could not fetch dns details for: $_candidate_domain"
+ _current_index=$(_math "$_current_index" + 1)
+
+ # Fail if there are no candidates left
+ if [ "$_current_index" -gt "$_domain_sections" ]; then
+ _err "Could not determine the root domain for: $_full_domain"
+ return 1
+ fi
+ else
+ sub_domain="$_candidate_subdomain"
+ root_domain="$_candidate_domain"
+ # Extract the domain id, which is an integer and contains no commas
+ domain_id="$(printf "%s" "$dns_details" | _egrep_o '"Id":[^,]*' | _head_n 1 | cut -d : -f 2 | tr -d ' "')"
+
+ if [ -z "$domain_id" ]; then
+ _err "Could not determine the domain id for: $root_domain"
+ return 1
+ fi
+
+ _debug "Retrieved root domain id: $domain_id"
+ return 0
+ fi
+ done
+}
+
+#
+# Usage: _ab_dns_record_id _acme-challenge.www.domain.com "aaaabbbbcccc111122223333" "{JSON_CONTENT}"
+#
+# Extract the record id of the first TXT record whose name and content match the input values
+#
+# Variables
+# _record_name: $1
+# _txt_value: $2
+# _dns_details: $3 - the json returned by a previous call to '_ab_dns_details() $root_domain'
+# _record_ids
+# _record_names
+# _record_types
+# _record_contents
+# _record_ids_count
+# _record_names_count
+# _record_types_count
+# _record_contents_count
+# _i
+# dns_record_id
+#
+# Notes
+# TXT correspond to record type 5
+# ArubaBusiness appends a terminating dot (.) to the record name
+# The content field may contain the following character sequence: \"
+# All record names are always converted to lowercase
+#
+_ab_dns_record_id() {
+ _record_name=$1
+ _txt_value=$2
+ _dns_details=$3
+
+ _record_name_lowercase=$(printf "%s" "$_record_name" | _lower_case)
+
+ # Extract the record ids, which are integers and contain no commas, colons or spaces
+ # The first id is skipped because it refers to the domain id
+ _record_ids=$(printf "%s" "$_dns_details" | sed 's/"Id":/\n"Id":/g' | _egrep_o '"Id":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ')
+
+ # Extract the record names, which are strings but cannot contain commas, colons, spaces and quotes
+ # The first name is skipped because it refers to the domain name
+ _record_names=$(printf "%s" "$_dns_details" | sed 's/"Name":/\n"Name":/g' | _egrep_o '"Name":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' "' | tr '\n' ' ')
+
+ # Extract the record types, which are integers (except for the first one) and contain no commas, colons or spaces
+ # The first type is skipped because it refers to the domain type
+ _record_types=$(printf "%s" "$_dns_details" | sed 's/"Type":/\n"Type":/g' | _egrep_o '"Type":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ')
+
+ # Extract the record contents, which are strings and may contain no quotes except for TXT records, which must be delimited by two \" literals
+ # Note: There is no domain related entry here
+ # Note: A " character is appended at the end of each content to make it easier to process the list later
+ _record_contents=$(printf "%s" "$_dns_details" | sed 's/"Content":/\n"Content":/g' | sed 's/\\"//g' | _egrep_o '"Content": *"[^"]*"' | cut -d : -f 2- | sed -n 's/"\(.*\)"/\1/p' | tr '\n' '#')
+
+ _info "IDS: $_record_ids"
+ _info "NAMES: $_record_names"
+ _info "TYPEs: $_record_types"
+ _info "CONTENTS: $_record_contents"
+
+ _record_ids_count=$(printf "%s" "$_record_ids" | tr ' ' '\n' | wc -l)
+ _record_names_count=$(printf "%s" "$_record_names" | tr ' ' '\n' | wc -l)
+ _record_types_count=$(printf "%s" "$_record_types" | tr ' ' '\n' | wc -l)
+ _record_contents_count=$(printf "%s" "$_record_contents" | tr '#' '\n' | wc -l)
+
+ _info "Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count"
+
+ if [ "$_record_ids_count" != "$_record_names_count" ] || [ "$_record_ids_count" != "$_record_types_count" ] || [ "$_record_ids_count" != "$_record_contents_count" ]; then
+ _err "Failed to parse record elements. Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count"
+ return 1
+ fi
+
+ _info "Looking for a TXT record matching inputs - name: $_record_name_lowercase value: $_txt_value"
+
+ _i=1
+ while [ "$_i" -le "$_record_ids_count" ]; do
+ _current_name=$(printf "%s" "$_record_names" | cut -d " " -f "$_i")
+ _current_type=$(printf "%s" "$_record_types" | cut -d " " -f "$_i")
+ _current_content=$(printf "%s" "$_record_contents" | cut -d "#" -f "$_i")
+
+ if [ "$_record_name_lowercase." = "$_current_name" ] && [ "5" = "$_current_type" ] && [ "$_txt_value" = "$_current_content" ]; then
+ dns_record_id=$(printf "%s" "$_record_ids" | cut -d " " -f "$_i")
+ _info "Found matching record with id: $dns_record_id"
+ return 0
+ else
+ _debug2 "Record does not match - type: '$_current_type' name: '$_current_name' value: '$_current_content'; Expected '$_record_name_lowercase.' '5' '$_txt_value'"
+ fi
+ _i=$(_math "$_i" + 1)
+ done
+
+ _debug2 "No matching record was found in $_dns_details"
+ return 1
+}
+
+#
+# Usage: _ab_dns_details domain.com
+#
+# Retrieve dns info for the given input domain
+#
+# Variables
+# _domain: $1
+# dns_details: the json returned by the call to $ARUBABUSINESS_API/api/domains/dns/$_domain/details (if return status is 0)
+# response
+#
+_ab_dns_details() {
+ _domain=$1
+
+ if ! _ab_rest GET "api/domains/dns/$_domain/details" || ! _contains "$response" "DomainId"; then
+ return 1
+ fi
+
+ dns_details="$response"
+ return 0
+}
+
+#
+# Usage: _ab_authenticate
+#
+# Read account conf, update domain conf and perform user authentication to acquire an access token
+#
+# Variables
+# AB_Key
+# AB_User
+# AB_Pass
+# AB_Token
+#
+_ab_authenticate() {
+ AB_Key="${AB_Key:-$(_readaccountconf_mutable AB_Key)}"
+ AB_User="${AB_User:-$(_readaccountconf_mutable AB_User)}"
+ AB_Pass="${AB_Pass:-$(_readaccountconf_mutable AB_Pass)}"
+
+ if [ -z "$AB_Key" ] || [ -z "$AB_User" ] || [ -z "$AB_Pass" ]; then
+ AB_Key=""
+ AB_User=""
+ AB_Pass=""
+ _err "Either the ArubaBusiness API key, the user or the password has not been defined yet."
+ _err "Please configure them and try again."
+ return 1
+ fi
+
+ _saveaccountconf_mutable AB_Key "$AB_Key"
+ _saveaccountconf_mutable AB_User "$AB_User"
+ _saveaccountconf_mutable AB_Pass "$AB_Pass"
+
+ if ! _ab_get_token || [ -z "$AB_Token" ]; then
+ _err "Failed to acquire an access token"
+ return 1
+ fi
+
+ return 0
+}
+
+#
+# Usage: _ab_get_token
+#
+# Try acquiring a temporary access token. The token should have a 24h lifespan
+#
+# Variables
+# _ab_user_enc
+# _ab_pass_enc
+# _ab_authdata
+# AB_User
+# AB_Pass
+# AB_Token
+# response
+# _H2
+#
+_ab_get_token() {
+ _ab_user_enc=$(printf "%s" "$AB_User" | _url_encode)
+ _ab_pass_enc=$(printf "%s" "$AB_Pass" | _url_encode)
+ _ab_authdata="grant_type=password&username=$_ab_user_enc&password=$_ab_pass_enc"
+
+ _H2="Content-Type: application/x-www-form-urlencoded"
+
+ if ! _ab_rest POST "auth/token" "$_ab_authdata" || ! _contains "$response" "access_token"; then
+ _err "Authentication failure"
+ return 1
+ fi
+
+ AB_Token="$(printf "%s" "$response" | _egrep_o '"access_token":"[^\"]*"' | cut -d : -f 2 | tr -d '"')"
+
+ if [ -z "$AB_Token" ]; then
+ _err "Could not extract access token"
+ return 1
+ fi
+
+ _debug "Acquired access token"
+ return 0
+}
+
+#
+# Usage: _ab_rest POST "example/endpoint" "password=123"
+#
+# Perform a REST request using the given method, endpoint and data
+#
+# Variables
+# _method: $1 - The http method
+# _endpoint: $2 - The api path (relative to $ARUBABUSINESS_API)
+# _data: $3 - The body of the request (optional)
+# _key_trimmed
+# _token_trimmed
+# _ret_code
+# AB_Key
+# AB_Token
+# ARUBABUSINESS_API
+# _H1
+# _H2
+# _H3
+# _H4
+#
+_ab_rest() {
+ _method=$1
+ _endpoint="$2"
+ _data="$3"
+
+ _key_trimmed=$(printf "%s" "$AB_Key" | tr -d '"')
+ _token_trimmed=$(printf "%s" "$AB_Token" | tr -d '"')
+
+ _H1="Accept: application/json"
+
+ if [ -z "$_H2" ]; then
+ # Default to application/json
+ _H2="Content-Type: application/json"
+ fi
+
+ if [ "$_key_trimmed" ]; then
+ _H3="Authorization-Key: $_key_trimmed"
+ else
+ _err "Missing Api Key"
+ _ab_cleanup_headers
+ return 1
+ fi
+
+ if [ "$_token_trimmed" ]; then
+ _H4="Authorization: Bearer $_token_trimmed"
+ else
+ _debug "No access token set"
+ fi
+
+ if [ "$_method" != "GET" ]; then
+ response="$(_post "$_data" "$ARUBABUSINESS_API/$_endpoint" "" "$_method")"
+ else
+ response="$(_get "$ARUBABUSINESS_API/$_endpoint")"
+ fi
+
+ _ret_code=$?
+
+ if [ "$_ret_code" = "0" ] && _ab_call_is_success; then
+ # Normalize the json response
+ response="$(printf "%s" "$response" | _normalizeJson)"
+ _ret_code=0
+ else
+ _err "Failed to call endpoint: $_endpoint"
+ _ret_code=1
+ fi
+
+ _ab_cleanup_headers
+
+ return $_ret_code
+}
+
+#
+# Usage: _ab_cleanup_headers
+#
+# Unset header variables to avoid interfering with other calls
+#
+# Variables
+# _H1
+# _H2
+# _H3
+# _H4
+#
+_ab_cleanup_headers() {
+ # Cleanup request headers
+ unset _H1 _H2 _H3 _H4 _H5
+
+ # Cleanup response headers
+ if [ -f "$HTTP_HEADER" ]; then
+ : >"$HTTP_HEADER"
+ fi
+}
+
+#
+# Usage: _ab_call_is_success
+#
+# Check whether a call's response http status is one of 200, 201, 202 or 204 (other 2xx are not handled)
+#
+# Variables
+# _status
+# _http_status
+# _success_http_codes
+# HTTP_HEADER
+#
+_ab_call_is_success() {
+ _success_http_codes="200 201 202 204"
+ if [ -f "$HTTP_HEADER" ]; then
+ _http_status=$(_egrep_o "^HTTP[\/0-9. ]*" <"$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2)
+ for _status in $_success_http_codes; do
+ if [ "$_status" = "$_http_status" ]; then
+ return 0
+ fi
+ done
+ fi
+
+ return 1
+}
From e52d75d762fc389966915d7952328e390d24424e Mon Sep 17 00:00:00 2001
From: neil
Date: Thu, 2 Jul 2026 13:09:48 +0800
Subject: [PATCH 051/224] fix
https://github.com/acmesh-official/acme.sh/issues/7062
---
dnsapi/dns_selfhost.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh
index 4912dfdf..40cc0210 100644
--- a/dnsapi/dns_selfhost.sh
+++ b/dnsapi/dns_selfhost.sh
@@ -18,7 +18,7 @@ dns_selfhost_add() {
_debug fulldomain "$fulldomain"
_debug txtvalue "$txt"
- SELFHOSTDNS_UPDATE_URL="https://selfhost.de/cgi-bin/api.pl"
+ SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl"
# Get values, but don't save until we successfully validated
SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}"
From 5433ea86c8fcf10c038f75f93cf3a5f12e4a3afe Mon Sep 17 00:00:00 2001
From: Toni Karppi
Date: Thu, 2 Jul 2026 07:10:29 +0200
Subject: [PATCH 052/224] Add Glesys dnsapi provider (#7059)
* Add Glesys dnsapi provider
* Fix typo in error message for dns_glesys_add
* Use API to get record id in Glesys provider
* Use listrecords API endpoint to find root domain
* Remove record id parsin from add function
---
dnsapi/dns_glesys.sh | 263 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 263 insertions(+)
create mode 100644 dnsapi/dns_glesys.sh
diff --git a/dnsapi/dns_glesys.sh b/dnsapi/dns_glesys.sh
new file mode 100644
index 00000000..008abd12
--- /dev/null
+++ b/dnsapi/dns_glesys.sh
@@ -0,0 +1,263 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_glesys_info='Glesys
+Site: Glesys.se
+Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_glesys
+Options:
+ GLESYS_API_KEY Generated API key.
+ GLESYS_PROJECT_ID Project ID for the API key (e.g. cl12345).
+ GLESYS_API API endpoint. Default "https://api.glesys.com/domain".
+ GLESYS_TTL TXT record TTL. Default 120.
+Issues: https://github.com/acmesh-official/acme.sh/issues/7057
+Author: Toni Karppi
+'
+
+GLESYS_API_DEFAULT="https://api.glesys.com/domain"
+GLESYS_TTL_DEFAULT="120"
+
+######## Public functions #####################################################
+
+# Usage:
+# dns_glesys_add _acme-challenge.www.example.com "txt-value"
+dns_glesys_add() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ _glesys_init || return 1
+
+ if ! _glesys_get_root "$fulldomain"; then
+ _err "Could not find root zone for $fulldomain"
+ return 1
+ fi
+
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ host_value="${_sub_domain:-@}"
+ _debug _host_value "$host_value"
+
+ data="{\"domainname\":\"$_domain\",\"host\":\"$host_value\",\"type\":\"TXT\",\"data\":\"$txtvalue\",\"ttl\":\"$GLESYS_TTL\"}"
+
+ _debug2 data "$data"
+
+ if ! _glesys_rest POST "/addrecord" "$data"; then
+ _err "Failed to send HTTP request to add TXT record"
+ return 1
+ fi
+
+ response_code=$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ _egrep_o '"code":"?[0-9]+' |
+ _egrep_o '[0-9]+$'
+ )
+
+ _debug response_code "$response_code"
+
+ if [ "$response_code" != "200" ]; then
+ _err "GleSYS API responded with an unexpected status when attempting to add TXT record"
+ _debug2 "API response" "$response"
+ return 1
+ fi
+
+ _info "TXT record added"
+
+ return 0
+}
+
+# Usage:
+# dns_glesys_rm _acme-challenge.www.example.com "txt-value"
+dns_glesys_rm() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ _glesys_init || return 1
+
+ if ! _glesys_get_root "$fulldomain"; then
+ _err "Could not find root zone for $fulldomain"
+ return 1
+ fi
+
+ if ! _glesys_find_record_id "$txtvalue"; then
+ _info "TXT record not present, skip removal"
+ return 0
+ fi
+
+ _debug _record_id "$_record_id"
+
+ if ! _glesys_rest POST "/deleterecord" "{\"recordid\":$_record_id}"; then
+ _err "Failed to send HTTP request to remove TXT record"
+ return 1
+ fi
+
+ response_code=$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ _egrep_o '"code":"?[0-9]+' |
+ _egrep_o '[0-9]+$'
+ )
+
+ _debug response_code "$response_code"
+
+ if [ "$response_code" != "200" ]; then
+ _err "GleSYS API responded with unexpected status when attempting to remove TXT record"
+ _debug2 "API response" "$response"
+ return 1
+ fi
+
+ _info "TXT record removed"
+
+ return 0
+}
+
+######## Private functions ####################################################
+
+_glesys_find_record_id() {
+ txtvalue="$1"
+
+ _debug txtvalue "$txtvalue"
+
+ if [ -z "$txtvalue" ]; then
+ return 1
+ fi
+
+ _record_id=""
+
+ _debug "Looking for TXT record with value" "$txtvalue"
+
+ if ! _glesys_rest GET "/listrecords?domainname=$_domain"; then
+ _err "Failed to list DNS records"
+ return 1
+ fi
+
+ records="$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ sed 's/},{/}\
+{/g'
+ )"
+
+ _debug2 records "$records"
+
+ expected_data="\"data\":\"$txtvalue\""
+
+ _record_id="$(
+ printf "%s\n" "$records" |
+ while IFS= read -r record; do
+ printf "%s" "$record" | grep -q '"type":"TXT"' || continue
+ printf "%s" "$record" | grep -Fq "$expected_data" || continue
+
+ printf "%s" "$record" |
+ grep -E -o '"recordid":"?[0-9]+' |
+ grep -E -o '[0-9]+$'
+
+ break
+ done
+ )"
+
+ _debug _record_id "$_record_id"
+
+ if [ -z "$_record_id" ]; then
+ return 1
+ fi
+
+ return 0
+}
+
+# Finds:
+# _domain example.com
+# _sub_domain _acme-challenge.www
+_glesys_get_root() {
+ domain="$1"
+ i=1
+
+ while true; do
+ h="$(printf "%s" "$domain" | cut -d . -f "$i"-100)"
+
+ if [ -z "$h" ]; then
+ return 1
+ fi
+
+ if _glesys_rest GET "/listrecords?domainname=$h"; then
+ response_code=$(
+ printf "%s" "$response" |
+ tr -d '\r\n\t ' |
+ _egrep_o '"code":"?[0-9]+' |
+ _egrep_o '[0-9]+$'
+ )
+
+ _debug response_code "$response_code"
+
+ if [ "$response_code" = "200" ]; then
+ cut_len="$((${#domain} - ${#h} - 1))"
+ _domain="$h"
+ _sub_domain="$(printf "%s" "$domain" | cut -c "1-$cut_len")"
+ return 0
+ fi
+ fi
+
+ i="$((i + 1))"
+ done
+}
+
+_glesys_init() {
+ [ -z "$GLESYS_API" ] && GLESYS_API="$GLESYS_API_DEFAULT"
+ [ -z "$GLESYS_TTL" ] && GLESYS_TTL="$GLESYS_TTL_DEFAULT"
+
+ _debug GLESYS_API "$GLESYS_API"
+ _debug GLESYS_TTL "$GLESYS_TTL"
+
+ GLESYS_API_KEY="${GLESYS_API_KEY:-$(_readaccountconf_mutable GLESYS_API_KEY)}"
+ GLESYS_PROJECT_ID="${GLESYS_PROJECT_ID:-$(_readaccountconf_mutable GLESYS_PROJECT_ID)}"
+
+ if [ -z "$GLESYS_API_KEY" ] || [ -z "$GLESYS_PROJECT_ID" ]; then
+ _err "GLESYS_API_KEY and GLESYS_PROJECT_ID must be set for this provider"
+ return 1
+ fi
+
+ _secure_debug GLESYS_API_KEY "$GLESYS_API_KEY"
+ _secure_debug GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID"
+
+ _glesys_basic_auth="$(printf "%s:%s" "$GLESYS_PROJECT_ID" "$GLESYS_API_KEY" | _base64)"
+ _secure_debug2 _glesys_basic_auth "$_glesys_basic_auth"
+
+ _saveaccountconf_mutable GLESYS_API_KEY "$GLESYS_API_KEY"
+ _saveaccountconf_mutable GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID"
+
+ return 0
+}
+
+_glesys_rest() {
+ method="$1"
+ path="$2"
+ data="$3"
+
+ export _H1="Authorization: Basic $_glesys_basic_auth"
+ export _H2="Content-Type: application/json"
+ export _H3="Accept: application/json"
+
+ url="$GLESYS_API$path"
+ _debug "$method $url"
+
+ if [ "$method" = "GET" ]; then
+ response="$(_get "$url")"
+ else
+ response="$(_post "$data" "$url" "" "$method")"
+ fi
+
+ ret="$?"
+ _debug2 response "$response"
+ _debug ret "$ret"
+
+ if [ "$ret" != "0" ]; then
+ return 1
+ fi
+
+ return 0
+}
From a50fad865fd63cafe83efc3007b02748fdad9568 Mon Sep 17 00:00:00 2001
From: MarFri <163347538+M4rFri@users.noreply.github.com>
Date: Thu, 2 Jul 2026 17:08:59 +0200
Subject: [PATCH 053/224] DNS_IONOS double sending content type & case
sensitive mismatch (#7028)
* double sending content type results in error from ionos
* Normalize fulldomain to lowercase in the _ionos_get_record function.
---
dnsapi/dns_ionos.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/dnsapi/dns_ionos.sh b/dnsapi/dns_ionos.sh
index 9a464253..00662e82 100755
--- a/dnsapi/dns_ionos.sh
+++ b/dnsapi/dns_ionos.sh
@@ -16,7 +16,7 @@ IONOS_TXT_TTL=60 # minimum accepted by API
IONOS_TXT_PRIO=10
dns_ionos_add() {
- fulldomain=$1
+ fulldomain="$(echo "$1" | _lower_case)"
txtvalue=$2
if ! _ionos_init; then
@@ -34,7 +34,7 @@ dns_ionos_add() {
}
dns_ionos_rm() {
- fulldomain=$1
+ fulldomain="$(echo "$1" | _lower_case)"
txtvalue=$2
if ! _ionos_init; then
@@ -146,7 +146,7 @@ _ionos_rest() {
if [ "$method" != "GET" ]; then
export _H2="Accept: application/json"
- export _H3="Content-Type: application/json"
+ export _H3=
_response="$(_post "$data" "$IONOS_API$route" "" "$method" "application/json")"
else
From ccd2f04c338d2a4dffaa55539003014b713bba23 Mon Sep 17 00:00:00 2001
From: neil
Date: Thu, 2 Jul 2026 23:15:29 +0800
Subject: [PATCH 054/224] fix
https://github.com/acmesh-official/acme.sh/issues/6986
---
acme.sh | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/acme.sh b/acme.sh
index 59700442..ad8a3839 100755
--- a/acme.sh
+++ b/acme.sh
@@ -4707,6 +4707,13 @@ issue() {
else
_cleardomainconf "Le_ChallengeAlias"
fi
+ # Save Le_DNSSleep unconditionally here: the save inside the dns_entries
+ # branch is skipped when all authorizations are already valid (e.g. issuing
+ # the ECC twin of a just-issued RSA cert), which left the setting out of
+ # that cert's conf. https://github.com/acmesh-official/acme.sh/issues/6986
+ if [ "$Le_DNSSleep" ]; then
+ _savedomainconf "Le_DNSSleep" "$Le_DNSSleep"
+ fi
if [ "$_preferred_chain" ]; then
_savedomainconf "Le_Preferred_Chain" "$_preferred_chain" "base64"
else
From 42e13fa7970fcd69f54be937a3488d61644c7a33 Mon Sep 17 00:00:00 2001
From: neil
Date: Thu, 2 Jul 2026 23:18:26 +0800
Subject: [PATCH 055/224] fix
https://github.com/acmesh-official/acme.sh/issues/6851
---
dnsapi/dns_infomaniak.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh
index 52417fef..43ade8ce 100755
--- a/dnsapi/dns_infomaniak.sh
+++ b/dnsapi/dns_infomaniak.sh
@@ -129,7 +129,7 @@ dns_infomaniak_rm() {
fi
export _H1="Authorization: Bearer $INFOMANIAK_API_TOKEN"
- export _H2="ContentType: application/json"
+ export _H2="Content-Type: application/json"
fulldomain=$1
txtvalue=$2
From 116c05fbff85574b0065af7846cebe9c91c181e9 Mon Sep 17 00:00:00 2001
From: neil
Date: Thu, 2 Jul 2026 23:30:56 +0800
Subject: [PATCH 056/224] fix
https://github.com/acmesh-official/acme.sh/issues/6400
---
acme.sh | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index ad8a3839..d714d421 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7738,9 +7738,16 @@ _checkSudo() {
return 0
fi
if [ -n "$SUDO_COMMAND" ]; then
- #it's a normal user doing "sudo su", or `sudo -i` or `sudo -s`, or `sudo su acmeuser1`
- _endswith "$SUDO_COMMAND" /bin/su || _contains "$SUDO_COMMAND" "/bin/su " || grep "^$SUDO_COMMAND\$" /etc/shells >/dev/null 2>&1
- return $?
+ #The SUDO_* env vars are often inherited into shells that were not
+ #started as `sudo acme.sh` at all (e.g. `sudo su - user`, or
+ #`sudo pct enter ` on Proxmox, which copies them into the
+ #container). Only warn when sudo was used to run acme.sh itself;
+ #anything else means the sudo happened further up and is fine.
+ #https://github.com/acmesh-official/acme.sh/issues/6400
+ if _contains "$SUDO_COMMAND" "$PROJECT_ENTRY"; then
+ return 1
+ fi
+ return 0
fi
#otherwise
return 1
From ced8d72808810c69703f52eda1f48accdd9181fa Mon Sep 17 00:00:00 2001
From: "Simon V." <218359733+sim0n-v@users.noreply.github.com>
Date: Fri, 3 Jul 2026 09:12:32 +0200
Subject: [PATCH 057/224] ARI - Run cron job more frequently (#6939)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* ARI - Run cron job more frequently
With ACME Renewal Info (RFC9773 §4.3), fetching renewal window should be more frequent, e.g. in case of revocation incident.
"For instance, a server that needs to revoke certificates within 24 hours of notification of a problem might choose to reserve twelve hours for investigation, six hours for clients to fetch updated RenewalInfo objects, and six hours for clients to perform a renewal."
More flexible option is to run the cron job every hour and only refresh ARI when the last one + Retry-After header is in the past.
* Fix cron job schedule for certificate renewal
* Fix random_hour syntax in cron job installation
* Update Windows task scheduler to run more frequently
Add support for randomized hour and update frequency
Ref:
* [/mo](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create#to-schedule-a-task-to-run-every-n-hours)
* [/SC HOURLY](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create#parameters)
* Add padding for StartTime (/ST) in SCHTASKS.exe
* New Banner
Updated README to include responsive images for dark and light modes.
* rebase
* Reset README
---------
Co-authored-by: ZeroSSL-Andreas
---
acme.sh | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index d714d421..ad7a6325 100755
--- a/acme.sh
+++ b/acme.sh
@@ -6470,6 +6470,7 @@ _install_win_taskscheduler() {
_lesh="$1"
_centry="$2"
_randomminute="$3"
+ _randomhour="$4"
if ! _exists cygpath; then
_err "cygpath not found"
return 1
@@ -6539,7 +6540,7 @@ installcronjob() {
fi
_t=$(_time)
random_minute=$(_math $_t % 60)
- random_hour=$(_math $_t / 60 % 24)
+ random_hour=$(_math $_t / 60 % 6)
if ! _exists "$_CRONTAB" && _exists "fcrontab"; then
_CRONTAB="fcrontab"
@@ -6548,7 +6549,7 @@ installcronjob() {
if ! _exists "$_CRONTAB"; then
if _exists cygpath && _exists schtasks.exe; then
_info "It seems you are on Windows, let's install the Windows scheduler task."
- if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute"; then
+ if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute" "$random_hour"; then
_info "Successfully installed Windows scheduler task."
return 0
else
@@ -6570,7 +6571,7 @@ installcronjob() {
fi
$_CRONTAB -l 2>/dev/null | {
cat
- echo "$random_minute $random_hour * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null"
+ 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"
} | $_CRONTAB_STDIN
fi
if [ "$?" != "0" ]; then
From fdf528c26c72884981dec9600834c8411a87ca32 Mon Sep 17 00:00:00 2001
From: Howtin
Date: Fri, 3 Jul 2026 18:09:32 +0800
Subject: [PATCH 058/224] feat: Add Volcano Engine DNS API (#7069)
* feat: add volcengine dns api
* fix(volcengine): address review findings and fix record matching
Code review fixes:
- fix format string usage in signature computation (use printf %b / %s)
- clear _H1.._H5 header state at start of request to avoid leaking
conditionally-set headers into subsequent requests
- check ListZones return status in _get_root
- document Volcengine_SESSION_TOKEN option and fix duplicate "and" typo
- fix Docs and Issues sections
- remove and update some code comments
Functional fixes:
- stop matching ListRecords results by FQDN string: Volcengine lowercases
the Host/FQDN in responses, so a case-sensitive compare against
$fulldomain failed for mixed-case names, making rm silently skip
deletion and add lose idempotency. ListRecords is already filtered by
ZID+Host+Value+SearchMode:exact, so just extract RecordID from the
result instead.
- reset _record_id at the start of add/rm to avoid stale state leaking
across calls within the same process
- tag created records with Remark "acme.sh" for easier identification
- adjust debug levels: hide Authorization header behind _debug2, surface
response at _debug
Co-Authored-By: Claude Opus 4.8
---------
Co-authored-by: wenxuan70
Co-authored-by: Claude Opus 4.8
---
dnsapi/dns_volcengine.sh | 297 +++++++++++++++++++++++++++++++++++++++
1 file changed, 297 insertions(+)
create mode 100755 dnsapi/dns_volcengine.sh
diff --git a/dnsapi/dns_volcengine.sh b/dnsapi/dns_volcengine.sh
new file mode 100755
index 00000000..2cc805d5
--- /dev/null
+++ b/dnsapi/dns_volcengine.sh
@@ -0,0 +1,297 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_volcengine_info='Volcano Engine DNS API
+Site: https://www.volcengine.com/docs/6758/155086
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_volcengine
+Options:
+ Volcengine_ACCESS_KEY_ID API Key ID
+ Volcengine_SECRET_ACCESS_KEY API Secret
+ Volcengine_SESSION_TOKEN Session Token. Optional, only needed when using temporary STS credentials.
+Issues: github.com/acmesh-official/acme.sh/issues/7064
+'
+
+Volcengine_HOST="dns.volcengineapi.com"
+Volcengine_URL="https://$Volcengine_HOST"
+
+######## Public functions #####################
+
+#fulldomain txtvalue
+dns_volcengine_add() {
+ fulldomain=$1
+ txtvalue=$2
+ _record_id=""
+
+ Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}"
+ Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}"
+
+ if [ -z "$Volcengine_ACCESS_KEY_ID" ] || [ -z "$Volcengine_SECRET_ACCESS_KEY" ]; then
+ Volcengine_ACCESS_KEY_ID=""
+ Volcengine_SECRET_ACCESS_KEY=""
+ _err "You haven't specified the volcengine dns api key id and api key secret yet."
+ return 1
+ fi
+
+ #save the api key and email to the account conf file.
+ _saveaccountconf_mutable Volcengine_ACCESS_KEY_ID "$Volcengine_ACCESS_KEY_ID"
+ _saveaccountconf_mutable Volcengine_SECRET_ACCESS_KEY "$Volcengine_SECRET_ACCESS_KEY"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ _sleep 1
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ # _info "Getting existing records for $fulldomain"
+ if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then
+ _sleep 1
+ return 1
+ fi
+
+ # ListRecords already filtered by ZID + Host + Value + SearchMode:exact,
+ # so any returned record is our target. Don't match on FQDN: Volcengine
+ # lowercases the Host/FQDN in the response, which would break a
+ # case-sensitive string compare against $fulldomain.
+ _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')"
+ _debug "_record_id" "$_record_id"
+
+ if [ "$_record_id" ] && _contains "$response" "$txtvalue"; then
+ _info "The TXT record already exists. Skipping."
+ _sleep 1
+ return 0
+ fi
+
+ _debug "Adding records"
+
+ if volcengine_rest POST "" "Action=CreateRecord&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"Remark\":\"acme.sh\"}"; then
+ _info "TXT record updated successfully."
+ _sleep 1
+ return 0
+ fi
+
+ _sleep 1
+ return 1
+}
+
+#fulldomain txtvalue
+dns_volcengine_rm() {
+ fulldomain=$1
+ txtvalue=$2
+ _record_id=""
+
+ Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}"
+ Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ _sleep 1
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _info "Getting existing records for $fulldomain"
+
+ if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then
+ _sleep 1
+ return 1
+ fi
+
+ # ListRecords already filtered by ZID + Host + Value + SearchMode:exact,
+ # so any returned record is our target. Don't match on FQDN: Volcengine
+ # lowercases the Host/FQDN in the response, which would break a
+ # case-sensitive string compare against $fulldomain.
+ _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')"
+ _debug "_record_id" "$_record_id"
+
+ if [ -z "$_record_id" ]; then
+ _debug "no records exist, skip"
+ _sleep 1
+ return 0
+ fi
+
+ if volcengine_rest POST "" "Action=DeleteRecord&Version=2018-08-01" "{\"RecordID\":\"$_record_id\"}"; then
+ _info "TXT record deleted successfully."
+ _sleep 1
+ return 0
+ fi
+ _sleep 1
+ return 1
+}
+
+#################### Private functions below ##################################
+
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ # iterate over names (a.b.c.d -> b.c.d -> c.d -> d)
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ _debug "Checking domain: $h"
+ if [ -z "$h" ]; then
+ _err "invalid domain"
+ return 1
+ fi
+
+ # iterate over paginated result for list_hosted_zones
+ if ! volcengine_rest POST "" "Action=ListZones&Version=2018-08-01" "{\"Key\":\"$h\",\"SearchMode\":\"exact\"}"; then
+ return 1
+ fi
+ if _contains "$response" "\"ZoneName\":\"$h\""; then
+ _domain_id=$(printf "%s" "$response" | _egrep_o "\"ZID\":[0-9]+," | cut -d: -f2 | cut -d, -f1)
+ if [ "$_domain_id" ]; then
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+ _err "Can't find domain with id: $h"
+ return 1
+ fi
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+ return 1
+}
+
+#method uri qstr data
+volcengine_rest() {
+ mtd="$1"
+ ep="$2"
+ qsr="$3"
+ data="$4"
+
+ _debug mtd "$mtd"
+ _debug ep "$ep"
+ _debug qsr "$qsr"
+ _debug data "$data"
+
+ # clear any header state left over from a previous request so that
+ # conditionally-set headers (e.g. x-content-sha256, x-security-token)
+ # can't leak into the next request
+ _H1=""
+ _H2=""
+ _H3=""
+ _H4=""
+ _H5=""
+
+ CanonicalURI="/$ep"
+ _debug2 CanonicalURI "$CanonicalURI"
+
+ CanonicalQueryString="$qsr"
+ _debug2 CanonicalQueryString "$CanonicalQueryString"
+
+ RequestDate="$(date -u +"%Y%m%dT%H%M%SZ")"
+ _debug2 RequestDate "$RequestDate"
+
+ Hash="sha256"
+
+ _H1="X-Date: $RequestDate"
+ _debug2 _H1 "$_H1"
+
+ volcengine_host="$Volcengine_HOST"
+ CanonicalHeaders="host:$volcengine_host\n"
+ SignedHeaders="host"
+
+ if [ -n "$data" ]; then
+ XContentSha256="$(printf "%s" "$data" | _digest "$Hash" hex)"
+ _H4="x-content-sha256: $XContentSha256"
+ _debug2 _H4 "$_H4"
+
+ CanonicalHeaders="${CanonicalHeaders}x-content-sha256:$XContentSha256\n"
+ SignedHeaders="${SignedHeaders};x-content-sha256"
+ fi
+
+ CanonicalHeaders="${CanonicalHeaders}x-date:$RequestDate\n"
+ SignedHeaders="${SignedHeaders};x-date"
+
+ if [ -n "$Volcengine_SESSION_TOKEN" ]; then
+ _H3="x-security-token: $Volcengine_SESSION_TOKEN"
+ CanonicalHeaders="${CanonicalHeaders}x-security-token:$Volcengine_SESSION_TOKEN\n"
+ SignedHeaders="${SignedHeaders};x-security-token"
+ fi
+
+ _debug2 CanonicalHeaders "$CanonicalHeaders"
+ _debug2 SignedHeaders "$SignedHeaders"
+
+ RequestPayload="$data"
+ _debug2 RequestPayload "$RequestPayload"
+
+ CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$(printf "%s" "$RequestPayload" | _digest "$Hash" hex)"
+ _debug2 CanonicalRequest "$CanonicalRequest"
+
+ HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)"
+ _debug2 HashedCanonicalRequest "$HashedCanonicalRequest"
+
+ Algorithm="HMAC-SHA256"
+ _debug2 Algorithm "$Algorithm"
+
+ RequestDateOnly="$(echo "$RequestDate" | cut -c 1-8)"
+ _debug2 RequestDateOnly "$RequestDateOnly"
+
+ Region="cn-beijing"
+ Service="dns"
+
+ CredentialScope="$RequestDateOnly/$Region/$Service/request"
+ _debug2 CredentialScope "$CredentialScope"
+
+ StringToSign="$Algorithm\n$RequestDate\n$CredentialScope\n$HashedCanonicalRequest"
+
+ _debug2 StringToSign "$StringToSign"
+
+ kSecret="$Volcengine_SECRET_ACCESS_KEY"
+
+ _secure_debug2 kSecret "$kSecret"
+
+ kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")"
+ _secure_debug2 kSecretH "$kSecretH"
+
+ kDateH="$(printf "%s" "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)"
+ _debug2 kDateH "$kDateH"
+
+ kRegionH="$(printf "%s" "$Region" | _hmac "$Hash" "$kDateH" hex)"
+ _debug2 kRegionH "$kRegionH"
+
+ kServiceH="$(printf "%s" "$Service" | _hmac "$Hash" "$kRegionH" hex)"
+ _debug2 kServiceH "$kServiceH"
+
+ kSigningH="$(printf "%s" "request" | _hmac "$Hash" "$kServiceH" hex)"
+ _debug2 kSigningH "$kSigningH"
+
+ signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)"
+ _debug2 signature "$signature"
+
+ Authorization="$Algorithm Credential=$Volcengine_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature"
+ _debug2 Authorization "$Authorization"
+
+ _H2="Authorization: $Authorization"
+ _debug2 _H2 "$_H2"
+
+ url="$Volcengine_URL/$ep"
+ if [ "$qsr" ]; then
+ url="$Volcengine_URL/$ep?$qsr"
+ fi
+
+ if [ "$mtd" = "GET" ]; then
+ response="$(_get "$url")"
+ else
+ response="$(_post "$data" "$url" "" "POST" "application/json")"
+ fi
+
+ _ret="$?"
+ _debug response "$response"
+ if [ "$_ret" = "0" ]; then
+ if _contains "$response" "\"Error\":{"; then
+ _err "Response error:$response"
+ return 1
+ fi
+ fi
+
+ return "$_ret"
+}
From f03895819262753b8b9699437967daadb4aeb891 Mon Sep 17 00:00:00 2001
From: CZECHIA-COM
Date: Fri, 3 Jul 2026 12:12:25 +0200
Subject: [PATCH 059/224] fix(dns_czechia): read _normalizeJson input from
stdin, not as an argument (#7077)
_normalizeJson reads its JSON from stdin (sed | sed | tr) and ignores
any positional argument. dns_czechia_add() called it as
`_normalizeJson "$_res"`, so the response was discarded and the inner
sed blocked reading from stdin.
When issuing for a single domain, or for a record that already exists,
the "already exists" branch returns early and never reaches this call,
which is why the bug stayed hidden. With multiple domains, the first
record often short-circuits on "already exists" while the next,
freshly-added record reaches the broken call and hangs on interactive
runs (or consumes unrelated stdin non-interactively).
Pipe the response into _normalizeJson via stdin, matching
dns_czechia_rm() and every other dnsapi plugin.
Co-authored-by: Claude Opus 4.8 (1M context)
---
dnsapi/dns_czechia.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_czechia.sh b/dnsapi/dns_czechia.sh
index f0f4c32e..e2ffcf50 100644
--- a/dnsapi/dns_czechia.sh
+++ b/dnsapi/dns_czechia.sh
@@ -76,7 +76,7 @@ dns_czechia_add() {
return 0
fi
- _nres="$(_normalizeJson "$_res")"
+ _nres="$(printf '%s' "$_res" | _normalizeJson)"
if [ "$?" -ne 0 ] || [ -z "$_nres" ]; then
_nres="$_res"
fi
From 01d6d469148ac98949787355ff6156562163154d Mon Sep 17 00:00:00 2001
From: ZeroSSL-Andreas
Date: Fri, 19 Jun 2026 17:06:57 +0200
Subject: [PATCH 060/224] New Banner
Updated README to include responsive images for dark and light modes.
---
README.md | 20 +++++++++++++++++---
1 file changed, 17 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index 44a73e83..b93a8a50 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,21 @@
-
-
-
+
+
+
+
+
+
+
+
+
+
🔐 acme.sh
From 9900adb0076f88ca943b0f527662aa5d9f208e50 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 19:01:10 +0800
Subject: [PATCH 061/224] fix
https://github.com/acmesh-official/acme.sh/issues/4756
---
acme.sh | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/acme.sh b/acme.sh
index ad7a6325..1b3af51f 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7199,6 +7199,12 @@ install() {
if [ "$_DEFAULT_CERT_HOME" != "$CERT_HOME" ]; then
_saveaccountconf "CERT_HOME" "$CERT_HOME"
+ # Create the custom cert home now instead of on first issuance, so the
+ # user can see --install honored it.
+ # https://github.com/acmesh-official/acme.sh/issues/4756
+ if [ ! -d "$CERT_HOME" ]; then
+ mkdir -p "$CERT_HOME"
+ fi
fi
if [ "$_DEFAULT_ACCOUNT_KEY_PATH" != "$ACCOUNT_KEY_PATH" ]; then
From 1241649501437ca0603703f4c8190a15b8a23313 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 19:38:21 +0800
Subject: [PATCH 062/224] fix
https://github.com/acmesh-official/acme.sh/issues/7009
---
acme.sh | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/acme.sh b/acme.sh
index 1b3af51f..5529fe88 100755
--- a/acme.sh
+++ b/acme.sh
@@ -5838,6 +5838,14 @@ renew() {
fi
_info "Renewing using Le_API=$Le_API"
+ # Honor --local-address given on the renew/renewAll command line: it overrides
+ # the value saved at issue time (and gets re-saved by issue() below), so certs
+ # issued before the machine gained multiple addresses can still be renewed.
+ # https://github.com/acmesh-official/acme.sh/issues/7009
+ if [ "$_local_address" ]; then
+ Le_LocalAddress="$_local_address"
+ fi
+
_clearAPI
_clearCA
export ACME_DIRECTORY="$Le_API"
From eabd23a55164c9a75044573584d24a8505666fa1 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 19:38:42 +0800
Subject: [PATCH 063/224] fix
https://github.com/acmesh-official/acme.sh/issues/6963
---
dnsapi/dns_namecheap.sh | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/dnsapi/dns_namecheap.sh b/dnsapi/dns_namecheap.sh
index 5527b357..cca59735 100755
--- a/dnsapi/dns_namecheap.sh
+++ b/dnsapi/dns_namecheap.sh
@@ -264,8 +264,16 @@ _set_namecheap_TXT() {
_debug hosts "$hosts"
if [ -z "$hosts" ]; then
- _err "Hosts not found"
- return 1
+ # An empty host list is only acceptable when the API positively confirms
+ # a successful getHosts reply: setHosts below REPLACES all records, so
+ # proceeding on a malformed/unparsed response would wipe the whole zone.
+ # https://github.com/acmesh-official/acme.sh/issues/6963
+ if _contains "$response" "Status=\"OK\"" && _contains "$response" "DomainDNSGetHostsResult"; then
+ _debug "No existing host records, adding the TXT record as the first one"
+ else
+ _err "Hosts not found"
+ return 1
+ fi
fi
_namecheap_reset_hostList
From ad99628e50e4614c33082114856c92a417c76555 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 20:04:32 +0800
Subject: [PATCH 064/224] fix
https://github.com/acmesh-official/acme.sh/issues/6917
---
acme.sh | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/acme.sh b/acme.sh
index 5529fe88..1c68845f 100755
--- a/acme.sh
+++ b/acme.sh
@@ -5751,6 +5751,17 @@ $_authorizations_map"
fi
fi
+ # Warn when the scheduled renewal falls after the cert has already expired,
+ # e.g. a 1-day cert from an internal CA combined with the default 60-day
+ # schedule, which computes from the creation date and never looks at
+ # notAfter. https://github.com/acmesh-official/acme.sh/issues/6917
+ _renew_chk_enddate="$(_enddate "$CERT_PATH")"
+ _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")"
+ if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then
+ _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")"
+ _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")"
+ fi
+
_savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr"
_savedomainconf "Le_NextRenewTime" "$Le_NextRenewTime"
From 92bd80c07daf03b4ed362184f1b4514826ffba0f Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 20:07:42 +0800
Subject: [PATCH 065/224] fix
https://github.com/acmesh-official/acme.sh/issues/6914
---
dnsapi/dns_dnsexit.sh | 115 +++++++++---------------------------------
1 file changed, 25 insertions(+), 90 deletions(-)
diff --git a/dnsapi/dns_dnsexit.sh b/dnsapi/dns_dnsexit.sh
index ec3b07a4..6b10891c 100644
--- a/dnsapi/dns_dnsexit.sh
+++ b/dnsapi/dns_dnsexit.sh
@@ -5,14 +5,11 @@ Site: DNSExit.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsexit
Options:
DNSEXIT_API_KEY API Key
- DNSEXIT_AUTH_USER Username
- DNSEXIT_AUTH_PASS Password
Issues: github.com/acmesh-official/acme.sh/issues/4719
Author: Samuel Jimenez
'
DNSEXIT_API_URL="https://api.dnsexit.com/dns/"
-DNSEXIT_HOSTS_URL="https://update.dnsexit.com/ipupdate/hosts.jsp"
######## Public functions #####################
#Usage: dns_dnsexit_add _acme-challenge.*.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
@@ -28,20 +25,7 @@ dns_dnsexit_add() {
return 1
fi
- _debug 'First detect the root zone'
- if ! _get_root "$fulldomain"; then
- return 1
- fi
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
-
- if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"add\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\",\"ttl\":0,\"overwrite\":false}}"; then
- _err "$response"
- return 1
- fi
-
- _debug2 _response "$response"
- return 0
+ _dnsexit_zone_op add ',"ttl":0,"overwrite":false'
}
#Usage: fulldomain txtvalue
@@ -58,54 +42,43 @@ dns_dnsexit_rm() {
return 1
fi
- _debug 'First detect the root zone'
- if ! _get_root "$fulldomain"; then
- _err "$response"
- return 1
- fi
- _debug _sub_domain "$_sub_domain"
- _debug _domain "$_domain"
-
- if ! _dnsexit_rest "{\"domain\":\"$_domain\",\"delete\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"}}"; then
- _err "$response"
- return 1
- fi
-
- _debug2 _response "$response"
- return 0
+ _dnsexit_zone_op delete ''
}
#################### Private functions below ##################################
-#_acme-challenge.www.domain.com
-#returns
-# _sub_domain=_acme-challenge.www
-# _domain=domain.com
-_get_root() {
- domain=$1
+# The legacy zone-detection endpoint (update.dnsexit.com/ipupdate/hosts.jsp)
+# was shut down by DNSExit and now returns 503, and the JSON API offers no
+# zone-list call. So find the root zone by attempting the actual operation at
+# each domain level: the API answers "code":0 only when the domain matches a
+# zone of the account. https://github.com/acmesh-official/acme.sh/issues/6914
+#Usage: _dnsexit_zone_op
+_dnsexit_zone_op() {
+ _op="$1"
+ _extra="$2"
i=1
while true; do
- _domain=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
- _debug h "$_domain"
+ _domain=$(printf "%s" "$fulldomain" | cut -d . -f "$i"-100)
+ _debug _domain "$_domain"
if [ -z "$_domain" ]; then
+ _err "Could not find the root zone of $fulldomain in your DNSExit account"
return 1
fi
- _debug login "$DNSEXIT_AUTH_USER"
- _debug password "$DNSEXIT_AUTH_PASS"
- _debug domain "$_domain"
+ _sub_domain="$(printf "%s" "$fulldomain" | sed "s/\\.$_domain\$//")"
+ if [ "$_sub_domain" = "$fulldomain" ]; then
+ _sub_domain=""
+ fi
+ _debug _sub_domain "$_sub_domain"
- _dnsexit_http "login=$DNSEXIT_AUTH_USER&password=$DNSEXIT_AUTH_PASS&domain=$_domain"
-
- if _contains "$response" "0=$_domain"; then
- _sub_domain="$(echo "$fulldomain" | sed "s/\\.$_domain\$//")"
- return 0
- else
- _debug "Go to next level of $_domain"
+ if _dnsexit_rest "{\"domain\":\"$_domain\",\"$_op\":{\"type\":\"TXT\",\"name\":\"$_sub_domain\",\"content\":\"$txtvalue\"$_extra}}"; then
+ if _contains "$response" "\"code\":0" || _contains "$response" "\"code\": 0"; then
+ _debug2 _response "$response"
+ return 0
+ fi
+ _debug "Zone $_domain was not accepted, trying the next level" "$response"
fi
i=$(_math "$i" + 1)
done
-
- return 1
}
_dnsexit_rest() {
@@ -136,27 +109,7 @@ _dnsexit_rest() {
return 0
}
-_dnsexit_http() {
- m=GET
- param="$1"
- _debug param "$param"
- _debug get "$DNSEXIT_HOSTS_URL?$param"
-
- response="$(_get "$DNSEXIT_HOSTS_URL?$param")"
-
- _debug response "$response"
-
- if [ "$?" != "0" ]; then
- _err "Error $param"
- return 1
- fi
-
- _debug2 response "$response"
- return 0
-}
-
get_account_info() {
-
DNSEXIT_API_KEY="${DNSEXIT_API_KEY:-$(_readaccountconf_mutable DNSEXIT_API_KEY)}"
if test -z "$DNSEXIT_API_KEY"; then
DNSEXIT_API_KEY=''
@@ -166,23 +119,5 @@ get_account_info() {
_saveaccountconf_mutable DNSEXIT_API_KEY "$DNSEXIT_API_KEY"
- DNSEXIT_AUTH_USER="${DNSEXIT_AUTH_USER:-$(_readaccountconf_mutable DNSEXIT_AUTH_USER)}"
- if test -z "$DNSEXIT_AUTH_USER"; then
- DNSEXIT_AUTH_USER=""
- _err 'DNSEXIT_AUTH_USER was not exported'
- return 1
- fi
-
- _saveaccountconf_mutable DNSEXIT_AUTH_USER "$DNSEXIT_AUTH_USER"
-
- DNSEXIT_AUTH_PASS="${DNSEXIT_AUTH_PASS:-$(_readaccountconf_mutable DNSEXIT_AUTH_PASS)}"
- if test -z "$DNSEXIT_AUTH_PASS"; then
- DNSEXIT_AUTH_PASS=""
- _err 'DNSEXIT_AUTH_PASS was not exported'
- return 1
- fi
-
- _saveaccountconf_mutable DNSEXIT_AUTH_PASS "$DNSEXIT_AUTH_PASS"
-
return 0
}
From 7e7c0ee984bd2548453f436382c65de3345ecfe0 Mon Sep 17 00:00:00 2001
From: magyarsz <699745+magyarsz@users.noreply.github.com>
Date: Fri, 3 Jul 2026 15:42:52 +0200
Subject: [PATCH 066/224] Merge pull request #6720 from magyarsz/dev
Fix a logical error in the `renew` function
---
acme.sh | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/acme.sh b/acme.sh
index 1c68845f..b65d41ef 100755
--- a/acme.sh
+++ b/acme.sh
@@ -5936,11 +5936,8 @@ renew() {
fi
issue "$Le_Webroot" "$Le_Domain" "$Le_Alt" "$Le_Keylength" "$Le_RealCertPath" "$Le_RealKeyPath" "$Le_RealCACertPath" "$Le_ReloadCmd" "$Le_RealFullChainPath" "$Le_PreHook" "$Le_PostHook" "$Le_RenewHook" "$Le_LocalAddress" "$Le_ChallengeAlias" "$Le_Preferred_Chain" "$Le_Valid_From" "$Le_Valid_To" "$Le_Certificate_Profile" "$Le_ExtKeyUse"
res="$?"
- if [ "$res" != "0" ]; then
- return "$res"
- fi
- if [ "$Le_DeployHook" ]; then
+ if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then
_deploy "$Le_Domain" "$Le_DeployHook"
res="$?"
fi
From 5038d12d6278b1fc888f80373f68999bece3b688 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 21:55:23 +0800
Subject: [PATCH 067/224] forbid using --days together with --valid-to
https://github.com/acmesh-official/acme.sh/pull/6572
---
acme.sh | 27 ++++++++++++++++++++-------
1 file changed, 20 insertions(+), 7 deletions(-)
diff --git a/acme.sh b/acme.sh
index b65d41ef..affe7b78 100755
--- a/acme.sh
+++ b/acme.sh
@@ -5752,14 +5752,18 @@ $_authorizations_map"
fi
# Warn when the scheduled renewal falls after the cert has already expired,
- # e.g. a 1-day cert from an internal CA combined with the default 60-day
+ # e.g. a 1-day cert from an internal CA combined with the default 30-day
# schedule, which computes from the creation date and never looks at
- # notAfter. https://github.com/acmesh-official/acme.sh/issues/6917
- _renew_chk_enddate="$(_enddate "$CERT_PATH")"
- _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")"
- if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then
- _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")"
- _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")"
+ # notAfter. Skip the warning for a fixed-date --valid-to: there
+ # Le_NextRenewTime equals the expiry by design and the non-renewable state
+ # was already reported above. https://github.com/acmesh-official/acme.sh/issues/6917
+ if [ -z "$_valid_to" ] || _startswith "$_valid_to" "+"; then
+ _renew_chk_enddate="$(_enddate "$CERT_PATH")"
+ _renew_chk_endtime="$(_ssldate2time "$_renew_chk_enddate")"
+ if [ "$Le_NextRenewTime" ] && [ "$_renew_chk_endtime" ] && [ "$Le_NextRenewTime" -ge "$_renew_chk_endtime" ]; then
+ _info "$(__red "WARNING: the cert expires at $_renew_chk_enddate, BEFORE the next scheduled renewal time $Le_NextRenewTimeStr.")"
+ _info "$(__red "The cert will already be expired when the renewal runs. If your CA issues short-lived certs, use a negative --days value (e.g. --days -1) to renew relative to the expiry time.")"
+ fi
fi
_savedomainconf "Le_NextRenewTimeStr" "$Le_NextRenewTimeStr"
@@ -8568,6 +8572,15 @@ _process() {
_debug2 LE_WORKING_DIR "$LE_WORKING_DIR"
+ # --days and --valid-to are mutually exclusive by design: --valid-to pins
+ # the cert lifetime and the renewal time follows the expiry, so a
+ # creation-based --days schedule can not apply.
+ if [ "$_days" ] && [ "$_valid_to" ]; then
+ _err "--days can not be used together with --valid-to."
+ _err "With --valid-to, the renewal time is derived from the expiry time automatically."
+ return 1
+ fi
+
if [ "$DEBUG" ]; then
version
if [ "$_server" ]; then
From 92a1b4710838866a4a409be593f4fb7df9ffdd21 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 22:26:50 +0800
Subject: [PATCH 068/224] forbid spaces in the --home/--config-home path
https://github.com/acmesh-official/acme.sh/issues/2163
---
acme.sh | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/acme.sh b/acme.sh
index affe7b78..23adba93 100755
--- a/acme.sh
+++ b/acme.sh
@@ -2842,6 +2842,17 @@ __initHome() {
_debug "Using config home: $LE_CONFIG_HOME"
export LE_CONFIG_HOME
+ # Paths with whitespace break the unquoted $_CURL/$_WGET command expansion,
+ # so fail early with a clear error instead of a cryptic curl/wget failure.
+ # https://github.com/acmesh-official/acme.sh/issues/2163
+ case "$LE_WORKING_DIR$LE_CONFIG_HOME" in
+ *" "*)
+ _err "The --home or --config-home path can not contain spaces: '$LE_WORKING_DIR'"
+ _err "Please install $PROJECT_NAME to a path without spaces."
+ exit 1
+ ;;
+ esac
+
_DEFAULT_ACCOUNT_CONF_PATH="$LE_CONFIG_HOME/account.conf"
if [ -z "$ACCOUNT_CONF_PATH" ]; then
From 61400500e2ddcf400682aa9dacf27aa891aa9411 Mon Sep 17 00:00:00 2001
From: Trekky12
Date: Fri, 3 Jul 2026 16:31:31 +0200
Subject: [PATCH 069/224] Suppress 'signal process started' message when nginx
config is restored (related to issue #4995) (#6747)
---
acme.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/acme.sh b/acme.sh
index 23adba93..3cb4e248 100755
--- a/acme.sh
+++ b/acme.sh
@@ -3524,7 +3524,7 @@ _restoreNginx() {
done
_info "Reloading nginx"
- if ! nginx -s reload >/dev/null; then
+ if ! nginx -s reload >/dev/null 2>&1; then
_err "An error occurred while reloading nginx, please open an issue on $PROJECT."
return 1
fi
From f4d2db64efb54175d57f0aaa9f4a97c9c1e2c2ab Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 22:49:23 +0800
Subject: [PATCH 070/224] dns_gd: skip readback check when GoDaddy API returns
UNKNOWN_DOMAIN https://github.com/acmesh-official/acme.sh/issues/6517
---
dnsapi/dns_gd.sh | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh
index ee66ee19..e08f2a05 100755
--- a/dnsapi/dns_gd.sh
+++ b/dnsapi/dns_gd.sh
@@ -69,7 +69,12 @@ dns_gd_add() {
return 1
fi
- if ! _contains "$response" "$txtvalue"; then
+ if _contains "$response" "UNKNOWN_DOMAIN"; then
+ # GoDaddy sometimes returns UNKNOWN_DOMAIN when reading a record back even
+ # though the PUT above succeeded; skip the local readback check and let
+ # acme.sh's own DNS propagation check verify the record was published.
+ _info "GoDaddy API won't allow reading the record back; skipping local verification."
+ elif ! _contains "$response" "$txtvalue"; then
_err "TXT record '${txtvalue}' for '${fulldomain}', value wasn't set!"
return 1
fi
From 20254cbaf0755175024b2a9e0aaa87f9d998fc2e Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 23:02:35 +0800
Subject: [PATCH 071/224] dns_dnsimple: support user tokens (dnsimple_u_*)
https://github.com/acmesh-official/acme.sh/issues/6491
---
dnsapi/dns_dnsimple.sh | 36 +++++++++++++++++++++++++++++-------
1 file changed, 29 insertions(+), 7 deletions(-)
diff --git a/dnsapi/dns_dnsimple.sh b/dnsapi/dns_dnsimple.sh
index 10a3821d..e262239a 100644
--- a/dnsapi/dns_dnsimple.sh
+++ b/dnsapi/dns_dnsimple.sh
@@ -5,6 +5,7 @@ Site: DNSimple.com
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_dnsimple
Options:
DNSimple_OAUTH_TOKEN OAuth Token
+ DNSimple_ACCOUNT_ID Account ID. Optional, only needed when the token can access multiple accounts.
Issues: github.com/pho3nixf1re/acme.sh/issues
'
@@ -28,7 +29,7 @@ dns_dnsimple_add() {
_saveaccountconf DNSimple_OAUTH_TOKEN "$DNSimple_OAUTH_TOKEN"
if ! _get_account_id; then
- _err "failed to retrive account id"
+ _err "failed to retrieve account id"
return 1
fi
@@ -57,7 +58,7 @@ dns_dnsimple_rm() {
fulldomain=$1
if ! _get_account_id; then
- _err "failed to retrive account id"
+ _err "failed to retrieve account id"
return 1
fi
@@ -122,13 +123,16 @@ _get_root() {
# returns _account_id
_get_account_id() {
- _debug "retrive account id"
- if ! _dnsimple_rest GET "whoami"; then
- return 1
+ DNSimple_ACCOUNT_ID="${DNSimple_ACCOUNT_ID:-$(_readaccountconf DNSimple_ACCOUNT_ID)}"
+ if [ "$DNSimple_ACCOUNT_ID" ]; then
+ _saveaccountconf DNSimple_ACCOUNT_ID "$DNSimple_ACCOUNT_ID"
+ _account_id="$DNSimple_ACCOUNT_ID"
+ _debug _account_id "$_account_id"
+ return 0
fi
- if _contains "$response" "\"account\":null"; then
- _err "no account associated with this token"
+ _debug "retrieve account id"
+ if ! _dnsimple_rest GET "whoami"; then
return 1
fi
@@ -137,7 +141,25 @@ _get_account_id() {
return 1
fi
+ if _contains "$response" "\"account\":null"; then
+ # the whoami of a user token (dnsimple_u_*) carries no account,
+ # so list the accounts the token can access instead
+ # https://github.com/acmesh-official/acme.sh/issues/6491
+ if ! _dnsimple_rest GET "accounts"; then
+ return 1
+ fi
+ fi
+
_account_id=$(printf "%s" "$response" | _egrep_o "\"id\":[^,]*,\"email\":" | cut -d: -f2 | cut -d, -f1)
+ if [ -z "$_account_id" ]; then
+ _err "no account associated with this token"
+ return 1
+ fi
+ if [ "$(echo "$_account_id" | wc -l)" -gt 1 ]; then
+ _err "The token has access to multiple accounts, please pick one and set it explicitly:"
+ _err "export DNSimple_ACCOUNT_ID="
+ return 1
+ fi
_debug _account_id "$_account_id"
return 0
From 0ce8c24736971db8b1a73b07d0d010cbec3cb13e Mon Sep 17 00:00:00 2001
From: szakharchenko
Date: Fri, 3 Jul 2026 18:25:38 +0300
Subject: [PATCH 072/224] dev_mythic_beasts: Fix header name: Accepts => Accept
(#6428)
---
dnsapi/dns_mythic_beasts.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/dnsapi/dns_mythic_beasts.sh b/dnsapi/dns_mythic_beasts.sh
index 1529e1e7..a49ab8ab 100755
--- a/dnsapi/dns_mythic_beasts.sh
+++ b/dnsapi/dns_mythic_beasts.sh
@@ -186,7 +186,7 @@ _oauth2() {
_oauth2_std() {
# HTTP Basic Authentication
_H1="Authorization: Basic $(echo "$MB_AK:$MB_AS" | _base64)"
- _H2="Accepts: application/json"
+ _H2="Accept: application/json"
export _H1 _H2
body="grant_type=client_credentials"
@@ -210,7 +210,7 @@ _oauth2_std() {
}
_oauth2_github() {
- _H1="Accepts: application/json"
+ _H1="Accept: application/json"
export _H1
body="{\"login\":{\"handle\":\"$MB_AK\",\"pass\":\"$MB_AS\",\"floating\":1}}"
@@ -241,7 +241,7 @@ _mb_rest() {
fi
_H1="Authorization: Bearer $MB_TK"
- _H2="Accepts: application/json"
+ _H2="Accept: application/json"
export _H1 _H2
if [ "$data" ] || [ "$m" = "POST" ] || [ "$m" = "PUT" ] || [ "$m" = "DELETE" ]; then
# body url [needbase64] [POST|PUT|DELETE] [ContentType]
From b974bbd6d656e24126838f70bbcdc768db0220e6 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 23:28:19 +0800
Subject: [PATCH 073/224] fix upgrade with a relative --home path
https://github.com/acmesh-official/acme.sh/issues/6477
---
acme.sh | 27 ++++++++++++++++++++++++++-
1 file changed, 26 insertions(+), 1 deletion(-)
diff --git a/acme.sh b/acme.sh
index 3cb4e248..b345de8e 100755
--- a/acme.sh
+++ b/acme.sh
@@ -2834,11 +2834,31 @@ __initHome() {
_debug "Using default home: $DEFAULT_INSTALL_HOME"
LE_WORKING_DIR="$DEFAULT_INSTALL_HOME"
fi
+ # Convert a relative --home to an absolute path: later code cd's around
+ # (e.g. installOnline extracts and enters the archive dir), where a
+ # relative path would point into the wrong directory.
+ # https://github.com/acmesh-official/acme.sh/issues/6477
+ case "$LE_WORKING_DIR" in
+ /*) ;;
+ *)
+ if [ -d "$LE_WORKING_DIR" ]; then
+ LE_WORKING_DIR="$(cd "$LE_WORKING_DIR" && pwd)"
+ fi
+ ;;
+ esac
export LE_WORKING_DIR
if [ -z "$LE_CONFIG_HOME" ]; then
LE_CONFIG_HOME="$LE_WORKING_DIR"
fi
+ case "$LE_CONFIG_HOME" in
+ /*) ;;
+ *)
+ if [ -d "$LE_CONFIG_HOME" ]; then
+ LE_CONFIG_HOME="$(cd "$LE_CONFIG_HOME" && pwd)"
+ fi
+ ;;
+ esac
_debug "Using config home: $LE_CONFIG_HOME"
export LE_CONFIG_HOME
@@ -7676,7 +7696,9 @@ installOnline() {
cd "$PROJECT_NAME-$_branch"
chmod +x $PROJECT_ENTRY
- if ./$PROJECT_ENTRY --install "$@"; then
+ ./$PROJECT_ENTRY --install "$@"
+ _install_rc="$?"
+ if [ "$_install_rc" = "0" ]; then
_info "Install success!"
fi
@@ -7684,6 +7706,9 @@ installOnline() {
rm -rf "$PROJECT_NAME-$_branch"
rm -f "$localname"
+ # Propagate the install result so a failed upgrade is not reported as
+ # success. https://github.com/acmesh-official/acme.sh/issues/6477
+ exit "$_install_rc"
)
}
From ac5624536b1321c8ac47d1b2d25946b9a8032144 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 23:30:16 +0800
Subject: [PATCH 074/224] dns_gd: fix root zone detection for API-restricted
accounts https://github.com/acmesh-official/acme.sh/issues/4487
---
dnsapi/dns_gd.sh | 40 ++++++++++++++++++++++++++++++++--------
1 file changed, 32 insertions(+), 8 deletions(-)
diff --git a/dnsapi/dns_gd.sh b/dnsapi/dns_gd.sh
index e08f2a05..c92bdfa2 100755
--- a/dnsapi/dns_gd.sh
+++ b/dnsapi/dns_gd.sh
@@ -150,8 +150,8 @@ dns_gd_rm() {
# _domain=domain.com
_get_root() {
domain=$1
- i=2
- p=1
+ i=1
+ p=0
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
if [ -z "$h" ]; then
@@ -159,17 +159,41 @@ _get_root() {
return 1
fi
- if ! _gd_rest GET "domains/$h"; then
- return 1
+ # The record name is whatever precedes the candidate zone. Do not assume
+ # _acme-challenge here: with DNS alias mode it can be any name, and the
+ # record may even sit at the zone apex (name "@").
+ if [ "$p" = "0" ]; then
+ _probe_sub="@"
+ else
+ _probe_sub=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
fi
- if _contains "$response" '"code":"NOT_FOUND"'; then
- _debug "$h not found"
- else
- _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ # Probe with the records endpoint instead of "GET domains/$h": since
+ # 2024-05 GoDaddy rejects the domain details call for accounts with
+ # fewer than 10 domains, while record-level calls keep working.
+ # https://github.com/acmesh-official/acme.sh/issues/4487
+ if ! _gd_rest GET "domains/$h/records/TXT/$_probe_sub"; then
+ return 1
+ fi
+ if _startswith "$response" '\['; then
+ _sub_domain="$_probe_sub"
_domain="$h"
return 0
fi
+
+ # Some accounts get UNKNOWN_DOMAIN when reading records of a valid zone
+ # even though writes succeed (see issue #6517); fall back to the domain
+ # details call for them.
+ if ! _gd_rest GET "domains/$h"; then
+ return 1
+ fi
+ if _contains "$response" '"domainId"'; then
+ _sub_domain="$_probe_sub"
+ _domain="$h"
+ return 0
+ fi
+
+ _debug "$h not found"
p="$i"
i=$(_math "$i" + 1)
done
From 780f2ad5dcf42b9b317192fc4031dd86b5d197d5 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 23:44:12 +0800
Subject: [PATCH 076/224] dns_ali: do not rely on "_url_encode upper-hex" so
the signature works with older bundled libraries (e.g. Proxmox VE)
https://github.com/acmesh-official/acme.sh/issues/6272
---
dnsapi/dns_ali.sh | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/dnsapi/dns_ali.sh b/dnsapi/dns_ali.sh
index 90196c69..62e54e0c 100755
--- a/dnsapi/dns_ali.sh
+++ b/dnsapi/dns_ali.sh
@@ -69,8 +69,8 @@ _ali_rest() {
ign="$2"
mtd="${3:-GET}"
- signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _url_encode upper-hex)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64)
- signature=$(printf "%s" "$signature" | _url_encode upper-hex)
+ signature=$(printf "%s" "$mtd&%2F&$(printf "%s" "$query" | _ali_urlencode_upper)" | _hmac "sha1" "$(printf "%s" "$Ali_Secret&" | _hex_dump | tr -d " ")" | _base64)
+ signature=$(printf "%s" "$signature" | _ali_urlencode_upper)
url="$endpoint?Signature=$signature"
if [ "$mtd" = "GET" ]; then
@@ -96,6 +96,20 @@ _ali_rest() {
fi
}
+# stdin stdout
+# The Aliyun signature requires percent-encoding with upper-case hex.
+# Do not use "_url_encode upper-hex" here: this file is also bundled by
+# third parties (e.g. Proxmox VE proxmox-acme) whose older copies of the
+# acme.sh function library ignore the upper-hex argument and output
+# lower-case hex, which invalidates the signature.
+# https://github.com/acmesh-official/acme.sh/issues/6272
+_ali_urlencode_upper() {
+ {
+ _url_encode
+ echo
+ } | sed 's/%a/%A/g;s/%b/%B/g;s/%c/%C/g;s/%d/%D/g;s/%e/%E/g;s/%f/%F/g;s/%\(.\)a/%\1A/g;s/%\(.\)b/%\1B/g;s/%\(.\)c/%\1C/g;s/%\(.\)d/%\1D/g;s/%\(.\)e/%\1E/g;s/%\(.\)f/%\1F/g'
+}
+
_ali_nonce() {
if [ "$ACME_OPENSSL_BIN" ]; then
"$ACME_OPENSSL_BIN" rand -hex 16 2>/dev/null && return 0
From 7fb40f0ccf32bc16589c63d7a478b42f3fcb0fe5 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 3 Jul 2026 23:55:23 +0800
Subject: [PATCH 077/224] fix
https://github.com/acmesh-official/acme.sh/issues/6609
---
acme.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/acme.sh b/acme.sh
index b345de8e..b4861b4c 100755
--- a/acme.sh
+++ b/acme.sh
@@ -3888,7 +3888,7 @@ _regAccount() {
mkdir -p "$CA_DIR"
- if [ ! -f "$ACCOUNT_KEY_PATH" ]; then
+ if [ ! -s "$ACCOUNT_KEY_PATH" ]; then
if ! _create_account_key "$_reg_length"; then
_err "Error creating account key."
return 1
From 0df051577ca1344b75fdce80840f642a640f3e0c Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 00:06:26 +0800
Subject: [PATCH 078/224] fix
https://github.com/acmesh-official/acme.sh/issues/6609
---
acme.sh | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/acme.sh b/acme.sh
index b4861b4c..dfc056b8 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1179,6 +1179,11 @@ _createkey() {
length="$1"
f="$2"
_debug2 "_createkey for file:$f"
+ if ! _exists "${ACME_OPENSSL_BIN:-openssl}"; then
+ _err "Please install openssl first. ACME_OPENSSL_BIN=$ACME_OPENSSL_BIN"
+ _err "We need openssl to generate keys."
+ return 1
+ fi
eccname="$length"
if _startswith "$length" "ec-"; then
length=$(printf "%s" "$length" | cut -d '-' -f 2-100)
@@ -1201,6 +1206,7 @@ _createkey() {
_debug "Using length $length"
+ _new_key_file=""
if ! [ -e "$f" ]; then
if ! touch "$f" >/dev/null 2>&1; then
_f_path="$(dirname "$f")"
@@ -1214,6 +1220,7 @@ _createkey() {
return 1
fi
chmod 600 "$f"
+ _new_key_file="1"
fi
if _isEccKey "$length"; then
@@ -1222,6 +1229,10 @@ _createkey() {
echo "$_opkey" >"$f"
else
_err "Error encountered for ECC key named $eccname"
+ #do not leave an empty file behind, or the next run would treat the key as existing
+ if [ "$_new_key_file" ]; then
+ rm -f "$f"
+ fi
return 1
fi
else
@@ -1234,6 +1245,10 @@ _createkey() {
echo "$_opkey" >"$f"
else
_err "Error encountered for RSA key of length $length"
+ #do not leave an empty file behind, or the next run would treat the key as existing
+ if [ "$_new_key_file" ]; then
+ rm -f "$f"
+ fi
return 1
fi
fi
From 2229330c48bcf7c9cf866e595bc6588a9574bde0 Mon Sep 17 00:00:00 2001
From: Artur Klauser
Date: Fri, 3 Jul 2026 09:32:45 -0700
Subject: [PATCH 079/224] Fix typo in synology_dsm.sh (#6406)
Fix typo in an error message.
---
deploy/synology_dsm.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh
index e28a4036..6fce1f19 100644
--- a/deploy/synology_dsm.sh
+++ b/deploy/synology_dsm.sh
@@ -276,7 +276,7 @@ synology_dsm_deploy() {
if [ -n "$error_code" ]; then
if [ "$error_code" == "403" ] && [ -n "$SYNO_DEVICE_ID" ]; then
_cleardeployconf SYNO_DEVICE_ID
- _err "Failed to authenticate with SYNO_DEVICE_ID (may expired or invalid), please try again in a new terminal window."
+ _err "Failed to authenticate with SYNO_DEVICE_ID (may be expired or invalid), please try again in a new terminal window."
elif [ "$error_code" == "404" ]; then
_err "Failed to authenticate with provided 2FA-OTP code, please try again in a new terminal window."
elif [ "$error_code" == "406" ]; then
From f4dc9fd9d110a27870b78395ec1741434a82208c Mon Sep 17 00:00:00 2001
From: Laurent Grawet
Date: Fri, 3 Jul 2026 18:35:02 +0200
Subject: [PATCH 080/224] haproxy.sh: allows certificate deployment to multiple
hosts (#5180)
* haproxy.sh: allows certificate deployment to multiple hosts
* Update deploy/haproxy.sh
Co-authored-by: Matt Simerson
* Update deploy/haproxy.sh
Co-authored-by: Matt Simerson
---------
Co-authored-by: Matt Simerson
---
deploy/haproxy.sh | 103 ++++++++++++++++++++++++----------------------
1 file changed, 53 insertions(+), 50 deletions(-)
diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh
index 19509e3b..b618a65b 100644
--- a/deploy/haproxy.sh
+++ b/deploy/haproxy.sh
@@ -43,7 +43,8 @@
# needing to reload HAProxy. Default is "no".
#
# Require the socat binary. DEPLOY_HAPROXY_STATS_SOCKET variable uses the socat
-# address format.
+# address format. The certificate can be deployed to a comma separated ',' list
+# of hosts ("TCP4:10.0.0.1:1999,TCP4:10.0.0.2:1999")
#
# export DEPLOY_HAPROXY_MASTER_CLI="UNIX:/run/haproxy-master.sock"
#
@@ -193,7 +194,6 @@ haproxy_deploy() {
_issuer="${_pem}.issuer"
_ocsp="${_pem}.ocsp"
_reload="${Le_Deploy_haproxy_reload}"
- _statssock="${Le_Deploy_haproxy_stats_socket}"
_info "Deploying PEM file"
# Create a temporary PEM file
@@ -327,62 +327,65 @@ haproxy_deploy() {
# Update certificate over HAProxy stats socket or master CLI.
if _exists socat; then
- # look for the certificate on the stats socket, to chose between updating or creating one
- _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'"
- _debug _socat_cert_cmd "${_socat_cert_cmd}"
- eval "${_socat_cert_cmd}"
- _ret=$?
- if [ "${_ret}" != "0" ]; then
- _newcert="1"
- _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}."
- # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate.
- _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'"
- _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}"
- eval "${_socat_crtlist_show_cmd}"
+ IFS=','
+ for _statssock in ${Le_Deploy_haproxy_stats_socket}; do
+ # look for the certificate on the stats socket, to choose between updating or creating one
+ _socat_cert_cmd="echo '${_cmdpfx}show ssl cert' | socat '${_statssock}' - | grep -q '^${_pem}$'"
+ _debug _socat_cert_cmd "${_socat_cert_cmd}"
+ eval "${_socat_cert_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
- _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'"
- return "${_ret}"
+ _newcert="1"
+ _info "Creating new certificate '${_pem}' over HAProxy ${_socketname}."
+ # certificate wasn't found, it's a new one. We should check if the crt-list exists and creates/inserts the certificate.
+ _socat_crtlist_show_cmd="echo '${_cmdpfx}show ssl crt-list' | socat '${_statssock}' - | grep -q '^${Le_Deploy_haproxy_pem_path}$'"
+ _debug _socat_crtlist_show_cmd "${_socat_crtlist_show_cmd}"
+ eval "${_socat_crtlist_show_cmd}"
+ _ret=$?
+ if [ "${_ret}" != "0" ]; then
+ _err "Couldn't find '${Le_Deploy_haproxy_pem_path}' in haproxy 'show ssl crt-list'"
+ return "${_ret}"
+ fi
+ # create a new certificate
+ _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'"
+ _debug _socat_new_cmd "${_socat_new_cmd}"
+ eval "${_socat_new_cmd}"
+ _ret=$?
+ if [ "${_ret}" != "0" ]; then
+ _err "Couldn't create '${_pem}' in haproxy"
+ return "${_ret}"
+ fi
+ else
+ _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}."
fi
- # create a new certificate
- _socat_new_cmd="echo '${_cmdpfx}new ssl cert ${_pem}' | socat '${_statssock}' - | grep -q 'New empty'"
- _debug _socat_new_cmd "${_socat_new_cmd}"
- eval "${_socat_new_cmd}"
- _ret=$?
- if [ "${_ret}" != "0" ]; then
- _err "Couldn't create '${_pem}' in haproxy"
- return "${_ret}"
- fi
- else
- _info "Update existing certificate '${_pem}' over HAProxy ${_socketname}."
- fi
- _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'"
- _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}"
- eval "${_socat_cert_set_cmd}"
- _ret=$?
- if [ "${_ret}" != "0" ]; then
- _err "Can't update '${_pem}' in haproxy"
- return "${_ret}"
- fi
- _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'"
- _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}"
- eval "${_socat_cert_commit_cmd}"
- _ret=$?
- if [ "${_ret}" != "0" ]; then
- _err "Can't commit '${_pem}' in haproxy"
- return ${_ret}
- fi
- if [ "${_newcert}" = "1" ]; then
- # if this is a new certificate, it needs to be inserted into the crt-list`
- _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'"
- _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}"
- eval "${_socat_cert_add_cmd}"
+ _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'"
+ _secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}"
+ eval "${_socat_cert_set_cmd}"
_ret=$?
if [ "${_ret}" != "0" ]; then
_err "Can't update '${_pem}' in haproxy"
return "${_ret}"
fi
- fi
+ _socat_cert_commit_cmd="echo '${_cmdpfx}commit ssl cert ${_pem}' | socat '${_statssock}' - | grep -q '^Success!$'"
+ _debug _socat_cert_commit_cmd "${_socat_cert_commit_cmd}"
+ eval "${_socat_cert_commit_cmd}"
+ _ret=$?
+ if [ "${_ret}" != "0" ]; then
+ _err "Can't commit '${_pem}' in haproxy"
+ return ${_ret}
+ fi
+ if [ "${_newcert}" = "1" ]; then
+ # if this is a new certificate, it needs to be inserted into the crt-list`
+ _socat_cert_add_cmd="echo '${_cmdpfx}add ssl crt-list ${Le_Deploy_haproxy_pem_path} ${_pem}' | socat '${_statssock}' - | grep -q 'Success!'"
+ _debug _socat_cert_add_cmd "${_socat_cert_add_cmd}"
+ eval "${_socat_cert_add_cmd}"
+ _ret=$?
+ if [ "${_ret}" != "0" ]; then
+ _err "Can't update '${_pem}' in haproxy"
+ return "${_ret}"
+ fi
+ fi
+ done
else
_err "'socat' is not available, couldn't update over ${_socketname}"
fi
From 0a6abaf8a1336a2bc33c1847850176ef0cb7471d Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 00:39:35 +0800
Subject: [PATCH 081/224] fix
https://github.com/acmesh-official/acme.sh/issues/6388
---
acme.sh | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/acme.sh b/acme.sh
index dfc056b8..5a41f16b 100755
--- a/acme.sh
+++ b/acme.sh
@@ -6381,7 +6381,13 @@ deploy() {
fi
_debug2 DOMAIN_CONF "$DOMAIN_CONF"
- . "$DOMAIN_CONF"
+ # The cert dir may exist without a domain conf (e.g. the conf was deleted, or
+ # the cert was placed here manually). Deploy can still proceed using env-provided
+ # settings, and _savedomainconf below will recreate the conf, so only source it
+ # when present instead of failing on a missing file.
+ if [ -f "$DOMAIN_CONF" ]; then
+ . "$DOMAIN_CONF"
+ fi
_savedomainconf Le_DeployHook "$_hooks"
From b4de9e86217281eff474a2af7b40620618c1f869 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 10:49:32 +0800
Subject: [PATCH 082/224] fix docker deploy hook on podman, check exec ExitCode
instead of response body (#4977)
---
deploy/docker.sh | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/deploy/docker.sh b/deploy/docker.sh
index 264963ae..7fdcf604 100755
--- a/deploy/docker.sh
+++ b/deploy/docker.sh
@@ -189,10 +189,22 @@ _docker_exec() {
_debug2 cjson "$cjson"
execid="$(echo "$cjson" | cut -d '"' -f 4)"
_debug execid "$execid"
- ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": false,\"Tty\": false}")"
+ #Detach:true is required for podman's docker-compatible API: with
+ #Detach:false it streams the command output on the connection, so the
+ #non-empty response was misread as an error (issue #4977). The real
+ #result is checked via the exec inspect ExitCode below instead.
+ ejson="$(_curl_unix_sock "$_DOCKER_SOCK" POST "/exec/$execid/start" "{\"Detach\": true,\"Tty\": false}")"
_debug2 ejson "$ejson"
- if [ "$ejson" ]; then
- _err "$ejson"
+ _et=0
+ ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")"
+ while _contains "$ijson" "\"Running\":true" && [ "$_et" -lt 10 ]; do
+ sleep 1
+ _et="$(_math "$_et" + 1)"
+ ijson="$(_curl_unix_sock "$_DOCKER_SOCK" GET "/exec/$execid/json")"
+ done
+ _debug2 ijson "$ijson"
+ if ! echo "$ijson" | _egrep_o "\"ExitCode\": *0[,}]" >/dev/null 2>&1; then
+ _err "docker exec error: $ijson"
return 1
fi
else
From 6cd0c00a21ecea6b4f1f25c4b715c1f2282c4cff Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 11:23:11 +0800
Subject: [PATCH 083/224] extract authorizations parsing into
_authorizations_from_order, fix IPv6 urls (#6326)
---
acme.sh | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/acme.sh b/acme.sh
index 5a41f16b..a21bfd71 100755
--- a/acme.sh
+++ b/acme.sh
@@ -918,6 +918,15 @@ _json_decode() {
echo "$_j_str"
}
+#extract the authorization URLs from an order response on stdin, as a
+#comma-separated list. The entries are quoted URL strings and a quote cannot
+#occur inside a URL, so the first '"]' is always the end of the array. A
+#char-class scan would stop early on the brackets of an IPv6 host
+#(https://[2001:db8::1]/...). Outputs nothing if the field is missing.
+_authorizations_from_order() {
+ sed -n 's/.*"authorizations" *: *\[//p' | sed 's/" *\].*//' | tr -d '" '
+}
+
#options file
_sed_i() {
options="$1"
@@ -4981,7 +4990,7 @@ issue() {
#for dns manual mode
_savedomainconf "Le_OrderFinalize" "$Le_OrderFinalize"
- _authorizations_seg="$(echo "$response" | _json_decode | _egrep_o '"authorizations" *: *\[[^\[]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')"
+ _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)"
_debug2 _authorizations_seg "$_authorizations_seg"
if [ -z "$_authorizations_seg" ]; then
_err "_authorizations_seg not found."
@@ -6839,7 +6848,7 @@ _deactivate() {
_err "Cannot get new order for domain."
return 1
fi
- _authorizations_seg="$(echo "$response" | _egrep_o '"authorizations" *: *\[[^\]*\]' | cut -d '[' -f 2 | tr -d ']' | tr -d '"')"
+ _authorizations_seg="$(echo "$response" | _json_decode | _authorizations_from_order)"
_debug2 _authorizations_seg "$_authorizations_seg"
if [ -z "$_authorizations_seg" ]; then
_err "_authorizations_seg not found."
From 7653eaab31e3519bf03cd46ff3a22f72f55e7651 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 11:44:32 +0800
Subject: [PATCH 084/224] fix
https://github.com/acmesh-official/acme.sh/issues/4879#issuecomment-2942728895
---
dnsapi/dns_hostingde.sh | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/dnsapi/dns_hostingde.sh b/dnsapi/dns_hostingde.sh
index 41ccab2b..ed675b42 100644
--- a/dnsapi/dns_hostingde.sh
+++ b/dnsapi/dns_hostingde.sh
@@ -40,6 +40,11 @@ _hostingde_apiKey() {
return 1
fi
+ # The endpoint is the base URL only; the api path is appended below.
+ # hosting.de's own docs show the full api URL, so strip it if pasted in.
+ # https://github.com/acmesh-official/acme.sh/issues/6896
+ HOSTINGDE_ENDPOINT="$(echo "$HOSTINGDE_ENDPOINT" | sed 's|/api/dns/v1/json||; s|/*$||')"
+
_saveaccountconf_mutable HOSTINGDE_APIKEY "$HOSTINGDE_APIKEY"
_saveaccountconf_mutable HOSTINGDE_ENDPOINT "$HOSTINGDE_ENDPOINT"
}
From e64529ab501b217ba453ee43144c478ef3288ab7 Mon Sep 17 00:00:00 2001
From: "Simon V." <218359733+sim0n-v@users.noreply.github.com>
Date: Sat, 4 Jul 2026 10:50:55 +0200
Subject: [PATCH 085/224] ARI - Add support for Mass Revocation (#6953)
* ARI - Add support for Mass Revocation
* feat: update ARI each time NextRenewTime is not within the suggestedWindow
* Remove _ari_should_renew and add condition on Le_NextRenewTime
* Add support for ARI explanationURL
* Fix debug variable _d_ari
* New Banner
Updated README to include responsive images for dark and light modes.
* multiple fix
* fix
* fix shfmt
* Reset README
---------
Co-authored-by: ZeroSSL-Andreas
---
acme.sh | 47 ++++++++++++++++++++++++++++++++++++++---------
1 file changed, 38 insertions(+), 9 deletions(-)
diff --git a/acme.sh b/acme.sh
index a21bfd71..8e8186e0 100755
--- a/acme.sh
+++ b/acme.sh
@@ -5928,7 +5928,6 @@ renew() {
# If the window has started, renew now even if Le_NextRenewTime is in the future.
# Set NO_ARI=1 (env, account.conf, or ca.conf) to opt out and use only
# Le_NextRenewTime for the renewal decision.
- _ari_should_renew=""
if [ "$NO_ARI" = "1" ]; then
_debug "NO_ARI=1, skipping ARI suggestedWindow check"
elif [ -z "$FORCE" ] && [ -f "$CERT_PATH" ]; then
@@ -5939,20 +5938,38 @@ renew() {
_ari_end="$(echo "$_ari_resp" | _egrep_o '"end" *: *"[^"]*' | sed 's/.*"//')"
_debug "ARI suggestedWindow.start" "$_ari_start"
_debug "ARI suggestedWindow.end" "$_ari_end"
- if [ "$_ari_start" ]; then
+ if [ "$_ari_start" ] && [ "$_ari_end" ]; then
_ari_start_t="$(_date2time "$(echo "$_ari_start" | sed 's/\.[0-9]*//')")"
+ _ari_end_t="$(_date2time "$(echo "$_ari_end" | sed 's/\.[0-9]*//')")"
+ _ari_explanation_url="$(echo "$_ari_resp" | _egrep_o '"explanationURL" *: *"[^"]*' | sed 's/.*"//')"
_debug "_ari_start_t" "$_ari_start_t"
- if [ "$_ari_start_t" ] && [ "$(_time)" -ge "$_ari_start_t" ]; then
- _info "ARI suggestedWindow has started ($(__green "$_ari_start")), proceeding with renewal."
- _ari_should_renew="1"
- else
- _info "ARI suggestedWindow starts at: $(__green "$_ari_start")"
+ _debug "_ari_end_t" "$_ari_end_t"
+ _debug "_ari_explanation_url" "$_ari_explanation_url"
+ _debug "Le_NextRenewTime" "$Le_NextRenewTime"
+ # Update ARI if needed
+ if [ "$_ari_start_t" ] && [ "$_ari_end_t" ] && [ "$Le_NextRenewTime" ] && [ "$_ari_end_t" -gt "$_ari_start_t" ] && ([ "$Le_NextRenewTime" -lt "$_ari_start_t" ] || [ "$Le_NextRenewTime" -gt "$_ari_end_t" ]); then
+ _ari_old_time_str="$Le_NextRenewTimeStr"
+ _info "Current renewal time: $(__green "$_ari_old_time_str")"
+ _ari_window=$(_math "$_ari_end_t" - "$_ari_start_t")
+ _ari_offset=$(_math "$(_time)" % "$_ari_window")
+ Le_NextRenewTime=$(_math "$_ari_start_t" + "$_ari_offset")
+ Le_NextRenewTimeStr=$(_time2str "$Le_NextRenewTime")
+ _info "ARI suggestedWindow: $(__green "$_ari_start") to $(__green "$_ari_end")"
+ _info "Updating renewal time picked from ARI window: $(__green "$Le_NextRenewTimeStr")"
+ _savedomainconf Le_NextRenewTime "$Le_NextRenewTime"
+ _savedomainconf Le_NextRenewTimeStr "$Le_NextRenewTimeStr"
+ fi
+ if [ "$Le_NextRenewTime" ] && [ "$(_time)" -ge "$Le_NextRenewTime" ]; then
+ _info "ARI suggested renewal has passed ($(__green "$Le_NextRenewTimeStr")), proceeding with renewal."
+ if [ "$_ari_explanation_url" ]; then
+ _info "For more information on this renewal: $(__green "$_ari_explanation_url")"
+ fi
fi
fi
fi
fi
- if [ -z "$FORCE" ] && [ -z "$_ari_should_renew" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then
+ if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then
_info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")"
_info "Add '$(__red '--force')' to force renewal."
if [ -z "$_ACME_IN_RENEWALL" ]; then
@@ -6044,12 +6061,19 @@ renewAll() {
fi
d=$(basename "$di")
_debug d "$d"
+ _d_ari="$di.ari"
+ _debug _d_ari "$_d_ari"
(
if _endswith "$d" "$ECC_SUFFIX"; then
_isEcc=$(echo "$d" | cut -d "$ECC_SEP" -f 2)
d=$(echo "$d" | cut -d "$ECC_SEP" -f 1)
fi
renew "$d" "$_isEcc" "$_server"
+ rc="$?"
+ if [ "$rc" = "0" ] && [ "$_ari_explanation_url" ]; then
+ echo "$_ari_explanation_url" >"$_d_ari"
+ fi
+ return $rc
)
rc="$?"
_debug "Return code: $rc"
@@ -6064,8 +6088,13 @@ renewAll() {
_send_notify "Renew $d success" "Good, the cert is renewed." "$NOTIFY_HOOK" 0
fi
fi
+ _renewal_explanation=""
+ if [ -f "$_d_ari" ]; then
+ _renewal_explanation=" ($(cat "$_d_ari"))"
+ rm -f "$_d_ari"
+ fi
- _success_msg="${_success_msg} $d
+ _success_msg="${_success_msg} $d$_renewal_explanation
"
elif [ "$rc" = "$RENEW_SKIP" ]; then
if [ $_error_level -gt $NOTIFY_LEVEL_SKIP ]; then
From 8f2a476d21c13cde3b0c97ed0074c8d6b259d3f4 Mon Sep 17 00:00:00 2001
From: wardhus
Date: Sat, 4 Jul 2026 10:54:05 +0200
Subject: [PATCH 086/224] Add Calrissia.be API (#6811)
Co-authored-by: Ward
---
dnsapi/dns_calrissia.sh | 137 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 137 insertions(+)
create mode 100644 dnsapi/dns_calrissia.sh
diff --git a/dnsapi/dns_calrissia.sh b/dnsapi/dns_calrissia.sh
new file mode 100644
index 00000000..01ca3092
--- /dev/null
+++ b/dnsapi/dns_calrissia.sh
@@ -0,0 +1,137 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_calrissia_info='Calrissia.be DNS API
+Site: calrissia.be
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_calrissia
+Options:
+ CALRISSIA_TOKEN Personal access token
+Issues: github.com/acmesh-official/acme.sh/issues/6809
+Author: Ward Hus
+'
+
+CALRISSIA_API="https://my.calrissia.com/api"
+
+dns_calrissia_add() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _calrissia_load_token || return 1
+
+ if ! _calrissia_get_root "$fulldomain"; then
+ _err "Unable to find domain in Calrissia account for: $fulldomain"
+ return 1
+ fi
+
+ _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'"
+ _info "Adding TXT record for $fulldomain"
+
+ _body="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120,\"prio\":0}"
+ _response="$(_calrissia_request POST "/domain/$_domain_id/record" "$_body")"
+
+ if ! _contains "$_response" '"id"'; then
+ _err "Failed to create TXT record: $_response"
+ return 1
+ fi
+
+ return 0
+}
+
+dns_calrissia_rm() {
+ fulldomain="$1"
+ txtvalue="$2"
+
+ _calrissia_load_token || return 1
+
+ if ! _calrissia_get_root "$fulldomain"; then
+ _err "Unable to find domain in Calrissia account for: $fulldomain"
+ return 1
+ fi
+
+ _debug "domain='$_domain' id='$_domain_id' sub='$_sub_domain'"
+
+ # Look the record up from the API instead of relying on local state.
+ # The record list is embedded in the domain object.
+ _response="$(_calrissia_request GET "/domain/$_domain_id")"
+ _debug2 "Response: $_response"
+
+ # Split the record objects onto separate lines, then match on both the
+ # subdomain name and the TXT value to find the record id to delete.
+ _record_id="$(printf "%s" "$_response" |
+ tr '{}' '\n' |
+ grep "\"name\" *: *\"$_sub_domain\"" |
+ grep "\"content\" *: *\"$txtvalue\"" |
+ _egrep_o '"id" *: *[0-9]+' |
+ _head_n 1 |
+ _egrep_o '[0-9]+')"
+
+ if [ -z "$_record_id" ]; then
+ _info "No matching TXT record found for $fulldomain; nothing to remove"
+ return 0
+ fi
+
+ _info "Removing TXT record id=$_record_id from domain id=$_domain_id"
+ if ! _response="$(_calrissia_request DELETE "/domain/$_domain_id/record/$_record_id")" || _contains "$_response" '"error"'; then
+ _err "Failed to remove TXT record: $_response"
+ return 1
+ fi
+ return 0
+}
+
+####################
+# Private helpers #
+####################
+
+_calrissia_load_token() {
+ CALRISSIA_TOKEN="${CALRISSIA_TOKEN:-$(_readaccountconf_mutable CALRISSIA_TOKEN)}"
+ if [ -z "$CALRISSIA_TOKEN" ]; then
+ _err "CALRISSIA_TOKEN is not set. Generate one at https://identity.calrissia.com under API Keys."
+ return 1
+ fi
+ _saveaccountconf_mutable CALRISSIA_TOKEN "$CALRISSIA_TOKEN"
+}
+
+# Sets _domain, _domain_id, _sub_domain for a given FQDN.
+_calrissia_get_root() {
+ _fqdn="$1"
+
+ i=1
+ while true; do
+ _candidate="$(printf "%s" "$_fqdn" | cut -d . -f "$i"-)"
+ [ -z "$_candidate" ] && return 1
+
+ _debug "Trying root domain: $_candidate"
+ _response="$(_calrissia_request GET "/domain?full_domain_name=$_candidate")"
+ _debug2 "Response: $_response"
+
+ _domain_id="$(printf "%s" "$_response" |
+ _egrep_o '"id" *: *[0-9]+' |
+ _head_n 1 |
+ _egrep_o '[0-9]+')"
+
+ if [ -n "$_domain_id" ]; then
+ if [ "$i" = "1" ]; then
+ # The FQDN itself is the zone apex, e.g. a challenge-alias domain.
+ _sub_domain=""
+ else
+ _sub_domain="$(printf "%s" "$_fqdn" | cut -d . -f "1-$((i - 1))")"
+ fi
+ _domain="$_candidate"
+ return 0
+ fi
+
+ i=$((i + 1))
+ done
+}
+
+_calrissia_request() {
+ _method="$1"
+ _path="$2"
+ _body="$3"
+ export _H1="Authorization: Bearer $CALRISSIA_TOKEN"
+ export _H2="Accept: application/json"
+ if [ "$_method" = "GET" ]; then
+ _get "$CALRISSIA_API$_path"
+ else
+ _post "$_body" "$CALRISSIA_API$_path" "" "$_method" "application/json"
+ fi
+}
From 1a3682346166989a02894b4fa401ec9127445938 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 16:44:07 +0800
Subject: [PATCH 087/224]
https://github.com/acmesh-official/acme.sh/issues/3201
---
deploy/synology_dsm.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh
index 6fce1f19..502bc59b 100644
--- a/deploy/synology_dsm.sh
+++ b/deploy/synology_dsm.sh
@@ -322,8 +322,8 @@ synology_dsm_deploy() {
_savedeployconf SYNO_USE_TEMP_ADMIN "$SYNO_USE_TEMP_ADMIN"
_savedeployconf SYNO_LOCAL_HOSTNAME "$SYNO_LOCAL_HOSTNAME"
else
- _savedeployconf SYNO_USERNAME "$SYNO_USERNAME"
- _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD"
+ _savedeployconf SYNO_USERNAME "$SYNO_USERNAME" "base64"
+ _savedeployconf SYNO_PASSWORD "$SYNO_PASSWORD" "base64"
_savedeployconf SYNO_DEVICE_ID "$SYNO_DEVICE_ID"
_savedeployconf SYNO_DEVICE_NAME "$SYNO_DEVICE_NAME"
fi
From 6df2d9e451443ac3d935b7dfcb14b6bd146dea7e Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 18:19:45 +0800
Subject: [PATCH 088/224] dns_da: document that special characters in DA_Api
credentials must be percent-encoded
https://github.com/acmesh-official/acme.sh/issues/3468
---
dnsapi/dns_da.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_da.sh b/dnsapi/dns_da.sh
index 36251b05..d9cf6247 100755
--- a/dnsapi/dns_da.sh
+++ b/dnsapi/dns_da.sh
@@ -4,7 +4,7 @@ dns_da_info='DirectAdmin Server API
Site: DirectAdmin.com/api.php
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_da
Options:
- DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443"
+ DA_Api API Server URL. E.g. "https://remoteUser:remotePassword@da.domain.tld:8443". Special characters in the user/password must be percent-encoded, e.g. "@" -> "%40".
DA_Api_Insecure Insecure TLS. 0: check for cert validity, 1: always accept
Issues: github.com/TigerP/acme.sh/issues
'
From bbfb6f50aec5bddb397b5f852c5d0d1f3d5690df Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 18:46:59 +0800
Subject: [PATCH 089/224] deploy/cpanel_uapi: strip YAML double quotes around
wildcard domains in list_domains output
fix https://github.com/acmesh-official/acme.sh/issues/6115
---
deploy/cpanel_uapi.sh | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh
index e5381b61..16b622bb 100644
--- a/deploy/cpanel_uapi.sh
+++ b/deploy/cpanel_uapi.sh
@@ -194,7 +194,8 @@ __cpanel_parse_response() {
printf("%s%s=%s\n", prefix, $2, $3);
}
}' |
- sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p'
+ sed -En -e 's/^result\/data\/(main_domain|sub_domains\/-|addon_domains\/-|parked_domains\/-)=(.*)$/\2/p' |
+ sed -e 's/^"//' -e 's/"$//' # YAML double-quotes values starting with '*' (wildcard subdomains)
}
# Load parameter by prefix+name - fallback to default if not set, and save to config
From bcbfe25d08b90a133ac9da87c832889eb1975fe0 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 18:50:29 +0800
Subject: [PATCH 090/224] haproxy.sh: use two-argument -header form for
LibreSSL (#3438)
---
deploy/haproxy.sh | 10 ++++++++--
1 file changed, 8 insertions(+), 2 deletions(-)
diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh
index b618a65b..66a2e83e 100644
--- a/deploy/haproxy.sh
+++ b/deploy/haproxy.sh
@@ -272,12 +272,18 @@ haproxy_deploy() {
_cafile_argument=""
fi
_debug _cafile_argument "${_cafile_argument}"
- # if OpenSSL/LibreSSL is v1.1 or above, the format for the -header option has changed
+ # OpenSSL 1.1+ expects -header Host=value (one argument), while
+ # LibreSSL keeps the old two-argument form -header Host value at any
+ # version (3.x/4.x), so it must be detected by name, not by number.
+ _openssl_name=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f1)
_openssl_version=$(${ACME_OPENSSL_BIN:-openssl} version | cut -d' ' -f2)
+ _debug _openssl_name "${_openssl_name}"
_debug _openssl_version "${_openssl_version}"
_openssl_major=$(echo "${_openssl_version}" | cut -d '.' -f1)
_openssl_minor=$(echo "${_openssl_version}" | cut -d '.' -f2)
- if [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then
+ if [ "${_openssl_name}" = "LibreSSL" ]; then
+ _header_sep=" "
+ elif [ "${_openssl_major}" -eq "1" ] && [ "${_openssl_minor}" -ge "1" ] || [ "${_openssl_major}" -ge "2" ]; then
_header_sep="="
else
_header_sep=" "
From 33704fc27479a86d8aa6e6b14c27d9b3ab82b87d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Cl=C3=A9ment=20Gouin?=
Date: Sat, 4 Jul 2026 12:54:07 +0200
Subject: [PATCH 091/224] Allow creation of ACME account with EAB directly from
`--issue` command (#5087)
* formalized _eab_id and _eab_kid and added EAB parameters to _regAccount on --issue
* Update acme.sh
* Update acme.sh
---
acme.sh | 32 ++++++++++++++++----------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/acme.sh b/acme.sh
index 8e8186e0..6bf59fbb 100755
--- a/acme.sh
+++ b/acme.sh
@@ -3872,10 +3872,10 @@ _on_issue_success() {
#account_key_length eab-kid eab-hmac-key
registeraccount() {
_account_key_length="$1"
- _eab_id="$2"
+ _eab_kid="$2"
_eab_hmac_key="$3"
_initpath
- _regAccount "$_account_key_length" "$_eab_id" "$_eab_hmac_key"
+ _regAccount "$_account_key_length" "$_eab_kid" "$_eab_hmac_key"
}
__calcAccountKeyHash() {
@@ -3905,7 +3905,7 @@ _getAccountEmail() {
_regAccount() {
_initpath
_reg_length="$1"
- _eab_id="$2"
+ _eab_kid="$2"
_eab_hmac_key="$3"
_debug3 _regAccount "$_regAccount"
_initAPI
@@ -3922,13 +3922,13 @@ _regAccount() {
if ! _calcjwk "$ACCOUNT_KEY_PATH"; then
return 1
fi
- if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then
- _savecaconf CA_EAB_KEY_ID "$_eab_id"
+ if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then
+ _savecaconf CA_EAB_KEY_ID "$_eab_kid"
_savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key"
fi
- _eab_id=$(_readcaconf "CA_EAB_KEY_ID")
+ _eab_kid=$(_readcaconf "CA_EAB_KEY_ID")
_eab_hmac_key=$(_readcaconf "CA_EAB_HMAC_KEY")
- _secure_debug3 _eab_id "$_eab_id"
+ _secure_debug3 _eab_kid "$_eab_kid"
_secure_debug3 _eab_hmac_key "$_eab_hmac_key"
_email="$(_getAccountEmail)"
if [ "$_email" ]; then
@@ -3936,7 +3936,7 @@ _regAccount() {
fi
if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then
- if [ -z "$_eab_id" ] || [ -z "$_eab_hmac_key" ]; then
+ if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then
_info "No EAB credentials found for ZeroSSL, let's obtain them"
if [ -z "$_email" ]; then
_info "$(__green "$PROJECT_NAME is using ZeroSSL as default CA now.")"
@@ -3952,10 +3952,10 @@ _regAccount() {
return 1
fi
_secure_debug2 _eabresp "$_eabresp"
- _eab_id="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')"
- _secure_debug2 _eab_id "$_eab_id"
- if [ -z "$_eab_id" ]; then
- _err "Cannot resolve _eab_id"
+ _eab_kid="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_kid"' | cut -d : -f 2 | tr -d '"')"
+ _secure_debug2 _eab_kid "$_eab_kid"
+ if [ -z "$_eab_kid" ]; then
+ _err "Cannot resolve _eab_kid"
return 1
fi
_eab_hmac_key="$(echo "$_eabresp" | tr ',}' '\n\n' | grep '"eab_hmac_key"' | cut -d : -f 2 | tr -d '"')"
@@ -3964,12 +3964,12 @@ _regAccount() {
_err "Cannot resolve _eab_hmac_key"
return 1
fi
- _savecaconf CA_EAB_KEY_ID "$_eab_id"
+ _savecaconf CA_EAB_KEY_ID "$_eab_kid"
_savecaconf CA_EAB_HMAC_KEY "$_eab_hmac_key"
fi
fi
- if [ "$_eab_id" ] && [ "$_eab_hmac_key" ]; then
- eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_id\",\"url\":\"${ACME_NEW_ACCOUNT}\"}"
+ if [ "$_eab_kid" ] && [ "$_eab_hmac_key" ]; then
+ eab_protected="{\"alg\":\"HS256\",\"kid\":\"$_eab_kid\",\"url\":\"${ACME_NEW_ACCOUNT}\"}"
_debug3 eab_protected "$eab_protected"
eab_protected64=$(printf "%s" "$eab_protected" | _base64 | _url_replace)
@@ -4798,7 +4798,7 @@ issue() {
_debug2 _saved_account_key_hash "$_saved_account_key_hash"
if [ -z "$ACCOUNT_URL" ] || [ -z "$_saved_account_key_hash" ] || [ "$_saved_account_key_hash" != "$(__calcAccountKeyHash)" ]; then
- if ! _regAccount "$_accountkeylength"; then
+ if ! _regAccount "$_accountkeylength" "$_eab_kid" "$_eab_hmac_key"; then
_on_issue_err "$_post_hook"
return 1
fi
From 917bebd46033fad6a167d768e9c04cc82520bff3 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 19:06:26 +0800
Subject: [PATCH 092/224] dns_huaweicloud: add optional HUAWEICLOUD_Region
(default ap-southeast-1)
The DNS endpoint and IAM token scope project were hardcoded to
ap-southeast-1, which fails for accounts without that region enabled.
fix https://github.com/acmesh-official/acme.sh/issues/5302
---
dnsapi/dns_huaweicloud.sh | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/dnsapi/dns_huaweicloud.sh b/dnsapi/dns_huaweicloud.sh
index ee2d2b8e..83fcc625 100644
--- a/dnsapi/dns_huaweicloud.sh
+++ b/dnsapi/dns_huaweicloud.sh
@@ -7,11 +7,11 @@ Options:
HUAWEICLOUD_Username Username
HUAWEICLOUD_Password Password
HUAWEICLOUD_DomainName DomainName
+ HUAWEICLOUD_Region Region. E.g. "cn-north-4". Optional, defaults to "ap-southeast-1".
Issues: github.com/acmesh-official/acme.sh/issues/3265
'
iam_api="https://iam.myhuaweicloud.com"
-dns_api="https://dns.ap-southeast-1.myhuaweicloud.com" # Should work
######## Public functions #####################
@@ -30,6 +30,7 @@ dns_huaweicloud_add() {
HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}"
HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}"
HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}"
+ HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}"
# Check information
if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then
@@ -37,8 +38,11 @@ dns_huaweicloud_add() {
return 1
fi
+ _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}"
+ dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com"
+
unset token # Clear token
- token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")"
+ token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")"
if [ -z "${token}" ]; then # Check token
_err "dns_api(dns_huaweicloud): Error getting token."
return 1
@@ -65,6 +69,9 @@ dns_huaweicloud_add() {
_saveaccountconf_mutable HUAWEICLOUD_Username "${HUAWEICLOUD_Username}"
_saveaccountconf_mutable HUAWEICLOUD_Password "${HUAWEICLOUD_Password}"
_saveaccountconf_mutable HUAWEICLOUD_DomainName "${HUAWEICLOUD_DomainName}"
+ if [ -n "${HUAWEICLOUD_Region}" ]; then
+ _saveaccountconf_mutable HUAWEICLOUD_Region "${HUAWEICLOUD_Region}"
+ fi
return 0
}
@@ -81,6 +88,7 @@ dns_huaweicloud_rm() {
HUAWEICLOUD_Username="${HUAWEICLOUD_Username:-$(_readaccountconf_mutable HUAWEICLOUD_Username)}"
HUAWEICLOUD_Password="${HUAWEICLOUD_Password:-$(_readaccountconf_mutable HUAWEICLOUD_Password)}"
HUAWEICLOUD_DomainName="${HUAWEICLOUD_DomainName:-$(_readaccountconf_mutable HUAWEICLOUD_DomainName)}"
+ HUAWEICLOUD_Region="${HUAWEICLOUD_Region:-$(_readaccountconf_mutable HUAWEICLOUD_Region)}"
# Check information
if [ -z "${HUAWEICLOUD_Username}" ] || [ -z "${HUAWEICLOUD_Password}" ] || [ -z "${HUAWEICLOUD_DomainName}" ]; then
@@ -88,8 +96,11 @@ dns_huaweicloud_rm() {
return 1
fi
+ _huaweicloud_region="${HUAWEICLOUD_Region:-ap-southeast-1}"
+ dns_api="https://dns.${_huaweicloud_region}.myhuaweicloud.com"
+
unset token # Clear token
- token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}")"
+ token="$(_get_token "${HUAWEICLOUD_Username}" "${HUAWEICLOUD_Password}" "${HUAWEICLOUD_DomainName}" "${_huaweicloud_region}")"
if [ -z "${token}" ]; then # Check token
_err "dns_api(dns_huaweicloud): Error getting token."
return 1
@@ -298,6 +309,7 @@ _get_token() {
_username=$1
_password=$2
_domain_name=$3
+ _region_name=$4
_debug "Getting Token"
body="{
@@ -318,7 +330,7 @@ _get_token() {
},
\"scope\": {
\"project\": {
- \"name\": \"ap-southeast-1\"
+ \"name\": \"${_region_name}\"
}
}
}
From a1051260635487670f28188156caec4f6dfe40fe Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 19:14:36 +0800
Subject: [PATCH 093/224] _date2time: pass date via argv to python to prevent
code injection (#6463) https://github.com/acmesh-official/acme.sh/issues/6463
---
acme.sh | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index 6bf59fbb..148945b9 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1895,12 +1895,13 @@ _date2time() {
if gdate -u -d "$(echo "$1" | tr -d "Z" | tr "T" ' ')" +"%s" 2>/dev/null; then
return
fi
- #Omnios
- if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then
+ #Omnios. Pass the date as argv (sys.argv[1]) instead of interpolating it into
+ #the -c program text, so a quote in the input cannot inject Python code.
+ if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%d %H:%M:%S\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then
return
fi
#Omnios
- if python3 -c "import datetime; print(int(datetime.datetime.strptime(\"$1\", \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" 2>/dev/null; then
+ if python3 -c "import datetime,sys; print(int(datetime.datetime.strptime(sys.argv[1], \"%Y-%m-%dT%H:%M:%SZ\").replace(tzinfo=datetime.timezone.utc).timestamp()))" "$1" 2>/dev/null; then
return
fi
_err "Cannot parse _date2time $1"
@@ -2992,6 +2993,11 @@ _initAPI() {
return 0
fi
_err "Cannot init API for $_api_server"
+ if [ "$_api_server" = "$CA_ZEROSSL" ]; then
+ _info "$(__green "If this host is IPv6-only: ZeroSSL currently has no IPv6 endpoint.")"
+ _info "$(__green "Try another CA, e.g.: $PROJECT_ENTRY --set-default-ca --server letsencrypt")"
+ _info "See: $(__green "https://github.com/acmesh-official/acme.sh/issues/6872")"
+ fi
return 1
}
From d3e12694b9a945664c963b3336270273d309e991 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 19:56:37 +0800
Subject: [PATCH 094/224] fix "identifiers are duplicated" when signing a CSR
with a wildcard CN also present in SAN
_contains matches with grep regex, so the '*' in "DNS:*.example.com," never
matched and the subject was appended to the identifiers a second time.
Escape the wildcard before the check, the same way the sed removal already does.
fix https://github.com/acmesh-official/acme.sh/issues/5251
---
acme.sh | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/acme.sh b/acme.sh
index 148945b9..8256d5f0 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1423,12 +1423,13 @@ _readSubjectAltNamesFromCSR() {
_dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | grep "^ *DNS:.*" | tr -d ' \n')"
_debug _dnsAltnames "$_dnsAltnames"
- if _contains "$_dnsAltnames," "DNS:$_csrsubj,"; then
+ # escape the wildcard '*' so it is not taken as a regex operator by grep/sed below
+ _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')"
+ _debug _excapedAlgnames "$_excapedAlgnames"
+ _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')"
+ _debug _escapedSubject "$_escapedSubject"
+ if _contains "$_excapedAlgnames," "DNS:$_escapedSubject,"; then
_debug "AltNames contains subject"
- _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')"
- _debug _excapedAlgnames "$_excapedAlgnames"
- _escapedSubject="$(echo "$_csrsubj" | tr '*' '#')"
- _debug _escapedSubject "$_escapedSubject"
_dnsAltnames="$(echo "$_excapedAlgnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")"
_debug _dnsAltnames "$_dnsAltnames"
else
From 843a7efa7ddd2b069c96249de6261100e08b6af5 Mon Sep 17 00:00:00 2001
From: xiaopc
Date: Sat, 4 Jul 2026 20:14:32 +0800
Subject: [PATCH 095/224] fix(gcore_cdn): renew login api url (#5143)
https://api.gcore.com/docs/iam#tag/Account
---
deploy/gcore_cdn.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/deploy/gcore_cdn.sh b/deploy/gcore_cdn.sh
index fd17cc25..93e9e32c 100644
--- a/deploy/gcore_cdn.sh
+++ b/deploy/gcore_cdn.sh
@@ -57,7 +57,7 @@ gcore_cdn_deploy() {
_request="{\"username\":\"$Le_Deploy_gcore_cdn_username\",\"password\":\"$Le_Deploy_gcore_cdn_password\"}"
_debug _request "$_request"
export _H1="Content-Type:application/json"
- _response=$(_post "$_request" "https://api.gcore.com/auth/jwt/login")
+ _response=$(_post "$_request" "https://api.gcore.com/iam/auth/jwt/login")
_debug _response "$_response"
_regex=".*\"access\":\"\([-._0-9A-Za-z]*\)\".*$"
_debug _regex "$_regex"
From ede9a86d46d93f56d5b36ce2873c0d19489604bc Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 20:21:17 +0800
Subject: [PATCH 096/224] Accept both 401 and 403 for deactivated account
detection
RFC 8555 sec 7.3.6 requires 401 (Unauthorized) when a request is
signed by a deactivated account, which ZeroSSL follows, while
Boulder (Let's Encrypt) historically returns 403. Check both codes
in _regAccount and deactivateaccount.
fix https://github.com/acmesh-official/acme.sh/issues/5138
---
acme.sh | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/acme.sh b/acme.sh
index 8256d5f0..e7e9eb66 100755
--- a/acme.sh
+++ b/acme.sh
@@ -4042,7 +4042,9 @@ _regAccount() {
_debug "Calc CA_KEY_HASH" "$CA_KEY_HASH"
_savecaconf CA_KEY_HASH "$CA_KEY_HASH"
- if [ "$code" = '403' ]; then
+ #RFC 8555 sec 7.3.6 requires 401 for requests from a deactivated account,
+ #but Boulder (Let's Encrypt) historically returns 403. Accept both.
+ if [ "$code" = '403' ] || [ "$code" = '401' ]; then
_err "It seems that the account key has been deactivated, please use a new account key."
return 1
fi
@@ -4122,7 +4124,8 @@ deactivateaccount() {
if _send_signed_request "$_accUri" "$_djson" && _contains "$response" '"deactivated"'; then
_info "Successfully deactivated account $_accUri."
_accid=$(echo "$response" | _egrep_o "\"id\" *: *[^,]*," | cut -d : -f 2 | tr -d ' ,')
- elif [ "$code" = "403" ]; then
+ elif [ "$code" = "403" ] || [ "$code" = "401" ]; then
+ #RFC 8555 sec 7.3.6: 401 from a deactivated account; Boulder returns 403
_info "The account is already deactivated."
_accid=$(_getfield "$_accUri" "999" "/")
else
From fbf3b41c541694348584024b5d6703b3ddfbde95 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 20:28:56 +0800
Subject: [PATCH 097/224] dns_inwx: fix _get_root false zone match for
single-letter subdomains
_get_root matched the candidate zone with _contains (grep), which treats
the domain as a regex. For "-d g." the candidate "g." matched
"..." because '.' matches the '>' after "string" and the 'g'
comes from the "" tag, so "g." was wrongly taken as the root
zone (sub=_acme-challenge instead of _acme-challenge.g). Anchor the match
to $h and escape dots so the zone is compared literally.
Fixes #5129
---
dnsapi/dns_inwx.sh | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/dnsapi/dns_inwx.sh b/dnsapi/dns_inwx.sh
index dba23846..460d4d28 100755
--- a/dnsapi/dns_inwx.sh
+++ b/dnsapi/dns_inwx.sh
@@ -307,13 +307,18 @@ _get_root() {
return 1
fi
- if _contains "$response" "$h"; then
+ # Anchor the match to the XML tag and escape dots so $h is compared
+ # literally: _contains uses grep, which treats "$h" as a regex, and a
+ # bare "g.berlight.de" would match "berlight.de" (the 'g' from
+ # "" plus '.' matching '>'). See issue #5129.
+ _hregex=$(printf "%s" "$h" | sed 's/\./\\./g')
+ if _contains "$response" "$_hregex "; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain="$h"
return 0
fi
# IDN fallback: INWX returns Unicode zone names; when $h is ACE/punycode,
- # encode each zone name via _idn() and compare — no python dependency.
+ # encode each zone name via _idn() and compare -- no python dependency.
if _contains "$h" "xn--"; then
_zone_unicode=$(printf "%s" "$response" | _egrep_o '[^<]*' |
sed 's/<[^>]*>//g' | while IFS= read -r _z; do
From b92516f79edef6a286dd8ba52305b8d541830410 Mon Sep 17 00:00:00 2001
From: Ramon
Date: Sat, 4 Jul 2026 15:07:18 +0200
Subject: [PATCH 098/224] add application/json to acmedns (#5066)
---
dnsapi/dns_acmedns.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh
index f3f50233..b109a4e5 100755
--- a/dnsapi/dns_acmedns.sh
+++ b/dnsapi/dns_acmedns.sh
@@ -71,7 +71,7 @@ dns_acmedns_add() {
data="{\"subdomain\":\"$ACMEDNS_SUBDOMAIN\", \"txt\": \"$txtvalue\"}"
_debug data "$data"
- response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST")"
+ response="$(_post "$data" "$ACMEDNS_UPDATE_URL" "" "POST" "application/json")"
_debug response "$response"
if ! echo "$response" | grep "\"$txtvalue\"" >/dev/null; then
From 988afd0f59545ca6cc57b9317701c3156934e261 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 21:34:35 +0800
Subject: [PATCH 099/224] _isIPv4: do not glob segments, require exactly 4
octets
The unquoted splitting let a "*" segment expand against files in the
current directory, so "*.*.*.*" could pass as a valid IPv4 address
(issue 4971). The old code also accepted "", "1.2.3", "1.2.3.4.5",
"1..2.3" and bare numbers. Split with IFS under set -f, require 4
octets, and validate each as a 1-3 digit number <= 255.
Based on https://github.com/acmesh-official/acme.sh/pull/4974
fix https://github.com/acmesh-official/acme.sh/issues/4971
---
acme.sh | 25 +++++++++++++++++--------
1 file changed, 17 insertions(+), 8 deletions(-)
diff --git a/acme.sh b/acme.sh
index e7e9eb66..b62b19cf 100755
--- a/acme.sh
+++ b/acme.sh
@@ -4598,16 +4598,25 @@ _match_issuer() {
#ip
_isIPv4() {
- for seg in $(echo "$1" | tr '.' ' '); do
- _debug2 seg "$seg"
- if [ "$(echo "$seg" | tr -d '[0-9]')" ]; then
- #not all number
+ #splitting must not glob: a "*" segment would match files in cwd
+ set -f
+ _ipv4_saved_ifs="$IFS"
+ IFS='.'
+ # shellcheck disable=SC2086
+ set -- $1
+ IFS="$_ipv4_saved_ifs"
+ set +f
+ if [ $# -ne 4 ]; then
+ return 1
+ fi
+ for _ipv4_seg in "$@"; do
+ _debug2 _ipv4_seg "$_ipv4_seg"
+ case "$_ipv4_seg" in
+ *[!0-9]* | "") return 1 ;;
+ esac
+ if [ "${#_ipv4_seg}" -gt 3 ] || [ "$_ipv4_seg" -gt 255 ]; then
return 1
fi
- if [ $seg -ge 0 ] && [ $seg -lt 256 ]; then
- continue
- fi
- return 1
done
return 0
}
From 9764f67619065a5aaa6869a595eac30c9286357c Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 21:58:57 +0800
Subject: [PATCH 100/224] dns_cn: convert IDN domain to punycode before API
calls
Core-Networks' API rejects Unicode domain names with "invalid domain";
it requires punycode. dns_cn_add / dns_cn_rm passed the raw challenge
domain straight through, so IDN certs failed at the TXT add step
(issue #4804). Run fulldomain through _idn() in both functions. For
ASCII/punycode input _idn() is a pass-through, so non-IDN domains are
unaffected.
Fixes #4804
---
dnsapi/dns_cn.sh | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/dnsapi/dns_cn.sh b/dnsapi/dns_cn.sh
index 79698e88..e06a2be6 100644
--- a/dnsapi/dns_cn.sh
+++ b/dnsapi/dns_cn.sh
@@ -15,7 +15,8 @@ CN_API="https://beta.api.core-networks.de"
######## Public functions #####################
dns_cn_add() {
- fulldomain=$1
+ # Core-Networks API requires punycode for IDN domains
+ fulldomain=$(_idn "$1")
txtvalue=$2
if ! _cn_login; then
@@ -58,7 +59,8 @@ dns_cn_add() {
}
dns_cn_rm() {
- fulldomain=$1
+ # Core-Networks API requires punycode for IDN domains
+ fulldomain=$(_idn "$1")
txtvalue=$2
if ! _cn_login; then
From 4978782fb8df3a72d2f79a8360123f872be8a3df Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 23:40:31 +0800
Subject: [PATCH 101/224] renewAll: error out if CERT_HOME is not a directory
With a misconfigured $HOME / CERT_HOME the glob over "$CERT_HOME"/*.*
matches nothing, so renewAll silently does nothing and returns success --
--renew-all / --cron appears to work while renewing no certificates.
Check that CERT_HOME is a directory up front and return 1 with a clear
error instead.
Closes #4508
---
acme.sh | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/acme.sh b/acme.sh
index b62b19cf..4ac94da3 100755
--- a/acme.sh
+++ b/acme.sh
@@ -6072,6 +6072,10 @@ renewAll() {
_set_level=${NOTIFY_LEVEL:-$NOTIFY_LEVEL_DEFAULT}
_debug "_set_level" "$_set_level"
export _ACME_IN_RENEWALL=1
+ if ! [ -d "$CERT_HOME" ]; then
+ _err "$CERT_HOME is not a directory, please check your configuration."
+ return 1
+ fi
for di in "${CERT_HOME}"/*.* "${CERT_HOME}"/*:*; do
_debug di "$di"
if ! [ -d "$di" ]; then
From 77047eb0efaf21b0019b8389891b00388f830eee Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 4 Jul 2026 23:55:16 +0800
Subject: [PATCH 102/224] fix CSR reading on systems without a default
openssl.cnf (e.g. NetBSD)
"openssl req -noout -in" aborts when the default config file is missing;
reading a CSR needs no config, so pass -config /dev/null explicitly.
Stock NetBSD does not install /etc/openssl/openssl.cnf, so --signcsr
never worked there.
---
acme.sh | 8 +++++---
1 file changed, 5 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index 4ac94da3..4bc4b2ba 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1405,7 +1405,9 @@ _readSubjectFromCSR() {
_usage "_readSubjectFromCSR mycsr.csr"
return 1
fi
- ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n'
+ # -config /dev/null: reading a CSR needs no config, but a missing default
+ # openssl.cnf is fatal on some systems (e.g. NetBSD does not install one)
+ ${ACME_OPENSSL_BIN:-openssl} req -noout -in "$_csrfile" -subject -config /dev/null | tr ',' "\n" | _egrep_o "CN *=.*" | cut -d = -f 2 | cut -d / -f 1 | tr -d ' \n'
}
#_csrfile
@@ -1420,7 +1422,7 @@ _readSubjectAltNamesFromCSR() {
_csrsubj="$(_readSubjectFromCSR "$_csrfile")"
_debug _csrsubj "$_csrsubj"
- _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" | grep "^ *DNS:.*" | tr -d ' \n')"
+ _dnsAltnames="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null | grep "^ *DNS:.*" | tr -d ' \n')"
_debug _dnsAltnames "$_dnsAltnames"
# escape the wildcard '*' so it is not taken as a regex operator by grep/sed below
@@ -1447,7 +1449,7 @@ _readKeyLengthFromCSR() {
return 1
fi
- _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile")"
+ _outcsr="$(${ACME_OPENSSL_BIN:-openssl} req -noout -text -in "$_csrfile" -config /dev/null)"
_debug2 _outcsr "$_outcsr"
if _contains "$_outcsr" "Public Key Algorithm: id-ecPublicKey"; then
_debug "ECC CSR"
From 0eb5cc8384c306fc3adcbbe8d00bb85608751749 Mon Sep 17 00:00:00 2001
From: Foster Snowhill
Date: Sun, 5 Jul 2026 06:03:48 +0200
Subject: [PATCH 103/224] dns_desec: fix advertised token variable name (#7081)
This must've been a copy-paste error from `dns_ddnss`.
Fixes: 6b7b5caf54ea ("DNS provider API: structured description")
---
dnsapi/dns_desec.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh
index 275babea..e5e4809a 100644
--- a/dnsapi/dns_desec.sh
+++ b/dnsapi/dns_desec.sh
@@ -4,7 +4,7 @@ dns_desec_info='deSEC.io
Site: desec.readthedocs.io/en/latest/
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_desec
Options:
- DDNSS_Token API Token
+ DEDYN_TOKEN API Token
Issues: github.com/acmesh-official/acme.sh/issues/2180
Author: Zheng Qian
'
From 524d96a3a8004eb7674bd74b1ba7b6f67f4e808a Mon Sep 17 00:00:00 2001
From: Jan Forman <47356271+jforman96@users.noreply.github.com>
Date: Sun, 5 Jul 2026 06:44:07 +0200
Subject: [PATCH 104/224] Add WEDOS WAPI DNS API (dns_wedos) (#7072)
* Add WEDOS WAPI DNS API (dns_wedos)
* dns_wedos: fix response parsing on systems without egrep -o
* dns_wedos: report WAPI auth errors, UTC fallback for hosts ignoring TZ
---
dnsapi/dns_wedos.sh | 217 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 217 insertions(+)
create mode 100644 dnsapi/dns_wedos.sh
diff --git a/dnsapi/dns_wedos.sh b/dnsapi/dns_wedos.sh
new file mode 100644
index 00000000..d1f353e2
--- /dev/null
+++ b/dnsapi/dns_wedos.sh
@@ -0,0 +1,217 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_wedos_info='WEDOS.com
+Site: wedos.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_wedos
+Options:
+ WEDOS_Username WAPI login (account email)
+ WEDOS_Wapipass WAPI password
+Issues: github.com/acmesh-official/acme.sh/issues/7071
+Author: Jan Forman
+'
+
+WEDOS_Api="https://api.wedos.com/wapi/json"
+
+######## Public functions #####################
+
+#Usage: dns_wedos_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_wedos_add() {
+ fulldomain=$(echo "$1" | _lower_case)
+ txtvalue=$2
+
+ if ! _wedos_init; then
+ return 1
+ fi
+
+ _debug "Detecting root zone for $fulldomain"
+ if ! _get_root "$fulldomain"; then
+ _err "Cannot determine root zone for: $fulldomain"
+ return 1
+ fi
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ _info "Adding TXT record: $_sub_domain.$_domain"
+ if ! _wedos_request "dns-row-add" "{\"domain\":\"$_domain\",\"name\":\"$_sub_domain\",\"ttl\":\"300\",\"type\":\"TXT\",\"rdata\":\"$txtvalue\"}"; then
+ _err "Failed to add TXT record"
+ return 1
+ fi
+
+ _info "Committing DNS changes for $_domain"
+ if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then
+ _err "Failed to commit DNS changes"
+ return 1
+ fi
+
+ return 0
+}
+
+#Usage: dns_wedos_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_wedos_rm() {
+ fulldomain=$(echo "$1" | _lower_case)
+ txtvalue=$2
+
+ if ! _wedos_init; then
+ return 1
+ fi
+
+ _debug "Detecting root zone for $fulldomain"
+ if ! _get_root "$fulldomain"; then
+ _err "Cannot determine root zone for: $fulldomain"
+ return 1
+ fi
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+
+ # _get_root leaves the dns-rows-list response for $_domain in $response
+ _debug "Looking up row IDs for TXT value: $txtvalue"
+ _row_ids=$(echo "$response" | tr '{' '\n' | grep -F -- "\"rdata\":\"$txtvalue\"" | grep -F -- "\"name\":\"$_sub_domain\"" | _egrep_o '"ID": *"[0-9]*"' | tr -dc '0-9\n')
+ _debug _row_ids "$_row_ids"
+
+ if [ -z "$_row_ids" ]; then
+ _info "TXT record not found, nothing to remove"
+ return 0
+ fi
+
+ for _row_id in $_row_ids; do
+ _info "Removing TXT record ID $_row_id from $_domain"
+ if ! _wedos_request "dns-row-delete" "{\"domain\":\"$_domain\",\"row_id\":\"$_row_id\"}"; then
+ _err "Failed to delete TXT record"
+ return 1
+ fi
+ done
+
+ _info "Committing DNS changes for $_domain"
+ if ! _wedos_request "dns-domain-commit" "{\"name\":\"$_domain\"}"; then
+ _err "Failed to commit DNS changes"
+ return 1
+ fi
+
+ return 0
+}
+
+#################### Private functions below ##################################
+
+_wedos_init() {
+ WEDOS_Username="${WEDOS_Username:-$(_readaccountconf_mutable WEDOS_Username)}"
+ WEDOS_Wapipass="${WEDOS_Wapipass:-$(_readaccountconf_mutable WEDOS_Wapipass)}"
+
+ if [ -z "$WEDOS_Username" ] || [ -z "$WEDOS_Wapipass" ]; then
+ WEDOS_Username=""
+ WEDOS_Wapipass=""
+ _err "You didn't specify the WEDOS WAPI credentials yet."
+ _err "Please export WEDOS_Username and WEDOS_Wapipass and try again."
+ return 1
+ fi
+
+ _saveaccountconf_mutable WEDOS_Username "$WEDOS_Username"
+ _saveaccountconf_mutable WEDOS_Wapipass "$WEDOS_Wapipass"
+ return 0
+}
+
+# WAPI auth token: sha1(login + sha1(password) + hour), where the hour is
+# the current hour on the WEDOS servers (Europe/Prague timezone).
+# The POSIX TZ string is used so no tzdata is required on the client.
+_wedos_auth() {
+ if [ "$_wedos_utc" ]; then
+ # fallback: WAPI accepts 1 hour of skew, UTC+1 fits both CET and CEST
+ _wedos_hour=$(date -u +%H)
+ _wedos_hour=$(printf '%02d' "$(((${_wedos_hour#0} + 1) % 24))")
+ else
+ _wedos_hour=$(TZ='CET-1CEST,M3.5.0,M10.5.0/3' date +%H)
+ fi
+ _wedos_phash=$(printf '%s' "$WEDOS_Wapipass" | _digest sha1 hex)
+ printf '%s' "${WEDOS_Username}${_wedos_phash}${_wedos_hour}" | _digest sha1 hex
+}
+
+#Usage: _wedos_request
+#Returns 0 and sets $response on WAPI code 1000, returns 1 otherwise.
+_wedos_request() {
+ _wedos_cmd="$1"
+ _wedos_data="$2"
+
+ _wedos_token=$(_wedos_auth)
+ _secure_debug _wedos_token "$_wedos_token"
+
+ _wedos_json="{\"request\":{\"user\":\"$WEDOS_Username\",\"auth\":\"$_wedos_token\",\"command\":\"$_wedos_cmd\",\"data\":$_wedos_data}}"
+ _debug2 "WAPI command: $_wedos_cmd"
+ _debug2 "WAPI data: $_wedos_data"
+
+ # _post sends the global _H1.._H5 headers with every request; clear them so
+ # headers from earlier API calls are not leaked to the WAPI endpoint.
+ export _H1=""
+ export _H2=""
+ export _H3=""
+ export _H4=""
+ export _H5=""
+
+ _wedos_body="request=$(printf '%s' "$_wedos_json" | _url_encode)"
+ response=$(_post "$_wedos_body" "$WEDOS_Api" "" "POST" "application/x-www-form-urlencoded")
+ if [ "$?" != "0" ]; then
+ _err "WAPI request failed for command '$_wedos_cmd'"
+ return 1
+ fi
+ _debug2 "WAPI response: $response"
+
+ _wedos_code=$(echo "$response" | _egrep_o '"code": *[0-9]*' | _head_n 1 | tr -dc '0-9')
+ _debug2 "WAPI result code: $_wedos_code"
+ if [ "$_wedos_code" = "1000" ]; then
+ return 0
+ fi
+
+ # some systems ignore the TZ variable (Haiku), sending a wrong auth hour;
+ # retry once with the UTC fallback in _wedos_auth
+ if [ "$_wedos_code" = "2050" ] && [ -z "$_wedos_utc" ]; then
+ _wedos_utc=1
+ _wedos_request "$_wedos_cmd" "$_wedos_data"
+ return $?
+ fi
+
+ # 2050 = bad credentials, 2051 = IP not whitelisted, 2052 = IP blocked
+ if [ "$_wedos_code" = "2050" ] || [ "$_wedos_code" = "2051" ] || [ "$_wedos_code" = "2052" ]; then
+ _wedos_result=$(echo "$response" | _egrep_o '"result": *"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
+ _err "WAPI authentication error $_wedos_code: $_wedos_result"
+ _err "Check WEDOS_Username, WEDOS_Wapipass and the WAPI IP whitelist."
+ _wedos_autherr=1
+ return 1
+ fi
+
+ _debug "WAPI error for command '$_wedos_cmd': $response"
+ return 1
+}
+
+# Determine the registered domain (_domain) and subdomain prefix (_sub_domain)
+# by walking up the labels and calling dns-rows-list until WAPI accepts one.
+# _acme-challenge.www.example.co.uk
+# -> _sub_domain=_acme-challenge.www _domain=example.co.uk
+# The full domain itself is tried first, so a zone apex (e.g. DNS alias mode
+# pointing at the registered domain) resolves to an empty _sub_domain.
+_get_root() {
+ _gr_full="$1"
+ _gr_i=1
+ _wedos_autherr=""
+ while true; do
+ _gr_candidate=$(printf '%s' "$_gr_full" | cut -d . -f "${_gr_i}"-100)
+ _debug2 "Checking zone candidate: $_gr_candidate"
+ if [ -z "$_gr_candidate" ]; then
+ return 1
+ fi
+
+ if _wedos_request "dns-rows-list" "{\"domain\":\"$_gr_candidate\"}"; then
+ _domain="$_gr_candidate"
+ if [ "$_gr_i" = "1" ]; then
+ _sub_domain=""
+ else
+ _sub_domain=$(printf '%s' "$_gr_full" | cut -d . -f 1-"$((_gr_i - 1))")
+ fi
+ return 0
+ fi
+
+ # auth error hits every candidate, stop the walk
+ if [ "$_wedos_autherr" ]; then
+ return 1
+ fi
+
+ _gr_i=$((_gr_i + 1))
+ done
+}
From 1e2cd50fc9759d046ef16d8dd7ada263fe257078 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 15:44:12 +0800
Subject: [PATCH 105/224] deploy/haproxy: use printf instead of "echo -e" for
the stats socket payload
dash's echo has no -e flag and sends a literal "-e " prefix to the
socket, so haproxy rejects the command and the hot update always fails
on Debian/Ubuntu (/bin/sh = dash). Also accept "Transaction updated",
which haproxy replies when an uncommitted transaction already exists.
fix https://github.com/acmesh-official/acme.sh/issues/6165
---
deploy/haproxy.sh | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/deploy/haproxy.sh b/deploy/haproxy.sh
index 66a2e83e..9736e6ff 100644
--- a/deploy/haproxy.sh
+++ b/deploy/haproxy.sh
@@ -364,7 +364,9 @@ haproxy_deploy() {
else
_info "Update existing certificate '${_pem}' over HAProxy ${_socketname}."
fi
- _socat_cert_set_cmd="echo -e '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -q 'Transaction created'"
+ # printf %b, not "echo -e": dash's echo has no -e and sends a literal "-e " to the socket.
+ # "Transaction updated" is replied instead of "created" when an uncommitted transaction exists.
+ _socat_cert_set_cmd="printf '%b\n' '${_cmdpfx}set ssl cert ${_pem} <<\n$(cat "${_pem}")\n' | socat '${_statssock}' - | grep -qE 'Transaction (created|updated)'"
_secure_debug _socat_cert_set_cmd "${_socat_cert_set_cmd}"
eval "${_socat_cert_set_cmd}"
_ret=$?
From 31b13caf8bfe8f95a49b19662d507280368eeba0 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 15:51:38 +0800
Subject: [PATCH 106/224] DNS.yml: fix workflow warnings
- replace deprecated set-output with GITHUB_OUTPUT
- untap aws/tap before brew install to silence tap trust warning
- inject safe.directory=* for cygwin git so the checkout post step
no longer fails with dubious ownership (exit 128)
---
.github/workflows/DNS.yml | 17 ++++++++++++-----
1 file changed, 12 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml
index a972ae1a..5417068f 100644
--- a/.github/workflows/DNS.yml
+++ b/.github/workflows/DNS.yml
@@ -26,9 +26,9 @@ jobs:
id: step_one
run: |
if [ "${{secrets.TokenName1}}" ] ; then
- echo "::set-output name=hasToken::true"
+ echo "hasToken=true" >> "$GITHUB_OUTPUT"
else
- echo "::set-output name=hasToken::false"
+ echo "hasToken=false" >> "$GITHUB_OUTPUT"
fi
- name: Check the value
run: echo ${{ steps.step_one.outputs.hasToken }}
@@ -116,7 +116,9 @@ jobs:
steps:
- uses: actions/checkout@v6
- name: Install tools
- run: brew install socat
+ run: |
+ brew untap aws/tap || true
+ brew install socat
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- name: Run acmetest
@@ -176,9 +178,14 @@ jobs:
C:\tools\cygwin\cygwinsetup.exe -qgnNdO -R C:/tools/cygwin -s https://mirrors.kernel.org/sourceware/cygwin/ -P socat,curl,cron,unzip,git
shell: cmd
- name: Set ENV
- shell: cmd
+ shell: bash
run: |
- echo PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin >> %GITHUB_ENV%
+ echo 'PATH=C:\tools\cygwin\bin;C:\tools\cygwin\usr\bin' >> "$GITHUB_ENV"
+ # cygwin git sees the runner workspace as owned by another user and
+ # fails with "dubious ownership" (exit 128) in the checkout post step
+ echo 'GIT_CONFIG_COUNT=1' >> "$GITHUB_ENV"
+ echo 'GIT_CONFIG_KEY_0=safe.directory' >> "$GITHUB_ENV"
+ echo 'GIT_CONFIG_VALUE_0=*' >> "$GITHUB_ENV"
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- name: Run acmetest
From 1cd63e1480bbb692a937e1e04709971b5e2e9382 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:02:52 +0800
Subject: [PATCH 107/224] _createcsr: omit CN from the CSR subject when it
exceeds 64 characters (#4867)
---
acme.sh | 29 +++++++++++++++++++++++------
1 file changed, 23 insertions(+), 6 deletions(-)
diff --git a/acme.sh b/acme.sh
index 4bc4b2ba..98f237a7 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1307,6 +1307,23 @@ _idn() {
}
#_createcsr cn san_list keyfile csrfile conf acmeValidationv1 extendedUsage
+#cn
+#The x509 Common Name is limited to 64 characters (RFC 5280 ub-common-name,
+#enforced by openssl in ASN1_mbstring_ncopy), and an IP address or an empty
+#name is not usable as CN either. When this rejects the name, _createcsr
+#omits CN from the CSR subject and the CA takes the identifiers from the
+#subjectAltName extension (issue 4867).
+_is_valid_cn() {
+ _cn_v="$1"
+ if [ -z "$_cn_v" ] || [ "${#_cn_v}" -gt 64 ]; then
+ return 1
+ fi
+ if _isIP "$_cn_v"; then
+ return 1
+ fi
+ return 0
+}
+
_createcsr() {
_debug _createcsr
domain="$1"
@@ -1370,16 +1387,16 @@ _createcsr() {
_csr_cn="$(_idn "$domain")"
_debug2 _csr_cn "$_csr_cn"
if _contains "$(uname -a)" "MINGW"; then
- if _isIP "$_csr_cn"; then
- ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
- else
+ if _is_valid_cn "$_csr_cn"; then
${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//CN=$_csr_cn" -config "$csrconf" -out "$csr"
+ else
+ ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "//O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
fi
else
- if _isIP "$_csr_cn"; then
- ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
- else
+ if _is_valid_cn "$_csr_cn"; then
${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/CN=$_csr_cn" -config "$csrconf" -out "$csr"
+ else
+ ${ACME_OPENSSL_BIN:-openssl} req -new -sha256 -key "$csrkey" -subj "/O=$PROJECT_NAME" -config "$csrconf" -out "$csr"
fi
fi
}
From 24895a15c864adfb461bfe6026a2b934da78060f Mon Sep 17 00:00:00 2001
From: laineus
Date: Sun, 5 Jul 2026 17:05:33 +0900
Subject: [PATCH 108/224] Add dns_muumuu: muumuu-domain.com DNS API (#7012)
* Add dns_muumuu: muumuu-domain.com DNS API
* Fix: remove local keyword for POSIX sh compatibility
* Fix: lowercase fulldomain for API compatibility
* Style: use echo instead of printf for lower_case (consistent with other plugins)
* Fix: prefix rest vars, clear _H4/_H5, guard record_id, update Issues URL
---
dnsapi/dns_muumuu.sh | 167 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 167 insertions(+)
create mode 100755 dnsapi/dns_muumuu.sh
diff --git a/dnsapi/dns_muumuu.sh b/dnsapi/dns_muumuu.sh
new file mode 100755
index 00000000..8ef0b8c8
--- /dev/null
+++ b/dnsapi/dns_muumuu.sh
@@ -0,0 +1,167 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_muumuu_info='muumuu-domain.com
+Site: muumuu-domain.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_muumuu
+Options:
+ MUUMUU_PAT Personal Access Token (scopes: domains:read, dns:read, dns:write)
+Issues: github.com/acmesh-official/acme.sh/issues/7011
+'
+
+MUUMUU_API="https://muumuu-domain.com/api/v2"
+
+######## Public functions #####################
+
+dns_muumuu_add() {
+ fulldomain="$(echo "$1" | _lower_case)"
+ txtvalue="$2"
+
+ _info "Using muumuu-domain.com DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}"
+ if [ -z "$MUUMUU_PAT" ]; then
+ _err "MUUMUU_PAT is not set."
+ _err "Please create a Personal Access Token at https://muumuu-domain.com"
+ _err "with scopes: domains:read, dns:read, dns:write"
+ return 1
+ fi
+ _saveaccountconf_mutable MUUMUU_PAT "$MUUMUU_PAT"
+
+ if ! _muumuu_get_root "$fulldomain"; then
+ _err "Unable to find the root domain for $fulldomain"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _info "Adding TXT record for ${fulldomain}"
+ body="{\"fqdn\":\"${fulldomain}.\",\"type\":\"TXT\",\"value\":\"${txtvalue}\",\"ttl\":3600}"
+ if _muumuu_rest POST "/me/domains/${_domain_id}/dns-records" "$body"; then
+ if [ "$_muumuu_code" = "201" ]; then
+ _info "TXT record added successfully"
+ return 0
+ fi
+ fi
+
+ _err "Failed to add TXT record (HTTP ${_muumuu_code})"
+ return 1
+}
+
+dns_muumuu_rm() {
+ fulldomain="$(echo "$1" | _lower_case)"
+ txtvalue="$2"
+
+ _info "Using muumuu-domain.com DNS API"
+ _debug fulldomain "$fulldomain"
+ _debug txtvalue "$txtvalue"
+
+ MUUMUU_PAT="${MUUMUU_PAT:-$(_readaccountconf_mutable MUUMUU_PAT)}"
+ if [ -z "$MUUMUU_PAT" ]; then
+ _err "MUUMUU_PAT is not set."
+ return 1
+ fi
+
+ if ! _muumuu_get_root "$fulldomain"; then
+ _err "Unable to find the root domain for $fulldomain"
+ return 1
+ fi
+ _debug _domain_id "$_domain_id"
+
+ _info "Looking up TXT record for ${fulldomain}"
+ if ! _muumuu_rest GET "/me/domains/${_domain_id}/dns-records?type=TXT&fqdn=${fulldomain}."; then
+ _err "Failed to list TXT records"
+ return 1
+ fi
+
+ record_id=$(echo "$response" | _egrep_o "\"id\":[0-9]+[^}]*\"value\":\"${txtvalue}\"" | _egrep_o "\"id\":[0-9]+" | _head_n 1 | cut -d: -f2)
+ if [ -z "$record_id" ]; then
+ _info "TXT record not found, nothing to remove"
+ return 0
+ fi
+ _debug record_id "$record_id"
+
+ if _muumuu_rest DELETE "/me/domains/${_domain_id}/dns-records/${record_id}"; then
+ if [ "$_muumuu_code" = "204" ]; then
+ _info "TXT record deleted successfully"
+ return 0
+ fi
+ fi
+
+ _err "Failed to delete TXT record (HTTP ${_muumuu_code})"
+ return 1
+}
+
+#################### Private functions below ##################################
+
+# _acme-challenge.www.example.com
+# sets:
+# _domain_id MU00000001
+# _sub_domain _acme-challenge.www
+# _domain example.com
+_muumuu_get_root() {
+ domain="$1"
+ i=1
+ p=0
+ h=""
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ if [ -z "$h" ]; then
+ return 1
+ fi
+ if ! _muumuu_rest GET "/me/domains?fqdn=${h}&page-size=1"; then
+ return 1
+ fi
+ if [ "$_muumuu_code" = "401" ] || [ "$_muumuu_code" = "403" ]; then
+ _err "Authentication failed (HTTP ${_muumuu_code}). Check MUUMUU_PAT."
+ return 1
+ fi
+ if _contains "$response" "\"fqdn\":\"${h}\""; then
+ _domain_id=$(echo "$response" | _egrep_o "\"id\":\"MU[0-9]+\"" | _head_n 1 | cut -d: -f2 | tr -d '"')
+ _domain="$h"
+ if [ "$p" = "0" ]; then
+ _sub_domain=""
+ else
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ fi
+ return 0
+ fi
+ p="$i"
+ i=$(_math "$i" + 1)
+ done
+}
+
+_muumuu_rest() {
+ _muumuu_method="$1"
+ _muumuu_path="$2"
+ _muumuu_data="$3"
+ _muumuu_url="${MUUMUU_API}${_muumuu_path}"
+
+ export _H1="Authorization: Bearer ${MUUMUU_PAT}"
+ export _H2="Content-Type: application/json"
+ export _H3="Accept: application/json"
+ export _H4=""
+ export _H5=""
+
+ _secure_debug2 data "$_muumuu_data"
+
+ if [ "$_muumuu_method" = "GET" ]; then
+ response="$(_get "$_muumuu_url")"
+ else
+ response="$(_post "$_muumuu_data" "$_muumuu_url" "" "$_muumuu_method")"
+ fi
+ _muumuu_ret="$?"
+ _muumuu_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")"
+ _debug "HTTP code: ${_muumuu_code}"
+ _secure_debug2 response "$response"
+
+ if [ "$_muumuu_ret" != "0" ]; then
+ _err "Error accessing ${_muumuu_url}"
+ return 1
+ fi
+
+ response="$(printf "%s" "$response" | _normalizeJson)"
+ return 0
+}
From cacafc9c23947b5a5d4c2b2ddad56eca9e56d1da Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:18:27 +0800
Subject: [PATCH 109/224] add Nginx workflow to test the --nginx mode
Runs le_test_nginx from acmetest against Pebble: nginx listens on
Pebble's HTTP-01 validation port with an aaPanel/BT style
"location ^~ /" reverse proxy block, the regression case of #6125.
---
.github/workflows/Nginx.yml | 66 +++++++++++++++++++++++++++++++++++++
acme.sh | 11 +++++--
2 files changed, 74 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/Nginx.yml
diff --git a/.github/workflows/Nginx.yml b/.github/workflows/Nginx.yml
new file mode 100644
index 00000000..2ca9d64a
--- /dev/null
+++ b/.github/workflows/Nginx.yml
@@ -0,0 +1,66 @@
+name: Nginx
+on:
+ push:
+ paths:
+ - '*.sh'
+ - '.github/workflows/Nginx.yml'
+ pull_request:
+ branches:
+ - dev
+ paths:
+ - '*.sh'
+ - '.github/workflows/Nginx.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ Nginx:
+ runs-on: ubuntu-latest
+ env:
+ TestingDomain: example.com
+ TEST_ACME_Server: https://localhost:14000/dir
+ HTTPS_INSECURE: 1
+ TEST_LOCAL: 1
+ TEST_CA: "Pebble Intermediate CA"
+ TEST_NGINX: 1
+ CASE: le_test_nginx
+ steps:
+ - uses: actions/checkout@v6
+ - name: Install tools
+ run: sudo apt-get install -y socat nginx
+ - name: Run Pebble
+ run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d
+ - name: Set up Pebble
+ run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4
+ - name: Set up nginx
+ # a backend on 8081 plus a site with an aaPanel/BT style
+ # "location ^~ /" proxy block that shadows plain regex locations
+ # (regression for #6125); the site listens on 5002, which is the
+ # HTTP-01 validation port in Pebble's default config
+ run: |
+ sudo tee /etc/nginx/sites-available/default >/dev/null <<'EOF'
+ server {
+ listen 127.0.0.1:8081;
+ location / {
+ default_type text/plain;
+ return 200 "backend";
+ }
+ }
+ server {
+ listen 5002 default_server;
+ server_name example.com;
+ location ^~ / {
+ proxy_pass http://127.0.0.1:8081;
+ proxy_set_header Host $http_host;
+ }
+ }
+ EOF
+ sudo nginx -t
+ sudo systemctl restart nginx
+ curl -s -H "Host: example.com" http://127.0.0.1:5002/ | grep backend
+ - name: Clone acmetest
+ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
+ - name: Run acmetest
+ run: cd ../acmetest && sudo --preserve-env ./letest.sh
diff --git a/acme.sh b/acme.sh
index 98f237a7..1b9c0b89 100755
--- a/acme.sh
+++ b/acme.sh
@@ -3448,9 +3448,14 @@ _setNginx() {
fi
echo "$NGINX_START
-location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" {
- default_type text/plain;
- return 200 \"\$1.$_thumbpt\";
+location ^~ /.well-known/acme-challenge/ {
+ # the ^~ prefix wins over regex-skipping blocks like \"location ^~ /\",
+ # the nested regex location still captures the token as \$1
+ location ~ \"^/\.well-known/acme-challenge/([-_a-zA-Z0-9]+)\$\" {
+ default_type text/plain;
+ return 200 \"\$1.$_thumbpt\";
+ }
+ return 404;
}
#NGINX_START
" >>"$FOUND_REAL_NGINX_CONF"
From 504540e67ccf564dcdfd8cd369a26ff12127748e Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:29:58 +0800
Subject: [PATCH 110/224] dnsapi/dns_autodns: escape XML special characters in
credentials (#5317)
---
dnsapi/dns_autodns.sh | 11 ++++++++++-
1 file changed, 10 insertions(+), 1 deletion(-)
diff --git a/dnsapi/dns_autodns.sh b/dnsapi/dns_autodns.sh
index ce566978..e26d699b 100644
--- a/dnsapi/dns_autodns.sh
+++ b/dnsapi/dns_autodns.sh
@@ -139,12 +139,21 @@ _get_autodns_zone() {
return 1
}
+# Escape the XML special characters (& < > ' ") so that credentials
+# containing them do not break the request document (issue 5317).
+_autodns_xml_encode() {
+ sed "s/&/\&/g;s/\</g;s/>/\>/g;s/'/\'/g;s/\"/\"/g"
+}
+
_build_request_auth_xml() {
+ _autodns_user_xml="$(printf "%s" "$AUTODNS_USER" | _autodns_xml_encode)"
+ _autodns_password_xml="$(printf "%s" "$AUTODNS_PASSWORD" | _autodns_xml_encode)"
+ _autodns_context_xml="$(printf "%s" "$AUTODNS_CONTEXT" | _autodns_xml_encode)"
printf "
%s
%s
%s
- " "$AUTODNS_USER" "$AUTODNS_PASSWORD" "$AUTODNS_CONTEXT"
+ " "$_autodns_user_xml" "$_autodns_password_xml" "$_autodns_context_xml"
}
# Arguments:
From 507baff2ef8b4391c32b68d4b818f8fe439214dc Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:30:46 +0800
Subject: [PATCH 111/224] deploy/docker: allow setting key file mode and owner
in the container
The docker deploy hook copied the key file preserving the source mode
(root:root 0600), so a non-root container service (uid >= 1000) could not
read it. Add DEPLOY_DOCKER_CONTAINER_KEY_MODE and
DEPLOY_DOCKER_CONTAINER_KEY_OWNER, applied via chmod/chown inside the
container after the key is copied and before the reload command.
Closes #5333
---
deploy/docker.sh | 28 ++++++++++++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/deploy/docker.sh b/deploy/docker.sh
index 7fdcf604..276172aa 100755
--- a/deploy/docker.sh
+++ b/deploy/docker.sh
@@ -3,6 +3,8 @@
#DEPLOY_DOCKER_CONTAINER_LABEL="xxxxxxx"
#DEPLOY_DOCKER_CONTAINER_KEY_FILE="/path/to/key.pem"
+#DEPLOY_DOCKER_CONTAINER_KEY_MODE="0640"
+#DEPLOY_DOCKER_CONTAINER_KEY_OWNER="1000:1000"
#DEPLOY_DOCKER_CONTAINER_CERT_FILE="/path/to/cert.pem"
#DEPLOY_DOCKER_CONTAINER_CA_FILE="/path/to/ca.pem"
#DEPLOY_DOCKER_CONTAINER_FULLCHAIN_FILE="/path/to/fullchain.pem"
@@ -71,6 +73,18 @@ docker_deploy() {
_savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_FILE "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"
fi
+ _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE
+ _debug2 DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE"
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then
+ _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_MODE "$DEPLOY_DOCKER_CONTAINER_KEY_MODE"
+ fi
+
+ _getdeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER
+ _debug2 DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER"
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then
+ _savedeployconf DEPLOY_DOCKER_CONTAINER_KEY_OWNER "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER"
+ fi
+
_getdeployconf DEPLOY_DOCKER_CONTAINER_CERT_FILE
_debug2 DEPLOY_DOCKER_CONTAINER_CERT_FILE "$DEPLOY_DOCKER_CONTAINER_CERT_FILE"
if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then
@@ -112,6 +126,20 @@ docker_deploy() {
if ! _docker_cp "$_cid" "$_ckey" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then
return 1
fi
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" ]; then
+ _info "Setting key file owner to $DEPLOY_DOCKER_CONTAINER_KEY_OWNER"
+ if ! _docker_exec "$_cid" chown "$DEPLOY_DOCKER_CONTAINER_KEY_OWNER" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then
+ _err "Can not change owner of key file in container"
+ return 1
+ fi
+ fi
+ if [ "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" ]; then
+ _info "Setting key file mode to $DEPLOY_DOCKER_CONTAINER_KEY_MODE"
+ if ! _docker_exec "$_cid" chmod "$DEPLOY_DOCKER_CONTAINER_KEY_MODE" "$DEPLOY_DOCKER_CONTAINER_KEY_FILE"; then
+ _err "Can not change mode of key file in container"
+ return 1
+ fi
+ fi
fi
if [ "$DEPLOY_DOCKER_CONTAINER_CERT_FILE" ]; then
From 1f94fd7fd596f0bee2e11fbfd033c43a0eeb3d78 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:31:51 +0800
Subject: [PATCH 112/224] add Apache workflow to test the --apache mode
Runs le_test_apache from acmetest against Pebble, with Apache
listening on Pebble's HTTP-01 validation port.
---
.github/workflows/Apache.yml | 50 ++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
create mode 100644 .github/workflows/Apache.yml
diff --git a/.github/workflows/Apache.yml b/.github/workflows/Apache.yml
new file mode 100644
index 00000000..b17abbd1
--- /dev/null
+++ b/.github/workflows/Apache.yml
@@ -0,0 +1,50 @@
+name: Apache
+on:
+ push:
+ paths:
+ - '*.sh'
+ - '.github/workflows/Apache.yml'
+ pull_request:
+ branches:
+ - dev
+ paths:
+ - '*.sh'
+ - '.github/workflows/Apache.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ Apache:
+ runs-on: ubuntu-latest
+ env:
+ TestingDomain: example.com
+ TEST_ACME_Server: https://localhost:14000/dir
+ HTTPS_INSECURE: 1
+ TEST_LOCAL: 1
+ TEST_CA: "Pebble Intermediate CA"
+ TEST_APACHE: 1
+ CASE: le_test_apache
+ steps:
+ - uses: actions/checkout@v6
+ - name: Install tools
+ run: sudo apt-get install -y socat apache2
+ - name: Run Pebble
+ run: cd .. && curl https://raw.githubusercontent.com/letsencrypt/pebble/master/docker-compose.yml >docker-compose.yml && docker compose up -d
+ - name: Set up Pebble
+ run: curl --request POST --data '{"ip":"10.30.50.1"}' http://localhost:8055/set-default-ipv4
+ - name: Set up Apache
+ # Apache serves on 5002, which is the HTTP-01 validation port in
+ # Pebble's default config; acme.sh appends the challenge Alias to
+ # the main config itself
+ run: |
+ echo "Listen 5002" | sudo tee /etc/apache2/ports.conf
+ sudo sed -i "s/\*:80/*:5002/" /etc/apache2/sites-available/000-default.conf
+ sudo apache2ctl configtest
+ sudo systemctl restart apache2
+ curl -s -o /dev/null -w "%{http_code}" -H "Host: example.com" http://127.0.0.1:5002/ | grep -E "200|403|404"
+ - name: Clone acmetest
+ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
+ - name: Run acmetest
+ run: cd ../acmetest && sudo --preserve-env ./letest.sh
From 4256e3532b37b5ff27ad5a534e50aef01003c82a Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:32:57 +0800
Subject: [PATCH 113/224] _regAccount: error out clearly when the eab-hmac-key
cannot be base64-decoded
An undecodable key (e.g. broken LibreSSL base64 -d -A) used to produce
the cryptic "Usage: _hmac hashalg secret [outputhex]" and an empty EAB
signature that the CA rejects with 403.
https://github.com/acmesh-official/acme.sh/issues/4082
---
acme.sh | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/acme.sh b/acme.sh
index 1b9c0b89..42d94f79 100755
--- a/acme.sh
+++ b/acme.sh
@@ -4014,6 +4014,10 @@ _regAccount() {
key_hex="$(_durl_replace_base64 "$_eab_hmac_key" | _dbase64 | _hex_dump | tr -d ' ')"
_debug3 key_hex "$key_hex"
+ if [ -z "$key_hex" ]; then
+ _err "Cannot base64-decode the eab-hmac-key. Please check the value, and your openssl version."
+ return 1
+ fi
eab_signature=$(printf "%s" "$eab_sign_t" | _hmac sha256 $key_hex | _base64 | _url_replace)
_debug3 eab_signature "$eab_signature"
From defd64022d8864153d8a6caeb18c76066be3dca3 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:53:14 +0800
Subject: [PATCH 114/224] dnsapi/dns_namecom: probe the root zone with
GetDomain instead of listing all domains
The domain list is paginated at 1000 entries per page and only the
first page was fetched, so accounts with more than 1000 domains never
found the root zone.
fix https://github.com/acmesh-official/acme.sh/issues/5051
---
dnsapi/dns_namecom.sh | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh
index 1062c849..1ba6a6e5 100755
--- a/dnsapi/dns_namecom.sh
+++ b/dnsapi/dns_namecom.sh
@@ -153,10 +153,9 @@ _namecom_get_root() {
i=2
p=1
- if ! _namecom_rest GET "domains"; then
- return 1
- fi
-
+ # Probe each candidate with GetDomain (GET /v4/domains/{domainName}) instead
+ # of listing all domains: the list is paginated at 1000 domains per page, so
+ # larger accounts never found their domain on the first page.
# Need to exclude the last field (tld)
numfields=$(echo "$domain" | _egrep_o "\." | wc -l)
while [ "$i" -le "$numfields" ]; do
@@ -166,7 +165,7 @@ _namecom_get_root() {
return 1
fi
- if _contains "$response" "$host"; then
+ if _namecom_rest GET "domains/$host" && _contains "$response" "\"domainName\":\"$host\""; then
_sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
_domain="$host"
return 0
From 7def43481a41f35c30e45b5407878145e8ffa53c Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 16:57:59 +0800
Subject: [PATCH 115/224] dns_regru: require a dot boundary in root zone
matching
_get_root matched a registered domain anywhere as a substring of the
challenge domain, so with both "test.com.ru" and "subtest.com.ru" in the
account, issuing for subtest.com.ru wrongly picked test.com.ru as the
root (it is a substring of "sub-test.com.ru"). Anchor the match to a '.'
boundary so a shorter domain no longer matches a longer subdomain label.
Fixes the issue reported in #5036 (thanks @koledas)
Closes #5036
---
dnsapi/dns_regru.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/dnsapi/dns_regru.sh b/dnsapi/dns_regru.sh
index be5ae117..edf8b464 100644
--- a/dnsapi/dns_regru.sh
+++ b/dnsapi/dns_regru.sh
@@ -96,8 +96,8 @@ _get_root() {
for ITEM in ${domains_list}; do
IDN_ITEM=${ITEM}
- case "${domain}" in
- *${IDN_ITEM}*)
+ case ".${domain}" in
+ *.${IDN_ITEM}*)
_domain="$(_idn "${ITEM}")"
_debug _domain "${_domain}"
return 0
From 934711e51d10c765f67900892e2759ae3dd37dae Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 17:09:51 +0800
Subject: [PATCH 116/224] notify/aws_ses: add container/instance IAM role auth
(IMDSv2)
aws_ses_send calls `_use_container_role || _use_instance_role` when no
static AWS keys are set, but those functions were never defined -- only
_use_metadata was -- so role-based auth silently fell through to the
"no api key" error. Add both, using the current IMDSv2-capable versions
from dns_aws.sh, and set the IMDSv2 token header in _use_metadata so the
credential fetch works on IMDSv2-only instances.
Closes #4742
---
notify/aws_ses.sh | 36 ++++++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/notify/aws_ses.sh b/notify/aws_ses.sh
index 07e0c48c..735e6204 100644
--- a/notify/aws_ses.sh
+++ b/notify/aws_ses.sh
@@ -83,7 +83,43 @@ aws_ses_send() {
response="$(aws_rest POST "" "" "$_data")"
}
+_use_container_role() {
+ # automatically set if running inside ECS
+ if [ -z "$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI" ]; then
+ _debug "No ECS environment variable detected"
+ return 1
+ fi
+ _use_metadata "169.254.170.2$AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"
+}
+
+_use_instance_role() {
+ _instance_role_name_url="http://169.254.169.254/latest/meta-data/iam/security-credentials/"
+
+ if _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 401; then
+ _debug "Using IMDSv2"
+ _token_url="http://169.254.169.254/latest/api/token"
+ export _H1="X-aws-ec2-metadata-token-ttl-seconds: 21600"
+ _token="$(_post "" "$_token_url" "" "PUT")"
+ _secure_debug3 "_token" "$_token"
+ if [ -z "$_token" ]; then
+ _debug "Unable to fetch IMDSv2 token from instance metadata"
+ return 1
+ fi
+ export _H1="X-aws-ec2-metadata-token: $_token"
+ fi
+
+ if ! _get "$_instance_role_name_url" true 1 | _head_n 1 | grep -Fq 200; then
+ _debug "Unable to fetch IAM role from instance metadata"
+ return 1
+ fi
+
+ _instance_role_name=$(_get "$_instance_role_name_url" "" 1)
+ _debug "_instance_role_name" "$_instance_role_name"
+ _use_metadata "$_instance_role_name_url$_instance_role_name" "$_token"
+}
+
_use_metadata() {
+ export _H1="X-aws-ec2-metadata-token: $2"
_aws_creds="$(
_get "$1" "" 1 |
_normalizeJson |
From e964157bffedb865b532e24fcf8abe6b1e0fe1ab Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 17:13:24 +0800
Subject: [PATCH 117/224] _install_win_taskscheduler: zero-pad the minute in
the schtasks /ST value (#4950)
---
acme.sh | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/acme.sh b/acme.sh
index 42d94f79..d3e364bb 100755
--- a/acme.sh
+++ b/acme.sh
@@ -6658,8 +6658,10 @@ _install_win_taskscheduler() {
_info "$PROJECT_NAME will not save your password."
_info "Please input your Windows password for: $(__green "$_myname")"
_password="$(__read_password)"
- #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null
- echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_randomminute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null
+ #schtasks.exe /ST requires the HH:mm format, so the minute must be zero-padded (issue 4950)
+ _st_minute="$(printf "%02d" "$_randomminute")"
+ #SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'" >/dev/null
+ echo SCHTASKS.exe '/create' '/SC' 'DAILY' '/TN' "$_WINDOWS_SCHEDULER_NAME" '/F' '/ST' "00:$_st_minute" '/RU' "$_myname" '/RP' "$_password" '/TR' "\"$_winbash -l -c '$_lesh --cron --home \"$LE_WORKING_DIR\" $_centry'\"" | cmd.exe >/dev/null
echo
}
From d2b3772631f7055f27a267ae94b83e0f4c99962f Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 17:13:59 +0800
Subject: [PATCH 118/224] deploy/panos: do not commit when the cert or key
import failed (#4716)
Committing after a failed import leaves a mismatched cert/key pair on
the firewall (PAN-OS does not validate the pair at commit time), which
can lock the admin out of the https management interface.
---
deploy/panos.sh | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/deploy/panos.sh b/deploy/panos.sh
index 00badffc..fcfd6fb5 100644
--- a/deploy/panos.sh
+++ b/deploy/panos.sh
@@ -296,9 +296,20 @@ panos_deploy() {
_err "Unable to generate an API key. The user and pass may be invalid or not authorized to generate a new key. Please check the PANOS_USER and PANOS_PASS credentials and try again"
return 1
else
- deployer cert
- deployer key
- deployer commit
+ # A commit of a failed import would leave a mismatched cert/key pair
+ # on the firewall and can lock the admin out of the management
+ # interface, see https://github.com/acmesh-official/acme.sh/issues/4716
+ if ! deployer cert; then
+ _err "Cert import failed. Aborting without committing."
+ return 1
+ fi
+ if ! deployer key; then
+ _err "Key import failed. Aborting without committing. Warning: the firewall now has an uncommitted mismatched cert/key pair in its candidate config."
+ return 1
+ fi
+ if ! deployer commit; then
+ return 1
+ fi
if [ "$_panos_template_stack" ]; then
# try to get job status for 20 times in 30 sec interval
i=0
From 1746fbdb255204bbb417e1ca3298699863630eb0 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 17:37:40 +0800
Subject: [PATCH 119/224] support multiple account emails (#1309)
ACCOUNT_EMAIL / --email now accepts a comma- or space-separated list
and registers all of them as ACME contact entries. The ZeroSSL EAB
endpoint takes a single address, so the first one is used there.
---
acme.sh | 18 +++++++++++++++---
1 file changed, 15 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index d3e364bb..931ab750 100755
--- a/acme.sh
+++ b/acme.sh
@@ -3917,6 +3917,16 @@ __calc_account_thumbprint() {
printf "%s" "$jwk" | tr -d ' ' | _digest "sha256" | _url_replace
}
+#Reads a comma- or space-separated email list from stdin and prints
+#the ACME contact list items: "mailto:a@example.com","mailto:b@example.com"
+_mailto_contacts() {
+ _mc_out=""
+ for _mc_m in $(tr ',' ' '); do
+ _mc_out="$_mc_out,\"mailto:$_mc_m\""
+ done
+ echo "$_mc_out" | cut -c 2-
+}
+
_getAccountEmail() {
if [ "$ACCOUNT_EMAIL" ]; then
echo "$ACCOUNT_EMAIL"
@@ -3976,7 +3986,9 @@ _regAccount() {
_info "See: $(__green "$_ZEROSSL_WIKI")"
return 1
fi
- _eabresp=$(_post "email=$_email" $_ZERO_EAB_ENDPOINT)
+ #the ZeroSSL EAB endpoint takes a single address, use the first one
+ _eab_email="$(echo "$_email" | tr ',' ' ' | awk '{print $1}')"
+ _eabresp=$(_post "email=$_eab_email" $_ZERO_EAB_ENDPOINT)
if [ "$?" != "0" ]; then
_debug2 "$_eabresp"
_err "Cannot get EAB credentials from ZeroSSL."
@@ -4026,7 +4038,7 @@ _regAccount() {
_debug3 externalBinding "$externalBinding"
fi
if [ "$_email" ]; then
- email_sg="\"contact\": [\"mailto:$_email\"], "
+ email_sg="\"contact\": [$(echo "$_email" | _mailto_contacts)], "
fi
regjson="{$email_sg\"termsOfServiceAgreed\": true$externalBinding}"
@@ -4106,7 +4118,7 @@ updateaccount() {
_email="$(_getAccountEmail)"
if [ "$_email" ]; then
- updjson='{"contact": ["mailto:'$_email'"]}'
+ updjson='{"contact": ['$(echo "$_email" | _mailto_contacts)']}'
else
updjson='{"contact": []}'
fi
From cabe432539f0acbdac954c48175d20d259ab9aaf Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 17:39:50 +0800
Subject: [PATCH 120/224] add bash completion for commands and parameters,
installed via --install (#307)
---
acme.sh | 11 ++
acme.sh.completion | 341 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 352 insertions(+)
create mode 100644 acme.sh.completion
diff --git a/acme.sh b/acme.sh
index 931ab750..4735b830 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7236,6 +7236,10 @@ _installalias() {
_sed_i "/^export LE_CONFIG_HOME/d" "$_envfile"
fi
_setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\""
+ if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then
+ #the completion file does nothing when sourced by a non-bash shell
+ _setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\""
+ fi
_profile="$(_detect_profile)"
if [ "$_profile" ]; then
@@ -7353,6 +7357,11 @@ install() {
_info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY"
+ if [ -f "$PROJECT_ENTRY.completion" ]; then
+ cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/"
+ _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion"
+ fi
+
if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then
_installalias "$_c_home"
fi
@@ -7430,6 +7439,7 @@ uninstall() {
_uninstallalias
rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY"
+ rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion"
_info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself."
}
@@ -7739,6 +7749,7 @@ Parameters:
--config-home Specifies the home dir to save all the configurations.
--useragent Specifies the user agent string. it will be saved for future use too.
-m, --email Specifies the account email, only valid for the '--install' and '--update-account' command.
+ Multiple emails can be given as a comma-separated list: 'a@example.com,b@example.com'
--accountkey Specifies the account key path, only valid for the '--install' command.
--days Specifies the days to renew the cert when using '--issue' command. The default value is $DEFAULT_RENEW days.
Negative values could be used to specify a number of days relative to the expiration date of the certificate.
diff --git a/acme.sh.completion b/acme.sh.completion
new file mode 100644
index 00000000..26cb88da
--- /dev/null
+++ b/acme.sh.completion
@@ -0,0 +1,341 @@
+# Bash completion for acme.sh: https://github.com/acmesh-official/acme.sh
+#
+# "acme.sh --install" copies this file to the acme.sh home dir and wires
+# it into acme.sh.env, so the completion is loaded automatically in new
+# bash sessions after installation.
+#
+# To use it without installing acme.sh, source it from ~/.bashrc, or copy
+# it to /usr/share/bash-completion/completions/acme.sh
+#
+# Zsh users can load it with:
+# autoload -U +X bashcompinit && bashcompinit
+# . /path/to/acme.sh.completion
+
+# This file may also be sourced by non-bash shells via acme.sh.env,
+# so silently do nothing if the "complete" builtin is not available.
+if ! command -v complete >/dev/null 2>&1; then
+ return 0 2>/dev/null || exit 0
+fi
+
+# Add each word of $1 that starts with $cur to COMPREPLY.
+# The words are read line by line, so that candidates like a wildcard
+# domain "*.example.com" are never glob-expanded against the cwd.
+_acme_sh_add_matches() {
+ local _word
+ while read -r _word; do
+ [ -n "$_word" ] || continue
+ case "$_word" in
+ "$cur"*) COMPREPLY=("${COMPREPLY[@]}" "$_word") ;;
+ esac
+ done </dev/null 2>&1; then
+ compopt -o filenames 2>/dev/null
+ fi
+ return 0
+}
+
+_acme_sh_dirs() {
+ local _dir
+ while IFS= read -r _dir; do
+ [ -n "$_dir" ] || continue
+ COMPREPLY=("${COMPREPLY[@]}" "$_dir")
+ done </dev/null 2>&1; then
+ compopt -o filenames 2>/dev/null
+ fi
+ return 0
+}
+
+# Complete the domains that already have a cert: every directory in the
+# config home that contains a ".conf" file ("_ecc" suffix stripped).
+_acme_sh_domains() {
+ local _dir _name _domains=""
+ [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null
+ for _dir in "$_acme_conf_home"/*/; do
+ [ -d "$_dir" ] || continue
+ _name="${_dir%/}"
+ _name="${_name##*/}"
+ _name="${_name%_ecc}"
+ if [ -f "${_dir}${_name}.conf" ]; then
+ case " $_domains " in
+ *" $_name "*) ;;
+ *) _domains="$_domains $_name" ;;
+ esac
+ fi
+ done
+ _acme_sh_add_matches "$_domains"
+}
+
+# Complete hook names from a subfolder of the acme.sh home dir.
+# $1: subfolder (dnsapi/deploy/notify), $2: file name prefix or empty.
+_acme_sh_hooks() {
+ local _file _hooks=""
+ [ -n "${ZSH_VERSION:-}" ] && setopt localoptions nonomatch 2>/dev/null
+ for _file in "$_acme_home/$1/$2"*.sh; do
+ [ -f "$_file" ] || continue
+ _file="${_file##*/}"
+ _hooks="$_hooks ${_file%.sh}"
+ done
+ _acme_sh_add_matches "$_hooks"
+}
+
+_acme_sh_completion() {
+ local cur prev _acme_home _acme_conf_home
+ COMPREPLY=()
+ cur="${COMP_WORDS[COMP_CWORD]}"
+ prev=""
+ if [ "$COMP_CWORD" -gt 0 ]; then
+ prev="${COMP_WORDS[COMP_CWORD - 1]}"
+ fi
+ _acme_home="${LE_WORKING_DIR:-$HOME/.acme.sh}"
+ _acme_conf_home="${LE_CONFIG_HOME:-$_acme_home}"
+
+ # The first argument is the command.
+ if [ "$COMP_CWORD" -eq 1 ]; then
+ _acme_sh_add_matches "
+ --help
+ --version
+ --install
+ --install-online
+ --uninstall
+ --upgrade
+ --issue
+ --deploy
+ --sign-csr
+ --show-csr
+ --install-cert
+ --renew
+ --renew-all
+ --revoke
+ --remove
+ --list
+ --list-profiles
+ --info
+ --to-pkcs12
+ --to-pkcs8
+ --create-account-key
+ --create-domain-key
+ --create-csr
+ --deactivate
+ --update-account
+ --register-account
+ --deactivate-account
+ --make-dns-persist-value
+ --install-cronjob
+ --uninstall-cronjob
+ --cron
+ --set-notify
+ --set-default-ca
+ --set-default-chain
+ "
+ return 0
+ fi
+
+ # Complete the value of the previous option.
+ case "$prev" in
+ -d | --domain | --challenge-alias | --domain-alias)
+ _acme_sh_domains
+ return 0
+ ;;
+ --dns)
+ # The dns hook argument is optional, keep completing options if the
+ # current word already looks like one.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_hooks "dnsapi" "dns_"
+ return 0
+ ;;
+ esac
+ ;;
+ --deploy-hook)
+ _acme_sh_hooks "deploy" ""
+ return 0
+ ;;
+ --notify-hook)
+ _acme_sh_hooks "notify" ""
+ return 0
+ ;;
+ --server)
+ _acme_sh_add_matches "letsencrypt letsencrypt_test zerossl sslcom google google_test actalis"
+ return 0
+ ;;
+ -k | --keylength | -ak | --accountkeylength)
+ _acme_sh_add_matches "2048 3072 4096 8192 ec-256 ec-384 ec-521"
+ return 0
+ ;;
+ --debug)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_add_matches "0 1 2 3"
+ return 0
+ ;;
+ esac
+ ;;
+ --log)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_files
+ return 0
+ ;;
+ esac
+ ;;
+ --nginx)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_files
+ return 0
+ ;;
+ esac
+ ;;
+ --auto-upgrade | --always-force-new-domain-key)
+ # Optional argument.
+ case "$cur" in
+ -*) ;;
+ *)
+ _acme_sh_add_matches "0 1"
+ return 0
+ ;;
+ esac
+ ;;
+ --log-level)
+ _acme_sh_add_matches "1 2"
+ return 0
+ ;;
+ --syslog)
+ _acme_sh_add_matches "0 3 6 7"
+ return 0
+ ;;
+ --notify-level)
+ _acme_sh_add_matches "0 1 2 3"
+ return 0
+ ;;
+ --notify-mode)
+ _acme_sh_add_matches "0 1"
+ return 0
+ ;;
+ --revoke-reason)
+ _acme_sh_add_matches "0 1 2 3 4 5 6 7 8 9 10"
+ return 0
+ ;;
+ --cert-file | --key-file | --ca-file | --fullchain-file | --csr | --accountconf | --accountkey | --ca-bundle | --openssl-bin)
+ _acme_sh_files
+ return 0
+ ;;
+ -w | --webroot | --home | --cert-home | --config-home | --ca-path)
+ _acme_sh_dirs
+ return 0
+ ;;
+ -m | --email | --password | --useragent | --days | --valid-from | --valid-to | --httpport | --tlsport | --local-address | --dnssleep | --pre-hook | --post-hook | --renew-hook | --reloadcmd | --extended-key-usage | -b | --branch | --notify-source | --eab-kid | --eab-hmac-key | --preferred-chain | --cert-profile | --certificate-profile | --dns-persist-ca-name | --dns-persist-days)
+ # These options take a free-form value, offer nothing.
+ return 0
+ ;;
+ esac
+
+ # Complete the parameters.
+ _acme_sh_add_matches "
+ --accountconf
+ --accountkey
+ --accountkeylength
+ --alpn
+ --always-force-new-domain-key
+ --apache
+ --auto-upgrade
+ --branch
+ --ca-bundle
+ --ca-file
+ --ca-path
+ --cert-file
+ --cert-home
+ --cert-profile
+ --challenge-alias
+ --config-home
+ --csr
+ --days
+ --debug
+ --deploy-hook
+ --dns
+ --dns-persist
+ --dns-persist-ca-name
+ --dns-persist-days
+ --dns-persist-wildcard
+ --dnssleep
+ --domain
+ --domain-alias
+ --eab-hmac-key
+ --eab-kid
+ --ecc
+ --email
+ --extended-key-usage
+ --force
+ --force-color
+ --fullchain-file
+ --home
+ --httpport
+ --insecure
+ --key-file
+ --keylength
+ --listen-v4
+ --listen-v6
+ --listraw
+ --local-address
+ --log
+ --log-level
+ --nginx
+ --no-color
+ --no-cron
+ --no-profile
+ --notify-hook
+ --notify-level
+ --notify-mode
+ --notify-source
+ --ocsp-must-staple
+ --openssl-bin
+ --output-insecure
+ --password
+ --post-hook
+ --pre-hook
+ --preferred-chain
+ --reloadcmd
+ --renew-hook
+ --revoke-reason
+ --server
+ --staging
+ --standalone
+ --stateless
+ --stop-renew-on-error
+ --syslog
+ --tlsport
+ --treat-skip-as-success
+ --use-wget
+ --useragent
+ --valid-from
+ --valid-to
+ --webroot
+ --yes-I-know-dns-manual-mode-enough-go-ahead-please
+ "
+ return 0
+}
+
+complete -F _acme_sh_completion acme.sh
From 7b6d96387c7eae778ab3e630950d14f8a434785e Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 17:48:33 +0800
Subject: [PATCH 121/224] migrate the legacy ACMEDNS_UPDATE_URL from the
account conf (#3899)
---
dnsapi/dns_acmedns.sh | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/dnsapi/dns_acmedns.sh b/dnsapi/dns_acmedns.sh
index b109a4e5..a21f8ef0 100755
--- a/dnsapi/dns_acmedns.sh
+++ b/dnsapi/dns_acmedns.sh
@@ -37,6 +37,16 @@ dns_acmedns_add() {
ACMEDNS_PASSWORD="${ACMEDNS_PASSWORD:-$(_readdomainconf ACMEDNS_PASSWORD)}"
ACMEDNS_SUBDOMAIN="${ACMEDNS_SUBDOMAIN:-$(_readdomainconf ACMEDNS_SUBDOMAIN)}"
+ #for compatibility: old versions stored ACMEDNS_UPDATE_URL in the account
+ #conf (issue 3899). Do not clear it here: it must stay available for the
+ #other domains that have not migrated to their domain conf yet.
+ if [ -z "$ACMEDNS_BASE_URL" ]; then
+ _acmedns_update_url="$(_readaccountconf_mutable ACMEDNS_UPDATE_URL)"
+ if [ "$_acmedns_update_url" ]; then
+ ACMEDNS_BASE_URL="$(echo "$_acmedns_update_url" | sed 's#/update$##')"
+ fi
+ fi
+
if [ "$ACMEDNS_BASE_URL" = "" ]; then
ACMEDNS_BASE_URL="https://auth.acme-dns.io"
fi
From d3af3315da2a8d9a0080bc62c348e728db7feefc Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 18:02:34 +0800
Subject: [PATCH 122/224] dnsapi/dns_edgedns: use the system clock for the
request timestamp (#3973)
---
dnsapi/dns_edgedns.sh | 17 ++++++-----------
1 file changed, 6 insertions(+), 11 deletions(-)
diff --git a/dnsapi/dns_edgedns.sh b/dnsapi/dns_edgedns.sh
index e88a1483..9ff1cc06 100755
--- a/dnsapi/dns_edgedns.sh
+++ b/dnsapi/dns_edgedns.sh
@@ -363,17 +363,12 @@ _edgedns_rest() {
_edgedns_eg_timestamp() {
_debug "Generating signature Timestamp"
- _debug3 "Retriving ntp time"
- _timeheaders="$(_get "https://www.ntp.org" "onlyheader")"
- _debug3 "_timeheaders" "$_timeheaders"
- _ntpdate="$(echo "$_timeheaders" | grep -i "Date:" | _head_n 1 | cut -d ':' -f 2- | tr -d "\r\n")"
- _debug3 "_ntpdate" "$_ntpdate"
- _ntpdate="$(echo "${_ntpdate}" | sed -e 's/^[[:space:]]*//')"
- _debug3 "_NTPDATE" "$_ntpdate"
- _ntptime="$(echo "${_ntpdate}" | _head_n 1 | cut -d " " -f 5 | tr -d "\r\n")"
- _debug3 "_ntptime" "$_ntptime"
- _eg_timestamp=$(date -u "+%Y%m%dT")
- _eg_timestamp="$(printf "%s%s+0000" "$_eg_timestamp" "$_ntptime")"
+ #Akamai accepts a clock skew of +/-30s, so use the system clock directly.
+ #The previous code fetched the Date header from www.ntp.org, which is not
+ #a reliable time source (it served a wrong time for hours, issue 3973),
+ #cost an extra https round-trip for every API request, and combined the
+ #remote time of day with the LOCAL date, breaking around UTC midnight.
+ _eg_timestamp="$(date -u "+%Y%m%dT%H:%M:%S+0000")"
_debug "_eg_timestamp" "$_eg_timestamp"
}
From 2a175f97e87890e47f87eb09198155cc5a94a668 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 5 Jul 2026 18:04:24 +0800
Subject: [PATCH 123/224] toPkcs8: support --password and re-export the pkcs8
file on renewal (#4134)
---
acme.sh | 35 +++++++++++++++++++++++++++++------
1 file changed, 29 insertions(+), 6 deletions(-)
diff --git a/acme.sh b/acme.sh
index 4735b830..f09436b9 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1547,6 +1547,22 @@ _toPkcs() {
}
+_toPkcs8() {
+ _cpkcs8="$1"
+ _ckey="$2"
+ pkcs8Password="$3"
+
+ if [ "$pkcs8Password" ]; then
+ ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -v2 aes256 -passout "pass:$pkcs8Password" -in "$_ckey" -out "$_cpkcs8"
+ else
+ ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$_ckey" -out "$_cpkcs8"
+ fi
+ if [ "$?" = "0" ]; then
+ _savedomainconf "Le_PKCS8Password" "$pkcs8Password" "base64"
+ fi
+
+}
+
#domain [password] [isEcc]
toPkcs() {
domain="$1"
@@ -1568,20 +1584,21 @@ toPkcs() {
}
-#domain [isEcc]
+#domain [password] [isEcc]
toPkcs8() {
domain="$1"
+ pkcs8Password="$2"
if [ -z "$domain" ]; then
- _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--ecc]"
+ _usage "Usage: $PROJECT_ENTRY --to-pkcs8 --domain [--password ] [--ecc]"
return 1
fi
- _isEcc="$2"
+ _isEcc="$3"
_initpath "$domain" "$_isEcc"
- ${ACME_OPENSSL_BIN:-openssl} pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in "$CERT_KEY_PATH" -out "$CERT_PKCS8_PATH"
+ _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$pkcs8Password"
if [ "$?" = "0" ]; then
_info "Success, $CERT_PKCS8_PATH"
@@ -5889,6 +5906,12 @@ $_authorizations_map"
_toPkcs "$CERT_PFX_PATH" "$CERT_KEY_PATH" "$CERT_PATH" "$CA_CERT_PATH" "$Le_PFXPassword"
fi
+ #convert to pkcs8
+ Le_PKCS8Password="$(_readdomainconf Le_PKCS8Password)"
+ if [ "$Le_PKCS8Password" ]; then
+ _toPkcs8 "$CERT_PKCS8_PATH" "$CERT_KEY_PATH" "$Le_PKCS8Password"
+ fi
+
if [ "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then
_savedomainconf "Le_RealCertPath" "$_real_cert"
_savedomainconf "Le_RealCACertPath" "$_real_ca"
@@ -7801,7 +7824,7 @@ Parameters:
--revoke-reason <0-10> The reason for revocation, can be used in conjunction with the '--revoke' command.
See: $_REVOKE_WIKI
- --password Add a password to exported pfx file. Use with --to-pkcs12.
+ --password Add a password to the exported pfx or pkcs8 file. Use with '--to-pkcs12' or '--to-pkcs8'.
"
@@ -8818,7 +8841,7 @@ _process() {
toPkcs "$_domain" "$_password" "$_ecc"
;;
toPkcs8)
- toPkcs8 "$_domain" "$_ecc"
+ toPkcs8 "$_domain" "$_password" "$_ecc"
;;
createAccountKey)
createAccountKey "$_accountkeylength"
From 8f3c1701f396887bf242e912dcbe3a50419af8c1 Mon Sep 17 00:00:00 2001
From: LaoDC
Date: Sun, 5 Jul 2026 22:30:26 +0700
Subject: [PATCH 124/224] Add LaoDC DNS API (dns_laodc) (#6974)
* Added LaoDC API Module
* Cleaned up debug and info
revised get subdomain to filter by TXT records.
* Added commet to _get_root
* Removed PATCH logic of updating acme records as this doesn't work for wildcard DNS.
Revised rm() function to do explicit record matching.
* Revised _get_root() to handle different scenarios.
Fixed _laodc_api() function to check if query failed to run.
added basic json sanitation to handle \ and " in $value
unset _H2 _H3 after call as per request from copilot.
* fixed indentation of case statement block
* fixed condition checking.
_get_root should start at 1 so full fqdn can be tested
$? was being reference after export command failing response checks
removed export txtvalue
fixed docs link and issues link
* Verify key for both add and rm
* fixed dns alias condition check
validate key returns 1 if failed.
---------
Co-authored-by: neil
Co-authored-by: LaoDC
---
dnsapi/dns_laodc.sh | 197 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 197 insertions(+)
create mode 100644 dnsapi/dns_laodc.sh
diff --git a/dnsapi/dns_laodc.sh b/dnsapi/dns_laodc.sh
new file mode 100644
index 00000000..9f2103b3
--- /dev/null
+++ b/dnsapi/dns_laodc.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_laodc_info='LaoDC DNS API Server
+Site: laodc.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_laodc
+Options:
+ LaoDC_Key API Key
+Issues: github.com/acmesh-official/acme.sh/issues/6973
+Author: @laodc
+'
+
+# Usage:
+# export LaoDC_Key="your-api-key"
+# acme.sh --issue --dns dns_laodc -d example.la -d *.example.la --dnssleep 120
+#
+# The credentials will be saved in ~/.acme.sh/account.conf
+
+LAODC_VER="0.1.2"
+LAODC_API_ENDPOINT="https://dns.laodc.com/v1"
+
+######## Public functions #####################
+
+# Usage: dns_laodc_add _acme-challenge.example.la ZPXvna6tBhq7XQMH7_t2WC2sg0F-BdmtmmpUJiK6Ho
+dns_laodc_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _info "Using LaoDC DNS API"
+
+ _laodc_validate_key || return 1
+
+ _debug "Checking root zone exists for [$fulldomain]"
+ if ! _get_root "$fulldomain"; then
+ _err "Invalid domain"
+ return 1
+ fi
+
+ domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \")
+ _debug _domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain_hash "$domain_hash"
+
+ _info "Adding acme record"
+ if _laodc_api "POST" "$domain_hash" "$_sub_domain" "$txtvalue"; then
+ if [ "$_code" = "201" ]; then
+ _info "Added, OK"
+ return 0
+ else
+ _err "Add TXT record error, invalid code. Code: $_code"
+ return 1
+ fi
+ fi
+
+ _err "Add TXT record error."
+ return 1
+}
+
+dns_laodc_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ _laodc_validate_key || return 1
+
+ _debug "Checking root zone exists for [$fulldomain]"
+ if ! _get_root "$fulldomain"; then
+ _err "Invalid domain"
+ return 1
+ fi
+
+ domain_hash=$(echo "$response" | _egrep_o "\"hash\":\"[^\"]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \")
+ _debug _root_domain "$_domain"
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain_hash "$domain_hash"
+
+ _info "Deleting acme record"
+ if _laodc_api "DELETE" "$domain_hash" "$_sub_domain" "$txtvalue"; then
+ if [ "$_code" = "204" ]; then
+ _info "Deleted, OK"
+ return 0
+ else
+ _err "Delete TXT record error, invalid code. Code: $_code"
+ return 1
+ fi
+ fi
+
+ _err "Delete TXT record error."
+ return 1
+}
+
+#################### Private functions below ##################################
+# _acme-challenge.www.domain.com
+# returns
+# _domain=domain.com
+# _sub_domain=www
+_get_root() {
+ fqdn=$1
+ p=1
+ i=1
+
+ while true; do
+ h=$(printf "%s" "$fqdn" | cut -d . -f "$i"-100)
+ if [ -z "$h" ]; then
+ return 1 # not valid domain
+ fi
+
+ # Check API if domain exists
+ if _laodc_api "GET" "$h"; then
+ if [ "$_code" = "200" ]; then
+ _domain="$h"
+
+ # DNS alias mode - @ is alias for fqdn
+ _sub_domain=$(printf "%s" "$fqdn" | cut -d . -f 1-"$p")
+ if [ "$i" = "1" ]; then
+ _sub_domain="@"
+ fi
+
+ return 0
+ fi
+ fi
+
+ p="$i"
+ i=$(_math "$i" + 1)
+ done
+
+ return 1
+}
+
+_laodc_validate_key() {
+ LaoDC_Key="${LaoDC_Key:-$(_readaccountconf_mutable LaoDC_Key)}"
+
+ if [ -z "$LaoDC_Key" ]; then
+ LaoDC_Key=""
+ _err "You didn't specify a LaoDC API Key yet."
+ _err "Please export LaoDC_Key and try again."
+ return 1
+ fi
+
+ # Save the api key to the account conf file.
+ _saveaccountconf_mutable LaoDC_Key "$LaoDC_Key"
+}
+
+_laodc_api() {
+ method=$1
+ domain=$2
+ subdomain=$3
+ value=$4
+
+ export _H1="Content-Type: application/json"
+ export _H2="User-Agent: acme.sh/$VER laodc-dns-acme-sh/$LAODC_VER"
+ export _H3="Authorization: Bearer $LaoDC_Key"
+
+ case $method in
+ GET)
+ if [ -n "$subdomain" ]; then
+ response="$(_get "$LAODC_API_ENDPOINT/$domain/$subdomain?type=TXT")"
+ else
+ response="$(_get "$LAODC_API_ENDPOINT/$domain")"
+ fi
+ ;;
+ POST)
+ # Sanitize value input
+ value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
+ data="{ \"type\": \"TXT\", \"value\": \"$value\", \"ttl\": \"60\" }"
+ response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "POST" "application/json")"
+ ;;
+ DELETE)
+ # Sanitize value input
+ value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
+ data="{ \"type\": \"TXT\", \"value\": \"$value\" }"
+ response="$(_post "$data" "$LAODC_API_ENDPOINT/$domain/$subdomain" "" "DELETE" "application/json")"
+ ;;
+ esac
+
+ _ret=$?
+
+ # Unset immediately after request to prevent leaks
+ export _H1=
+ export _H2=
+ export _H3=
+
+ if [ "$_ret" != "0" ]; then
+ _err "Error $domain"
+ return 1
+ fi
+
+ responseHeaders="$(cat "$HTTP_HEADER")"
+
+ if echo "$responseHeaders" | grep -i "Content-Type: *application/json" >/dev/null 2>&1; then
+ response="$(echo "$response" | _json_decode | _normalizeJson)"
+ fi
+
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")"
+
+ _debug "http response code $_code"
+ _debug response "$response"
+ return 0
+}
From 58423df3e82b181946c1d57f821f20405715c52d Mon Sep 17 00:00:00 2001
From: PM Extra
Date: Sun, 5 Jul 2026 23:33:57 +0800
Subject: [PATCH 125/224] retry failed install and deploy on renew (#7083)
* retry failed install and deploy on renew
* fix notify level for renew retry failures
---
acme.sh | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 109 insertions(+)
diff --git a/acme.sh b/acme.sh
index f09436b9..5b8f9566 100755
--- a/acme.sh
+++ b/acme.sh
@@ -6052,6 +6052,31 @@ renew() {
fi
if [ -z "$FORCE" ] && [ "$Le_NextRenewTime" ] && [ "$(_time)" -lt "$Le_NextRenewTime" ]; then
+ _renew_retry_fixed=""
+ res="0"
+ _ensure_install "$Le_Domain"
+ res="$?"
+ if [ "$Le_DeployHook" ] && [ "$res" = "0" ]; then
+ _ensure_deploy "$Le_Domain"
+ res="$?"
+ fi
+ if [ "$res" != "0" ]; then
+ if [ -z "$_ACME_IN_RENEWALL" ]; then
+ if [ $_set_level -ge $NOTIFY_LEVEL_ERROR ]; then
+ _send_notify "Renew $Le_Domain error" "There is an error." "$NOTIFY_HOOK" 1
+ fi
+ fi
+ return 1
+ fi
+ if [ "$_renew_retry_fixed" ]; then
+ _info "Install/deploy retry succeeded, no renewal is needed."
+ if [ -z "$_ACME_IN_RENEWALL" ]; then
+ if [ $_set_level -ge $NOTIFY_LEVEL_RENEW ]; then
+ _send_notify "Renew $Le_Domain success" "Good, the cert install/deploy retry succeeded." "$NOTIFY_HOOK" 0
+ fi
+ fi
+ return 0
+ fi
_info "Skipping. Next renewal time is: $(__green "$Le_NextRenewTimeStr")"
_info "Add '$(__red '--force')' to force renewal."
if [ -z "$_ACME_IN_RENEWALL" ]; then
@@ -6485,6 +6510,45 @@ _deploy() {
_info "$(__green Success)"
fi
done
+
+ _deploy_success_time="$(_time)"
+ _savedomainconf "Le_DeploySuccessTime" "$_deploy_success_time"
+ _savedomainconf "Le_DeploySuccessTimeStr" "$(_time2str "$_deploy_success_time")"
+}
+
+_ensure_deploy() {
+ _d="$1"
+ if [ -z "$Le_DeployHook" ]; then
+ return 0
+ fi
+ if [ -z "$Le_CertCreateTime" ]; then
+ return 0
+ fi
+
+ _deploy_success_time="$(_readdomainconf Le_DeploySuccessTime)"
+ if [ -z "$_deploy_success_time" ]; then
+ _debug "Le_DeploySuccessTime is empty, skip deploy retry check."
+ return 0
+ fi
+ case "$_deploy_success_time$Le_CertCreateTime" in
+ *[!0-9]*)
+ _debug "Le_DeploySuccessTime or Le_CertCreateTime is not a number, skip deploy retry check."
+ return 0
+ ;;
+ esac
+
+ if [ "$_deploy_success_time" -lt "$Le_CertCreateTime" ]; then
+ _info "The cert was created after the last successful deploy, retrying deploy hooks."
+ if _deploy "$_d" "$Le_DeployHook"; then
+ _info "Deploy retry succeeded."
+ _renew_retry_fixed=1
+ return 0
+ fi
+ _err "Deploy retry failed."
+ return 1
+ fi
+
+ return 0
}
#domain hooks
@@ -6644,9 +6708,54 @@ _installcert() {
_info "$(__green "Reload successful")"
else
_err "Reload error for: $_main_domain"
+ return 1
fi
fi
+ _installcert_success_time="$(_time)"
+ _savedomainconf "Le_InstallCertSuccessTime" "$_installcert_success_time"
+ _savedomainconf "Le_InstallCertSuccessTimeStr" "$(_time2str "$_installcert_success_time")"
+}
+
+_ensure_install() {
+ _d="$1"
+ if [ -z "$Le_CertCreateTime" ]; then
+ return 0
+ fi
+
+ _real_cert="$(_readdomainconf Le_RealCertPath)"
+ _real_key="$(_readdomainconf Le_RealKeyPath)"
+ _real_ca="$(_readdomainconf Le_RealCACertPath)"
+ _reload_cmd="$(_readdomainconf Le_ReloadCmd)"
+ _real_fullchain="$(_readdomainconf Le_RealFullChainPath)"
+ if [ -z "$_real_cert$_real_key$_real_ca$_reload_cmd$_real_fullchain" ]; then
+ return 0
+ fi
+
+ _installcert_success_time="$(_readdomainconf Le_InstallCertSuccessTime)"
+ if [ -z "$_installcert_success_time" ]; then
+ _debug "Le_InstallCertSuccessTime is empty, skip install retry check."
+ return 0
+ fi
+ case "$_installcert_success_time$Le_CertCreateTime" in
+ *[!0-9]*)
+ _debug "Le_InstallCertSuccessTime or Le_CertCreateTime is not a number, skip install retry check."
+ return 0
+ ;;
+ esac
+
+ if [ "$_installcert_success_time" -lt "$Le_CertCreateTime" ]; then
+ _info "The cert was created after the last successful install, retrying install cert."
+ if _installcert "$_d" "$_real_cert" "$_real_key" "$_real_ca" "$_real_fullchain" "$_reload_cmd"; then
+ _info "Install cert retry succeeded."
+ _renew_retry_fixed=1
+ return 0
+ fi
+ _err "Install cert retry failed."
+ return 1
+ fi
+
+ return 0
}
__read_password() {
From 1f778e6ef1c08d089413da8777359ef9ec87f9d3 Mon Sep 17 00:00:00 2001
From: Oliver Mueller
Date: Mon, 6 Jul 2026 04:11:58 +0200
Subject: [PATCH 126/224] deploy/ssh: return non-zero when a server deployment
fails (#6795)
ssh_deploy() ignored the result of _ssh_deploy and always returned
success, so a failed transfer to one (or all) of the servers in
DEPLOY_SSH_SERVER was silently swallowed. Track the return code across
the loop and return non-zero if any server failed, letting the caller
handle notification.
Co-authored-by: Claude Opus 4.8 (1M context)
---
deploy/ssh.sh | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/deploy/ssh.sh b/deploy/ssh.sh
index 848380a5..82b0382c 100644
--- a/deploy/ssh.sh
+++ b/deploy/ssh.sh
@@ -170,10 +170,16 @@ ssh_deploy() {
_info "Required commands batched and sent in single call to remote host"
fi
+ _returnCode=0
_deploy_ssh_servers="$DEPLOY_SSH_SERVER"
for DEPLOY_SSH_SERVER in $_deploy_ssh_servers; do
- _ssh_deploy
+ if ! _ssh_deploy; then
+ # in case of an error, remember it, but keep going for the remaining servers
+ _returnCode=1
+ fi
done
+
+ return $_returnCode
}
_ssh_deploy() {
From ff9b969bdb04065f0ae6afae0449bbe5f02d3d9d Mon Sep 17 00:00:00 2001
From: "Simon V." <218359733+sim0n-v@users.noreply.github.com>
Date: Mon, 6 Jul 2026 04:22:36 +0200
Subject: [PATCH 127/224] Add support for Account Key Rollover (#7080)
* add wiki
* feat: add support for account key rollover
* Place --update-account-key next to --update-account
* fix shfmt
* fix shfmt
* fix shfmt
* Fix from review
* fix shfmt
* fix from review
* fix review
---------
Co-authored-by: neil
---
acme.sh | 94 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 94 insertions(+)
diff --git a/acme.sh b/acme.sh
index 5b8f9566..4f1c0336 100755
--- a/acme.sh
+++ b/acme.sh
@@ -4154,6 +4154,93 @@ updateaccount() {
fi
}
+#Implement account key rollover
+updateaccountkey() {
+ _length="$1"
+ _initpath
+
+ if [ ! -f "$ACCOUNT_KEY_PATH" ]; then
+ _err "Account key not found at: $ACCOUNT_KEY_PATH"
+ return 1
+ fi
+ ACCOUNT_KEY_PATH_NEW="$ACCOUNT_KEY_PATH.new"
+
+ _accUri=$(_readcaconf "ACCOUNT_URL")
+ _debug _accUri "$_accUri"
+
+ if [ -z "$_accUri" ]; then
+ _err "The account URL is empty, please run '--update-account' first to update the account info, then try again."
+ return 1
+ fi
+ if ! _calcjwk "$ACCOUNT_KEY_PATH"; then
+ return 1
+ fi
+ _inner_payload="{\"account\": \"$_accUri\", \"oldKey\": $jwk}"
+
+ _initAPI
+ if [ -z "$ACME_KEY_CHANGE" ]; then
+ _err "Server does not expose keyChange url."
+ return 1
+ fi
+
+ _url="$ACME_KEY_CHANGE"
+ if _createkey "$_length" "$ACCOUNT_KEY_PATH_NEW"; then
+ _info "New account key creation OK."
+ else
+ _err "New account key creation error."
+ return 1
+ fi
+
+ if ! _calcjwk "$ACCOUNT_KEY_PATH_NEW"; then
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+ _inner_protected="{\"url\": \"${_url}$JWK_HEADERPLACE_PART2, \"jwk\": $jwk"'}'
+ _inner_protected64="$(printf "%s" "$_inner_protected" | _base64 | _url_replace)"
+ _inner_payload64="$(printf "%s" "$_inner_payload" | _base64 | _url_replace)"
+ if ! _inner_sig_t="$(printf "%s" "$_inner_protected64.$_inner_payload64" | _sign "$ACCOUNT_KEY_PATH_NEW" "sha256")"; then
+ _err "Sign request failed."
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+ _debug3 _inner_sig_t "$_inner_sig_t"
+
+ _inner_sig="$(printf "%s" "$_inner_sig_t" | _url_replace)"
+ _debug3 _inner_sig "$_inner_sig"
+
+ _body="{\"protected\": \"$_inner_protected64\", \"payload\": \"$_inner_payload64\", \"signature\": \"$_inner_sig\"}"
+
+ if ! _send_signed_request "$_url" "$_body" "" "$ACCOUNT_KEY_PATH"; then
+ _err "Error rotating account key: $response."
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+
+ if [ "$code" = '200' ]; then
+ echo "$response" >"$ACCOUNT_JSON_PATH"
+ mv -f "$ACCOUNT_KEY_PATH_NEW" "$ACCOUNT_KEY_PATH"
+ _info "Account key rotation success for $_accUri."
+ elif [ "$code" = "409" ]; then
+ _err "An existing account is using the new key"
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ else
+ _err "Account key rollover error: $response"
+ rm -f "$ACCOUNT_KEY_PATH_NEW"
+ return 1
+ fi
+
+ __CACHED_JWK_KEY_FILE=""
+ _calcjwk "$ACCOUNT_KEY_PATH"
+
+ ACCOUNT_THUMBPRINT="$(__calc_account_thumbprint)"
+ _info "ACCOUNT_THUMBPRINT" "$ACCOUNT_THUMBPRINT"
+
+ CA_KEY_HASH="$(__calcAccountKeyHash)"
+ _debug "Calc CA_KEY_HASH" "$CA_KEY_HASH"
+ _savecaconf CA_KEY_HASH "$CA_KEY_HASH"
+}
+
#Implement deactivate account
deactivateaccount() {
_initpath
@@ -7786,6 +7873,7 @@ Commands:
-ccr, --create-csr Create CSR, professional use.
--create-domain-key Create an domain private key, professional use.
--update-account Update account info.
+ --update-account-key Rotate account key.
--register-account Register account key.
--deactivate-account Deactivate the account.
--make-dns-persist-value Print the DNS TXT record(s) to enable persistent DNS validation
@@ -8338,6 +8426,9 @@ _process() {
--update-account | --updateaccount)
_CMD="updateaccount"
;;
+ --update-account-key | --updateaccountkey)
+ _CMD="updateaccountkey"
+ ;;
--register-account | --registeraccount)
_CMD="registeraccount"
;;
@@ -8931,6 +9022,9 @@ _process() {
updateaccount)
updateaccount
;;
+ updateaccountkey)
+ updateaccountkey "$_accountkeylength"
+ ;;
deactivateaccount)
deactivateaccount
;;
From ca8ab7f8b5bdaa43b2ecb1cf4bc7d4af6bba1743 Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 13:35:27 +0800
Subject: [PATCH 128/224] add wiki-guard workflow: auto-restore wiki pages
deleted or renamed by non-maintainer
---
.github/workflows/wiki-guard.yml | 95 ++++++++++++++++++++++++++++++++
1 file changed, 95 insertions(+)
create mode 100644 .github/workflows/wiki-guard.yml
diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml
new file mode 100644
index 00000000..356ff58a
--- /dev/null
+++ b/.github/workflows/wiki-guard.yml
@@ -0,0 +1,95 @@
+name: Restore Wiki Pages Deleted by Others
+
+# The gollum event only fires on page create/update, never on deletion,
+# so deletions can only be caught by polling the wiki git history.
+
+on:
+ schedule:
+ - cron: "*/10 * * * *"
+ gollum:
+ workflow_dispatch:
+
+permissions:
+ contents: write
+ issues: write
+
+concurrency:
+ group: wiki-guard
+ cancel-in-progress: false
+
+jobs:
+ restore:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout wiki repository
+ uses: actions/checkout@v7
+ with:
+ repository: ${{ github.repository }}.wiki
+ path: wiki
+ fetch-depth: 0
+
+ - name: Restore pages deleted by non-maintainer
+ id: restore
+ run: |
+ cd wiki
+ git config core.quotePath false
+
+ # Any author email under this domain is the maintainer and may delete pages.
+ OWNER_DOMAIN="neilpang.com"
+
+ : > ../restored.txt
+
+ # Paths deleted within the recent window (rolling; the cron runs
+ # every 10 minutes, so 7 days gives ample overlap without
+ # resurrecting old deletions the maintainer already accepted).
+ # --no-renames makes a rename count as a deletion of the old path,
+ # so a page renamed by a non-maintainer is restored under its
+ # original name (the new name stays -- creating pages is allowed).
+ git log --since="7 days ago" --diff-filter=D --no-renames --name-only --format= \
+ | sort -u \
+ | while IFS= read -r f; do
+ if [ -z "$f" ] || [ -e "$f" ]; then
+ continue
+ fi
+ # The most recent commit (in full history) that deleted this path.
+ del="$(git log -1 --diff-filter=D --no-renames --format=%H -- "$f")"
+ if [ -z "$del" ]; then
+ continue
+ fi
+ ae="$(git show -s --format=%ae "$del" | tr 'A-Z' 'a-z')"
+ case "$ae" in
+ *@"$OWNER_DOMAIN")
+ continue
+ ;;
+ esac
+ an="$(git show -s --format=%an "$del")"
+ echo "Restoring: $f (deleted in $del by $an <$ae>)"
+ git checkout "$del^" -- "$f"
+ printf '%s\n' "- \`$f\` deleted in $del by $an <$ae>" >> ../restored.txt
+ done
+
+ if [ -s ../restored.txt ]; then
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git commit -m "Restore pages deleted by non-maintainer"
+ git push origin HEAD || { git pull --rebase && git push origin HEAD; }
+ {
+ echo "The following wiki pages were deleted or renamed by someone other than the maintainer and have been restored automatically:"
+ echo ""
+ cat ../restored.txt
+ echo ""
+ echo "Wiki: https://github.com/${GITHUB_REPOSITORY}/wiki"
+ } > ../restore-msg.txt
+ echo "restored=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "No unauthorized deletions found."
+ echo "restored=false" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Create issue to notify Neilpang
+ if: steps.restore.outputs.restored == 'true'
+ uses: peter-evans/create-issue-from-file@v6
+ with:
+ title: "Wiki pages restored after unauthorized deletion"
+ content-filepath: ./restore-msg.txt
+ assignees: Neilpang
From 8585d9f4a7bc4a135df9e6f995388fadf76b3622 Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 14:23:51 +0800
Subject: [PATCH 129/224] wiki-monitor: skip notification for the maintainer's
own wiki changes
---
.github/workflows/wiki-monitor.yml | 18 ++++++++++++++++--
1 file changed, 16 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml
index 7e5d7ca3..349ef3f3 100644
--- a/.github/workflows/wiki-monitor.yml
+++ b/.github/workflows/wiki-monitor.yml
@@ -9,13 +9,14 @@ jobs:
if: github.actor != 'neilpang'
steps:
- name: Checkout wiki repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
repository: ${{ github.repository }}.wiki
path: wiki
fetch-depth: 0
- name: Generate wiki change message
+ id: msg
run: |
actor="${{ github.actor }}"
sender_url=$(jq -r '.sender.html_url' "$GITHUB_EVENT_PATH")
@@ -27,6 +28,17 @@ jobs:
now="$(date '+%Y-%m-%d %H:%M:%S')"
cd wiki
+ # Skip notification when the change was authored by the
+ # maintainer himself (any author email under neilpang.com),
+ # e.g. a direct git push to the wiki repository.
+ author_email=$(git show -s --format=%ae "$page_sha" 2>/dev/null | tr 'A-Z' 'a-z')
+ case "$author_email" in
+ *@neilpang.com)
+ echo "Change authored by maintainer ($author_email); skipping notification."
+ echo "notify=false" >> "$GITHUB_OUTPUT"
+ exit 0
+ ;;
+ esac
prev_sha=$(git rev-list $page_sha^ -- "$page_name.md" | head -n 1)
if [ -n "$prev_sha" ]; then
git diff $prev_sha $page_sha -- "$page_name.md" > ../wiki.diff || echo "(No diff found)" > ../wiki.diff
@@ -44,13 +56,15 @@ jobs:
echo "[Click here to Revert](${page_url}/_history)"
echo ""
echo "----"
- echo "### diff:"
+ echo "### diff:"
echo '```diff'
cat wiki.diff
echo '```'
} > wiki-change-msg.txt
+ echo "notify=true" >> "$GITHUB_OUTPUT"
- name: Create issue to notify Neilpang
+ if: steps.msg.outputs.notify == 'true'
uses: peter-evans/create-issue-from-file@v6
with:
title: "Wiki edited"
From dc1b06006f244f75b4ab9f446191f4b8df3139dc Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 14:27:00 +0800
Subject: [PATCH 130/224] issue.yml: assign and label "Report bugs to" tracking
issues instead of posting the upgrade boilerplate
---
.github/workflows/issue.yml | 23 ++++++++++++++++++++---
1 file changed, 20 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml
index c659fce5..37e95eba 100644
--- a/.github/workflows/issue.yml
+++ b/.github/workflows/issue.yml
@@ -10,10 +10,27 @@ jobs:
- uses: actions/github-script@v9
with:
script: |
- github.rest.issues.createComment({
- issue_number: context.issue.number,
+ const issue = context.payload.issue;
+ if (issue.title.startsWith("Report bugs to")) {
+ // Tracking issue for a third-party dns/deploy/notify api:
+ // no upgrade boilerplate; assign it to the opener and label it.
+ await github.rest.issues.addAssignees({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ assignees: [issue.user.login]
+ });
+ await github.rest.issues.addLabels({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ labels: ["3rd party api"]
+ });
+ return;
+ }
+ await github.rest.issues.createComment({
+ 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."
-
})
\ No newline at end of file
From 2b5a19d34a363597be753261f43b584448599df8 Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 15:11:35 +0800
Subject: [PATCH 131/224] wiki-guard: trust repo/org members with write access
in all rule checks
---
.github/workflows/issue.yml | 46 +++++
.github/workflows/wiki-guard.yml | 293 +++++++++++++++++++++++++++----
2 files changed, 301 insertions(+), 38 deletions(-)
diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml
index 37e95eba..bd6dc9e8 100644
--- a/.github/workflows/issue.yml
+++ b/.github/workflows/issue.yml
@@ -2,6 +2,12 @@ name: "Update issues"
on:
issues:
types: [opened]
+ pull_request_target:
+ types: [opened]
+
+permissions:
+ issues: write
+ pull-requests: write
jobs:
comment:
@@ -10,6 +16,46 @@ jobs:
- uses: actions/github-script@v9
with:
script: |
+ const item = context.payload.issue || context.payload.pull_request;
+
+ // Close on sight anything opened by a user on the wiki Blacklist
+ // page (maintained by the Wiki Guard workflow).
+ let blacklist = [];
+ try {
+ const res = await fetch(`https://raw.githubusercontent.com/wiki/${context.repo.owner}/${context.repo.repo}/Blacklist.md`);
+ if (res.ok) {
+ blacklist = (await res.text()).split("\n")
+ .filter(l => l.startsWith("- "))
+ .map(l => l.slice(2).trim().toLowerCase())
+ .filter(Boolean);
+ }
+ } catch (e) {
+ core.warning(`Failed to fetch the blacklist: ${e}`);
+ }
+ if (blacklist.includes(item.user.login.toLowerCase())) {
+ if (context.payload.pull_request) {
+ await github.rest.pulls.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ pull_number: item.number,
+ state: "closed"
+ });
+ } else {
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: item.number,
+ state: "closed",
+ state_reason: "not_planned"
+ });
+ }
+ return;
+ }
+
+ if (context.payload.pull_request) {
+ return;
+ }
+
const issue = context.payload.issue;
if (issue.title.startsWith("Report bugs to")) {
// Tracking issue for a third-party dns/deploy/notify api:
diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml
index 356ff58a..a0199441 100644
--- a/.github/workflows/wiki-guard.yml
+++ b/.github/workflows/wiki-guard.yml
@@ -1,7 +1,19 @@
-name: Restore Wiki Pages Deleted by Others
+name: Wiki Guard
+# Rules enforced here:
+# - Only the maintainer and write-access members may delete or rename wiki
+# pages. Anyone else doing so gets blacklisted and the page restored to
+# its last good revision.
+# - Only the maintainer and write-access members may edit the Blacklist
+# wiki page. Anyone else touching it gets blacklisted and the page
+# reverted.
+# - Any wiki change made by a blacklisted identity is reverted.
+# A "good" revision is one authored by the maintainer, by this bot, or by
+# a non-blacklisted user -- restoring from the deleted commit's parent is
+# NOT safe, because vandals replace a page before destroying it and the
+# parent would launder their version into a bot commit.
# The gollum event only fires on page create/update, never on deletion,
-# so deletions can only be caught by polling the wiki git history.
+# so violations are caught by polling the wiki git history.
on:
schedule:
@@ -18,7 +30,7 @@ concurrency:
cancel-in-progress: false
jobs:
- restore:
+ guard:
runs-on: ubuntu-latest
steps:
- name: Checkout wiki repository
@@ -28,68 +40,273 @@ jobs:
path: wiki
fetch-depth: 0
- - name: Restore pages deleted by non-maintainer
- id: restore
+ - name: Enforce wiki rules
+ id: guard
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
+ # Logins with write (push) access to the repository, including
+ # organization members -- they may delete/rename pages and edit
+ # the blacklist just like the maintainer. If the API call fails,
+ # the list stays empty and enforcement falls back to
+ # maintainer-only, which is the safe direction.
+ gh api "repos/${GITHUB_REPOSITORY}/collaborators?per_page=100" --paginate \
+ -q '.[] | select(.permissions.push) | .login' 2>/dev/null \
+ | tr 'A-Z' 'a-z' | sort -u > writers.txt || true
cd wiki
git config core.quotePath false
- # Any author email under this domain is the maintainer and may delete pages.
+ # Any author email under this domain is the maintainer.
OWNER_DOMAIN="neilpang.com"
+ # Our own enforcement commits.
+ BOT_EMAIL="41898282+github-actions[bot]@users.noreply.github.com"
+ BL_PAGE="Blacklist.md"
+ # Rolling window; the cron runs every 10 minutes, so 7 days gives
+ # ample overlap without re-judging old changes the maintainer
+ # already accepted.
+ WINDOW="7 days ago"
- : > ../restored.txt
+ : > ../actions.txt
+ : > ../bl_new.txt
- # Paths deleted within the recent window (rolling; the cron runs
- # every 10 minutes, so 7 days gives ample overlap without
- # resurrecting old deletions the maintainer already accepted).
- # --no-renames makes a rename count as a deletion of the old path,
- # so a page renamed by a non-maintainer is restored under its
- # original name (the new name stays -- creating pages is allowed).
- git log --since="7 days ago" --diff-filter=D --no-renames --name-only --format= \
+ is_owner() {
+ case "$1" in
+ *@"$OWNER_DOMAIN") return 0 ;;
+ esac
+ return 1
+ }
+
+ is_bot() {
+ [ "$1" = "$BOT_EMAIL" ]
+ }
+
+ author_email() {
+ git show -s --format=%ae "$1" | tr 'A-Z' 'a-z'
+ }
+
+ # Identity of a commit author: the GitHub login when the email is a
+ # users.noreply.github.com address, otherwise the email itself.
+ identity_of() {
+ case "$1" in
+ *+*@users.noreply.github.com)
+ printf '%s\n' "$1" | sed 's/^[^+]*+//; s/@users\.noreply\.github\.com$//'
+ ;;
+ *@users.noreply.github.com)
+ printf '%s\n' "$1" | sed 's/@users\.noreply\.github\.com$//'
+ ;;
+ *)
+ printf '%s\n' "$1"
+ ;;
+ esac
+ }
+
+ is_blacklisted() {
+ grep -Fxq "$1" ../bl_all.txt
+ }
+
+ # Trusted committers: the maintainer (by email domain), this bot,
+ # and anyone whose GitHub login has write access to the repo.
+ is_trusted() {
+ if is_owner "$1" || is_bot "$1"; then
+ return 0
+ fi
+ grep -Fxq "$(identity_of "$1")" ../writers.txt
+ }
+
+ # Newest commit on file $1 authored by a non-blacklisted user.
+ last_good_for() {
+ for g in $(git log --format=%H --no-renames -- "$1"); do
+ gae="$(author_email "$g")"
+ if is_trusted "$gae"; then
+ printf '%s\n' "$g"
+ return 0
+ fi
+ gid="$(identity_of "$gae")"
+ if ! is_blacklisted "$gid" && ! is_blacklisted "$gae"; then
+ printf '%s\n' "$g"
+ return 0
+ fi
+ done
+ return 0
+ }
+
+ if [ -e "$BL_PAGE" ]; then
+ page_existed=1
+ else
+ page_existed=""
+ fi
+
+ # ---- 1. Last good version of the blacklist page: the newest
+ # revision authored by the maintainer or by this bot. Everything
+ # else on that page is tampering and is discarded.
+ bl_good_commit=""
+ for c in $(git log --format=%H --no-renames -- "$BL_PAGE"); do
+ ae="$(author_email "$c")"
+ if is_trusted "$ae"; then
+ bl_good_commit="$c"
+ break
+ fi
+ done
+ if [ -n "$bl_good_commit" ] && git cat-file -e "$bl_good_commit:$BL_PAGE" 2>/dev/null; then
+ git show "$bl_good_commit:$BL_PAGE" > ../bl_page.txt
+ else
+ {
+ echo "# Blacklist"
+ echo ""
+ echo "Users listed below violated the wiki rules (deleted or renamed"
+ echo "pages, or tampered with this page). Their new issues and pull"
+ echo "requests are closed on sight and their wiki edits are reverted"
+ echo "automatically. Only the maintainer and write-access members"
+ echo "may edit this page."
+ echo ""
+ echo "To pardon a user while their violation is still inside the"
+ echo "scan window, replace their entry with: pardon: username"
+ echo ""
+ } > ../bl_page.txt
+ fi
+ sed -n 's/^- *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_good.txt
+ sed -n 's/^[Pp]ardon: *//p' ../bl_page.txt | tr -d '\r' | tr 'A-Z' 'a-z' | sort -u > ../bl_pardon.txt
+
+ bl_add() {
+ if grep -Fxq "$1" ../bl_pardon.txt; then
+ return 0
+ fi
+ if ! grep -Fxq "$1" ../bl_good.txt && ! grep -Fxq "$1" ../bl_new.txt; then
+ printf '%s\n' "$1" >> ../bl_new.txt
+ printf '%s\n' "- blacklisted \`$1\`: $2" >> ../actions.txt
+ fi
+ }
+
+ # ---- 2. Blacklist everyone who deleted or renamed a page.
+ # --no-renames makes a rename count as a deletion of the old path.
+ for c in $(git log --since="$WINDOW" --diff-filter=D --no-renames --format=%H); do
+ ae="$(author_email "$c")"
+ if is_trusted "$ae"; then
+ continue
+ fi
+ an="$(git show -s --format=%an "$c")"
+ bl_add "$(identity_of "$ae")" "deleted or renamed pages in $c ($an <$ae>)"
+ done
+
+ # ---- 3. Blacklist everyone else who touched the blacklist page.
+ # The revert of their tampering falls out of steps 5 and 6.
+ for c in $(git log --since="$WINDOW" --format=%H --no-renames -- "$BL_PAGE"); do
+ ae="$(author_email "$c")"
+ if is_trusted "$ae"; then
+ continue
+ fi
+ an="$(git show -s --format=%an "$c")"
+ bl_add "$(identity_of "$ae")" "tampered with \`$BL_PAGE\` in $c ($an <$ae>)"
+ done
+
+ sort -u ../bl_new.txt > ../bl_new_u.txt
+ cat ../bl_good.txt ../bl_new_u.txt | sort -u > ../bl_all.txt
+
+ # ---- 4. Restore pages that are currently missing because a
+ # non-maintainer deleted them, using the last good revision.
+ git log --since="$WINDOW" --diff-filter=D --no-renames --name-only --format= \
| sort -u \
| while IFS= read -r f; do
- if [ -z "$f" ] || [ -e "$f" ]; then
+ if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ] || [ -e "$f" ]; then
continue
fi
- # The most recent commit (in full history) that deleted this path.
del="$(git log -1 --diff-filter=D --no-renames --format=%H -- "$f")"
if [ -z "$del" ]; then
continue
fi
- ae="$(git show -s --format=%ae "$del" | tr 'A-Z' 'a-z')"
- case "$ae" in
- *@"$OWNER_DOMAIN")
- continue
- ;;
- esac
- an="$(git show -s --format=%an "$del")"
- echo "Restoring: $f (deleted in $del by $an <$ae>)"
- git checkout "$del^" -- "$f"
- printf '%s\n' "- \`$f\` deleted in $del by $an <$ae>" >> ../restored.txt
+ ae="$(author_email "$del")"
+ if is_trusted "$ae"; then
+ continue
+ fi
+ good="$(last_good_for "$f")"
+ if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then
+ git checkout "$good" -- "$f"
+ printf '%s\n' "- restored \`$f\` (deleted in $del) from its last good revision $good" >> ../actions.txt
+ fi
done
- if [ -s ../restored.txt ]; then
+ # ---- 5. Revert every recent change made by a blacklisted
+ # identity: each touched file goes back to its newest revision
+ # authored by a non-blacklisted user; a file that has no such
+ # revision (they created it) is removed.
+ if [ -s ../bl_all.txt ]; then
+ for c in $(git log --since="$WINDOW" --format=%H --no-renames); do
+ ae="$(author_email "$c")"
+ if is_trusted "$ae"; then
+ continue
+ fi
+ id="$(identity_of "$ae")"
+ if ! is_blacklisted "$id" && ! is_blacklisted "$ae"; then
+ continue
+ fi
+ git show --name-only --no-renames --format= "$c" \
+ | while IFS= read -r f; do
+ if [ -z "$f" ] || [ "$f" = "$BL_PAGE" ]; then
+ continue
+ fi
+ good="$(last_good_for "$f")"
+ if [ -n "$good" ] && git cat-file -e "$good:$f" 2>/dev/null; then
+ want="$(git rev-parse "$good:$f")"
+ have="$(git hash-object -- "$f" 2>/dev/null || echo missing)"
+ if [ "$want" != "$have" ]; then
+ git checkout "$good" -- "$f"
+ printf '%s\n' "- reverted \`$f\` to its last good revision $good (undoing change by \`$id\` in $c)" >> ../actions.txt
+ fi
+ elif [ -e "$f" ]; then
+ git rm -q -- "$f"
+ printf '%s\n' "- removed \`$f\` created by blacklisted \`$id\` in $c" >> ../actions.txt
+ fi
+ done
+ done
+ fi
+
+ # ---- 6. Regenerate the blacklist page: the last good text plus
+ # any newly blacklisted identities. This both reverts tampering
+ # and records new violators; manual edits by the maintainer are
+ # preserved as the new good text.
+ cp ../bl_page.txt ../bl_page_new.txt
+ if [ -s ../bl_page_new.txt ] && [ -n "$(tail -c1 ../bl_page_new.txt)" ]; then
+ echo >> ../bl_page_new.txt
+ fi
+ while IFS= read -r id; do
+ if [ -n "$id" ] && ! grep -Fxiq -- "- $id" ../bl_page_new.txt; then
+ printf -- '- %s\n' "$id" >> ../bl_page_new.txt
+ fi
+ done < ../bl_new_u.txt
+ if ! cmp -s ../bl_page_new.txt "$BL_PAGE" 2>/dev/null; then
+ cp ../bl_page_new.txt "$BL_PAGE"
+ git add -- "$BL_PAGE"
+ if [ -n "$page_existed" ] || [ -s ../bl_new_u.txt ]; then
+ printf '%s\n' "- updated \`$BL_PAGE\`" >> ../actions.txt
+ fi
+ fi
+
+ # ---- 7. Commit, push, notify.
+ if [ -n "$(git status --porcelain)" ]; then
git config user.name "github-actions[bot]"
- git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
- git commit -m "Restore pages deleted by non-maintainer"
+ git config user.email "$BOT_EMAIL"
+ git commit -m "wiki-guard: restore pages and enforce blacklist"
git push origin HEAD || { git pull --rebase && git push origin HEAD; }
+ fi
+ if [ -s ../actions.txt ]; then
{
- echo "The following wiki pages were deleted or renamed by someone other than the maintainer and have been restored automatically:"
+ echo "The wiki guard handled the following rule violations:"
echo ""
- cat ../restored.txt
+ cat ../actions.txt
echo ""
+ echo "Blacklist: https://github.com/${GITHUB_REPOSITORY}/wiki/Blacklist"
echo "Wiki: https://github.com/${GITHUB_REPOSITORY}/wiki"
- } > ../restore-msg.txt
- echo "restored=true" >> "$GITHUB_OUTPUT"
+ } > ../guard-msg.txt
+ echo "acted=true" >> "$GITHUB_OUTPUT"
else
- echo "No unauthorized deletions found."
- echo "restored=false" >> "$GITHUB_OUTPUT"
+ echo "No rule violations found."
+ echo "acted=false" >> "$GITHUB_OUTPUT"
fi
- name: Create issue to notify Neilpang
- if: steps.restore.outputs.restored == 'true'
+ if: steps.guard.outputs.acted == 'true'
uses: peter-evans/create-issue-from-file@v6
with:
- title: "Wiki pages restored after unauthorized deletion"
- content-filepath: ./restore-msg.txt
+ title: "Wiki guard: rule violations handled"
+ content-filepath: ./guard-msg.txt
assignees: Neilpang
From e4eaa590639d8590f0fe3016f67bf7f85dbc511e Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 15:23:45 +0800
Subject: [PATCH 132/224] wiki-guard: log the number of write-access members
loaded
---
.github/workflows/wiki-guard.yml | 1 +
1 file changed, 1 insertion(+)
diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml
index a0199441..45033554 100644
--- a/.github/workflows/wiki-guard.yml
+++ b/.github/workflows/wiki-guard.yml
@@ -53,6 +53,7 @@ jobs:
gh api "repos/${GITHUB_REPOSITORY}/collaborators?per_page=100" --paginate \
-q '.[] | select(.permissions.push) | .login' 2>/dev/null \
| tr 'A-Z' 'a-z' | sort -u > writers.txt || true
+ echo "write-access members loaded: $(wc -l < writers.txt)"
cd wiki
git config core.quotePath false
From 919492df1347127f14f92e4732d9b0d862a1a0ba Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 16:27:26 +0800
Subject: [PATCH 133/224] wiki-guard: use WIKI_GUARD_TOKEN (PAT with read:org)
to enumerate org write members
---
.github/workflows/wiki-guard.yml | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml
index 45033554..6c392d37 100644
--- a/.github/workflows/wiki-guard.yml
+++ b/.github/workflows/wiki-guard.yml
@@ -43,7 +43,10 @@ jobs:
- name: Enforce wiki rules
id: guard
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ # WIKI_GUARD_TOKEN: a PAT with read:org, needed to enumerate
+ # members whose write access comes via the organization -- the
+ # repo-scoped GITHUB_TOKEN only sees direct collaborators.
+ GH_TOKEN: ${{ secrets.WIKI_GUARD_TOKEN || secrets.GITHUB_TOKEN }}
run: |
# Logins with write (push) access to the repository, including
# organization members -- they may delete/rename pages and edit
@@ -54,6 +57,7 @@ jobs:
-q '.[] | select(.permissions.push) | .login' 2>/dev/null \
| tr 'A-Z' 'a-z' | sort -u > writers.txt || true
echo "write-access members loaded: $(wc -l < writers.txt)"
+ cat writers.txt
cd wiki
git config core.quotePath false
From 73df21abc4d81e2e98fd5d67a8284bbee54368fc Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 16:30:41 +0800
Subject: [PATCH 134/224] clean
---
.github/workflows/wiki-guard.yml | 1 -
1 file changed, 1 deletion(-)
diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml
index 6c392d37..3c21f492 100644
--- a/.github/workflows/wiki-guard.yml
+++ b/.github/workflows/wiki-guard.yml
@@ -57,7 +57,6 @@ jobs:
-q '.[] | select(.permissions.push) | .login' 2>/dev/null \
| tr 'A-Z' 'a-z' | sort -u > writers.txt || true
echo "write-access members loaded: $(wc -l < writers.txt)"
- cat writers.txt
cd wiki
git config core.quotePath false
From f2b37b32ff6feec7228fbfbbe988056e6f26dac2 Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 16:36:16 +0800
Subject: [PATCH 135/224] add more events
---
.github/workflows/wiki-guard.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/wiki-guard.yml b/.github/workflows/wiki-guard.yml
index 3c21f492..709fb8ea 100644
--- a/.github/workflows/wiki-guard.yml
+++ b/.github/workflows/wiki-guard.yml
@@ -19,6 +19,12 @@ on:
schedule:
- cron: "*/10 * * * *"
gollum:
+ # Piggyback on frequent repo activity, because the cron schedule is
+ # best-effort and often delayed well beyond its interval.
+ issues:
+ types: [opened]
+ issue_comment:
+ types: [created]
workflow_dispatch:
permissions:
From cc64a732308bb0f5cf5c573a0539f3db1ba4f4b0 Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 6 Jul 2026 17:39:56 +0800
Subject: [PATCH 136/224] one-click revert and ban
---
.github/workflows/blacklist-command.yml | 113 ++++++++++++++++++++++++
.github/workflows/issue.yml | 4 +
.github/workflows/revert-command.yml | 109 +++++++++++++++++++++++
.github/workflows/wiki-monitor.yml | 4 +-
4 files changed, 229 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/blacklist-command.yml
create mode 100644 .github/workflows/revert-command.yml
diff --git a/.github/workflows/blacklist-command.yml b/.github/workflows/blacklist-command.yml
new file mode 100644
index 00000000..4d0ff5f3
--- /dev/null
+++ b/.github/workflows/blacklist-command.yml
@@ -0,0 +1,113 @@
+name: Blacklist Command
+
+# An issue titled "blacklist: " opened by the maintainer
+# or a write-access member adds that identity to the Blacklist wiki page
+# (see wiki-guard.yml) and closes the issue. The wiki-monitor notification
+# embeds a prefilled link that opens such an issue in one click.
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ contents: write
+ issues: write
+
+# Share the wiki-guard concurrency group so we never push to the wiki
+# at the same time as the guard.
+concurrency:
+ group: wiki-guard
+ cancel-in-progress: false
+
+jobs:
+ blacklist:
+ if: startsWith(github.event.issue.title, 'blacklist:')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check authorization
+ id: auth
+ run: |
+ assoc="${{ github.event.issue.author_association }}"
+ case "$assoc" in
+ OWNER|MEMBER|COLLABORATOR)
+ echo "ok=true" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "issue author is not authorized ($assoc); ignoring"
+ echo "ok=false" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+
+ - name: Checkout wiki repository
+ if: steps.auth.outputs.ok == 'true'
+ uses: actions/checkout@v7
+ with:
+ repository: ${{ github.repository }}.wiki
+ path: wiki
+
+ - name: Add the identity to the blacklist page
+ if: steps.auth.outputs.ok == 'true'
+ id: add
+ env:
+ TITLE: ${{ github.event.issue.title }}
+ run: |
+ target="$(printf '%s' "$TITLE" \
+ | sed 's/^blacklist:[[:space:]]*//; s/^@//; s/[[:space:]].*$//' \
+ | tr 'A-Z' 'a-z')"
+ case "$target" in
+ ''|*[!a-z0-9._+@-]*)
+ echo "invalid target: '$target'"
+ echo "result=invalid" >> "$GITHUB_OUTPUT"
+ exit 0
+ ;;
+ esac
+ echo "target=$target" >> "$GITHUB_OUTPUT"
+ cd wiki
+ if [ ! -e Blacklist.md ]; then
+ echo "result=nopage" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ if grep -Fxiq -- "- $target" Blacklist.md; then
+ echo "result=already" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ if [ -n "$(tail -c1 Blacklist.md)" ]; then
+ echo >> Blacklist.md
+ fi
+ printf -- '- %s\n' "$target" >> Blacklist.md
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add Blacklist.md
+ git commit -m "blacklist $target (requested in #${{ github.event.issue.number }})"
+ git push origin HEAD || { git pull --rebase && git push origin HEAD; }
+ echo "result=added" >> "$GITHUB_OUTPUT"
+
+ - name: Reply and close
+ if: steps.auth.outputs.ok == 'true'
+ uses: actions/github-script@v9
+ env:
+ RESULT: ${{ steps.add.outputs.result }}
+ TARGET: ${{ steps.add.outputs.target }}
+ with:
+ script: |
+ const result = process.env.RESULT;
+ const target = process.env.TARGET;
+ const messages = {
+ added: `\`${target}\` has been added to the [Blacklist](https://github.com/${context.repo.owner}/${context.repo.repo}/wiki/Blacklist). The wiki guard will revert their recent wiki changes on its next run.`,
+ already: `\`${target}\` is already on the blacklist.`,
+ invalid: "Could not parse a valid login or email from the issue title.",
+ nopage: "The Blacklist wiki page does not exist."
+ };
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: messages[result] || "No action taken."
+ });
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ state: "closed",
+ state_reason: result === "added" ? "completed" : "not_planned"
+ });
diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml
index bd6dc9e8..a72c18ab 100644
--- a/.github/workflows/issue.yml
+++ b/.github/workflows/issue.yml
@@ -57,6 +57,10 @@ jobs:
}
const issue = context.payload.issue;
+ if (issue.title.startsWith("blacklist:") || issue.title.startsWith("revert:")) {
+ // Handled by the Blacklist / Revert Command workflows.
+ return;
+ }
if (issue.title.startsWith("Report bugs to")) {
// Tracking issue for a third-party dns/deploy/notify api:
// no upgrade boilerplate; assign it to the opener and label it.
diff --git a/.github/workflows/revert-command.yml b/.github/workflows/revert-command.yml
new file mode 100644
index 00000000..7a792bf4
--- /dev/null
+++ b/.github/workflows/revert-command.yml
@@ -0,0 +1,109 @@
+name: Revert Command
+
+# An issue titled "revert: " opened by the maintainer or
+# a write-access member reverts that commit in the wiki repository and
+# closes the issue. The wiki-monitor notification embeds a prefilled link
+# that opens such an issue in one click.
+
+on:
+ issues:
+ types: [opened]
+
+permissions:
+ contents: write
+ issues: write
+
+# Share the wiki-guard concurrency group so we never push to the wiki
+# at the same time as the guard.
+concurrency:
+ group: wiki-guard
+ cancel-in-progress: false
+
+jobs:
+ revert:
+ if: startsWith(github.event.issue.title, 'revert:')
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check authorization
+ id: auth
+ run: |
+ assoc="${{ github.event.issue.author_association }}"
+ case "$assoc" in
+ OWNER|MEMBER|COLLABORATOR)
+ echo "ok=true" >> "$GITHUB_OUTPUT"
+ ;;
+ *)
+ echo "issue author is not authorized ($assoc); ignoring"
+ echo "ok=false" >> "$GITHUB_OUTPUT"
+ ;;
+ esac
+
+ - name: Checkout wiki repository
+ if: steps.auth.outputs.ok == 'true'
+ uses: actions/checkout@v7
+ with:
+ repository: ${{ github.repository }}.wiki
+ path: wiki
+ fetch-depth: 0
+
+ - name: Revert the wiki commit
+ if: steps.auth.outputs.ok == 'true'
+ id: revert
+ env:
+ TITLE: ${{ github.event.issue.title }}
+ run: |
+ target="$(printf '%s' "$TITLE" \
+ | sed 's/^revert:[[:space:]]*//; s/[[:space:]].*$//' \
+ | tr 'A-Z' 'a-z')"
+ case "$target" in
+ *[!0-9a-f]*|"")
+ echo "invalid commit sha: '$target'"
+ echo "result=invalid" >> "$GITHUB_OUTPUT"
+ exit 0
+ ;;
+ esac
+ echo "target=$target" >> "$GITHUB_OUTPUT"
+ cd wiki
+ if ! git cat-file -e "$target^{commit}" 2>/dev/null; then
+ echo "result=notfound" >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ if git revert --no-edit "$target"; then
+ git push origin HEAD || { git pull --rebase && git push origin HEAD; }
+ echo "result=reverted" >> "$GITHUB_OUTPUT"
+ else
+ git revert --abort || true
+ echo "result=conflict" >> "$GITHUB_OUTPUT"
+ fi
+
+ - name: Reply and close
+ if: steps.auth.outputs.ok == 'true'
+ uses: actions/github-script@v9
+ env:
+ RESULT: ${{ steps.revert.outputs.result }}
+ TARGET: ${{ steps.revert.outputs.target }}
+ with:
+ script: |
+ const result = process.env.RESULT;
+ const target = process.env.TARGET;
+ const messages = {
+ reverted: `Wiki commit \`${target}\` has been reverted.`,
+ conflict: `Reverting \`${target}\` conflicts with later edits; please revert manually from the page history.`,
+ notfound: `Commit \`${target}\` was not found in the wiki repository.`,
+ invalid: "Could not parse a commit sha from the issue title."
+ };
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body: messages[result] || "No action taken."
+ });
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ state: "closed",
+ state_reason: result === "reverted" ? "completed" : "not_planned"
+ });
diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml
index 349ef3f3..89bb1f3a 100644
--- a/.github/workflows/wiki-monitor.yml
+++ b/.github/workflows/wiki-monitor.yml
@@ -53,7 +53,9 @@ jobs:
echo "Time: $now"
echo "Page: [$page_name]($page_url) (Action: $page_action)"
echo "Comment: $page_summary"
- echo "[Click here to Revert](${page_url}/_history)"
+ echo "[Click here to Revert](https://github.com/${GITHUB_REPOSITORY}/issues/new?title=revert%3A+${page_sha}&body=Revert+wiki+commit+${page_sha}+by+@${actor}.)"
+ echo ""
+ echo "[Click here to Blacklist @$actor](https://github.com/${GITHUB_REPOSITORY}/issues/new?title=blacklist%3A+${actor}&body=Blacklist+@${actor},+requested+from+the+wiki+monitor.)"
echo ""
echo "----"
echo "### diff:"
From b1b539695f8e6337421bb65a5a76afb758c8c2ff Mon Sep 17 00:00:00 2001
From: Marvo2011
Date: Mon, 6 Jul 2026 15:30:13 +0200
Subject: [PATCH 137/224] Merge pull request #7026 from Marvo2011/dev
Update SelfHost DNS provider
---
dnsapi/dns_selfhost.sh | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh
index 40cc0210..782a5d5f 100644
--- a/dnsapi/dns_selfhost.sh
+++ b/dnsapi/dns_selfhost.sh
@@ -7,6 +7,7 @@ Options:
SELFHOSTDNS_USERNAME Username
SELFHOSTDNS_PASSWORD Password
SELFHOSTDNS_MAP Subdomain name
+ SELFHOSTDNS_UPDATE_URL API url. Optional. Default "https://account.selfhost.de/cgi-bin/api.pl"
Issues: github.com/acmesh-official/acme.sh/issues/4291
Author: Marvin Edeler
'
@@ -18,9 +19,11 @@ dns_selfhost_add() {
_debug fulldomain "$fulldomain"
_debug txtvalue "$txt"
- SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl"
+ DEFAULT_SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl"
# Get values, but don't save until we successfully validated
+ SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$(_readaccountconf_mutable SELFHOSTDNS_UPDATE_URL)}"
+ SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$DEFAULT_SELFHOSTDNS_UPDATE_URL}"
SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}"
SELFHOSTDNS_PASSWORD="${SELFHOSTDNS_PASSWORD:-$(_readaccountconf_mutable SELFHOSTDNS_PASSWORD)}"
# These values are domain dependent, so read them from there
@@ -84,6 +87,11 @@ dns_selfhost_add() {
fi
fi
+ # Save api url if different from default
+ if [ "$DEFAULT_SELFHOSTDNS_UPDATE_URL" != "$SELFHOSTDNS_UPDATE_URL" ]; then
+ _saveaccountconf_mutable SELFHOSTDNS_UPDATE_URL "$SELFHOSTDNS_UPDATE_URL"
+ fi
+
# Now that we know the values are good, save them
_saveaccountconf_mutable SELFHOSTDNS_USERNAME "$SELFHOSTDNS_USERNAME"
_saveaccountconf_mutable SELFHOSTDNS_PASSWORD "$SELFHOSTDNS_PASSWORD"
From ca118be754d59ecdfcc5f298d156dcd7fa93a1c3 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 10:02:45 +0800
Subject: [PATCH 138/224] issue.yml: match tracking issue title variants
---
.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 a72c18ab..f72a1f06 100644
--- a/.github/workflows/issue.yml
+++ b/.github/workflows/issue.yml
@@ -61,7 +61,7 @@ jobs:
// Handled by the Blacklist / Revert Command workflows.
return;
}
- if (issue.title.startsWith("Report bugs to")) {
+ if (/^report\s+(bugs?|issues?)\b/i.test(issue.title)) {
// Tracking issue for a third-party dns/deploy/notify api:
// no upgrade boilerplate; assign it to the opener and label it.
await github.rest.issues.addAssignees({
From cf3eab95ee85fb38fa8e62e1f4c9b35eee14c789 Mon Sep 17 00:00:00 2001
From: lwohn-creo
Date: Fri, 10 Jul 2026 04:07:12 +0200
Subject: [PATCH 139/224] Add creoline API as DNS provider (#7100)
* New Banner
Updated README to include responsive images for dark and light modes.
* acme-sh-creoline-as-dns-provider
* acme-sh-creoline-as-dns-provider - Review changes implemented according code review
* acme-sh-creoline-as-dns-provider - Review changes implemented according second code review, minding --cron
* acme-sh-creoline-as-dns-provider - Remove debug code
* acme-sh-creoline-as-dns-provider - shfmt formatting according Code of conduct
---------
Co-authored-by: neil
Co-authored-by: ZeroSSL-Andreas
Co-authored-by: Steven Kauschke
---
dnsapi/dns_creoline.sh | 181 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 181 insertions(+)
create mode 100644 dnsapi/dns_creoline.sh
diff --git a/dnsapi/dns_creoline.sh b/dnsapi/dns_creoline.sh
new file mode 100644
index 00000000..9d04af4f
--- /dev/null
+++ b/dnsapi/dns_creoline.sh
@@ -0,0 +1,181 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_creoline_info='creoline
+Site: https://www.creoline.com/de
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_creoline
+Help: https://help.creoline.com
+Options:
+ creolineApiToken
+ creolineApiSecret
+Issues: github.com/acmesh-official/acme.sh/issues/7103
+'
+
+creolineApi="https://api.creoline.com/v1"
+
+######## Public functions #####################
+
+# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPB8"
+dns_creoline_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ creolineApiToken="${creolineApiToken:-$(_readaccountconf_mutable creolineApiToken)}"
+ creolineApiSecret="${creolineApiSecret:-$(_readaccountconf_mutable creolineApiSecret)}"
+
+ if [ -z "$creolineApiToken" ] || [ -z "$creolineApiSecret" ]; then
+ _err "Error required creoline API Token or creoline API Secret not specified."
+ _err "Please set it with the Command 'export creolineApiToken=' and 'export creolineApiSecret='."
+ return 1
+ else
+ _saveaccountconf_mutable creolineApiToken "$creolineApiToken"
+ _saveaccountconf_mutable creolineApiSecret "$creolineApiSecret"
+ fi
+
+ _debug "Detecting the root dns zone."
+ if ! _get_root "$fulldomain"; then
+ _err "Error on detecting the root dns zone."
+ return 1
+ fi
+
+ _info "Adding record"
+ if _creoline_rest POST "dns/zone/$_domain/record" "{\"type\":\"TXT\",\"host\":\"$_sub_domain\",\"record\":\"$txtvalue\",\"ttl\":\"60\"}"; then
+ if _contains "$response" "$txtvalue"; then
+ _info "Added, OK"
+ return 0
+ else
+ _err "Add txt record error."
+ return 1
+ fi
+ fi
+ _err "Add txt record error."
+ return 1
+}
+
+#fulldomain txtvalue
+dns_creoline_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ creolineApiToken="${creolineApiToken:-$(_readaccountconf_mutable creolineApiToken)}"
+ creolineApiSecret="${creolineApiSecret:-$(_readaccountconf_mutable creolineApiSecret)}"
+
+ _debug "Detecting the root dns zone."
+ if ! _get_root "$fulldomain"; then
+ _err "Error on detecting the root dns zone."
+ return 1
+ fi
+
+ _info "Getting earlier created txt record."
+ if ! _creoline_rest GET "dns/zone/$_domain/record/type/TXT/record/$txtvalue"; then
+ if _contains "$response" "errors" || _contains "$response" "message"; then
+ _err "Error on getting earlier created txt record."
+ return 1
+ fi
+ _err "Error on getting earlier created txt record."
+ return 1
+ fi
+
+ record_id=$(echo "$response" | _egrep_o "\"id\"[[:space:]]*:[[:space:]]*[0-9]+" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _debug "record_id" "$record_id"
+
+ if [ -z "$record_id" ]; then
+ _err "Error on deleting earlier created txt record. No record id found in response."
+ return 1
+ fi
+
+ _info "Deleting earlier created txt record."
+ if ! _creoline_rest DELETE "dns/zone/$_domain/record/$record_id"; then
+ if _contains "$response" "errors" || _contains "$response" "message"; then
+ _err "Error on deleting earlier created txt record."
+ return 1
+ fi
+ _err "Error on deleting earlier created txt record."
+ return 1
+ fi
+
+ _info "Deleted, OK"
+ return 0
+}
+
+#################### Private functions below ##################################
+#_acme-challenge.www.domain.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+_get_root() {
+ domain=$1
+ if ! _creoline_rest GET "dns/zone/root/$domain"; then
+ return 1
+ fi
+
+ _sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _debug _sub_domain "$_sub_domain"
+
+ _domain=$(echo "$response" | _egrep_o "\"domain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _debug _domain "$_domain"
+
+ if [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then
+ return 1
+ fi
+}
+
+_creoline_rest() {
+ method=$1
+ uri="$2"
+ data="$3"
+ timestamp=$(_time)
+ canonical_request="${timestamp}.${creolineApi}/${uri}"
+ signature_hash=$(printf "%s" "$canonical_request" | _hmac sha256 "$(printf "%s" "$creolineApiSecret" | _hex_dump | tr -d " ")" hex)
+
+ _debug method "$method"
+ _debug uri "$uri"
+ _debug data "$data"
+
+ _debug2 timestamp "$timestamp"
+ _debug2 canonical_request "$canonical_request"
+ _debug2 signature_hash "$signature_hash"
+
+ token_trimmed=$(echo "$creolineApiToken" | tr -d '"')
+ hmac_trimmed=$(echo "$signature_hash" | tr -d '"')
+
+ export _H1="Content-Type: application/json"
+
+ if [ "$token_trimmed" ]; then
+ export _H2="X-Api-Token: $token_trimmed"
+ fi
+
+ if [ "$hmac_trimmed" ]; then
+ export _H3="X-Creoline-Api-Signature: $hmac_trimmed"
+ fi
+
+ if [ "$timestamp" ]; then
+ export _H4="X-Creoline-Api-Timestamp: $timestamp"
+ fi
+
+ if [ "$method" != "GET" ]; then
+ response="$(_post "$data" "$creolineApi/$uri" "" "$method")"
+ else
+ response="$(_get "$creolineApi/$uri")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "error $uri"
+ return 1
+ fi
+
+ _debug response "$response"
+
+ if _contains "$response" "errors"; then
+ error=$(echo "$response" | _egrep_o "\"errors\":[[]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | tr -d "[")
+ _err "Error: $error"
+ _err "URI:$uri"
+ return 1
+ elif _contains "$response" "message"; then
+ message=$(echo "$response" | _egrep_o "\"message\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \")
+ _err "Error: $message"
+ _err "URI:$uri"
+ return 1
+ fi
+
+ return 0
+}
From 1324dcd4720274ac328165a54a06f0055b4e93e4 Mon Sep 17 00:00:00 2001
From: invario <67800603+invario@users.noreply.github.com>
Date: Thu, 9 Jul 2026 19:10:44 -0700
Subject: [PATCH 140/224] Docker: update crontab used (#7111)
Signed-off-by: invario <67800603+invario@users.noreply.github.com>
---
Dockerfile | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 55a9cc67..229e4830 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -81,8 +81,8 @@ if [ \"\$1\" = \"daemon\" ]; then \n \
echo \"\$LE_CONFIG_HOME/crontab not found, generating one\" \n \
time=\$(date -u \"+%s\") \n \
random_minute=\$((\$time % 60)) \n \
- random_hour=\$((\$time / 60 % 24)) \n \
- echo \"\$random_minute \$random_hour * * * \\\"\$LE_WORKING_DIR\\\"/acme.sh --cron --home \\\"\$LE_WORKING_DIR\\\" --config-home \\\"\$LE_CONFIG_HOME\\\"\" > \"\$LE_CONFIG_HOME\"/crontab \n \
+ random_hour=\$((\$time / 60 % 6)) \n \
+ echo \"\$random_minute \$random_hour,\$((\$random_hour + 6)),\$((\$random_hour + 12)),\$((\$random_hour + 18)) * * * \\\"\$LE_WORKING_DIR\\\"/acme.sh --cron --home \\\"\$LE_WORKING_DIR\\\" --config-home \\\"\$LE_CONFIG_HOME\\\"\" > \"\$LE_CONFIG_HOME\"/crontab \n \
fi \n \
echo \"Running Supercronic using crontab at \$LE_CONFIG_HOME/crontab\" \n \
exec -- /usr/bin/supercronic \"\$LE_CONFIG_HOME/crontab\" \n \
From bed15ba8447e93c3052442900955894d79d8daf7 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 10:23:45 +0800
Subject: [PATCH 141/224] dns_freedns.sh: use grep -E, BRE \| alternation is a
GNU extension
OpenBSD grep treats \| in a BRE as a literal | character, so
_freedns_domain_id never matched any row and every domain lookup
failed with "Domain not found". Switch to ERE with -E, keeping the
parens escaped so the (.*) suffix branch still requires literal
parentheses and does not widen the match (e.g. searching example.com
must not match example.company).
Reported-by: @katiekloss @boretom
Ref: https://github.com/acmesh-official/acme.sh/issues/2305
---
dnsapi/dns_freedns.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_freedns.sh b/dnsapi/dns_freedns.sh
index 13d9f68b..8ea86c24 100755
--- a/dnsapi/dns_freedns.sh
+++ b/dnsapi/dns_freedns.sh
@@ -305,7 +305,7 @@ _freedns_domain_id() {
fi
domain_id="$(echo "$htmlpage" | tr -d " \t\r\n\v\f" | sed 's//@ /g' | tr '@' '\n' |
- grep "$search_domain \|$search_domain(.*) " |
+ grep -E "$search_domain |$search_domain\(.*\) " |
sed -n 's/.*\(edit\.php?edit_domain_id=[0-9a-zA-Z]*\).*/\1/p' |
cut -d = -f 2)"
# The above beauty extracts domain ID from the html page...
From 83b52e0cd77e3fd189e1508dde9da08b9f50b94f Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 10:38:52 +0800
Subject: [PATCH 142/224] notify/smtp.sh: add --crlf so curl sends CRLF line
endings
Postfix with smtpd_forbid_bare_newline (default hardening since 3.9,
after SMTP smuggling) rejects the message with
"521 5.5.2 Error: bare received". RFC 5321 requires CRLF.
The python sender is unaffected (smtplib already emits CRLF).
fix https://github.com/acmesh-official/acme.sh/issues/7104
---
notify/smtp.sh | 1 +
1 file changed, 1 insertion(+)
diff --git a/notify/smtp.sh b/notify/smtp.sh
index f5ebebca..a7318692 100644
--- a/notify/smtp.sh
+++ b/notify/smtp.sh
@@ -200,6 +200,7 @@ _smtp_send_curl() {
set -- "$@" \
--upload-file - \
+ --crlf \
--mail-from "$SMTP_FROM" \
--max-time "$SMTP_TIMEOUT"
From 45c0ad4112557599d5dd1725c7c6048efa563fd1 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 11:05:40 +0800
Subject: [PATCH 143/224] Add _cleardeployconf to clear deploy hook keys from
domain conf
Mirrors _clearaccountconf_mutable: clears the SAVED_ prefixed key and
the legacy unprefixed key. Replaces the local copy in synology_dsm.sh
and the direct _cleardomainconf call in multideploy.sh.
Closes #4722. Thanks to @sg1888.
---
acme.sh | 7 +++++++
deploy/multideploy.sh | 2 +-
deploy/synology_dsm.sh | 5 -----
3 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/acme.sh b/acme.sh
index 4f1c0336..b072c6cf 100755
--- a/acme.sh
+++ b/acme.sh
@@ -2594,6 +2594,13 @@ _savedeployconf() {
_cleardomainconf "$1"
}
+#key
+_cleardeployconf() {
+ _cleardomainconf "SAVED_$1"
+ #remove later
+ _cleardomainconf "$1"
+}
+
#key
_getdeployconf() {
_rac_key="$1"
diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh
index ef920f64..375668ec 100644
--- a/deploy/multideploy.sh
+++ b/deploy/multideploy.sh
@@ -210,7 +210,7 @@ _clear_envs() {
echo "$env_pairs" | while IFS='=' read -r _key _value; do
_debug3 "Deleting key" "$_key"
- _cleardomainconf "SAVED_$_key"
+ _cleardeployconf "$_key"
unset -v "$_key"
done
}
diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh
index 502bc59b..202e8188 100644
--- a/deploy/synology_dsm.sh
+++ b/deploy/synology_dsm.sh
@@ -424,11 +424,6 @@ _temp_admin_cleanup() {
fi
}
-#_cleardeployconf key
-_cleardeployconf() {
- _cleardomainconf "SAVED_$1"
-}
-
# key
_check2cleardeployconfexp() {
_key="$1"
From 534a1714dcbb79699407a4f9e0f2137ca92b1eb7 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 11:20:08 +0800
Subject: [PATCH 144/224] dns_me.sh: use LC_ALL=C so the request date header is
always English
LC_ALL in the environment overrides both LC_TIME and LANG, so LANG=C
alone still produced localized day/month names on non-English systems
and DNS Made Easy rejected the request date header. An LC_ALL=C
command prefix beats every locale variable (same pattern as
dns_oci.sh).
Fixes #4272. Closes #4271. Thanks to @Nickinthebox.
---
dnsapi/dns_me.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_me.sh b/dnsapi/dns_me.sh
index 163fe8db..0966c5f1 100644
--- a/dnsapi/dns_me.sh
+++ b/dnsapi/dns_me.sh
@@ -140,7 +140,7 @@ _me_rest() {
data="$3"
_debug "$ep"
- cdate=$(LANG=C date -u +"%a, %d %b %Y %T %Z")
+ cdate=$(LC_ALL=C date -u +"%a, %d %b %Y %T %Z")
hmac=$(printf "%s" "$cdate" | _hmac sha1 "$(printf "%s" "$ME_Secret" | _hex_dump | tr -d " ")" hex)
export _H1="x-dnsme-apiKey: $ME_Key"
From 90b4795bb1a75712258b523d7bcec9fdd3e2c635 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 11:22:20 +0800
Subject: [PATCH 145/224] issue: strip the trailing dot of a fully-qualified
alias domain
A trailing dot in --domain-alias/--challenge-alias was passed through
to the dnsapi hook verbatim. Providers with exact-match record-name
lookups (e.g. Cloudflare's name= filter) then never find the record,
so rm never deletes it and relic TXT records accumulate on every issue.
Stripping in issue() also fixes certs with a dotted alias already
saved in domain.conf.
fix https://github.com/acmesh-official/acme.sh/issues/4636
---
acme.sh | 2 ++
1 file changed, 2 insertions(+)
diff --git a/acme.sh b/acme.sh
index b072c6cf..84b38068 100755
--- a/acme.sh
+++ b/acme.sh
@@ -5350,6 +5350,8 @@ $_authorizations_map"
fi
_d_alias="$(_getfield "$_challenge_alias" "$_alias_index")"
test "$_d_alias" = "$NO_VALUE" && _d_alias=""
+ # strip the trailing dot of a fully-qualified alias domain
+ _d_alias="${_d_alias%.}"
_alias_index="$(_math "$_alias_index" + 1)"
_debug "_d_alias" "$_d_alias"
if [ "$_d_alias" ]; then
From fa763db1051e15978aa11b5e8721e7c82ae3314e Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 12:05:28 +0800
Subject: [PATCH 146/224] dns_pleskxml.sh: use grep -F when matching
interpolated values
fulldomain/txtvalue/root_domain_name were interpolated into grep
regex patterns; match them as fixed strings instead.
from https://github.com/acmesh-official/acme.sh/pull/7031
---
dnsapi/dns_pleskxml.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/dnsapi/dns_pleskxml.sh b/dnsapi/dns_pleskxml.sh
index 465bcc60..176f329d 100644
--- a/dnsapi/dns_pleskxml.sh
+++ b/dnsapi/dns_pleskxml.sh
@@ -151,8 +151,8 @@ dns_pleskxml_rm() {
# Extracting the id of the TXT record for the full domain (NOT case-sensitive) and corresponding value
recid="$(
_value "$reclist" |
- grep -i "${fulldomain}. " |
- grep "${txtvalue} " |
+ grep -Fi "${fulldomain}. " |
+ grep -F "${txtvalue} " |
sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/'
)"
@@ -419,7 +419,7 @@ _pleskxml_get_root_domain() {
_debug "Checking if '$root_domain_name' is managed by the Plesk server..."
- root_domain_id="$(_value "$output" | grep "$root_domain_name " | _head_n 1 | sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/')"
+ root_domain_id="$(_value "$output" | grep -F "$root_domain_name " | _head_n 1 | sed 's/^.*\([0-9]\{1,\}\)<\/id>.*$/\1/')"
if [ -n "$root_domain_id" ]; then
# Found a match
From 2af543a358a1ac1cdb316d8d551db6bd41a99550 Mon Sep 17 00:00:00 2001
From: Jan Pieper
Date: Fri, 10 Jul 2026 08:42:57 +0200
Subject: [PATCH 147/224] Fix typo (#6924)
---
deploy/synology_dsm.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh
index 202e8188..e19024c7 100644
--- a/deploy/synology_dsm.sh
+++ b/deploy/synology_dsm.sh
@@ -72,7 +72,7 @@ synology_dsm_deploy() {
if [ -n "$SYNO_USE_TEMP_ADMIN" ]; then
if ! _exists synouser || ! _exists synogroup || ! _exists synosetkeyvalue; then
- _err "Missing required tools to creat temp admin user, please set SYNO_USERNAME and SYNO_PASSWORD instead."
+ _err "Missing required tools to create temp admin user, please set SYNO_USERNAME and SYNO_PASSWORD instead."
_err "Notice: temp admin user authorization method only supports local deployment on DSM."
return 1
fi
From 660a5e322c24c4975bb416a52de288474ae4aabf Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 18:37:13 +0800
Subject: [PATCH 148/224] deploy/synology_dsm.sh: use grep -Eo '[0-9]+' when
extracting error codes
grep -o '[0-9]*' can match the empty string; GNU grep skips empty
matches but BSD greps handle them differently, breaking the 2FA
login flow on OpenBSD. Force a non-empty match at all three sites.
from https://github.com/acmesh-official/acme.sh/pull/6725
---
deploy/synology_dsm.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh
index e19024c7..75497671 100644
--- a/deploy/synology_dsm.sh
+++ b/deploy/synology_dsm.sh
@@ -234,7 +234,7 @@ synology_dsm_deploy() {
fi
fi
- error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
+ error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+')
_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
@@ -269,7 +269,7 @@ synology_dsm_deploy() {
_secure_debug2 SYNO_DEVICE_ID "$SYNO_DEVICE_ID"
fi
fi
- error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
+ error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+')
_debug2 error_code "$error_code"
fi
@@ -336,7 +336,7 @@ synology_dsm_deploy() {
id=$(echo "$response" | sed -n "s/.*\"desc\":\"$escaped_certificate\",\"id\":\"\([^\"]*\).*/\1/p")
_debug2 id "$id"
- error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -o '[0-9]*')
+ error_code=$(echo "$response" | grep '"error":' | grep -o '"code":[0-9]*' | grep -Eo '[0-9]+')
_debug2 error_code "$error_code"
if [ -n "$error_code" ]; then
if [ "$error_code" -eq 105 ]; then
From 2058a77d83c0211e5a5b09cc2924eae6a944b3c3 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 19:37:36 +0800
Subject: [PATCH 149/224] acme.sh: fix variable name typo _excapedAlgnames ->
_escapedAltnames
from https://github.com/acmesh-official/acme.sh/pull/6547
---
acme.sh | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/acme.sh b/acme.sh
index 84b38068..6495a90e 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1443,13 +1443,13 @@ _readSubjectAltNamesFromCSR() {
_debug _dnsAltnames "$_dnsAltnames"
# escape the wildcard '*' so it is not taken as a regex operator by grep/sed below
- _excapedAlgnames="$(echo "$_dnsAltnames" | tr '*' '#')"
- _debug _excapedAlgnames "$_excapedAlgnames"
+ _escapedAltnames="$(echo "$_dnsAltnames" | tr '*' '#')"
+ _debug _escapedAltnames "$_escapedAltnames"
_escapedSubject="$(echo "$_csrsubj" | tr '*' '#')"
_debug _escapedSubject "$_escapedSubject"
- if _contains "$_excapedAlgnames," "DNS:$_escapedSubject,"; then
+ if _contains "$_escapedAltnames," "DNS:$_escapedSubject,"; then
_debug "AltNames contains subject"
- _dnsAltnames="$(echo "$_excapedAlgnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")"
+ _dnsAltnames="$(echo "$_escapedAltnames," | sed "s/DNS:$_escapedSubject,//g" | tr '#' '*' | sed "s/,\$//g")"
_debug _dnsAltnames "$_dnsAltnames"
else
_debug "AltNames doesn't contain subject"
From 98c30912fb5a58f3a50b6263ec0e30574c5ef8c1 Mon Sep 17 00:00:00 2001
From: "Andrew V."
Date: Fri, 10 Jul 2026 15:36:42 +0300
Subject: [PATCH 150/224] 2024-12-24 - Ensure that $PDNS_Url has no trailing
slash ('/'). (#6171)
---
dnsapi/dns_pdns.sh | 3 +++
1 file changed, 3 insertions(+)
diff --git a/dnsapi/dns_pdns.sh b/dnsapi/dns_pdns.sh
index ec19ad25..847a1af1 100755
--- a/dnsapi/dns_pdns.sh
+++ b/dnsapi/dns_pdns.sh
@@ -50,6 +50,9 @@ dns_pdns_add() {
PDNS_Ttl="$DEFAULT_PDNS_TTL"
fi
+ # Ensure PDNS_Url has no trailing slash ('/')
+ PDNS_Url="${PDNS_Url%/}"
+
#save the api addr and key to the account conf file.
_saveaccountconf_mutable PDNS_Url "$PDNS_Url"
_saveaccountconf_mutable PDNS_ServerId "$PDNS_ServerId"
From 58cd667d6573262eb1061b51a18b21f2721e4fb0 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 10 Jul 2026 20:47:20 +0800
Subject: [PATCH 151/224] dns_njalla.sh: accept string record ids when removing
records
The Njalla API returns record ids as JSON strings now; the numeric-only
pattern matched nothing, so the removal never found the record id.
Match both quoted and bare ids.
from https://github.com/acmesh-official/acme.sh/pull/5121
---
dnsapi/dns_njalla.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_njalla.sh b/dnsapi/dns_njalla.sh
index c410447d..6ce51380 100644
--- a/dnsapi/dns_njalla.sh
+++ b/dnsapi/dns_njalla.sh
@@ -98,7 +98,7 @@ dns_njalla_rm() {
echo "$records" | while read -r record; do
record_name=$(echo "$record" | _egrep_o "\"name\":\s?\"[^\"]*\"" | cut -d : -f 2 | tr -d " " | tr -d \")
record_content=$(echo "$record" | _egrep_o "\"content\":\s?\"[^\"]*\"" | cut -d : -f 2 | tr -d " " | tr -d \")
- record_id=$(echo "$record" | _egrep_o "\"id\":\s?[0-9]+" | cut -d : -f 2 | tr -d " " | tr -d \")
+ record_id=$(echo "$record" | _egrep_o "\"id\":\s?\"?[^\",}]*" | cut -d : -f 2 | tr -d " " | tr -d \")
if [ "$_sub_domain" = "$record_name" ]; then
if [ "$txtvalue" = "$record_content" ]; then
_debug "record_id" "$record_id"
From ebde8345ae154e621087341eecd691b26bbc555b Mon Sep 17 00:00:00 2001
From: CV
Date: Fri, 10 Jul 2026 14:55:23 +0200
Subject: [PATCH 152/224] dns_ispconfig.sh client_id not numeric at ispconfig v
3.2.7p1 (#4999)
Getting client_id failed due to incorrect extraction!
At least in version 3.2.7p1 and probably later the plugin is not working any more properly. The result of ```curResult="$(_post "${curData}" "${ISPC_Api}?client_get_id")"``` is something like this ```Result of _ISPC_ClientGetID: '[Tue Jan 23 11:44:57 CET 2024] Retrying post
{"code":"ok","message":"","response":3}[Tue Jan 23 11:44:57 CET 2024] _hcode 0'```. The parsing code does not work properly and leaves a non numeric value such as ```Client ID: '3[Tue Jan 23 11'```.
---
dnsapi/dns_ispconfig.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_ispconfig.sh b/dnsapi/dns_ispconfig.sh
index edc789e1..bd6bfb28 100755
--- a/dnsapi/dns_ispconfig.sh
+++ b/dnsapi/dns_ispconfig.sh
@@ -136,7 +136,7 @@ _ISPC_getZoneInfo() {
curResult="$(_post "${curData}" "${ISPC_Api}?client_get_id")"
_debug "Calling _ISPC_ClientGetID: '${curData}' '${ISPC_Api}?client_get_id'"
_debug "Result of _ISPC_ClientGetID: '$curResult'"
- client_id=$(echo "${curResult}" | _egrep_o "response.*" | cut -d ':' -f 2 | cut -d '"' -f 2 | tr -d '{}')
+ client_id=$(echo "${curResult}" | _egrep_o "response.*" | cut -d ':' -f 2 | cut -d '"' -f 2 | cut -d '[' -f 1 | tr -d '{}')
_debug "Client ID: '${client_id}'"
case "${client_id}" in
'' | *[!0-9]*)
From 50e5e771d56a5989e0bc24df3b422059df63669b Mon Sep 17 00:00:00 2001
From: Roman Lumetsberger
Date: Fri, 10 Jul 2026 13:02:06 +0000
Subject: [PATCH 153/224] Feature: Support other shells then sh (#4877)
* Add support for DEPLOY_SSH_REMOTE_SHELL
* allow to configure quoting of remote cmd string
* shell check and shellfmt fixes
---
deploy/ssh.sh | 31 +++++++++++++++++++++++++++----
1 file changed, 27 insertions(+), 4 deletions(-)
diff --git a/deploy/ssh.sh b/deploy/ssh.sh
index 82b0382c..0bf3ee48 100644
--- a/deploy/ssh.sh
+++ b/deploy/ssh.sh
@@ -25,7 +25,8 @@
# export DEPLOY_SSH_MULTI_CALL="" # yes or no, default to no or previously saved value
# export DEPLOY_SSH_USE_SCP="" yes or no, default to no
# export DEPLOY_SSH_SCP_CMD="" defaults to "scp -q"
-#
+# export DEPLOY_SSH_REMOTE_SHELL="" # defaults to sh -c
+# export DEPLOY_SSH_REMOTE_CMD_QUOTE="" # yes or no, defaults to yes
######## Public functions #####################
#domain keyfile certfile cafile fullchain
@@ -71,6 +72,24 @@ ssh_deploy() {
fi
_savedeployconf DEPLOY_SSH_CMD "$DEPLOY_SSH_CMD"
+ # REMOTE_SHELL is optional. If not provided then use sh
+ _migratedeployconf Le_Deploy_ssh_remote_shell DEPLOY_SSH_REMOTE_SHELL
+ _getdeployconf DEPLOY_SSH_REMOTE_SHELL
+ _debug2 DEPLOY_SSH_REMOTE_SHELL "$DEPLOY_SSH_REMOTE_SHELL"
+ if [ -z "$DEPLOY_SSH_REMOTE_SHELL" ]; then
+ DEPLOY_SSH_REMOTE_SHELL="sh -c"
+ fi
+ _savedeployconf DEPLOY_SSH_REMOTE_SHELL "$DEPLOY_SSH_REMOTE_SHELL"
+
+ # REMOTE_CMD_QUOTE is optional. If not provided then yes
+ _migratedeployconf Le_Deploy_ssh_remote_cmd_quote DEPLOY_SSH_REMOTE_CMD_QUOTE
+ _getdeployconf DEPLOY_SSH_REMOTE_CMD_QUOTE
+ _debug2 DEPLOY_SSH_REMOTE_CMD_QUOTE "$DEPLOY_SSH_REMOTE_CMD_QUOTE"
+ if [ -z "$DEPLOY_SSH_REMOTE_CMD_QUOTE" ]; then
+ DEPLOY_SSH_REMOTE_CMD_QUOTE="yes"
+ fi
+ _savedeployconf DEPLOY_SSH_REMOTE_CMD_QUOTE "$DEPLOY_SSH_REMOTE_CMD_QUOTE"
+
# BACKUP is optional. If not provided then default to previously saved value or yes.
_migratedeployconf Le_Deploy_ssh_backup DEPLOY_SSH_BACKUP
_getdeployconf DEPLOY_SSH_BACKUP
@@ -434,9 +453,13 @@ _ssh_remote_cmd() {
_secure_debug "Remote commands to execute: $_cmd"
_info "Submitting sequence of commands to remote server by $_ssh_cmd"
- # quotations in bash cmd below intended. Squash travis spellcheck error
- # shellcheck disable=SC2029
- $_ssh_cmd "$DEPLOY_SSH_USER@$_host" sh -c "'$_cmd'"
+ if [ "$DEPLOY_SSH_REMOTE_CMD_QUOTE" = "yes" ]; then
+ # quotations in bash cmd below intended. Squash travis spellcheck error
+ # shellcheck disable=SC2029
+ $_ssh_cmd "$DEPLOY_SSH_USER@$_host" "$DEPLOY_SSH_REMOTE_SHELL" "'$_cmd'"
+ else
+ $_ssh_cmd "$DEPLOY_SSH_USER@$_host" "$DEPLOY_SSH_REMOTE_SHELL" "$_cmd"
+ fi
_err_code="$?"
if [ "$_err_code" != "0" ]; then
From fef90e15e131b8fa2f47dad471aa37dcdba4f772 Mon Sep 17 00:00:00 2001
From: Mike Lei
Date: Fri, 10 Jul 2026 21:56:54 +0800
Subject: [PATCH 154/224] Fix name.com DNS API for processing IDNs (#4381)
---
dnsapi/dns_namecom.sh | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/dnsapi/dns_namecom.sh b/dnsapi/dns_namecom.sh
index 1ba6a6e5..bd7da0c2 100755
--- a/dnsapi/dns_namecom.sh
+++ b/dnsapi/dns_namecom.sh
@@ -15,7 +15,7 @@ Namecom_API="https://api.name.com/v4"
#Usage: dns_namecom_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_namecom_add() {
- fulldomain=$1
+ fulldomain=$(_idn "$1")
txtvalue=$2
Namecom_Username="${Namecom_Username:-$(_readaccountconf_mutable Namecom_Username)}"
@@ -68,7 +68,7 @@ dns_namecom_add() {
#Usage: fulldomain txtvalue
#Remove the txt record after validation.
dns_namecom_rm() {
- fulldomain=$1
+ fulldomain=$(_idn "$1")
txtvalue=$2
Namecom_Username="${Namecom_Username:-$(_readaccountconf_mutable Namecom_Username)}"
From 2e4acba1055ea7b82e79f4f0ac2c7264796f7b00 Mon Sep 17 00:00:00 2001
From: Sasha Reid
Date: Sat, 11 Jul 2026 04:04:49 +0200
Subject: [PATCH 155/224] Microwavenby dns hostinger (#6843)
* [Microwavenby--dns_hostinger] Adding initial dns support for Hostinger.com
* [Microwavenby--dns_hostinger] Creating a commit now that workflows are enabled
* [Microwavenby--dns_hostinger] Correcting shellcheck. Why is this not automatic?
* [Microwavenby-dns-hostinger] Responding to comments from Neil
* [dns-hostinger] SHfmt and Shellcheck
* [dns-hostinger] Writing non-greedy-ish regexes. correcting copypasta
---
dnsapi/dns_hostinger.sh | 196 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 196 insertions(+)
create mode 100755 dnsapi/dns_hostinger.sh
diff --git a/dnsapi/dns_hostinger.sh b/dnsapi/dns_hostinger.sh
new file mode 100755
index 00000000..665c65da
--- /dev/null
+++ b/dnsapi/dns_hostinger.sh
@@ -0,0 +1,196 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_hostinger_info='Hostinger
+Site: Hostinger.com
+Domains: hostinger.nl
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hostinger
+Options:
+ HOSTINGER_Token API Key
+Issues: https://github.com/acmesh-official/acme.sh/issues/6831
+Author: Sasha Reid
+'
+
+HOSTINGER_Api="https://developers.hostinger.com/api/dns/v1/zones"
+
+######## Public functions #####################
+
+#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_hostinger_add() {
+ fulldomain=$1
+ txtvalue=$2
+
+ HOSTINGER_Token="${HOSTINGER_Token:-$(_readaccountconf_mutable HOSTINGER_Token)}"
+
+ if [ -z "$HOSTINGER_Token" ]; then
+ HOSTINGER_Token=""
+ _err "You didn't specify a Hostinger API Key yet."
+ _err "Please read the documentation for the Hostinger API authentication at https://developers.hostinger.com/#description/authentication"
+ return 1
+ fi
+ _saveaccountconf_mutable HOSTINGER_Token "$HOSTINGER_Token"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _debug "Getting existing records"
+ _hostinger_rest GET "${_domain}"
+
+ if [ -z "$response" ]; then
+ _err "Error"
+ return 1
+ fi
+
+ # For wildcard cert, the main root domain and the wildcard domain have the same txt subdomain name, so
+ # we can not use updating anymore.
+ # count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
+ # _debug count "$count"
+ # if [ "$count" = "0" ]; then
+ _info "Adding record"
+ if _hostinger_rest PUT "$_domain" "{\"zone\":[{\"name\": \"$_sub_domain\",\"records\": [{\"content\":\"$txtvalue\"}],\"type\":\"TXT\",\"ttl\":\"120\"}],\"overwrite\":false}"; then
+ if _contains "$response" "Request accepted"; then
+ _info "Added, OK"
+ return 0
+ elif _contains "$response" "DNS resource record is not valid or conflicts with another resource record" ||
+ _contains "$response" 'DNS:4008'; then
+ _info "Already exists, OK"
+ return 0
+ else
+ _err "Add txt record error."
+ return 1
+ fi
+ fi
+ _err "Add txt record error."
+ return 1
+
+}
+
+#fulldomain txtvalue
+dns_hostinger_rm() {
+ fulldomain=$1
+ txtvalue=$2
+
+ HOSTINGER_Token="${HOSTINGER_Token:-$(_readaccountconf_mutable HOSTINGER_Token)}"
+
+ if [ -z "$HOSTINGER_Token" ]; then
+ HOSTINGER_Token=""
+ _err "You didn't specify a Hostinger API Key yet."
+ _err "Please read the documentation for the Hostinger API authentication at https://developers.hostinger.com/#description/authentication"
+ return 1
+ fi
+ _saveaccountconf_mutable HOSTINGER_Token "$HOSTINGER_Token"
+
+ _debug "First detect the root zone"
+ if ! _get_root "$fulldomain"; then
+ _err "invalid domain"
+ return 1
+ fi
+ _debug _sub_domain "$_sub_domain"
+ _debug _domain "$_domain"
+
+ _debug "Getting existing records"
+ _hostinger_rest GET "${_domain}"
+
+ if [ -z "$response" ]; then
+ _err "Error"
+ return 1
+ fi
+
+ if _contains "$response" "\"name\":\"$_sub_domain\""; then
+ # Match the record, and make certain it is a TXT record for the domain not another type. Then remove our target record from the list
+ remaining_records=$(echo "$response" | _normalizeJson | _egrep_o '{"name":"'"$_sub_domain"'","records":\[[^]]+\],"ttl":[0-9]+,"type":"TXT"\}' | _egrep_o "\[.*\]" | sed -E 's#\{"content":"\\"'"$txtvalue"'\\"","is_disabled":false\},?##g')
+ if [ "$remaining_records" != "[]" ]; then
+ remaining_json=$(echo "$remaining_records" | _egrep_o '"content":"\\"[^}]+\\""' | sed -E 's/^(.*)$/{\1},/g' | tr -d '\n' | sed 's/,$//')
+ # We need to set the remaining records back to Hostinger, as we can't partially delete
+ _info "Removing $txtvalue from $_sub_domain by setting records to ${remaining_json}"
+ if _hostinger_rest PUT "$_domain" "{\"zone\":[{\"name\": \"$_sub_domain\",\"records\": [${remaining_json}],\"type\":\"TXT\",\"ttl\":\"120\"}],\"overwrite\":true}"; then
+ if _contains "$response" "Request accepted"; then
+ _info "Updated remaining records, OK"
+ return 0
+ elif _contains "$response" "DNS resource record is not valid or conflicts with another resource record" ||
+ _contains "$response" 'DNS:4008'; then
+ _info "Already exists, OK"
+ return 0
+ else
+ _err "Add txt record error."
+ return 1
+ fi
+ fi
+ # Otherwise delete the TXT record that matches the subdomain
+ else
+ if ! _hostinger_rest DELETE "$_domain" "{\"filters\":[{\"name\":\"$_sub_domain\",\"type\":\"TXT\"}]}"; then
+ _err "Delete record error."
+ return 1
+ fi
+ fi
+ echo "$response" | grep "Request accepted" >/dev/null
+ else
+ _info "Don't need to remove."
+ fi
+
+}
+
+#################### Private functions below ##################################
+#_acme-challenge.www.domain.com
+#returns
+# _sub_domain=_acme-challenge.www
+# _domain=domain.com
+_get_root() {
+ domain=$1
+ i=1
+ p=1
+
+ while true; do
+ h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
+ _debug h "$h"
+ if [ -z "$h" ]; then
+ #not valid
+ return 1
+ fi
+
+ _hostinger_rest GET "$h"
+ if _contains "$response" "records"; then
+ if [ "$response" = "[]" ]; then
+ _debug "Valid subdomains are not the root"
+ else
+ _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
+ _domain=$h
+ return 0
+ fi
+ fi
+
+ p=$i
+ i=$(_math "$i" + 1)
+ done
+ return 1
+}
+
+_hostinger_rest() {
+ m=$1
+ ep="$2"
+ data="$3"
+ _debug "$ep"
+
+ token_trimmed=$(echo "$HOSTINGER_Token" | tr -d '"')
+
+ export _H1="Content-Type: application/json"
+ export _H2="Authorization: Bearer $token_trimmed"
+
+ if [ "$m" != "GET" ]; then
+ _debug data "$data"
+ response="$(_post "$data" "$HOSTINGER_Api/$ep" "" "$m")"
+ else
+ response="$(_get "$HOSTINGER_Api/$ep")"
+ fi
+
+ if [ "$?" != "0" ]; then
+ _err "error $ep"
+ return 1
+ fi
+ _debug2 response "$response"
+ return 0
+}
From 9366c2e065d1ef006d18fe97965eb85b3a8f3a59 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 11 Jul 2026 11:42:32 +0800
Subject: [PATCH 156/224] dns_cpanel: resolve the most specific zone in
_get_root
With both domain.tld and sub.domain.tld zones on the account, the first
endswith hit could pick the parent zone while cPanel stores the record
in the most specific one, so the cleanup never found the record and
left an orphaned _acme-challenge TXT entry. Pick the longest matching
zone with an exact literal suffix match (_endswith treats the needle as
a regex, letting xdomain.tld wrongly match zone domain.tld).
https://github.com/acmesh-official/acme.sh/issues/6807
---
dnsapi/dns_cpanel.sh | 28 +++++++++++++++++++++-------
1 file changed, 21 insertions(+), 7 deletions(-)
diff --git a/dnsapi/dns_cpanel.sh b/dnsapi/dns_cpanel.sh
index a6991403..3868c679 100755
--- a/dnsapi/dns_cpanel.sh
+++ b/dnsapi/dns_cpanel.sh
@@ -38,7 +38,7 @@ dns_cpanel_add() {
fi
# adding entry
_info "Adding the entry"
- stripped_fulldomain=$(echo "$fulldomain" | sed "s/.$_domain//")
+ stripped_fulldomain="${fulldomain%.$_domain}"
_debug "Adding $stripped_fulldomain to $_domain zone"
_myget "json-api/cpanel?cpanel_jsonapi_apiversion=2&cpanel_jsonapi_module=ZoneEdit&cpanel_jsonapi_func=add_zone_record&domain=$_domain&name=$stripped_fulldomain&type=TXT&txtdata=$txtvalue&ttl=1"
if _successful_update; then return 0; fi
@@ -128,13 +128,27 @@ _get_root() {
_err "Primary domain list not found!"
return 1
fi
- for _domain in $_domains; do
- _debug "Checking if $fulldomain ends with $_domain"
- if (_endswith "$fulldomain" "$_domain"); then
- _debug "Root domain: $_domain"
- return 0
- fi
+ # Pick the LONGEST matching zone, dot-anchored: with both domain.tld and
+ # sub.domain.tld zones on the account, cPanel stores the record in the
+ # most specific zone, so add and rm must both resolve to that one.
+ _domain=""
+ for d in $_domains; do
+ _debug "Checking if $fulldomain ends with $d"
+ # case with quoted patterns gives an exact literal suffix match;
+ # _endswith treats the needle as a regex, so its dots would let
+ # xdomain.tld wrongly match zone domain.tld
+ case "$fulldomain" in
+ "$d" | *".$d")
+ if [ "${#d}" -gt "${#_domain}" ]; then
+ _domain="$d"
+ fi
+ ;;
+ esac
done
+ if [ -n "$_domain" ]; then
+ _debug "Root domain: $_domain"
+ return 0
+ fi
return 1
}
From 2215f1b988dd544f186b9ae05d0a7061bf92b80f Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 11 Jul 2026 11:56:58 +0800
Subject: [PATCH 157/224] notify: clear inherited _H1.._H5 before running each
notify hook
The dns/deploy hooks export _H1.._H5 in the main process, and the
notify hooks run in a subshell that inherits them. A hook that does
not overwrite every slot (ntfy without NTFY_TOKEN, slack, telegram,
etc.) sent the stale headers with its request, leaking another
service's Authorization credentials to the notify endpoint.
https://github.com/acmesh-official/acme.sh/issues/6801
---
acme.sh | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/acme.sh b/acme.sh
index 6495a90e..9ec92585 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7765,6 +7765,14 @@ _send_notify() {
continue
fi
if ! (
+ # The dns/deploy hooks export _H1.._H5 in the main process, so the
+ # values are inherited here. Clear them: a stale Authorization header
+ # from another service must not leak into the notify request.
+ export _H1=""
+ export _H2=""
+ export _H3=""
+ export _H4=""
+ export _H5=""
if ! . "$_n_hook_file"; then
_err "Error loading file $_n_hook_file. Please check your API file and try again."
return 1
From 04e0422526fc5723c3ce4fe88c4e39e9af8c7e2d Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 12 Jul 2026 09:30:46 +0800
Subject: [PATCH 158/224] precheck: log the socat recommendation with _info
instead of _err
Missing socat only matters for standalone mode; the text even says the
warning can be ignored. Printing it to stderr made every --upgrade in a
cron noisy for DNS-only users who redirect stdout.
https://github.com/acmesh-official/acme.sh/issues/6525
---
acme.sh | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index 9ec92585..04002193 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7420,9 +7420,9 @@ _precheck() {
fi
if ! _exists "socat" && ! _exists "python" && ! _exists "python2" && ! _exists "python3"; then
- _err "It is recommended to install socat or python first."
- _err "We use socat or python for the standalone server, which is used for standalone mode."
- _err "If you don't want to use standalone mode, you may ignore this warning."
+ _info "It is recommended to install socat or python first."
+ _info "We use socat or python for the standalone server, which is used for standalone mode."
+ _info "If you don't want to use standalone mode, you may ignore this warning."
fi
return 0
From 22a5ae3cebe762017baeb3e14f4b3c71d3db6a13 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 12 Jul 2026 09:34:59 +0800
Subject: [PATCH 159/224] dns_cloudns: include the server response in the login
error
https://github.com/acmesh-official/acme.sh/issues/6520
---
dnsapi/dns_cloudns.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_cloudns.sh b/dnsapi/dns_cloudns.sh
index 23a219da..2c543271 100755
--- a/dnsapi/dns_cloudns.sh
+++ b/dnsapi/dns_cloudns.sh
@@ -135,7 +135,7 @@ _dns_cloudns_init_check() {
_dns_cloudns_http_api_call "dns/login.json" ""
if ! _contains "$response" "\"status\":\"Success\""; then
- _err "Invalid CLOUDNS_AUTH_ID or CLOUDNS_AUTH_PASSWORD. Please check your login credentials."
+ _err "Invalid CLOUDNS_AUTH_ID or CLOUDNS_AUTH_PASSWORD. Server response: $response"
return 1
fi
From 76811857a09fe7d63036c05555630ff9a1901f13 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 12 Jul 2026 09:35:22 +0800
Subject: [PATCH 160/224] dns_cpanel: quote inner expansion in suffix strip
(SC2295)
---
dnsapi/dns_cpanel.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/dnsapi/dns_cpanel.sh b/dnsapi/dns_cpanel.sh
index 3868c679..6939c3f1 100755
--- a/dnsapi/dns_cpanel.sh
+++ b/dnsapi/dns_cpanel.sh
@@ -38,7 +38,7 @@ dns_cpanel_add() {
fi
# adding entry
_info "Adding the entry"
- stripped_fulldomain="${fulldomain%.$_domain}"
+ stripped_fulldomain="${fulldomain%."$_domain"}"
_debug "Adding $stripped_fulldomain to $_domain zone"
_myget "json-api/cpanel?cpanel_jsonapi_apiversion=2&cpanel_jsonapi_module=ZoneEdit&cpanel_jsonapi_func=add_zone_record&domain=$_domain&name=$stripped_fulldomain&type=TXT&txtdata=$txtvalue&ttl=1"
if _successful_update; then return 0; fi
From 7d0283ca2cced9d6eb9985e36de96d15e902b556 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 12 Jul 2026 10:26:40 +0800
Subject: [PATCH 161/224] 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 162/224] 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 163/224] 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 164/224] 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 165/224] 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 166/224] 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 167/224] 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 168/224] 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 169/224] 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 170/224] 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 171/224] 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 172/224] 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 237f2d9c3b3a54bd1cf7e5ce118de7ec4f9fc8ae Mon Sep 17 00:00:00 2001
From: Achmad Alif Nasrulloh
Date: Fri, 10 Jul 2026 18:06:13 +0700
Subject: [PATCH 173/224] 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..022d8916
--- /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 d45b6fe8e6b84a85e1a9bdb13ee6bbc6d647dc16 Mon Sep 17 00:00:00 2001
From: Achmad Alif Nasrulloh
Date: Sat, 11 Jul 2026 18:42:53 +0700
Subject: [PATCH 174/224] fix(notify): waha: tighten response check
---
notify/waha.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/notify/waha.sh b/notify/waha.sh
index 022d8916..989f57ac 100755
--- a/notify/waha.sh
+++ b/notify/waha.sh
@@ -65,7 +65,7 @@ waha_send() {
_waha_url="${WAHA_URL}/api/sendText"
response="$(_post "$_data" "$_waha_url" "" "POST" "application/json")"
- if [ "$?" = "0" ] && _contains "$response" "id"; then
+ if [ "$?" = "0" ] && _contains "$response" "\"id\""; then
_info "waha send success."
return 0
fi
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 175/224] 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 176/224] 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 177/224] 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 178/224] 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 179/224] 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 180/224] 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 181/224] 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 182/224] 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 183/224] 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 184/224] 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 185/224] 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 186/224] 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 187/224] 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 188/224] 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 189/224] 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 190/224] 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 191/224] 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 192/224] 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 193/224] 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 194/224] 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"
From a82cf763cfe8a69f8329f4f0d2cfc4b5a8cb6240 Mon Sep 17 00:00:00 2001
From: Achmad Alif Nasrulloh
Date: Thu, 16 Jul 2026 11:19:07 +0700
Subject: [PATCH 195/224] fix(notify): remove duplicate Content-Type header in
waha hook
---
notify/waha.sh | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/notify/waha.sh b/notify/waha.sh
index 989f57ac..573295e9 100755
--- a/notify/waha.sh
+++ b/notify/waha.sh
@@ -57,9 +57,8 @@ waha_send() {
_debug "_data" "$_data"
- export _H1="Content-Type: application/json"
if [ "$WAHA_API_KEY" ]; then
- export _H2="X-Api-Key: $WAHA_API_KEY"
+ export _H1="X-Api-Key: $WAHA_API_KEY"
fi
_waha_url="${WAHA_URL}/api/sendText"
From 6d559ae69f6b18bd1b4e0087dafba695acbbd933 Mon Sep 17 00:00:00 2001
From: ACHMAD ALIF NASRULLOH <106044706+achmadalifn4@users.noreply.github.com>
Date: Thu, 16 Jul 2026 11:29:39 +0700
Subject: [PATCH 196/224] Add newline at end of waha.sh
Fix missing newline at end of file.
---
notify/waha.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/notify/waha.sh b/notify/waha.sh
index bbb1dc44..573295e9 100755
--- a/notify/waha.sh
+++ b/notify/waha.sh
@@ -71,4 +71,4 @@ waha_send() {
_err "waha send error."
_err "$response"
return 1
-}
\ No newline at end of file
+}
From 745a42f1936cff1d9853ae7ee9d6e12c1f98d344 Mon Sep 17 00:00:00 2001
From: neil
Date: Thu, 16 Jul 2026 20:09:10 +0800
Subject: [PATCH 197/224] minor
---
.github/workflows/issue.yml | 45 ++++++++++++++++++++++++++++++++++++-
1 file changed, 44 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml
index 00e9ddc5..a25cd4ef 100644
--- a/.github/workflows/issue.yml
+++ b/.github/workflows/issue.yml
@@ -2,6 +2,8 @@ name: "Update issues"
on:
issues:
types: [opened]
+ issue_comment:
+ types: [created]
pull_request_target:
types: [opened]
@@ -32,6 +34,32 @@ jobs:
} catch (e) {
core.warning(`Failed to fetch the blacklist: ${e}`);
}
+ // A comment on a closed tracking issue reopens it (the standard
+ // closing note promises this). Bots, blacklisted users and the
+ // maintainer's own comments don't reopen.
+ if (context.eventName === "issue_comment") {
+ const issue = context.payload.issue;
+ const commenter = context.payload.comment.user;
+ if (issue.pull_request || issue.state !== "closed") {
+ return;
+ }
+ if (!/^report\s+(bugs?|issues?)\b/i.test(issue.title)) {
+ return;
+ }
+ if (commenter.type === "Bot" ||
+ commenter.login.toLowerCase() === "neilpang" ||
+ blacklist.includes(commenter.login.toLowerCase())) {
+ return;
+ }
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ state: "open"
+ });
+ return;
+ }
+
if (blacklist.includes(item.user.login.toLowerCase())) {
if (context.payload.pull_request) {
await github.rest.pulls.update({
@@ -63,7 +91,9 @@ jobs:
}
if (/^report\s+(bugs?|issues?)\b/i.test(issue.title)) {
// Tracking issue for a third-party dns/deploy/notify api:
- // no upgrade boilerplate; assign it to the opener and label it.
+ // no upgrade boilerplate; assign it to the opener, label it,
+ // then close it right away to keep the issue list clean. Any
+ // later comment reopens it (see the issue_comment handler).
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
@@ -76,6 +106,19 @@ jobs:
issue_number: issue.number,
labels: ["3rd party api"]
});
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ body: "Closing this tracking issue for now to keep the issue list clean. It remains the place to report problems with this provider -- if you hit a bug, comment here and the issue will be reopened."
+ });
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issue.number,
+ state: "closed",
+ state_reason: "completed"
+ });
return;
}
await github.rest.issues.createComment({
From a836e747d1aea2d64e393bb5046471325e6dfa3a Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 17 Jul 2026 10:10:07 +0800
Subject: [PATCH 198/224] start 3.1.5
---
acme.sh | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/acme.sh b/acme.sh
index a5f53469..5fa6882a 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
-VER=3.1.4
+VER=3.1.5
PROJECT_NAME="acme.sh"
From 59a97d7f8b765a9ff0a27bc408b939903b46e551 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 17 Jul 2026 12:38:14 +0800
Subject: [PATCH 199/224] fix bug for solaris. dnsapi/deploy: remove POSIX
character classes from sed/grep patterns
Solaris /usr/bin/sed and /usr/bin/grep parse [[:space:]] etc. as a
literal bracket set and silently mis-match. Replace with [ ]* for
JSON matching, a printf-tab bracket for user-input trimming, and
[0-9] for digits; also drop GNU-only sed -r/-E in rage4, selfhost
and selectel, and reuse _strip_blank_lines in byteplus_alb.
---
.github/workflows/vtag.yml | 32 ++++++++++++++++++++++++++++++++
acme.sh | 5 ++++-
deploy/byteplus_alb.sh | 4 ++--
dnsapi/dns_bhosted.sh | 6 +++---
dnsapi/dns_creoline.sh | 8 ++++----
dnsapi/dns_czechia.sh | 13 ++++++++-----
dnsapi/dns_hostup.sh | 8 ++++----
dnsapi/dns_infoblox_uddi.sh | 4 ++--
dnsapi/dns_poweradmin.sh | 2 +-
dnsapi/dns_rage4.sh | 2 +-
dnsapi/dns_selectel.sh | 2 +-
dnsapi/dns_selfhost.sh | 7 +++++--
dnsapi/dns_udr.sh | 4 ++--
dnsapi/dns_yandex360.sh | 4 ++--
14 files changed, 71 insertions(+), 30 deletions(-)
create mode 100644 .github/workflows/vtag.yml
diff --git a/.github/workflows/vtag.yml b/.github/workflows/vtag.yml
new file mode 100644
index 00000000..e9f7e8df
--- /dev/null
+++ b/.github/workflows/vtag.yml
@@ -0,0 +1,32 @@
+name: Mirror version tag
+
+# Historical release tags are plain version numbers ("3.1.3") and cannot be
+# renamed. When a plain version tag is pushed (including the tag created by
+# publishing a GitHub release), mirror it as a "v"-prefixed tag ("v3.1.3")
+# pointing to the same object, so both forms exist.
+# No retrigger loop: the tag filter never matches a "v"-prefixed tag, and
+# refs created with GITHUB_TOKEN do not fire workflows anyway.
+
+on:
+ push:
+ tags:
+ - '[0-9]*'
+
+permissions:
+ contents: write
+
+jobs:
+ vtag:
+ if: github.repository == 'acmesh-official/acme.sh'
+ runs-on: ubuntu-latest
+ steps:
+ - name: Create the v-prefixed tag
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ if gh api "repos/${{ github.repository }}/git/ref/tags/v${{ github.ref_name }}" >/dev/null 2>&1; then
+ echo "Tag v${{ github.ref_name }} already exists, nothing to do."
+ exit 0
+ fi
+ gh api "repos/${{ github.repository }}/git/refs" -f ref="refs/tags/v${{ github.ref_name }}" -f sha="${{ github.sha }}"
+ echo "Created tag v${{ github.ref_name }} -> ${{ github.sha }}"
diff --git a/acme.sh b/acme.sh
index 5fa6882a..753b1159 100755
--- a/acme.sh
+++ b/acme.sh
@@ -6078,7 +6078,10 @@ $_authorizations_map"
#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'
+ #spell out space and tab: Solaris sed treats [[:space:]] as a literal
+ #bracket set and silently stops matching the blank lines
+ _sbl_tab="$(printf '\t')"
+ sed "/^[ $_sbl_tab]*\$/d"
}
_split_cert_chain() {
diff --git a/deploy/byteplus_alb.sh b/deploy/byteplus_alb.sh
index 8443bb99..394b431f 100644
--- a/deploy/byteplus_alb.sh
+++ b/deploy/byteplus_alb.sh
@@ -163,8 +163,8 @@ byteplus_alb_deploy() {
# ── 3. Read cert and key ─────────────────────────────────────────────────────
# BytePlus requires NO blank lines between PEM blocks in the certificate chain
- _public_key=$(sed '/^[[:space:]]*$/d' "$_cfullchain" | tr -d '\r')
- _private_key=$(sed '/^[[:space:]]*$/d' "$_ckey" | tr -d '\r')
+ _public_key=$(_strip_blank_lines <"$_cfullchain" | tr -d '\r')
+ _private_key=$(_strip_blank_lines <"$_ckey" | tr -d '\r')
if [ -z "$_public_key" ] || [ -z "$_private_key" ]; then
_err "Failed to read certificate or key file."
diff --git a/dnsapi/dns_bhosted.sh b/dnsapi/dns_bhosted.sh
index 1493c60a..46ddd5dd 100644
--- a/dnsapi/dns_bhosted.sh
+++ b/dnsapi/dns_bhosted.sh
@@ -323,21 +323,21 @@ _bhosted_extract_id() {
fi
# JSON: "id":12345
- _id="$(printf "%s" "$_resp" | _egrep_o '"id"[[:space:]]*:[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
+ _id="$(printf "%s" "$_resp" | _egrep_o '"id"[ ]*:[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
fi
# key=value: id=12345
- _id="$(printf "%s" "$_resp" | _egrep_o '(^|[[:space:][:punct:]])id[[:space:]]*=[[:space:]]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
+ _id="$(printf "%s" "$_resp" | _egrep_o '(^|[^0-9a-zA-Z])id[ ]*=[ ]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
fi
# "record id 12345" / "recordid 12345"
- _id="$(printf "%s" "$_resp" | _egrep_o '(record[[:space:]]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
+ _id="$(printf "%s" "$_resp" | _egrep_o '(record[ ]*id|recordid)[^0-9]*[0-9]+' | _head_n 1 | tr -cd '0-9')"
if [ -n "$_id" ]; then
printf "%s" "$_id"
return 0
diff --git a/dnsapi/dns_creoline.sh b/dnsapi/dns_creoline.sh
index 9d04af4f..f4d76f8e 100644
--- a/dnsapi/dns_creoline.sh
+++ b/dnsapi/dns_creoline.sh
@@ -75,7 +75,7 @@ dns_creoline_rm() {
return 1
fi
- record_id=$(echo "$response" | _egrep_o "\"id\"[[:space:]]*:[[:space:]]*[0-9]+" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ record_id=$(echo "$response" | _egrep_o "\"id\"[ ]*:[ ]*[0-9]+" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
_debug "record_id" "$record_id"
if [ -z "$record_id" ]; then
@@ -108,10 +108,10 @@ _get_root() {
return 1
fi
- _sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _sub_domain=$(echo "$response" | _egrep_o "\"subDomain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
_debug _sub_domain "$_sub_domain"
- _domain=$(echo "$response" | _egrep_o "\"domain\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
+ _domain=$(echo "$response" | _egrep_o "\"domain\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \" | _head_n 1 | tr -d " ")
_debug _domain "$_domain"
if [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then
@@ -171,7 +171,7 @@ _creoline_rest() {
_err "URI:$uri"
return 1
elif _contains "$response" "message"; then
- message=$(echo "$response" | _egrep_o "\"message\"[[:space:]]*:[[:space:]]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \")
+ message=$(echo "$response" | _egrep_o "\"message\"[ ]*:[ ]*\"[^\"]+\"" | cut -d : -f 2 | tr -d \")
_err "Error: $message"
_err "URI:$uri"
return 1
diff --git a/dnsapi/dns_czechia.sh b/dnsapi/dns_czechia.sh
index e2ffcf50..6ad60442 100644
--- a/dnsapi/dns_czechia.sh
+++ b/dnsapi/dns_czechia.sh
@@ -30,8 +30,9 @@ dns_czechia_add() {
return 1
fi
- _cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
- _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
+ _czechia_tab="$(printf '\t')"
+ _cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//")
+ _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//")
if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken."
@@ -108,8 +109,9 @@ dns_czechia_rm() {
return 1
fi
- _cz=$(printf "%s" "$_current_zone" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
- _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')
+ _czechia_tab="$(printf '\t')"
+ _cz=$(printf "%s" "$_current_zone" | _lower_case | sed "s/[ $_czechia_tab]//g; s/\.\$//")
+ _tk=$(printf "%s" "$CZ_AuthorizationToken" | sed "s/^[ $_czechia_tab]*//; s/[ $_czechia_tab]*\$//")
if [ -z "$_cz" ] || [ -z "$_tk" ]; then
_err "Missing zone or CZ_AuthorizationToken."
@@ -180,12 +182,13 @@ _czechia_load_conf() {
}
_czechia_pick_zone() {
+ _czechia_pz_tab="$(printf '\t')"
_fd=$(printf "%s" "$1" | _lower_case | sed 's/\.$//')
_best_zone=""
_zones_space=$(printf "%s" "$CZ_Zones" | sed 's/,/ /g')
for _z in $_zones_space; do
- _clean_z=$(printf "%s" "$_z" | _lower_case | sed 's/[[:space:]]//g; s/\.$//')
+ _clean_z=$(printf "%s" "$_z" | _lower_case | sed "s/[ $_czechia_pz_tab]//g; s/\.\$//")
[ -z "$_clean_z" ] && continue
case "$_fd" in
diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh
index 73189189..a3d9174a 100644
--- a/dnsapi/dns_hostup.sh
+++ b/dnsapi/dns_hostup.sh
@@ -441,18 +441,18 @@ _hostup_json_extract() {
input="${2:-$line}"
# First try to extract quoted values (strings)
- quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | _head_n 1)"
+ quoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*\"[^\"]*\"" | _head_n 1)"
if [ -n "$quoted_match" ]; then
printf "%s" "$quoted_match" |
cut -d : -f2- |
- sed 's/^[[:space:]]*"//' |
- sed 's/"[[:space:]]*$//' |
+ sed 's/^[ ]*"//' |
+ sed 's/"[ ]*$//' |
sed 's/\\"/"/g'
return 0
fi
# Fallback for unquoted values (e.g., numeric IDs)
- unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[[:space:]]*:[[:space:]]*[^,}]*" | _head_n 1)"
+ unquoted_match="$(printf "%s" "$input" | _egrep_o "\"$key\"[ ]*:[ ]*[^,}]*" | _head_n 1)"
if [ -n "$unquoted_match" ]; then
printf "%s" "$unquoted_match" |
cut -d : -f2- |
diff --git a/dnsapi/dns_infoblox_uddi.sh b/dnsapi/dns_infoblox_uddi.sh
index 4b15088a..902cc700 100644
--- a/dnsapi/dns_infoblox_uddi.sh
+++ b/dnsapi/dns_infoblox_uddi.sh
@@ -117,7 +117,7 @@ dns_infoblox_uddi_rm() {
return 0
fi
- record_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
+ record_id=$(echo "$response" | _egrep_o '"id":[ ]*"[^"]*"' | _head_n 1 | cut -d '"' -f 4)
_debug "record_id" "$record_id"
if [ -z "$record_id" ]; then
@@ -178,7 +178,7 @@ _get_root() {
# Check if response contains results (even if empty)
if _contains "$response" '"results"'; then
# Extract zone ID - must match the pattern dns/auth_zone/...
- zone_id=$(echo "$response" | _egrep_o '"id":[[:space:]]*"dns/auth_zone/[^"]*"' | _head_n 1 | cut -d '"' -f 4)
+ zone_id=$(echo "$response" | _egrep_o '"id":[ ]*"dns/auth_zone/[^"]*"' | _head_n 1 | cut -d '"' -f 4)
if [ -n "$zone_id" ]; then
# Found the zone
_domain="$h"
diff --git a/dnsapi/dns_poweradmin.sh b/dnsapi/dns_poweradmin.sh
index db31fa4f..a4c81835 100644
--- a/dnsapi/dns_poweradmin.sh
+++ b/dnsapi/dns_poweradmin.sh
@@ -227,7 +227,7 @@ _poweradmin_rest() {
return 1
fi
- if printf '%s' "$response" | grep -q '"success"[[:space:]]*:[[:space:]]*false'; then
+ if printf '%s' "$response" | grep -q '"success"[ ]*:[ ]*false'; then
_err "API reported failure on $method $ep"
_debug "Response: $response"
return 1
diff --git a/dnsapi/dns_rage4.sh b/dnsapi/dns_rage4.sh
index c27fbc5f..b9abff17 100755
--- a/dnsapi/dns_rage4.sh
+++ b/dnsapi/dns_rage4.sh
@@ -71,7 +71,7 @@ dns_rage4_rm() {
_debug "Getting txt records"
_rage4_rest "getrecords/?id=${_domain_id}"
- _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -rn 's/.*"id":([[:digit:]]+),.*/\1/p')
+ _record_id=$(echo "$response" | tr '{' '\n' | grep '"TXT"' | grep "\"$txtvalue" | sed -n 's/.*"id":\([0-9][0-9]*\),.*/\1/p')
if [ -z "$_record_id" ]; then
_err "error retrieving the record_id of the new TXT record in order to delete it, got: '$_record_id'."
return 1
diff --git a/dnsapi/dns_selectel.sh b/dnsapi/dns_selectel.sh
index 565f541b..8ba9a4fb 100644
--- a/dnsapi/dns_selectel.sh
+++ b/dnsapi/dns_selectel.sh
@@ -368,7 +368,7 @@ _get_auth_token() {
_data_auth="{\"auth\":{\"identity\":{\"methods\":[\"password\"],\"password\":{\"user\":{\"name\":\"${SL_Login_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"},\"password\":\"${SL_Pswd}\"}}},\"scope\":{\"project\":{\"name\":\"${SL_Project_Name}\",\"domain\":{\"name\":\"${SL_Login_ID}\"}}}}}"
export _H1="Content-Type: application/json"
_result=$(_post "$_data_auth" "$auth_uri")
- _token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | sed -nE "s/[[:space:]]*x-subject-token:[[:space:]]*([[:print:]]*)(\r*)/\1/p")
+ _token_keystone=$(grep 'x-subject-token' "$HTTP_HEADER" | cut -d ':' -f 2- | tr -d ' \t\r')
_dt_curr=$(date +%s)
SL_Token_V2="${SL_Login_Name}${_sl_sep}${_token_keystone}${_sl_sep}${SL_Login_ID}${_sl_sep}${SL_Project_Name}${_sl_sep}${_dt_curr}"
_saveaccountconf_mutable SL_Token_V2 "$SL_Token_V2"
diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh
index 782a5d5f..25130146 100644
--- a/dnsapi/dns_selfhost.sh
+++ b/dnsapi/dns_selfhost.sh
@@ -42,7 +42,10 @@ dns_selfhost_add() {
# only match full domains (at the beginning of the string or with a leading whitespace),
# e.g. don't match mytest.example.com or sub.test.example.com for test.example.com
# if the domain is defined multiple times only the last occurance will be matched
- mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain)(:[[:digit:]]+)([:]?[[:digit:]]*)(.*)/\2\3\4/p")
+ # prepend a space to each line so "start of line" and "after whitespace"
+ # can both be matched as "after a space/tab" (portable BRE, no ERE (^|..))
+ _selfhost_tab="$(printf '\t')"
+ mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p")
_debug2 mapEntry "$mapEntry"
if test -z "$mapEntry"; then
_err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID"
@@ -54,7 +57,7 @@ dns_selfhost_add() {
rid2=$(echo "$mapEntry" | cut -d: -f3)
# read last used rid domain
- lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed -n -E "s/(^|^.*[[:space:]])($fulldomain:[[:digit:]]+)(.*)/\2/p")
+ lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*\).*/\1/p")
_debug2 lastUsedRidForDomainEntry "$lastUsedRidForDomainEntry"
lastUsedRidForDomain=$(echo "$lastUsedRidForDomainEntry" | cut -d: -f2)
diff --git a/dnsapi/dns_udr.sh b/dnsapi/dns_udr.sh
index 656a0557..dbc959d6 100644
--- a/dnsapi/dns_udr.sh
+++ b/dnsapi/dns_udr.sh
@@ -145,8 +145,8 @@ _udr_rest() {
_debug data "${data}"
response="$(_post "${data}" "${UDR_API}?s_login=${UDR_USER}&s_pw=${UDR_PASS}" "" "POST")"
- _code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
- _description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
+ _code=$(echo "$response" | _egrep_o "code = ([0-9]+)" | _head_n 1 | cut -d = -f 2 | tr -d ' \t\r')
+ _description=$(echo "$response" | _egrep_o "description = .*" | _head_n 1 | cut -d = -f 2 | tr -d '\r' | sed -e 's/^[ ]*//' -e 's/[ ]*$//')
_debug response_code "$_code"
_debug response_description "$_description"
diff --git a/dnsapi/dns_yandex360.sh b/dnsapi/dns_yandex360.sh
index 18d01361..98841d6b 100644
--- a/dnsapi/dns_yandex360.sh
+++ b/dnsapi/dns_yandex360.sh
@@ -149,7 +149,7 @@ _check_variables() {
org_response="$(echo "$org_response" | _normalizeJson)"
YANDEX360_ORG_ID=$(
echo "$org_response" |
- _egrep_o '"id":[[:space:]]*[0-9]+' |
+ _egrep_o '"id":[ ]*[0-9]+' |
cut -d':' -f2
)
_debug 'Automatically retrieved YANDEX360_ORG_ID' "$YANDEX360_ORG_ID"
@@ -216,7 +216,7 @@ _get_token() {
interval=$(
echo "$response" |
- _egrep_o '"interval":[[:space:]]*[0-9]+' |
+ _egrep_o '"interval":[ ]*[0-9]+' |
cut -d':' -f2
)
_debug 'Polling interval' "$interval"
From 7fa301821911ca93bbf82ad77edef577ac07785e Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 17 Jul 2026 22:03:00 +0800
Subject: [PATCH 200/224] feat: add ACME_PACKAGED for distro-packaged installs
When ACME_PACKAGED is set (e.g. exported by a distro package wrapper):
- --install does not copy the script or the hooks into LE_WORKING_DIR;
the cron job and the shell alias point to the packaged script instead
- --upgrade, --install-online and the cron AUTO_UPGRADE path refuse and
point to the system package manager
- --uninstall does not remove the packaged files
https://github.com/acmesh-official/acme.sh/issues/7135
---
acme.sh | 101 ++++++++++++++++++++++++++++++++++++++------------------
1 file changed, 68 insertions(+), 33 deletions(-)
diff --git a/acme.sh b/acme.sh
index 753b1159..274af3ac 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7528,6 +7528,15 @@ _installalias() {
_c_home="$1"
_initpath
+ _alias_bin="$LE_WORKING_DIR/$PROJECT_ENTRY"
+ if [ ! -f "$_alias_bin" ]; then
+ #ACME_PACKAGED install: no copy in LE_WORKING_DIR, alias the current script
+ _script="$(_readlink "$_SCRIPT_")"
+ if [ -f "$_script" ]; then
+ _alias_bin="$_script"
+ fi
+ fi
+
_envfile="$LE_WORKING_DIR/$PROJECT_ENTRY.env"
if [ "$_upgrading" ] && [ "$_upgrading" = "1" ]; then
echo "$(cat "$_envfile")" | sed "s|^LE_WORKING_DIR.*$||" >"$_envfile"
@@ -7545,7 +7554,7 @@ _installalias() {
else
_sed_i "/^export LE_CONFIG_HOME/d" "$_envfile"
fi
- _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\""
+ _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$_alias_bin$_c_entry\""
if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then
#the completion file does nothing when sourced by a non-bash shell
_setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\""
@@ -7572,7 +7581,7 @@ _installalias() {
else
_sed_i "/^setenv LE_CONFIG_HOME/d" "$_cshfile"
fi
- _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\""
+ _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\""
_setopt "$_csh_profile" "source \"$_cshfile\""
fi
@@ -7584,7 +7593,7 @@ _installalias() {
if [ "$_c_home" ]; then
_setopt "$_cshfile" "setenv LE_CONFIG_HOME" " " "\"$LE_CONFIG_HOME\""
fi
- _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\""
+ _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\""
_setopt "$_tcsh_profile" "source \"$_cshfile\""
fi
@@ -7658,30 +7667,38 @@ install() {
chmod 700 "$LE_CONFIG_HOME"
fi
- cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY"
+ if [ "$ACME_PACKAGED" ]; then
+ #the script and its hooks are managed by a system package manager,
+ #do not copy them into LE_WORKING_DIR. https://github.com/acmesh-official/acme.sh/issues/7135
+ _info "ACME_PACKAGED is set, skipping the script copy."
+ else
+ cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY"
- if [ "$?" != "0" ]; then
- _err "Installation failed, cannot copy $PROJECT_ENTRY"
- return 1
- fi
+ if [ "$?" != "0" ]; then
+ _err "Installation failed, cannot copy $PROJECT_ENTRY"
+ return 1
+ fi
- _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY"
+ _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY"
- if [ -f "$PROJECT_ENTRY.completion" ]; then
- cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/"
- _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion"
+ if [ -f "$PROJECT_ENTRY.completion" ]; then
+ cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/"
+ _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion"
+ fi
fi
if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then
_installalias "$_c_home"
fi
- for subf in $_SUB_FOLDERS; do
- if [ -d "$subf" ]; then
- mkdir -p "$LE_WORKING_DIR/$subf"
- cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/
- fi
- done
+ if [ -z "$ACME_PACKAGED" ]; then
+ for subf in $_SUB_FOLDERS; do
+ if [ -d "$subf" ]; then
+ mkdir -p "$LE_WORKING_DIR/$subf"
+ cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/
+ fi
+ done
+ fi
if [ ! -f "$ACCOUNT_CONF_PATH" ]; then
_initconf
@@ -7709,7 +7726,7 @@ install() {
installcronjob "$_c_home"
fi
- if [ -z "$NO_DETECT_SH" ]; then
+ if [ -z "$NO_DETECT_SH" ] && [ -z "$ACME_PACKAGED" ]; then
#Modify shebang
if _exists bash; then
_bash_path="$(bash -c "command -v bash 2>/dev/null")"
@@ -7734,7 +7751,9 @@ install() {
if [ "$_accountemail" ]; then
_saveaccountconf "ACCOUNT_EMAIL" "$_accountemail"
fi
- _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)"
+ if [ -z "$ACME_PACKAGED" ]; then
+ _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)"
+ fi
_info OK
}
@@ -7748,8 +7767,12 @@ uninstall() {
_uninstallalias
- rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY"
- rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion"
+ if [ -z "$ACME_PACKAGED" ]; then
+ #don't remove the script when it is managed by a system package manager,
+ #LE_WORKING_DIR may point to the packaged files
+ rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY"
+ rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion"
+ fi
_info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself."
}
@@ -7785,20 +7808,24 @@ cron() {
_initpath
_info "$(__green "===Starting cron===")"
if [ "$AUTO_UPGRADE" = "1" ]; then
- export LE_WORKING_DIR
- (
- if ! upgrade; then
- _err "Cron: Upgrade failed!"
- return 1
+ if [ "$ACME_PACKAGED" ]; then
+ _info "ACME_PACKAGED is set, skipping the auto upgrade."
+ else
+ export LE_WORKING_DIR
+ (
+ if ! upgrade; then
+ _err "Cron: Upgrade failed!"
+ return 1
+ fi
+ )
+ . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null
+
+ if [ -t 1 ]; then
+ __INTERACTIVE="1"
fi
- )
- . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null
- if [ -t 1 ]; then
- __INTERACTIVE="1"
+ _info "Automatically upgraded to: $VER"
fi
-
- _info "Automatically upgraded to: $VER"
fi
_TREAT_SKIP_AS_SUCCESS="1"
renewAll
@@ -8128,6 +8155,10 @@ Parameters:
}
installOnline() {
+ if [ "$ACME_PACKAGED" ]; then
+ _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade."
+ return 1
+ fi
_info "Installing from online archive."
_branch="$BRANCH"
@@ -8185,6 +8216,10 @@ _getUpgradeHash() {
}
upgrade() {
+ if [ "$ACME_PACKAGED" ]; then
+ _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade."
+ exit 1
+ fi
if (
_initpath
[ -z "$FORCE" ] && [ "$(_getUpgradeHash)" = "$(_readaccountconf "UPGRADE_HASH")" ] && _info "Already up to date!" && exit 0
From 97c5aca136c37830ffcd116435aa1eb9b2d80aa1 Mon Sep 17 00:00:00 2001
From: neil
Date: Sat, 18 Jul 2026 09:32:26 +0800
Subject: [PATCH 201/224] add cache-after-prepare: true
---
.github/workflows/DNS.yml | 11 +++++++++++
.github/workflows/DragonFlyBSD.yml | 1 +
.github/workflows/FreeBSD.yml | 1 +
.github/workflows/GhostBSD.yml | 1 +
.github/workflows/Haiku.yml | 1 +
.github/workflows/MidnightBSD.yml | 1 +
.github/workflows/NetBSD.yml | 1 +
.github/workflows/Omnios.yml | 1 +
.github/workflows/OpenBSD.yml | 1 +
.github/workflows/OpenIndiana.yml | 1 +
.github/workflows/Solaris.yml | 1 +
.github/workflows/Tribblix.yml | 1 +
12 files changed, 22 insertions(+)
diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml
index 5417068f..f7d5cb98 100644
--- a/.github/workflows/DNS.yml
+++ b/.github/workflows/DNS.yml
@@ -237,6 +237,7 @@ jobs:
- uses: vmactions/freebsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: pkg install -y socat curl
usesh: true
@@ -295,6 +296,7 @@ jobs:
- uses: vmactions/ghostbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: pkg install -y socat curl
usesh: true
@@ -351,6 +353,7 @@ jobs:
- uses: vmactions/openbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: pkg_add socat curl libiconv
usesh: true
@@ -407,6 +410,7 @@ jobs:
- uses: vmactions/netbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: |
/usr/sbin/pkg_add curl socat
@@ -464,6 +468,7 @@ jobs:
- uses: vmactions/dragonflybsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: |
pkg install -y libnghttp2
@@ -525,6 +530,7 @@ jobs:
- uses: vmactions/midnightbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
prepare: mport install socat curl || true
usesh: true
@@ -582,6 +588,7 @@ jobs:
- uses: vmactions/solaris-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: |
@@ -641,6 +648,7 @@ jobs:
- uses: vmactions/omnios-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: pkg install socat
@@ -697,6 +705,7 @@ jobs:
- uses: vmactions/openindiana-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: pkg install socat
@@ -753,6 +762,7 @@ jobs:
- uses: vmactions/tribblix-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: nfs
prepare: zap install socat
@@ -809,6 +819,7 @@ jobs:
- uses: vmactions/haiku-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
sync: rsync
copyback: false
diff --git a/.github/workflows/DragonFlyBSD.yml b/.github/workflows/DragonFlyBSD.yml
index c8cbc985..20c61dcc 100644
--- a/.github/workflows/DragonFlyBSD.yml
+++ b/.github/workflows/DragonFlyBSD.yml
@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/dragonflybsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/FreeBSD.yml b/.github/workflows/FreeBSD.yml
index 50fcab32..88ef0a6e 100644
--- a/.github/workflows/FreeBSD.yml
+++ b/.github/workflows/FreeBSD.yml
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/freebsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml
index c77fdf2e..04510c15 100644
--- a/.github/workflows/GhostBSD.yml
+++ b/.github/workflows/GhostBSD.yml
@@ -66,6 +66,7 @@ jobs:
- uses: vmactions/ghostbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/Haiku.yml b/.github/workflows/Haiku.yml
index 9884ebeb..b133dd18 100644
--- a/.github/workflows/Haiku.yml
+++ b/.github/workflows/Haiku.yml
@@ -65,6 +65,7 @@ jobs:
- uses: vmactions/haiku-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/MidnightBSD.yml b/.github/workflows/MidnightBSD.yml
index 15024833..ce499e4e 100644
--- a/.github/workflows/MidnightBSD.yml
+++ b/.github/workflows/MidnightBSD.yml
@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/midnightbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/NetBSD.yml b/.github/workflows/NetBSD.yml
index 16d0ae2d..6695f71e 100644
--- a/.github/workflows/NetBSD.yml
+++ b/.github/workflows/NetBSD.yml
@@ -58,6 +58,7 @@ jobs:
- uses: vmactions/netbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/Omnios.yml b/.github/workflows/Omnios.yml
index eb486b35..aabb168b 100644
--- a/.github/workflows/Omnios.yml
+++ b/.github/workflows/Omnios.yml
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/omnios-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/OpenBSD.yml b/.github/workflows/OpenBSD.yml
index 4fdb76c5..46318163 100644
--- a/.github/workflows/OpenBSD.yml
+++ b/.github/workflows/OpenBSD.yml
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/openbsd-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/OpenIndiana.yml b/.github/workflows/OpenIndiana.yml
index b5061ba7..e3119f8e 100644
--- a/.github/workflows/OpenIndiana.yml
+++ b/.github/workflows/OpenIndiana.yml
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/openindiana-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/Solaris.yml b/.github/workflows/Solaris.yml
index 3393269a..30e4e291 100644
--- a/.github/workflows/Solaris.yml
+++ b/.github/workflows/Solaris.yml
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/solaris-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
diff --git a/.github/workflows/Tribblix.yml b/.github/workflows/Tribblix.yml
index cd43e0e3..68e61dc8 100644
--- a/.github/workflows/Tribblix.yml
+++ b/.github/workflows/Tribblix.yml
@@ -64,6 +64,7 @@ jobs:
- uses: vmactions/tribblix-vm@v1
with:
debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET'
nat: |
"8080": "80"
From 6feb1df83cb2d1e628c0b751cf63a645ec5266c5 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 19 Jul 2026 10:20:58 +0800
Subject: [PATCH 202/224] fix cpanel_uapi: pass --user to DomainInfo
list_domains when run as root
The auto mode sitelist query was missing the --user branch that the
install_ssl calls already have, so deploy always failed under root.
fix https://github.com/acmesh-official/acme.sh/issues/7139
---
deploy/cpanel_uapi.sh | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh
index 156044eb..02ef6b3e 100644
--- a/deploy/cpanel_uapi.sh
+++ b/deploy/cpanel_uapi.sh
@@ -87,7 +87,11 @@ cpanel_uapi_deploy() {
# Auto mode
if [ "$DEPLOY_CPANEL_AUTO_ENABLED" = "true" ]; then
# call API for site config
- _response=$(uapi DomainInfo list_domains)
+ if [ -n "$_uapi_user" ]; then
+ _response=$(uapi --user="$_uapi_user" DomainInfo list_domains)
+ else
+ _response=$(uapi DomainInfo list_domains)
+ fi
# exit if error in response
if [ -z "$_response" ] || [ "${_response#*"$uapi_error_response"}" != "$_response" ]; then
_err "Error in deploying certificate - cannot retrieve sitelist:"
From 4c8a143086549d77d21fcec4fce0bb7d97b60821 Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 20 Jul 2026 10:02:54 +0800
Subject: [PATCH 203/224] fix proxmoxve/proxmoxbs deploy: fail on non-2xx API
response
The success check only grepped "message" from the response body, but
PVE/PBS auth failures return HTTP 401 with an empty body, so wrong or
unauthorized API tokens were reported as "Certificate successfully
deployed". Also _retval captured the exit code of the message pipeline
instead of _post. Check the HTTP status line from $HTTP_HEADER and
capture _post's exit code directly.
fix https://github.com/acmesh-official/acme.sh/issues/7141
---
deploy/proxmoxbs.sh | 27 +++++++++++++++++----------
deploy/proxmoxve.sh | 27 +++++++++++++++++----------
2 files changed, 34 insertions(+), 20 deletions(-)
diff --git a/deploy/proxmoxbs.sh b/deploy/proxmoxbs.sh
index 179b0369..30599a44 100644
--- a/deploy/proxmoxbs.sh
+++ b/deploy/proxmoxbs.sh
@@ -116,17 +116,24 @@ HEREDOC
export HTTPS_INSECURE=1
export _H1="Authorization: PBSAPIToken=${_proxmoxbs_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json")
+ _retval=$?
+ # The API errors out with a non-2xx HTTP status and an empty body,
+ # so the status line is checked too, not only the response body.
+ _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug2 "HTTP status" "$_status_code"
response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
- _retval=$?
- if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
- _debug3 response "$response"
- _info "Certificate successfully deployed"
- return 0
- else
- _err "Certificate deployment failed: $message"
- _debug "Response" "$response"
- return 1
- fi
+ case "$_status_code" in
+ 2[0-9][0-9])
+ if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
+ _debug3 response "$response"
+ _info "Certificate successfully deployed"
+ return 0
+ fi
+ ;;
+ esac
+ _err "Certificate deployment failed (HTTP status $_status_code). $message"
+ _debug "Response" "$response"
+ return 1
}
diff --git a/deploy/proxmoxve.sh b/deploy/proxmoxve.sh
index b6298ee7..fd8d69d8 100644
--- a/deploy/proxmoxve.sh
+++ b/deploy/proxmoxve.sh
@@ -128,17 +128,24 @@ HEREDOC
export HTTPS_INSECURE=1
export _H1="Authorization: PVEAPIToken=${_proxmoxve_header_api_token}"
response=$(_post "$_json_payload" "$_target_url" "" POST "application/json")
+ _retval=$?
+ # The API errors out with a non-2xx HTTP status and an empty body,
+ # so the status line is checked too, not only the response body.
+ _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug2 "HTTP status" "$_status_code"
response="$(echo "$response" | _json_decode | _normalizeJson)"
message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"')
- _retval=$?
- if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
- _debug3 response "$response"
- _info "Certificate successfully deployed"
- return 0
- else
- _err "Certificate deployment failed: $message"
- _debug "Response" "$response"
- return 1
- fi
+ case "$_status_code" in
+ 2[0-9][0-9])
+ if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then
+ _debug3 response "$response"
+ _info "Certificate successfully deployed"
+ return 0
+ fi
+ ;;
+ esac
+ _err "Certificate deployment failed (HTTP status $_status_code). $message"
+ _debug "Response" "$response"
+ return 1
}
From 1774d838cabca07d50e55b7e736c83e3d1228ecf Mon Sep 17 00:00:00 2001
From: neil
Date: Wed, 22 Jul 2026 20:42:14 +0800
Subject: [PATCH 204/224] add GNU hurd
---
.github/workflows/DNS.yml | 91 +++++++++++++++++++++++++++++++-------
.github/workflows/Hurd.yml | 76 +++++++++++++++++++++++++++++++
README.md | 2 +
3 files changed, 154 insertions(+), 15 deletions(-)
create mode 100644 .github/workflows/Hurd.yml
diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml
index f7d5cb98..09b65a96 100644
--- a/.github/workflows/DNS.yml
+++ b/.github/workflows/DNS.yml
@@ -66,7 +66,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- name: Set env file
@@ -114,7 +114,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Install tools
run: |
brew untap aws/tap || true
@@ -167,7 +167,7 @@ jobs:
- name: Set git to use LF
run: |
git config --global core.autocrlf false
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Install cygwin base packages with chocolatey
run: |
choco config get cacheLocation
@@ -231,7 +231,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/freebsd-vm@v1
@@ -290,7 +290,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/ghostbsd-vm@v1
@@ -347,7 +347,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/openbsd-vm@v1
@@ -404,7 +404,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/netbsd-vm@v1
@@ -462,7 +462,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/dragonflybsd-vm@v1
@@ -524,7 +524,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/midnightbsd-vm@v1
@@ -582,7 +582,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/solaris-vm@v1
@@ -642,7 +642,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/omnios-vm@v1
@@ -699,7 +699,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/openindiana-vm@v1
@@ -756,7 +756,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/tribblix-vm@v1
@@ -813,7 +813,7 @@ jobs:
TokenName4: ${{ secrets.TokenName4}}
TokenName5: ${{ secrets.TokenName5}}
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
- name: Clone acmetest
run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
- uses: vmactions/haiku-vm@v1
@@ -826,7 +826,68 @@ jobs:
prepare: |
mkdir -p /boot/home/.cache
pkgman install -y cronie
-
+
+ run: |
+ if [ "${{ secrets.TokenName1}}" ] ; then
+ export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}"
+ fi
+ if [ "${{ secrets.TokenName2}}" ] ; then
+ export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}"
+ fi
+ if [ "${{ secrets.TokenName3}}" ] ; then
+ export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}"
+ fi
+ if [ "${{ secrets.TokenName4}}" ] ; then
+ export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}"
+ fi
+ if [ "${{ secrets.TokenName5}}" ] ; then
+ export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}"
+ fi
+ cd ../acmetest
+ ./letest.sh
+ - name: DebugOnError
+ if: ${{ failure() }}
+ run: |
+ echo "See how to debug in VM:"
+ echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
+
+
+
+ Hurd:
+ runs-on: ubuntu-latest
+ needs: Haiku
+ env:
+ TEST_DNS : ${{ secrets.TEST_DNS }}
+ TestingDomain: ${{ secrets.TestingDomain }}
+ TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }}
+ TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }}
+ TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }}
+ CASE: le_test_dnsapi
+ TEST_LOCAL: 1
+ DEBUG: ${{ secrets.DEBUG }}
+ http_proxy: ${{ secrets.http_proxy }}
+ https_proxy: ${{ secrets.https_proxy }}
+ HTTPS_INSECURE: 1 # always set to 1 to ignore https error
+ TokenName1: ${{ secrets.TokenName1}}
+ TokenName2: ${{ secrets.TokenName2}}
+ TokenName3: ${{ secrets.TokenName3}}
+ TokenName4: ${{ secrets.TokenName4}}
+ TokenName5: ${{ secrets.TokenName5}}
+ steps:
+ - uses: actions/checkout@v7
+ - name: Clone acmetest
+ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
+ - uses: vmactions/hurd-vm@v1
+ with:
+ debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
+ envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}'
+ sync: rsync
+ copyback: false
+ usesh: true
+ prepare: |
+ apt-get update -y
+ apt-get install -y curl cron
run: |
if [ "${{ secrets.TokenName1}}" ] ; then
export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}"
diff --git a/.github/workflows/Hurd.yml b/.github/workflows/Hurd.yml
new file mode 100644
index 00000000..fee80d29
--- /dev/null
+++ b/.github/workflows/Hurd.yml
@@ -0,0 +1,76 @@
+name: Hurd
+on:
+ push:
+ branches:
+ - '*'
+ paths:
+ - '*.sh'
+ - '.github/workflows/Hurd.yml'
+
+ pull_request:
+ branches:
+ - dev
+ paths:
+ - '*.sh'
+ - '.github/workflows/Hurd.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+
+
+jobs:
+ Hurd:
+ strategy:
+ matrix:
+ include:
+ - TEST_ACME_Server: "LetsEncrypt.org_test"
+ CA_ECDSA: ""
+ CA: ""
+ CA_EMAIL: ""
+ TEST_PREFERRED_CHAIN: (STAGING)
+ runs-on: ubuntu-latest
+ env:
+ TEST_LOCAL: 1
+ TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }}
+ CA_ECDSA: ${{ matrix.CA_ECDSA }}
+ CA: ${{ matrix.CA }}
+ CA_EMAIL: ${{ matrix.CA_EMAIL }}
+ TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }}
+ steps:
+ - uses: actions/checkout@v7
+ - uses: anyvm-org/cf-tunnel@v0
+ id: tunnel
+ with:
+ protocol: http
+ port: 8080
+ - name: Set envs
+ run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV
+ - name: Clone acmetest
+ run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/
+ - uses: vmactions/hurd-vm@v1
+ with:
+ debug-on-error: ${{ vars.DEBUG_ON_ERROR }}
+ cache-after-prepare: true
+ envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN'
+ nat: |
+ "8080": "80"
+ # Do NOT install socat: socat's SYSTEM: address is broken on GNU Hurd
+ # (the child shell output goes to socat's stdout instead of the socket,
+ # so clients get an empty reply). Without socat, acme.sh standalone
+ # mode falls back to its python3 server, which works on Hurd.
+ prepare: |
+ apt-get update -y
+ apt-get install -y curl cron
+ usesh: true
+ sync: rsync
+ copyback: false
+ run: |
+ cd ../acmetest \
+ && ./letest.sh
+ - name: DebugOnError
+ if: ${{ failure() }}
+ run: |
+ echo "See how to debug in VM:"
+ echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM"
diff --git a/README.md b/README.md
index b93a8a50..23c8b3e0 100644
--- a/README.md
+++ b/README.md
@@ -36,6 +36,7 @@
+
@@ -130,6 +131,7 @@
|25|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml)|Haiku OS
|26|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix
|27|[](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD
+|28|[](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd
> 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest)
From 24d573b6d3f0ee409c145b0ed26813efaae4c63c Mon Sep 17 00:00:00 2001
From: Radu
Date: Thu, 23 Jul 2026 06:50:20 +0300
Subject: [PATCH 205/224] Merge pull request #7140 from radumalica/dns_hestiacp
feat: add dnsapi for HestiaCP
---
dnsapi/dns_hestiacp.sh | 198 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 198 insertions(+)
create mode 100644 dnsapi/dns_hestiacp.sh
diff --git a/dnsapi/dns_hestiacp.sh b/dnsapi/dns_hestiacp.sh
new file mode 100644
index 00000000..13ee6caf
--- /dev/null
+++ b/dnsapi/dns_hestiacp.sh
@@ -0,0 +1,198 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_hestiacp_info='HestiaCP Server API
+Site: hestiacp.com
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hestiacp
+Options:
+ HESTIA_HOST Panel URL. E.g. "https://panel.example.com:8083"
+ HESTIA_ACCESS API access key
+ HESTIA_SECRET API secret key
+ HESTIA_USER Username owning the DNS zones. Default "admin". Optional.
+Issues: github.com/acmesh-official/acme.sh/issues/6251
+Author: Radu Malica