From 0e5f1518aab3937eb690aa421ed48078c79b2ecd Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 4 Jun 2026 22:57:50 +0200 Subject: [PATCH 01/35] upgrade --- .github/workflows/dockerhub.yml | 4 ++-- .github/workflows/issue.yml | 2 +- .github/workflows/pr_dns.yml | 2 +- .github/workflows/pr_notify.yml | 2 +- .github/workflows/wiki-monitor.yml | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dockerhub.yml b/.github/workflows/dockerhub.yml index d10c17a8..7dc42290 100644 --- a/.github/workflows/dockerhub.yml +++ b/.github/workflows/dockerhub.yml @@ -50,14 +50,14 @@ jobs: with: persist-credentials: false - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v4 - name: Extract Docker metadata id: meta uses: docker/metadata-action@v6 with: images: ${DOCKER_IMAGE} - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v4 - name: login to docker hub run: | echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin diff --git a/.github/workflows/issue.yml b/.github/workflows/issue.yml index e92b0411..c659fce5 100644 --- a/.github/workflows/issue.yml +++ b/.github/workflows/issue.yml @@ -7,7 +7,7 @@ jobs: comment: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | github.rest.issues.createComment({ diff --git a/.github/workflows/pr_dns.yml b/.github/workflows/pr_dns.yml index 558ebf48..19763a15 100644 --- a/.github/workflows/pr_dns.yml +++ b/.github/workflows/pr_dns.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'neilpang' steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/pr_notify.yml b/.github/workflows/pr_notify.yml index 416ed721..76ae76f6 100644 --- a/.github/workflows/pr_notify.yml +++ b/.github/workflows/pr_notify.yml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: github.actor != 'neilpang' steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/wiki-monitor.yml b/.github/workflows/wiki-monitor.yml index a706529a..7e5d7ca3 100644 --- a/.github/workflows/wiki-monitor.yml +++ b/.github/workflows/wiki-monitor.yml @@ -51,7 +51,7 @@ jobs: } > wiki-change-msg.txt - name: Create issue to notify Neilpang - uses: peter-evans/create-issue-from-file@v5 + uses: peter-evans/create-issue-from-file@v6 with: title: "Wiki edited" content-filepath: ./wiki-change-msg.txt From b4634719514509d2485aff87c23800fee116ac59 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 5 Jun 2026 19:22:25 +0200 Subject: [PATCH 02/35] fix localaddress https://github.com/acmesh-official/acme.sh/issues/7009#issuecomment-4633681701 --- acme.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/acme.sh b/acme.sh index 00192868..e6272c9f 100755 --- a/acme.sh +++ b/acme.sh @@ -5312,6 +5312,8 @@ $_authorizations_map" fi fi elif [ "$vtype" = "$VTYPE_ALPN" ]; then + _ncaddr="$(_getfield "$_local_addr" "$_ncIndex")" + _ncIndex="$(_math $_ncIndex + 1)" acmevalidationv1="$(printf "%s" "$keyauthorization" | _digest "sha256" "hex")" _debug acmevalidationv1 "$acmevalidationv1" if ! _starttlsserver "$d" "" "$Le_TLSPort" "$keyauthorization" "$_ncaddr" "$acmevalidationv1"; then From a2f046306e5089b2ab82b247a319218b3aa30278 Mon Sep 17 00:00:00 2001 From: Adrian Fedoreanu Date: Fri, 5 Jun 2026 19:28:08 +0200 Subject: [PATCH 03/35] dns_1984hosting: cleanup, memoize zone id (#6978) * dns_1984hosting: cleanup, memoize zone id, optional OTP --- dnsapi/dns_1984hosting.sh | 51 +++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 10 deletions(-) diff --git a/dnsapi/dns_1984hosting.sh b/dnsapi/dns_1984hosting.sh index 8d9676ac..8ed9b8ef 100755 --- a/dnsapi/dns_1984hosting.sh +++ b/dnsapi/dns_1984hosting.sh @@ -7,6 +7,7 @@ Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_1984hosting Options: One984HOSTING_Username Username One984HOSTING_Password Password + One984HOSTING_TOTP_Secret Base32 TOTP shared secret. Required only if the account has 2FA enabled. Requires oathtool. Used to mint the OTP code automatically at login so cron renewals keep working. Issues: github.com/acmesh-official/acme.sh/issues/2851 Author: Adrian Fedoreanu ' @@ -124,11 +125,28 @@ _1984hosting_login() { _debug "Login to 1984Hosting as user $One984HOSTING_Username." username=$(printf '%s' "$One984HOSTING_Username" | _url_encode) password=$(printf '%s' "$One984HOSTING_Password" | _url_encode) - url="https://1984.hosting/api/auth/" - _get "https://1984.hosting/accounts/login/" | grep "csrfmiddlewaretoken" - csrftoken="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | tr -d ';')" - sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | tr -d ';')" + # When 2FA is enabled, mint a fresh TOTP code from the stored shared secret. + # Empty otpkey is accepted by the server when 2FA is off. + otpkey="" + if [ -n "$One984HOSTING_TOTP_Secret" ]; then + if ! _exists oathtool; then + _err "oathtool is required to use One984HOSTING_TOTP_Secret for 2FA. Please install it." + return 1 + fi + otpcode="$(oathtool --base32 --totp "$One984HOSTING_TOTP_Secret" 2>/dev/null)" + if [ -z "$otpcode" ]; then + _err "Failed to generate TOTP code from One984HOSTING_TOTP_Secret." + return 1 + fi + otpkey="$(printf '%s' "$otpcode" | _url_encode)" + fi + + # Fetch the login page to obtain CSRF and session cookies. + # Note: _get sets the global 'url', so assign the auth URL afterwards. + _get "https://1984.hosting/accounts/login/" >/dev/null + csrftoken="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | _head_n 1 | tr -d ';')" + sessionid="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | _head_n 1 | tr -d ';')" if [ -z "$csrftoken" ] || [ -z "$sessionid" ]; then _err "One or more cookies are empty: '$csrftoken', '$sessionid'." @@ -140,17 +158,23 @@ _1984hosting_login() { csrf_header=$(echo "$csrftoken" | sed 's/csrftoken=//' | _head_n 1) export _H3="X-CSRFToken: $csrf_header" - response="$(_post "username=$username&password=$password&otpkey=" $url)" + url="https://1984.hosting/api/auth/" + response="$(_post "username=$username&password=$password&otpkey=$otpkey" "$url")" response="$(echo "$response" | _normalizeJson)" _debug2 response "$response" if _contains "$response" '"loggedin": true'; then - One984HOSTING_SESSIONID_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | tr -d ';')" - One984HOSTING_CSRFTOKEN_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | tr -d ';')" + One984HOSTING_SESSIONID_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'cookie1984nammnamm=[^;]*;' | _head_n 1 | tr -d ';')" + One984HOSTING_CSRFTOKEN_COOKIE="$(grep -i '^set-cookie:' "$HTTP_HEADER" | _egrep_o 'csrftoken=[^;]*;' | _head_n 1 | tr -d ';')" export One984HOSTING_SESSIONID_COOKIE export One984HOSTING_CSRFTOKEN_COOKIE _saveaccountconf_mutable One984HOSTING_Username "$One984HOSTING_Username" _saveaccountconf_mutable One984HOSTING_Password "$One984HOSTING_Password" + if [ -n "$One984HOSTING_TOTP_Secret" ]; then + _saveaccountconf_mutable One984HOSTING_TOTP_Secret "$One984HOSTING_TOTP_Secret" + else + _clearaccountconf_mutable One984HOSTING_TOTP_Secret + fi _saveaccountconf_mutable One984HOSTING_SESSIONID_COOKIE "$One984HOSTING_SESSIONID_COOKIE" _saveaccountconf_mutable One984HOSTING_CSRFTOKEN_COOKIE "$One984HOSTING_CSRFTOKEN_COOKIE" return 0 @@ -161,6 +185,7 @@ _1984hosting_login() { _check_credentials() { One984HOSTING_Username="${One984HOSTING_Username:-$(_readaccountconf_mutable One984HOSTING_Username)}" One984HOSTING_Password="${One984HOSTING_Password:-$(_readaccountconf_mutable One984HOSTING_Password)}" + One984HOSTING_TOTP_Secret="${One984HOSTING_TOTP_Secret:-$(_readaccountconf_mutable One984HOSTING_TOTP_Secret)}" if [ -z "$One984HOSTING_Username" ] || [ -z "$One984HOSTING_Password" ]; then One984HOSTING_Username="" One984HOSTING_Password="" @@ -225,9 +250,15 @@ _get_root() { # Usage: _get_zone_id url domain.com # Returns zone id for domain.com +# Memoized per-domain so add/rm don't re-fetch the same zone list within a run. +# Keyed on domain (not url) since the url is always the domains listing. _get_zone_id() { url=$1 domain=$2 + if [ "$_zone_id_for" = "$domain" ] && [ -n "$_zone_id" ]; then + _debug2 _zone_id "$_zone_id (cached)" + return 0 + fi _htmlget "$url" "$domain" _zone_id="$(echo "$_response" | _egrep_o 'zone\/[0-9]+' | _head_n 1)" _debug2 _zone_id "$_zone_id" @@ -235,6 +266,7 @@ _get_zone_id() { _err "Error getting _zone_id for $2." return 1 fi + _zone_id_for="$domain" return 0 } @@ -257,9 +289,8 @@ _htmlget() { # Add extra headers to request _authpost() { - url="https://1984.hosting/domains" - _get_zone_id "$url" "$_domain" - csrf_header="$(echo "$One984HOSTING_CSRFTOKEN_COOKIE" | _egrep_o "=[^=][0-9a-zA-Z]*" | tr -d "=")" + _get_zone_id "https://1984.hosting/domains" "$_domain" + csrf_header="$(echo "$One984HOSTING_CSRFTOKEN_COOKIE" | sed 's/csrftoken=//' | _head_n 1)" export _H1="Cookie: $One984HOSTING_CSRFTOKEN_COOKIE; $One984HOSTING_SESSIONID_COOKIE" export _H2="Referer: https://1984.hosting/domains/$_zone_id" export _H3="X-CSRFToken: $csrf_header" From 58d9c8d7f613c1975f694df417d78dda805d5629 Mon Sep 17 00:00:00 2001 From: rajcz Date: Fri, 5 Jun 2026 19:38:50 +0200 Subject: [PATCH 04/35] acme.sh: validate cert response before writing .cer (#7006) --- acme.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/acme.sh b/acme.sh index e6272c9f..a7397be1 100755 --- a/acme.sh +++ b/acme.sh @@ -5544,6 +5544,13 @@ $_authorizations_map" return 1 fi + if ! _contains "$response" "$BEGIN_CERT"; then + response="$(echo "$response" | _dbase64 "multiline" | tr -d '\0' | _normalizeJson)" + _err "Signing failed: $(echo "$response" | _egrep_o '"detail":"[^"]*"')" + _on_issue_err "$_post_hook" + return 1 + fi + echo "$response" >"$CERT_PATH" _split_cert_chain "$CERT_PATH" "$CERT_FULLCHAIN_PATH" "$CA_CERT_PATH" if [ -z "$_preferred_chain" ]; then @@ -5563,6 +5570,11 @@ $_authorizations_map" _err "$response" continue fi + + if ! _contains "$response" "$BEGIN_CERT"; then + _debug2 "Skipping alternate cert link due to unexpected response format." + continue + fi _relcert="$CERT_PATH.alt" _relfullchain="$CERT_FULLCHAIN_PATH.alt" _relca="$CA_CERT_PATH.alt" From 9b597b3f1bb424b2b782c54b74955bd9efa37c8e Mon Sep 17 00:00:00 2001 From: aitor422 Date: Fri, 5 Jun 2026 20:02:14 +0000 Subject: [PATCH 05/35] Add CDMON Api (#6984) * Added CDMon DNS API --- dnsapi/dns_cdmon.sh | 137 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 dnsapi/dns_cdmon.sh diff --git a/dnsapi/dns_cdmon.sh b/dnsapi/dns_cdmon.sh new file mode 100644 index 00000000..470fb5fe --- /dev/null +++ b/dnsapi/dns_cdmon.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 + +dns_cdmon_info='cdmon +Site: www.cdmon.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_cdmon +Options: + CDMON_Key API Key +' + +CDMON_Api="https://api-domains.cdmon.services/api-domains" + +######## Public functions ##################### +# Usage: add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +# Used to add txt record +dns_cdmon_add() { + fulldomain=$1 + txtvalue=$2 + + CDMON_Key="${CDMON_Key:-$(_readaccountconf_mutable CDMON_Key)}" + + if [ -z "$CDMON_Key" ]; then + CDMON_Key="" + _err "You didn't specify your cdmon api key yet." + _err "Please create your key and try again." + return 1 + fi + + _saveaccountconf_mutable CDMON_Key "$CDMON_Key" + + _debug "First, we detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + _info "Adding record" + if _cdmon_rest "dnsrecords/create" "{\"data\":{\"type\":\"TXT\",\"domain\":\"$_domain\",\"value\":\"$txtvalue\",\"ttl\":120,\"host\":\"$_sub_domain\"}}"; then + if _contains "$response" "\"status\":\"ok\""; then + _info "Added, OK" + return 0 + else + _err "Add txt record error." + return 1 + fi + fi + _err "Add txt record error." + return 1 +} + +# Usage: fulldomain txtvalue +# Used to remove the txt record after validation +dns_cdmon_rm() { + fulldomain=$1 + txtvalue=$2 + + CDMON_Key="${CDMON_Key:-$(_readaccountconf_mutable CDMON_Key)}" + _debug "First, we detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Removing record" + if _cdmon_rest "dnsrecords/delete" "{\"data\":{\"value\":\"$txtvalue\",\"type\":\"TXT\",\"domain\":\"$_domain\",\"host\":\"$_sub_domain\"}}"; then + if _contains "$response" "\"status\":\"ok\""; then + _info "Deleted, OK" + return 0 + else + _err "Delete txt record error." + return 1 + fi + fi + _err "Delete txt record error." + return 1 +} + +#################### Private functions below ################################## +#_acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com +_get_root() { + domain=$1 + i=1 + p=1 + + if ! _cdmon_rest "domains/list"; then + return 1 + fi + + 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 _contains "$response" "\"domain\":\"$h\""; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +_cdmon_rest() { + ep="$1" + data="$2" + _debug "$ep" + + key_trimmed=$(echo "$CDMON_Key" | tr -d '"') + + export _H1="Content-Type: application/json" + export _H2="apikey: $key_trimmed" + + _debug data "$data" + response="$(_post "$data" "$CDMON_Api/$ep")" + _ret="$?" + + unset _H1 _H2 + + if [ "$_ret" != "0" ]; then + _err "error $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} From d98fa53f627ab08e17616a8037cea86e496aa917 Mon Sep 17 00:00:00 2001 From: Bill <80264737+billzee@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:06:49 -0400 Subject: [PATCH 06/35] Updated AWS Route53 service endpoint to the dual-stack endpoint (#6994) * Update to dual-stack service endpoint --- dnsapi/dns_aws.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dnsapi/dns_aws.sh b/dnsapi/dns_aws.sh index b76d69c2..1face1c8 100755 --- a/dnsapi/dns_aws.sh +++ b/dnsapi/dns_aws.sh @@ -11,7 +11,8 @@ Options: # All `_sleep` commands are included to avoid Route53 throttling, see # https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests -AWS_HOST="route53.amazonaws.com" +# Updated from "route53.amazonaws.com" +AWS_HOST="route53.global.api.aws" AWS_URL="https://$AWS_HOST" AWS_WIKI="https://github.com/acmesh-official/acme.sh/wiki/How-to-use-Amazon-Route53-API" From 4575877d48643494d6e5769c0468386ea6a80a77 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 5 Jun 2026 23:01:31 +0200 Subject: [PATCH 07/35] add GhostBSD --- .github/workflows/DNS.yml | 58 +++++++++++++++++++++++- .github/workflows/GhostBSD.yml | 80 ++++++++++++++++++++++++++++++++++ README.md | 2 + 3 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/GhostBSD.yml diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 232c9b0f..06dd29ac 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -260,7 +260,7 @@ jobs: - OpenBSD: + GhostBSD: runs-on: ubuntu-latest needs: FreeBSD env: @@ -281,6 +281,62 @@ jobs: TokenName5: ${{ secrets.TokenName5}} steps: - uses: actions/checkout@v6 + - 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 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + envs: 'TEST_DNS TestingDomain TEST_DNS_NO_WILDCARD TEST_DNS_NO_SUBDOMAIN TEST_DNS_SLEEP CASE TEST_LOCAL DEBUG http_proxy https_proxy TokenName1 TokenName2 TokenName3 TokenName4 TokenName5 ${{ secrets.TokenName1}} ${{ secrets.TokenName2}} ${{ secrets.TokenName3}} ${{ secrets.TokenName4}} ${{ secrets.TokenName5}}' + prepare: pkg install -y socat curl + usesh: true + sync: nfs + run: | + if [ "${{ secrets.TokenName1}}" ] ; then + export ${{ secrets.TokenName1}}="${{ secrets.TokenValue1}}" + fi + if [ "${{ secrets.TokenName2}}" ] ; then + export ${{ secrets.TokenName2}}="${{ secrets.TokenValue2}}" + fi + if [ "${{ secrets.TokenName3}}" ] ; then + export ${{ secrets.TokenName3}}="${{ secrets.TokenValue3}}" + fi + if [ "${{ secrets.TokenName4}}" ] ; then + export ${{ secrets.TokenName4}}="${{ secrets.TokenValue4}}" + fi + if [ "${{ secrets.TokenName5}}" ] ; then + export ${{ secrets.TokenName5}}="${{ secrets.TokenValue5}}" + fi + cd ../acmetest + ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" + + + + OpenBSD: + runs-on: ubuntu-latest + needs: GhostBSD + env: + TEST_DNS : ${{ secrets.TEST_DNS }} + TestingDomain: ${{ secrets.TestingDomain }} + TEST_DNS_NO_WILDCARD: ${{ secrets.TEST_DNS_NO_WILDCARD }} + TEST_DNS_NO_SUBDOMAIN: ${{ secrets.TEST_DNS_NO_SUBDOMAIN }} + TEST_DNS_SLEEP: ${{ secrets.TEST_DNS_SLEEP }} + CASE: le_test_dnsapi + TEST_LOCAL: 1 + DEBUG: ${{ secrets.DEBUG }} + http_proxy: ${{ secrets.http_proxy }} + https_proxy: ${{ secrets.https_proxy }} + TokenName1: ${{ secrets.TokenName1}} + TokenName2: ${{ secrets.TokenName2}} + TokenName3: ${{ secrets.TokenName3}} + TokenName4: ${{ secrets.TokenName4}} + TokenName5: ${{ secrets.TokenName5}} + steps: + - uses: actions/checkout@v6 - 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 diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml new file mode 100644 index 00000000..2dd2412b --- /dev/null +++ b/.github/workflows/GhostBSD.yml @@ -0,0 +1,80 @@ +name: GhostBSD +on: + push: + branches: + - '*' + paths: + - '*.sh' + - '.github/workflows/GhostBSD.yml' + + pull_request: + branches: + - dev + paths: + - '*.sh' + - '.github/workflows/GhostBSD.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + + +jobs: + GhostBSD: + strategy: + matrix: + include: + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + - TEST_ACME_Server: "LetsEncrypt.org_test" + CA_ECDSA: "" + CA: "" + CA_EMAIL: "" + TEST_PREFERRED_CHAIN: (STAGING) + ACME_USE_WGET: 1 + #- TEST_ACME_Server: "ZeroSSL.com" + # CA_ECDSA: "ZeroSSL ECC DV SSL CA 2" + # CA: "ZeroSSL RSA DV SSL CA 2" + # CA_EMAIL: "githubtest@acme.sh" + # TEST_PREFERRED_CHAIN: "" + runs-on: ubuntu-latest + env: + TEST_LOCAL: 1 + TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} + CA_ECDSA: ${{ matrix.CA_ECDSA }} + CA: ${{ matrix.CA }} + CA_EMAIL: ${{ matrix.CA_EMAIL }} + TEST_PREFERRED_CHAIN: ${{ matrix.TEST_PREFERRED_CHAIN }} + ACME_USE_WGET: ${{ matrix.ACME_USE_WGET }} + steps: + - uses: actions/checkout@v6 + - 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/ghostbsd-vm@v1 + with: + debug-on-error: ${{ vars.DEBUG_ON_ERROR }} + envs: 'TEST_LOCAL TestingDomain TEST_ACME_Server CA_ECDSA CA CA_EMAIL TEST_PREFERRED_CHAIN ACME_USE_WGET' + nat: | + "8080": "80" + prepare: pkg install -y socat curl wget + usesh: true + sync: nfs + run: | + cd ../acmetest \ + && ./letest.sh + - name: DebugOnError + if: ${{ failure() }} + run: | + echo "See how to debug in VM:" + echo "https://github.com/acmesh-official/acme.sh/wiki/debug-in-VM" diff --git a/README.md b/README.md index 22700b4c..3a697691 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Solaris DragonFlyBSD MidnightBSD + GhostBSD Omnios OpenIndiana Tribblix @@ -114,6 +115,7 @@ |24|[![](https://acmesh-official.github.io/acmetest/status/proxmox.svg)](https://github.com/acmesh-official/letest#here-are-the-latest-status)| Proxmox: See Proxmox VE Wiki. Version [4.x, 5.0, 5.1](https://pve.proxmox.com/wiki/HTTPS_Certificate_Configuration_(Version_4.x,_5.0_and_5.1)#Let.27s_Encrypt_using_acme.sh), version [5.2 and up](https://pve.proxmox.com/wiki/Certificate_Management) |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 > 🧪 Check our [testing project](https://github.com/acmesh-official/acmetest) From db098055de3ea8012190db7e71d02a0e229a0e75 Mon Sep 17 00:00:00 2001 From: SpeedGriffon <5631890+SpeedGriffon@users.noreply.github.com> Date: Fri, 19 Jun 2026 14:23:03 +0200 Subject: [PATCH 08/35] Fix RouterOS deploy (#7034) * routeros: save ROUTER_OS_ADDITIONAL_SERVICES as base64 * routeros: remove cer_3 --- deploy/routeros.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deploy/routeros.sh b/deploy/routeros.sh index ef9c6954..328fabbd 100644 --- a/deploy/routeros.sh +++ b/deploy/routeros.sh @@ -125,7 +125,7 @@ routeros_deploy() { _savedeployconf ROUTER_OS_PORT "$ROUTER_OS_PORT" _savedeployconf ROUTER_OS_SSH_CMD "$ROUTER_OS_SSH_CMD" _savedeployconf ROUTER_OS_SCP_CMD "$ROUTER_OS_SCP_CMD" - _savedeployconf ROUTER_OS_ADDITIONAL_SERVICES "$ROUTER_OS_ADDITIONAL_SERVICES" + _savedeployconf ROUTER_OS_ADDITIONAL_SERVICES "$ROUTER_OS_ADDITIONAL_SERVICES" "base64" # push key to routeros if ! _scp_certificate "$_ckey" "$ROUTER_OS_USERNAME@$ROUTER_OS_HOST:$_cdomain.key"; then @@ -143,6 +143,7 @@ comment=\"generated by routeros deploy script in acme.sh\" \ source=\"/certificate remove [ find name=$_cdomain.cer_0 ];\ \n/certificate remove [ find name=$_cdomain.cer_1 ];\ \n/certificate remove [ find name=$_cdomain.cer_2 ];\ +\n/certificate remove [ find name=$_cdomain.cer_3 ];\ \ndelay 1;\ \n/certificate import file-name=\\\"$_cdomain.cer\\\" passphrase=\\\"\\\";\ \n/certificate import file-name=\\\"$_cdomain.key\\\" passphrase=\\\"\\\";\ From 365d2d10f3d5e170d6e9b92d2f79b2c8b86bdd75 Mon Sep 17 00:00:00 2001 From: regisvidal-bitmapz Date: Fri, 19 Jun 2026 14:24:20 +0200 Subject: [PATCH 09/35] Fix dns_namesilo_rm failing to remove TXT record (#6969) * Fixes #6907 --- dnsapi/dns_namesilo.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_namesilo.sh b/dnsapi/dns_namesilo.sh index 5d47a59a..df5871cf 100755 --- a/dnsapi/dns_namesilo.sh +++ b/dnsapi/dns_namesilo.sh @@ -65,7 +65,7 @@ dns_namesilo_rm() { if _namesilo_rest GET "dnsListRecords?version=1&type=xml&key=$Namesilo_Key&domain=$_domain"; then retcode=$(printf "%s\n" "$response" | _egrep_o "300") if [ "$retcode" ]; then - _record_id=$(echo "$response" | _egrep_o "([^<]*)TXT$fulldomain" | _egrep_o "([^<]*)" | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1) + _record_id=$(echo "$response" | _egrep_o "([^<]*)TXT$_sub_domain$txtvalue" | _egrep_o "([^<]*)" | sed -r "s/([^<]*)<\/record_id>/\1/" | tail -n 1) _debug _record_id "$_record_id" if [ "$_record_id" ]; then _info "Successfully retrieved the record id for ACME challenge." From 0dc97187e129d90bb42168f8cca9d9b425ad8c26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ko=C5=82odziejczak?= <31549762+mrl5@users.noreply.github.com> Date: Sun, 28 Jun 2026 18:41:15 +0200 Subject: [PATCH 10/35] docs: introduce contributing doc (#7052) prevents friction and frustrations like in issue #7050 closes #7050 --- CONTRIBUTING.md | 8 ++++++++ README.md | 2 ++ 2 files changed, 10 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..33294ce7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,8 @@ +# Contributing + +1. Do NOT send pull request to `master` branch. +Please send to `dev` branch instead. +Any PR to `master` branch will NOT be merged. + +2. For dns api support, read this guide first: https://github.com/acmesh-official/acme.sh/wiki/DNS-API-Dev-Guide +You will NOT get any review without passing this guide. You also need to fix the CI errors. diff --git a/README.md b/README.md index 3a697691..44a73e83 100644 --- a/README.md +++ b/README.md @@ -615,6 +615,8 @@ This project exists thanks to all the people who contribute. +If you want to become a contributor make sure to read [CONTRIBUTING.md](./CONTRIBUTING.md). + ### 💰 Financial Contributors Become a financial contributor and help us sustain our community. [[Contribute](https://opencollective.com/acmesh/contribute)] From b3579ff18dcf3c530293b096e232fab4b4b87216 Mon Sep 17 00:00:00 2001 From: Jeroen Moors Date: Sun, 28 Jun 2026 19:27:41 +0200 Subject: [PATCH 11/35] Implement support for DNS Level27 (#7043) * Add Level27 DNS API support Implements dns_level27_add and dns_level27_rm for the Level27 (level27.eu) DNS API, used for ACME dns-01 challenges. - Authenticates with a persistent API key via the Authorization header. - Resolves the registered zone with domains?filter and exact fullname match (supports DNS alias mode). - Removes the challenge record by its exact TXT value, leaving other records intact (wildcard-safe). - Optional LEVEL27_API override for non-default/staging endpoints. * A little better documentation --------- Co-authored-by: Jeroen Moors --- dnsapi/dns_level27.sh | 197 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 dnsapi/dns_level27.sh diff --git a/dnsapi/dns_level27.sh b/dnsapi/dns_level27.sh new file mode 100644 index 00000000..3fbaf810 --- /dev/null +++ b/dnsapi/dns_level27.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_level27_info='Level27 +Site: Level27.be +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_level27 +Options: + LEVEL27_API_KEY API key. Get one from the Level27 control panel (https://app.level27.eu/account/profile/security). +OptionsAlt: + LEVEL27_API API base URL. Optional. Default "https://api.level27.eu/v1". +Issues: github.com/acmesh-official/acme.sh/issues +Author: Jeroen Moors +' + +LEVEL27_API_DEFAULT="https://api.level27.eu/v1" + +######## Public functions ##################### + +# Usage: dns_level27_add _acme-challenge.www.example.com "TXT-value" +dns_level27_add() { + fulldomain="$(_idn "$1")" + txtvalue="$2" + + _info "Using Level27 to add a TXT record for $fulldomain" + + if ! _level27_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Could not determine the root zone for $fulldomain at Level27." + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _level27_data="{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\"}" + if ! _level27_rest POST "domains/$_domain_id/records" "$_level27_data"; then + _err "Could not add the TXT record." + return 1 + fi + + if _contains "$response" "\"id\":"; then + _info "TXT record added." + return 0 + fi + + _err "Unexpected response while adding the TXT record." + return 1 +} + +# Usage: dns_level27_rm _acme-challenge.www.example.com "TXT-value" +dns_level27_rm() { + fulldomain="$(_idn "$1")" + txtvalue="$2" + + _info "Using Level27 to remove the TXT record for $fulldomain" + + if ! _level27_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "Could not determine the root zone for $fulldomain at Level27." + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + if ! _level27_rest GET "domains/$_domain_id/records?type=TXT"; then + _err "Could not list the existing TXT records." + return 1 + fi + + _record_id="$(_level27_find_record_id "$response" "$txtvalue")" + if [ -z "$_record_id" ]; then + _info "No matching TXT record found; nothing to remove." + return 0 + fi + _debug _record_id "$_record_id" + + if ! _level27_rest DELETE "domains/$_domain_id/records/$_record_id"; then + _err "Could not remove the TXT record." + return 1 + fi + + _info "TXT record removed." + return 0 +} + +#################### Private functions below ################################## + +# Reads and validates the API credentials and endpoint, and stores them for renewals. +_level27_init() { + LEVEL27_API_KEY="${LEVEL27_API_KEY:-$(_readaccountconf_mutable LEVEL27_API_KEY)}" + if [ -z "$LEVEL27_API_KEY" ]; then + LEVEL27_API_KEY="" + _err "You must export the variable LEVEL27_API_KEY before using the Level27 DNS API." + _err "Get an API key from the Level27 control panel (https://app.level27.eu/account/profile/security)." + return 1 + fi + LEVEL27_API_KEY="$(echo "$LEVEL27_API_KEY" | tr -d '"')" + _saveaccountconf_mutable LEVEL27_API_KEY "$LEVEL27_API_KEY" + + LEVEL27_API="${LEVEL27_API:-$(_readaccountconf_mutable LEVEL27_API)}" + if [ -z "$LEVEL27_API" ]; then + LEVEL27_API="$LEVEL27_API_DEFAULT" + fi + _saveaccountconf_mutable LEVEL27_API "$LEVEL27_API" + + # Remove a trailing slash so endpoints can be appended consistently. + LEVEL27_API="$(echo "$LEVEL27_API" | sed 's#/$##')" + return 0 +} + +# Usage: _get_root _acme-challenge.www.example.com +# Splits the full domain into the registered zone and the subdomain part. +# Sets: _domain, _domain_id, _sub_domain +_get_root() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug h "$h" + if [ -z "$h" ]; then + # not valid + return 1 + fi + + if ! _level27_rest GET "domains?filter=$h"; then + return 1 + fi + + _level27_zones="$(echo "$response" | _normalizeJson)" + if _contains "$_level27_zones" "\"fullname\":\"$h\""; then + _domain_line="$(echo "$_level27_zones" | sed 's/},{/}\n{/g' | grep "\"fullname\":\"$h\"" | _head_n 1)" + _domain_id="$(echo "$_domain_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2)" + if [ "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + return 1 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +# Usage: _level27_find_record_id "" "" +# Prints the id of the TXT record whose content matches the value, or nothing. +_level27_find_record_id() { + _records="$(echo "$1" | _normalizeJson | sed 's/},{/}\n{/g')" + _wanted="$2" + _record_line="$(echo "$_records" | grep "\"content\":\"$_wanted\"" | _head_n 1)" + if [ -z "$_record_line" ]; then + # Some APIs store TXT content wrapped in quotes. + _record_line="$(echo "$_records" | grep "\"content\":\"\\\\\"$_wanted\\\\\"\"" | _head_n 1)" + fi + if [ -z "$_record_line" ]; then + return 0 + fi + echo "$_record_line" | _egrep_o '"id":[0-9]*' | _head_n 1 | cut -d : -f 2 +} + +# Usage: _level27_rest [data] +# Performs an authenticated API call and stores the body in $response. +_level27_rest() { + m="$1" + ep="$2" + data="$3" + _debug "$ep" + + export _H1="Authorization: $LEVEL27_API_KEY" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + + if [ "$m" != "GET" ]; then + _debug2 data "$data" + response="$(_post "$data" "$LEVEL27_API/$ep" "" "$m")" + else + response="$(_get "$LEVEL27_API/$ep")" + fi + + if [ "$?" != "0" ]; then + _err "Error querying the Level27 API endpoint: $ep" + return 1 + fi + _debug2 response "$response" + return 0 +} From 2998106bd1cebebf681bafec23b19113bb6e21ac Mon Sep 17 00:00:00 2001 From: Alexander Stehlik Date: Wed, 1 Jul 2026 12:54:24 +0200 Subject: [PATCH 12/35] fix(dns_desec): fix rate limit and compatibility issues (#7027) * fix(dns_desec): sleep after DNS record change to prevent rate limit issues Also: make sure the subname is lowercase to fix tests where the acmetestXyzRandomName subdomain is used. * fix: make regexes POSIX-compatible (for OpenBSD) * chore: use _sleep instead of sleep to follow acme.sh standards --- dnsapi/dns_desec.sh | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_desec.sh b/dnsapi/dns_desec.sh index d6b9c355..275babea 100644 --- a/dnsapi/dns_desec.sh +++ b/dnsapi/dns_desec.sh @@ -39,6 +39,7 @@ dns_desec_add() { _err "invalid domain" return 1 fi + _sub_domain=$(echo "$_sub_domain" | _lower_case) _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" @@ -48,7 +49,7 @@ dns_desec_add() { _desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/" if [ "$_code" = "200" ]; then - oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" + oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" _debug "existing TXT found" _debug oldtxtvalues "$oldtxtvalues" if [ -n "$oldtxtvalues" ]; then @@ -100,7 +101,7 @@ dns_desec_rm() { _err "invalid domain" return 1 fi - + _sub_domain=$(echo "$_sub_domain" | _lower_case) _debug _sub_domain "$_sub_domain" _debug _domain "$_domain" @@ -110,7 +111,7 @@ dns_desec_rm() { _desec_rest GET "$REST_API/$_domain/rrsets/$_sub_domain/TXT/" if [ "$_code" = "200" ]; then - oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"\\S*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" + oldtxtvalues="$(echo "$response" | _egrep_o "\"records\":\\[\"[^ ]*\"\\]" | cut -d : -f 2 | tr -d "[]\\\\\"" | sed "s/,/ /g")" _debug "existing TXT found" _debug oldtxtvalues "$oldtxtvalues" if [ -n "$oldtxtvalues" ]; then @@ -150,6 +151,8 @@ _desec_rest() { if [ "$m" != "GET" ]; then _secure_debug2 data "$data" response="$(_post "$data" "$ep" "" "$m")" + _info "Sleeping 1s to respect deSEC write rate limit" + _sleep 1 else response="$(_get "$ep")" fi From c38182897d55e811aa8e09c00f68890afbc8360f Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 18:59:14 +0800 Subject: [PATCH 13/35] fix ghostbsd --- .github/workflows/DNS.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/DNS.yml b/.github/workflows/DNS.yml index 06dd29ac..a972ae1a 100644 --- a/.github/workflows/DNS.yml +++ b/.github/workflows/DNS.yml @@ -263,6 +263,8 @@ jobs: GhostBSD: runs-on: ubuntu-latest needs: FreeBSD + # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run + continue-on-error: true env: TEST_DNS : ${{ secrets.TEST_DNS }} TestingDomain: ${{ secrets.TestingDomain }} From 0d53d29f7efa6b40c6193c1b9b338da180cd24f7 Mon Sep 17 00:00:00 2001 From: hostup <52465293+hostup@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:44:21 +0200 Subject: [PATCH 14/35] Update dns_hostup.sh to v2 API (#7014) * Update dns_hostup.sh Update to v2 api support; developer.hostup.se * Update dns_hostup.sh --- dnsapi/dns_hostup.sh | 326 ++++++++++++++++++++++++++----------------- 1 file changed, 201 insertions(+), 125 deletions(-) diff --git a/dnsapi/dns_hostup.sh b/dnsapi/dns_hostup.sh index b3211069..73189189 100644 --- a/dnsapi/dns_hostup.sh +++ b/dnsapi/dns_hostup.sh @@ -6,13 +6,13 @@ Site: hostup.se Docs: https://developer.hostup.se/ Options: HOSTUP_API_KEY Required. HostUp API key with read:dns + write:dns + read:domains scopes. - HOSTUP_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api). + HOSTUP_API_BASE Optional. Override API base URL (default: https://cloud.hostup.se/api/v2). HOSTUP_TTL Optional. TTL for TXT records (default: 60 seconds). - HOSTUP_ZONE_ID Optional. Force a specific zone ID (skip auto-detection). + HOSTUP_ZONE_ID Optional. Force a specific v2 zone ID (zone_...) and skip auto-detection. Author: HostUp (https://cloud.hostup.se/contact/en) ' -HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api" +HOSTUP_API_BASE_DEFAULT="https://cloud.hostup.se/api/v2" HOSTUP_DEFAULT_TTL=60 # Public: add TXT record @@ -20,6 +20,7 @@ HOSTUP_DEFAULT_TTL=60 dns_hostup_add() { fulldomain="$1" txtvalue="$2" + hostup_add_txtvalue="$2" _info "Using HostUp DNS API" @@ -34,31 +35,34 @@ dns_hostup_add() { record_name="$(_hostup_record_name "$fulldomain" "$HOSTUP_ZONE_DOMAIN")" record_name="$(_hostup_sanitize_name "$record_name")" - record_value="$(_hostup_json_escape "$txtvalue")" + hostup_add_record_value="$(_hostup_json_escape "$hostup_add_txtvalue")" - ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}" + raw_ttl="${HOSTUP_TTL:-$HOSTUP_DEFAULT_TTL}" + ttl="$(_hostup_normalize_ttl "$raw_ttl")" + if [ -z "$ttl" ]; then + _err "HOSTUP_TTL must be a whole number between 60 and 86400 seconds." + return 1 + fi + if [ -n "$HOSTUP_TTL" ]; then + HOSTUP_TTL="$ttl" + _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL" + fi _debug "zone_id" "$HOSTUP_ZONE_ID" _debug "zone_domain" "$HOSTUP_ZONE_DOMAIN" _debug "record_name" "$record_name" _debug "ttl" "$ttl" - request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$record_value\",\"ttl\":$ttl}" - - if ! _hostup_rest "POST" "/dns/zones/$HOSTUP_ZONE_ID/records" "$request_body"; then - return 1 + record_name_fqdn="$(_hostup_fqdn "$fulldomain")" + if _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$hostup_add_txtvalue"; then + _info "TXT record already exists for $fulldomain" + return 0 fi - if ! _contains "$_hostup_response" '"success":true'; then - _err "HostUp DNS API: failed to create TXT record for $fulldomain" - _debug2 "_hostup_response" "$_hostup_response" - return 1 - fi + request_body="{\"name\":\"$record_name\",\"type\":\"TXT\",\"value\":\"$hostup_add_record_value\",\"ttl\":$ttl}" - record_id="$(_hostup_extract_record_id "$_hostup_response")" - if [ -n "$record_id" ]; then - _hostup_save_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_id" - _debug "hostup_saved_record_id" "$record_id" + if ! _hostup_rest "POST" "/dns-zones/$HOSTUP_ZONE_ID/records" "$request_body"; then + return 1 fi _info "Added TXT record for $fulldomain" @@ -85,20 +89,9 @@ dns_hostup_rm() { record_name_fqdn="$(_hostup_fqdn "$fulldomain")" record_value="$txtvalue" - record_id_cached="$(_hostup_get_saved_record_id "$HOSTUP_ZONE_ID" "$fulldomain")" - if [ -n "$record_id_cached" ]; then - _debug "hostup_record_id_cached" "$record_id_cached" - if _hostup_delete_record_by_id "$HOSTUP_ZONE_ID" "$record_id_cached"; then - _info "Deleted TXT record $record_id_cached" - _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" - HOSTUP_ZONE_ID="" - return 0 - fi - fi - if ! _hostup_find_record "$HOSTUP_ZONE_ID" "$record_name_fqdn" "$record_value"; then _info "TXT record not found for $record_name_fqdn. Skipping removal." - _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" + _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value" return 0 fi @@ -109,7 +102,7 @@ dns_hostup_rm() { fi _info "Deleted TXT record $HOSTUP_RECORD_ID" - _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" + _hostup_clear_record_id "$HOSTUP_ZONE_ID" "$fulldomain" "$record_value" HOSTUP_ZONE_ID="" return 0 } @@ -127,21 +120,18 @@ _hostup_init() { if [ -z "$HOSTUP_API_BASE" ]; then HOSTUP_API_BASE="$HOSTUP_API_BASE_DEFAULT" fi + HOSTUP_API_BASE="$(_hostup_normalize_api_base "$HOSTUP_API_BASE")" if [ -z "$HOSTUP_API_KEY" ]; then HOSTUP_API_KEY="" _err "HOSTUP_API_KEY is not set." - _err "Please export your HostUp API key with read:dns and write:dns scopes." + _err "Please export your HostUp API key with read:dns, write:dns, and read:domains scopes." return 1 fi _saveaccountconf_mutable HOSTUP_API_KEY "$HOSTUP_API_KEY" _saveaccountconf_mutable HOSTUP_API_BASE "$HOSTUP_API_BASE" - if [ -n "$HOSTUP_TTL" ]; then - _saveaccountconf_mutable HOSTUP_TTL "$HOSTUP_TTL" - fi - if [ -n "$HOSTUP_ZONE_ID" ]; then _saveaccountconf_mutable HOSTUP_ZONE_ID "$HOSTUP_ZONE_ID" fi @@ -149,11 +139,80 @@ _hostup_init() { return 0 } +_hostup_normalize_api_base() { + api_base="${1%/}" + + case "$api_base" in + */api/v2) + printf "%s" "$api_base" + ;; + */api) + printf "%s/v2" "$api_base" + ;; + *) + printf "%s" "$api_base" + ;; + esac +} + +_hostup_normalize_ttl() { + ttl_value="$1" + + case "$ttl_value" in + "" | *[!0-9]*) + return 1 + ;; + esac + + while [ "${ttl_value#0}" != "$ttl_value" ]; do + ttl_value="${ttl_value#0}" + done + [ -z "$ttl_value" ] && ttl_value=0 + + case "$ttl_value" in + ??????*) + return 1 + ;; + esac + + if [ "$ttl_value" -lt 60 ] || [ "$ttl_value" -gt 86400 ]; then + return 1 + fi + + printf "%s" "$ttl_value" +} + +_hostup_domain_in_zone() { + host="$(printf "%s" "${1%.}" | _lower_case)" + zone="$(printf "%s" "${2%.}" | _lower_case)" + + if [ -z "$host" ] || [ -z "$zone" ]; then + return 1 + fi + + if [ "$host" = "$zone" ]; then + return 0 + fi + + case "$host" in + *."$zone") + return 0 + ;; + esac + + return 1 +} + _hostup_detect_zone() { fulldomain="$1" if [ -n "$HOSTUP_ZONE_ID" ] && [ -n "$HOSTUP_ZONE_DOMAIN" ]; then - return 0 + if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then + return 0 + fi + _debug "hostup_cached_zone_mismatch" "$HOSTUP_ZONE_DOMAIN" + HOSTUP_ZONE_ID="" + HOSTUP_ZONE_DOMAIN="" fi HOSTUP_ZONE_DOMAIN="" @@ -162,16 +221,16 @@ _hostup_detect_zone() { if [ -n "$HOSTUP_ZONE_ID" ] && [ -z "$HOSTUP_ZONE_DOMAIN" ]; then # Attempt to fetch domain name for provided zone ID if _hostup_fetch_zone_details "$HOSTUP_ZONE_ID"; then - return 0 + if _hostup_domain_in_zone "$fulldomain" "$HOSTUP_ZONE_DOMAIN"; then + return 0 + fi + _debug "hostup_forced_zone_mismatch" "$HOSTUP_ZONE_DOMAIN" fi HOSTUP_ZONE_ID="" + HOSTUP_ZONE_DOMAIN="" fi - if ! _hostup_load_zones; then - return 1 - fi - - _domain_candidate="$(printf "%s" "$fulldomain" | _lower_case)" + _domain_candidate="$(printf "%s" "${fulldomain%.}" | _lower_case)" _debug "hostup_initial_candidate" "$_domain_candidate" while [ -n "$_domain_candidate" ]; do @@ -240,11 +299,11 @@ _hostup_fqdn() { _hostup_fetch_zone_details() { zone_id="$1" - if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then + if ! _hostup_rest "GET" "/dns-zones/$zone_id/records" ""; then return 1 fi - zonedomain="$(printf "%s" "$_hostup_response" | _egrep_o '"domain":"[^"]*"' | sed -n '1p' | cut -d ':' -f 2 | tr -d '"')" + zonedomain="$(_hostup_json_extract "name" "$_hostup_response")" if [ -n "$zonedomain" ]; then HOSTUP_ZONE_DOMAIN="$zonedomain" return 0 @@ -254,7 +313,7 @@ _hostup_fetch_zone_details() { } _hostup_load_zones() { - if ! _hostup_rest "GET" "/dns/zones" ""; then + if ! _hostup_rest "GET" "/dns-zones?limit=1000" ""; then return 1 fi @@ -263,9 +322,9 @@ _hostup_load_zones() { while IFS= read -r line; do case "$line" in - *'"domain_id"'*'"domain"'*) - zone_id="$(printf "%s" "$line" | _hostup_json_extract "domain_id")" - zone_domain="$(printf "%s" "$line" | _hostup_json_extract "domain")" + *'"id"'*'"name"'*) + zone_id="$(_hostup_json_extract "id" "$line")" + zone_domain="$(_hostup_json_extract "name" "$line")" if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then HOSTUP_ZONES_CACHE="${HOSTUP_ZONES_CACHE}${zone_domain}|${zone_id} " @@ -290,9 +349,30 @@ _hostup_lookup_zone() { _lookup_zone_id="" _lookup_zone_domain="" + encoded_domain="$(printf "%s" "$lookup_domain" | _url_encode)" + if _hostup_rest "GET" "/dns-zones?name=$encoded_domain&limit=1" ""; then + zone_id="$(_hostup_json_extract "id" "$_hostup_response")" + zone_domain="$(_hostup_json_extract "name" "$_hostup_response")" + if [ -n "$zone_id" ] && [ -n "$zone_domain" ]; then + zone_domain_lower="$(printf "%s" "$zone_domain" | _lower_case)" + if [ "$zone_domain_lower" = "$lookup_domain" ]; then + _lookup_zone_domain="$zone_domain" + _lookup_zone_id="$zone_id" + HOSTUP_ZONE_DOMAIN="$zone_domain" + HOSTUP_ZONE_ID="$zone_id" + return 0 + fi + fi + fi + + if [ -z "$HOSTUP_ZONES_CACHE" ] && ! _hostup_load_zones; then + return 1 + fi + while IFS='|' read -r domain zone_id; do [ -z "$domain" ] && continue - if [ "$domain" = "$lookup_domain" ]; then + domain_lower="$(printf "%s" "$domain" | _lower_case)" + if [ "$domain_lower" = "$lookup_domain" ]; then _lookup_zone_domain="$domain" _lookup_zone_id="$zone_id" HOSTUP_ZONE_DOMAIN="$domain" @@ -307,50 +387,50 @@ EOF } _hostup_find_record() { - zone_id="$1" - fqdn="$2" - txtvalue="$3" + _hostup_find_zone_id="$1" + _hostup_find_fqdn="$2" + _hostup_find_txtvalue="$3" - if ! _hostup_rest "GET" "/dns/zones/$zone_id/records" ""; then + _hostup_find_encoded_name="$(printf "%s" "$_hostup_find_fqdn" | _url_encode)" + if ! _hostup_rest "GET" "/dns-zones/$_hostup_find_zone_id/records?type=TXT&name=$_hostup_find_encoded_name" ""; then return 1 fi HOSTUP_RECORD_ID="" - records="$(printf "%s" "$_hostup_response" | tr '{' '\n')" + _hostup_find_records="$(printf "%s" "$_hostup_response" | tr '{' '\n')" - while IFS= read -r line; do + while IFS= read -r _hostup_find_line; do # Normalize line to make TXT value matching reliable - line_clean="$(printf "%s" "$line" | tr -d '\r\n')" - line_value_clean="$(printf "%s" "$line_clean" | sed 's/\\"//g')" + _hostup_find_line_clean="$(printf "%s" "$_hostup_find_line" | tr -d '\r\n')" + _hostup_find_line_value_clean="$(printf "%s" "$_hostup_find_line_clean" | sed 's/\\"//g')" - case "$line_clean" in - *'"type":"TXT"'*'"name"'*'"value"'*) - name_value="$(_hostup_json_extract "name" "$line_clean")" - record_value="$(_hostup_json_extract "value" "$line_value_clean")" + _hostup_find_record_type="$(_hostup_json_extract "type" "$_hostup_find_line_clean")" + [ "$_hostup_find_record_type" != "TXT" ] && continue - _debug "hostup_record_raw" "$record_value" - if [ "${record_value#\"}" != "$record_value" ] && [ "${record_value%\"}" != "$record_value" ]; then - record_value="${record_value#\"}" - record_value="${record_value%\"}" - fi - if [ "${record_value#\'}" != "$record_value" ] && [ "${record_value%\'}" != "$record_value" ]; then - record_value="${record_value#\'}" - record_value="${record_value%\'}" - fi - record_value="$(printf "%s" "$record_value" | tr -d '\r\n')" - _debug "hostup_record_value" "$record_value" + _hostup_find_name_value="$(_hostup_json_extract "name" "$_hostup_find_line_clean")" + _hostup_find_record_value="$(_hostup_json_extract "value" "$_hostup_find_line_value_clean")" - if [ "$name_value" = "$fqdn" ] && [ "$record_value" = "$txtvalue" ]; then - record_id="$(_hostup_json_extract "id" "$line_clean")" - if [ -n "$record_id" ]; then - HOSTUP_RECORD_ID="$record_id" - return 0 - fi + _debug "hostup_record_raw" "$_hostup_find_record_value" + if [ "${_hostup_find_record_value#\"}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\"}" != "$_hostup_find_record_value" ]; then + _hostup_find_record_value="${_hostup_find_record_value#\"}" + _hostup_find_record_value="${_hostup_find_record_value%\"}" + fi + if [ "${_hostup_find_record_value#\'}" != "$_hostup_find_record_value" ] && [ "${_hostup_find_record_value%\'}" != "$_hostup_find_record_value" ]; then + _hostup_find_record_value="${_hostup_find_record_value#\'}" + _hostup_find_record_value="${_hostup_find_record_value%\'}" + fi + _hostup_find_record_value="$(printf "%s" "$_hostup_find_record_value" | tr -d '\r\n')" + _debug "hostup_record_value" "$_hostup_find_record_value" + + if [ "$_hostup_find_name_value" = "$_hostup_find_fqdn" ] && [ "$_hostup_find_record_value" = "$_hostup_find_txtvalue" ]; then + _hostup_find_record_id="$(_hostup_json_extract "id" "$_hostup_find_line_clean")" + if [ -n "$_hostup_find_record_id" ]; then + HOSTUP_RECORD_ID="$_hostup_find_record_id" + return 0 fi - ;; - esac + fi done < Date: Wed, 1 Jul 2026 14:55:55 +0200 Subject: [PATCH 15/35] Adding custom Port definitions for truenas (#7033) * closing bracket and adding port for customer installations * adding savedeployconfig * fixing shfmt * changeing --------- Co-authored-by: neil --- deploy/truenas_ws.sh | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/deploy/truenas_ws.sh b/deploy/truenas_ws.sh index df34f927..33e3dfa0 100644 --- a/deploy/truenas_ws.sh +++ b/deploy/truenas_ws.sh @@ -16,7 +16,12 @@ # # # API KEY # # Use the folowing URL to create a new API token: /ui/apikeys -# export DEPLOY_TRUENAS_APIKEY=" Date: Wed, 1 Jul 2026 15:05:07 +0200 Subject: [PATCH 16/35] fix(dns_infomaniak): correctly detect API errors (#7048) The add/rm success check never rejected anything: for any non-empty API response it always reported "Record added"/"Record deleted" and returned 0, so the _err branch was dead code. A valid key looked fine only because the API call genuinely created the record; an invalid key returning {"result":"error"} produced the same "Record added" output even though nothing was created. Root cause, in: if [ -n "$response" ]; then if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then - _contains() ignores stdin (it reads only $1 and $2), so the piped "$response" was discarded. - The pattern '"result":"success"' was passed as $1 (the haystack), leaving $2 (the needle) empty, so it ran: echo '"result":"success"' | grep -- "" >/dev/null 2>&1 grep with an empty pattern always matches. - That grep output is redirected to /dev/null, so the command substitution always captured "", making [ ! "" ] always true. Fix: call _contains "$response" '"result":"success"' directly and branch on its exit code, so error responses now correctly fail (return 1). Co-authored-by: neil --- dnsapi/dns_infomaniak.sh | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh index 0ae32b47..52417fef 100755 --- a/dnsapi/dns_infomaniak.sh +++ b/dnsapi/dns_infomaniak.sh @@ -85,12 +85,10 @@ dns_infomaniak_add() { # API call response=$(_post "$data" "${INFOMANIAK_API_URL}/2/zones/${zone}/records") - if [ -n "$response" ]; then - if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then - _info "Record added" - _debug "response: $response" - return 0 - fi + if _contains "$response" '"result":"success"'; then + _info "Record added" + _debug "response: $response" + return 0 fi _err "Could not create record." _debug "Response: $response" @@ -169,11 +167,10 @@ dns_infomaniak_rm() { # API call response=$(_post "" "${INFOMANIAK_API_URL}/2/zones/${zone}/records/${record_id}" "" DELETE) - if [ -n "$response" ]; then - if [ ! "$(echo "$response" | _contains '"result":"success"')" ]; then - _info "Record deleted" - return 0 - fi + if _contains "$response" '"result":"success"'; then + _info "Record deleted" + _debug "response: $response" + return 0 fi _err "Could not delete record." _debug "Response: $response" From d0fcafe29b157b727a34a8961137022b3bb11950 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 21:22:36 +0800 Subject: [PATCH 17/35] fix https://github.com/acmesh-official/acme.sh/issues/7035 --- acme.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/acme.sh b/acme.sh index a7397be1..59700442 100755 --- a/acme.sh +++ b/acme.sh @@ -7512,8 +7512,8 @@ Parameters: --dnssleep The time in seconds to wait for all the txt records to propagate in dns api mode. It's not necessary to use this by default, $PROJECT_NAME polls dns status by DOH automatically. - -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256, ec-384, ec-521. - -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096 + -k, --keylength Specifies the domain key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521. + -ak, --accountkeylength Specifies the account key length: 2048, 3072, 4096, 8192 or ec-256 (default), ec-384, ec-521. --log [file] Specifies the log file. Defaults to \"$DEFAULT_LOG_FILE\" if argument is omitted. --log-level <1|2> Specifies the log level, default is $DEFAULT_LOG_LEVEL. --syslog <0|3|6|7> Syslog level, 0: disable syslog, 3: error, 6: info, 7: debug. From a7ccfcf91d5843840f113a60e65ed11af3d438e8 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 21:33:24 +0800 Subject: [PATCH 18/35] fix --- .github/workflows/GhostBSD.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/GhostBSD.yml b/.github/workflows/GhostBSD.yml index 2dd2412b..c77fdf2e 100644 --- a/.github/workflows/GhostBSD.yml +++ b/.github/workflows/GhostBSD.yml @@ -42,6 +42,8 @@ jobs: # CA_EMAIL: "githubtest@acme.sh" # TEST_PREFERRED_CHAIN: "" runs-on: ubuntu-latest + # GhostBSD VM frequently flakes on boot/ssh; don't let it fail the whole run + continue-on-error: true env: TEST_LOCAL: 1 TEST_ACME_Server: ${{ matrix.TEST_ACME_Server }} From 81100db2f3f4ead0c6e28a48a939aaee7f401a08 Mon Sep 17 00:00:00 2001 From: neil Date: Wed, 1 Jul 2026 21:59:41 +0800 Subject: [PATCH 19/35] fix https://github.com/acmesh-official/acme.sh/issues/6498 --- dnsapi/dns_joker.sh | 51 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_joker.sh b/dnsapi/dns_joker.sh index 401471be..0ad80327 100644 --- a/dnsapi/dns_joker.sh +++ b/dnsapi/dns_joker.sh @@ -35,9 +35,28 @@ dns_joker_add() { return 1 fi + # Joker's /nic/replace overwrites all TXT records at the label on every call, + # and the API is not readable, so accumulate the values locally (keyed by the + # full record name) and re-send the whole set each time. This is required so a + # wildcard cert (base + *.domain both validating under the same + # _acme-challenge label) does not overwrite its own first challenge value. + _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_') + _joker_values=$(_readdomainconf "$_joker_conf_key") + if [ -z "$_joker_values" ]; then + _joker_values="$txtvalue" + elif ! _contains " $_joker_values " " $txtvalue "; then + _joker_values="$_joker_values $txtvalue" + fi + + _joker_value_params="" + for _joker_v in $_joker_values; do + _joker_value_params="$_joker_value_params&value=$_joker_v" + done + _info "Adding TXT record" - if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value=$txtvalue"; then + if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then if _startswith "$response" "OK"; then + _savedomainconf "$_joker_conf_key" "$_joker_values" _info "Added, OK" return 0 fi @@ -59,10 +78,36 @@ dns_joker_rm() { return 1 fi + # Remove only this value from the accumulated set and replace the label with + # whatever remains (an empty value clears the label's TXT records entirely). + _joker_conf_key=$(printf "%s" "JOKER_TXT_${fulldomain}" | tr '.-' '_') + _joker_values=$(_readdomainconf "$_joker_conf_key") + _joker_remaining="" + for _joker_v in $_joker_values; do + if [ "$_joker_v" != "$txtvalue" ]; then + _joker_remaining="$_joker_remaining $_joker_v" + fi + done + _joker_remaining=$(printf "%s" "$_joker_remaining" | sed 's/^ *//') + + _joker_value_params="" + for _joker_v in $_joker_remaining; do + _joker_value_params="$_joker_value_params&value=$_joker_v" + done + if [ -z "$_joker_value_params" ]; then + _joker_value_params="&value=" + fi + _info "Removing TXT record" - # TXT record is removed by setting its value to empty. - if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT&value="; then + # TXT record is removed by replacing the label with the remaining values + # (or an empty value, which clears all TXT records at the label). + if _joker_rest "username=$JOKER_USERNAME&password=$JOKER_PASSWORD&zone=$_domain&label=$_sub_domain&type=TXT$_joker_value_params"; then if _startswith "$response" "OK"; then + if [ -z "$_joker_remaining" ]; then + _cleardomainconf "$_joker_conf_key" + else + _savedomainconf "$_joker_conf_key" "$_joker_remaining" + fi _info "Removed, OK" return 0 fi From c83eed499473861631d4316f9a6831db1251b5b9 Mon Sep 17 00:00:00 2001 From: bluenenschloss Date: Thu, 2 Jul 2026 07:00:49 +0200 Subject: [PATCH 20/35] dns_inwx: fix IDN zone detection without python dependency (#7056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * dns_inwx: fix IDN zone detection without python dependency INWX returns zone names in Unicode form (e.g. lünenschloß.de) even when the domain was registered as an IDN. When acme.sh passes the SAN in punycode (xn--lnenschlo-o1a42a.de), _contains never matches and _get_root falls through to the TLD, placing the TXT record in the wrong zone. Previous fix used python3 which is not available in all environments (BusyBox, BSD, minimal containers). Replace with _idn()-based approach: extract values from the nameserver.list XML response, encode each via _idn(), and compare to $h. When a match is found, use the original Unicode zone name for createRecord. Fixes #7038 * dns_inwx: fix shebang, use _egrep_o, shfmt cleanup - Revert shebang to #!/usr/bin/env sh (POSIX sh, fixes ShellCheck) - Replace grep -o with _egrep_o for portability - shfmt -i 2: drop backslash continuation after pipe, fix indentation Requested by @neilpang * fix: drop closing from _egrep_o pattern to avoid sed delimiter collision --------- Co-authored-by: bluenenschloss --- dnsapi/dns_inwx.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/dnsapi/dns_inwx.sh b/dnsapi/dns_inwx.sh index 2cf91404..dba23846 100755 --- a/dnsapi/dns_inwx.sh +++ b/dnsapi/dns_inwx.sh @@ -312,6 +312,22 @@ _get_root() { _domain="$h" return 0 fi + # IDN fallback: INWX returns Unicode zone names; when $h is ACE/punycode, + # encode each zone name via _idn() and compare — no python dependency. + if _contains "$h" "xn--"; then + _zone_unicode=$(printf "%s" "$response" | _egrep_o '[^<]*' | + sed 's/<[^>]*>//g' | while IFS= read -r _z; do + if [ "$(_idn "$_z")" = "$h" ]; then + printf "%s" "$_z" + break + fi + done) + if [ -n "$_zone_unicode" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain="$_zone_unicode" + return 0 + fi + fi p=$i i=$(_math "$i" + 1) done From b039ff3087e84ce664eedb048bb2e539f881f218 Mon Sep 17 00:00:00 2001 From: pxMan79 Date: Thu, 2 Jul 2026 13:02:30 +0800 Subject: [PATCH 21/35] fix(dns_baidu): prefer new Baidu DNS API with legacy BCD fallback (#6992) * fix(acme): prefer new Baidu DNS API with legacy BCD fallback Keep the existing BCD implementation and add fallback support for the newer Baidu DNS record API. Prefer the new API by default, then fall back to the legacy BCD API to reduce compatibility risk. * fix(dns_baidu): route through _get/_post + restore legacy BCD auth headers Per review: _baidu_dns_call now uses _get/_post with _H1.._H5 (no raw curl, no __HTTP_STATUS__ parsing); _baidu_bcd_post restores _H1.._H5 so the legacy BCD path sends the Authorization signature again (fixes 401). --------- Co-authored-by: neil --- dnsapi/dns_baidu.sh | 329 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 278 insertions(+), 51 deletions(-) diff --git a/dnsapi/dns_baidu.sh b/dnsapi/dns_baidu.sh index 8651deab..dfad8eeb 100644 --- a/dnsapi/dns_baidu.sh +++ b/dnsapi/dns_baidu.sh @@ -49,26 +49,95 @@ Options: Baidu_SK SecretAccessKey OptionsAlt: Baidu_BCD_Host API host, default: bcd.baidubce.com + Baidu_DNS_Host New DNS API host, default: dns.baidubce.com + Baidu_API_Preference Engine preference, default: auto Baidu_BCD_Version API version number, default: 1 Baidu_BCD_Expire Signature expiration seconds, default: 3600 Baidu_View Resolve view, default: DEFAULT + Baidu_Line New DNS line, default: default Baidu_TTL Resolve ttl seconds, default: 300 Baidu_RM_Max Max records to delete in one run, default: 20 ' BAIDU_BCD_DEFAULT_HOST="bcd.baidubce.com" +BAIDU_DNS_DEFAULT_HOST="dns.baidubce.com" # --- Public API --- dns_baidu_add() { fulldomain=$(_idn "$1") txtvalue=$2 - if ! _baidu_prepare_record "$fulldomain"; then - _baidu_err "baidu_prepare_record failed for add: $fulldomain" + if ! _baidu_run_with_fallback "add" "$fulldomain" "$txtvalue"; then + _baidu_err "all baidu api engines failed for add: $fulldomain" return 1 fi - if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then + return 0 +} + +dns_baidu_rm() { + fulldomain=$(_idn "$1") + txtvalue=$2 + + if ! _baidu_run_with_fallback "rm" "$fulldomain" "$txtvalue"; then + _baidu_err "all baidu api engines failed for delete: $fulldomain" + return 1 + fi + + return 0 +} + +_baidu_run_with_fallback() { + _action="$1" + _fulldomain="$2" + _txtvalue="$3" + + if ! _baidu_load_credentials; then + _baidu_err "baidu_load_credentials failed" + return 1 + fi + + for _baidu_api_engine in $(_baidu_engine_order); do + if ! _baidu_prepare_record "$_fulldomain"; then + _baidu_info "prepare failed for engine: $_baidu_api_engine" + continue + fi + + if [ "$_action" = "add" ]; then + if _baidu_add_record "$_txtvalue"; then + return 0 + fi + else + if _baidu_rm_record "$_txtvalue"; then + return 0 + fi + fi + + _baidu_info "engine failed, try next if available: $_baidu_api_engine" + done + + return 1 +} + +_baidu_engine_order() { + _pref="$(_lower_case "$(_baidu_trim_ws "${Baidu_API_Preference:-auto}")")" + case "$_pref" in + legacy) + printf "%s" "legacy new" + ;; + new) + printf "%s" "new legacy" + ;; + *) + printf "%s" "new legacy" + ;; + esac +} + +_baidu_add_record() { + _txtvalue="$1" + + if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then _baidu_err "baidu_find_record_ids failed for add: $_record_domain.$_zone_name" return 1 fi @@ -85,16 +154,28 @@ dns_baidu_add() { _ttl="300" ;; esac - _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")" - txtvalue="$(_baidu_trim_ws "$txtvalue")" + + txtvalue="$(_baidu_trim_ws "$_txtvalue")" _record_domain="$(_baidu_trim_ws "$_record_domain")" _zone_name="$(_baidu_trim_ws "$_zone_name")" - _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")" - - if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then - _baidu_err "baidu_bcd_post failed: add record" - return 1 + if [ "$_baidu_api_engine" = "new" ]; then + _line="$(_baidu_trim_ws "${Baidu_Line:-default}")" + if [ -z "$_line" ]; then + _line="default" + fi + _body="$(_baidu_payload_add_txt_dns "$_record_domain" "$txtvalue" "$_ttl" "$_line")" + if ! _baidu_dns_call "POST" "/v1/dns/zone/${_zone_name}/record" "$_body"; then + _baidu_err "baidu_dns_call failed: add record" + return 1 + fi + else + _view="$(_baidu_trim_ws "${Baidu_View:-DEFAULT}")" + _body="$(_baidu_payload_add_txt "$_zone_name" "$_record_domain" "$txtvalue" "$_ttl" "$_view")" + if ! _baidu_bcd_post "/domain/resolve/add" "$_body"; then + _baidu_err "baidu_bcd_post failed: add record" + return 1 + fi fi if _baidu_is_api_error "$response"; then @@ -105,16 +186,10 @@ dns_baidu_add() { return 0 } -dns_baidu_rm() { - fulldomain=$(_idn "$1") - txtvalue=$2 +_baidu_rm_record() { + _txtvalue="$1" - if ! _baidu_prepare_record "$fulldomain"; then - _baidu_err "baidu_prepare_record failed for delete: $fulldomain" - return 1 - fi - - if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then + if ! _baidu_find_record_ids_current "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then _baidu_err "baidu_find_record_ids failed for delete: $_record_domain.$_zone_name" return 1 fi @@ -138,28 +213,37 @@ dns_baidu_rm() { fi for _rid in $_ids; do - _body="$(_baidu_payload_delete "$_zone_name" "$_rid")" - if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then - _baidu_err "baidu_bcd_post failed: delete recordId=$_rid" - return 1 - fi - if _baidu_is_api_error "$response"; then - _baidu_err "$response" - return 1 + if [ "$_baidu_api_engine" = "new" ]; then + if ! _baidu_dns_call "DELETE" "/v1/dns/zone/${_zone_name}/record/${_rid}" ""; then + _baidu_err "baidu_dns_call failed: delete recordId=$_rid" + return 1 + fi + else + _body="$(_baidu_payload_delete "$_zone_name" "$_rid")" + if ! _baidu_bcd_post "/domain/resolve/delete" "$_body"; then + _baidu_err "baidu_bcd_post failed: delete recordId=$_rid" + return 1 + fi + if _baidu_is_api_error "$response"; then + _baidu_err "$response" + return 1 + fi fi done - if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$txtvalue"; then - _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name" - return 1 - fi - _left_ids="$_BAIDU_FIND_RESULT" - if [ -z "$_left_ids" ]; then - return 0 - fi - if [ -n "$_left_ids" ]; then - _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records" - return 1 + if [ "$_baidu_api_engine" = "legacy" ]; then + if ! _baidu_find_record_ids "$_zone_name" "$_record_domain" "TXT" "$_txtvalue"; then + _baidu_err "baidu_find_record_ids failed for delete verify: $_record_domain.$_zone_name" + return 1 + fi + _left_ids="$_BAIDU_FIND_RESULT" + if [ -z "$_left_ids" ]; then + return 0 + fi + if [ -n "$_left_ids" ]; then + _baidu_err "delete verification failed: $_record_domain.$_zone_name still has TXT records" + return 1 + fi fi return 0 @@ -182,6 +266,7 @@ _baidu_load_credentials() { _saveaccountconf_mutable Baidu_SK "$Baidu_SK" BAIDU_BCD_HOST="${Baidu_BCD_Host:-$BAIDU_BCD_DEFAULT_HOST}" + BAIDU_DNS_HOST="${Baidu_DNS_Host:-$BAIDU_DNS_DEFAULT_HOST}" BAIDU_BCD_VERSION="${Baidu_BCD_Version:-1}" return 0 @@ -189,13 +274,16 @@ _baidu_load_credentials() { _baidu_prepare_record() { _fulldomain="$1" - if ! _baidu_load_credentials; then - _baidu_err "baidu_load_credentials failed" - return 1 - fi - if ! _baidu_get_root "$_fulldomain"; then - _baidu_err "Could not find zone for $_fulldomain" - return 1 + if [ "$_baidu_api_engine" = "new" ]; then + if ! _baidu_get_root_dns "$_fulldomain"; then + _baidu_err "Could not find zone by new dns api for $_fulldomain" + return 1 + fi + else + if ! _baidu_get_root "$_fulldomain"; then + _baidu_err "Could not find zone by legacy bcd api for $_fulldomain" + return 1 + fi fi _record_domain="$_sub_domain" _zone_name="$_domain" @@ -234,6 +322,43 @@ _baidu_get_root() { done } +_baidu_get_root_dns() { + domain=$1 + i=1 + p=1 + + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + _baidu_err "invalid domain: $domain" + return 1 + fi + + if ! _baidu_dns_call "GET" "/v1/dns/zone/${h}/record" ""; then + _baidu_info "baidu_dns_call failed: list zones" + elif ! _baidu_is_api_error "$response" && (_contains "$response" "\"records\"" || _contains "$response" "\"maxKeys\""); then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + if [ "$_sub_domain" = "$_domain" ]; then + _sub_domain="@" + fi + _baidu_info "zone matched by dns api: $_domain (host: $_sub_domain)" + return 0 + fi + + p=$i + i=$(_math "$i" + 1) + done +} + +_baidu_find_record_ids_current() { + if [ "$_baidu_api_engine" = "new" ]; then + _baidu_find_record_ids_dns "$@" + else + _baidu_find_record_ids "$@" + fi +} + _baidu_find_record_ids() { _zone_name="$1" _record_domain="$2" @@ -293,6 +418,39 @@ EOF _BAIDU_FIND_RESULT="$_ids" } +_baidu_find_record_ids_dns() { + _zone_name="$1" + _record_domain="$2" + _rdtype="$3" + _rdata="$4" + _BAIDU_FIND_RESULT="" + + if ! _baidu_dns_call "GET" "/v1/dns/zone/${_zone_name}/record" ""; then + _baidu_err "baidu_dns_call failed: list records" + return 1 + fi + + if _baidu_is_api_error "$response"; then + _baidu_err "baidu_dns error: $(_baidu_json_get_str "$response" "code") $(_baidu_json_get_str "$response" "message")" + return 1 + fi + + _normalized="$(printf "%s" "$response" | _normalizeJson)" + _records=$(printf "%s" "$_normalized" | sed 's/},{/}\n{/g') + _ids="" + + while IFS= read -r _line; do + _id="$(_baidu_match_record_id_dns "$_line" "$_record_domain" "$_rdtype" "$_rdata")" + if [ "$_id" ]; then + _ids="$_ids $_id" + fi + done < Date: Thu, 2 Jul 2026 07:06:41 +0200 Subject: [PATCH 22/35] Implemented support for Aruba Business DNS API (#7042) * Add support for arubabusiness api * Fix formatting * record names are always converted to lowercase * Docs * remove leftover unconditional authentication call lowercase urlencoded body + x-www-form-urlencoded content-type cleanup header variables cleanup typos grammar * Strengthen _ab_rest failure checks Properly process parallel lists in _ab_dns_record_id Remove hard fails when a txt record already exists * fix json parsing * Fix formatting --------- Co-authored-by: Manwe-Sulimo --- dnsapi/dns_arubabusiness.sh | 490 ++++++++++++++++++++++++++++++++++++ 1 file changed, 490 insertions(+) create mode 100644 dnsapi/dns_arubabusiness.sh diff --git a/dnsapi/dns_arubabusiness.sh b/dnsapi/dns_arubabusiness.sh new file mode 100644 index 00000000..90b3f18d --- /dev/null +++ b/dnsapi/dns_arubabusiness.sh @@ -0,0 +1,490 @@ +#!/usr/bin/env sh + +# shellcheck disable=SC2034 +dns_arubabusiness_info='ArubaBusiness +Site: business.aruba.it +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi#dns_arubabusiness +Options: + AB_Key Your ArubaBusiness API Key + AB_User Your account user + AB_Pass Your account password +' + +# +# A word of warning: as of this writing, api.arubabusiness.it only supports oauth authentication using the "password" grant type. +# If you are REALLY sure you want to use it, it would be wise set up a dedicated technical user without administrative privileges +# + +ARUBABUSINESS_API='https://api.arubabusiness.it' + +######## Public functions ######## + +# +# Usage: dns_arubabusiness_add _acme-challenge.www.domain.com aaaabbbbcccc111122223333 +# +# Add a new TXT record whose name and value match the given domain and value +# +# Variables +# _full_domain: $1 - the name of the TXT record +# _txt_value: $2 - the value of the TXT record +# _body +# dns_details +# domain_id +# dns_record_id +# response +# +dns_arubabusiness_add() { + _full_domain=$1 + _txt_value=$2 + + if ! _ab_authenticate; then + return 1 + fi + + if ! _ab_domain_id "$_full_domain"; then + return 1 + fi + + if _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then + # This is very unlikely, but allow the process to use the existing record + _info "A TXT record with name: $_full_domain and value: $_txt_value already exists (id: $dns_record_id)" + return 0 + fi + + _body="{ \"IdDomain\": $domain_id, \"Type\": \"TXT\", \"Name\": \"$_full_domain\", \"Content\": \"\\\"$_txt_value\\\"\" }" + + _debug "Adding TXT record with name: $_full_domain and value: $_txt_value" + + if ! _ab_rest POST "api/domains/dns/record" "$_body" || ! _contains "$response" "DomainId"; then + _err "Failed to add TXT record with name: $_full_domain" + return 1 + fi + + _info "Sleeping 10 seconds to let ArubaBusiness do its magic" + _sleep 10 + + # Refresh dns details and check that the record was really added + if ! _ab_dns_details "$root_domain"; then + return 1 + fi + + if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details"; then + # This should never happen + _err "The TXT record with name: $_full_domain was not set" + _err "Please check that the dns records are clean" + return 1 + fi + + _info "Added TXT record with id: $dns_record_id" + return 0 +} + +# +# Usage: dns_arubabusiness_rm _acme-challenge.www.domain.com aaaabbbbcccc111122223333 +# +# Remove the TXT record whose name and value match the given domain and value +# +# Variables +# _full_domain: $1 - the name of the TXT record +# _txt_value: $2 - the value of the TXT record +# dns_details +# dns_record_id +# +dns_arubabusiness_rm() { + _full_domain=$1 + _txt_value=$2 + + if ! _ab_authenticate; then + return 1 + fi + + if ! _ab_domain_id "$_full_domain"; then + return 1 + fi + + if ! _ab_dns_record_id "$_full_domain" "$_txt_value" "$dns_details" || [ -z "$dns_record_id" ]; then + _err "Could not retrieve the record id for: $_full_domain" + return 1 + fi + + _debug "Deleting TXT record: $dns_record_id" + if ! _ab_rest DELETE "api/domains/dns/record/$dns_record_id" || ! _contains "$response" "DomainId"; then + _err "Failed to delete TXT record: $dns_record_id" + return 1 + fi + + _info "Deleted TXT record: $dns_record_id" + return 0 +} + +######## Private functions ######## + +# +# Usage: _ab_domain_id _acme-challenge.www.domain.com +# +# Split the input domain into subdomain + root domain and get the id of the root domain +# +# Variables +# _full_domain: $1 - the domain whose root needs to be extracted +# _domain_sections +# _current_index +# _candidate_subdomain +# _candidate_domain +# sub_domain +# root_domain +# domain_id +# dns_details: a json containing all dns records registered on the root domain +# +# Example +# _get_root _acme-challenge.www.domain.com +# +# Should return +# sub_domain=_acme-challenge.www +# root_domain=domain.com +# domain_id=123123123123 +# dns_details="{JSON_CONTENT}" +# +_ab_domain_id() { + _full_domain=$1 + + _info "Attempting to retrieve root domain details for: $_full_domain" + + _domain_sections=$(_math "$(printf "%s" "$_full_domain" | tr '.' '\n' | wc -l)" + 1) + + if [ "$_domain_sections" -lt 1 ]; then + _err "Invalid input $_full_domain" + return 1 + fi + + _current_index=1 + while true; do + _candidate_subdomain=$(if [ "$_current_index" = "1" ]; then printf ""; else printf "%s" "$_full_domain" | cut -d . -f 1-"$(_math "$_current_index" - 1)"; fi) + _candidate_domain=$(printf "%s" "$_full_domain" | cut -d . -f "$_current_index"-"$_domain_sections") + + if ! _ab_dns_details "$_candidate_domain"; then + _debug2 "Could not fetch dns details for: $_candidate_domain" + _current_index=$(_math "$_current_index" + 1) + + # Fail if there are no candidates left + if [ "$_current_index" -gt "$_domain_sections" ]; then + _err "Could not determine the root domain for: $_full_domain" + return 1 + fi + else + sub_domain="$_candidate_subdomain" + root_domain="$_candidate_domain" + # Extract the domain id, which is an integer and contains no commas + domain_id="$(printf "%s" "$dns_details" | _egrep_o '"Id":[^,]*' | _head_n 1 | cut -d : -f 2 | tr -d ' "')" + + if [ -z "$domain_id" ]; then + _err "Could not determine the domain id for: $root_domain" + return 1 + fi + + _debug "Retrieved root domain id: $domain_id" + return 0 + fi + done +} + +# +# Usage: _ab_dns_record_id _acme-challenge.www.domain.com "aaaabbbbcccc111122223333" "{JSON_CONTENT}" +# +# Extract the record id of the first TXT record whose name and content match the input values +# +# Variables +# _record_name: $1 +# _txt_value: $2 +# _dns_details: $3 - the json returned by a previous call to '_ab_dns_details() $root_domain' +# _record_ids +# _record_names +# _record_types +# _record_contents +# _record_ids_count +# _record_names_count +# _record_types_count +# _record_contents_count +# _i +# dns_record_id +# +# Notes +# TXT correspond to record type 5 +# ArubaBusiness appends a terminating dot (.) to the record name +# The content field may contain the following character sequence: \" +# All record names are always converted to lowercase +# +_ab_dns_record_id() { + _record_name=$1 + _txt_value=$2 + _dns_details=$3 + + _record_name_lowercase=$(printf "%s" "$_record_name" | _lower_case) + + # Extract the record ids, which are integers and contain no commas, colons or spaces + # The first id is skipped because it refers to the domain id + _record_ids=$(printf "%s" "$_dns_details" | sed 's/"Id":/\n"Id":/g' | _egrep_o '"Id":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ') + + # Extract the record names, which are strings but cannot contain commas, colons, spaces and quotes + # The first name is skipped because it refers to the domain name + _record_names=$(printf "%s" "$_dns_details" | sed 's/"Name":/\n"Name":/g' | _egrep_o '"Name":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' "' | tr '\n' ' ') + + # Extract the record types, which are integers (except for the first one) and contain no commas, colons or spaces + # The first type is skipped because it refers to the domain type + _record_types=$(printf "%s" "$_dns_details" | sed 's/"Type":/\n"Type":/g' | _egrep_o '"Type":[^,]*' | _tail_n +2 | cut -d : -f 2 | tr -d ' ' | tr '\n' ' ') + + # Extract the record contents, which are strings and may contain no quotes except for TXT records, which must be delimited by two \" literals + # Note: There is no domain related entry here + # Note: A " character is appended at the end of each content to make it easier to process the list later + _record_contents=$(printf "%s" "$_dns_details" | sed 's/"Content":/\n"Content":/g' | sed 's/\\"//g' | _egrep_o '"Content": *"[^"]*"' | cut -d : -f 2- | sed -n 's/"\(.*\)"/\1/p' | tr '\n' '#') + + _info "IDS: $_record_ids" + _info "NAMES: $_record_names" + _info "TYPEs: $_record_types" + _info "CONTENTS: $_record_contents" + + _record_ids_count=$(printf "%s" "$_record_ids" | tr ' ' '\n' | wc -l) + _record_names_count=$(printf "%s" "$_record_names" | tr ' ' '\n' | wc -l) + _record_types_count=$(printf "%s" "$_record_types" | tr ' ' '\n' | wc -l) + _record_contents_count=$(printf "%s" "$_record_contents" | tr '#' '\n' | wc -l) + + _info "Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count" + + if [ "$_record_ids_count" != "$_record_names_count" ] || [ "$_record_ids_count" != "$_record_types_count" ] || [ "$_record_ids_count" != "$_record_contents_count" ]; then + _err "Failed to parse record elements. Ids: $_record_ids_count, names: $_record_names_count, types: $_record_types_count, contents: $_record_contents_count" + return 1 + fi + + _info "Looking for a TXT record matching inputs - name: $_record_name_lowercase value: $_txt_value" + + _i=1 + while [ "$_i" -le "$_record_ids_count" ]; do + _current_name=$(printf "%s" "$_record_names" | cut -d " " -f "$_i") + _current_type=$(printf "%s" "$_record_types" | cut -d " " -f "$_i") + _current_content=$(printf "%s" "$_record_contents" | cut -d "#" -f "$_i") + + if [ "$_record_name_lowercase." = "$_current_name" ] && [ "5" = "$_current_type" ] && [ "$_txt_value" = "$_current_content" ]; then + dns_record_id=$(printf "%s" "$_record_ids" | cut -d " " -f "$_i") + _info "Found matching record with id: $dns_record_id" + return 0 + else + _debug2 "Record does not match - type: '$_current_type' name: '$_current_name' value: '$_current_content'; Expected '$_record_name_lowercase.' '5' '$_txt_value'" + fi + _i=$(_math "$_i" + 1) + done + + _debug2 "No matching record was found in $_dns_details" + return 1 +} + +# +# Usage: _ab_dns_details domain.com +# +# Retrieve dns info for the given input domain +# +# Variables +# _domain: $1 +# dns_details: the json returned by the call to $ARUBABUSINESS_API/api/domains/dns/$_domain/details (if return status is 0) +# response +# +_ab_dns_details() { + _domain=$1 + + if ! _ab_rest GET "api/domains/dns/$_domain/details" || ! _contains "$response" "DomainId"; then + return 1 + fi + + dns_details="$response" + return 0 +} + +# +# Usage: _ab_authenticate +# +# Read account conf, update domain conf and perform user authentication to acquire an access token +# +# Variables +# AB_Key +# AB_User +# AB_Pass +# AB_Token +# +_ab_authenticate() { + AB_Key="${AB_Key:-$(_readaccountconf_mutable AB_Key)}" + AB_User="${AB_User:-$(_readaccountconf_mutable AB_User)}" + AB_Pass="${AB_Pass:-$(_readaccountconf_mutable AB_Pass)}" + + if [ -z "$AB_Key" ] || [ -z "$AB_User" ] || [ -z "$AB_Pass" ]; then + AB_Key="" + AB_User="" + AB_Pass="" + _err "Either the ArubaBusiness API key, the user or the password has not been defined yet." + _err "Please configure them and try again." + return 1 + fi + + _saveaccountconf_mutable AB_Key "$AB_Key" + _saveaccountconf_mutable AB_User "$AB_User" + _saveaccountconf_mutable AB_Pass "$AB_Pass" + + if ! _ab_get_token || [ -z "$AB_Token" ]; then + _err "Failed to acquire an access token" + return 1 + fi + + return 0 +} + +# +# Usage: _ab_get_token +# +# Try acquiring a temporary access token. The token should have a 24h lifespan +# +# Variables +# _ab_user_enc +# _ab_pass_enc +# _ab_authdata +# AB_User +# AB_Pass +# AB_Token +# response +# _H2 +# +_ab_get_token() { + _ab_user_enc=$(printf "%s" "$AB_User" | _url_encode) + _ab_pass_enc=$(printf "%s" "$AB_Pass" | _url_encode) + _ab_authdata="grant_type=password&username=$_ab_user_enc&password=$_ab_pass_enc" + + _H2="Content-Type: application/x-www-form-urlencoded" + + if ! _ab_rest POST "auth/token" "$_ab_authdata" || ! _contains "$response" "access_token"; then + _err "Authentication failure" + return 1 + fi + + AB_Token="$(printf "%s" "$response" | _egrep_o '"access_token":"[^\"]*"' | cut -d : -f 2 | tr -d '"')" + + if [ -z "$AB_Token" ]; then + _err "Could not extract access token" + return 1 + fi + + _debug "Acquired access token" + return 0 +} + +# +# Usage: _ab_rest POST "example/endpoint" "password=123" +# +# Perform a REST request using the given method, endpoint and data +# +# Variables +# _method: $1 - The http method +# _endpoint: $2 - The api path (relative to $ARUBABUSINESS_API) +# _data: $3 - The body of the request (optional) +# _key_trimmed +# _token_trimmed +# _ret_code +# AB_Key +# AB_Token +# ARUBABUSINESS_API +# _H1 +# _H2 +# _H3 +# _H4 +# +_ab_rest() { + _method=$1 + _endpoint="$2" + _data="$3" + + _key_trimmed=$(printf "%s" "$AB_Key" | tr -d '"') + _token_trimmed=$(printf "%s" "$AB_Token" | tr -d '"') + + _H1="Accept: application/json" + + if [ -z "$_H2" ]; then + # Default to application/json + _H2="Content-Type: application/json" + fi + + if [ "$_key_trimmed" ]; then + _H3="Authorization-Key: $_key_trimmed" + else + _err "Missing Api Key" + _ab_cleanup_headers + return 1 + fi + + if [ "$_token_trimmed" ]; then + _H4="Authorization: Bearer $_token_trimmed" + else + _debug "No access token set" + fi + + if [ "$_method" != "GET" ]; then + response="$(_post "$_data" "$ARUBABUSINESS_API/$_endpoint" "" "$_method")" + else + response="$(_get "$ARUBABUSINESS_API/$_endpoint")" + fi + + _ret_code=$? + + if [ "$_ret_code" = "0" ] && _ab_call_is_success; then + # Normalize the json response + response="$(printf "%s" "$response" | _normalizeJson)" + _ret_code=0 + else + _err "Failed to call endpoint: $_endpoint" + _ret_code=1 + fi + + _ab_cleanup_headers + + return $_ret_code +} + +# +# Usage: _ab_cleanup_headers +# +# Unset header variables to avoid interfering with other calls +# +# Variables +# _H1 +# _H2 +# _H3 +# _H4 +# +_ab_cleanup_headers() { + # Cleanup request headers + unset _H1 _H2 _H3 _H4 _H5 + + # Cleanup response headers + if [ -f "$HTTP_HEADER" ]; then + : >"$HTTP_HEADER" + fi +} + +# +# Usage: _ab_call_is_success +# +# Check whether a call's response http status is one of 200, 201, 202 or 204 (other 2xx are not handled) +# +# Variables +# _status +# _http_status +# _success_http_codes +# HTTP_HEADER +# +_ab_call_is_success() { + _success_http_codes="200 201 202 204" + if [ -f "$HTTP_HEADER" ]; then + _http_status=$(_egrep_o "^HTTP[\/0-9. ]*" <"$HTTP_HEADER" | _head_n 1 | cut -d " " -f 2) + for _status in $_success_http_codes; do + if [ "$_status" = "$_http_status" ]; then + return 0 + fi + done + fi + + return 1 +} From e52d75d762fc389966915d7952328e390d24424e Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 13:09:48 +0800 Subject: [PATCH 23/35] fix https://github.com/acmesh-official/acme.sh/issues/7062 --- dnsapi/dns_selfhost.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh index 4912dfdf..40cc0210 100644 --- a/dnsapi/dns_selfhost.sh +++ b/dnsapi/dns_selfhost.sh @@ -18,7 +18,7 @@ dns_selfhost_add() { _debug fulldomain "$fulldomain" _debug txtvalue "$txt" - SELFHOSTDNS_UPDATE_URL="https://selfhost.de/cgi-bin/api.pl" + SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl" # Get values, but don't save until we successfully validated SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}" From 5433ea86c8fcf10c038f75f93cf3a5f12e4a3afe Mon Sep 17 00:00:00 2001 From: Toni Karppi Date: Thu, 2 Jul 2026 07:10:29 +0200 Subject: [PATCH 24/35] Add Glesys dnsapi provider (#7059) * Add Glesys dnsapi provider * Fix typo in error message for dns_glesys_add * Use API to get record id in Glesys provider * Use listrecords API endpoint to find root domain * Remove record id parsin from add function --- dnsapi/dns_glesys.sh | 263 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 dnsapi/dns_glesys.sh diff --git a/dnsapi/dns_glesys.sh b/dnsapi/dns_glesys.sh new file mode 100644 index 00000000..008abd12 --- /dev/null +++ b/dnsapi/dns_glesys.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_glesys_info='Glesys +Site: Glesys.se +Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_glesys +Options: + GLESYS_API_KEY Generated API key. + GLESYS_PROJECT_ID Project ID for the API key (e.g. cl12345). + GLESYS_API API endpoint. Default "https://api.glesys.com/domain". + GLESYS_TTL TXT record TTL. Default 120. +Issues: https://github.com/acmesh-official/acme.sh/issues/7057 +Author: Toni Karppi +' + +GLESYS_API_DEFAULT="https://api.glesys.com/domain" +GLESYS_TTL_DEFAULT="120" + +######## Public functions ##################################################### + +# Usage: +# dns_glesys_add _acme-challenge.www.example.com "txt-value" +dns_glesys_add() { + fulldomain="$1" + txtvalue="$2" + + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + _glesys_init || return 1 + + if ! _glesys_get_root "$fulldomain"; then + _err "Could not find root zone for $fulldomain" + return 1 + fi + + _debug _domain "$_domain" + _debug _sub_domain "$_sub_domain" + + host_value="${_sub_domain:-@}" + _debug _host_value "$host_value" + + data="{\"domainname\":\"$_domain\",\"host\":\"$host_value\",\"type\":\"TXT\",\"data\":\"$txtvalue\",\"ttl\":\"$GLESYS_TTL\"}" + + _debug2 data "$data" + + if ! _glesys_rest POST "/addrecord" "$data"; then + _err "Failed to send HTTP request to add TXT record" + return 1 + fi + + response_code=$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + _egrep_o '"code":"?[0-9]+' | + _egrep_o '[0-9]+$' + ) + + _debug response_code "$response_code" + + if [ "$response_code" != "200" ]; then + _err "GleSYS API responded with an unexpected status when attempting to add TXT record" + _debug2 "API response" "$response" + return 1 + fi + + _info "TXT record added" + + return 0 +} + +# Usage: +# dns_glesys_rm _acme-challenge.www.example.com "txt-value" +dns_glesys_rm() { + fulldomain="$1" + txtvalue="$2" + + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + _glesys_init || return 1 + + if ! _glesys_get_root "$fulldomain"; then + _err "Could not find root zone for $fulldomain" + return 1 + fi + + if ! _glesys_find_record_id "$txtvalue"; then + _info "TXT record not present, skip removal" + return 0 + fi + + _debug _record_id "$_record_id" + + if ! _glesys_rest POST "/deleterecord" "{\"recordid\":$_record_id}"; then + _err "Failed to send HTTP request to remove TXT record" + return 1 + fi + + response_code=$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + _egrep_o '"code":"?[0-9]+' | + _egrep_o '[0-9]+$' + ) + + _debug response_code "$response_code" + + if [ "$response_code" != "200" ]; then + _err "GleSYS API responded with unexpected status when attempting to remove TXT record" + _debug2 "API response" "$response" + return 1 + fi + + _info "TXT record removed" + + return 0 +} + +######## Private functions #################################################### + +_glesys_find_record_id() { + txtvalue="$1" + + _debug txtvalue "$txtvalue" + + if [ -z "$txtvalue" ]; then + return 1 + fi + + _record_id="" + + _debug "Looking for TXT record with value" "$txtvalue" + + if ! _glesys_rest GET "/listrecords?domainname=$_domain"; then + _err "Failed to list DNS records" + return 1 + fi + + records="$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + sed 's/},{/}\ +{/g' + )" + + _debug2 records "$records" + + expected_data="\"data\":\"$txtvalue\"" + + _record_id="$( + printf "%s\n" "$records" | + while IFS= read -r record; do + printf "%s" "$record" | grep -q '"type":"TXT"' || continue + printf "%s" "$record" | grep -Fq "$expected_data" || continue + + printf "%s" "$record" | + grep -E -o '"recordid":"?[0-9]+' | + grep -E -o '[0-9]+$' + + break + done + )" + + _debug _record_id "$_record_id" + + if [ -z "$_record_id" ]; then + return 1 + fi + + return 0 +} + +# Finds: +# _domain example.com +# _sub_domain _acme-challenge.www +_glesys_get_root() { + domain="$1" + i=1 + + while true; do + h="$(printf "%s" "$domain" | cut -d . -f "$i"-100)" + + if [ -z "$h" ]; then + return 1 + fi + + if _glesys_rest GET "/listrecords?domainname=$h"; then + response_code=$( + printf "%s" "$response" | + tr -d '\r\n\t ' | + _egrep_o '"code":"?[0-9]+' | + _egrep_o '[0-9]+$' + ) + + _debug response_code "$response_code" + + if [ "$response_code" = "200" ]; then + cut_len="$((${#domain} - ${#h} - 1))" + _domain="$h" + _sub_domain="$(printf "%s" "$domain" | cut -c "1-$cut_len")" + return 0 + fi + fi + + i="$((i + 1))" + done +} + +_glesys_init() { + [ -z "$GLESYS_API" ] && GLESYS_API="$GLESYS_API_DEFAULT" + [ -z "$GLESYS_TTL" ] && GLESYS_TTL="$GLESYS_TTL_DEFAULT" + + _debug GLESYS_API "$GLESYS_API" + _debug GLESYS_TTL "$GLESYS_TTL" + + GLESYS_API_KEY="${GLESYS_API_KEY:-$(_readaccountconf_mutable GLESYS_API_KEY)}" + GLESYS_PROJECT_ID="${GLESYS_PROJECT_ID:-$(_readaccountconf_mutable GLESYS_PROJECT_ID)}" + + if [ -z "$GLESYS_API_KEY" ] || [ -z "$GLESYS_PROJECT_ID" ]; then + _err "GLESYS_API_KEY and GLESYS_PROJECT_ID must be set for this provider" + return 1 + fi + + _secure_debug GLESYS_API_KEY "$GLESYS_API_KEY" + _secure_debug GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID" + + _glesys_basic_auth="$(printf "%s:%s" "$GLESYS_PROJECT_ID" "$GLESYS_API_KEY" | _base64)" + _secure_debug2 _glesys_basic_auth "$_glesys_basic_auth" + + _saveaccountconf_mutable GLESYS_API_KEY "$GLESYS_API_KEY" + _saveaccountconf_mutable GLESYS_PROJECT_ID "$GLESYS_PROJECT_ID" + + return 0 +} + +_glesys_rest() { + method="$1" + path="$2" + data="$3" + + export _H1="Authorization: Basic $_glesys_basic_auth" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + + url="$GLESYS_API$path" + _debug "$method $url" + + if [ "$method" = "GET" ]; then + response="$(_get "$url")" + else + response="$(_post "$data" "$url" "" "$method")" + fi + + ret="$?" + _debug2 response "$response" + _debug ret "$ret" + + if [ "$ret" != "0" ]; then + return 1 + fi + + return 0 +} From a50fad865fd63cafe83efc3007b02748fdad9568 Mon Sep 17 00:00:00 2001 From: MarFri <163347538+M4rFri@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:08:59 +0200 Subject: [PATCH 25/35] DNS_IONOS double sending content type & case sensitive mismatch (#7028) * double sending content type results in error from ionos * Normalize fulldomain to lowercase in the _ionos_get_record function. --- dnsapi/dns_ionos.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dnsapi/dns_ionos.sh b/dnsapi/dns_ionos.sh index 9a464253..00662e82 100755 --- a/dnsapi/dns_ionos.sh +++ b/dnsapi/dns_ionos.sh @@ -16,7 +16,7 @@ IONOS_TXT_TTL=60 # minimum accepted by API IONOS_TXT_PRIO=10 dns_ionos_add() { - fulldomain=$1 + fulldomain="$(echo "$1" | _lower_case)" txtvalue=$2 if ! _ionos_init; then @@ -34,7 +34,7 @@ dns_ionos_add() { } dns_ionos_rm() { - fulldomain=$1 + fulldomain="$(echo "$1" | _lower_case)" txtvalue=$2 if ! _ionos_init; then @@ -146,7 +146,7 @@ _ionos_rest() { if [ "$method" != "GET" ]; then export _H2="Accept: application/json" - export _H3="Content-Type: application/json" + export _H3= _response="$(_post "$data" "$IONOS_API$route" "" "$method" "application/json")" else From ccd2f04c338d2a4dffaa55539003014b713bba23 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 23:15:29 +0800 Subject: [PATCH 26/35] fix https://github.com/acmesh-official/acme.sh/issues/6986 --- acme.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/acme.sh b/acme.sh index 59700442..ad8a3839 100755 --- a/acme.sh +++ b/acme.sh @@ -4707,6 +4707,13 @@ issue() { else _cleardomainconf "Le_ChallengeAlias" fi + # Save Le_DNSSleep unconditionally here: the save inside the dns_entries + # branch is skipped when all authorizations are already valid (e.g. issuing + # the ECC twin of a just-issued RSA cert), which left the setting out of + # that cert's conf. https://github.com/acmesh-official/acme.sh/issues/6986 + if [ "$Le_DNSSleep" ]; then + _savedomainconf "Le_DNSSleep" "$Le_DNSSleep" + fi if [ "$_preferred_chain" ]; then _savedomainconf "Le_Preferred_Chain" "$_preferred_chain" "base64" else From 42e13fa7970fcd69f54be937a3488d61644c7a33 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 23:18:26 +0800 Subject: [PATCH 27/35] fix https://github.com/acmesh-official/acme.sh/issues/6851 --- dnsapi/dns_infomaniak.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_infomaniak.sh b/dnsapi/dns_infomaniak.sh index 52417fef..43ade8ce 100755 --- a/dnsapi/dns_infomaniak.sh +++ b/dnsapi/dns_infomaniak.sh @@ -129,7 +129,7 @@ dns_infomaniak_rm() { fi export _H1="Authorization: Bearer $INFOMANIAK_API_TOKEN" - export _H2="ContentType: application/json" + export _H2="Content-Type: application/json" fulldomain=$1 txtvalue=$2 From 116c05fbff85574b0065af7846cebe9c91c181e9 Mon Sep 17 00:00:00 2001 From: neil Date: Thu, 2 Jul 2026 23:30:56 +0800 Subject: [PATCH 28/35] fix https://github.com/acmesh-official/acme.sh/issues/6400 --- acme.sh | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index ad8a3839..d714d421 100755 --- a/acme.sh +++ b/acme.sh @@ -7738,9 +7738,16 @@ _checkSudo() { return 0 fi if [ -n "$SUDO_COMMAND" ]; then - #it's a normal user doing "sudo su", or `sudo -i` or `sudo -s`, or `sudo su acmeuser1` - _endswith "$SUDO_COMMAND" /bin/su || _contains "$SUDO_COMMAND" "/bin/su " || grep "^$SUDO_COMMAND\$" /etc/shells >/dev/null 2>&1 - return $? + #The SUDO_* env vars are often inherited into shells that were not + #started as `sudo acme.sh` at all (e.g. `sudo su - user`, or + #`sudo pct enter ` on Proxmox, which copies them into the + #container). Only warn when sudo was used to run acme.sh itself; + #anything else means the sudo happened further up and is fine. + #https://github.com/acmesh-official/acme.sh/issues/6400 + if _contains "$SUDO_COMMAND" "$PROJECT_ENTRY"; then + return 1 + fi + return 0 fi #otherwise return 1 From ced8d72808810c69703f52eda1f48accdd9181fa Mon Sep 17 00:00:00 2001 From: "Simon V." <218359733+sim0n-v@users.noreply.github.com> Date: Fri, 3 Jul 2026 09:12:32 +0200 Subject: [PATCH 29/35] ARI - Run cron job more frequently (#6939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ARI - Run cron job more frequently With ACME Renewal Info (RFC9773 §4.3), fetching renewal window should be more frequent, e.g. in case of revocation incident. "For instance, a server that needs to revoke certificates within 24 hours of notification of a problem might choose to reserve twelve hours for investigation, six hours for clients to fetch updated RenewalInfo objects, and six hours for clients to perform a renewal." More flexible option is to run the cron job every hour and only refresh ARI when the last one + Retry-After header is in the past. * Fix cron job schedule for certificate renewal * Fix random_hour syntax in cron job installation * Update Windows task scheduler to run more frequently Add support for randomized hour and update frequency Ref: * [/mo](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create#to-schedule-a-task-to-run-every-n-hours) * [/SC HOURLY](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-create#parameters) * Add padding for StartTime (/ST) in SCHTASKS.exe * New Banner Updated README to include responsive images for dark and light modes. * rebase * Reset README --------- Co-authored-by: ZeroSSL-Andreas --- acme.sh | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index d714d421..ad7a6325 100755 --- a/acme.sh +++ b/acme.sh @@ -6470,6 +6470,7 @@ _install_win_taskscheduler() { _lesh="$1" _centry="$2" _randomminute="$3" + _randomhour="$4" if ! _exists cygpath; then _err "cygpath not found" return 1 @@ -6539,7 +6540,7 @@ installcronjob() { fi _t=$(_time) random_minute=$(_math $_t % 60) - random_hour=$(_math $_t / 60 % 24) + random_hour=$(_math $_t / 60 % 6) if ! _exists "$_CRONTAB" && _exists "fcrontab"; then _CRONTAB="fcrontab" @@ -6548,7 +6549,7 @@ installcronjob() { if ! _exists "$_CRONTAB"; then if _exists cygpath && _exists schtasks.exe; then _info "It seems you are on Windows, let's install the Windows scheduler task." - if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute"; then + if _install_win_taskscheduler "$lesh" "$_c_entry" "$random_minute" "$random_hour"; then _info "Successfully installed Windows scheduler task." return 0 else @@ -6570,7 +6571,7 @@ installcronjob() { fi $_CRONTAB -l 2>/dev/null | { cat - echo "$random_minute $random_hour * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" + echo "$random_minute $random_hour,$(_math $random_hour + 6),$(_math $random_hour + 12),$(_math $random_hour + 18) * * * $lesh --cron --home \"$LE_WORKING_DIR\" $_c_entry> /dev/null" } | $_CRONTAB_STDIN fi if [ "$?" != "0" ]; then From fdf528c26c72884981dec9600834c8411a87ca32 Mon Sep 17 00:00:00 2001 From: Howtin Date: Fri, 3 Jul 2026 18:09:32 +0800 Subject: [PATCH 30/35] feat: Add Volcano Engine DNS API (#7069) * feat: add volcengine dns api * fix(volcengine): address review findings and fix record matching Code review fixes: - fix format string usage in signature computation (use printf %b / %s) - clear _H1.._H5 header state at start of request to avoid leaking conditionally-set headers into subsequent requests - check ListZones return status in _get_root - document Volcengine_SESSION_TOKEN option and fix duplicate "and" typo - fix Docs and Issues sections - remove and update some code comments Functional fixes: - stop matching ListRecords results by FQDN string: Volcengine lowercases the Host/FQDN in responses, so a case-sensitive compare against $fulldomain failed for mixed-case names, making rm silently skip deletion and add lose idempotency. ListRecords is already filtered by ZID+Host+Value+SearchMode:exact, so just extract RecordID from the result instead. - reset _record_id at the start of add/rm to avoid stale state leaking across calls within the same process - tag created records with Remark "acme.sh" for easier identification - adjust debug levels: hide Authorization header behind _debug2, surface response at _debug Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: wenxuan70 Co-authored-by: Claude Opus 4.8 --- dnsapi/dns_volcengine.sh | 297 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100755 dnsapi/dns_volcengine.sh diff --git a/dnsapi/dns_volcengine.sh b/dnsapi/dns_volcengine.sh new file mode 100755 index 00000000..2cc805d5 --- /dev/null +++ b/dnsapi/dns_volcengine.sh @@ -0,0 +1,297 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_volcengine_info='Volcano Engine DNS API +Site: https://www.volcengine.com/docs/6758/155086 +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_volcengine +Options: + Volcengine_ACCESS_KEY_ID API Key ID + Volcengine_SECRET_ACCESS_KEY API Secret + Volcengine_SESSION_TOKEN Session Token. Optional, only needed when using temporary STS credentials. +Issues: github.com/acmesh-official/acme.sh/issues/7064 +' + +Volcengine_HOST="dns.volcengineapi.com" +Volcengine_URL="https://$Volcengine_HOST" + +######## Public functions ##################### + +#fulldomain txtvalue +dns_volcengine_add() { + fulldomain=$1 + txtvalue=$2 + _record_id="" + + Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}" + Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}" + + if [ -z "$Volcengine_ACCESS_KEY_ID" ] || [ -z "$Volcengine_SECRET_ACCESS_KEY" ]; then + Volcengine_ACCESS_KEY_ID="" + Volcengine_SECRET_ACCESS_KEY="" + _err "You haven't specified the volcengine dns api key id and api key secret yet." + return 1 + fi + + #save the api key and email to the account conf file. + _saveaccountconf_mutable Volcengine_ACCESS_KEY_ID "$Volcengine_ACCESS_KEY_ID" + _saveaccountconf_mutable Volcengine_SECRET_ACCESS_KEY "$Volcengine_SECRET_ACCESS_KEY" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + _sleep 1 + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + # _info "Getting existing records for $fulldomain" + if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then + _sleep 1 + return 1 + fi + + # ListRecords already filtered by ZID + Host + Value + SearchMode:exact, + # so any returned record is our target. Don't match on FQDN: Volcengine + # lowercases the Host/FQDN in the response, which would break a + # case-sensitive string compare against $fulldomain. + _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')" + _debug "_record_id" "$_record_id" + + if [ "$_record_id" ] && _contains "$response" "$txtvalue"; then + _info "The TXT record already exists. Skipping." + _sleep 1 + return 0 + fi + + _debug "Adding records" + + if volcengine_rest POST "" "Action=CreateRecord&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"Remark\":\"acme.sh\"}"; then + _info "TXT record updated successfully." + _sleep 1 + return 0 + fi + + _sleep 1 + return 1 +} + +#fulldomain txtvalue +dns_volcengine_rm() { + fulldomain=$1 + txtvalue=$2 + _record_id="" + + Volcengine_ACCESS_KEY_ID="${Volcengine_ACCESS_KEY_ID:-$(_readaccountconf_mutable Volcengine_ACCESS_KEY_ID)}" + Volcengine_SECRET_ACCESS_KEY="${Volcengine_SECRET_ACCESS_KEY:-$(_readaccountconf_mutable Volcengine_SECRET_ACCESS_KEY)}" + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + _sleep 1 + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Getting existing records for $fulldomain" + + if ! volcengine_rest POST "" "Action=ListRecords&Version=2018-08-01" "{\"ZID\":$_domain_id,\"Host\":\"$_sub_domain\",\"Type\":\"TXT\",\"Value\":\"$txtvalue\",\"SearchMode\":\"exact\"}"; then + _sleep 1 + return 1 + fi + + # ListRecords already filtered by ZID + Host + Value + SearchMode:exact, + # so any returned record is our target. Don't match on FQDN: Volcengine + # lowercases the Host/FQDN in the response, which would break a + # case-sensitive string compare against $fulldomain. + _record_id="$(echo "$response" | _egrep_o "\"RecordID\":\"[0-9]+\"," | cut -d: -f2 | cut -d, -f1 | tr -d '"')" + _debug "_record_id" "$_record_id" + + if [ -z "$_record_id" ]; then + _debug "no records exist, skip" + _sleep 1 + return 0 + fi + + if volcengine_rest POST "" "Action=DeleteRecord&Version=2018-08-01" "{\"RecordID\":\"$_record_id\"}"; then + _info "TXT record deleted successfully." + _sleep 1 + return 0 + fi + _sleep 1 + return 1 +} + +#################### Private functions below ################################## + +_get_root() { + domain=$1 + i=1 + p=1 + + # iterate over names (a.b.c.d -> b.c.d -> c.d -> d) + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + _debug "Checking domain: $h" + if [ -z "$h" ]; then + _err "invalid domain" + return 1 + fi + + # iterate over paginated result for list_hosted_zones + if ! volcengine_rest POST "" "Action=ListZones&Version=2018-08-01" "{\"Key\":\"$h\",\"SearchMode\":\"exact\"}"; then + return 1 + fi + if _contains "$response" "\"ZoneName\":\"$h\""; then + _domain_id=$(printf "%s" "$response" | _egrep_o "\"ZID\":[0-9]+," | cut -d: -f2 | cut -d, -f1) + if [ "$_domain_id" ]; then + _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") + _domain=$h + return 0 + fi + _err "Can't find domain with id: $h" + return 1 + fi + p=$i + i=$(_math "$i" + 1) + done + return 1 +} + +#method uri qstr data +volcengine_rest() { + mtd="$1" + ep="$2" + qsr="$3" + data="$4" + + _debug mtd "$mtd" + _debug ep "$ep" + _debug qsr "$qsr" + _debug data "$data" + + # clear any header state left over from a previous request so that + # conditionally-set headers (e.g. x-content-sha256, x-security-token) + # can't leak into the next request + _H1="" + _H2="" + _H3="" + _H4="" + _H5="" + + CanonicalURI="/$ep" + _debug2 CanonicalURI "$CanonicalURI" + + CanonicalQueryString="$qsr" + _debug2 CanonicalQueryString "$CanonicalQueryString" + + RequestDate="$(date -u +"%Y%m%dT%H%M%SZ")" + _debug2 RequestDate "$RequestDate" + + Hash="sha256" + + _H1="X-Date: $RequestDate" + _debug2 _H1 "$_H1" + + volcengine_host="$Volcengine_HOST" + CanonicalHeaders="host:$volcengine_host\n" + SignedHeaders="host" + + if [ -n "$data" ]; then + XContentSha256="$(printf "%s" "$data" | _digest "$Hash" hex)" + _H4="x-content-sha256: $XContentSha256" + _debug2 _H4 "$_H4" + + CanonicalHeaders="${CanonicalHeaders}x-content-sha256:$XContentSha256\n" + SignedHeaders="${SignedHeaders};x-content-sha256" + fi + + CanonicalHeaders="${CanonicalHeaders}x-date:$RequestDate\n" + SignedHeaders="${SignedHeaders};x-date" + + if [ -n "$Volcengine_SESSION_TOKEN" ]; then + _H3="x-security-token: $Volcengine_SESSION_TOKEN" + CanonicalHeaders="${CanonicalHeaders}x-security-token:$Volcengine_SESSION_TOKEN\n" + SignedHeaders="${SignedHeaders};x-security-token" + fi + + _debug2 CanonicalHeaders "$CanonicalHeaders" + _debug2 SignedHeaders "$SignedHeaders" + + RequestPayload="$data" + _debug2 RequestPayload "$RequestPayload" + + CanonicalRequest="$mtd\n$CanonicalURI\n$CanonicalQueryString\n$CanonicalHeaders\n$SignedHeaders\n$(printf "%s" "$RequestPayload" | _digest "$Hash" hex)" + _debug2 CanonicalRequest "$CanonicalRequest" + + HashedCanonicalRequest="$(printf '%b' "$CanonicalRequest" | _digest "$Hash" hex)" + _debug2 HashedCanonicalRequest "$HashedCanonicalRequest" + + Algorithm="HMAC-SHA256" + _debug2 Algorithm "$Algorithm" + + RequestDateOnly="$(echo "$RequestDate" | cut -c 1-8)" + _debug2 RequestDateOnly "$RequestDateOnly" + + Region="cn-beijing" + Service="dns" + + CredentialScope="$RequestDateOnly/$Region/$Service/request" + _debug2 CredentialScope "$CredentialScope" + + StringToSign="$Algorithm\n$RequestDate\n$CredentialScope\n$HashedCanonicalRequest" + + _debug2 StringToSign "$StringToSign" + + kSecret="$Volcengine_SECRET_ACCESS_KEY" + + _secure_debug2 kSecret "$kSecret" + + kSecretH="$(printf "%s" "$kSecret" | _hex_dump | tr -d " ")" + _secure_debug2 kSecretH "$kSecretH" + + kDateH="$(printf "%s" "$RequestDateOnly" | _hmac "$Hash" "$kSecretH" hex)" + _debug2 kDateH "$kDateH" + + kRegionH="$(printf "%s" "$Region" | _hmac "$Hash" "$kDateH" hex)" + _debug2 kRegionH "$kRegionH" + + kServiceH="$(printf "%s" "$Service" | _hmac "$Hash" "$kRegionH" hex)" + _debug2 kServiceH "$kServiceH" + + kSigningH="$(printf "%s" "request" | _hmac "$Hash" "$kServiceH" hex)" + _debug2 kSigningH "$kSigningH" + + signature="$(printf '%b' "$StringToSign" | _hmac "$Hash" "$kSigningH" hex)" + _debug2 signature "$signature" + + Authorization="$Algorithm Credential=$Volcengine_ACCESS_KEY_ID/$CredentialScope, SignedHeaders=$SignedHeaders, Signature=$signature" + _debug2 Authorization "$Authorization" + + _H2="Authorization: $Authorization" + _debug2 _H2 "$_H2" + + url="$Volcengine_URL/$ep" + if [ "$qsr" ]; then + url="$Volcengine_URL/$ep?$qsr" + fi + + if [ "$mtd" = "GET" ]; then + response="$(_get "$url")" + else + response="$(_post "$data" "$url" "" "POST" "application/json")" + fi + + _ret="$?" + _debug response "$response" + if [ "$_ret" = "0" ]; then + if _contains "$response" "\"Error\":{"; then + _err "Response error:$response" + return 1 + fi + fi + + return "$_ret" +} From f03895819262753b8b9699437967daadb4aeb891 Mon Sep 17 00:00:00 2001 From: CZECHIA-COM Date: Fri, 3 Jul 2026 12:12:25 +0200 Subject: [PATCH 31/35] fix(dns_czechia): read _normalizeJson input from stdin, not as an argument (#7077) _normalizeJson reads its JSON from stdin (sed | sed | tr) and ignores any positional argument. dns_czechia_add() called it as `_normalizeJson "$_res"`, so the response was discarded and the inner sed blocked reading from stdin. When issuing for a single domain, or for a record that already exists, the "already exists" branch returns early and never reaches this call, which is why the bug stayed hidden. With multiple domains, the first record often short-circuits on "already exists" while the next, freshly-added record reaches the broken call and hangs on interactive runs (or consumes unrelated stdin non-interactively). Pipe the response into _normalizeJson via stdin, matching dns_czechia_rm() and every other dnsapi plugin. Co-authored-by: Claude Opus 4.8 (1M context) --- dnsapi/dns_czechia.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnsapi/dns_czechia.sh b/dnsapi/dns_czechia.sh index f0f4c32e..e2ffcf50 100644 --- a/dnsapi/dns_czechia.sh +++ b/dnsapi/dns_czechia.sh @@ -76,7 +76,7 @@ dns_czechia_add() { return 0 fi - _nres="$(_normalizeJson "$_res")" + _nres="$(printf '%s' "$_res" | _normalizeJson)" if [ "$?" -ne 0 ] || [ -z "$_nres" ]; then _nres="$_res" fi From 01d6d469148ac98949787355ff6156562163154d Mon Sep 17 00:00:00 2001 From: ZeroSSL-Andreas Date: Fri, 19 Jun 2026 17:06:57 +0200 Subject: [PATCH 32/35] New Banner Updated README to include responsive images for dark and light modes. --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 44a73e83..b93a8a50 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,21 @@

- - zerossl.com - + + + + + + + + ZeroSSL + +

🔐 acme.sh

From 9900adb0076f88ca943b0f527662aa5d9f208e50 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 19:01:10 +0800 Subject: [PATCH 33/35] fix https://github.com/acmesh-official/acme.sh/issues/4756 --- acme.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/acme.sh b/acme.sh index ad7a6325..1b3af51f 100755 --- a/acme.sh +++ b/acme.sh @@ -7199,6 +7199,12 @@ install() { if [ "$_DEFAULT_CERT_HOME" != "$CERT_HOME" ]; then _saveaccountconf "CERT_HOME" "$CERT_HOME" + # Create the custom cert home now instead of on first issuance, so the + # user can see --install honored it. + # https://github.com/acmesh-official/acme.sh/issues/4756 + if [ ! -d "$CERT_HOME" ]; then + mkdir -p "$CERT_HOME" + fi fi if [ "$_DEFAULT_ACCOUNT_KEY_PATH" != "$ACCOUNT_KEY_PATH" ]; then From 1241649501437ca0603703f4c8190a15b8a23313 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 19:38:21 +0800 Subject: [PATCH 34/35] fix https://github.com/acmesh-official/acme.sh/issues/7009 --- acme.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/acme.sh b/acme.sh index 1b3af51f..5529fe88 100755 --- a/acme.sh +++ b/acme.sh @@ -5838,6 +5838,14 @@ renew() { fi _info "Renewing using Le_API=$Le_API" + # Honor --local-address given on the renew/renewAll command line: it overrides + # the value saved at issue time (and gets re-saved by issue() below), so certs + # issued before the machine gained multiple addresses can still be renewed. + # https://github.com/acmesh-official/acme.sh/issues/7009 + if [ "$_local_address" ]; then + Le_LocalAddress="$_local_address" + fi + _clearAPI _clearCA export ACME_DIRECTORY="$Le_API" From eabd23a55164c9a75044573584d24a8505666fa1 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 3 Jul 2026 19:38:42 +0800 Subject: [PATCH 35/35] fix https://github.com/acmesh-official/acme.sh/issues/6963 --- dnsapi/dns_namecheap.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_namecheap.sh b/dnsapi/dns_namecheap.sh index 5527b357..cca59735 100755 --- a/dnsapi/dns_namecheap.sh +++ b/dnsapi/dns_namecheap.sh @@ -264,8 +264,16 @@ _set_namecheap_TXT() { _debug hosts "$hosts" if [ -z "$hosts" ]; then - _err "Hosts not found" - return 1 + # An empty host list is only acceptable when the API positively confirms + # a successful getHosts reply: setHosts below REPLACES all records, so + # proceeding on a malformed/unparsed response would wipe the whole zone. + # https://github.com/acmesh-official/acme.sh/issues/6963 + if _contains "$response" "Status=\"OK\"" && _contains "$response" "DomainDNSGetHostsResult"; then + _debug "No existing host records, adding the TXT record as the first one" + else + _err "Hosts not found" + return 1 + fi fi _namecheap_reset_hostList