From 237f2d9c3b3a54bd1cf7e5ce118de7ec4f9fc8ae Mon Sep 17 00:00:00 2001 From: Achmad Alif Nasrulloh Date: Fri, 10 Jul 2026 18:06:13 +0700 Subject: [PATCH 01/29] Add notify waha support --- notify/waha.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 notify/waha.sh diff --git a/notify/waha.sh b/notify/waha.sh new file mode 100755 index 00000000..022d8916 --- /dev/null +++ b/notify/waha.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env sh + +#Support WAHA (WhatsApp HTTP API) - free, self-hosted WhatsApp API +#https://waha.devlike.pro/ + +#Required: +#WAHA_URL="http://localhost:3000" +#WAHA_CHAT_ID="1234567890@c.us" + +#Optional: +#WAHA_API_KEY="" +#WAHA_SESSION="default" + +waha_send() { + _subject="$1" + _content="$2" + _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped + _debug "_subject" "$_subject" + _debug "_content" "$_content" + _debug "_statusCode" "$_statusCode" + + WAHA_URL="${WAHA_URL:-$(_readaccountconf_mutable WAHA_URL)}" + if [ -z "$WAHA_URL" ]; then + WAHA_URL="" + _err "You didn't specify the WAHA server url WAHA_URL yet." + _err "Example: export WAHA_URL=\"http://localhost:3000\"" + return 1 + fi + _saveaccountconf_mutable WAHA_URL "$WAHA_URL" + + WAHA_CHAT_ID="${WAHA_CHAT_ID:-$(_readaccountconf_mutable WAHA_CHAT_ID)}" + if [ -z "$WAHA_CHAT_ID" ]; then + WAHA_CHAT_ID="" + _err "You didn't specify the WhatsApp chat id WAHA_CHAT_ID yet." + _err "Example: export WAHA_CHAT_ID=\"1234567890@c.us\"" + return 1 + fi + _saveaccountconf_mutable WAHA_CHAT_ID "$WAHA_CHAT_ID" + + WAHA_API_KEY="${WAHA_API_KEY:-$(_readaccountconf_mutable WAHA_API_KEY)}" + if [ "$WAHA_API_KEY" ]; then + _saveaccountconf_mutable WAHA_API_KEY "$WAHA_API_KEY" + fi + + WAHA_SESSION="${WAHA_SESSION:-$(_readaccountconf_mutable WAHA_SESSION)}" + if [ -z "$WAHA_SESSION" ]; then + WAHA_SESSION="default" + else + _saveaccountconf_mutable WAHA_SESSION "$WAHA_SESSION" + fi + + _content=$(printf "*%s*\n%s" "$_subject" "$_content" | _json_encode) + + _data="{\"chatId\": \"$WAHA_CHAT_ID\", " + _data="$_data\"text\": \"$_content\", " + _data="$_data\"session\": \"$WAHA_SESSION\"}" + + _debug "_data" "$_data" + + export _H1="Content-Type: application/json" + if [ "$WAHA_API_KEY" ]; then + export _H2="X-Api-Key: $WAHA_API_KEY" + fi + + _waha_url="${WAHA_URL}/api/sendText" + response="$(_post "$_data" "$_waha_url" "" "POST" "application/json")" + + if [ "$?" = "0" ] && _contains "$response" "id"; then + _info "waha send success." + return 0 + fi + _err "waha send error." + _err "$response" + return 1 +} From d45b6fe8e6b84a85e1a9bdb13ee6bbc6d647dc16 Mon Sep 17 00:00:00 2001 From: Achmad Alif Nasrulloh Date: Sat, 11 Jul 2026 18:42:53 +0700 Subject: [PATCH 02/29] fix(notify): waha: tighten response check --- notify/waha.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notify/waha.sh b/notify/waha.sh index 022d8916..989f57ac 100755 --- a/notify/waha.sh +++ b/notify/waha.sh @@ -65,7 +65,7 @@ waha_send() { _waha_url="${WAHA_URL}/api/sendText" response="$(_post "$_data" "$_waha_url" "" "POST" "application/json")" - if [ "$?" = "0" ] && _contains "$response" "id"; then + if [ "$?" = "0" ] && _contains "$response" "\"id\""; then _info "waha send success." return 0 fi From a82cf763cfe8a69f8329f4f0d2cfc4b5a8cb6240 Mon Sep 17 00:00:00 2001 From: Achmad Alif Nasrulloh Date: Thu, 16 Jul 2026 11:19:07 +0700 Subject: [PATCH 03/29] fix(notify): remove duplicate Content-Type header in waha hook --- notify/waha.sh | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/notify/waha.sh b/notify/waha.sh index 989f57ac..573295e9 100755 --- a/notify/waha.sh +++ b/notify/waha.sh @@ -57,9 +57,8 @@ waha_send() { _debug "_data" "$_data" - export _H1="Content-Type: application/json" if [ "$WAHA_API_KEY" ]; then - export _H2="X-Api-Key: $WAHA_API_KEY" + export _H1="X-Api-Key: $WAHA_API_KEY" fi _waha_url="${WAHA_URL}/api/sendText" From 6d559ae69f6b18bd1b4e0087dafba695acbbd933 Mon Sep 17 00:00:00 2001 From: ACHMAD ALIF NASRULLOH <106044706+achmadalifn4@users.noreply.github.com> Date: Thu, 16 Jul 2026 11:29:39 +0700 Subject: [PATCH 04/29] Add newline at end of waha.sh Fix missing newline at end of file. --- notify/waha.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notify/waha.sh b/notify/waha.sh index bbb1dc44..573295e9 100755 --- a/notify/waha.sh +++ b/notify/waha.sh @@ -71,4 +71,4 @@ waha_send() { _err "waha send error." _err "$response" return 1 -} \ No newline at end of file +} From 7fa301821911ca93bbf82ad77edef577ac07785e Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 17 Jul 2026 22:03:00 +0800 Subject: [PATCH 05/29] feat: add ACME_PACKAGED for distro-packaged installs When ACME_PACKAGED is set (e.g. exported by a distro package wrapper): - --install does not copy the script or the hooks into LE_WORKING_DIR; the cron job and the shell alias point to the packaged script instead - --upgrade, --install-online and the cron AUTO_UPGRADE path refuse and point to the system package manager - --uninstall does not remove the packaged files https://github.com/acmesh-official/acme.sh/issues/7135 --- acme.sh | 101 ++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 68 insertions(+), 33 deletions(-) diff --git a/acme.sh b/acme.sh index 753b1159..274af3ac 100755 --- a/acme.sh +++ b/acme.sh @@ -7528,6 +7528,15 @@ _installalias() { _c_home="$1" _initpath + _alias_bin="$LE_WORKING_DIR/$PROJECT_ENTRY" + if [ ! -f "$_alias_bin" ]; then + #ACME_PACKAGED install: no copy in LE_WORKING_DIR, alias the current script + _script="$(_readlink "$_SCRIPT_")" + if [ -f "$_script" ]; then + _alias_bin="$_script" + fi + fi + _envfile="$LE_WORKING_DIR/$PROJECT_ENTRY.env" if [ "$_upgrading" ] && [ "$_upgrading" = "1" ]; then echo "$(cat "$_envfile")" | sed "s|^LE_WORKING_DIR.*$||" >"$_envfile" @@ -7545,7 +7554,7 @@ _installalias() { else _sed_i "/^export LE_CONFIG_HOME/d" "$_envfile" fi - _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + _setopt "$_envfile" "alias $PROJECT_ENTRY" "=" "\"$_alias_bin$_c_entry\"" if [ -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" ]; then #the completion file does nothing when sourced by a non-bash shell _setopt "$_envfile" ". \"$LE_WORKING_DIR/$PROJECT_ENTRY.completion\"" @@ -7572,7 +7581,7 @@ _installalias() { else _sed_i "/^setenv LE_CONFIG_HOME/d" "$_cshfile" fi - _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\"" _setopt "$_csh_profile" "source \"$_cshfile\"" fi @@ -7584,7 +7593,7 @@ _installalias() { if [ "$_c_home" ]; then _setopt "$_cshfile" "setenv LE_CONFIG_HOME" " " "\"$LE_CONFIG_HOME\"" fi - _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$LE_WORKING_DIR/$PROJECT_ENTRY$_c_entry\"" + _setopt "$_cshfile" "alias $PROJECT_ENTRY" " " "\"$_alias_bin$_c_entry\"" _setopt "$_tcsh_profile" "source \"$_cshfile\"" fi @@ -7658,30 +7667,38 @@ install() { chmod 700 "$LE_CONFIG_HOME" fi - cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY" + if [ "$ACME_PACKAGED" ]; then + #the script and its hooks are managed by a system package manager, + #do not copy them into LE_WORKING_DIR. https://github.com/acmesh-official/acme.sh/issues/7135 + _info "ACME_PACKAGED is set, skipping the script copy." + else + cp "$PROJECT_ENTRY" "$LE_WORKING_DIR/" && chmod +x "$LE_WORKING_DIR/$PROJECT_ENTRY" - if [ "$?" != "0" ]; then - _err "Installation failed, cannot copy $PROJECT_ENTRY" - return 1 - fi + if [ "$?" != "0" ]; then + _err "Installation failed, cannot copy $PROJECT_ENTRY" + return 1 + fi - _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY" + _info "Installed to $LE_WORKING_DIR/$PROJECT_ENTRY" - if [ -f "$PROJECT_ENTRY.completion" ]; then - cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/" - _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion" + if [ -f "$PROJECT_ENTRY.completion" ]; then + cp "$PROJECT_ENTRY.completion" "$LE_WORKING_DIR/" + _debug "Installed bash completion to $LE_WORKING_DIR/$PROJECT_ENTRY.completion" + fi fi if [ "$_ACME_IN_CRON" != "1" ] && [ -z "$_noprofile" ]; then _installalias "$_c_home" fi - for subf in $_SUB_FOLDERS; do - if [ -d "$subf" ]; then - mkdir -p "$LE_WORKING_DIR/$subf" - cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/ - fi - done + if [ -z "$ACME_PACKAGED" ]; then + for subf in $_SUB_FOLDERS; do + if [ -d "$subf" ]; then + mkdir -p "$LE_WORKING_DIR/$subf" + cp "$subf"/* "$LE_WORKING_DIR"/"$subf"/ + fi + done + fi if [ ! -f "$ACCOUNT_CONF_PATH" ]; then _initconf @@ -7709,7 +7726,7 @@ install() { installcronjob "$_c_home" fi - if [ -z "$NO_DETECT_SH" ]; then + if [ -z "$NO_DETECT_SH" ] && [ -z "$ACME_PACKAGED" ]; then #Modify shebang if _exists bash; then _bash_path="$(bash -c "command -v bash 2>/dev/null")" @@ -7734,7 +7751,9 @@ install() { if [ "$_accountemail" ]; then _saveaccountconf "ACCOUNT_EMAIL" "$_accountemail" fi - _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)" + if [ -z "$ACME_PACKAGED" ]; then + _saveaccountconf "UPGRADE_HASH" "$(_getUpgradeHash)" + fi _info OK } @@ -7748,8 +7767,12 @@ uninstall() { _uninstallalias - rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" - rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" + if [ -z "$ACME_PACKAGED" ]; then + #don't remove the script when it is managed by a system package manager, + #LE_WORKING_DIR may point to the packaged files + rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY" + rm -f "$LE_WORKING_DIR/$PROJECT_ENTRY.completion" + fi _info "The keys and certs are in \"$(__green "$LE_CONFIG_HOME")\". You can remove them by yourself." } @@ -7785,20 +7808,24 @@ cron() { _initpath _info "$(__green "===Starting cron===")" if [ "$AUTO_UPGRADE" = "1" ]; then - export LE_WORKING_DIR - ( - if ! upgrade; then - _err "Cron: Upgrade failed!" - return 1 + if [ "$ACME_PACKAGED" ]; then + _info "ACME_PACKAGED is set, skipping the auto upgrade." + else + export LE_WORKING_DIR + ( + if ! upgrade; then + _err "Cron: Upgrade failed!" + return 1 + fi + ) + . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null + + if [ -t 1 ]; then + __INTERACTIVE="1" fi - ) - . "$LE_WORKING_DIR/$PROJECT_ENTRY" >/dev/null - if [ -t 1 ]; then - __INTERACTIVE="1" + _info "Automatically upgraded to: $VER" fi - - _info "Automatically upgraded to: $VER" fi _TREAT_SKIP_AS_SUCCESS="1" renewAll @@ -8128,6 +8155,10 @@ Parameters: } installOnline() { + if [ "$ACME_PACKAGED" ]; then + _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade." + return 1 + fi _info "Installing from online archive." _branch="$BRANCH" @@ -8185,6 +8216,10 @@ _getUpgradeHash() { } upgrade() { + if [ "$ACME_PACKAGED" ]; then + _err "ACME_PACKAGED is set: acme.sh is managed by the system package manager, please use it to upgrade." + exit 1 + fi if ( _initpath [ -z "$FORCE" ] && [ "$(_getUpgradeHash)" = "$(_readaccountconf "UPGRADE_HASH")" ] && _info "Already up to date!" && exit 0 From 97c5aca136c37830ffcd116435aa1eb9b2d80aa1 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 18 Jul 2026 09:32:26 +0800 Subject: [PATCH 06/29] add cache-after-prepare: true --- .github/workflows/DNS.yml | 11 +++++++++++ .github/workflows/DragonFlyBSD.yml | 1 + .github/workflows/FreeBSD.yml | 1 + .github/workflows/GhostBSD.yml | 1 + .github/workflows/Haiku.yml | 1 + .github/workflows/MidnightBSD.yml | 1 + .github/workflows/NetBSD.yml | 1 + .github/workflows/Omnios.yml | 1 + .github/workflows/OpenBSD.yml | 1 + .github/workflows/OpenIndiana.yml | 1 + .github/workflows/Solaris.yml | 1 + .github/workflows/Tribblix.yml | 1 + 12 files changed, 22 insertions(+) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 5417068f..f7d5cb98 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -237,6 +237,7 @@ jobs: - uses: vmactions/freebsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg install -y socat curl usesh: true @@ -295,6 +296,7 @@ jobs: - uses: vmactions/ghostbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg install -y socat curl usesh: true @@ -351,6 +353,7 @@ jobs: - uses: vmactions/openbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: pkg_add socat curl libiconv usesh: true @@ -407,6 +410,7 @@ jobs: - uses: vmactions/netbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: | /usr/sbin/pkg_add curl socat @@ -464,6 +468,7 @@ jobs: - uses: vmactions/dragonflybsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: | pkg install -y libnghttp2 @@ -525,6 +530,7 @@ jobs: - uses: vmactions/midnightbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' prepare: mport install socat curl || true usesh: true @@ -582,6 +588,7 @@ jobs: - uses: vmactions/solaris-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: | @@ -641,6 +648,7 @@ jobs: - uses: vmactions/omnios-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: pkg install socat @@ -697,6 +705,7 @@ jobs: - uses: vmactions/openindiana-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: pkg install socat @@ -753,6 +762,7 @@ jobs: - uses: vmactions/tribblix-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: nfs prepare: zap install socat @@ -809,6 +819,7 @@ jobs: - uses: vmactions/haiku-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' sync: rsync copyback: false diff --git a/.github/workflows/DragonFlyBSD.yml b/.github/workflows/DragonFlyBSD.yml index c8cbc985..20c61dcc 100644 --- a/.github/workflows/DragonFlyBSD.yml +++ b/.github/workflows/DragonFlyBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/dragonflybsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/FreeBSD.yml b/.github/workflows/FreeBSD.yml index 50fcab32..88ef0a6e 100644 --- a/.github/workflows/FreeBSD.yml +++ b/.github/workflows/FreeBSD.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/freebsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml index c77fdf2e..04510c15 100644 --- a/.github/workflows/GhostBSD.yml +++ b/.github/workflows/GhostBSD.yml @@ -66,6 +66,7 @@ jobs: - uses: vmactions/ghostbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Haiku.yml b/.github/workflows/Haiku.yml index 9884ebeb..b133dd18 100644 --- a/.github/workflows/Haiku.yml +++ b/.github/workflows/Haiku.yml @@ -65,6 +65,7 @@ jobs: - uses: vmactions/haiku-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/MidnightBSD.yml b/.github/workflows/MidnightBSD.yml index 15024833..ce499e4e 100644 --- a/.github/workflows/MidnightBSD.yml +++ b/.github/workflows/MidnightBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/midnightbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/NetBSD.yml b/.github/workflows/NetBSD.yml index 16d0ae2d..6695f71e 100644 --- a/.github/workflows/NetBSD.yml +++ b/.github/workflows/NetBSD.yml @@ -58,6 +58,7 @@ jobs: - uses: vmactions/netbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Omnios.yml b/.github/workflows/Omnios.yml index eb486b35..aabb168b 100644 --- a/.github/workflows/Omnios.yml +++ b/.github/workflows/Omnios.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/omnios-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/OpenBSD.yml b/.github/workflows/OpenBSD.yml index 4fdb76c5..46318163 100644 --- a/.github/workflows/OpenBSD.yml +++ b/.github/workflows/OpenBSD.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/openbsd-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/OpenIndiana.yml b/.github/workflows/OpenIndiana.yml index b5061ba7..e3119f8e 100644 --- a/.github/workflows/OpenIndiana.yml +++ b/.github/workflows/OpenIndiana.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/openindiana-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Solaris.yml b/.github/workflows/Solaris.yml index 3393269a..30e4e291 100644 --- a/.github/workflows/Solaris.yml +++ b/.github/workflows/Solaris.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/solaris-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" diff --git a/.github/workflows/Tribblix.yml b/.github/workflows/Tribblix.yml index cd43e0e3..68e61dc8 100644 --- a/.github/workflows/Tribblix.yml +++ b/.github/workflows/Tribblix.yml @@ -64,6 +64,7 @@ jobs: - uses: vmactions/tribblix-vm@v1 with: debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' nat: | "8080": "80" From 6feb1df83cb2d1e628c0b751cf63a645ec5266c5 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 19 Jul 2026 10:20:58 +0800 Subject: [PATCH 07/29] fix cpanel_uapi: pass --user to DomainInfo list_domains when run as root The auto mode sitelist query was missing the --user branch that the install_ssl calls already have, so deploy always failed under root. fix https://github.com/acmesh-official/acme.sh/issues/7139 --- deploy/cpanel_uapi.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/deploy/cpanel_uapi.sh b/deploy/cpanel_uapi.sh index 156044eb..02ef6b3e 100644 --- a/deploy/cpanel_uapi.sh +++ b/deploy/cpanel_uapi.sh @@ -87,7 +87,11 @@ cpanel_uapi_deploy() { # Auto mode if [ "$DEPLOY_CPANEL_AUTO_ENABLED" = "true" ]; then # call API for site config - _response=$(uapi DomainInfo list_domains) + if [ -n "$_uapi_user" ]; then + _response=$(uapi --user="$_uapi_user" DomainInfo list_domains) + else + _response=$(uapi DomainInfo list_domains) + fi # exit if error in response if [ -z "$_response" ] || [ "${_response#*"$uapi_error_response"}" != "$_response" ]; then _err "Error in deploying certificate - cannot retrieve sitelist:" From 4c8a143086549d77d21fcec4fce0bb7d97b60821 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 20 Jul 2026 10:02:54 +0800 Subject: [PATCH 08/29] fix proxmoxve/proxmoxbs deploy: fail on non-2xx API response The success check only grepped "message" from the response body, but PVE/PBS auth failures return HTTP 401 with an empty body, so wrong or unauthorized API tokens were reported as "Certificate successfully deployed". Also _retval captured the exit code of the message pipeline instead of _post. Check the HTTP status line from $HTTP_HEADER and capture _post's exit code directly. fix https://github.com/acmesh-official/acme.sh/issues/7141 --- deploy/proxmoxbs.sh | 27 +++++++++++++++++---------- deploy/proxmoxve.sh | 27 +++++++++++++++++---------- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/deploy/proxmoxbs.sh b/deploy/proxmoxbs.sh index 179b0369..30599a44 100644 --- a/deploy/proxmoxbs.sh +++ b/deploy/proxmoxbs.sh @@ -116,17 +116,24 @@ HEREDOC export HTTPS_INSECURE=1 export _H1="Authorization: PBSAPIToken=${_proxmoxbs_header_api_token}" response=$(_post "$_json_payload" "$_target_url" "" POST "application/json") + _retval=$? + # The API errors out with a non-2xx HTTP status and an empty body, + # so the status line is checked too, not only the response body. + _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 "HTTP status" "$_status_code" response="$(echo "$response" | _json_decode | _normalizeJson)" message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"') - _retval=$? - if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then - _debug3 response "$response" - _info "Certificate successfully deployed" - return 0 - else - _err "Certificate deployment failed: $message" - _debug "Response" "$response" - return 1 - fi + case "$_status_code" in + 2[0-9][0-9]) + if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then + _debug3 response "$response" + _info "Certificate successfully deployed" + return 0 + fi + ;; + esac + _err "Certificate deployment failed (HTTP status $_status_code). $message" + _debug "Response" "$response" + return 1 } diff --git a/deploy/proxmoxve.sh b/deploy/proxmoxve.sh index b6298ee7..fd8d69d8 100644 --- a/deploy/proxmoxve.sh +++ b/deploy/proxmoxve.sh @@ -128,17 +128,24 @@ HEREDOC export HTTPS_INSECURE=1 export _H1="Authorization: PVEAPIToken=${_proxmoxve_header_api_token}" response=$(_post "$_json_payload" "$_target_url" "" POST "application/json") + _retval=$? + # The API errors out with a non-2xx HTTP status and an empty body, + # so the status line is checked too, not only the response body. + _status_code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 "HTTP status" "$_status_code" response="$(echo "$response" | _json_decode | _normalizeJson)" message=$(echo "$response" | _egrep_o '"message":"[^"]*' | cut -d : -f 2 | tr -d '"') - _retval=$? - if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then - _debug3 response "$response" - _info "Certificate successfully deployed" - return 0 - else - _err "Certificate deployment failed: $message" - _debug "Response" "$response" - return 1 - fi + case "$_status_code" in + 2[0-9][0-9]) + if [ "${_retval}" -eq 0 ] && [ -z "$message" ]; then + _debug3 response "$response" + _info "Certificate successfully deployed" + return 0 + fi + ;; + esac + _err "Certificate deployment failed (HTTP status $_status_code). $message" + _debug "Response" "$response" + return 1 } From 1774d838cabca07d50e55b7e736c83e3d1228ecf Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 22 Jul 2026 20:42:14 +0800 Subject: [PATCH 09/29] add GNU hurd --- .github/workflows/DNS.yml | 91 +++++++++++++++++++++++++++++++------- .github/workflows/Hurd.yml | 76 +++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/Hurd.yml diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index f7d5cb98..09b65a96 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -66,7 +66,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - name: Set env file @@ -114,7 +114,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install tools run: | brew untap aws/tap || true @@ -167,7 +167,7 @@ jobs: - name: Set git to use LF run: | git config --global core.autocrlf false - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install cygwin base packages with chocolatey run: | choco config get cacheLocation @@ -231,7 +231,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/freebsd-vm@v1 @@ -290,7 +290,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/ghostbsd-vm@v1 @@ -347,7 +347,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/openbsd-vm@v1 @@ -404,7 +404,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/netbsd-vm@v1 @@ -462,7 +462,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/dragonflybsd-vm@v1 @@ -524,7 +524,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/midnightbsd-vm@v1 @@ -582,7 +582,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/solaris-vm@v1 @@ -642,7 +642,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/omnios-vm@v1 @@ -699,7 +699,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/openindiana-vm@v1 @@ -756,7 +756,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/tribblix-vm@v1 @@ -813,7 +813,7 @@ jobs: TokenName4: ${{ secrets.TokenName4}} TokenName5: ${{ secrets.TokenName5}} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Clone acmetest run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ - uses: vmactions/haiku-vm@v1 @@ -826,7 +826,68 @@ jobs: prepare: | mkdir -p /boot/home/.cache pkgman install -y cronie - + + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + Hurd: + runs-on: ubuntu-latest + needs: Haiku + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + HTTPS_INSECURE: 1 # always set to 1 to ignore https error + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hurd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: rsync + copyback: false + usesh: true + prepare: | + apt-get update -y + apt-get install -y curl cron run: | if [ "${{ secrets.TokenName1}}" ] ; then export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" diff --git a/.github/workflows/Hurd.yml b/.github/workflows/Hurd.yml new file mode 100644 index 00000000..fee80d29 --- /dev/null +++ b/.github/workflows/Hurd.yml @@ -0,0 +1,76 @@ +name: Hurd +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/Hurd.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/Hurd.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + Hurd: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/hurd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN' + nat: | + "8080": "80" + # Do NOT install socat: socat's SYSTEM: address is broken on GNU Hurd + # (the child shell output goes to socat's stdout instead of the socket, + # so clients get an empty reply). Without socat, acme.sh standalone + # mode falls back to its python3 server, which works on Hurd. + prepare: | + apt-get update -y + apt-get install -y curl cron + usesh: true + sync: rsync + copyback: false + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/README.md b/README.md index b93a8a50..23c8b3e0 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ OpenIndiana Tribblix Haiku + Hurd

@@ -130,6 +131,7 @@ |25|[![Haiku](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Haiku.yml)|Haiku OS |26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix |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 > ๐Ÿงช Check our [testing project](https://github.com/acmesh-official/acmetest) From 24d573b6d3f0ee409c145b0ed26813efaae4c63c Mon Sep 17 00:00:00 2001 From: Radu Date: Thu, 23 Jul 2026 06:50:20 +0300 Subject: [PATCH 10/29] Merge pull request #7140 from radumalica/dns_hestiacp feat: add dnsapi for HestiaCP --- dnsapi/dns_hestiacp.sh | 198 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 dnsapi/dns_hestiacp.sh diff --git a/dnsapi/dns_hestiacp.sh b/dnsapi/dns_hestiacp.sh new file mode 100644 index 00000000..13ee6caf --- /dev/null +++ b/dnsapi/dns_hestiacp.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_hestiacp_info='HestiaCP Server API +Site: hestiacp.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_hestiacp +Options: + HESTIA_HOST Panel URL. E.g. "https://panel.example.com:8083" + HESTIA_ACCESS API access key + HESTIA_SECRET API secret key + HESTIA_USER Username owning the DNS zones. Default "admin". Optional. +Issues: github.com/acmesh-official/acme.sh/issues/6251 +Author: Radu Malica +' + +######## Public functions ##################### + +# Usage: dns_hestiacp_add fulldomain txtvalue +dns_hestiacp_add() { + fulldomain=$1 + txtvalue=$2 + + if ! _hestia_init; then + return 1 + fi + + _debug "Detecting the root zone for $fulldomain" + if ! _hestia_get_root "$fulldomain"; then + _err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER" + return 1 + fi + _debug _hestia_domain "$_hestia_domain" + _debug _hestia_sub "$_hestia_sub" + + # _hestia_get_root left the zone record listing in _hestia_response + if _hestia_find_records "$_hestia_sub" "TXT" | grep -F -- "$txtvalue" >/dev/null; then + _info "The TXT record already exists, skipping" + return 0 + fi + + _info "Adding TXT record for $fulldomain" + if ! _hestia_rest "v-add-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_sub" "TXT" "$txtvalue" "" "" "yes" "600"; then + _err "Error adding TXT record: $_hestia_response" + return 1 + fi + _info "TXT record added successfully" + return 0 +} + +# Usage: dns_hestiacp_rm fulldomain txtvalue +dns_hestiacp_rm() { + fulldomain=$1 + txtvalue=$2 + + if ! _hestia_init; then + return 1 + fi + + _debug "Detecting the root zone for $fulldomain" + if ! _hestia_get_root "$fulldomain"; then + _err "Cannot find a DNS zone for $fulldomain under user $HESTIA_USER" + return 1 + fi + _debug _hestia_domain "$_hestia_domain" + _debug _hestia_sub "$_hestia_sub" + + _hestia_removed=0 + _hestia_failed=0 + while IFS='|' read -r _hestia_id _hestia_value || [ -n "$_hestia_id" ]; do + if [ -z "$_hestia_id" ]; then + continue + fi + if ! _contains "$_hestia_value" "$txtvalue"; then + continue + fi + _info "Deleting TXT record $_hestia_id" + if ! _hestia_rest "v-delete-dns-record" "$HESTIA_USER" "$_hestia_domain" "$_hestia_id" "yes"; then + _err "Error deleting TXT record $_hestia_id: $_hestia_response" + _hestia_failed=$(_math "$_hestia_failed" + 1) + continue + fi + _hestia_removed=$(_math "$_hestia_removed" + 1) + done < Date: Fri, 24 Jul 2026 07:55:17 +0200 Subject: [PATCH 11/29] feat: added realtoxmedia dnsapi (#7156) --- dnsapi/dns_rltx.sh | 145 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 dnsapi/dns_rltx.sh diff --git a/dnsapi/dns_rltx.sh b/dnsapi/dns_rltx.sh new file mode 100644 index 00000000..065ac177 --- /dev/null +++ b/dnsapi/dns_rltx.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_rltx_info='Realtox Media Cloudpanel DNS API +Site: realtoxmedia.de +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_rltx +Options: + RLTX_Key API Key + RLTX_OrganizationID Organization ID +' + +######## Public functions ##################### + +#Usage: dns_rltx_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_rltx_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using Realtox Media Cloudpanel DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _rltx_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + _err "Could not find matching DNS zone for $fulldomain" + return 1 + fi + + _debug _domain_id "$_domain_id" + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}" + if ! _rltx_rest POST "domains/$_domain_id/dns/acme-txt" "$data"; then + _err "Add TXT record request failed" + return 1 + fi + if _contains "$response" '"status":"added"'; then + _info "Added TXT record, OK" + return 0 + fi + _err "Add TXT record failed: $response" + return 1 +} + +#Usage: fulldomain txtvalue +#Remove the txt record after validation. +dns_rltx_rm() { + fulldomain=$1 + txtvalue=$2 + + _info "Using Realtox Media Cloudpanel DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _rltx_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + _err "Could not find matching DNS zone for $fulldomain" + return 1 + fi + + _debug _domain_id "$_domain_id" + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + data="{\"name\":\"$_sub_domain\",\"value\":\"$txtvalue\",\"ttl\":120}" + if ! _rltx_rest DELETE "domains/$_domain_id/dns/acme-txt" "$data"; then + _err "Remove TXT record request failed" + return 1 + fi + if _contains "$response" '"status":"removed"'; then + _info "Removed TXT record, OK" + return 0 + fi + _err "Remove TXT record failed: $response" + return 1 +} + +#################### Private functions below ################################## + +_rltx_init() { + RLTX_Key="${RLTX_Key:-$(_readaccountconf_mutable RLTX_Key)}" + RLTX_OrganizationID="${RLTX_OrganizationID:-$(_readaccountconf_mutable RLTX_OrganizationID)}" + + if [ -z "$RLTX_Key" ] || [ -z "$RLTX_OrganizationID" ]; then + RLTX_Key="" + RLTX_OrganizationID="" + _err "Please specify RLTX_Key and RLTX_OrganizationID." + _err "You can export them and retry: export RLTX_Key=... RLTX_OrganizationID=..." + return 1 + fi + + _saveaccountconf_mutable RLTX_Key "$RLTX_Key" + _saveaccountconf_mutable RLTX_OrganizationID "$RLTX_OrganizationID" +} + +_get_root() { + domain=$1 + fqdn_encoded="$(printf "%s" "$domain" | _url_encode)" + if ! _rltx_rest GET "domains/dns/acme-zone?fqdn=$fqdn_encoded"; then + return 1 + fi + if ! _contains "$response" '"domain_id":"'; then + return 1 + fi + + _domain_id="$(printf "%s" "$response" | _egrep_o '"domain_id":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)" + _domain="$(printf "%s" "$response" | _egrep_o '"zone":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)" + _sub_domain="$(printf "%s" "$response" | _egrep_o '"record_name":"[^"]*"' | cut -d : -f 2 | tr -d '"' | _head_n 1)" + + if [ -z "$_domain_id" ] || [ -z "$_domain" ] || [ -z "$_sub_domain" ]; then + return 1 + fi + return 0 +} + +_rltx_rest() { + m=$1 + ep="$2" + data="$3" + _debug "$ep" + + export _H1="X-API-Key: $RLTX_Key" + export _H2="X-Organization-ID: $RLTX_OrganizationID" + export _H3="Content-Type: application/json" + + if [ "$m" = "GET" ]; then + response="$(_get "https://api.ccp.realtoxmedia.de/api/$ep")" + else + _debug2 data "$data" + response="$(_post "$data" "https://api.ccp.realtoxmedia.de/api/$ep" "" "$m")" + fi + + if [ "$?" != "0" ]; then + _err "Realtox Media Cloudpanel API request failed: $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} From 698f6c73298e76c65ad7e726e462b50af1c5cf28 Mon Sep 17 00:00:00 2001 From: Qhilm <3350433+Qhilm@users.noreply.github.com> Date: Sat, 25 Jul 2026 07:03:56 +0200 Subject: [PATCH 12/29] Feat: Shelly deploy hook for firmware 2.0.0+ (#7145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Shelly Gen3+ deploy hook with RFC 7616 HTTP Digest auth Adds deploy/shelly.sh for deploying Let's Encrypt HTTPS server certificates to Shelly Gen3+ devices (Gen4 tested) via JSON-RPC over HTTP. - RFC 7616 SHA-256 HTTP Digest authentication (Authorization header) - Uploads fullchain.pem and private key via Shelly.PutHTTPServerCert / PutHTTPServerKey - Auto-reboot support (SHELLY_REBOOT to disable) - Auth auto-detection: no password = no auth, password = Digest - Nonce counter (nc) increments per request per RFC 7616 - Tested against Shelly 2PM Gen4 (firmware 2.0.0) Also adds deploy/test_shelly.sh for self-testing the hook logic without a real device (mocked _post). * fix: address review feedback on shelly deploy hook - Fix _secure_debug calls to use two arguments (label + value) - Remove bash-only $RANDOM cnonce fallback; openssl always available - Parse $HTTP_HEADER directly instead of raw curl re-request - Detect auth via HTTP 401 status line, not empty response body - Route reboot through _shelly_rpc to rebuild auth header with correct nc - Remove export HTTPS_INSECURE=1 (no-op for http://, leaks to other hooks) - Clear _H1 before returning from shelly_deploy - Prefix all helper variables with _shelly_ to avoid namespace collisions - Delete deploy/test_shelly.sh (deploy/ files become hook names) - Fix missing trailing newline * fix: validate shelly JSON-RPC responses are valid JSON Non-JSON responses like HTTP 429 'Too Many Requests' would pass the empty-response and '"error"' checks and be reported as success. Now reject any response that doesn't start with '{' and contain '"id"'. * fix: add 1s delay between shelly cert/key clear and upload calls The Shelly device has a race condition where uploading data immediately after clearing the existing cert/key returns -103 'Missing required argument data!'. A 1-second delay fixes this. * fix: remove clear-before-upload in shelly deploy hook Shelly auto-removes all three TLS files (cert, key, CA bundle) when any single one is cleared. The old sequence clear-cert โ†’ upload-cert โ†’ clear-key โ†’ upload-key resulted in the key clear wiping the newly uploaded cert, leaving only the key at boot time. The mbedtls pk_check_pair then silently skipped the HTTPS listener. Fix: just upload directly (overwrite in place). No clearing needed. * Fix ShellCheck SC2090 and shfmt in shelly deploy hook SC2090: false positive on export _H1 (used quoted in _post) shfmt: no space after "<" in _json_encode redirects * moved two lines to cover the whole if block --------- Co-authored-by: neil Co-authored-by: cysimons --- deploy/shelly.sh | 280 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 deploy/shelly.sh diff --git a/deploy/shelly.sh b/deploy/shelly.sh new file mode 100644 index 00000000..dbdab346 --- /dev/null +++ b/deploy/shelly.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env sh + +# Here is a script to deploy cert to a Shelly Gen3+ device. +# Deploy the HTTPS server certificate to a Shelly device on the local network. +# +# ```sh +# export SHELLY_HOST=192.168.1.100 +# export SHELLY_PASSWORD=mysecret # only if auth is enabled on the device +# acme.sh --deploy -d shelly.example.com --deploy-hook shelly +# ``` +# +# Environment variables: +# SHELLY_HOST (required) IP or hostname of the Shelly device +# SHELLY_PASSWORD (optional) Admin password for digest authentication. +# Omit if auth is disabled on the device. +# SHELLY_USER (optional) Username for auth. Default: admin +# SHELLY_REBOOT (optional) Set to "0" to skip auto-reboot. +# Default: 1 (reboot after upload) +# +# Requirements: +# - Shelly Gen3+ device (Gen4 recommended) +# - Firmware 2.0.0+ for HTTPS server certificate support +# - curl or wget +# - openssl (for SHA-256 digest and random cnonce) +# +# The device must be reachable via HTTP on the local network. +# The hook uploads the fullchain.pem and private key, +# then reboots the device to apply the new certificate. +# +# Authentication uses standard RFC 7616 HTTP Digest (SHA-256) since +# firmware 2.0.0. The JSON-RPC auth object is not used for HTTP transport. +# +# returns 0 means success, otherwise error. + +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +shelly_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + _getdeployconf SHELLY_HOST + _getdeployconf SHELLY_PASSWORD + _getdeployconf SHELLY_USER + _getdeployconf SHELLY_REBOOT + + _debug SHELLY_HOST "$SHELLY_HOST" + _debug SHELLY_USER "$SHELLY_USER" + _secure_debug SHELLY_PASSWORD "$SHELLY_PASSWORD" + _debug SHELLY_REBOOT "$SHELLY_REBOOT" + + if [ -z "$SHELLY_HOST" ]; then + _err "SHELLY_HOST is required. Please set the IP or hostname of your Shelly device." + return 1 + fi + + SHELLY_USER="${SHELLY_USER:-admin}" + SHELLY_REBOOT="${SHELLY_REBOOT:-1}" + + _savedeployconf SHELLY_HOST "$SHELLY_HOST" + _savedeployconf SHELLY_PASSWORD "$SHELLY_PASSWORD" + _savedeployconf SHELLY_USER "$SHELLY_USER" + _savedeployconf SHELLY_REBOOT "$SHELLY_REBOOT" + + # --- Auth handshake (only if password is set) --- + _shelly_auth_header="" + if [ -n "$SHELLY_PASSWORD" ]; then + _info "Authenticating to Shelly device at $SHELLY_HOST" + if ! _shelly_handshake; then + _err "Authentication handshake failed. Check SHELLY_PASSWORD and device accessibility." + return 1 + fi + _info "Authentication successful" + fi + + # --- Upload certificate --- + _info "Uploading certificate to Shelly device at $SHELLY_HOST" + if ! _shelly_upload_cert; then + _err "Certificate upload failed" + return 1 + fi + + # --- Upload key --- + _info "Uploading private key to Shelly device" + if ! _shelly_upload_key; then + _err "Private key upload failed" + return 1 + fi + + _info "Certificate and key uploaded successfully" + + # --- Reboot --- + if [ "$SHELLY_REBOOT" != "0" ]; then + _info "Rebooting Shelly device to apply certificate" + # Reboot may close the connection before sending a response + _shelly_rpc "Shelly.Reboot" '{}' || _debug "Reboot may have closed connection (expected)" + _info "Reboot command sent. Device will restart shortly." + else + _info "Skipping reboot (SHELLY_REBOOT=0). Certificate will apply on next restart." + fi + + # Clear auth header so it does not leak to other hooks + export _H1="" + + return 0 +} + +# --- Helper functions --- + +# Perform RFC 7616 HTTP Digest auth handshake. +# Sets _shelly_auth_header on success (the Authorization header value). +_shelly_handshake() { + _inithttp + + _debug "Probing device for auth challenge" + + # Use a protected method (Shelly.GetStatus) to trigger 401. + # Shelly.GetDeviceInfo is excluded from auth and would miss the challenge. + _post '{"id":1,"method":"Shelly.GetStatus"}' \ + "http://${SHELLY_HOST}/rpc" "" "" "application/json" + + # Detect auth from HTTP status line rather than response body + if ! _shelly_has_auth_challenge "$HTTP_HEADER"; then + # No auth challenge โ€” device accepted the request without credentials + _debug "Device responded without auth challenge. Proceeding without auth." + return 0 + fi + + _shelly_realm="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*realm="//;s/".*//')" + _shelly_nonce="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*nonce="//;s/".*//')" + _shelly_qop="$(grep -i '^WWW-Authenticate:' "$HTTP_HEADER" | sed 's/.*qop="//;s/".*//')" + + if [ -z "$_shelly_nonce" ]; then + _err "Failed to extract nonce from WWW-Authenticate header. Is SHELLY_PASSWORD correct?" + return 1 + fi + + _shelly_qop="${_shelly_qop:-auth}" + + _debug "Shelly realm: $_shelly_realm" + _debug "Shelly qop: $_shelly_qop" + _secure_debug "Shelly nonce" "$_shelly_nonce" + + # ha1 = SHA256(username:realm:password) + _shelly_ha1="$(printf '%s' "${SHELLY_USER}:${_shelly_realm}:${SHELLY_PASSWORD}" | _digest sha256 hex)" + _secure_debug "Shelly ha1" "$_shelly_ha1" + + # Generate client nonce (openssl is required for _digest, so always available) + _shelly_cnonce="$(${ACME_OPENSSL_BIN:-openssl} rand -hex 8 2>/dev/null)" + _debug "Shelly cnonce: $_shelly_cnonce" + + # Build the digest Authorization header value (stored for reuse) + _shelly_nc=1 + _shelly_build_auth_header + + return 0 +} + +# Check whether the HTTP response headers contain a digest auth challenge. +# Returns 0 (true) if a 401 with WWW-Authenticate is present. +_shelly_has_auth_challenge() { + _shelly_headers_file="$1" + _shelly_status="$(grep -i '^HTTP/' "$_shelly_headers_file" | _tail_n 1 | awk '{print $2}')" + [ "$_shelly_status" = "401" ] && grep -qi '^WWW-Authenticate:' "$_shelly_headers_file" +} + +# Build or rebuild the RFC 7616 Authorization header. +# Uses: _shelly_ha1, _shelly_nonce, _shelly_cnonce, _shelly_qop, _shelly_realm, _shelly_nc +# Sets: _shelly_auth_header +_shelly_build_auth_header() { + _shelly_nc_hex="$(printf '%08x' "$_shelly_nc")" + + # ha2 = SHA256(POST:/rpc) + _shelly_ha2="$(printf '%s' "POST:/rpc" | _digest sha256 hex)" + + # response = SHA256(ha1:nonce:nc:cnonce:qop:ha2) + _shelly_digest_response="$(printf '%s' "${_shelly_ha1}:${_shelly_nonce}:${_shelly_nc_hex}:${_shelly_cnonce}:${_shelly_qop}:${_shelly_ha2}" | _digest sha256 hex)" + + # Build the Authorization header value (without the "Authorization: " prefix) + _shelly_auth_header="Digest username=\"${SHELLY_USER}\", realm=\"${_shelly_realm}\", nonce=\"${_shelly_nonce}\", uri=\"/rpc\", qop=${_shelly_qop}, nc=${_shelly_nc_hex}, cnonce=\"${_shelly_cnonce}\", response=\"${_shelly_digest_response}\", algorithm=SHA-256" + + _secure_debug "Authorization header" "$_shelly_auth_header" +} + +# Make a Shelly JSON-RPC call. +# Usage: _shelly_rpc +# Returns 0 on success, 1 on error. +_shelly_rpc() { + _shelly_method="$1" + _shelly_params="$2" + + _shelly_body='{"id":1,"method":"'"$_shelly_method"'","params":'"$_shelly_params"'}' + + _debug "RPC method: $_shelly_method" + _debug2 "RPC body: $_shelly_body" + + # shellcheck disable=SC2090 + if [ -n "$_shelly_auth_header" ]; then + export _H1="Authorization: $_shelly_auth_header" + else + export _H1="" + fi + + _post "$_shelly_body" "http://${SHELLY_HOST}/rpc" "" "" "application/json" + _shelly_ret=$? + + if [ "$_shelly_ret" != "0" ]; then + _err "HTTP request failed for $_shelly_method (curl/wget error $_shelly_ret)" + return 1 + fi + + # Empty response means something went wrong (auth required but not provided, etc.) + if [ -z "$response" ]; then + _err "Empty response from Shelly device. If authentication is enabled on the device, set SHELLY_PASSWORD." + return 1 + fi + + # Validate response looks like a Shelly JSON-RPC response. + # Catches non-JSON responses such as HTTP 429 "Too Many Requests" which + # would otherwise pass the empty and "error" checks below. + if ! _startswith "$response" '{' || ! _contains "$response" '"id"'; then + _err "Invalid response from Shelly device: $response" + return 1 + fi + + # Check for JSON-RPC error in response + if _contains "$response" '"error"'; then + _err "RPC error from Shelly: $response" + return 1 + fi + + _debug "RPC response: $response" + + # Increment nonce counter and rebuild auth header for next request + if [ -n "$_shelly_auth_header" ]; then + _shelly_nc=$((_shelly_nc + 1)) + _shelly_build_auth_header + fi + + return 0 +} + +# Upload the certificate to the device. +# Note: We do NOT clear the existing certificate first, because the Shelly +# auto-removes all three files (cert, key, CA) when any one is cleared. +# Uploading overwrites in place โ€” no clearing needed. +_shelly_upload_cert() { + _shelly_cert_data="$(_json_encode <"$_cfullchain")" + + _debug "Uploading certificate" + if ! _shelly_rpc "Shelly.PutHTTPServerCert" '{"data":"'"$_shelly_cert_data"'"}'; then + _err "Failed to upload certificate to device" + return 1 + fi + + return 0 +} + +# Upload the private key to the device. +# Note: Do not clear first โ€” see _shelly_upload_cert for rationale. +_shelly_upload_key() { + _shelly_key_data="$(_json_encode <"$_ckey")" + + _debug "Uploading key" + if ! _shelly_rpc "Shelly.PutHTTPServerKey" '{"data":"'"$_shelly_key_data"'"}'; then + _err "Failed to upload key to device" + return 1 + fi + + return 0 +} From 830782fd1da6b20f25017f9778af6fe1cc80d7a5 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 13:33:54 +0800 Subject: [PATCH 13/29] fix dns_yc: avoid empty-matchable _egrep_o pattern that hangs OmniOS OmniOS native egrep -o infinite-loops emitting empty lines when the pattern can match the empty string, so `_egrep_o "[^:]*$"` never lets the pipeline finish and dns_yc hangs until the CI timeout. Require at least one character instead. `+` is not usable because the sed fallback in _egrep_o parses BRE. --- dnsapi/dns_yc.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index 36c49ce4..13a0d1f6 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -126,7 +126,7 @@ dns_yc_rm() { _debug "Getting txt records" if _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then - exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:]*$") + exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:][^:]*$") _debug exists_txtvalue "$exists_txtvalue" else _err "Error: $response" @@ -194,7 +194,7 @@ _get_root() { return 1 fi if _contains "$response" "\"zone\": \"$h\""; then - _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"') + _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"') _debug _domain_id "$_domain_id" if [ "$_domain_id" ]; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") @@ -264,7 +264,7 @@ _yc_login() { _iam_response="$(_post "$_jwt" "https://iam.api.cloud.yandex.net/iam/v1/tokens" "" "POST")" _debug3 _iam_response "$(echo "$_iam_response" | _normalizeJson)" - YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:]*$" | tr -d '"')" + YC_Token="$(echo "$_iam_response" | _normalizeJson | _egrep_o "\"iamToken\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"')" _debug3 YC_Token return 0 From 7c12deb7ef2e8c5fdcfced93aaa5501ae30e4d35 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 25 Jul 2026 16:00:03 +0800 Subject: [PATCH 14/29] fix: grep -A is not portable, breaks ARI on Solaris Solaris /usr/bin/grep has no -A ("illegal option -- A"), so _getAKI printed an error to stderr on every cron renewal and returned empty. The empty AKI silently corrupts the RFC 9773 ARI certID, so ARI is never available and renewal falls back to the fixed schedule. Split the pipeline into a testable stdin filter _extractAKI and select the value line with a portable sed range instead. Same fix for the two hooks that still used grep -A: dns_world4you.sh (also replaces the GNU-only "\s" in the same expression) and deploy/keyhelp.sh (the -A 2 window could truncate the div range that follows it, so it is just dropped). https://github.com/acmesh-official/acme.sh/issues/7159 --- acme.sh | 10 +++++++++- deploy/keyhelp.sh | 4 ++-- dnsapi/dns_world4you.sh | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/acme.sh b/acme.sh index 274af3ac..17244133 100755 --- a/acme.sh +++ b/acme.sh @@ -7369,10 +7369,18 @@ deactivate() { done } +#reads the output of "openssl x509 -text" from stdin, prints the hex AKI +#the value is on the line right after the extension header; "grep -A" is not +#portable (Solaris /usr/bin/grep: "illegal option -- A"), so select from the +#header to EOF and keep the second line of that range +_extractAKI() { + sed -n '/X509v3 Authority Key Identifier/,$p' | _head_n 2 | _tail_n 1 | tr -d ': ' | sed "s/keyid//" +} + #cert _getAKI() { _cert="$1" - ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | grep -A 1 "X509v3 Authority Key Identifier" | _tail_n 1 | tr -d ': ' | sed "s/keyid//" + ${ACME_OPENSSL_BIN:-openssl} x509 -in "$_cert" -text -noout | _extractAKI } #cert diff --git a/deploy/keyhelp.sh b/deploy/keyhelp.sh index 97f9c21c..f66d27ce 100644 --- a/deploy/keyhelp.sh +++ b/deploy/keyhelp.sh @@ -83,7 +83,7 @@ keyhelp_deploy() { _request_body="submit=1&certificate_name=$certificate_name&add_type=upload&text_private_key=$encoded_key&text_certificate=$encoded_ccert&text_ca_certificate=$encoded_cca" _H1="Cookie: $_cookie" _response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=ssl_certificates&action=add" "" "POST") - _message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/

/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') + _message=$(echo "$_response" | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') _info "_message" "$_message" if [ -z "$_message" ]; then _err "Fail to upload certificate." @@ -118,7 +118,7 @@ keyhelp_deploy() { _request_body="submit=1&id=$DOMAIN_ID&target_type=$target_type&path=$path&is_prefer_https=$is_prefer_https&hsts_enabled=$hsts_enabled&certificate_type=custom&certificate_id=$cert_value&enforce_https=$DEPLOY_KEYHELP_ENFORCE_HTTPS" _response=$(_post "$_request_body" "$DEPLOY_KEYHELP_BASEURL/index.php?page=domains&action=edit" "" "POST") - _message=$(echo "$_response" | grep -A 2 'message-body' | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') + _message=$(echo "$_response" | sed -n '/
/,/<\/div>/{//!p;}' | sed 's/<[^>]*>//g' | sed 's/^ *//;s/ *$//') _info "_message" "$_message" if [ -z "$_message" ]; then _err "Fail to apply certificate." diff --git a/dnsapi/dns_world4you.sh b/dnsapi/dns_world4you.sh index f59715ac..0a1cda6b 100644 --- a/dnsapi/dns_world4you.sh +++ b/dnsapi/dns_world4you.sh @@ -61,7 +61,7 @@ dns_world4you_add() { if _contains "$res" "successfully"; then return 0 else - msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g') + msg=$(_w4y_alert_msg "$res") if [ "$msg" = '' ]; then _err "Unable to add record: Unknown error" echo "$ret" >'error-01.html' @@ -125,7 +125,7 @@ dns_world4you_rm() { if _contains "$res" "successfully"; then return 0 else - msg=$(echo "$res" | grep -A 20 'alert-notification' | grep 'class="weak-title">[^<]' | sed 's/<[^>]*>//g;s/^\s*//g') + msg=$(_w4y_alert_msg "$res") if [ "$msg" = '' ]; then _err "Unable to remove record: Unknown error" echo "$ret" >'error-01.html' @@ -145,6 +145,17 @@ dns_world4you_rm() { ################ Private functions ################ +# Usage: _w4y_alert_msg +# Extracts the error text out of the alert box of a DNS page. +# "grep -A" is not portable (Solaris /usr/bin/grep: "illegal option -- A"), +# so select from the alert to EOF and keep the same number of lines. +# "\s" is a GNU sed extension, use an explicit space/tab bracket instead. +_w4y_alert_msg() { + _w4y_tab=$(printf '\t') + echo "$1" | sed -n '/alert-notification/,$p' | _head_n 21 | + grep 'class="weak-title">[^<]' | sed "s/<[^>]*>//g;s/^[ $_w4y_tab]*//" +} + # Usage: _login _login() { WORLD4YOU_USERNAME="${WORLD4YOU_USERNAME:-$(_readaccountconf_mutable WORLD4YOU_USERNAME)}" From 057c94089595b9565bc350ac9ceef73d7e3b9784 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 26 Jul 2026 15:01:41 +0800 Subject: [PATCH 15/29] add OpenEuler --- .github/workflows/DNS.yml | 59 +++++++++++++++++++++++++++ .github/workflows/OpenEuler.yml | 70 +++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 131 insertions(+) create mode 100644 .github/workflows/OpenEuler.yml diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 09b65a96..84a17470 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -914,3 +914,62 @@ jobs: + OpenEuler: + runs-on: ubuntu-latest + needs: Hurd + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + HTTPS_INSECURE: 1 # always set to 1 to ignore https error + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v7 + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/openeuler-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy HTTPS_INSECURE TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + sync: rsync + copyback: false + usesh: true + prepare: dnf install -y curl socat cronie tar gzip + 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/OpenEuler.yml b/.github/workflows/OpenEuler.yml new file mode 100644 index 00000000..2b4bd0ab --- /dev/null +++ b/.github/workflows/OpenEuler.yml @@ -0,0 +1,70 @@ +name: OpenEuler +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/OpenEuler.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/OpenEuler.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + OpenEuler: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + steps: + - uses: actions/checkout@v7 + - uses: anyvm-org/cf-tunnel@v0 + id: tunnel + with: + protocol: http + port: 8080 + - name: Set envs + run: echo "TestingDomain=${{steps.tunnel.outputs.server}}" >> $GITHUB_ENV + - name: Clone acmetest + run: cd .. && git clone --depth=1 https://github.com/acmesh-official/acmetest.git && cp -r acme.sh acmetest/ + - uses: vmactions/openeuler-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + cache-after-prepare: true + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN' + nat: | + "8080": "80" + prepare: dnf install -y curl socat cronie tar gzip + usesh: true + sync: rsync + copyback: false + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/README.md b/README.md index 23c8b3e0..90280e94 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Tribblix Haiku Hurd + OpenEuler

@@ -132,6 +133,7 @@ |26|[![Tribblix](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml/badge.svg)](https://github.com/acmesh-official/acme.sh/actions/workflows/Tribblix.yml)|Tribblix |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 > ๐Ÿงช Check our [testing project](https://github.com/acmesh-official/acmetest) From 05654436229074ff015a6539476eb51dd8b8609c Mon Sep 17 00:00:00 2001 From: Goncharenko Alexander <61147910+gasRU76@users.noreply.github.com> Date: Tue, 28 Jul 2026 05:04:20 +0300 Subject: [PATCH 16/29] dns_yc: fix TXT record removal failing with "Unknown key file format" (#7150) * dns_yc: restore YC_SA_Key_File in dns_yc_rm before signing the JWT dns_yc_rm() never rebuilt YC_SA_Key_File from YC_SA_Key_File_PEM_b64 / YC_SA_Key_File_Path like dns_yc_add() does. Per the DNS API dev guide, add()/rm() run in separate subshells, so rm() must repeat add()'s setup steps rather than rely on variables set during add(). Without it, when _yc_login() needs a fresh JWT during removal (the IAM token from the add phase isn't available), it signs with an empty/unset key path, and openssl fails with "Unknown key file format". The resulting auth failure then surfaces misleadingly as "invalid domain" in _get_root, and the TXT record is never deleted. Verified against a real Yandex Cloud account/zone with --staging: before the fix, removal failed with the same errors reported in the issue; after adding the missing key-restoration block, add + remove both succeed and the TXT record is actually deleted. * dns_yc: preserve other TXT values when removing one at the same name dns_yc_rm previously sent the full current data array (all existing TXT values at the name) to the deletions API, wiping out the whole rrset instead of only the value being removed. This breaks wildcard + base domain issuance, where both share the same _acme-challenge name with two different values: removing the first one deleted both, leaving nothing for the second removal to find. * dns_yc: read persisted config from domain conf before account conf YC_Zone_ID, YC_Folder_ID, YC_SA_ID, YC_SA_Key_ID (zone-ID mode) and YC_SA_Key_File_PEM_b64/Path were always saved via _savedomainconf (domain.conf), but only ever read back via _readaccountconf_mutable (account.conf). Once the env vars were unset, none of these could be recovered from the saved config, so dns_yc_add/dns_yc_rm failed with "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." even though the values had been persisted correctly on the prior run. * dns_yc: replace grep -Fxv/sed with a portable loop in dns_yc_rm Solaris's /usr/bin/grep supports neither -F nor -x, so _remaining_txtvalue was always empty there and the preserve-other- values logic silently fell back to deleting the whole rrset (with a grep usage error on stderr on every rm). The sed trailing-comma strip had a matching issue on Solaris, whose sed drops an unterminated last line. CI didn't catch this because the fallback path also returns "done: true". Use a plain for-loop with word splitting instead. * dns_yc: use upsertRecordSets.deletions to remove a single TXT value updateRecordSets has no "merges" field (only deletions/additions), so the previous preserve-other-values logic silently did nothing -- the TXT record was never actually removed, a regression from before that change (which at least deleted the whole rrset). CI didn't catch it because _clearupdns runs dns_yc_rm in a subshell and ignores its exit code. upsertRecordSets.deletions removes only the specified value from the rrset directly, so the getRecordSet read and the remaining-value recomputation are no longer needed at all. Verified against a real zone (base + wildcard domain sharing one _acme-challenge name): adding both values then removing one leaves the other in place, and removing the second cleans up fully. * dns_yc: don't delete the user's own key file in YC_SA_Key_File_Path mode _yc_login unconditionally rm'd $YC_SA_Key_File after signing. That's fine for the PEM_b64 path, where it's a decoded temp file, but in YC_SA_Key_File_Path mode it's the user's own persistent key file -- the first successful login permanently deleted it, so every subsequent dns_yc_rm/renewal hit "Unknown key file format" (the exact symptom this PR is about, just from a different cause). Track whether the key file is our own temp copy and only delete it in that case. Verified with a stubbed _yc_login: a temp-mode key gets removed after login, a path-mode key survives. * dns_yc: clear both domain and account conf on invalid config The failure branch in dns_yc_add only ever called _clearaccountconf, but YC_Zone_ID/YC_Folder_ID/YC_SA_Key_File_PEM_b64/Path are persisted via _savedomainconf, and YC_SA_ID/YC_SA_Key_ID may have been saved via _saveaccountconf_mutable (Folder_ID mode, which stores under a SAVED_ prefix read back by _readaccountconf_mutable). Clearing only one store left stale values behind in whichever one wasn't touched. Verified by seeding both domain.conf and account.conf with leftover values, then triggering this branch and confirming both config files end up empty. --- dnsapi/dns_yc.sh | 62 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 13 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index 13a0d1f6..cacb5977 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -22,21 +22,32 @@ dns_yc_add() { fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name txtvalue=$2 + # YC_SA_Key_File_PEM_b64/Path are always persisted to the domain conf below, + # so they must be recovered from there first (account conf is only a + # fallback for the YC_Folder_ID case, see the SA_ID/SA_Key_ID save below). + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}" YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" + YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}" YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" if [ "$YC_SA_Key_File_PEM_b64" ]; then echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key YC_SA_Key_File="private.key" + _yc_key_is_temp=1 _savedomainconf YC_SA_Key_File_PEM_b64 "$YC_SA_Key_File_PEM_b64" else YC_SA_Key_File="$YC_SA_Key_File_Path" + _yc_key_is_temp="" _savedomainconf YC_SA_Key_File_Path "$YC_SA_Key_File_Path" fi + YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}" YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" if [ "$YC_SA_ID" ] && [ "$YC_SA_Key_ID" ] && [ "$YC_SA_Key_File" ]; then @@ -65,11 +76,21 @@ dns_yc_add() { return 1 fi else + # Clear both possible stores -- YC_Zone_ID/YC_Folder_ID/key material are + # persisted to the domain conf, while YC_SA_ID/YC_SA_Key_ID may have been + # saved account-wide (Folder_ID mode), so a plain _clearaccountconf alone + # would leave stale values behind in whichever store wasn't touched. + _cleardomainconf YC_Zone_ID _clearaccountconf YC_Zone_ID + _cleardomainconf YC_Folder_ID _clearaccountconf YC_Folder_ID - _clearaccountconf YC_SA_ID - _clearaccountconf YC_SA_Key_ID + _cleardomainconf YC_SA_ID + _clearaccountconf_mutable YC_SA_ID + _cleardomainconf YC_SA_Key_ID + _clearaccountconf_mutable YC_SA_Key_ID + _cleardomainconf YC_SA_Key_File_PEM_b64 _clearaccountconf YC_SA_Key_File_PEM_b64 + _cleardomainconf YC_SA_Key_File_Path _clearaccountconf YC_SA_Key_File_Path _err "You didn't specify a YC_SA_ID or YC_SA_Key_ID or YC_SA_Key_File." return 1 @@ -110,11 +131,30 @@ dns_yc_rm() { fulldomain="$(echo "$1". | _lower_case)" # Add dot at end of domain name txtvalue=$2 + YC_Zone_ID="${YC_Zone_ID:-$(_readdomainconf YC_Zone_ID)}" YC_Zone_ID="${YC_Zone_ID:-$(_readaccountconf_mutable YC_Zone_ID)}" + YC_Folder_ID="${YC_Folder_ID:-$(_readdomainconf YC_Folder_ID)}" YC_Folder_ID="${YC_Folder_ID:-$(_readaccountconf_mutable YC_Folder_ID)}" + YC_SA_ID="${YC_SA_ID:-$(_readdomainconf YC_SA_ID)}" YC_SA_ID="${YC_SA_ID:-$(_readaccountconf_mutable YC_SA_ID)}" + YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readdomainconf YC_SA_Key_ID)}" YC_SA_Key_ID="${YC_SA_Key_ID:-$(_readaccountconf_mutable YC_SA_Key_ID)}" + # See dns_yc_add() for why domain conf is checked before account conf. + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readdomainconf YC_SA_Key_File_PEM_b64)}" + YC_SA_Key_File_PEM_b64="${YC_SA_Key_File_PEM_b64:-$(_readaccountconf_mutable YC_SA_Key_File_PEM_b64)}" + YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readdomainconf YC_SA_Key_File_Path)}" + YC_SA_Key_File_Path="${YC_SA_Key_File_Path:-$(_readaccountconf_mutable YC_SA_Key_File_Path)}" + + if [ "$YC_SA_Key_File_PEM_b64" ]; then + echo "$YC_SA_Key_File_PEM_b64" | _dbase64 >private.key + YC_SA_Key_File="private.key" + _yc_key_is_temp=1 + else + YC_SA_Key_File="$YC_SA_Key_File_Path" + _yc_key_is_temp="" + fi + _debug "First detect the root zone" if ! _get_root "$fulldomain"; then _err "invalid domain" @@ -124,16 +164,10 @@ dns_yc_rm() { _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" - _debug "Getting txt records" - if _yc_rest GET "zones/${_domain_id}:getRecordSet?type=TXT&name=$_sub_domain"; then - exists_txtvalue=$(echo "$response" | _normalizeJson | _egrep_o "\"data\".*\][^,]*" | _egrep_o "[^:][^:]*$") - _debug exists_txtvalue "$exists_txtvalue" - else - _err "Error: $response" - return 1 - fi - - if _yc_rest POST "zones/$_domain_id:updateRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":$exists_txtvalue}]}"; then + # upsertRecordSets.deletions removes only the given value from the rrset, + # leaving any other values at the same name (e.g. base + wildcard domain) + # intact -- no need to read the current data set and recompute it. + if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then if _contains "$response" "\"done\": true"; then _info "Delete, OK" return 0 @@ -255,7 +289,9 @@ _yc_login() { _signature=$(printf "%s.%s" "$header" "$payload" | _sign "$YC_SA_Key_File" "sha256 -sigopt rsa_padding_mode:pss -sigopt rsa_pss_saltlen:-1" | _url_replace) _debug2 _signature "$_signature" - rm -rf "$YC_SA_Key_File" + if [ "$_yc_key_is_temp" ]; then + rm -f "$YC_SA_Key_File" + fi _jwt=$(printf "{\"jwt\": \"%s.%s.%s\"}" "$header" "$payload" "$_signature") _debug2 _jwt "$_jwt" From bf90b845b2d673adcf2b132826eb975fe41e70f2 Mon Sep 17 00:00:00 2001 From: Joel Samson Date: Sun, 2 Aug 2026 09:34:41 -0400 Subject: [PATCH 17/29] Refactor dns_freemyip.sh for enhanced compatibility (#7166) * Refactor dns_freemyip.sh for clarity and compatibility Updated dns_freemyip.sh for better readability and compatibility with ASUSWRT-Merlin. Improved error handling and response logging. * Update author information in dns_freemyip.sh * replace both loops with POSIX shell counters replace both loops with POSIX shell counters * Typo Typo * Fix error message for freemyip API request failure Remove existing token leak. Not my regression. * Refactor retry logic and improve error handling * Remove unnecessary blank lines in dns_freemyip.sh * Clean up dns_freemyip.sh by removing blank lines Removed unnecessary blank lines in the script to improve readability. --- dnsapi/dns_freemyip.sh | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/dnsapi/dns_freemyip.sh b/dnsapi/dns_freemyip.sh index d598a657..18d8e7f9 100644 --- a/dnsapi/dns_freemyip.sh +++ b/dnsapi/dns_freemyip.sh @@ -6,7 +6,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_freemyip Options: FREEMYIP_Token API Token Issues: github.com/acmesh-official/acme.sh/issues/6247 -Author: Recolic Keghart , @Giova96 +Author: Recolic Keghart , @Giova96, ExtremeFiretop ' FREEMYIP_DNS_API="https://freemyip.com/update?" @@ -68,22 +68,30 @@ dns_freemyip_rm() { return $? } -################ Private functions below ################ +################ Private functions below ################ _get_root() { _fmi_d="$1" - echo "$_fmi_d" | rev | cut -d '.' -f 1-3 | rev + echo "$_fmi_d" | sed 's/.*\.\([^.]*\.[^.]*\.[^.]*\)$/\1/' } # There is random failure while calling freemyip API too fast. This function automatically retry until success. _freemyip_get_until_ok() { _fmi_url="$1" - for i in $(seq 1 8); do - _debug "HTTP GET freemyip.com API '$_fmi_url', retry $i/8..." - _get "$_fmi_url" | tee /dev/fd/2 | grep OK && return 0 + _fmi_i=1 + while [ "$_fmi_i" -le 8 ]; do + _debug "HTTP GET freemyip.com API '$_fmi_url', retry $_fmi_i/8..." + _fmi_response="$(_get "$_fmi_url")" + printf '%s\n' "$_fmi_response" >&2 + + if _contains "$_fmi_response" "OK"; then + return 0 + fi + _sleep 1 # DO NOT send the request too fast + _fmi_i=$((_fmi_i + 1)) done - _err "Failed to request freemyip API: $_fmi_url . Server does not say 'OK'" + _err "Failed to request freemyip API. Server does not say 'OK'" return 1 } @@ -93,13 +101,16 @@ _is_root_domain_published() { _webroot="$(_get_root "$_fmi_d")" _info "Verifying '""$_fmi_d""' freemyip webroot (""$_webroot"") is not published yet" - for i in $(seq 1 3); do - _debug "'$_webroot' ns lookup, retry $i/3..." + _fmi_i=1 + while [ "$_fmi_i" -le 3 ]; do + _debug "'$_webroot' ns lookup, retry $_fmi_i/3..." + if [ "$(_ns_lookup "$_fmi_d" TXT)" ]; then _debug "'$_webroot' already has a TXT record published!" return 0 fi _sleep 10 # Give it some time to propagate the TXT record + _fmi_i=$((_fmi_i + 1)) done return 1 } From ea5e70564d94ec3aa4a06c72b33e458f0c8fcb91 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 2 Aug 2026 21:47:51 +0800 Subject: [PATCH 18/29] Fix --make-dns-persist-value printing a wildcard TXT record name For -d '*.example.com' the printed record name kept the literal '*' label (_validation-persist.*.example.com). The CA never queries that name, so issuance fails with "No TXT record found for DNS-PERSIST-01 challenge". Per draft-ietf-acme-dns-persist-01 sec 4 and 10.2 the record is published at the base domain's Validation Domain Name; the wildcard scope comes from 'policy=wildcard' in the record value (sec 5.1), not from a '*' label in the record name. Strip the leading "*." in a new _dns_persist_txt_name helper, and imply --dns-persist-wildcard for a wildcard -d, since without policy=wildcard the printed record can never authorize the wildcard. Fixes #7168 --- acme.sh | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 17244133..9601d43d 100755 --- a/acme.sh +++ b/acme.sh @@ -4355,6 +4355,24 @@ deactivateaccount() { fi } +#domain +#Print the Validation Domain Name where the persistent TXT record must be +#published: the "_validation-persist" label prepended to the domain being +#validated (draft-ietf-acme-dns-persist-01 sec 4). +#A wildcard identifier is validated by the record at its base domain, so the +#leading "*." label is dropped: the wildcard scope comes from 'policy=wildcard' +#in the record value, not from a "*" label in the record name (sec 5.1, 10.2). +_dns_persist_txt_name() { + _dpt_domain="$1" + if _startswith "$_dpt_domain" "*."; then + _dpt_domain="$(echo "$_dpt_domain" | sed 's/^\*\.//')" + fi + if [ -z "$_dpt_domain" ]; then + return 1 + fi + echo "_validation-persist.$_dpt_domain" +} + #domain wildcard ca_name days #Print the TXT record(s) the user must add to enable persistent DNS validation #per draft-ietf-acme-dns-persist-01. @@ -4369,6 +4387,20 @@ makednspersistvalue() { return 1 fi + _txt_name="$(_dns_persist_txt_name "$_mdpv_domain")" + if [ -z "$_txt_name" ]; then + _err "Invalid domain: $_mdpv_domain" + return 1 + fi + _debug _txt_name "$_txt_name" + + #A wildcard identifier can only be issued if the record carries + #'policy=wildcard', so don't print a record that is guaranteed to fail. + if _startswith "$_mdpv_domain" "*." && [ "$_mdpv_wildcard" != "1" ]; then + _info "$_mdpv_domain is a wildcard domain, adding 'policy=wildcard' automatically." + _mdpv_wildcard="1" + fi + if [ -n "$_mdpv_days" ]; then case "$_mdpv_days" in '' | *[!0-9]*) @@ -4400,8 +4432,6 @@ makednspersistvalue() { fi _debug "Account URL" "$_accUri" - _txt_name="_validation-persist.$_mdpv_domain" - _txt_suffix="; accounturi=$_accUri" if [ "$_mdpv_wildcard" = "1" ]; then _txt_suffix="$_txt_suffix; policy=wildcard" @@ -8075,7 +8105,9 @@ Parameters: --dns-persist-wildcard Used with '--make-dns-persist-value'. Adds 'policy=wildcard' to the generated TXT record so the issuer is also authorized for wildcards - and subdomains (draft-ietf-acme-dns-persist-01). + and subdomains (draft-ietf-acme-dns-persist-01). It is implied when + the domain given to -d is a wildcard (e.g. '*.example.com'); the + record itself is always published at the base domain. --dns-persist-ca-name Used with '--make-dns-persist-value'. Use the given CA identity domain (e.g. 'ssl.com') as the issuer-domain-name in the TXT record. If omitted, the identities are read from the ACME directory's From b4925052dd10f6b407fba5c5f550ebe829356d28 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 2 Aug 2026 21:55:19 +0800 Subject: [PATCH 19/29] Fix dns_cyon cleanup failing on FreeBSD _cyon_delete_txt relied on `printf "%b"` to convert a sed-injected literal `\n` into a real newline, but `%b` also processes the `\"` escapes that the JSON response is full of. glibc/bash/dash keep the backslash of such an undefined escape, FreeBSD's printf (sh builtin and /usr/bin/printf alike) drops it -- so `data-hash=\"..\"` became `data-hash=".."`, the extraction regex matched nothing, _dns_entries stayed empty and no TXT record was ever deleted. Drop the newline injection and use _egrep_o, which already yields one match per line, then parse each line with sed. Also feed the read loop a newline-terminated list: `printf "%s"` left the last line unterminated, so `read` returned non-zero at EOF and the loop skipped the final entry on every platform. Verified identical output on FreeBSD 14.3, Linux/bash and Linux/dash. Fixes #7169 --- dnsapi/dns_cyon.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_cyon.sh b/dnsapi/dns_cyon.sh index d4b6b6e8..6677b32f 100644 --- a/dnsapi/dns_cyon.sh +++ b/dnsapi/dns_cyon.sh @@ -285,15 +285,15 @@ _cyon_delete_txt() { list_txt_url="https://my.cyon.ch/domain/dnseditor/list-async" - list_txt_response="$(_get "${list_txt_url}" | sed -e 's/data-hash/\\ndata-hash/g')" + list_txt_response="$(_get "${list_txt_url}")" _debug list_txt_response "${list_txt_response}" if ! _cyon_check_if_2fa_missed "${list_txt_response}"; then return 1; fi # Find and delete all acme challenge entries for the $fulldomain. - _dns_entries="$(printf "%b\n" "${list_txt_response}" | sed -n 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\".*/\1 \2/p')" + _dns_entries="$(printf "%s\n" "${list_txt_response}" | _egrep_o 'data-hash=\\"[^"]*\\" data-identifier=\\"[^"]*\\"' | sed 's/data-hash=\\"\([^"]*\)\\" data-identifier=\\"\([^"]*\)\\"/\1 \2/')" - printf "%s" "${_dns_entries}" | while read -r _hash _identifier; do + printf "%s\n" "${_dns_entries}" | while read -r _hash _identifier; do dns_type="$(printf "%s" "$_identifier" | cut -d'|' -f1)" dns_domain="$(printf "%s" "$_identifier" | cut -d'|' -f2)" From 5e6c263211d05d75434f10e61ac479b06ff30a1f Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 5 Aug 2026 19:53:52 +0800 Subject: [PATCH 20/29] Fix empty finalize URL when resuming a saved DNS-manual order The decision to resume a pending order is keyed on Le_Vlist, but the decision to keep Le_OrderFinalize/Le_LinkOrder was keyed on the webroot being exactly "dns". Any other webroot with a saved Le_Vlist skipped newOrder and then finalized against an empty URL. Key both on Le_Vlist, and always clear Le_LinkCert, which is per-run state that is never read back from the saved domain conf. Fixes #7177 --- acme.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index 9601d43d..5b59ece4 100755 --- a/acme.sh +++ b/acme.sh @@ -4949,11 +4949,18 @@ issue() { if [ -z "$_ACME_IS_RENEW" ]; then _initpath "$_main_domain" "$_key_length" mkdir -p "$DOMAIN_PATH" - elif ! _hasfield "$_web_roots" "$W_DNS"; then + elif [ -z "$Le_Vlist" ]; then + # Whether the saved order is resumed is decided by Le_Vlist below, so key + # this on Le_Vlist too. With no pending order to resume a new one is + # created, and a stale order link from the previous issuance must not be + # reused. https://github.com/acmesh-official/acme.sh/issues/3635 Le_OrderFinalize="" Le_LinkOrder="" - Le_LinkCert="" fi + # Per-run state only: it is set after finalize and never read back from the + # saved domain conf. Carrying it over would make a run that gives up while + # the order is still 'processing' download the previous certificate again. + Le_LinkCert="" if _hasfield "$_web_roots" "$W_DNS" && [ -z "$FORCE_DNS_MANUAL" ]; then _err "$_DNS_MANUAL_ERROR" From 603a126a7cd557d001dfc903ddd0189c914262e6 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 5 Aug 2026 19:56:13 +0800 Subject: [PATCH 21/29] Fix synology_dsm logging out after the temp admin is already deleted _temp_admin_cleanup ran before _logout, so the logout request carried the session id of an account synouser had already removed and DSM kept the orphaned entry in Connected Users. Swap the order in both terminal branches, and add the missing _logout to the two post-login error paths (CRT list failure, certificate not found without SYNO_CREATE). _logout overwrites the global $response, so the upload-failure branch prints its error message before calling it. Reported by @Bertl75 in #7174 --- deploy/synology_dsm.sh | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index d05e503a..336980a5 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -344,6 +344,7 @@ synology_dsm_deploy() { else _err "Failed to fetch certificate info: $error_code, please try again or contact Synology to learn more." fi + _logout _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi @@ -354,6 +355,7 @@ synology_dsm_deploy() { if [ -z "$id" ] && [ -z "$SYNO_CREATE" ]; then _err "Unable to find certificate: $SYNO_CERTIFICATE and \$SYNO_CREATE is not set." + _logout _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi @@ -389,13 +391,13 @@ synology_dsm_deploy() { else _info "Restart HTTP services not necessary." fi - _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" _logout + _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 0 else - _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" _err "Unable to update certificate, got error response: $response." _logout + _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi } @@ -403,6 +405,8 @@ synology_dsm_deploy() { #################### Private functions below ################################## _logout() { # Logout CERT user only to not occupy a permanent session, e.g. in DSM's "Connected Users" widget (based on previous variables) + # Must be called before _temp_admin_cleanup: once the temp admin is deleted, its session can no longer be logged out. + # Note: this overwrites $response, so print any error message that needs it before calling. response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=logout&_sid=$sid") _debug3 response "$response" } From 9aad4dcbd5fb22f4c97ee72963af58ee25481d1b Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 5 Aug 2026 23:01:54 +0800 Subject: [PATCH 22/29] Fix multideploy MULTIDEPLOY_FILENAME conf read and allow an absolute path _getdeployconf assigns and exports the variable, it does not print the value, so wrapping it in a command substitution ran it in a subshell and always yielded an empty string. A MULTIDEPLOY_FILENAME saved by an earlier run was therefore never restored on renewal and the hook silently fell back to multideploy.yml. Call it the same way every other deploy hook does. Also treat a MULTIDEPLOY_FILENAME starting with '/' as an absolute path instead of always resolving it under DOMAIN_PATH, so one deploy file can live outside the certificate directory and be shared by all domains. Names without a leading '/' keep resolving under DOMAIN_PATH as before. --- deploy/multideploy.sh | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/deploy/multideploy.sh b/deploy/multideploy.sh index 375668ec..4a8c9dc9 100644 --- a/deploy/multideploy.sh +++ b/deploy/multideploy.sh @@ -10,6 +10,10 @@ # Usage (shown values are the examples): # 1. Set optional environment variables # - export MULTIDEPLOY_FILENAME="multideploy.yaml" - "multideploy.yml" will be automatically used if not set" +# A name without a leading '/' is looked up in the certificate directory +# of the domain. An absolute path is used as is, so a single deploy file +# can be shared by all domains, e.g. +# - export MULTIDEPLOY_FILENAME="/etc/acme/multideploy.yml" # # 2. Run command: # acme.sh --deploy --deploy-hook multideploy -d example.com @@ -49,7 +53,7 @@ multideploy_deploy() { _debug _cfullchain "$_cfullchain" _debug _cpfx "$_cpfx" - MULTIDEPLOY_FILENAME="${MULTIDEPLOY_FILENAME:-$(_getdeployconf MULTIDEPLOY_FILENAME)}" + _getdeployconf MULTIDEPLOY_FILENAME if [ -z "$MULTIDEPLOY_FILENAME" ]; then MULTIDEPLOY_FILENAME="multideploy.yml" _info "MULTIDEPLOY_FILENAME is not set, so I will use 'multideploy.yml'." @@ -75,7 +79,8 @@ multideploy_deploy() { # This function preprocesses the deploy file by checking if 'yq' is installed, # verifying the existence of the deploy file, and ensuring only one deploy file is present. # Arguments: -# $@ - Posible deploy file names. +# $@ - Posible deploy file names. A name starting with '/' is treated as an +# absolute path, any other name is relative to the domain directory. # Usage: # _preprocess_deployfile "" "?" _preprocess_deployfile() { @@ -87,15 +92,21 @@ _preprocess_deployfile() { _debug3 "yq is installed." # Check if deploy file exists + found_file="" for file in "$@"; do - _debug3 "Checking file" "$DOMAIN_PATH/$file" - if [ -f "$DOMAIN_PATH/$file" ]; then + if _startswith "$file" "/"; then + _multideploy_path="$file" + else + _multideploy_path="$DOMAIN_PATH/$file" + fi + _debug3 "Checking file" "$_multideploy_path" + if [ -f "$_multideploy_path" ]; then _debug3 "File found" if [ -n "$found_file" ]; then _err "Multiple deploy files found. Please keep only one deploy file." return 1 fi - found_file="$file" + found_file="$_multideploy_path" else _debug3 "File not found" fi @@ -105,12 +116,12 @@ _preprocess_deployfile() { _err "Deploy file not found. Go to https://github.com/acmesh-official/acme.sh/wiki/deployhooks#36-deploying-to-multiple-services-with-the-same-hooks to see how to create one." return 1 fi - if ! _check_deployfile "$DOMAIN_PATH/$found_file"; then - _err "Deploy file is not valid: $DOMAIN_PATH/$found_file" + if ! _check_deployfile "$found_file"; then + _err "Deploy file is not valid: $found_file" return 1 fi - echo "$DOMAIN_PATH/$found_file" + echo "$found_file" } # Description: From f67be78ff429df2ebd7c447cfefd463804ddb4b6 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 6 Aug 2026 19:36:26 +0800 Subject: [PATCH 23/29] Fix dns_namecheap ignoring IsOurDNS when matching the root zone _get_root_by_getList() matched the candidate suffix as an unanchored substring of the whole domains.getList response and never looked at the IsOurDNS attribute. A domain parked on Namecheap's webhosting DNS is listed with IsOurDNS="false", yet it was still accepted as the root zone, so _get_root() returned success and the domains.dns.getHosts probe that would have found the real zone never ran. Every following getHosts call was then refused with error 2030288 "not using proper DNS servers" and the challenge failed with "invalid tld". Match the exact entry instead and require IsOurDNS="true", so a subdomain delegated to Namecheap BasicDNS/FreeDNS under a parent that is not on Namecheap DNS now resolves to its own zone. Matching the entry exactly also drops the old substring/regex match, in which the dots of a domain matched any character. Fixes #7178 --- dnsapi/dns_namecheap.sh | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_namecheap.sh b/dnsapi/dns_namecheap.sh index cca59735..7035640b 100755 --- a/dnsapi/dns_namecheap.sh +++ b/dnsapi/dns_namecheap.sh @@ -104,6 +104,9 @@ _get_root_by_getList() { return 1 fi + _namecheap_domain_list=$(echo "$response" | _egrep_o ']*') + _debug2 domain_list "$_namecheap_domain_list" + i=2 p=1 @@ -120,7 +123,7 @@ _get_root_by_getList() { return 1 fi - if ! _contains "$response" "$h"; then + if ! _namecheap_is_our_dns "$h"; then _debug "$h not found" else _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") @@ -133,6 +136,29 @@ _get_root_by_getList() { return 1 } +#Usage: _namecheap_is_our_dns +#Succeeds only when domains.getList listed exactly AND that entry is +#served by Namecheap's own DNS. A domain parked on Namecheap's webhosting DNS +#is listed with IsOurDNS="false", and every dns.getHosts/setHosts call against +#it is refused with error 2030288 "not using proper DNS servers". Accepting +#such a domain as the root zone hides a subdomain that IS delegated to +#Namecheap DNS and that the getHosts probe below would have found. +#https://github.com/acmesh-official/acme.sh/issues/7178 +_namecheap_is_our_dns() { + _namecheap_entry=$(echo "$_namecheap_domain_list" | grep -F " Name=\"$1\"" | _head_n 1) + if [ -z "$_namecheap_entry" ]; then + return 1 + fi + + _namecheap_ourdns=$(echo "$_namecheap_entry" | _egrep_o ' IsOurDNS="[^"]*' | cut -d '"' -f 2) + _debug2 "$1 IsOurDNS" "$_namecheap_ourdns" + + if [ "$_namecheap_ourdns" = "true" ]; then + return 0 + fi + return 1 +} + _get_root_by_getHosts() { i=100 p=99 From f1cbba05f67e82f921990d385add7db17224d56c Mon Sep 17 00:00:00 2001 From: Alexey Morozov <223741939+morozov-alexey@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:22:28 +0200 Subject: [PATCH 24/29] feat: added nexdns dnsapi (#7170) * feat: added nexdns dnsapi Adds a DNS-01 hook for NexDNS, an authoritative DNS service with a REST API. dns_nexdns_add walks the label list to find the zone that owns the challenge name and creates the TXT record in it. dns_nexdns_rm lists the TXT records at that name, picks the one carrying exactly this challenge value and deletes it by id, so a wildcard and its base domain do not remove each other's record. A 429 is waited out and the request retried, in the shape dns_hetznercloud.sh and dns_bunny.sh already use. * dns_nexdns: cap the rate-limit wait, judge success by status, add the tracking issue --- dnsapi/dns_nexdns.sh | 244 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100755 dnsapi/dns_nexdns.sh diff --git a/dnsapi/dns_nexdns.sh b/dnsapi/dns_nexdns.sh new file mode 100755 index 00000000..e4447c0e --- /dev/null +++ b/dnsapi/dns_nexdns.sh @@ -0,0 +1,244 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_nexdns_info='NexDNS +Site: nexdns.tech +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_nexdns +Options: + NEXDNS_Token API token. Can be created at https://nexdns.tech/settings/api-keys + NEXDNS_Api API base url. Default "https://api.nexdns.tech/v1". Optional. +Issues: github.com/acmesh-official/acme.sh/issues/7179 +Author: NexDNS +' + +NEXDNS_Api_Default="https://api.nexdns.tech/v1" + +######## Public functions ##################### + +#Usage: dns_nexdns_add _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_nexdns_add() { + fulldomain=$1 + txtvalue=$2 + + if ! _nexdns_init; then + return 1 + fi + + _saveaccountconf_mutable NEXDNS_Token "$NEXDNS_Token" + if [ "$NEXDNS_Api" != "$NEXDNS_Api_Default" ]; then + _saveaccountconf_mutable NEXDNS_Api "$NEXDNS_Api" + else + _clearaccountconf_mutable NEXDNS_Api + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Cannot find the zone of $fulldomain in this NexDNS account." + return 1 + fi + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _debug _domain_id "$_domain_id" + + _info "Adding the TXT record for $fulldomain" + if ! _nexdns_rest POST "zones/$_domain_id/records" "{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120}"; then + return 1 + fi + + _info "The TXT record has been added." + return 0 +} + +#Usage: dns_nexdns_rm _acme-challenge.www.example.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_nexdns_rm() { + fulldomain=$1 + txtvalue=$2 + + if ! _nexdns_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Cannot find the zone of $fulldomain in this NexDNS account." + return 1 + fi + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _debug _domain_id "$_domain_id" + + _info "Removing the TXT record for $fulldomain" + if ! _nexdns_rest GET "zones/$_domain_id/records?type=TXT&name=$_sub_domain"; then + return 1 + fi + + #All the challenge records share one name and one type, so the value is the + #only thing that tells them apart. A certificate covering example.com and + #*.example.com puts two of them at the same name at the same time. + _record_id="$(echo "$response" | tr '{' "\n" | grep -- "$txtvalue" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + _debug _record_id "$_record_id" + + if [ -z "$_record_id" ]; then + _info "The TXT record is already gone, nothing to remove." + return 0 + fi + + if ! _nexdns_rest DELETE "zones/$_domain_id/records/$_record_id"; then + return 1 + fi + + _info "The TXT record has been removed." + return 0 +} + +#################### Private functions below ################################## + +#Reads the token and the api url, and applies the default url. +_nexdns_init() { + NEXDNS_Token="${NEXDNS_Token:-$(_readaccountconf_mutable NEXDNS_Token)}" + NEXDNS_Api="${NEXDNS_Api:-$(_readaccountconf_mutable NEXDNS_Api)}" + + if [ -z "$NEXDNS_Token" ]; then + _err "You have not set NEXDNS_Token yet." + _err "Create one at https://nexdns.tech/settings/api-keys, on a plan that includes API access, then:" + _err "export NEXDNS_Token=\"your-api-token\"" + return 1 + fi + + if [ -z "$NEXDNS_Api" ]; then + NEXDNS_Api="$NEXDNS_Api_Default" + fi + #A trailing slash would make every request path begin with a double slash. + NEXDNS_Api="$(echo "$NEXDNS_Api" | sed 's|/*$||')" + _debug NEXDNS_Api "$NEXDNS_Api" + + return 0 +} + +#_acme-challenge.www.example.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=example.com +# _domain_id=Zm9vYmFy +_get_root() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + #not valid + return 1 + fi + + if ! _nexdns_rest GET "zones?search=$h&per_page=100"; then + return 1 + fi + + #search matches on a substring, so the page can also hold zones that merely + #contain h. Take the id of the one whose name is exactly h. + _domain_id="$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + + p=$i + i=$(_math "$i" + 1) + done +} + +#Usage: _nexdns_rest GET|POST|DELETE path [body] [attempt] +_nexdns_rest() { + m=$1 + ep=$2 + data=$3 + attempt=${4:-1} + _debug "$ep" + + export _H1="Authorization: Bearer $NEXDNS_Token" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + + if [ "$m" = "GET" ]; then + response="$(_get "$NEXDNS_Api/$ep")" + else + _debug2 data "$data" + response="$(_post "$data" "$NEXDNS_Api/$ep" "" "$m" "application/json")" + fi + + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + + #A single certificate costs a handful of requests, but a renewal sweep over + #many of them meets the account's per-minute budget, and that run is + #unattended. Retry-After is treated as a floor: an api may report the time one + #token needs at an average rate and name a second when nothing frees for a + #minute, so the wait grows on its own across attempts. + if [ "$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" = "429" ]; then + if [ "$attempt" -ge 4 ]; then + _err "$m $ep failed: rate limited, and the wait budget is spent" + return 1 + fi + + _retry_after="$(grep -i "^Retry-After" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d : -f 2 | tr -d " \r\n")" + _backoff="$(_math "$attempt" \* 15)" + #The header may also carry an http date. Anything but a plain count of + #seconds falls through to the backoff rather than being parsed: guessing + #wrong about a date is worse than waiting a known interval, and comparing a + #date numerically would abort the hook outright. + case "$_retry_after" in + "" | *[!0-9]*) _retry_after="$_backoff" ;; + *) + if [ "$_retry_after" -lt "$_backoff" ]; then + _retry_after="$_backoff" + fi + ;; + esac + + #A wait longer than this is a refusal rather than a schedule, and sleeping + #it out would hold the hook for the length of the window. Hand the run back + #instead, so the next cron pass picks it up. + if [ "$_retry_after" -gt 120 ]; then + _err "$m $ep failed: rate limited for ${_retry_after}s, longer than this hook will wait" + return 1 + fi + + _info "Rate limited by the NexDNS API; retrying in $_retry_after seconds." + _sleep "$_retry_after" + + _nexdns_rest "$m" "$ep" "$data" "$(_math "$attempt" + 1)" + return $? + fi + + #Whitespace between a key and its value would defeat every match made on the + #body, here and in the callers. + response="$(echo "$response" | _normalizeJson)" + _debug2 response "$response" + + #The status line decides success, not the body: a delete answers 204 with no + #body at all, and a record whose own content contains "error": would otherwise + #turn a stored value into a reported failure. The body is read only for the + #message once the status says the request was rejected. + _code="$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 _code "$_code" + case "$_code" in + "" | 2*) + return 0 + ;; + esac + + #A rejected request carries {"error":{"code":..,"message":..}}, so say what the + #api says went wrong. + _message="$(echo "$response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_message" ]; then + _message="status $_code" + fi + _err "$m $ep failed: $_message" + + return 1 +} From 05367d3598b43618f7e89f160f5d570f2ab1da39 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 8 Aug 2026 13:09:18 +0800 Subject: [PATCH 25/29] Listen on both IPv4 and IPv6 in standalone mode by default socat binds a single family unless told which one: up to 1.7.x the default IP version for TCP-LISTEN is 4, and 1.8.0 made it "no preference", which resolves to whatever getaddrinfo and bindv6only happen to give. So an order carrying both an IPv4 and an IPv6 identifier could never pass both http-01 challenges. Bind one socket per family instead, with ipv6only on the IPv6 one so the two do not collide. IPv4-mapped IPv6 addresses are not a portable alternative, OpenBSD does not support them at all. The IPv6 listener is best effort, a host without IPv6 still gets the IPv4 one. The python fallback does the same. --listen-v4 and --listen-v6 keep forcing a single family, and passing both now means both. Le_Listen_V4 and Le_Listen_V6 were mutually exclusive in the domain conf, which silently dropped one of them on renewal, and _starttlsserver let -4 win when both were set. Fixes #7185 --- acme.sh | 115 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 28 deletions(-) diff --git a/acme.sh b/acme.sh index 5b59ece4..4d4f6cbe 100755 --- a/acme.sh +++ b/acme.sh @@ -2715,6 +2715,21 @@ _clearcaconf() { _clear_conf "$CA_CONF" "$1" } +#Starts a socat listener in the background, the pid is set to _socat_pid. +#It uses the content, _content_len, _NC and _SOCAT_ERR of _startserver. +#options +_startsocat() { + _socat_opts="$1" + _debug "_NC" "$_NC $_socat_opts" + $_NC $_socat_opts SYSTEM:"sleep 1; \ +echo 'HTTP/1.0 200 OK'; \ +echo 'Content-Length\: $_content_len'; \ +echo ''; \ +printf '%s' '$content';" 2>>"$_SOCAT_ERR" & + _socat_pid="$!" + _debug "_socat_pid" "$_socat_pid" +} + # content localaddress _startserver() { content="$1" @@ -2728,16 +2743,24 @@ _startserver() { _debug Le_Listen_V4 "$Le_Listen_V4" _debug Le_Listen_V6 "$Le_Listen_V6" + _serverproc_v6="" if _exists "socat"; then _NC="socat" - if [ "$Le_Listen_V6" ]; then + SOCAT_OPTIONS6="" + if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then _NC="$_NC -6" SOCAT_OPTIONS=TCP6-LISTEN - elif [ "$Le_Listen_V4" ]; then + elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then _NC="$_NC -4" SOCAT_OPTIONS=TCP4-LISTEN - else + elif [ "$ncaddr" ]; then + #a single local address belongs to a single family, let socat pick it SOCAT_OPTIONS=TCP-LISTEN + else + #listen on both ipv4 and ipv6, with one socket for each family: + #ipv4-mapped ipv6 addresses are not available everywhere. + SOCAT_OPTIONS=TCP4-LISTEN + SOCAT_OPTIONS6=TCP6-LISTEN fi if [ "$DEBUG" ] && [ "$DEBUG" -gt "1" ]; then @@ -2745,6 +2768,10 @@ _startserver() { fi SOCAT_OPTIONS=$SOCAT_OPTIONS:$Le_HTTPPort,crlf,reuseaddr,fork + if [ "$SOCAT_OPTIONS6" ]; then + #ipv6only keeps this socket from colliding with the ipv4 one + SOCAT_OPTIONS6=$SOCAT_OPTIONS6:$Le_HTTPPort,crlf,reuseaddr,fork,ipv6only=1 + fi #Adding bind to local-address if [ "$ncaddr" ]; then @@ -2753,14 +2780,14 @@ _startserver() { _content_len="$(printf "%s" "$content" | wc -c)" _debug _content_len "$_content_len" - _debug "_NC" "$_NC $SOCAT_OPTIONS" export _SOCAT_ERR="$(_mktemp)" - $_NC $SOCAT_OPTIONS SYSTEM:"sleep 1; \ -echo 'HTTP/1.0 200 OK'; \ -echo 'Content-Length\: $_content_len'; \ -echo ''; \ -printf '%s' '$content';" 2>"$_SOCAT_ERR" & - serverproc="$!" + _startsocat "$SOCAT_OPTIONS" + serverproc="$_socat_pid" + if [ "$SOCAT_OPTIONS6" ]; then + #best effort, the host may have no ipv6 support at all + _startsocat "$SOCAT_OPTIONS6" + _serverproc_v6="$_socat_pid" + fi else _PYTHON="" if _exists "python3"; then @@ -2772,21 +2799,40 @@ printf '%s' '$content';" 2>"$_SOCAT_ERR" & fi if [ "$_PYTHON" ]; then _debug "Using python: $_PYTHON" - _AF="socket.AF_INET" - _BIND_ADDR="0.0.0.0" - if [ "$Le_Listen_V6" ]; then - _AF="socket.AF_INET6" + #a comma separated list of addresses to listen on, one socket for each + _BIND_ADDR="0.0.0.0,::" + if [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then _BIND_ADDR="::" + elif [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then + _BIND_ADDR="0.0.0.0" fi if [ "$ncaddr" ]; then _BIND_ADDR="$ncaddr" fi + _debug "_BIND_ADDR" "$_BIND_ADDR" export _SOCAT_ERR="$(_mktemp)" - $_PYTHON -c "import socket,sys;s=socket.socket($_AF,socket.SOCK_STREAM);s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1);s.bind((sys.argv[2],int(sys.argv[1])));s.listen(5);res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3]; + $_PYTHON -c "import socket,sys,select +res='HTTP/1.0 200 OK\r\nContent-Length: '+str(len(sys.argv[3]))+'\r\n\r\n'+sys.argv[3] +ads=sys.argv[2].split(',') +ls=[] +for ad in ads: + try: + sk=socket.socket(socket.AF_INET6 if ':' in ad else socket.AF_INET,socket.SOCK_STREAM) + sk.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1) + if ':' in ad and len(ads)>1: + sk.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1) + sk.bind((ad,int(sys.argv[1]))) + sk.listen(5) + ls.append(sk) + except Exception: + sys.stderr.write(str(sys.exc_info()[1])+'\n') +if not ls: + sys.exit(1) while True: - c,a=s.accept() - c.sendall(res.encode() if hasattr(res, 'encode') else res) - c.close()" "$Le_HTTPPort" "$_BIND_ADDR" "$content" 2>"$_SOCAT_ERR" & + for sk in select.select(ls,[],[])[0]: + c,a=sk.accept() + c.sendall(res.encode() if hasattr(res, 'encode') else res) + c.close()" "$Le_HTTPPort" "$_BIND_ADDR" "$content" 2>"$_SOCAT_ERR" & serverproc="$!" _NC="$_PYTHON" else @@ -2809,6 +2855,11 @@ while True: _stopserver() { pid="$1" _debug "pid" "$pid" + if [ "$_serverproc_v6" ]; then + _debug "_serverproc_v6" "$_serverproc_v6" + kill $_serverproc_v6 >/dev/null 2>&1 + _serverproc_v6="" + fi if [ -z "$pid" ]; then rm -f "$_SOCAT_ERR" return @@ -2882,9 +2933,11 @@ _starttlsserver() { _debug Le_Listen_V4 "$Le_Listen_V4" _debug Le_Listen_V6 "$Le_Listen_V6" - if [ "$Le_Listen_V4" ]; then + #openssl s_server binds a single socket, so both options together can only + #mean: do not force a family, same as when neither of them is given. + if [ "$Le_Listen_V4" ] && [ -z "$Le_Listen_V6" ]; then __S_OPENSSL="$__S_OPENSSL -4" - elif [ "$Le_Listen_V6" ]; then + elif [ "$Le_Listen_V6" ] && [ -z "$Le_Listen_V4" ]; then __S_OPENSSL="$__S_OPENSSL -6" fi @@ -5986,12 +6039,17 @@ $_authorizations_map" _clearaccountconf "HTTPS_INSECURE" fi - if [ "$Le_Listen_V4" ]; then - _savedomainconf "Le_Listen_V4" "$Le_Listen_V4" - _cleardomainconf Le_Listen_V6 - elif [ "$Le_Listen_V6" ]; then - _savedomainconf "Le_Listen_V6" "$Le_Listen_V6" - _cleardomainconf Le_Listen_V4 + if [ "$Le_Listen_V4" ] || [ "$Le_Listen_V6" ]; then + if [ "$Le_Listen_V4" ]; then + _savedomainconf "Le_Listen_V4" "$Le_Listen_V4" + else + _cleardomainconf Le_Listen_V4 + fi + if [ "$Le_Listen_V6" ]; then + _savedomainconf "Le_Listen_V6" "$Le_Listen_V6" + else + _cleardomainconf Le_Listen_V6 + fi fi if [ "$Le_ForceNewDomainKey" = "1" ]; then @@ -8172,8 +8230,9 @@ Parameters: --ocsp, --ocsp-must-staple Generate OCSP-Must-Staple extension. --always-force-new-domain-key Generate new domain key on renewal. Otherwise, the domain key is not changed by default. --auto-upgrade [0|1] Valid for '--upgrade' command, indicating whether to upgrade automatically in future. Defaults to 1 if argument is omitted. - --listen-v4 Force standalone/tls server to listen at ipv4. - --listen-v6 Force standalone/tls server to listen at ipv6. + --listen-v4 Force standalone/tls server to listen at ipv4 only. + By default the standalone server listens on both ipv4 and ipv6. + --listen-v6 Force standalone/tls server to listen at ipv6 only. --request-v4 Force client requests to use ipv4 to connect to the CA server. --request-v6 Force client requests to use ipv6 to connect to the CA server. --openssl-bin Specifies a custom openssl bin location. From 4a3bc2c9193360bc1a8fbf7e0d7974c8e3d0a2f1 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 10 Aug 2026 10:00:26 +0800 Subject: [PATCH 26/29] Fix dns_netcup reporting a bogus 4013 instead of the real zone error The zone lookup walked the challenge name from the right and ended up asking netcup for the full "_acme-challenge." as a zone name. That can never be a zone, so netcup answered 4013 "Validation Error", which replaced the real 5028 "The zone could not be found" as the error shown to the user. Stop one label short of the full name, and fail explicitly when no zone matched, reporting the last API response plus what to check. Before, a run where every candidate returned 5028 fell through to logout and returned success. --- dnsapi/dns_netcup.sh | 41 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_netcup.sh b/dnsapi/dns_netcup.sh index 8609adf6..3b291854 100644 --- a/dnsapi/dns_netcup.sh +++ b/dnsapi/dns_netcup.sh @@ -33,9 +33,11 @@ dns_netcup_add() { exit=$(echo "$fulldomain" | tr -dc '.' | wc -c) exit=$(_math "$exit" + 1) i=$exit + _nc_last=$(_nc_lastlevel "$i") + _nc_found="" while - [ "$exit" -gt 0 ] + [ "$exit" -ge "$_nc_last" ] do tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit") if [ "$(_math "$i" - "$exit")" -eq 0 ]; then @@ -51,12 +53,18 @@ dns_netcup_add() { _err "$msg" return 1 else + _nc_found=1 break fi fi fi exit=$(_math "$exit" - 1) done + if [ -z "$_nc_found" ]; then + _err "$msg" + _nc_nozone "$fulldomain" + return 1 + fi logout } @@ -70,9 +78,11 @@ dns_netcup_rm() { exit=$(_math "$exit" + 1) i=$exit rec="" + _nc_last=$(_nc_lastlevel "$i") + _nc_found="" while - [ "$exit" -gt 0 ] + [ "$exit" -ge "$_nc_last" ] do tmp=$(echo "$fulldomain" | cut -d'.' -f"$exit") if [ "$(_math "$i" - "$exit")" -eq 0 ]; then @@ -89,12 +99,18 @@ dns_netcup_rm() { _err "$msg" return 1 else + _nc_found=1 break fi fi fi exit=$(_math "$exit" - 1) done + if [ -z "$_nc_found" ]; then + _err "$msg" + _nc_nozone "$fulldomain" + return 1 + fi ida=0000 idv=0001 @@ -125,6 +141,27 @@ dns_netcup_rm() { logout } +# The zone is looked up by walking the challenge name from the right, one +# label at a time. The leftmost label is the challenge prefix, so the full +# name itself can never be a zone: asking netcup for it only returns 4013 +# "Validation Error", which would then mask the real 5028 "zone could not be +# found". Stop one label short, unless the name is too short to have a +# challenge prefix at all (manual invocation). +# levels +_nc_lastlevel() { + if [ "$1" -ge 3 ]; then + echo 2 + else + echo 1 + fi +} + +# fulldomain +_nc_nozone() { + _err "No DNS zone for $1 was found at netcup." + _err "Check that the domain belongs to the account of the configured NC_CID and that its DNS is hosted at netcup." +} + _login() { tmp=$(_post "{\"action\": \"login\", \"param\": {\"apikey\": \"$NC_Apikey\", \"apipassword\": \"$NC_Apipw\", \"customernumber\": \"$NC_CID\"}}" "$end" "" "POST") sid=$(echo "$tmp" | tr '{}' '\n' | grep apisessionid | cut -d '"' -f 4) From 2e2782f0d87b620acb1b17935ab658b4b904788c Mon Sep 17 00:00:00 2001 From: Zhiwei Liang Date: Wed, 12 Aug 2026 10:47:58 -0400 Subject: [PATCH 27/29] Remove deprecated Linode API v3 DNS plugin (#7054) Signed-off-by: Zhiwei Liang --- dnsapi/dns_linode.sh | 189 ------------------------------------------- 1 file changed, 189 deletions(-) delete mode 100755 dnsapi/dns_linode.sh diff --git a/dnsapi/dns_linode.sh b/dnsapi/dns_linode.sh deleted file mode 100755 index d74d1fc8..00000000 --- a/dnsapi/dns_linode.sh +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env sh -# shellcheck disable=SC2034 -dns_linode_info='Linode.com (Old) - Deprecated. Use dns_linode_v4 -Site: Linode.com -Options: - LINODE_API_KEY API Key -Author: Philipp Grosswiler -' - -LINODE_API_URL="https://api.linode.com/?api_key=$LINODE_API_KEY&api_action=" - -######## Public functions ##################### - -#Usage: dns_linode_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" -dns_linode_add() { - fulldomain="${1}" - txtvalue="${2}" - - if ! _Linode_API; then - return 1 - fi - - _info "Using Linode" - _debug "Calling: dns_linode_add() '${fulldomain}' '${txtvalue}'" - - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "Domain does not exist." - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _parameters="&DomainID=$_domain_id&Type=TXT&Name=$_sub_domain&Target=$txtvalue" - - if _rest GET "domain.resource.create" "$_parameters" && [ -n "$response" ]; then - _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1) - _debug _resource_id "$_resource_id" - - if [ -z "$_resource_id" ]; then - _err "Error adding the domain resource." - return 1 - fi - - _info "Domain resource successfully added." - return 0 - fi - - return 1 -} - -#Usage: dns_linode_rm _acme-challenge.www.domain.com -dns_linode_rm() { - fulldomain="${1}" - - if ! _Linode_API; then - return 1 - fi - - _info "Using Linode" - _debug "Calling: dns_linode_rm() '${fulldomain}'" - - _debug "First detect the root zone" - if ! _get_root "$fulldomain"; then - _err "Domain does not exist." - return 1 - fi - _debug _domain_id "$_domain_id" - _debug _sub_domain "$_sub_domain" - _debug _domain "$_domain" - - _parameters="&DomainID=$_domain_id" - - if _rest GET "domain.resource.list" "$_parameters" && [ -n "$response" ]; then - response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")" - - resource="$(echo "$response" | _egrep_o "{.*\"NAME\":\s*\"$_sub_domain\".*}")" - if [ "$resource" ]; then - _resource_id=$(printf "%s\n" "$resource" | _egrep_o "\"RESOURCEID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ ) - if [ "$_resource_id" ]; then - _debug _resource_id "$_resource_id" - - _parameters="&DomainID=$_domain_id&ResourceID=$_resource_id" - - if _rest GET "domain.resource.delete" "$_parameters" && [ -n "$response" ]; then - _resource_id=$(printf "%s\n" "$response" | _egrep_o "\"ResourceID\":\s*[0-9]+" | cut -d : -f 2 | tr -d " " | _head_n 1) - _debug _resource_id "$_resource_id" - - if [ -z "$_resource_id" ]; then - _err "Error deleting the domain resource." - return 1 - fi - - _info "Domain resource successfully deleted." - return 0 - fi - fi - - return 1 - fi - - return 0 - fi - - return 1 -} - -#################### Private functions below ################################## - -_Linode_API() { - if [ -z "$LINODE_API_KEY" ]; then - LINODE_API_KEY="" - - _err "You didn't specify the Linode API key yet." - _err "Please create your key and try again." - - return 1 - fi - - _saveaccountconf LINODE_API_KEY "$LINODE_API_KEY" -} - -#################### Private functions below ################################## -#_acme-challenge.www.domain.com -#returns -# _sub_domain=_acme-challenge.www -# _domain=domain.com -# _domain_id=12345 -_get_root() { - domain=$1 - i=2 - p=1 - - if _rest GET "domain.list"; then - response="$(echo "$response" | tr -d "\n" | tr '{' "|" | sed 's/|/&{/g' | tr "|" "\n")" - while true; do - h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) - _debug h "$h" - if [ -z "$h" ]; then - #not valid - return 1 - fi - - hostedzone="$(echo "$response" | _egrep_o "{.*\"DOMAIN\":\s*\"$h\".*}")" - if [ "$hostedzone" ]; then - _domain_id=$(printf "%s\n" "$hostedzone" | _egrep_o "\"DOMAINID\":\s*[0-9]+" | _head_n 1 | cut -d : -f 2 | tr -d \ ) - if [ "$_domain_id" ]; then - _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") - _domain=$h - return 0 - fi - return 1 - fi - p=$i - i=$(_math "$i" + 1) - done - fi - return 1 -} - -#method method action data -_rest() { - mtd="$1" - ep="$2" - data="$3" - - _debug mtd "$mtd" - _debug ep "$ep" - - export _H1="Accept: application/json" - export _H2="Content-Type: application/json" - - if [ "$mtd" != "GET" ]; then - # both POST and DELETE. - _debug data "$data" - response="$(_post "$data" "$LINODE_API_URL$ep" "" "$mtd")" - else - response="$(_get "$LINODE_API_URL$ep$data")" - fi - - if [ "$?" != "0" ]; then - _err "error $ep" - return 1 - fi - _debug2 response "$response" - return 0 -} From a89ba9c2e5ca269047adf585a371bd7bd3e25587 Mon Sep 17 00:00:00 2001 From: XuChao Date: Thu, 13 Aug 2026 14:52:48 +0800 Subject: [PATCH 28/29] add deploy hook support for ikuai (#6456) * add deploy hook support for ikuai * fix shellcheck warn and shfmt the code * 1.fix config load 2.use _secre_debug2 to log password 3.use fullchain to deploy 4.fix hardcode id 5.change shebang * fix miss ; after cookie * 1.fix shfmt ; 2.fix IKUAI_CERT_ID conf load; 3.correct IKUAI_CERT_ID description * fix some log msg * fix shfmt --- deploy/ikuai.sh | 114 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 deploy/ikuai.sh diff --git a/deploy/ikuai.sh b/deploy/ikuai.sh new file mode 100644 index 00000000..fa0926dc --- /dev/null +++ b/deploy/ikuai.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env sh + +# Here is a script to deploy cert to ikuai using curl +# +# it requires following environment variables: +# +# IKUAI_SCHEME="http" - http or https , defaults to "http" +# IKUAI_HOSTNAME="localhost" - host , defaults to "192.168.9.1" +# IKUAI_PORT="80" - port , defaults to "80" +# IKUAI_USERNAME="admin" - username , defaults to "admin" +# IKUAI_PASSWORD="yourPassword" - password +# IKUAI_CERT_ID=1 - ikuai cert id , defaults to 1, and only 1 is supported for now !!! +# +#returns 0 means success, otherwise error. +# +######## Public functions ##################### +# +#domain keyfile certfile cafile fullchain +ikuai_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + # Get deploy conf + _getdeployconf IKUAI_SCHEME + _getdeployconf IKUAI_HOSTNAME + _getdeployconf IKUAI_PORT + _getdeployconf IKUAI_USERNAME + _getdeployconf IKUAI_PASSWORD + _getdeployconf IKUAI_CERT_ID + + # Use default if not provided + [ -n "$IKUAI_SCHEME" ] || IKUAI_SCHEME="http" + [ -n "$IKUAI_HOSTNAME" ] || IKUAI_HOSTNAME="192.168.9.1" + [ -n "$IKUAI_PORT" ] || IKUAI_PORT=80 + [ -n "$IKUAI_USERNAME" ] || IKUAI_USERNAME="admin" + [ -n "$IKUAI_CERT_ID" ] || IKUAI_CERT_ID=1 + + if [ -z "$IKUAI_PASSWORD" ]; then + _err "please define IKUAI_PASSWORD." + return 1 + fi + + _debug2 IKUAI_SCHEME "$IKUAI_SCHEME" + _debug2 IKUAI_HOSTNAME "$IKUAI_HOSTNAME" + _debug2 IKUAI_PORT "$IKUAI_PORT" + _debug2 IKUAI_USERNAME "$IKUAI_USERNAME" + _secure_debug2 IKUAI_PASSWORD "$IKUAI_PASSWORD" + + _info "Login to ikuai ..." + _ikuai_url="$IKUAI_SCHEME://$IKUAI_HOSTNAME:$IKUAI_PORT" + _pass_md5="$(printf "%s" "$IKUAI_PASSWORD" | _digest md5 hex | _lower_case)" + _pass_salt="$(printf "salt_11%s" "$IKUAI_PASSWORD" | _base64)" + _debug2 _ikuai_url "$_ikuai_url" + + _login_req="{\"username\":\"$IKUAI_USERNAME\",\"passwd\":\"$_pass_md5\",\"pass\":\"$_pass_salt\",\"remember_password\":\"\"}" + _response=$(_post "$_login_req" "$_ikuai_url/Action/login" "" "POST" "application/json") + + _err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)" + # check ErrMsg + if [ "$_err_msg" != "Success" ]; then + _err "Failed to login to ikuai: $_err_msg" + return 1 + fi + # check cookie + _cookie="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2 | sed 's/;.*//')" + if [ -z "$_cookie" ]; then + _err "Fail to get the cookie." + return 1 + fi + + # Set cookie header + _H1="Cookie: $_cookie; username=$IKUAI_USERNAME; login=1" + + _info "Deploy the cert to ikuai ... " + + # Should replace \n to @ ," " to # + _cert_content_single_line="$(tr <"$_cfullchain" '\n' '@' | tr ' ' '#')" + _key_content_single_line="$(tr <"$_ckey" '\n' '@' | tr ' ' '#')" + + _debug2 _cert_content_single_line "$_cert_content_single_line" + _secure_debug2 _key_content_single_line "$_key_content_single_line" + + _key_manager_req="{\"func_name\":\"key_manager\",\"action\":\"save\",\"param\":{\"ca\":\"$_cert_content_single_line\",\"key\":\"$_key_content_single_line\",\"id\":$IKUAI_CERT_ID,\"enabled\":\"yes\",\"comment\":\"\"}}" + _response=$(_post "$_key_manager_req" "$_ikuai_url/Action/call" "" "POST" "application/json") + + _err_msg="$(printf "%s" "$_response" | _normalizeJson | _egrep_o '"ErrMsg":"[^"]*"' | cut -d'"' -f 4)" + # check ErrMsg + if [ "$_err_msg" != "Success" ]; then + _err "Failed to deploy the cert to ikuai: $_err_msg" + return 1 + fi + + _info "Save the deploy config ... " + # Save the config + _savedeployconf IKUAI_SCHEME "$IKUAI_SCHEME" + _savedeployconf IKUAI_HOSTNAME "$IKUAI_HOSTNAME" + _savedeployconf IKUAI_PORT "$IKUAI_PORT" + _savedeployconf IKUAI_USERNAME "$IKUAI_USERNAME" + _savedeployconf IKUAI_PASSWORD "$IKUAI_PASSWORD" + _savedeployconf IKUAI_CERT_ID "$IKUAI_CERT_ID" + + _info "Successfully deployed certificate to ikuai. Enjoy! :>" + + return 0 +} From 41bdd4cd0e9bc908fa57dcfb9f8afc3c2658af40 Mon Sep 17 00:00:00 2001 From: Pablo Date: Wed, 12 Aug 2026 23:57:44 -0700 Subject: [PATCH 29/29] Add UniFi OS Server deploy hook (#7184) * Add UniFi OS Server deploy hook Uses UniFi OS Server's local REST API (login, list, upload, activate, remove superseded) since it stores certificates in its own Postgres database rather than flat config files, unlike the Cloud Key/UDM hardware covered by the existing unifi deploy hook. Tested against real instances on both macOS and Ubuntu 26.04 (self-hosted, remote). * Address review: portable sed/grep, scoped HTTPS_INSECURE, fingerprint matching - Replace GNU-only \n in sed replacement with a portable literal newline (matches dnsapi/dns_cpanel_uapi.sh, dnsapi/dns_glesys.sh); pipe the list response through _normalizeJson first for consistent formatting. - Use grep -F for the domain-name match instead of an unescaped BRE -- a wildcard cert name (*.example.com) broke the regex. - Drop \W (undocumented, GNU-only) from the cookie lookup in favor of an anchored `^Set-Cookie: *NAME=` match. - Scope HTTPS_INSECURE=1 inside the hook (matches deploy/proxmoxve.sh, deploy/fritzbox.sh) instead of requiring the caller to export it for the whole acme.sh run, which would also disable verification for the connection to the ACME CA. - On a duplicate-certificate response, match the existing entry by fingerprint instead of taking the first name match -- with more than one stale entry for a domain, the wrong one could get activated. - Check the list endpoint's response code before proceeding. - Save username/password with the "base64" flag (matches deploy/synology_dsm.sh) since _save_conf wraps values in unescaped single quotes. * Rework certificate handling: unique names per upload, drop cleanup Testing against a real UniFi OS Server showed the server enforces name uniqueness independently of fingerprint uniqueness, and that activation is exclusive server-wide regardless of name/domain. A unique name per upload avoids the name-collision path entirely (previously only handled as a retry-of-identical-content edge case), and removes the need for the post-hoc cleanup loop, which risked deleting the wrong entry. Co-Authored-By: Claude Sonnet 5 * Shorten generated certificate name to Unix epoch seconds Real-hardware testing showed the UniFi OS Server certificate list's name column is fixed-width and doesn't wrap, so a full human-readable timestamp overlaps the Expires column and makes both unreadable. Epoch seconds are still short enough to fit while remaining unique. Co-Authored-By: Claude Sonnet 5 * Add scoped cleanup of old certificate entries, use _time helper Per review: dropping cleanup entirely went further than the original bug required, and left old entries (each holding a private key) accumulating indefinitely. Since every upload now gets a name unique to its domain and run, cleanup can safely target only entries whose name starts with that domain -- entries this hook itself created -- excluding the one just activated. Also swaps date +%s for the core _time helper, and rewrote the design comments to make them clearer and match the current behavior instead of the pre-redesign one. Co-Authored-By: Claude Sonnet 5 --------- Co-authored-by: Claude Sonnet 5 --- deploy/unifios.sh | 307 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 deploy/unifios.sh diff --git a/deploy/unifios.sh b/deploy/unifios.sh new file mode 100644 index 00000000..82b65c69 --- /dev/null +++ b/deploy/unifios.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env sh +# Deploy hook for UniFi OS Server (self-hosted). +# +# 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. +# +# Tested on: Ubuntu 26.04 (remote) and macOS 26.6 (local). +# +# 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. +# +# UniFi OS Server exposes a REST API on its management port (default +# 11443) that its own web UI uses for certificate management: +# POST /api/auth/login - session login (cookie + JWT) +# GET /api/userCertificates - list uploaded certificates +# POST /api/userCertificates - upload a new certificate +# DELETE /api/userCertificates/{id} - remove a certificate +# PUT /api/userCertificates/{id}/status - activate/deactivate a certificate +# +# This was reverse-engineered from the browser's Network tab while using the +# real GUI upload/activate/delete flow -- it is undocumented but is the same +# code path the UI uses, so it's far more robust than editing settings.yaml, +# http/local-certs.conf, or the underlying Postgres user_certificates table +# directly (all of which are also touched by this API, but only as a result +# of the app's own internal logic, which handles cert parsing, active-cert +# bookkeeping, and nginx config regeneration correctly on its own). +# +# Auth: POST /api/auth/login returns a `TOKEN` cookie containing a JWT whose +# payload has a `csrfToken` claim. That value must be echoed back as the +# `x-csrf-token` header on every subsequent state-changing request (a classic +# double-submit CSRF pattern). No other cookies were found to be necessary. +# +# 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), +# 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 +# ACME CA. +# +# Design: This hook does not save a certificate ID between renewals. Each +# upload gets a name unique to that run: the domain name plus a timestamp. +# This name never collides with an entry from a previous deploy. This is +# true even if that entry is still active. The hook uploads and activates +# the new certificate before it removes any old entries. If a failure +# occurs during this process, the server still has a valid, active +# certificate. The hook removes old entries only after activation is +# complete. It removes only entries whose name starts with the domain name, +# because this is the hook's own naming convention. As a result, this step +# can only affect entries that this hook created for this domain. It can +# never affect a certificate that a user uploaded manually, and it can +# never affect a self-signed certificate. +# +# 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) +# +# Example: +# export DEPLOY_UNIFIOS_USERNAME="acmeuser" +# export DEPLOY_UNIFIOS_PASSWORD="xxxxx" +# acme.sh --deploy -d example.com --deploy-hook unifios +# +# Please report bugs to https://github.com/acmesh-official/acme.sh/issues/7182 + +_uos_response_code() { + # tr strips the trailing newline along with form feeds; re-terminate + # before the second _egrep_o, whose sed fallback (used wherever egrep -o + # is unavailable) drops an unterminated final line on some platforms. + _uos_code="$(_egrep_o <"$HTTP_HEADER" "^HTTP[^ ]* .*$" | cut -d " " -f 2-100 | tr -d "\f\n")" + printf '%s\n' "$_uos_code" | _egrep_o "^[0-9][0-9]*" +} + +_uos_response_cookie() { + # $1 = cookie name + grep <"$HTTP_HEADER" -i "^Set-Cookie: *$1=" | _tail_n 1 | _egrep_o "$1=[^;]*" | _head_n 1 +} + +unifios_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + + # Scoped to this hook's own subshell -- does not affect the rest of the + # acme.sh run (e.g. the connection to the ACME CA). + export HTTPS_INSECURE=1 + + _getdeployconf DEPLOY_UNIFIOS_HOST + DEPLOY_UNIFIOS_HOST="${DEPLOY_UNIFIOS_HOST:-https://localhost:11443}" + _savedeployconf DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST" + _debug DEPLOY_UNIFIOS_HOST "$DEPLOY_UNIFIOS_HOST" + + _getdeployconf DEPLOY_UNIFIOS_USERNAME + _getdeployconf DEPLOY_UNIFIOS_PASSWORD + + if [ -z "$DEPLOY_UNIFIOS_USERNAME" ] || [ -z "$DEPLOY_UNIFIOS_PASSWORD" ]; then + _err "DEPLOY_UNIFIOS_USERNAME and DEPLOY_UNIFIOS_PASSWORD must be set." + return 1 + fi + _debug DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" + _secure_debug DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" + + _info "Logging in to UniFi OS Server API at $DEPLOY_UNIFIOS_HOST..." + + # _json_encode always appends a trailing "\n" escape, even to input with + # no trailing newline (it normalizes via `echo`, unconditionally adding + # one). That's harmless for the key/cert file content below, which + # legitimately ends in a real newline anyway, but wrong for these plain + # strings -- strip the spurious escape it leaves behind. + _uos_user_json="$(printf '%s' "$DEPLOY_UNIFIOS_USERNAME" | _json_encode)" + _uos_user_json="${_uos_user_json%\\n}" + _uos_pass_json="$(printf '%s' "$DEPLOY_UNIFIOS_PASSWORD" | _json_encode)" + _uos_pass_json="${_uos_pass_json%\\n}" + _login_body="{\"username\":\"$_uos_user_json\",\"password\":\"$_uos_pass_json\",\"token\":\"\",\"rememberMe\":false}" + + _login_json="$(_post "$_login_body" "$DEPLOY_UNIFIOS_HOST/api/auth/login" "" "POST" "application/json")" + _login_code="$(_uos_response_code)" + + if [ "$_login_code" != "200" ]; then + _err "Login failed (HTTP $_login_code)." + _err "Response: $_login_json" + return 1 + fi + + # Credentials are proven correct now -- save them, rather than only at the + # very end, so a later step failing doesn't discard a working login. + # base64-encoded: _save_conf wraps values in single quotes with no + # escaping, so a literal "'" in the password would otherwise corrupt the + # domain conf (see deploy/synology_dsm.sh for the same pattern). + _savedeployconf DEPLOY_UNIFIOS_USERNAME "$DEPLOY_UNIFIOS_USERNAME" "base64" + _savedeployconf DEPLOY_UNIFIOS_PASSWORD "$DEPLOY_UNIFIOS_PASSWORD" "base64" + + _uos_token="$(_uos_response_cookie TOKEN)" + if [ -z "$_uos_token" ]; then + _err "Login succeeded but no TOKEN cookie was returned." + return 1 + fi + + _H1="Cookie: $_uos_token" + export _H1 + + _uos_jwt_payload="$(echo "$_uos_token" | cut -d '=' -f 2- | cut -d '.' -f 2)" + _uos_csrf="$(_durl_replace_base64 "$_uos_jwt_payload" | _dbase64 | _egrep_o '"csrfToken":"[^"]*"' | cut -d '"' -f 4)" + if [ -z "$_uos_csrf" ]; then + _err "Could not extract csrfToken from session token." + return 1 + fi + + _H2="x-csrf-token: $_uos_csrf" + export _H2 + + _info "Uploading new certificate..." + # "name" is a purely cosmetic label -- the server never validates it + # against the certificate's actual CN/SAN, and accepts arbitrary text + # including spaces (confirmed: a cert for example.com served correctly + # after being uploaded under the unrelated name "totally unrelated label"). + # The only constraint that matters here is uniqueness: the server rejects + # a second entry with a name it already has, so a bare domain name would + # collide with the previous deploy's entry on every renewal after the + # first. A full human-readable timestamp would make that obvious in the + # UI, but the certificate list's name column is fixed-width and doesn't + # wrap (confirmed against the real UI: a long name overlaps the Expires + # column and makes both unreadable), so keep the suffix short instead -- + # Unix epoch seconds are still unique enough for this purpose. + _uos_name="$_cdomain $(_time)" + _uos_key_json="$(_json_encode <"$_ckey")" + _uos_cert_json="$(_json_encode <"$_cfullchain")" + _create_body="{\"name\":\"$_uos_name\",\"key\":\"$_uos_key_json\",\"cert\":\"$_uos_cert_json\"}" + + _create_json="$(_post "$_create_body" "$DEPLOY_UNIFIOS_HOST/api/userCertificates" "" "POST" "application/json")" + _create_code="$(_uos_response_code)" + + if [ "$_create_code" = "201" ]; then + _new_id="$(echo "$_create_json" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_new_id" ]; then + _err "Could not determine new certificate ID from upload response." + return 1 + fi + elif [ "$_create_code" = "400" ] && echo "$_create_json" | grep -q "USER_CERTIFICATE_DUPLICATE"; then + # HTTP 400 alone just means "bad request" -- it's the USER_CERTIFICATE_DUPLICATE + # code in the response body, checked above, that actually confirms this. + # The name above is unique to this run, so a duplicate here can only be + # the server's other uniqueness constraint: this exact certificate (by + # fingerprint) already exists as some other entry -- most likely a retry + # after a prior run already uploaded it (a real renewal always produces a + # new fingerprint, so this shouldn't happen in normal cron use). The + # response body doesn't include the existing entry's id, so look it up + # by fingerprint instead. + # The API's own fingerprint field is SHA-1 (20 bytes), not SHA-256 -- + # confirmed against a real response, e.g. + # "fingerprint":"FC:02:50:9C:3B:3F:B7:79:9D:CA:4D:7C:AC:92:E7:D5:EA:F1:3A:29" + # (20 colon-separated groups). _fingerprint (core helper) strips the + # colons that field has, so re-insert them rather than stripping the + # JSON's own colons, which would also remove the ones separating every + # key from its value. + _uos_fingerprint="$(_fingerprint "$_cfullchain" sha1)" + if [ -z "$_uos_fingerprint" ]; then + _err "Could not compute the certificate's fingerprint." + return 1 + fi + _uos_fingerprint="$(echo "$_uos_fingerprint" | sed 's/\(..\)/\1:/g; s/:$//')" + + _list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")" + _list_code="$(_uos_response_code)" + if [ "$_list_code" != "200" ]; then + _err "Failed to list existing certificates (HTTP $_list_code)." + _err "Response: $_list_json" + return 1 + fi + # _normalizeJson collapses the response to one predictable line (no stray + # whitespace around colons, no embedded CR/LF the server might emit) but + # also strips the trailing newline entirely -- re-terminate before the + # split below, since some sed implementations drop an unterminated final + # line rather than processing it. + _list_json="$(echo "$_list_json" | _normalizeJson)" + # A literal embedded newline (not the two-character "\n", which GNU sed + # treats as a newline in the replacement but POSIX doesn't define and BSD + # sed emits literally) splits it one JSON object per line so grep can + # match a single certificate entry at a time. + _list_json="$( + printf '%s\n' "$_list_json" | sed 's/},{/},\ +{/g' + )" + _new_id="$(echo "$_list_json" | grep -F "\"fingerprint\":\"$_uos_fingerprint\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + if [ -z "$_new_id" ]; then + _err "Certificate upload rejected as a duplicate (server reported USER_CERTIFICATE_DUPLICATE), but no existing entry matching this fingerprint was found." + _err "Response: $_create_json" + return 1 + fi + # Reusing the existing entry rather than deleting it and re-uploading + # under today's name+timestamp: the served content is identical either + # way, so replacing it would only cost an extra delete+create round trip + # for no functional benefit. The tradeoff is cosmetic -- this entry keeps + # whatever name it was given whenever it was originally uploaded, so it + # won't reflect today's date in the UI. + _info "Certificate already present as entry $_new_id; reusing it." + else + _err "Certificate upload failed (HTTP $_create_code)." + _err "Response: $_create_json" + return 1 + fi + + _info "Activating certificate $_new_id..." + _activate_json="$(_post '{"active":true}' "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_new_id/status" "" "PUT" "application/json")" + _activate_code="$(_uos_response_code)" + + if [ "$_activate_code" != "200" ]; then + _err "Failed to activate new certificate (HTTP $_activate_code)." + _err "Response: $_activate_json" + return 1 + fi + + # UniFi OS Server activation is exclusive server-wide. Tests against the + # real API confirm this: activation of one entry deactivates whichever + # other entry was active before, no matter its name or domain. As a + # result, the server serves the certificate that this hook just activated. + # This certificate is already live. If the removal of old entries below + # fails, the hook logs the failure. The deploy does not fail because of + # this. + _info "Checking for old certificate entries to remove..." + _list_json="$(_get "$DEPLOY_UNIFIOS_HOST/api/userCertificates")" + _list_code="$(_uos_response_code)" + if [ "$_list_code" != "200" ]; then + _err "Failed to list certificates for cleanup (HTTP $_list_code) -- leaving old entries in place." + else + _list_json="$(echo "$_list_json" | _normalizeJson)" + _list_json="$( + printf '%s\n' "$_list_json" | sed 's/},{/},\ +{/g' + )" + # The pattern below matches the domain name followed by a space. If the + # space is missing, the pattern can also match a different domain that + # starts with the same text as this domain. + _old_ids="$(echo "$_list_json" | grep -F "\"name\":\"$_cdomain " | _egrep_o '"id":"[^"]*"' | cut -d '"' -f 4 | grep -v "^$_new_id$")" + for _old_id in $_old_ids; do + _info "Removing old certificate entry $_old_id..." + _del_json="$(_post "" "$DEPLOY_UNIFIOS_HOST/api/userCertificates/$_old_id" "" "DELETE")" + _del_code="$(_uos_response_code)" + if [ "$_del_code" != "204" ] && [ "$_del_code" != "200" ]; then + _err "Failed to delete old certificate $_old_id (HTTP $_del_code) -- leaving it in place." + _err "Response: $_del_json" + fi + done + fi + + _info "UniFi OS Server certificate deployed and activated successfully." + return 0 +}