From 5ff7f0a4e78ee0d3898bfdb919deb9de797844fd Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 14 Aug 2026 11:53:31 +0800
Subject: [PATCH 01/14] Mirror the tag object, not the commit, in vtag.yml
The v-prefixed mirror was created from github.sha, so for an annotated or
signed tag it would point at the commit and drop the signature: "git
verify-tag v3.1.3" fails with "cannot verify a non-tag object of type
commit" while "git verify-tag 3.1.3" succeeds. Resolve refs/tags/
and mirror whatever object it points at instead, which keeps the current
behaviour for lightweight tags. Also move the workflow expressions into
env instead of interpolating them into the shell command.
---
.github/workflows/vtag.yml | 20 ++++++++++++++++----
1 file changed, 16 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/vtag.yml b/.github/workflows/vtag.yml
index e9f7e8df..6b5de15a 100644
--- a/.github/workflows/vtag.yml
+++ b/.github/workflows/vtag.yml
@@ -23,10 +23,22 @@ jobs:
- name: Create the v-prefixed tag
env:
GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ TAG: ${{ github.ref_name }}
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."
+ if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then
+ echo "Tag v$TAG 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 }}"
+ # Mirror the object the pushed tag actually points at: the commit
+ # for a lightweight tag, the tag object itself for an annotated or
+ # signed one. Pointing the mirror at the commit would strip the
+ # signature, so "git verify-tag v3.1.3" would fail while
+ # "git verify-tag 3.1.3" succeeds.
+ sha="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.sha)"
+ if [ -z "$sha" ] || [ "$sha" = "null" ]; then
+ echo "Could not resolve refs/tags/$TAG"
+ exit 1
+ fi
+ gh api "repos/$REPO/git/refs" -f ref="refs/tags/v$TAG" -f sha="$sha"
+ echo "Created tag v$TAG -> $sha"
From 518091192971b91195ebf3c56d17b81e86688744 Mon Sep 17 00:00:00 2001
From: neil
Date: Fri, 14 Aug 2026 12:15:15 +0800
Subject: [PATCH 02/14] fix
https://github.com/acmesh-official/acme.sh/issues/7195#issuecomment-5281002963
---
README.md | 25 +++++++++++++++++++++++++
allowed_signers | 20 ++++++++++++++++++++
2 files changed, 45 insertions(+)
create mode 100644 allowed_signers
diff --git a/README.md b/README.md
index 90280e94..2d0e130c 100644
--- a/README.md
+++ b/README.md
@@ -227,6 +227,31 @@ Cron entry example:
acme.sh -h
```
+#### 🔏 Verify a Release
+
+Release tags from `3.1.5` on are signed with the maintainer's SSH key. The
+signing happens on the maintainer's machine, so the private key is never
+available to CI. The public half is [`allowed_signers`](allowed_signers) in
+this repository. From a clone:
+
+```bash
+git config gpg.ssh.allowedSignersFile allowed_signers
+```
+
+```bash
+git verify-tag 3.1.5
+```
+
+The signature covers the tag object, which pins the commit and therefore the
+whole tree, so a good signature verifies every file at that release and no
+separate tarball checksum is needed. Build a tarball from the verified tag:
+
+```bash
+git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz
+```
+
+> ⚠️ Tags up to `3.1.4` predate the signing key and are unsigned.
+
---
### 2️⃣ Issue a Certificate
diff --git a/allowed_signers b/allowed_signers
new file mode 100644
index 00000000..8ed9d0a7
--- /dev/null
+++ b/allowed_signers
@@ -0,0 +1,20 @@
+# acme.sh release signing key.
+#
+# Release tags are signed with this key. Its private half is held by the
+# maintainer and is never available to CI, so a compromise of the build
+# pipeline cannot produce a tag that verifies against this file.
+#
+# Fingerprint: SHA256:M60qVafm/NUywQHXAkoQcj2v6KgkfrdSXv6mPejUUeE
+#
+# To verify a release tag, from a clone of this repository:
+#
+# git config gpg.ssh.allowedSignersFile allowed_signers
+# git verify-tag 3.1.5
+#
+# A good signature covers the tag object, which pins the commit, which pins
+# the whole tree -- so verifying the tag verifies every file at that
+# release. Build a tarball from the verified tag with:
+#
+# git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz
+#
+github@neilpang.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBTjI0HBJn3uhfT2DsNcFybfAZi3ADbIacMpz1BItKdB
From 1cbd2233864c70d23797b6e103cfd4f5113d73b1 Mon Sep 17 00:00:00 2001
From: neil
Date: Mon, 17 Aug 2026 13:42:10 +0800
Subject: [PATCH 03/14] deploy/unifios: document UniFi OS hardware support, not
just self-hosted
The certificate REST API this hook drives is UniFi OS's own, not
specific to the self-hosted UniFi OS Server: user reports confirm it on
a UDM Pro (UniFi OS 5.1.26) and a UCG Fiber (5.0.16). Reframe the scope
around the endpoint rather than the product line, state that the choice
between unifi and unifios is local/SSH file access vs remote REST API,
and note that the management port is 11443 on UniFi OS Server but 443
on hardware, so DEPLOY_UNIFIOS_HOST must be set there.
---
deploy/unifios.sh | 50 +++++++++++++++++++++++++++--------------------
1 file changed, 29 insertions(+), 21 deletions(-)
diff --git a/deploy/unifios.sh b/deploy/unifios.sh
index 82b65c69..05298c9f 100644
--- a/deploy/unifios.sh
+++ b/deploy/unifios.sh
@@ -1,24 +1,29 @@
#!/usr/bin/env sh
-# Deploy hook for UniFi OS Server (self-hosted).
+# Deploy hook for UniFi OS, via the certificate REST API.
#
-# Supports:
-# - UniFi OS Server on macOS
-# - UniFi OS Server on Linux
-# - UniFi OS Server on Windows should also work (runs under WSL2), but
-# has not been tested.
+# Works against any UniFi OS whose management UI exposes
+# /api/userCertificates. Confirmed on:
+# - UniFi OS Server (the separately-installed, self-hosted application)
+# on macOS and on Linux. Windows should also work (it runs under
+# WSL2), but has not been tested.
+# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local).
+# - UniFi OS hardware: UDM Pro on UniFi OS 5.1.26, UCG Fiber on
+# UniFi OS 5.0.16 (user reports, see issues 7184 and 6916).
+# No lower version bound is claimed -- if the UI has a certificate
+# manager, this hook should work.
#
-# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local).
+# `unifios` vs `unifi`: the split is the access method, not the product
+# line. `unifi` writes files / a Java keystore and needs local or SSH
+# access on the device; this hook drives the same REST API the web UI
+# uses and works remotely. Use `unifi` where acme.sh runs on the device
+# itself, this hook where it does not.
#
-# This is a different product from the Cloud Key / UDM hardware and
-# self-hosted Unifi Controller covered by the `unifi` deploy hook above
-# (that hook already covers Cloud Key running UnifiOS v2.0.0+/Gen2/2+) --
-# this hook targets the separately-installed, self-hosted "UniFi OS Server"
-# application instead, which stores certificates in its own Postgres
-# database via a REST API rather than a Java keystore, so the `unifi`
-# hook's approach does not apply here.
+# The API is served on the management port, which differs per install:
+# UniFi OS Server listens on 11443 (hence the default below), while
+# UniFi OS hardware serves it on 443 -- set DEPLOY_UNIFIOS_HOST to
+# "https://" there.
#
-# UniFi OS Server exposes a REST API on its management port (default
-# 11443) that its own web UI uses for certificate management:
+# Endpoints used, all as the web UI itself calls them:
# POST /api/auth/login - session login (cookie + JWT)
# GET /api/userCertificates - list uploaded certificates
# POST /api/userCertificates - upload a new certificate
@@ -41,8 +46,9 @@
# Uses core acme.sh helpers throughout (_post/_get, _json_encode,
# _durl_replace_base64, _dbase64, _egrep_o) rather than raw curl -k or
# python3, so the wget fallback, --debug tracing, and CA_BUNDLE are all
-# honored the same as every other hook. The management API's cert is
-# self-signed (it's a management-only port, not meant for public exposure),
+# honored the same as every other hook. The management API's cert may be
+# self-signed -- it always is on a fresh install, and there is no reliable
+# way to tell in advance whether an earlier run has already replaced it --
# so this hook sets HTTPS_INSECURE=1 itself, scoped to its own subshell (see
# acme.sh's per-hook sourcing in _deploy) -- it does not weaken TLS
# verification for the rest of the acme.sh run, e.g. the connection to the
@@ -63,9 +69,11 @@
#
# Settings:
# DEPLOY_UNIFIOS_HOST - base URL of the management API
-# (default: "https://localhost:11443")
-# DEPLOY_UNIFIOS_USERNAME - UniFi OS Server admin username (required)
-# DEPLOY_UNIFIOS_PASSWORD - UniFi OS Server admin password (required)
+# (default: "https://localhost:11443", i.e. a UniFi OS Server on the
+# same machine as acme.sh; set it to "https://" for UniFi OS
+# hardware or any remote target)
+# DEPLOY_UNIFIOS_USERNAME - UniFi OS admin username (required)
+# DEPLOY_UNIFIOS_PASSWORD - UniFi OS admin password (required)
#
# Example:
# export DEPLOY_UNIFIOS_USERNAME="acmeuser"
From e0c0297ba3c3daa87b3ba0f3f54300f0745455d9 Mon Sep 17 00:00:00 2001
From: neil
Date: Wed, 19 Aug 2026 22:50:03 +0800
Subject: [PATCH 04/14] fix cronjob
---
acme.sh | 39 +++++++++++++++++++++++++++++++++++++--
1 file changed, 37 insertions(+), 2 deletions(-)
diff --git a/acme.sh b/acme.sh
index 4d4f6cbe..5a8edab6 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7072,6 +7072,20 @@ _uninstall_win_taskscheduler() {
fi
}
+#binpath
+#Reads a crontab listing from stdin, prints it without the acme.sh cron
+#entries that call binpath.
+_filter_cron_bin() {
+ _fcb_bin="$1"
+ if [ -z "$_fcb_bin" ]; then
+ cat
+ return
+ fi
+ #-F: binpath is a literal, not a regex (the dot of ~/.acme.sh would
+ #otherwise match any character)
+ grep -v -F "$_fcb_bin --cron"
+}
+
#confighome
installcronjob() {
_c_home="$1"
@@ -7141,7 +7155,26 @@ installcronjob() {
return 1
fi
fi
- if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron"; then
+ #An entry that calls LE_WORKING_DIR/PROJECT_ENTRY is dead once that copy is
+ #gone: ACME_PACKAGED installs never write it, and the package manager
+ #removes it when it takes over. The entry below would then keep the install
+ #from adding a working one and cron would fail silently every day, so drop
+ #the stale entries first.
+ _cron_stale=""
+ if [ ! -f "$LE_WORKING_DIR/$PROJECT_ENTRY" ] && [ "$_cron_entries" ]; then
+ _cron_kept="$(echo "$_cron_entries" | _filter_cron_bin "\"$LE_WORKING_DIR\"/$PROJECT_ENTRY")"
+ if [ "$_cron_kept" != "$_cron_entries" ]; then
+ _info "Removing the cron job that calls the missing $LE_WORKING_DIR/$PROJECT_ENTRY"
+ _cron_entries="$_cron_kept"
+ _cron_stale=1
+ fi
+ fi
+ #>/dev/null: grep would print the matching crontab line to the console
+ _cron_add=""
+ if ! echo "$_cron_entries" | grep "$PROJECT_ENTRY --cron" >/dev/null; then
+ _cron_add=1
+ fi
+ if [ "$_cron_add" ] || [ "$_cron_stale" ]; then
if _exists uname && uname -a | grep SunOS >/dev/null; then
_CRONTAB_STDIN="$_CRONTAB --"
else
@@ -7151,7 +7184,9 @@ installcronjob() {
if [ "$_cron_entries" ]; then
echo "$_cron_entries"
fi
- echo "$_cron_entry"
+ if [ "$_cron_add" ]; then
+ echo "$_cron_entry"
+ fi
} | $_CRONTAB_STDIN
fi
if [ "$?" != "0" ]; then
From b1a8eb1c9573f285c33ef9226d9a84de9ab1419a Mon Sep 17 00:00:00 2001
From: neil
Date: Thu, 20 Aug 2026 08:40:49 +0800
Subject: [PATCH 05/14] installcronjob: match the stale cron path without grep
-F
---
acme.sh | 16 +++++++++++++---
1 file changed, 13 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index 5a8edab6..20a12992 100755
--- a/acme.sh
+++ b/acme.sh
@@ -7081,9 +7081,19 @@ _filter_cron_bin() {
cat
return
fi
- #-F: binpath is a literal, not a regex (the dot of ~/.acme.sh would
- #otherwise match any character)
- grep -v -F "$_fcb_bin --cron"
+ #a case pattern with a quoted variable matches binpath literally, which
+ #grep cannot do portably: Solaris /usr/bin/grep has no -F, and as a regex
+ #the dot of ~/.acme.sh would stand for any character
+ while IFS= read -r _fcb_line || [ -n "$_fcb_line" ]; do
+ case "$_fcb_line" in
+ *"$_fcb_bin --cron"*)
+ _debug3 "Dropping cron entry" "$_fcb_line"
+ ;;
+ *)
+ echo "$_fcb_line"
+ ;;
+ esac
+ done
}
#confighome
From b481ffb81b7b1d3300c0bff4f939bb821c2d2989 Mon Sep 17 00:00:00 2001
From: Fabian Lesniak
Date: Sat, 22 Aug 2026 04:13:58 +0200
Subject: [PATCH 06/14] Merge pull request #5194 from flesniak/myloc
Add dnsapi script for myloc.de/webtropia.com
---
dnsapi/dns_myloc.sh | 121 ++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 121 insertions(+)
create mode 100755 dnsapi/dns_myloc.sh
diff --git a/dnsapi/dns_myloc.sh b/dnsapi/dns_myloc.sh
new file mode 100755
index 00000000..49d800c1
--- /dev/null
+++ b/dnsapi/dns_myloc.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env sh
+# shellcheck disable=SC2034
+dns_myloc_info='myloc.de
+Site: myloc.de
+Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_myloc
+Issues: github.com/acmesh-official/acme.sh/issues/5193
+Options:
+ MYLOC_token API token
+'
+
+# updater for the (experimental) API of myloc.de / webtropia.com
+# usage: acme.sh --issue -d example.com --dns dns_myloc --dnssleep 60
+# API documentation at https://apidoc.myloc.de/
+# As the API does not support quering available zones yet, the zone for a given
+# fulldomain is searched recursively by removing prefixes one-by-one.
+
+_myloc_api="https://zkm.myloc.de/api"
+
+#Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+dns_myloc_add() {
+ _myloc_fulldomain=$1
+ _myloc_txtvalue=$2
+
+ _myloc_token="${MYLOC_token:-$(_readaccountconf_mutable MYLOC_token)}"
+ if [ -z "$_myloc_token" ]; then
+ _err "You didn't specify MYLOC_token"
+ return 1
+ fi
+
+ export _H1="Content-Type: application/json"
+ export _H2="Authorization: Bearer $_myloc_token"
+
+ _myloc_zone="$(_myloc_get_zone "$_myloc_fulldomain")"
+ if [ $? -ne 0 ]; then
+ return 1
+ fi
+
+ # save token if the previous request was successful
+ _saveaccountconf_mutable MYLOC_token "$_myloc_token"
+
+ _info "Adding record"
+ _myloc_record="{\"type\":\"TXT\",\"name\":\"${_myloc_fulldomain}\",\"content\":\"\\\"${_myloc_txtvalue}\\\"\",\"ttl\":60}"
+ _debug "add record request $_myloc_record to ${_myloc_api}/dns/zone/${_myloc_zone}"
+ _myloc_response="$(_post "$_myloc_record" "${_myloc_api}/dns/zone/${_myloc_zone}" "" "PUT")"
+ _myloc_status=$?
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug "add record response $_code $_myloc_response"
+ if [ $_myloc_status -ne 0 ]; then
+ _err "Add txt record curl error."
+ return 1
+ elif [ "$_code" = "204" ] && [ -z "$_myloc_response" ]; then
+ _info "Add txt record success"
+ return 0
+ elif _contains "$_myloc_response" "error" || _contains "$_myloc_response" "unexpected"; then
+ _err "Add txt record api error."
+ return 1
+ else
+ _err "Add txt record unknown response."
+ return 1
+ fi
+}
+
+#_myloc_fulldomain _myloc_txtvalue
+dns_myloc_rm() {
+ _myloc_fulldomain=$1
+ _myloc_txtvalue=$2
+
+ _myloc_token="${MYLOC_token:-$(_readaccountconf_mutable MYLOC_token)}"
+ if [ -z "$_myloc_token" ]; then
+ _err "You didn't specify MYLOC_token"
+ return 1
+ fi
+
+ export _H1="Content-Type: application/json"
+ export _H2="Authorization: Bearer $_myloc_token"
+
+ _myloc_zone="$(_myloc_get_zone "$_myloc_fulldomain")"
+ if [ $? -ne 0 ]; then
+ return 1
+ fi
+
+ # save token if the previous request was successful
+ _saveaccountconf_mutable MYLOC_token "$_myloc_token"
+
+ _info "Deleting record for $_myloc_fulldomain"
+ _myloc_record="{\"type\":\"TXT\",\"name\":\"${_myloc_fulldomain}\",\"content\":\"\\\"${_myloc_txtvalue}\\\"\"}"
+ _debug "delete record $_myloc_record"
+ _myloc_response="$(_post "$_myloc_record" "${_myloc_api}/dns/zone/${_myloc_zone}" "" "DELETE")"
+ _myloc_status=$?
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ _debug "delete response $_code $_myloc_response"
+ if [ $_myloc_status -ne 0 ] || [ "$_code" != "204" ]; then
+ _err "Failed to delete record"
+ return 1
+ fi
+
+ return 0
+}
+
+# Usage: _myloc_get_zone "_acme-challenge.sub1.mydomain.com"
+# Subdomains are walked until a zone is found or TLD is reached
+_myloc_get_zone() {
+ _myloc_zone=$1
+
+ while [ "${_myloc_zone#*.}" != "$_myloc_zone" ]; do
+ _debug "Get zone trying $_myloc_zone"
+ _myloc_response="$(_get "${_myloc_api}/dns/zone/${_myloc_zone}")"
+ _myloc_status=$?
+ _debug "Get zone response $_myloc_response"
+ _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
+ if [ $_myloc_status -eq 0 ] && [ "$_code" = "200" ]; then
+ _debug "Get zone success for $_myloc_zone"
+ echo "${_myloc_zone}"
+ return 0
+ fi
+ _myloc_zone="${_myloc_zone#*.}"
+ done
+
+ _err "Get zone failed for all candidates"
+ return 1
+}
From ef7b2d3c2e537e58f5d61e38d33e35442d7df84b Mon Sep 17 00:00:00 2001
From: wurzelpanzer <32928046+wurzelpanzer@users.noreply.github.com>
Date: Sat, 22 Aug 2026 04:15:52 +0200
Subject: [PATCH 07/14] dns_easydns: match the TXT record by its rdata when
removing (#7199)
dns_easydns_rm() picked the first id in the search response and ignored
$txtvalue. When two challenge records exist under the same host - for
example when example.com and *.example.com are issued as separate
certificates - a concurrent run's record could be deleted instead of
our own.
Select the record by its rdata instead, following the dns_cf.sh
convention of matching name + value. tr '{' '\n' puts one record per
line, so both _egrep_o branches - egrep -o and the BRE sed fallback -
return the same single id. Without it the sed fallback would return
only the last match, since .* is greedy.
An empty record_id is now treated as "nothing to remove" and returns 0,
rather than being reported as an error.
Also add the credential check that _rm was missing. It deliberately
does not call _saveaccountconf_mutable, as _add already does that.
Co-authored-by: wurzelpanzer
---
dnsapi/dns_easydns.sh | 32 +++++++++++++++++---------------
1 file changed, 17 insertions(+), 15 deletions(-)
diff --git a/dnsapi/dns_easydns.sh b/dnsapi/dns_easydns.sh
index 423def2b..2da45866 100644
--- a/dnsapi/dns_easydns.sh
+++ b/dnsapi/dns_easydns.sh
@@ -75,6 +75,11 @@ dns_easydns_rm() {
EASYDNS_Token="${EASYDNS_Token:-$(_readaccountconf_mutable EASYDNS_Token)}"
EASYDNS_Key="${EASYDNS_Key:-$(_readaccountconf_mutable EASYDNS_Key)}"
+ if [ -z "$EASYDNS_Token" ] || [ -z "$EASYDNS_Key" ]; then
+ _err "You didn't specify an easydns.net token or api key. Signup at https://cp.easydns.com/manage/security/api/signup.php"
+ return 1
+ fi
+
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
_err "invalid domain"
@@ -91,24 +96,21 @@ dns_easydns_rm() {
return 1
fi
- count=$(printf "%s\n" "$response" | _egrep_o "\"count\":[^,]*" | cut -d : -f 2)
- _debug count "$count"
- if [ "$count" = "0" ]; then
+ record_id=$(printf "%s\n" "$response" | tr '{' '\n' | grep "\"rdata\":\"$txtvalue\"" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \")
+ _debug "record_id" "$record_id"
+
+ if [ -z "$record_id" ]; then
_info "Don't need to remove."
- else
- record_id=$(printf "%s\n" "$response" | _egrep_o "\"id\":\"[^\"]*\"" | cut -d : -f 2 | tr -d \" | head -n 1)
- _debug "record_id" "$record_id"
- if [ -z "$record_id" ]; then
- _err "Can not get record id to remove."
- return 1
- fi
- if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then
- _err "Delete record error."
- return 1
- fi
- _contains "$response" "\"status\":200"
+ return 0
fi
+ if ! _EASYDNS_rest DELETE "zones/records/$_domain/$record_id"; then
+ _err "Delete record error."
+ return 1
+ fi
+
+ _contains "$response" "\"status\":200"
+
}
#################### Private functions below ##################################
From c6cd844986c7583b509ff36e9ab70a28ca63011a Mon Sep 17 00:00:00 2001
From: skysaint
Date: Sun, 30 Aug 2026 15:33:32 +0800
Subject: [PATCH 08/14] dns_jd: upgrade to JD Cloud v2 API (#7207)
---
dnsapi/dns_jd.sh | 88 ++++++++++++++++++++++++++----------------------
1 file changed, 48 insertions(+), 40 deletions(-)
diff --git a/dnsapi/dns_jd.sh b/dnsapi/dns_jd.sh
index 4b9067f2..58f52351 100644
--- a/dnsapi/dns_jd.sh
+++ b/dnsapi/dns_jd.sh
@@ -7,22 +7,23 @@ Options:
JD_ACCESS_KEY_ID Access key ID
JD_ACCESS_KEY_SECRET Access key secret
JD_REGION Region. E.g. "cn-north-1"
-Issues: github.com/acmesh-official/acme.sh/issues/2388
+Issues: github.com/acmesh-official/acme.sh/issues/7202
+Author: @skysaint
'
_JD_ACCOUNT="https://uc.jdcloud.com/account/accesskey"
-_JD_PROD="clouddnsservice"
+_JD_PROD="domainservice"
_JD_API="jdcloud-api.com"
-_JD_API_VERSION="v1"
+_JD_API_VERSION="v2"
_JD_DEFAULT_REGION="cn-north-1"
_JD_HOST="$_JD_PROD.$_JD_API"
######## Public functions #####################
-#Usage: dns_myapi_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
+#Usage: dns_jd_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_jd_add() {
fulldomain=$1
txtvalue=$2
@@ -58,24 +59,14 @@ dns_jd_add() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
- #_debug "Getting getViewTree"
+ #_debug "Getting describeViewTree"
_debug "Adding records"
- _addrr="{\"req\":{\"hostRecord\":\"$_sub_domain\",\"hostValue\":\"$txtvalue\",\"ttl\":300,\"type\":\"TXT\",\"viewValue\":-1},\"regionId\":\"$JD_REGION\",\"domainId\":\"$_domain_id\"}"
- #_addrr='{"req":{"hostRecord":"xx","hostValue":"\"value4\"","jcloudRes":false,"mxPriority":null,"port":null,"ttl":300,"type":"TXT","weight":null,"viewValue":-1},"regionId":"cn-north-1","domainId":"8824"}'
- if jd_rest POST "domain/$_domain_id/RRAdd" "" "$_addrr"; then
- _rid="$(echo "$response" | tr '{},' '\n' | grep '"id":' | cut -d : -f 2)"
- if [ -z "$_rid" ]; then
- _err "Can not find record id from the result."
- return 1
- fi
+ _addrr="{\"req\":{\"hostRecord\":\"$_sub_domain\",\"hostValue\":\"$txtvalue\",\"ttl\":300,\"type\":\"TXT\",\"viewValue\":-1}}"
+ #_addrr='{"req":{"hostRecord":"_acme-challenge","hostValue":"XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs","ttl":300,"type":"TXT","viewValue":-1}}'
+ if jd_rest POST "domain/$_domain_id/ResourceRecord" "" "$_addrr"; then
_info "TXT record added successfully."
- _srid="$(_readdomainconf "JD_CLOUD_RIDS")"
- if [ "$_srid" ]; then
- _rid="$_srid,$_rid"
- fi
- _savedomainconf "JD_CLOUD_RIDS" "$_rid"
return 0
fi
@@ -97,14 +88,7 @@ dns_jd_rm() {
_JD_BASE_URI="$_JD_API_VERSION/regions/$JD_REGION"
- _info "Getting existing records for $fulldomain"
- _srid="$(_readdomainconf "JD_CLOUD_RIDS")"
- _debug _srid "$_srid"
-
- if [ -z "$_srid" ]; then
- _err "Not rid skip"
- return 0
- fi
+ _info "Removing TXT record for $fulldomain"
_debug "First detect the root zone"
if ! _get_root "$fulldomain"; then
@@ -115,16 +99,37 @@ dns_jd_rm() {
_debug _sub_domain "$_sub_domain"
_debug _domain "$_domain"
- _cleardomainconf JD_CLOUD_RIDS
+ # List records, filter by hostRecord and use a large pageSize so it isn't missed on record-heavy zones.
+ if ! jd_rest GET "domain/$_domain_id/ResourceRecord" "pageSize=50&search=$_sub_domain"; then
+ _err "Failed to list resource records"
+ return 1
+ fi
- _aws_tmpl_xml="{\"ids\":[$_srid],\"action\":\"del\",\"regionId\":\"$JD_REGION\",\"domainId\":\"$_domain_id\"}"
+ # Match record by hostRecord + type TXT + hostValue
+ _record_id=""
+ _matched="$(echo "$response" | tr '{' '\n' | grep "\"hostRecord\":\"$_sub_domain\"" | grep "\"type\":\"TXT\"" | grep "\"hostValue\":\"$txtvalue\"")"
+ _debug2 _matched "$_matched"
- if jd_rest POST "domain/$_domain_id/RROperate" "" "$_aws_tmpl_xml" && _contains "$response" "\"code\":\"OK\""; then
+ if [ -z "$_matched" ]; then
+ _info "TXT record not found, nothing to remove."
+ return 0
+ fi
+
+ _record_id="$(echo "$_matched" | tr ',' '\n' | grep "\"id\":" | cut -d : -f 2 | tr -d '"' | _head_n 1)"
+ _debug _record_id "$_record_id"
+
+ if [ -z "$_record_id" ]; then
+ _info "Could not extract record id from response, nothing to remove."
+ return 0
+ fi
+
+ if jd_rest DELETE "domain/$_domain_id/ResourceRecord/$_record_id"; then
_info "TXT record deleted successfully."
return 0
fi
- return 1
+ _err "Failed to delete TXT record."
+ return 1
}
#################### Private functions below ##################################
@@ -134,13 +139,14 @@ _get_root() {
i=1
p=1
+ if ! jd_rest GET "domain"; then
+ _err "error get domain list"
+ return 1
+ fi
+
while true; do
h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
_debug2 "Checking domain: $h"
- if ! jd_rest GET "domain"; then
- _err "error get domain list"
- return 1
- fi
if [ -z "$h" ]; then
#not valid
_err "Invalid domain"
@@ -168,6 +174,8 @@ _get_root() {
return 1
}
+# Use '%b' with printf to expand \n escapes in CanonicalRequest and StringToSign.
+# Use '%s' for plain values that contain no escapes to avoid unintended expansion.
#method uri qstr data
jd_rest() {
mtd="$1"
@@ -220,7 +228,7 @@ jd_rest() {
CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$RequestPayloadHash"
_debug2 CanonicalRequest "$CanonicalRequest"
- HashedCanonicalRequest="$(printf "$CanonicalRequest%s" | _digest "$Hash" hex)"
+ HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)"
_debug2 HashedCanonicalRequest "$HashedCanonicalRequest"
Algorithm="JDCLOUD2-HMAC-SHA256"
@@ -246,19 +254,19 @@ jd_rest() {
kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")"
_secure_debug2 kSecretH "$kSecretH"
- kDateH="$(printf "$RequestDateOnly%s" | _hmac "$Hash" "$kSecretH" hex)"
+ kDateH="$(printf '%s' "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)"
_debug2 kDateH "$kDateH"
- kRegionH="$(printf "$Region%s" | _hmac "$Hash" "$kDateH" hex)"
+ kRegionH="$(printf '%s' "$Region" | _hmac "$Hash" "$kDateH" hex)"
_debug2 kRegionH "$kRegionH"
- kServiceH="$(printf "$Service%s" | _hmac "$Hash" "$kRegionH" hex)"
+ kServiceH="$(printf '%s' "$Service" | _hmac "$Hash" "$kRegionH" hex)"
_debug2 kServiceH "$kServiceH"
- kSigningH="$(printf "%s" "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)"
+ kSigningH="$(printf '%s' "jdcloud2_request" | _hmac "$Hash" "$kServiceH" hex)"
_debug2 kSigningH "$kSigningH"
- signature="$(printf "$StringToSign%s" | _hmac "$Hash" "$kSigningH" hex)"
+ signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)"
_debug2 signature "$signature"
Authorization="$Algorithm Credential=$JD_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature"
From 6e1deacac0c1700b23e8d18cd67112d8e2fd13df Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 30 Aug 2026 15:42:08 +0800
Subject: [PATCH 09/14] dns_azure: never read or persist AZUREDNS_BEARERTOKEN
from account.conf
Versions up to 3.0.9 cached the internally-acquired access token as
SAVED_AZUREDNS_BEARERTOKEN. 3.1.0 repurposed that variable for
user-supplied bearer tokens, so after an upgrade the stale cached token
was read back as if user-supplied, skipped the refresh path, and failed
renewals with 401 forever once expired.
A bearer token is short-lived, so persisting it is never useful: take it
from the environment only, and clear any stale saved value on the next
run.
fix https://github.com/acmesh-official/acme.sh/issues/7218
---
dnsapi/dns_azure.sh | 12 +++++++-----
1 file changed, 7 insertions(+), 5 deletions(-)
diff --git a/dnsapi/dns_azure.sh b/dnsapi/dns_azure.sh
index f9d84706..4708e151 100644
--- a/dnsapi/dns_azure.sh
+++ b/dnsapi/dns_azure.sh
@@ -9,7 +9,7 @@ Options:
AZUREDNS_APPID App ID. App ID of the service principal
AZUREDNS_CLIENTSECRET Client Secret. Secret from creating the service principal
AZUREDNS_MANAGEDIDENTITY Use Managed Identity. Use Managed Identity assigned to a resource instead of a service principal. "true"/"false"
- AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Optional.
+ AZUREDNS_BEARERTOKEN Bearer Token. Used instead of service principal credentials or managed identity. Not saved, provide it on every run. Optional.
'
wiki=https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Azure-DNS
@@ -47,13 +47,15 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID ""
_saveaccountconf_mutable AZUREDNS_APPID ""
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET ""
- _saveaccountconf_mutable AZUREDNS_BEARERTOKEN ""
+ _clearaccountconf_mutable AZUREDNS_BEARERTOKEN
else
_info "You didn't ask to use Azure managed identity, checking service principal credentials or provided bearer token"
AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}"
- AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}"
+ #AZUREDNS_BEARERTOKEN is short-lived, so it is taken from the environment only and never
+ #read from or saved to the account conf. Versions up to 3.0.9 cached their internal access
+ #token under the same name, which must not be replayed as a user token (#7218).
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID=""
@@ -93,7 +95,7 @@ dns_azure_add() {
_saveaccountconf_mutable AZUREDNS_TENANTID "$AZUREDNS_TENANTID"
_saveaccountconf_mutable AZUREDNS_APPID "$AZUREDNS_APPID"
_saveaccountconf_mutable AZUREDNS_CLIENTSECRET "$AZUREDNS_CLIENTSECRET"
- _saveaccountconf_mutable AZUREDNS_BEARERTOKEN "$AZUREDNS_BEARERTOKEN"
+ _clearaccountconf_mutable AZUREDNS_BEARERTOKEN
fi
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
@@ -175,7 +177,7 @@ dns_azure_rm() {
AZUREDNS_TENANTID="${AZUREDNS_TENANTID:-$(_readaccountconf_mutable AZUREDNS_TENANTID)}"
AZUREDNS_APPID="${AZUREDNS_APPID:-$(_readaccountconf_mutable AZUREDNS_APPID)}"
AZUREDNS_CLIENTSECRET="${AZUREDNS_CLIENTSECRET:-$(_readaccountconf_mutable AZUREDNS_CLIENTSECRET)}"
- AZUREDNS_BEARERTOKEN="${AZUREDNS_BEARERTOKEN:-$(_readaccountconf_mutable AZUREDNS_BEARERTOKEN)}"
+ #AZUREDNS_BEARERTOKEN comes from the environment only, see the note in dns_azure_add
if [ -z "$AZUREDNS_BEARERTOKEN" ]; then
if [ -z "$AZUREDNS_TENANTID" ]; then
AZUREDNS_SUBSCRIPTIONID=""
From 4756183e302d6822a88af971ee737905bfc50c06 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 30 Aug 2026 16:02:57 +0800
Subject: [PATCH 10/14] Never truncate the conf file when a saved value breaks
the rewrite sed
A value holding a backslash-digit sequence (a backreference to sed) or an
embedded line break made _setopt's replace command fail after the shell
had already truncated the conf file, wiping the whole domain conf; the
next renewal then fails with an empty Le_API and no validation method.
Same class as #2426, which escaped only '&' and '|'.
Escape the backslash too, write the sed output back only when sed
succeeds, reject values holding a line break, and rewrite the file with
printf instead of echo in the append path and in _clear_conf: dash's
builtin echo interprets backslash escapes and corrupted such values on
every rewrite.
https://github.com/acmesh-official/acme.sh/issues/7213
---
acme.sh | 59 +++++++++++++++++++++++++++++++++++++++++----------------
1 file changed, 43 insertions(+), 16 deletions(-)
diff --git a/acme.sh b/acme.sh
index 20a12992..99d68656 100755
--- a/acme.sh
+++ b/acme.sh
@@ -2499,6 +2499,15 @@ _send_signed_request() {
}
+#Reads a value from stdin, prints it escaped for use as the replacement text
+#of a sed s command delimited by '|'. The backslash must go first: a bare one
+#starts an escape sequence and backslash-digit is a backreference, both make
+#sed error out. Then '&' (the whole-match reference) and the '|' delimiter.
+#https://github.com/acmesh-official/acme.sh/issues/7213
+_sed_escape_rhs() {
+ sed -e 's/\\/\\\\/g' -e 's/&/\\&/g' -e 's/|/\\|/g'
+}
+
#setopt "file" "opt" "=" "value" [";"]
_setopt() {
__conf="$1"
@@ -2514,34 +2523,50 @@ _setopt() {
touch "$__conf"
chmod 600 "$__conf"
fi
+ __nl="
+"
+ case "$__val" in
+ *"$__nl"*)
+ #the conf format is line based and the file is sourced by the shell, so a
+ #value holding a line break cannot be represented in it (it would also
+ #make the replace sed below fail with an unterminated 's' command)
+ _err "The value of '$__opt' contains a line break, it cannot be saved to $__conf."
+ return 1
+ ;;
+ esac
if [ -n "$(_tail_c 1 <"$__conf")" ]; then
echo >>"$__conf"
fi
if grep -n "^$__opt$__sep" "$__conf" >/dev/null; then
_debug3 OK
- if _contains "$__val" "&"; then
- __val="$(echo "$__val" | sed 's/&/\\&/g')"
- fi
- if _contains "$__val" "|"; then
- __val="$(echo "$__val" | sed 's/|/\\|/g')"
- fi
+ __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)"
text="$(cat "$__conf")"
- printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|" >"$__conf"
+ #capture first, write only on success: redirecting sed straight into the
+ #conf file truncates it before sed runs, so a failing sed (e.g. on an
+ #unescaped special character in the value) wiped the whole conf (#2426)
+ if __text="$(printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then
+ printf -- "%s\n" "$__text" >"$__conf"
+ else
+ _err "Cannot save '$__opt' to $__conf."
+ return 1
+ fi
elif grep -n "^#$__opt$__sep" "$__conf" >/dev/null; then
- if _contains "$__val" "&"; then
- __val="$(echo "$__val" | sed 's/&/\\&/g')"
- fi
- if _contains "$__val" "|"; then
- __val="$(echo "$__val" | sed 's/|/\\|/g')"
- fi
+ __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)"
text="$(cat "$__conf")"
- printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|" >"$__conf"
+ if __text="$(printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then
+ printf -- "%s\n" "$__text" >"$__conf"
+ else
+ _err "Cannot save '$__opt' to $__conf."
+ return 1
+ fi
else
_debug3 APP
- echo "$__opt$__sep$__val$__end" >>"$__conf"
+ #printf, not echo: dash's builtin echo interprets backslash escapes in
+ #the value and would corrupt it
+ printf -- "%s\n" "$__opt$__sep$__val$__end" >>"$__conf"
fi
_debug3 "$(grep -n "^$__opt$__sep" "$__conf")"
}
@@ -2569,7 +2594,9 @@ _clear_conf() {
_sdkey="$2"
if [ "$_c_c_f" ]; then
_conf_data="$(cat "$_c_c_f")"
- echo "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f"
+ #printf, not echo: dash's builtin echo interprets backslash escapes and
+ #would corrupt saved values that contain them on every rewrite
+ printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f"
else
_err "Config file is empty, cannot clear"
fi
From cce4a28b99da1acd2a620613e43f88d92079e75b Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 30 Aug 2026 17:39:57 +0800
Subject: [PATCH 11/14] Fix the standalone port check on the bsds, macos and
haiku
---
.github/workflows/OpenEuler.yml | 10 +++++-
acme.sh | 59 +++++++++++++++++++++++----------
2 files changed, 50 insertions(+), 19 deletions(-)
diff --git a/.github/workflows/OpenEuler.yml b/.github/workflows/OpenEuler.yml
index 2b4bd0ab..35625a73 100644
--- a/.github/workflows/OpenEuler.yml
+++ b/.github/workflows/OpenEuler.yml
@@ -56,7 +56,15 @@ jobs:
envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN'
nat: |
"8080": "80"
- prepare: dnf install -y curl socat cronie tar gzip
+ prepare: |
+ # openEuler ships every repo with both a baseurl and a metalink.
+ # The metalink mirror pool is split-brain (dl-cdn.openeuler.openatom.cn
+ # froze at the 2026-08-20 snapshot while repo.openeuler.org moved on),
+ # so dnf takes repomd.xml from the stale mirror and then 404s fetching
+ # the checksummed metadata it names from the fresh ones. Keep only the
+ # vendor baseurl, which is self-consistent.
+ sed -i '/^metalink=/d' /etc/yum.repos.d/*.repo
+ dnf install -y curl socat cronie tar gzip
usesh: true
sync: rsync
copyback: false
diff --git a/acme.sh b/acme.sh
index 99d68656..a814d5e7 100755
--- a/acme.sh
+++ b/acme.sh
@@ -1482,39 +1482,57 @@ _readKeyLengthFromCSR() {
fi
}
+#port
+#Reads a netstat or ss listing on stdin, prints the lines that show a socket
+#listening on port.
+#Linux and windows print the local address as "addr:port", aix, macos, the
+#bsds and solaris print it as "addr.port", so both separators must match.
+#The state is "LISTEN" nearly everywhere, "LISTENING" on windows and lower
+#case "listen" on haiku, hence the substring match and the -i.
+_filter_listen_port() {
+ _flp_port="$1"
+ if [ -z "$_flp_port" ]; then
+ return
+ fi
+ grep -i "LISTEN" | grep "[:.]$_flp_port "
+}
+
+#port
_ss() {
_port="$1"
if _exists "ss"; then
_debug "Using: ss"
- ss -ntpl 2>/dev/null | grep ":$_port "
+ ss -ntpl 2>/dev/null | _filter_listen_port "$_port"
return 0
fi
- if [ "$(uname)" = "AIX" ]; then
- _debug "Using: AIX netstat"
- netstat -an | grep "^tcp" | grep "LISTEN" | grep "\.$_port "
+ #aix, macos and the bsds have no "-p protocol" socket listing that works on
+ #all of them: on netbsd "-p" is "Show statistics about protocol" instead
+ #(netstat(1), NetBSD 10.1). Their default display does show "the state of
+ #all sockets" with -a, so use that and keep only the tcp lines.
+ case "$(uname)" in
+ AIX | Darwin | DragonFly | *BSD*)
+ _debug "Using: AIX/BSD netstat"
+ netstat -an | grep "^tcp" | _filter_listen_port "$_port"
return 0
- fi
+ ;;
+ esac
if _exists "netstat"; then
_debug "Using: netstat"
if netstat -help 2>&1 | grep "\-p proto" >/dev/null; then
#for windows version netstat tool
- netstat -an -p tcp | grep "LISTENING" | grep ":$_port "
+ netstat -an -p tcp | _filter_listen_port "$_port"
+ elif netstat -help 2>&1 | grep -- '-P protocol' >/dev/null; then
+ #for solaris
+ netstat -an -P tcp | _filter_listen_port "$_port"
+ elif netstat -help 2>&1 | grep "\-p" >/dev/null; then
+ #for full linux
+ netstat -ntpl | _filter_listen_port "$_port"
else
- if netstat -help 2>&1 | grep "\-p protocol" >/dev/null; then
- netstat -an -p tcp | grep LISTEN | grep ":$_port "
- elif netstat -help 2>&1 | grep -- '-P protocol' >/dev/null; then
- #for solaris
- netstat -an -P tcp | grep "\.$_port " | grep "LISTEN"
- elif netstat -help 2>&1 | grep "\-p" >/dev/null; then
- #for full linux
- netstat -ntpl | grep ":$_port "
- else
- #for busybox (embedded linux; no pid support)
- netstat -ntl 2>/dev/null | grep ":$_port "
- fi
+ #for busybox (embedded linux; no pid support)
+ netstat -ntl 2>/dev/null | _filter_listen_port "$_port"
fi
return 0
fi
@@ -3938,6 +3956,11 @@ _on_before_issue() {
if [ -z "$netprc" ]; then
netprc="$(echo "$_netprc" | grep "$LOCAL_ANY_ADDRESS:$_checkport")"
fi
+ if [ -z "$netprc" ]; then
+ #aix, macos, the bsds and solaris print the wildcard local address as
+ #"*.port", not "0.0.0.0:port", and it blocks $_checkaddr just the same
+ netprc="$(echo "$_netprc" | grep " [*][:.]$_checkport ")"
+ fi
if [ "$netprc" ]; then
_err "$netprc"
_err "tcp port $_checkport is already used by $(echo "$netprc" | cut -d : -f 4)"
From cdca555cad2b8a75c99b63dcd36b431c2b1a16f2 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 30 Aug 2026 17:46:48 +0800
Subject: [PATCH 12/14] Add OPNsense and HardenedBSD to CI
---
.github/workflows/DNS.yml | 113 ++++++++++++++++++++++++++++++
.github/workflows/HardenedBSD.yml | 76 ++++++++++++++++++++
.github/workflows/OPNsense.yml | 86 +++++++++++++++++++++++
README.md | 4 ++
4 files changed, 279 insertions(+)
create mode 100644 .github/workflows/HardenedBSD.yml
create mode 100644 .github/workflows/OPNsense.yml
diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml
index 84a17470..6dd7400f 100644
--- a/.github/workflows/DNS.yml
+++ b/.github/workflows/DNS.yml
@@ -973,3 +973,116 @@ jobs:
+ HardenedBSD:
+ runs-on: ubuntu-latest
+ needs: OpenEuler
+ 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 }}
+ 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/hardenedbsd-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
+ sync: nfs
+ 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"
+
+
+
+ OPNsense:
+ runs-on: ubuntu-latest
+ needs: HardenedBSD
+ 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 }}
+ 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/opnsense-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}}'
+ #The dns-01 cases need no inbound port, so the appliance's web GUI can
+ #keep the 80 port here, unlike the standalone workflow.
+ prepare: pkg install -y socat curl
+ usesh: true
+ sync: nfs
+ 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"
diff --git a/.github/workflows/HardenedBSD.yml b/.github/workflows/HardenedBSD.yml
new file mode 100644
index 00000000..f5576856
--- /dev/null
+++ b/.github/workflows/HardenedBSD.yml
@@ -0,0 +1,76 @@
+name: HardenedBSD
+on:
+ push:
+ branches:
+ - '*'
+ paths:
+ - '*.sh'
+ - '.github/workflows/HardenedBSD.yml'
+
+ pull_request:
+ branches:
+ - dev
+ paths:
+ - '*.sh'
+ - '.github/workflows/HardenedBSD.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+
+
+jobs:
+ HardenedBSD:
+ strategy:
+ matrix:
+ include:
+ - TEST_ACME_Server: "LetsEncrypt.org_test"
+ CA_ECDSA: ""
+ CA: ""
+ CA_EMAIL: ""
+ TEST_PREFERRED_CHAIN: (STAGING)
+ - TEST_ACME_Server: "LetsEncrypt.org_test"
+ CA_ECDSA: ""
+ CA: ""
+ CA_EMAIL: ""
+ TEST_PREFERRED_CHAIN: (STAGING)
+ ACME_USE_WGET: 1
+ 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 }}
+ ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }}
+ 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/hardenedbsd-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"
+ prepare: pkg install -y socat curl wget
+ usesh: true
+ sync: nfs
+ 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/.github/workflows/OPNsense.yml b/.github/workflows/OPNsense.yml
new file mode 100644
index 00000000..d1d9570d
--- /dev/null
+++ b/.github/workflows/OPNsense.yml
@@ -0,0 +1,86 @@
+name: OPNsense
+on:
+ push:
+ branches:
+ - '*'
+ paths:
+ - '*.sh'
+ - '.github/workflows/OPNsense.yml'
+
+ pull_request:
+ branches:
+ - dev
+ paths:
+ - '*.sh'
+ - '.github/workflows/OPNsense.yml'
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+
+
+jobs:
+ OPNsense:
+ strategy:
+ matrix:
+ include:
+ - TEST_ACME_Server: "LetsEncrypt.org_test"
+ CA_ECDSA: ""
+ CA: ""
+ CA_EMAIL: ""
+ TEST_PREFERRED_CHAIN: (STAGING)
+ - TEST_ACME_Server: "LetsEncrypt.org_test"
+ CA_ECDSA: ""
+ CA: ""
+ CA_EMAIL: ""
+ TEST_PREFERRED_CHAIN: (STAGING)
+ ACME_USE_WGET: 1
+ 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 }}
+ ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }}
+ 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/opnsense-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"
+ prepare: pkg install -y socat curl wget
+ usesh: true
+ sync: nfs
+ run: |
+ #OPNsense is a firewall appliance whose web GUI holds the 80 port,
+ #where every --standalone case listens. configd has no "stop"
+ #action for it and the rc script cannot stop it either, so kill it.
+ #This belongs here and not in prepare: prepare runs before the
+ #cache-after-prepare reboot, which would bring the GUI back. And do
+ #NOT free the port by disabling the GUI's http redirect in
+ #config.xml: pf's automatic pass rule for the 80 port is generated
+ #from the web GUI settings, so dropping the redirect also drops the
+ #rule on the next boot, and the inbound challenge is filtered.
+ pkill lighttpd || true
+ 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 2d0e130c..8ffb54df 100644
--- a/README.md
+++ b/README.md
@@ -38,6 +38,8 @@
+
+
@@ -134,6 +136,8 @@
|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
|29|[](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml)|openEuler
+|30|[](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml)|HardenedBSD
+|31|[](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml)|OPNsense
> 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest)
From 19efdf269a3501033b49c9ac5009568cd215e2e2 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 30 Aug 2026 20:47:24 +0800
Subject: [PATCH 13/14] Retry a gateway error from the CA instead of failing
the order
---
acme.sh | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/acme.sh b/acme.sh
index a814d5e7..871430d9 100755
--- a/acme.sh
+++ b/acme.sh
@@ -2361,6 +2361,21 @@ _tail_c() {
tail -c "$1" 2>/dev/null || tail -"$1"c
}
+#code
+#Is this status the CA's front end failing rather than its ACME
+#implementation answering? 502 and 504 mean the proxy could not reach the
+#backend or gave up waiting for it, 503 that it is overloaded. The body of
+#those is the proxy's html, not problem+json, so no ACME status can be read
+#out of it and a caller looking for one abandons an order that is fine.
+#Anything else, a 500 from the ACME implementation included, is a real
+#answer and must be passed through to the caller.
+_is_gateway_error() {
+ case "$1" in
+ 502 | 503 | 504) return 0 ;;
+ esac
+ return 1
+}
+
# url payload needbase64 keyfile
_send_signed_request() {
url=$1
@@ -2484,13 +2499,13 @@ _send_signed_request() {
fi
_retryafter=$(echo "$responseHeaders" | grep -i "^Retry-After *: *[0-9]\+ *" | cut -d : -f 2 | tr -d ' ' | tr -d '\r')
- if [ "$code" = '503' ]; then
+ if _is_gateway_error "$code"; then
_sleep_overload_retry_sec=$_retryafter
if [ -z "$_sleep_overload_retry_sec" ]; then
_sleep_overload_retry_sec=5
fi
if [ $_sleep_overload_retry_sec -le 600 ]; then
- _info "It seems the CA server is currently overloaded, let's wait and retry. Sleeping for $_sleep_overload_retry_sec seconds."
+ _info "The CA server answered $code, let's wait and retry. Sleeping for $_sleep_overload_retry_sec seconds."
_sleep $_sleep_overload_retry_sec
continue
else
From 2b7487ba729c0304b46b357f619054f32e569cd2 Mon Sep 17 00:00:00 2001
From: neil
Date: Sun, 30 Aug 2026 21:24:02 +0800
Subject: [PATCH 14/14] Back off between retries instead of a flat two seconds
---
acme.sh | 25 ++++++++++++++++++++++---
1 file changed, 22 insertions(+), 3 deletions(-)
diff --git a/acme.sh b/acme.sh
index 871430d9..1667adfb 100755
--- a/acme.sh
+++ b/acme.sh
@@ -2376,6 +2376,24 @@ _is_gateway_error() {
return 1
}
+#attempt
+#Seconds to wait before retry number , for the cases where the CA
+#gave us no Retry-After to go by. A flat two seconds let the whole twenty
+#attempt budget burn out in forty eight seconds, which is shorter than the
+#gateway outages a CA really has: ZeroSSL answered 502 and 504 for over a
+#minute at a time through August 2026, so every renewal that started during
+#one of those died instead of waiting it out. Backing off spends the same
+#twenty attempts over about six minutes, which is still far below the ten
+#minutes at which a Retry-After is read as the CA refusing outright.
+_retry_backoff_sec() {
+ case "$1" in
+ 1) echo 2 ;;
+ 2) echo 5 ;;
+ 3) echo 10 ;;
+ *) echo 20 ;;
+ esac
+}
+
# url payload needbase64 keyfile
_send_signed_request() {
url=$1
@@ -2439,8 +2457,9 @@ _send_signed_request() {
nonce="$_CACHED_NONCE"
_debug2 nonce "$nonce"
if [ -z "$nonce" ]; then
- _info "Could not get nonce, let's try again."
- _sleep 2
+ _sleep_nonce_sec="$(_retry_backoff_sec "$_request_retry_times")"
+ _info "Could not get nonce, let's try again. Sleeping for $_sleep_nonce_sec seconds."
+ _sleep "$_sleep_nonce_sec"
continue
fi
@@ -2502,7 +2521,7 @@ _send_signed_request() {
if _is_gateway_error "$code"; then
_sleep_overload_retry_sec=$_retryafter
if [ -z "$_sleep_overload_retry_sec" ]; then
- _sleep_overload_retry_sec=5
+ _sleep_overload_retry_sec="$(_retry_backoff_sec "$_request_retry_times")"
fi
if [ $_sleep_overload_retry_sec -le 600 ]; then
_info "The CA server answered $code, let's wait and retry. Sleeping for $_sleep_overload_retry_sec seconds."