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 @@ Haiku Hurd OpenEuler + HardenedBSD + OPNsense

@@ -134,6 +136,8 @@ |27|[![GhostBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/GhostBSD.yml)|GhostBSD |28|[![Hurd](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Hurd.yml)|GNU Hurd |29|[![OpenEuler](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/OpenEuler.yml)|openEuler +|30|[![HardenedBSD](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/HardenedBSD.yml)|HardenedBSD +|31|[![OPNsense](https://github.com/acmesh-official/acme.sh/actions/workflows/OPNsense.yml/badge.svg)](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."