From e4c0a4f5f59140a926ceb2f2936075f4aa0f1f8d Mon Sep 17 00:00:00 2001 From: Kenny Kropp Date: Tue, 15 Sep 2026 14:56:59 +0200 Subject: [PATCH 01/22] add bergdns.at support --- dnsapi/dns_bergdns.sh | 252 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 dnsapi/dns_bergdns.sh diff --git a/dnsapi/dns_bergdns.sh b/dnsapi/dns_bergdns.sh new file mode 100644 index 00000000..7c3e2864 --- /dev/null +++ b/dnsapi/dns_bergdns.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_bergdns_info='bergdns.at +Site: bergdns.at +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_bergdns +Options: + BERGDNS_API_KEY API key, as issued in the account UI. Needs read (to find the zone and the record) and write over the challenge names. + BERGDNS_API_URL API base URL. Optional. Default "https://bergdns.at/v1". + BERGDNS_TTL TTL of the challenge record, in seconds. Optional. Default "60". + BERGDNS_PROPAGATION_TIMEOUT Seconds to wait for the record to reach every secondary. Optional. Default "120". "0" does not wait. +Author: Kenny Kropp +' + +_BERGDNS_DEFAULT_URL='https://bergdns.at/v1' +_BERGDNS_DEFAULT_TTL='60' +_BERGDNS_DEFAULT_WAIT='120' + +######## Public functions #################################################### + +# Usage: dns_bergdns_add _acme-challenge.www.example.com "token" +# The value is added to the RRset, so a domain and its wildcard can be +# validated at the same time. +dns_bergdns_add() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + _bergdns_init || return 1 + _bergdns_find_zone "$fulldomain" || return 1 + _bergdns_find_rrset "$fulldomain" || return 1 + + _info "Adding TXT $fulldomain in zone $_bergdns_zone_name" + if [ -z "$_bergdns_rrset_id" ]; then + if _bergdns_rest POST "zones/$_bergdns_zone_id/rrsets" \ + "{\"name\":\"$fulldomain\",\"type\":\"TXT\",\"ttl\":$BERGDNS_TTL,\"records\":[\"\\\"$txtvalue\\\"\"]}"; then + _bergdns_rrset_id=$(echo "$response" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _bergdns_wait "$fulldomain" + return 0 + fi + if [ "$_bergdns_code" != "rrset_exists" ]; then + _err "bergdns: could not add the challenge record: $_bergdns_error" + return 1 + fi + # another run created it since the lookup above + _bergdns_find_rrset "$fulldomain" || return 1 + if [ -z "$_bergdns_rrset_id" ]; then + _err "bergdns: could not find the challenge record at $fulldomain" + return 1 + fi + fi + + if ! _bergdns_rest POST \ + "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/records" \ + "{\"records\":[\"\\\"$txtvalue\\\"\"]}"; then + _err "bergdns: could not add the challenge record: $_bergdns_error" + return 1 + fi + + _bergdns_wait "$fulldomain" +} + +# Usage: dns_bergdns_rm _acme-challenge.www.example.com "token" +# Only this value is removed. Removing the last value deletes the RRset, and +# removing a value that does not exist is not an error. +dns_bergdns_rm() { + fulldomain=$(echo "$1" | _lower_case) + txtvalue=$2 + + _bergdns_init || return 1 + _bergdns_find_zone "$fulldomain" || return 1 + _bergdns_find_rrset "$fulldomain" || return 1 + + if [ -z "$_bergdns_rrset_id" ]; then + _info "bergdns: no TXT records at $fulldomain, nothing to remove" + return 0 + fi + + _info "Removing TXT $fulldomain from zone $_bergdns_zone_name" + if ! _bergdns_rest DELETE \ + "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/records" \ + "{\"records\":[\"\\\"$txtvalue\\\"\"]}"; then + if [ "$_bergdns_code" = "rrset_not_found" ]; then + return 0 + fi + _err "bergdns: could not remove the challenge record: $_bergdns_error" + return 1 + fi + return 0 +} + +######## Private functions ################################################### + +_bergdns_init() { + BERGDNS_API_KEY="${BERGDNS_API_KEY:-$(_readaccountconf_mutable BERGDNS_API_KEY)}" + BERGDNS_API_URL="${BERGDNS_API_URL:-$(_readaccountconf_mutable BERGDNS_API_URL)}" + BERGDNS_TTL="${BERGDNS_TTL:-$(_readaccountconf_mutable BERGDNS_TTL)}" + BERGDNS_PROPAGATION_TIMEOUT="${BERGDNS_PROPAGATION_TIMEOUT:-$(_readaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT)}" + + if [ -z "$BERGDNS_API_KEY" ]; then + BERGDNS_API_KEY="" + _clearaccountconf_mutable BERGDNS_API_KEY + _err "You have not set BERGDNS_API_KEY. Create a key in the bergdns UI and export it:" + _err " export BERGDNS_API_KEY=\"bgd_...\"" + return 1 + fi + + [ -n "$BERGDNS_API_URL" ] || BERGDNS_API_URL="$_BERGDNS_DEFAULT_URL" + [ -n "$BERGDNS_TTL" ] || BERGDNS_TTL="$_BERGDNS_DEFAULT_TTL" + [ -n "$BERGDNS_PROPAGATION_TIMEOUT" ] || BERGDNS_PROPAGATION_TIMEOUT="$_BERGDNS_DEFAULT_WAIT" + + # strip trailing slashes + BERGDNS_API_URL=$(echo "$BERGDNS_API_URL" | sed 's#/*$##') + + _saveaccountconf_mutable BERGDNS_API_KEY "$BERGDNS_API_KEY" + _saveaccountconf_mutable BERGDNS_API_URL "$BERGDNS_API_URL" + _saveaccountconf_mutable BERGDNS_TTL "$BERGDNS_TTL" + _saveaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT "$BERGDNS_PROPAGATION_TIMEOUT" + return 0 +} + +# Usage: _bergdns_find_zone _acme-challenge.www.example.com +# Sets _bergdns_zone_id and _bergdns_zone_name. +# Zones are addressed by an id, not by name, so the zone list is fetched once +# and the longest matching zone name wins. +_bergdns_find_zone() { + _fqdn=$1 + _bergdns_zone_id="" + _bergdns_zone_name="" + + if ! _bergdns_rest GET "zones"; then + _err "bergdns: could not list zones: $_bergdns_error" + return 1 + fi + + # one zone object per line, so id and name stay together + _zone_lines=$(echo "$response" | tr '{' '\n' | grep '"name":"') + + _candidate="$_fqdn" + while [ -n "$_candidate" ]; do + _line=$(echo "$_zone_lines" | grep -F "\"name\":\"$_candidate\"," | _head_n 1) + if [ -n "$_line" ]; then + _bergdns_zone_id=$(echo "$_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _bergdns_zone_name="$_candidate" + _debug _bergdns_zone_id "$_bergdns_zone_id" + _debug _bergdns_zone_name "$_bergdns_zone_name" + [ -n "$_bergdns_zone_id" ] && return 0 + break + fi + case "$_candidate" in + *.*) _candidate=${_candidate#*.} ;; + *) _candidate="" ;; + esac + done + + _err "bergdns: no zone in this account holds $_fqdn." + _err "bergdns: the key must be able to read the zone as well as write the record." + return 1 +} + +# Usage: _bergdns_find_rrset _acme-challenge.www.example.com +# Sets _bergdns_rrset_id to the id of the TXT RRset at that name, or to an +# empty string if there is none. Records are addressed by id, so the zone's +# RRsets are listed to find it. +_bergdns_find_rrset() { + _fqdn=$1 + _bergdns_rrset_id="" + + if ! _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets"; then + _err "bergdns: could not list the records of $_bergdns_zone_name: $_bergdns_error" + return 1 + fi + + _line=$(echo "$response" | tr '{' '\n' | grep -F "\"name\":\"$_fqdn\",\"type\":\"TXT\"," | _head_n 1) + if [ -n "$_line" ]; then + _bergdns_rrset_id=$(echo "$_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + fi + _debug _bergdns_rrset_id "$_bergdns_rrset_id" + return 0 +} + +# Usage: _bergdns_wait _acme-challenge.www.example.com +# Polls the propagation endpoint until all bergdns nameservers serve the +# record. A timeout is logged but does not fail the issuance. +_bergdns_wait() { + _fqdn=$1 + if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "0" ] || [ -z "$_bergdns_rrset_id" ]; then + return 0 + fi + + _waited=0 + while [ "$_waited" -lt "$BERGDNS_PROPAGATION_TIMEOUT" ]; do + if _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/propagation"; then + case "$response" in + *'"propagated":true'*) + _info "bergdns: $_fqdn is served by every secondary after ${_waited}s" + return 0 + ;; + esac + elif [ "$_bergdns_code" = "propagation_unavailable" ]; then + # propagation checks are not configured on this server + _info "bergdns: this deployment does not offer propagation checks; not waiting" + return 0 + fi + _sleep 5 + _waited=$((_waited + 5)) + done + + _info "bergdns: $_fqdn was not on every secondary after ${BERGDNS_PROPAGATION_TIMEOUT}s; continuing anyway" + return 0 +} + +# Usage: _bergdns_rest method endpoint [body] +# Sets response. On failure also sets _bergdns_error and _bergdns_code. +_bergdns_rest() { + _method=$1 + _endpoint=$2 + _body=$3 + _bergdns_error="" + _bergdns_code="" + + export _H1="Authorization: Bearer $BERGDNS_API_KEY" + export _H2="Accept: application/json" + + _url="$BERGDNS_API_URL/$_endpoint" + _debug _url "$_url" + + if [ "$_method" = "GET" ]; then + response="$(_get "$_url")" + else + _debug2 _body "$_body" + response="$(_post "$_body" "$_url" "" "$_method" "application/json")" + fi + _ret="$?" + _debug2 response "$response" + + if [ "$_ret" != "0" ]; then + _bergdns_error="the request to $_url could not be made" + return 1 + fi + + # Errors are returned as RFC 9457 problem+json, which always has "status" and + # "code". Successful responses have neither, so the body is checked instead + # of the HTTP status code. + case "$response" in + *'"status":'*'"code":"'* | *'"code":"'*'"status":'*) + _bergdns_error=$(echo "$response" | sed -n 's/.*"detail":"\([^"]*\)".*/\1/p') + [ -n "$_bergdns_error" ] || _bergdns_error="$response" + _bergdns_code=$(echo "$response" | sed -n 's/.*"code":"\([^"]*\)".*/\1/p') + return 1 + ;; + esac + return 0 +} From 0ee2720d731b341bde1428a6008b694a89c1c26d Mon Sep 17 00:00:00 2001 From: Kenny Kropp Date: Wed, 16 Sep 2026 08:30:13 +0200 Subject: [PATCH 02/22] add bergdns.at provider info block values --- dnsapi/dns_bergdns.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/dnsapi/dns_bergdns.sh b/dnsapi/dns_bergdns.sh index 7c3e2864..cfb9b519 100644 --- a/dnsapi/dns_bergdns.sh +++ b/dnsapi/dns_bergdns.sh @@ -8,6 +8,7 @@ Options: BERGDNS_API_URL API base URL. Optional. Default "https://bergdns.at/v1". BERGDNS_TTL TTL of the challenge record, in seconds. Optional. Default "60". BERGDNS_PROPAGATION_TIMEOUT Seconds to wait for the record to reach every secondary. Optional. Default "120". "0" does not wait. +Issues: github.com/acmesh-official/acme.sh/issues/7261 Author: Kenny Kropp ' From 84d6b5841e431477bcb3b012269e0facd29f0ddd Mon Sep 17 00:00:00 2001 From: Kenny Kropp Date: Sat, 19 Sep 2026 11:29:08 +0200 Subject: [PATCH 03/22] fix pr comment issues --- dnsapi/dns_bergdns.sh | 187 ++++++++++++++++++++++++++++++------------ 1 file changed, 134 insertions(+), 53 deletions(-) diff --git a/dnsapi/dns_bergdns.sh b/dnsapi/dns_bergdns.sh index cfb9b519..8e3caac1 100644 --- a/dnsapi/dns_bergdns.sh +++ b/dnsapi/dns_bergdns.sh @@ -7,14 +7,14 @@ Options: BERGDNS_API_KEY API key, as issued in the account UI. Needs read (to find the zone and the record) and write over the challenge names. BERGDNS_API_URL API base URL. Optional. Default "https://bergdns.at/v1". BERGDNS_TTL TTL of the challenge record, in seconds. Optional. Default "60". - BERGDNS_PROPAGATION_TIMEOUT Seconds to wait for the record to reach every secondary. Optional. Default "120". "0" does not wait. + BERGDNS_PROPAGATION_TIMEOUT Seconds to wait for the record to reach every secondary. Optional. Default "60". "0" does not wait. Issues: github.com/acmesh-official/acme.sh/issues/7261 Author: Kenny Kropp ' _BERGDNS_DEFAULT_URL='https://bergdns.at/v1' _BERGDNS_DEFAULT_TTL='60' -_BERGDNS_DEFAULT_WAIT='120' +_BERGDNS_DEFAULT_WAIT='60' ######## Public functions #################################################### @@ -34,6 +34,7 @@ dns_bergdns_add() { if _bergdns_rest POST "zones/$_bergdns_zone_id/rrsets" \ "{\"name\":\"$fulldomain\",\"type\":\"TXT\",\"ttl\":$BERGDNS_TTL,\"records\":[\"\\\"$txtvalue\\\"\"]}"; then _bergdns_rrset_id=$(echo "$response" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _debug _bergdns_rrset_id "$_bergdns_rrset_id" _bergdns_wait "$fulldomain" return 0 fi @@ -79,7 +80,11 @@ dns_bergdns_rm() { if ! _bergdns_rest DELETE \ "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/records" \ "{\"records\":[\"\\\"$txtvalue\\\"\"]}"; then - if [ "$_bergdns_code" = "rrset_not_found" ]; then + # Any flavour of not-found is a cleanup that has already happened: the + # RRset was removed by a previous run, or by the other half of a + # domain-and-wildcard pair taking the last value with it. + if [ "$_bergdns_status" = "404" ]; then + _info "bergdns: $fulldomain holds no such record any more, nothing to remove" return 0 fi _err "bergdns: could not remove the challenge record: $_bergdns_error" @@ -111,10 +116,30 @@ _bergdns_init() { # strip trailing slashes BERGDNS_API_URL=$(echo "$BERGDNS_API_URL" | sed 's#/*$##') + # The TTL is interpolated into the request body and the timeout is counted + # down in arithmetic, so a stray value from the environment or from an old + # account.conf has to be caught here rather than become malformed JSON and + # an opaque 400. + case "$BERGDNS_TTL" in + *[!0-9]* | '') + _err "bergdns: BERGDNS_TTL must be a number of seconds, not \"$BERGDNS_TTL\"." + return 1 + ;; + esac + case "$BERGDNS_PROPAGATION_TIMEOUT" in + *[!0-9]* | '') + _err "bergdns: BERGDNS_PROPAGATION_TIMEOUT must be a number of seconds, not \"$BERGDNS_PROPAGATION_TIMEOUT\"." + return 1 + ;; + esac + _saveaccountconf_mutable BERGDNS_API_KEY "$BERGDNS_API_KEY" - _saveaccountconf_mutable BERGDNS_API_URL "$BERGDNS_API_URL" - _saveaccountconf_mutable BERGDNS_TTL "$BERGDNS_TTL" - _saveaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT "$BERGDNS_PROPAGATION_TIMEOUT" + # Only what the user actually chose is written back. Persisting a default + # would pin the install to today's value, and a later change to the shipped + # one -- a move of the API base above all -- would never reach it. + [ "$BERGDNS_API_URL" = "$_BERGDNS_DEFAULT_URL" ] || _saveaccountconf_mutable BERGDNS_API_URL "$BERGDNS_API_URL" + [ "$BERGDNS_TTL" = "$_BERGDNS_DEFAULT_TTL" ] || _saveaccountconf_mutable BERGDNS_TTL "$BERGDNS_TTL" + [ "$BERGDNS_PROPAGATION_TIMEOUT" = "$_BERGDNS_DEFAULT_WAIT" ] || _saveaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT "$BERGDNS_PROPAGATION_TIMEOUT" return 0 } @@ -123,7 +148,7 @@ _bergdns_init() { # Zones are addressed by an id, not by name, so the zone list is fetched once # and the longest matching zone name wins. _bergdns_find_zone() { - _fqdn=$1 + _bergdns_fqdn=$1 _bergdns_zone_id="" _bergdns_zone_name="" @@ -133,26 +158,26 @@ _bergdns_find_zone() { fi # one zone object per line, so id and name stay together - _zone_lines=$(echo "$response" | tr '{' '\n' | grep '"name":"') + _bergdns_zone_lines=$(echo "$response" | tr '{' '\n') - _candidate="$_fqdn" - while [ -n "$_candidate" ]; do - _line=$(echo "$_zone_lines" | grep -F "\"name\":\"$_candidate\"," | _head_n 1) - if [ -n "$_line" ]; then - _bergdns_zone_id=$(echo "$_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) - _bergdns_zone_name="$_candidate" + _bergdns_cand="$_bergdns_fqdn" + while [ -n "$_bergdns_cand" ]; do + _bergdns_line=$(echo "$_bergdns_zone_lines" | _bergdns_select "$_bergdns_cand" | _head_n 1) + if [ -n "$_bergdns_line" ]; then + _bergdns_zone_id=$(echo "$_bergdns_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _bergdns_zone_name="$_bergdns_cand" _debug _bergdns_zone_id "$_bergdns_zone_id" _debug _bergdns_zone_name "$_bergdns_zone_name" [ -n "$_bergdns_zone_id" ] && return 0 break fi - case "$_candidate" in - *.*) _candidate=${_candidate#*.} ;; - *) _candidate="" ;; + case "$_bergdns_cand" in + *.*) _bergdns_cand=${_bergdns_cand#*.} ;; + *) _bergdns_cand="" ;; esac done - _err "bergdns: no zone in this account holds $_fqdn." + _err "bergdns: no zone in this account holds $_bergdns_fqdn." _err "bergdns: the key must be able to read the zone as well as write the record." return 1 } @@ -162,7 +187,7 @@ _bergdns_find_zone() { # empty string if there is none. Records are addressed by id, so the zone's # RRsets are listed to find it. _bergdns_find_rrset() { - _fqdn=$1 + _bergdns_fqdn=$1 _bergdns_rrset_id="" if ! _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets"; then @@ -170,29 +195,62 @@ _bergdns_find_rrset() { return 1 fi - _line=$(echo "$response" | tr '{' '\n' | grep -F "\"name\":\"$_fqdn\",\"type\":\"TXT\"," | _head_n 1) - if [ -n "$_line" ]; then - _bergdns_rrset_id=$(echo "$_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _bergdns_line=$(echo "$response" | tr '{' '\n' | _bergdns_select "$_bergdns_fqdn" TXT | _head_n 1) + if [ -n "$_bergdns_line" ]; then + _bergdns_rrset_id=$(echo "$_bergdns_line" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) fi _debug _bergdns_rrset_id "$_bergdns_rrset_id" return 0 } +# Usage: ... | _bergdns_select name [type] +# Reads one JSON object per line and prints those whose "name" is name and, +# when a type is given, whose "type" is that type. The two fields are matched +# one at a time, so neither the order the server writes its keys in nor +# anything sitting between them changes the answer. +# +# The comparison is a shell case, which is literal by construction: grep -F +# does not exist on Solaris, and _contains and _startswith would read the name +# as a regular expression. Each pattern anchors on the start of the object or +# on the comma before the key, so a key that merely ends in "name" cannot +# match. +_bergdns_select() { + _bergdns_sel_name=$1 + _bergdns_sel_type=$2 + while IFS= read -r _bergdns_sel_line || [ -n "$_bergdns_sel_line" ]; do + case "$_bergdns_sel_line" in + '"name":"'"$_bergdns_sel_name"'"'* | *',"name":"'"$_bergdns_sel_name"'"'*) ;; + *) continue ;; + esac + if [ -n "$_bergdns_sel_type" ]; then + case "$_bergdns_sel_line" in + '"type":"'"$_bergdns_sel_type"'"'* | *',"type":"'"$_bergdns_sel_type"'"'*) ;; + *) continue ;; + esac + fi + printf '%s\n' "$_bergdns_sel_line" + done +} + # Usage: _bergdns_wait _acme-challenge.www.example.com # Polls the propagation endpoint until all bergdns nameservers serve the # record. A timeout is logged but does not fail the issuance. +# +# This covers the zone transfer from the primary to the secondaries, which +# takes seconds; the resolver side is acme.sh's own _check_dns_entries, which +# runs after every record has been added and has a timeout of its own. _bergdns_wait() { - _fqdn=$1 + _bergdns_fqdn=$1 if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "0" ] || [ -z "$_bergdns_rrset_id" ]; then return 0 fi - _waited=0 - while [ "$_waited" -lt "$BERGDNS_PROPAGATION_TIMEOUT" ]; do + _bergdns_waited=0 + while [ "$_bergdns_waited" -lt "$BERGDNS_PROPAGATION_TIMEOUT" ]; do if _bergdns_rest GET "zones/$_bergdns_zone_id/rrsets/$_bergdns_rrset_id/propagation"; then case "$response" in *'"propagated":true'*) - _info "bergdns: $_fqdn is served by every secondary after ${_waited}s" + _info "bergdns: $_bergdns_fqdn is served by every secondary after ${_bergdns_waited}s" return 0 ;; esac @@ -200,54 +258,77 @@ _bergdns_wait() { # propagation checks are not configured on this server _info "bergdns: this deployment does not offer propagation checks; not waiting" return 0 + else + case "$_bergdns_status" in + 429) ;; # rate limited, worth another go + 4*) + # The check is refused rather than pending, and waiting will not + # change that. _check_dns_entries still has to pass, so this is not + # the place to fail the issuance. + _info "bergdns: the propagation check is unavailable ($_bergdns_error); not waiting" + return 0 + ;; + esac fi _sleep 5 - _waited=$((_waited + 5)) + _bergdns_waited=$((_bergdns_waited + 5)) done - _info "bergdns: $_fqdn was not on every secondary after ${BERGDNS_PROPAGATION_TIMEOUT}s; continuing anyway" + _info "bergdns: $_bergdns_fqdn was not on every secondary after ${BERGDNS_PROPAGATION_TIMEOUT}s; continuing anyway" return 0 } # Usage: _bergdns_rest method endpoint [body] -# Sets response. On failure also sets _bergdns_error and _bergdns_code. +# Sets response and _bergdns_status. On failure also sets _bergdns_error and, +# where the API itself answered, _bergdns_code. _bergdns_rest() { - _method=$1 - _endpoint=$2 - _body=$3 + _bergdns_method=$1 + _bergdns_endpoint=$2 + _bergdns_body=$3 _bergdns_error="" _bergdns_code="" + _bergdns_status="" export _H1="Authorization: Bearer $BERGDNS_API_KEY" export _H2="Accept: application/json" - _url="$BERGDNS_API_URL/$_endpoint" - _debug _url "$_url" + _bergdns_url="$BERGDNS_API_URL/$_bergdns_endpoint" + _debug _bergdns_url "$_bergdns_url" - if [ "$_method" = "GET" ]; then - response="$(_get "$_url")" - else - _debug2 _body "$_body" - response="$(_post "$_body" "$_url" "" "$_method" "application/json")" + # drop the headers of the previous request, so that a request which never + # reaches the server cannot be read as carrying its status + if [ -f "$HTTP_HEADER" ]; then + : >"$HTTP_HEADER" fi - _ret="$?" + + if [ "$_bergdns_method" = "GET" ]; then + response="$(_get "$_bergdns_url")" + else + _debug2 _bergdns_body "$_bergdns_body" + response="$(_post "$_bergdns_body" "$_bergdns_url" "" "$_bergdns_method" "application/json")" + fi + _bergdns_ret="$?" _debug2 response "$response" - if [ "$_ret" != "0" ]; then - _bergdns_error="the request to $_url could not be made" + if [ "$_bergdns_ret" != "0" ]; then + _bergdns_error="the request to $_bergdns_url could not be made" return 1 fi - # Errors are returned as RFC 9457 problem+json, which always has "status" and - # "code". Successful responses have neither, so the body is checked instead - # of the HTTP status code. - case "$response" in - *'"status":'*'"code":"'* | *'"code":"'*'"status":'*) - _bergdns_error=$(echo "$response" | sed -n 's/.*"detail":"\([^"]*\)".*/\1/p') - [ -n "$_bergdns_error" ] || _bergdns_error="$response" - _bergdns_code=$(echo "$response" | sed -n 's/.*"code":"\([^"]*\)".*/\1/p') - return 1 - ;; + _bergdns_status="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug _bergdns_status "$_bergdns_status" + + # The HTTP status decides. Errors from the API itself are RFC 9457 + # problem+json and carry a "detail" to show and a stable "code" to branch on, + # but a request that never gets that far -- bergdns.at answers from behind a + # reverse proxy, whose 502 and 504 are HTML -- has neither, and reading the + # body alone would take those for success. + case "$_bergdns_status" in + 2*) return 0 ;; esac - return 0 + _bergdns_error=$(echo "$response" | _egrep_o '"detail":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + _bergdns_code=$(echo "$response" | _egrep_o '"code":"[^"]*"' | _head_n 1 | cut -d '"' -f 4) + [ -n "$_bergdns_error" ] || _bergdns_error="$_bergdns_url answered HTTP ${_bergdns_status:-(none)}" + _debug _bergdns_code "$_bergdns_code" + return 1 } From ceed0e0228015e81d4f0e1b7f44ba90d4aee73e8 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 20 Sep 2026 16:56:43 +0200 Subject: [PATCH 04/22] start 3.1.7 --- acme.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/acme.sh b/acme.sh index 9561ff76..6fd8cd9a 100755 --- a/acme.sh +++ b/acme.sh @@ -1,6 +1,6 @@ #!/usr/bin/env sh -VER=3.1.6 +VER=3.1.7 PROJECT_NAME="acme.sh" From d51d853ad43d32b2509af3e2aac4f9a809059f21 Mon Sep 17 00:00:00 2001 From: Kenny Kropp Date: Mon, 21 Sep 2026 11:53:09 +0200 Subject: [PATCH 05/22] fix default value handling --- dnsapi/dns_bergdns.sh | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/dnsapi/dns_bergdns.sh b/dnsapi/dns_bergdns.sh index 8e3caac1..8660b0d5 100644 --- a/dnsapi/dns_bergdns.sh +++ b/dnsapi/dns_bergdns.sh @@ -134,12 +134,26 @@ _bergdns_init() { esac _saveaccountconf_mutable BERGDNS_API_KEY "$BERGDNS_API_KEY" - # Only what the user actually chose is written back. Persisting a default - # would pin the install to today's value, and a later change to the shipped - # one -- a move of the API base above all -- would never reach it. - [ "$BERGDNS_API_URL" = "$_BERGDNS_DEFAULT_URL" ] || _saveaccountconf_mutable BERGDNS_API_URL "$BERGDNS_API_URL" - [ "$BERGDNS_TTL" = "$_BERGDNS_DEFAULT_TTL" ] || _saveaccountconf_mutable BERGDNS_TTL "$BERGDNS_TTL" - [ "$BERGDNS_PROPAGATION_TIMEOUT" = "$_BERGDNS_DEFAULT_WAIT" ] || _saveaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT "$BERGDNS_PROPAGATION_TIMEOUT" + # Only what the user actually chose is written back, and a value equal to + # the default clears any older setting. Persisting a default would pin the + # install to today's value, and a later change to the shipped one -- a move + # of the API base above all -- would never reach it; leaving an old setting + # in place would mean the environment could never put one back to default. + if [ "$BERGDNS_API_URL" = "$_BERGDNS_DEFAULT_URL" ]; then + _clearaccountconf_mutable BERGDNS_API_URL + else + _saveaccountconf_mutable BERGDNS_API_URL "$BERGDNS_API_URL" + fi + if [ "$BERGDNS_TTL" = "$_BERGDNS_DEFAULT_TTL" ]; then + _clearaccountconf_mutable BERGDNS_TTL + else + _saveaccountconf_mutable BERGDNS_TTL "$BERGDNS_TTL" + fi + if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "$_BERGDNS_DEFAULT_WAIT" ]; then + _clearaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT + else + _saveaccountconf_mutable BERGDNS_PROPAGATION_TIMEOUT "$BERGDNS_PROPAGATION_TIMEOUT" + fi return 0 } From 6618d55bfc9628e3a74ef28f51a880ab69426f20 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 26 Sep 2026 11:50:21 +0200 Subject: [PATCH 06/22] Decide the ARI replaces retry on the HTTP status, not on the CA's wording The retry was keyed on the message text and missed ZeroSSL, which answers HTTP 401 with 'The "replaces" field does not identify a certificate that belongs to this ACME account', so switching the ACME server failed every renewal. RFC 9773 Section 5 mandates a problem type only for the 409 "alreadyReplaced" case and leaves its other checks (same account, shared identifier) to server policy, so the wording cannot be enumerated. Judge the status code instead: _isARIReplacesRejected treats only a non-2xx response as a rejection. That also removes a false positive on the success path, where the server "MUST reflect that field in the response" and the echoed base64url certID can itself contain "ARI". fix #7280 --- acme.sh | 37 ++++++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/acme.sh b/acme.sh index 6fd8cd9a..35f19ace 100755 --- a/acme.sh +++ b/acme.sh @@ -5590,11 +5590,9 @@ issue() { _on_issue_err "$_post_hook" return 1 fi - # RFC 9773 Section 5 only defines the "alreadyReplaced" error, but real CAs - # (Let's Encrypt) may also reject with a malformed error if the prior cert - # was issued by a different issuer / different CA. Retry without "replaces" - # whenever the failure mentions ARI or the replaces field. - if [ "$_replaces_certID" ] && { _contains "$response" "alreadyReplaced" || _contains "$response" "urn:ietf:params:acme:error:malformed" || _contains "$response" "'replaces'" || _contains "$response" "ARI"; }; then + # Retry without "replaces" whenever the CA rejected that field, e.g. after + # switching the ACME server: the prior cert belongs to the old CA. + if [ "$_replaces_certID" ] && _isARIReplacesRejected "$code" "$response"; then _info "ARI 'replaces' rejected by CA, retrying newOrder without 'replaces'." if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then _err "Error creating new order." @@ -7930,6 +7928,35 @@ _getARICertID() { printf "%s.%s" "$_akiurl" "$_serurl" } +#httpcode response +#Returns 0 when a newOrder was rejected because of the ARI "replaces" field, +#so that the order can be retried without it. +#The status code decides first, and an empty code counts as "not rejected": +#an ACCEPTED order echoes the field back, since RFC 9773 Section 5 says that +#a server accepting a newOrder request with a "replaces" field "MUST reflect +#that field in the response", and the certID it carries is base64url, so the +#response of a SUCCESSFUL order can contain "replaces" and even "ARI". +#Matching on the message alone would then re-order without "replaces" and +#defeat ARI. +#Only the 409 "alreadyReplaced" type is mandated by RFC 9773 Section 5; the +#other checks it lists (same ACME account, shared identifier) are left to +#server policy, so the wording differs per CA: Let's Encrypt answers +#malformed when the prior cert was issued by a different issuer, ZeroSSL +#answers 401 with 'The "replaces" field does not identify a certificate that +#belongs to this ACME account'. +#https://github.com/acmesh-official/acme.sh/issues/7280 +_isARIReplacesRejected() { + _ari_rej_code="$1" + _ari_rej_resp="$2" + if [ -z "$_ari_rej_code" ] || _startswith "$_ari_rej_code" "2"; then + return 1 + fi + _contains "$_ari_rej_resp" "alreadyReplaced" || + _contains "$_ari_rej_resp" "replaces" || + _contains "$_ari_rej_resp" "ARI" || + _contains "$_ari_rej_resp" "urn:ietf:params:acme:error:malformed" +} + #cert _get_ARI() { _cert="$1" From 31f06a4ecfbef425e0619d1b11d572db97abcded Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 26 Sep 2026 12:14:17 +0200 Subject: [PATCH 07/22] Keep the command line account email out of account.conf's way _initpath sources account.conf (twice, counting __initHome), and the assignment overwrote the ACCOUNT_EMAIL that -m had just exported, so --update-account -m sent the saved address instead of the given one. It also short-circuited _getAccountEmail, whose first branch is meant for the live value: the saved global address then won over the per-CA CA_EMAIL as well. Save the live value before the sourcing and restore it after. The saved address is still reached, by the _readaccountconf at the end of _getAccountEmail, which is where it belongs in the order. --- acme.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/acme.sh b/acme.sh index 35f19ace..e026b9c6 100755 --- a/acme.sh +++ b/acme.sh @@ -3475,12 +3475,22 @@ _initpath() { domain="$1" _ilength="$2" + #Keep the live ACCOUNT_EMAIL, the one -m exported in _process() or the + #caller put in the environment. account.conf is sourced twice below (here + #and inside __initHome), and a sourced assignment would overwrite it with + #the saved address, so -m silently lost to whatever account.conf held. + #The saved address is not lost either way: _getAccountEmail() reads it + #with _readaccountconf as its last resort, after the per-CA CA_EMAIL. + _cli_account_email="$ACCOUNT_EMAIL" + __initHome if [ -f "$ACCOUNT_CONF_PATH" ]; then . "$ACCOUNT_CONF_PATH" fi + ACCOUNT_EMAIL="$_cli_account_email" + if [ "$_ACME_IN_CRON" ]; then if [ ! "$_USER_PATH_EXPORTED" ]; then _USER_PATH_EXPORTED=1 From 427787cb8f1406c38190c18a4e7fea5b2f27f6e9 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 26 Sep 2026 12:38:43 +0200 Subject: [PATCH 08/22] Guard the account email in __initHome too _process() calls __initHome directly once the option loop is over, and that sourcing of account.conf overwrote the address -m had just exported -- before any command function reaches _initpath. Guarding only _initpath was not enough: by then ACCOUNT_EMAIL already held the saved address, so it was saved and restored unchanged. Keep the live value across this sourcing as well. --- acme.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/acme.sh b/acme.sh index e026b9c6..e276527a 100755 --- a/acme.sh +++ b/acme.sh @@ -3365,7 +3365,13 @@ __initHome() { if [ -z "$ACCOUNT_CONF_PATH" ]; then if [ -f "$_DEFAULT_ACCOUNT_CONF_PATH" ]; then + #Same as in _initpath: keep the live ACCOUNT_EMAIL across the sourcing, + #so that the -m address _process() exported is not replaced by the + #saved one. _process() calls __initHome directly, after the option + #loop, so this is the sourcing that -m used to lose to. + _ih_account_email="$ACCOUNT_EMAIL" . "$_DEFAULT_ACCOUNT_CONF_PATH" + ACCOUNT_EMAIL="$_ih_account_email" fi fi From 6ef16ffe6378e179d0c296362cebe70e3ae97df2 Mon Sep 17 00:00:00 2001 From: neil Date: Sat, 26 Sep 2026 12:48:52 +0200 Subject: [PATCH 09/22] Save CA_EMAIL only when the CA took the contact _regAccount wrote CA_EMAIL before sending newAccount, so it recorded an address the CA never stored: for an account key it already knows, the CA answers 200 and ignores the contact of the request. A --register-account -m on an existing account then left the local config claiming an email the account does not have. Save it in the 201 branch, and in the 200/409 branch say that the email was not changed and point at --update-account -m. Nothing is printed when the given address already matches the saved one. --- acme.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index e276527a..1f2454b4 100755 --- a/acme.sh +++ b/acme.sh @@ -4436,9 +4436,11 @@ _regAccount() { _secure_debug3 _eab_kid "$_eab_kid" _secure_debug3 _eab_hmac_key "$_eab_hmac_key" _email="$(_getAccountEmail)" - if [ "$_email" ]; then - _savecaconf "CA_EMAIL" "$_email" - fi + #CA_EMAIL is saved only once the CA has actually taken the contact, which + #is when it answers 201. For an account key it already knows it answers + #200 and ignores the contact of the request, so saving here would record + #an address the CA never stored. + _saved_ca_email="$(_readcaconf CA_EMAIL)" if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then @@ -4517,8 +4519,15 @@ _regAccount() { if [ "$code" = "" ] || [ "$code" = '201' ]; then echo "$response" >"$ACCOUNT_JSON_PATH" _info "Registered" + if [ "$_email" ]; then + _savecaconf "CA_EMAIL" "$_email" + fi elif [ "$code" = '409' ] || [ "$code" = '200' ]; then _info "Already registered" + if [ "$_email" ] && [ "$_email" != "$_saved_ca_email" ]; then + _info "The account email was not changed, the CA ignores the contact of an account it already has." + _info "Use '$PROJECT_ENTRY --update-account -m $_email' to change it." + fi elif [ "$code" = '400' ] && _contains "$response" 'The account is not awaiting external account binding'; then _info "EAB already registered" _eabAlreadyBound=1 From 84a8d2a7528b571d61ec9b2d78628acf1f7f155d Mon Sep 17 00:00:00 2001 From: Marc-Pascal Clement Date: Sun, 27 Sep 2026 09:51:04 +0200 Subject: [PATCH 10/22] Add Healthchecks.io notify hook (#7282) * Add Healthchecks.io notify hook Co-Authored-By: Claude Opus 5 * notify/healthchecks: require an exact "OK" response hc-ping.com answers HTTP 200 "OK (not found)" for an unknown UUID, so the prefix match reported success for a mistyped or deleted check, including the --set-notify test ping. Only treat an exact "OK" body as success. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5 --- notify/healthchecks.sh | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 notify/healthchecks.sh diff --git a/notify/healthchecks.sh b/notify/healthchecks.sh new file mode 100644 index 00000000..90d37a0b --- /dev/null +++ b/notify/healthchecks.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env sh + +#Support Healthchecks.io (hosted or self-hosted) +#https://healthchecks.io/docs/http_api/ + +#Required: +#HEALTHCHECKS_URL="https://hc-ping.com/your-uuid" + +#Use with --notify-level 3, so a ping is sent on every cron run, even when +#all certs are skipped. Otherwise Healthchecks reports the check as down. + +healthchecks_send() { + _subject="$1" + _content="$2" + _statusCode="$3" #0: success, 1: error 2($RENEW_SKIP): skipped + _debug "_subject" "$_subject" + _debug "_content" "$_content" + _debug "_statusCode" "$_statusCode" + + HEALTHCHECKS_URL="${HEALTHCHECKS_URL:-$(_readaccountconf_mutable HEALTHCHECKS_URL)}" + if [ -z "$HEALTHCHECKS_URL" ]; then + HEALTHCHECKS_URL="" + _err "You didn't specify the Healthchecks.io ping url HEALTHCHECKS_URL yet." + _err "Example: export HEALTHCHECKS_URL=\"https://hc-ping.com/your-uuid\"" + return 1 + fi + _saveaccountconf_mutable HEALTHCHECKS_URL "$HEALTHCHECKS_URL" + + _hc_url="${HEALTHCHECKS_URL%/}" + case "$_statusCode" in + 0 | 2) ;; + 1) + _hc_url="$_hc_url/fail" + ;; + *) + _hc_url="$_hc_url/log" + ;; + esac + + _data="$_subject + +$_content" + + response="$(_post "$_data" "$_hc_url" "" "POST" "text/plain")" + + if [ "$?" = "0" ] && [ "$response" = "OK" ]; then + _info "healthchecks ping success." + return 0 + fi + _err "healthchecks ping error." + _err "$response" + return 1 +} From a1de626512b201b820d065938dc54a5fd68d1ad9 Mon Sep 17 00:00:00 2001 From: ngehrsitz <45375059+ngehrsitz@users.noreply.github.com> Date: Sun, 27 Sep 2026 09:53:49 +0200 Subject: [PATCH 11/22] Use UTF-16LE encoding in Fritzbox script (#7288) instead of the glibc specifc UTF16LE alias that does not exist libiconv --- deploy/fritzbox.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/fritzbox.sh b/deploy/fritzbox.sh index 416a4121..3c07d3af 100644 --- a/deploy/fritzbox.sh +++ b/deploy/fritzbox.sh @@ -74,9 +74,9 @@ fritzbox_deploy() { _info "Log in to the FRITZ!Box" _fritzbox_challenge="$(_get "${DEPLOY_FRITZBOX_URL}/login_sid.lua" | sed -e 's/^.*//' -e 's/<\/Challenge>.*$//')" if _exists iconv; then - _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | iconv -f ASCII -t UTF16LE | _digest md5 hex)" + _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | iconv -f ASCII -t UTF-16LE | _digest md5 hex)" elif _exists uconv; then - _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF16LE | _digest md5 hex)" + _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF-16LE | _digest md5 hex)" else _fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | perl -p -e 'use Encode qw/encode/; print encode("UTF-16LE","$_"); $_="";' | _digest md5 hex)" fi From 6467ca9764f89b4a887142ee18b05219d1cf5f8a Mon Sep 17 00:00:00 2001 From: Tao An <1250043+tao-hpu@users.noreply.github.com> Date: Sun, 27 Sep 2026 16:02:42 +0800 Subject: [PATCH 12/22] fix: keep the conf intact when writing it fails (#7247) (#7278) * fix: keep the conf intact when writing it fails _setopt() and _clear_conf() redirected the new content straight into the conf file. The redirect truncates the file before anything is written, so a failed write (no space left on device, quota, I/O error) left a 0-byte conf, _save_conf still returned 0, and the cert could not be renewed any more even after space was freed. Write the new content to a temp file next to the conf, verify it, then rename it over the conf. The temp file is created with cp -p so the mode and owner are kept. A symlinked or bind mounted conf cannot be renamed over, so it is written in place once the temp copy is known to be good. A failed write now returns 1 with an error and the conf untouched. Fixes #7247 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RL9G1DHE5yNVtkm3FFPPNX * fix: route the append through _write_conf and keep the temp file on a failed in-place write The append branch of _setopt() still wrote into the conf with >>, so a write cut short (full disk, quota) left a half written line while _setopt returned 0, and sourcing the conf failed afterwards. Build the new content in all three branches and write it once through _write_conf. This also drops the separate trailing newline append. In the symlink / bind mount fallback, a failed in-place write leaves the conf truncated. Keep the temp file in that case, since it is the only complete copy, and name it in the error. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01G7ohY7h4RUSNJ2ES14NMmY --------- Co-authored-by: Claude Fable 5.1 --- acme.sh | 75 +++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/acme.sh b/acme.sh index 1f2454b4..4bb5acb2 100755 --- a/acme.sh +++ b/acme.sh @@ -2800,6 +2800,35 @@ _sed_escape_rhs() { sed -e 's/\\/\\\\/g' -e 's/&/\\&/g' -e 's/|/\\|/g' } +#_write_conf file content +#Replace the conf file with the content. +#Redirecting straight into the conf truncates it before anything is written, so +#a failed write (e.g. no space left on device) left an empty conf and the cert +#could not be renewed any more (#7247). Write a temp file next to the conf and +#rename it over the conf only after the content is verified. +_write_conf() { + __w_conf="$1" + __w_text="$2" + __w_tmp="$__w_conf.$$.tmp" + #cp -p, so the temp file carries the mode and owner of the conf + if ! cp -p "$__w_conf" "$__w_tmp" 2>/dev/null || + ! printf -- "%s\n" "$__w_text" 2>/dev/null >"$__w_tmp" || + [ "$(cat "$__w_tmp")" != "$__w_text" ]; then + rm -f "$__w_tmp" + return 1 + fi + if [ ! -L "$__w_conf" ] && mv -f "$__w_tmp" "$__w_conf" 2>/dev/null; then + return 0 + fi + #a symlink or a bind mounted file cannot be renamed over, write in place + if ! cat "$__w_tmp" 2>/dev/null >"$__w_conf"; then + #the conf may be truncated now, the temp file is the only complete copy + _err "Cannot write $__w_conf, the new content is kept in $__w_tmp" + return 1 + fi + rm -f "$__w_tmp" +} + #setopt "file" "opt" "=" "value" [";"] _setopt() { __conf="$1" @@ -2826,39 +2855,29 @@ _setopt() { return 1 ;; esac - if [ -n "$(_tail_c 1 <"$__conf")" ]; then - echo >>"$__conf" + if ! __text="$(cat "$__conf")"; then + _err "Cannot read $__conf." + return 1 fi - + #build the new content first and write it once through _write_conf: + #redirecting straight into the conf truncates it before anything is + #written, so a failing sed (#2426) or a failing write (#7247) left a + #truncated conf, and a short append left a half written line + __sed_err="" if grep -n "^$__opt$__sep" "$__conf" >/dev/null; then _debug3 OK __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)" - text="$(cat "$__conf")" - #capture first, write only on success: redirecting sed straight into the - #conf file truncates it before sed runs, so a failing sed (e.g. on an - #unescaped special character in the value) wiped the whole conf (#2426) - if __text="$(printf -- "%s\n" "$text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then - printf -- "%s\n" "$__text" >"$__conf" - else - _err "Cannot save '$__opt' to $__conf." - return 1 - fi - + __text="$(printf -- "%s\n" "$__text" | sed "s|^$__opt$__sep.*$|$__opt$__sep$__val$__end|")" || __sed_err=1 elif grep -n "^#$__opt$__sep" "$__conf" >/dev/null; then __val="$(printf -- "%s\n" "$__val" | _sed_escape_rhs)" - text="$(cat "$__conf")" - if __text="$(printf -- "%s\n" "$text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")"; then - printf -- "%s\n" "$__text" >"$__conf" - else - _err "Cannot save '$__opt' to $__conf." - return 1 - fi - + __text="$(printf -- "%s\n" "$__text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")" || __sed_err=1 else _debug3 APP - #printf, not echo: dash's builtin echo interprets backslash escapes in - #the value and would corrupt it - printf -- "%s\n" "$__opt$__sep$__val$__end" >>"$__conf" + __text="$__text${__text:+$__nl}$__opt$__sep$__val$__end" + fi + if [ "$__sed_err" ] || ! _write_conf "$__conf" "$__text"; then + _err "Cannot save '$__opt' to $__conf." + return 1 fi _debug3 "$(grep -n "^$__opt$__sep" "$__conf")" } @@ -2888,7 +2907,11 @@ _clear_conf() { _conf_data="$(cat "$_c_c_f")" #printf, not echo: dash's builtin echo interprets backslash escapes and #would corrupt saved values that contain them on every rewrite - printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f" + if ! _conf_data="$(printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d")" || + ! _write_conf "$_c_c_f" "$_conf_data"; then + _err "Cannot clear '$_sdkey' in $_c_c_f." + return 1 + fi else _err "Config file is empty, cannot clear" fi From acbe85b7e97afbf307fe133fd86fdfadccd56177 Mon Sep 17 00:00:00 2001 From: neil Date: Sun, 27 Sep 2026 19:32:38 +0200 Subject: [PATCH 13/22] Support wget2 in the wget code path Fedora 40 and later ship wget2 as wget. Under -q it prints nothing for -S, so $HTTP_HEADER stayed empty in wget mode (ACME_USE_WGET=1, or no curl): no status code, no Replay-Nonce, no Location, and every ACME request failed. Without -q its own "HTTP response 200 OK [url]" line would be read as the status line, and a HEAD response gets no headers at all. Detect wget2 by its --version line and run it without -q (and without -d, whose demultiplexing expects wget 1.x output). _wget2_headers keeps only the header blocks of its stderr; for a response without a body, where wget2 prints no block, it rebuilds the status line from the "HTTP response" line. HEAD goes through --method HEAD --save-headers, which writes the headers into $HTTP_HEADER. wget 1.x and curl are unchanged. --- acme.sh | 57 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/acme.sh b/acme.sh index 4bb5acb2..3b093b03 100755 --- a/acme.sh +++ b/acme.sh @@ -2140,6 +2140,7 @@ _resethttp() { __HTTP_INITIALIZED="" _ACME_CURL="" _ACME_WGET="" + _ACME_WGET2="" ACME_HTTP_NO_REDIRECTS="" } @@ -2190,7 +2191,15 @@ _inithttp() { fi if [ -z "$_ACME_WGET" ] && _exists "wget"; then - _ACME_WGET="wget -q" + #wget2, the wget of Fedora 40 and later, prints nothing for -S under -q, + #so it runs without -q and _wget2_fix_header cleans up after it + _ACME_WGET2="" + if _contains "$(wget --version 2>&1 | _head_n 1)" "Wget2"; then + _ACME_WGET2=1 + _ACME_WGET="wget" + else + _ACME_WGET="wget -q" + fi if [ "$ACME_USE_IPV6_REQUESTS" ]; then _ACME_WGET="$_ACME_WGET --inet6-only " elif [ "$ACME_USE_IPV4_REQUESTS" ]; then @@ -2200,7 +2209,8 @@ _inithttp() { _ACME_WGET="$_ACME_WGET --max-redirect 0 " fi if [ "$DEBUG" ] && [ "$DEBUG" -ge "2" ]; then - if [ "$_ACME_WGET" ] && _contains "$($_ACME_WGET --help 2>&1)" "--debug"; then + #the -d demultiplexing after each request expects wget 1.x output + if [ -z "$_ACME_WGET2" ] && _contains "$($_ACME_WGET --help 2>&1)" "--debug"; then _ACME_WGET="$_ACME_WGET -d " fi fi @@ -2220,6 +2230,30 @@ _inithttp() { } +#stdin: what wget2 -S wrote to stderr. Prints the response headers the way +#curl --dump-header writes them. wget2 frames every header block with lines of +#its own ("[0] Downloading ...", "# got header ...", and after the block +#"HTTP response 200 OK [url]", which callers would take for the status line), +#and prints no header block at all for a response without a body (a 204, the +#empty 200 of a revocation): then only the status line can be rebuilt, from +#the last "HTTP [ERROR ]response" line, with HTTP/1.1 assumed. +_wget2_headers() { + _w2h_in="$(cat)" + _w2h_cr="$(printf '\r')" + _w2h_blocks="$(printf "%s\n" "$_w2h_in" | sed -n "/^HTTP /d; /^HTTP\//,/^$_w2h_cr*\$/p")" + if [ "$_w2h_blocks" ]; then + printf "%s\n" "$_w2h_blocks" + else + printf "%s\n" "$_w2h_in" | sed -n 's/^HTTP ERROR response /HTTP response /; s/^HTTP response \([0-9][0-9]*\).*$/HTTP\/1.1 \1/p' | _tail_n 1 + fi +} + +#rewrite $HTTP_HEADER after a wget2 request, see _wget2_headers +_wget2_fix_header() { + _w2f_headers="$(_wget2_headers <"$HTTP_HEADER")" + printf "%s\n" "$_w2f_headers" >"$HTTP_HEADER" +} + # body url [needbase64] [POST|PUT|DELETE] [ContentType] _post() { body="$1" @@ -2315,7 +2349,15 @@ _post() { response="$($_WGET -S -O - --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --post-data="$body" "$_post_url" 2>"$HTTP_HEADER")" fi elif [ "$httpmethod" = "HEAD" ]; then - if [ "$_postContentType" ]; then + if [ "$_ACME_WGET2" ]; then + #wget2 prints no headers for a HEAD response, not even with -S, but + #--save-headers writes them into the -O file + if [ "$_postContentType" ]; then + response="$($_WGET --method HEAD --save-headers -O "$HTTP_HEADER" --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --header "Content-Type: $_postContentType" "$_post_url" 2>/dev/null)" + else + response="$($_WGET --method HEAD --save-headers -O "$HTTP_HEADER" --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" "$_post_url" 2>/dev/null)" + fi + elif [ "$_postContentType" ]; then response="$($_WGET --spider -S -O - --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --header "Content-Type: $_postContentType" --post-data="$body" "$_post_url" 2>"$HTTP_HEADER")" else response="$($_WGET --spider -S -O - --user-agent="$USER_AGENT" --header "$_H5" --header "$_H4" --header "$_H3" --header "$_H2" --header "$_H1" --post-data="$body" "$_post_url" 2>"$HTTP_HEADER")" @@ -2341,6 +2383,9 @@ _post() { cat "$HTTP_HEADER" >&2 _sed_i '/^[^ ][^ ]/d; /^ *$/d' "$HTTP_HEADER" fi + if [ "$_ACME_WGET2" ]; then + _wget2_fix_header + fi # remove leading whitespaces from header to match curl format _sed_i 's/^ //g' "$HTTP_HEADER" else @@ -2434,6 +2479,9 @@ _post_file() { cat "$HTTP_HEADER" >&2 _sed_i '/^[^ ][^ ]/d; /^ *$/d' "$HTTP_HEADER" fi + if [ "$_ACME_WGET2" ]; then + _wget2_fix_header + fi # remove leading whitespaces from header to match curl format _sed_i 's/^ //g' "$HTTP_HEADER" else @@ -2501,6 +2549,9 @@ _get() { cat "$HTTP_HEADER" >&2 _sed_i '/^[^ ][^ ]/d; /^ *$/d' "$HTTP_HEADER" fi + if [ "$_ACME_WGET2" ]; then + _wget2_fix_header + fi # remove leading whitespaces from header to match curl format _sed_i 's/^ //g' "$HTTP_HEADER" fi From b2e6173b1a4e780533d6413b39b9eabc809c8fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakob=20N=C3=A6ss?= <102598494+InvisibleDuck@users.noreply.github.com> Date: Sat, 3 Oct 2026 07:48:58 +0200 Subject: [PATCH 14/22] fix: poweradmin dnsapi leaves hanging txt records (#7232) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: improved record txt record matching matching txt records sometimes failed if they contained certain chars causing leftover challenges in dns zone * fix(dns_poweradmin): use portable record and zone matching grep -o and grep -F are not supported by Solaris /usr/bin/grep. Split JSON objects with the core _egrep_o wrapper and match name, type and content with case, which is literal by construction. Loop over a here-doc since case inside $(...) breaks bash 3.2 and Solaris sh. Apply the same to _get_root. Drop _json_escape: hostnames and base64url challenge values cannot contain quotes or backslashes, so escaping was a no-op. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: neil Co-authored-by: Jakob Næss Co-authored-by: Claude Opus 5.5 (1M context) --- dnsapi/dns_poweradmin.sh | 52 ++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/dnsapi/dns_poweradmin.sh b/dnsapi/dns_poweradmin.sh index a4c81835..2ae86347 100644 --- a/dnsapi/dns_poweradmin.sh +++ b/dnsapi/dns_poweradmin.sh @@ -114,21 +114,32 @@ _rm_record() { full=$1 txtvalue=$2 - if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records"; then + if ! _poweradmin_rest "GET" "/api/v${POWERADMIN_API_VERSION}/zones/$_zone_id/records?type=TXT"; then _err "Failed to retrieve records" return 1 fi - # The API returns: {"success":true,"data":[{"id":..., "name":"...", "type":"TXT", "content":"...", ...}]} - _txt_record_obj=$( - printf '%s\n' "$response" | - sed 's/^.*"data":\[//; s/\],"message":.*$//' | - awk '{ gsub(/},{/, "}\n{"); print }' | - grep -F "\"name\":\"$full\"" | - grep -F "\"type\":\"TXT\"" | - grep -F "\"content\":\"$txtvalue\"" | - _head_n 1 - ) + # The API returns: {"success":true,"data":{"records":[{"id":..., "name":"...", "type":"TXT", "content":"...", ...}]},"message":"..."} + _records="$(printf '%s\n' "$response" | tr -d '\r\n' | _egrep_o '\{[^{}]*\}')" + _txt_record_obj="" + while IFS= read -r _rec || [ -n "$_rec" ]; do + case "$_rec" in + *"\"name\":\"$full\""*) ;; + *) continue ;; + esac + case "$_rec" in + *"\"type\":\"TXT\""*) ;; + *) continue ;; + esac + case "$_rec" in + *"\"content\":\"$txtvalue\""*) + _txt_record_obj="$_rec" + break + ;; + esac + done < Date: Fri, 9 Oct 2026 11:20:39 -0400 Subject: [PATCH 15/22] Add deploy hook for nginx proxy manager and NPM plus (#7267) * Add deploy hook for nginx proxy manager and npmplus Signed-off-by: invario <67800603+invario@users.noreply.github.com> * rename to nginx-proxy-manager use _post instead of raw curl support both npmplus and nginx API create certificate if it doesn't already exist json_encode values evaluate and report server return code Signed-off-by: invario <67800603+invario@users.noreply.github.com> * - removed jq dependency, replaced with _egrep_o - fix _secure_debug output for _npm_token - remove newline from _npm_certname_json - remove unused variables - escape backslashes in passwords - unset _H1 before returning - tested on nginx proxy manager (image jc21/nginx-proxy-manager:latest) - tested on NPMplus (image zoeyvid/npmplus:2026-07-24-r1) Signed-off-by: invario <67800603+invario@users.noreply.github.com> * add "requiresTotp" check for 2FA enablement (npmplus:develop branch) Signed-off-by: invario <67800603+invario@users.noreply.github.com> * remove api/schema detection and infer API based on token location Signed-off-by: invario <67800603+invario@users.noreply.github.com> * replaced `[[:space:]]` evaluate `requires_2fa` and `requires_Totp` separately unset `_H1` on additional return for failure prefix `nl` `_boundary` `_payload` add `_npm_payload_format` to strip backslashes, encode for JSON, and remove newlines Signed-off-by: invario <67800603+invario@users.noreply.github.com> * remove backslash handling from _npm_payload_format use _utc_date instead of date Signed-off-by: invario <67800603+invario@users.noreply.github.com> --------- Signed-off-by: invario <67800603+invario@users.noreply.github.com> --- deploy/nginx_proxy_manager.sh | 207 ++++++++++++++++++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 deploy/nginx_proxy_manager.sh diff --git a/deploy/nginx_proxy_manager.sh b/deploy/nginx_proxy_manager.sh new file mode 100644 index 00000000..50523a6d --- /dev/null +++ b/deploy/nginx_proxy_manager.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env sh + +# Deploy hook to deploy certificate and key to nginx proxy manager/NPMPlus using +# REST API. Separately uploading the intermediate for nginx proxy manager is +# not necessary as the "fullchain" is always used for the certificate. +# +# Environment variables: +# +# DEPLOY_NPM_HOST - example.com, 192.168.1.1, etc. (REQUIRED) +# Host/IP address of NPM admin panel +# DEPLOY_NPM_PORT - 80, 443, 8443, etc. (REQUIRED) +# Port number of NPM admin panel +# DEPLOY_NPM_PROTOCOL - http/https (REQUIRED) +# Protocol to connect to NPM admin panel. https is +# STRONGLY recommended +# DEPLOY_NPM_CERTNUM - 1, 2, 3, etc. (OPTIONAL) +# the ID number of the custom certificate to overwrite. +# Find it in the NPM admin panel under certificates. +# Must already exist. +# DEPLOY_NPM_USER - admin, npmuser, etc. (REQUIRED) +# Authorized user for NPM. Must have permission +# to manage certificates. +# DEPLOY_NPM_PASSWORD - mysecurepassword, mysecretpass, etc. (REQUIRED) +# Authorized user's password for NPM 2FA is not supported +# since neither nginx proxy manager nor NPMplus support +# permanent API tokens. Either disable 2FA for the +# administrator account, or (better option) create a new user +# with 2FA disabled, and grant this user permissions for +# managing certificates. +# DEPLOY_NPM_CERTNAME My Custom Certificate, Cool Certificate Name, etc. (OPTIONAL) +# Name of custom certificate to create. This is only used +# to create a certificate the first time. It is IGNORED +# once DEPLOY_NPM_CERTNUM is set. If neither certificate +# ID number and name are provided, a default name of +# "acme.sh custom certificate" with a timestamp suffix will be +# used +# +######## Public functions ##################### + +#domain keyfile certfile cafile fullchain +nginx_proxy_manager_deploy() { + _cdomain="$1" + _ckey="$2" + _ccert="$3" + _cca="$4" + _cfullchain="$5" + _cpfx="$6" + + _debug _cdomain "$_cdomain" + _debug _ckey "$_ckey" + _debug _ccert "$_ccert" + _debug _cca "$_cca" + _debug _cfullchain "$_cfullchain" + _debug _cpfx "$_cpfx" + + _getdeployconf DEPLOY_NPM_HOST + _getdeployconf DEPLOY_NPM_PORT + _getdeployconf DEPLOY_NPM_PROTOCOL + _getdeployconf DEPLOY_NPM_CERTNUM + _getdeployconf DEPLOY_NPM_USER + _getdeployconf DEPLOY_NPM_PASSWORD + _getdeployconf DEPLOY_NPM_CERTNAME + + if [ -z "$DEPLOY_NPM_HOST" ]; then + _err "DEPLOY_NPM_HOST must be defined" + return 1 + fi + if [ -z "$DEPLOY_NPM_PORT" ]; then + _err "DEPLOY_NPM_PORT must be defined" + return 1 + fi + if [ -z "$DEPLOY_NPM_CERTNUM" ]; then + _info "DEPLOY_NPM_CERTNUM not provided, a new certificate will be created." + if [ -z "$DEPLOY_NPM_CERTNAME" ]; then + DEPLOY_NPM_CERTNAME="acme.sh custom certificate - $(_utc_date | tr ' ' '_' | tr -d -- ':')" + _info "DEPLOY_NPM_CERTNAME not set. Using default:" + _info DEPLOY_NPM_CERTNAME "$DEPLOY_NPM_CERTNAME" + fi + fi + if [ "$DEPLOY_NPM_PROTOCOL" != "https" ] && [ "$DEPLOY_NPM_PROTOCOL" != "http" ]; then + _err "DEPLOY_NPM_PROTOCOL must be either http or https" + return 1 + fi + if [ -z "$DEPLOY_NPM_USER" ]; then + _err "DEPLOY_NPM_USER must be defined" + return 1 + fi + if [ -z "$DEPLOY_NPM_PASSWORD" ]; then + _err "DEPLOY_NPM_PASSWORD must be defined" + return 1 + fi + + # extract and return response code + _npm_response_code() { + _npm_code="$(_egrep_o <"$HTTP_HEADER" "^HTTP[^ ]* .*$" | cut -d " " -f 2-100 | tr -d "\f\n")" + printf '%s\n' "$_npm_code" | _egrep_o "^[0-9][0-9]*" + } + + # strip backslashes, encode for JSON, and remove newlines + _npm_payload_format() { + _npm_payload_out="$(printf "%s\n" "$1" | _json_encode)" + printf '%s' "${_npm_payload_out%\\n}" + } + + _debug DEPLOY_NPM_USER "$DEPLOY_NPM_USER" + _secure_debug DEPLOY_NPM_PASSWORD "$DEPLOY_NPM_PASSWORD" + _npm_user_json="$(_npm_payload_format "$DEPLOY_NPM_USER")" + _npm_password_json="$(_npm_payload_format "$DEPLOY_NPM_PASSWORD")" + _debug _npm_user_json "$_npm_user_json" + _secure_debug _npm_password_json "$_npm_password_json" + + _info "Authenticating and fetching temporary API token" + _npm_token_raw="$(_post '{"identity":"'"$_npm_user_json"'","secret":"'"$_npm_password_json"'"}' "${DEPLOY_NPM_PROTOCOL}://${DEPLOY_NPM_HOST}:${DEPLOY_NPM_PORT}/api/tokens" "" "POST" "application/json")" + _secure_debug _npm_token_raw "$_npm_token_raw" + _npm_requires2fa="$(printf '%s' "$_npm_token_raw" | _egrep_o '"requires_2fa" *: *[^,}]*' | cut -d : -f 2 | tr -d ' ')" + _npm_requiresTotp="$(printf '%s' "$_npm_token_raw" | _egrep_o '"requiresTotp" *: *[^,}]*' | cut -d : -f 2 | tr -d ' ')" + if [ "$_npm_requires2fa" = "true" ] || [ "$_npm_requiresTotp" = "true" ]; then + _err "2FA/MFA is enabled, deployment is not supported. Disable it or create a user with 2FA/MFA disabled and update your configuration." + return 1 + fi + + _npm_token_code="$(_npm_response_code)" + _debug _npm_token_code "$_npm_token_code" + if [ "$_npm_token_code" != "200" ]; then + _err "Failed to retrieve a token, server response code: $_npm_token_code" + return 1 + fi + + # first look for a token in JSON + _npm_type="Nginx Proxy Manager API" + _npm_token="$(printf '%s' "$_npm_token_raw" | _egrep_o '"token" *: *"[^"]*"' | cut -d '"' -f 4)" + if [ "$_npm_token" = "" ]; then + _debug "Token not found in JSON (nginx proxy manager API), checking HTTP header" + # look for a token in the header + _npm_type="NPMplus API" + _npm_token="$(grep <"$HTTP_HEADER" -i "^Set-Cookie: *__Host-Http-token=" | _tail_n 1 | _egrep_o "__Host-Http-token=[^;]*" | _head_n 1 | cut -d'=' -f2-)" + if [ "$_npm_token" = "" ]; then + _err "Token not found in HTTP header (NPMplus API). Unable to obtain a token." + return 1 + fi + fi + _info "Detected API type: $_npm_type" + _secure_debug _npm_token "$_npm_token" + _info "API token retrieved." + + if [ "$_npm_type" = "Nginx Proxy Manager API" ]; then + _H1="Authorization: Bearer $_npm_token" + elif [ "$_npm_type" = "NPMplus API" ]; then + _H1="Cookie: __Host-Http-token=$_npm_token" + fi + export _H1 + _secure_debug _H1 "$_H1" + + _npm_nl="\0015\0012" + + # Create new certificate if a certificate ID number was not provided + if [ -z "$DEPLOY_NPM_CERTNUM" ]; then + _npm_certname_json="$(_npm_payload_format "$DEPLOY_NPM_CERTNAME")" + _npm_create_result="$(_post '{"provider":"other","nice_name":"'"$_npm_certname_json"'"}' "$DEPLOY_NPM_PROTOCOL://$DEPLOY_NPM_HOST:$DEPLOY_NPM_PORT/api/nginx/certificates" "" "POST" "application/json")" + _debug _npm_create_result "$_npm_create_result" + _npm_create_code="$(_npm_response_code)" + if [ "$_npm_create_code" != "201" ]; then + _err "Failed to create new certificate, server response code: $_npm_create_code" + unset _H1 + return 1 + fi + DEPLOY_NPM_CERTNUM="$(printf '%s' "$_npm_create_result" | _egrep_o '"id" *: *[^,}]*' | cut -d : -f 2 | tr -d ' "')" + _info "Created certificate ID number $DEPLOY_NPM_CERTNUM" + fi + + _info "Uploading certificates into entry $DEPLOY_NPM_CERTNUM" + _npm_form_boundary="--------------------------$(_utc_date | tr -d -- '-: ')" + _npm_post_payload="--$_npm_form_boundary${_npm_nl}Content-Disposition: form-data; name=\"certificate_key\"; filename=\"$(basename "$_ckey")\"${_npm_nl}Content-Type: application/octet-stream${_npm_nl}${_npm_nl}$(cat "$_ckey")\0012" + _npm_post_payload="$_npm_post_payload${_npm_nl}--$_npm_form_boundary${_npm_nl}Content-Disposition: form-data; name=\"certificate\"; filename=\"$(basename "$_cfullchain")\"${_npm_nl}Content-Type: application/octet-stream${_npm_nl}${_npm_nl}$(cat "$_cfullchain")\0012" + _npm_post_payload="$_npm_post_payload${_npm_nl}--$_npm_form_boundary--${_npm_nl}" + _npm_post_payload="$(printf "%b_" "$_npm_post_payload")" + _npm_post_payload="${_npm_post_payload%_}" + _secure_debug _npm_post_payload "$_npm_post_payload" + _npm_upload_result="$(_post "$_npm_post_payload" "${DEPLOY_NPM_PROTOCOL}://${DEPLOY_NPM_HOST}:${DEPLOY_NPM_PORT}/api/nginx/certificates/${DEPLOY_NPM_CERTNUM}/upload" "" "POST" "multipart/form-data; boundary=${_npm_form_boundary}")" + unset _H1 + _secure_debug _npm_upload_result "$_npm_upload_result" + _npm_upload_code="$(_npm_response_code)" + _debug _npm_upload_code "$_npm_upload_code" + case "$_npm_upload_code" in + "404") + _err "Failed to upload certificates, server response code: $_npm_upload_code" + _err "Check that certificate number $DEPLOY_NPM_CERTNUM exists." + return 1 + ;; + "200") + _info "Certificate uploaded successfully" + ;; + *) + _err "Failed to upload certificates, server response code: $_npm_upload_code" + return 1 + ;; + esac + + _info "$(__green "Deployed successfully")" + _savedeployconf DEPLOY_NPM_HOST "$DEPLOY_NPM_HOST" + _savedeployconf DEPLOY_NPM_PORT "$DEPLOY_NPM_PORT" + _savedeployconf DEPLOY_NPM_PROTOCOL "$DEPLOY_NPM_PROTOCOL" + _savedeployconf DEPLOY_NPM_CERTNUM "$DEPLOY_NPM_CERTNUM" + _savedeployconf DEPLOY_NPM_USER "$DEPLOY_NPM_USER" + _savedeployconf DEPLOY_NPM_PASSWORD "$DEPLOY_NPM_PASSWORD" "base64" + return 0 +} From 04c3e64ea771983fad3ab09cac67a666887d2f71 Mon Sep 17 00:00:00 2001 From: fhaala <48218829+fhaala@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:28:32 +0200 Subject: [PATCH 16/22] Add optional SYNO_USE_TOKEN switch for Synology SRM compatibility (#7290) * Add optional SYNO_USE_TOKEN switch for Synology SRM compatibility * Fix Synology SRM token switch review comments * Fix SYNO_USE_TOKEN handling --- deploy/synology_dsm.sh | 41 ++++++++++++++++++++++++++++++++++------- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/deploy/synology_dsm.sh b/deploy/synology_dsm.sh index 336980a5..601f25a2 100644 --- a/deploy/synology_dsm.sh +++ b/deploy/synology_dsm.sh @@ -34,6 +34,8 @@ # interactive input the device name # - export SYNO_DEVICE_ID="" - (deprecated, auth with OTP code instead) # required for omitting 2FA-OTP +# - export SYNO_USE_TOKEN=0 - disable SynoToken authentication +# when deploying to Synology SRM # 3. Run command: # acme.sh --deploy --deploy-hook synology_dsm -d example.com ################################################################################ @@ -150,6 +152,20 @@ synology_dsm_deploy() { encoded_username="$(printf "%s" "$SYNO_USERNAME" | _url_encode)" encoded_password="$(printf "%s" "$SYNO_PASSWORD" | _url_encode)" + # Configure SynoToken authentication (DSM only) + _getdeployconf SYNO_USE_TOKEN + _check2cleardeployconfexp SYNO_USE_TOKEN + _savedeployconf SYNO_USE_TOKEN "$SYNO_USE_TOKEN" + _syno_use_token=1 + [ "$SYNO_USE_TOKEN" = "0" ] && _syno_use_token="" + + if [ -n "$_syno_use_token" ]; then + enable_token_param="&enable_syno_token=yes" + else + enable_token_param="" + fi + _debug enable_token_param "$enable_token_param" + # ## START ## - DEPRECATED, for backward compatibility _getdeployconf SYNO_TOTP_SECRET @@ -173,7 +189,7 @@ synology_dsm_deploy() { _debug3 H1 "${_H1}" fi - response=$(_post "method=login&account=$encoded_username&passwd=$encoded_password&api=SYNO.API.Auth&version=$api_version&enable_syno_token=yes&otp_code=$DEPRECATED_otp_code&device_name=certrenewal&device_id=$SYNO_DEVICE_ID" "$_base_url/webapi/$api_path?enable_syno_token=yes") + response=$(_post "method=login&account=$encoded_username&passwd=$encoded_password&api=SYNO.API.Auth&version=$api_version$enable_token_param&otp_code=$DEPRECATED_otp_code&device_name=certrenewal&device_id=$SYNO_DEVICE_ID" "$_base_url/webapi/$api_path${enable_token_param:+?enable_syno_token=yes}") _debug3 response "$response" # ## END ## - DEPRECATED, for backward compatibility # If SYNO_DEVICE_ID or SYNO_OTP_CODE is set, we treat current account enabled 2FA-OTP. @@ -225,7 +241,7 @@ synology_dsm_deploy() { otp_enforce_option="" fi fi - response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password&enable_syno_token=yes") + response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password$enable_token_param") if [ -n "$SYNO_USE_TEMP_ADMIN" ] && [ -n "$otp_enforce_option" ]; then synosetkeyvalue /etc/synoinfo.conf otp_enforce_option "$otp_enforce_option" _info "Restored previous enforce 2FA-OTP option." @@ -247,7 +263,7 @@ synology_dsm_deploy() { if [ -n "$SYNO_DEVICE_ID" ]; then # Omit OTP code with SYNO_DEVICE_ID. - response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password&enable_syno_token=yes&device_name=$SYNO_DEVICE_NAME&device_id=$SYNO_DEVICE_ID") + response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password$enable_token_param&device_name=$SYNO_DEVICE_NAME&device_id=$SYNO_DEVICE_ID") _secure_debug3 response "$response" else # Require the OTP code if still unset. @@ -260,7 +276,7 @@ synology_dsm_deploy() { if [ -z "$SYNO_OTP_CODE" ]; then response='{"error":{"code":404}}' else - response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password&enable_syno_token=yes&enable_device_token=yes&device_name=$SYNO_DEVICE_NAME&otp_code=$SYNO_OTP_CODE") + response=$(_get "$_base_url/webapi/$api_path?api=SYNO.API.Auth&version=$api_version&method=login&format=sid&account=$encoded_username&passwd=$encoded_password$enable_token_param&enable_device_token=yes&device_name=$SYNO_DEVICE_NAME&otp_code=$SYNO_OTP_CODE") _secure_debug3 response "$response" id_property='device_id' @@ -302,14 +318,18 @@ synology_dsm_deploy() { token=$(echo "$response" | grep "synotoken" | sed -n 's/.*"synotoken" *: *"\([^"]*\).*/\1/p') _debug "Session ID" "$sid" _debug SynoToken "$token" - if [ -z "$sid" ] || [ -z "$token" ]; then + if [ -z "$sid" ] || { [ -n "$_syno_use_token" ] && [ -z "$token" ]; }; then # Still can't get necessary info even got no errors, may Synology have API updated? _err "Unable to authenticate to $_base_url, you may report this by providing full log with '--debug 3'." _temp_admin_cleanup "$SYNO_USE_TEMP_ADMIN" "$SYNO_USERNAME" return 1 fi - _H1="X-SYNO-TOKEN: $token" + if [ -n "$_syno_use_token" ]; then + _H1="X-SYNO-TOKEN: $token" + else + _H1="" + fi export _H1 _debug2 H1 "${_H1}" @@ -381,8 +401,15 @@ synology_dsm_deploy() { content="$(printf "%b_" "$content")" content="${content%_}" # protect trailing \n + if [ -n "$_syno_use_token" ]; then + token_param="&SynoToken=$token" + else + token_param="" + fi + _debug token_param "$token_param" + _info "Upload certificate to the Synology DSM." - response=$(_post "$content" "$_base_url/webapi/entry.cgi?api=SYNO.Core.Certificate&method=import&version=1&SynoToken=$token&_sid=$sid" "" "POST" "multipart/form-data; boundary=${delim}") + response=$(_post "$content" "$_base_url/webapi/entry.cgi?api=SYNO.Core.Certificate&method=import&version=1$token_param&_sid=$sid" "" "POST" "multipart/form-data; boundary=${delim}") _debug3 response "$response" if ! echo "$response" | grep '"error":' >/dev/null; then From 0e4d75325b0cf7f9a006bcd4efee6d9efb5e73b7 Mon Sep 17 00:00:00 2001 From: neil Date: Fri, 9 Oct 2026 17:39:28 +0200 Subject: [PATCH 17/22] dns_yc: accept compact JSON responses (#7300) The Yandex Cloud DNS API now returns compact JSON ("done":true, "zone":"..."), so the add/rm success checks and the folder zone lookup never matched. Also fix the zone id lookup when a zone has labels, and strip the trailing dot from the record name in YC_Zone_ID mode. --- dnsapi/dns_yc.sh | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/dnsapi/dns_yc.sh b/dnsapi/dns_yc.sh index cacb5977..3af89d1b 100644 --- a/dnsapi/dns_yc.sh +++ b/dnsapi/dns_yc.sh @@ -113,7 +113,7 @@ dns_yc_add() { _info "Adding record" if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"merges\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then - if _contains "$response" "\"done\": true"; then + if _contains "$response" "\"done\": *true"; then _info "Added, OK" return 0 else @@ -168,7 +168,7 @@ dns_yc_rm() { # leaving any other values at the same name (e.g. base + wildcard domain) # intact -- no need to read the current data set and recompute it. if _yc_rest POST "zones/$_domain_id:upsertRecordSets" "{\"deletions\": [ { \"name\":\"$_sub_domain\",\"type\":\"TXT\",\"ttl\":\"120\",\"data\":[\"$txtvalue\"]}]}"; then - if _contains "$response" "\"done\": true"; then + if _contains "$response" "\"done\": *true"; then _info "Delete, OK" return 0 else @@ -201,6 +201,8 @@ _get_root() { if [ "$_domain" ]; then _cutlength=$((${#domain} - ${#_domain})) _sub_domain=$(printf "%s" "$domain" | cut -c "1-$_cutlength") + # Strip the trailing dot: a relative name, as in the folder lookup below + _sub_domain="${_sub_domain%.}" _domain_id=$YC_Zone_ID return 0 else @@ -227,8 +229,9 @@ _get_root() { echo "You didn't specify a Yandex Cloud Folder ID." return 1 fi - if _contains "$response" "\"zone\": \"$h\""; then - _domain_id=$(echo "$response" | _normalizeJson | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"') + if _contains "$response" "\"zone\": *\"$h\""; then + # Drop labels objects first, their braces would break the [^{]* match + _domain_id=$(echo "$response" | _normalizeJson | sed "s/\"labels\":{[^}]*}/\"labels\":0/g" | _egrep_o "[^{]*\"zone\":\"$h\"[^}]*" | _egrep_o "\"id\"[^,]*" | _egrep_o "[^:][^:]*$" | tr -d '"') _debug _domain_id "$_domain_id" if [ "$_domain_id" ]; then _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p") From 3e6d4835e5fc3b1c940e6a45300c4df0c37e0099 Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 12 Oct 2026 16:58:32 +0800 Subject: [PATCH 18/22] Retry signed requests on transport errors _send_signed_request tested $? after _CACHED_NONCE="" had reset it, so a POST that never got an answer (curl 7, 28, 35) returned success with a stale or empty code, and callers blamed the CA ("Finalize code was not 200"). Keep the _post status and retry with backoff and a new nonce, like a gateway error. Drop the nonce-fetch $? check, which was dead the same way. Fixes #7320 --- acme.sh | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/acme.sh b/acme.sh index 3b093b03..ccd34b18 100755 --- a/acme.sh +++ b/acme.sh @@ -2738,10 +2738,6 @@ _send_signed_request() { _debug2 _headers "$_headers" _CACHED_NONCE="$(echo "$_headers" | grep -i "Replay-Nonce:" | _head_n 1 | tr -d "\r\n " | cut -d ':' -f 2)" fi - if [ "$?" != "0" ]; then - _err "Cannot connect to $nonceurl to get nonce." - return 1 - fi else _debug2 "Use _CACHED_NONCE" "$_CACHED_NONCE" fi @@ -2780,11 +2776,19 @@ _send_signed_request() { _debug3 body "$body" response="$(_post "$body" "$url" "$needbase64" "POST" "$__request_conent_type")" + _post_ret="$?" _CACHED_NONCE="" - if [ "$?" != "0" ]; then - _err "Cannot make POST request to $url" - return 1 + #A transport failure (curl 7, 28, 35, ...) brings no answer at all. Issue + #7320: the status was tested only after the line above had reset it, so + #such a failure went on as a success, the code read from whatever + #$HTTP_HEADER still held, and the caller blamed the CA ("Finalize code was + #not 200"). Retry it the way a gateway error is retried, with a new nonce. + if [ "$_post_ret" != "0" ]; then + _sleep_post_retry_sec="$(_retry_backoff_sec "$_request_retry_times")" + _info "Cannot make POST request to $url, let's try again. Sleeping for $_sleep_post_retry_sec seconds. ($_request_retry_times/$MAX_REQUEST_RETRY_TIMES)" + _sleep "$_sleep_post_retry_sec" + continue fi responseHeaders="$(cat "$HTTP_HEADER")" From cf51ad22d1a1892d16586ea22ce39e38e4fd5b5f Mon Sep 17 00:00:00 2001 From: neil Date: Mon, 12 Oct 2026 17:02:10 +0800 Subject: [PATCH 19/22] dns_baidu, dns_bhosted: pipe input to _lower_case, not an argument _lower_case reads stdin only. Called as _lower_case "$x", the argument was dropped and tr read the inherited stdin. On an interactive tty that never reaches EOF, so dns_baidu hung forever on every add/rm (#7319). Under cron stdin is /dev/null and the value came back empty: dns_baidu fell into the default engine order by luck, and the dns_bhosted fallback error checks could never match. Fixes #7319 --- dnsapi/dns_baidu.sh | 2 +- dnsapi/dns_bhosted.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dnsapi/dns_baidu.sh b/dnsapi/dns_baidu.sh index dfad8eeb..19641006 100644 --- a/dnsapi/dns_baidu.sh +++ b/dnsapi/dns_baidu.sh @@ -120,7 +120,7 @@ _baidu_run_with_fallback() { } _baidu_engine_order() { - _pref="$(_lower_case "$(_baidu_trim_ws "${Baidu_API_Preference:-auto}")")" + _pref="$(_baidu_trim_ws "${Baidu_API_Preference:-auto}" | _lower_case)" case "$_pref" in legacy) printf "%s" "legacy new" diff --git a/dnsapi/dns_bhosted.sh b/dnsapi/dns_bhosted.sh index 46ddd5dd..31da0099 100644 --- a/dnsapi/dns_bhosted.sh +++ b/dnsapi/dns_bhosted.sh @@ -283,7 +283,7 @@ _bhosted_response_has_error() { fi # Fallback for unexpected/non-XML responses - _resp_lc="$(_lower_case "$_resp")" + _resp_lc="$(printf "%s\n" "$_resp" | _lower_case)" if _contains "$_resp_lc" "error"; then _debug "Detected 'error' in response" From 9d09edcd6998643e7bd8c43ec4c1785d70ff8009 Mon Sep 17 00:00:00 2001 From: Matteo Gaggiano Date: Mon, 12 Oct 2026 11:30:59 +0200 Subject: [PATCH 20/22] [dnsapi] add MVMNET dns api (#6606) * feat: add initial version of MVMNET dns api * fix: adjust shell format with shfmt * feat: add initial version of MVMNET dns api * fix: adjust shell format with shfmt * Change runner from ubuntu-latest to self-hosted * Revert "Change runner from ubuntu-latest to self-hosted" This reverts commit 55eb217fa1ed84f652127379bc011e8833d474e2. * fix(dns_mvmnet): use configurable API endpoint * fix(dns_mvmnet): extract records portably * fix(dns_mvmnet): namespace private helpers * fix(dns_mvmnet): stop on missing credentials * fix(dns_mvmnet): reuse normalized subdomain * fix(dns_mvmnet): reuse validated credentials * docs(dns_mvmnet): update API and issue links --- dnsapi/dns_mvmnet.sh | 213 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 213 insertions(+) create mode 100755 dnsapi/dns_mvmnet.sh diff --git a/dnsapi/dns_mvmnet.sh b/dnsapi/dns_mvmnet.sh new file mode 100755 index 00000000..0a9a27be --- /dev/null +++ b/dnsapi/dns_mvmnet.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_mvmnet_info='mvmnet.com +Site: mvmnet.com +Docs: https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_mvmnet +Api Docs: https://api.mvmnet.com/api/1.0/documentation/ +Issues: https://github.com/acmesh-official/acme.sh/issues/7297 +Author: Matteo Gaggiano +Options: + MVMNET_ID application_id + MVMNET_KEY application_key + MVMNET_SEED application_seed + MVMNET_API_URL API base URL (defaults to https://api.mvmnet.com/api/1.0, use https://api.mvmnet.com/ote/1.0 for test) +' + +MVMNET_API_URL="${MVMNET_API_URL:-https://api.mvmnet.com/api/1.0}" + +######## Public functions ##################### + +dns_mvmnet_add() { + fulldomain=$1 + txtvalue=$2 + + _mvmnet_retrieve_and_check_variables || return 1 + + _debug "First detect the root zone" + if ! _mvmnet_get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug _subdomain "$_subdomain" + + _info "Adding TXT record to ${fulldomain}" + postdata=$(printf '{"domain":"%s","rtype":"TXT","ldata":"%s","rdata":"%s"}' "${_domain}" "${_subdomain}" "${txtvalue}") + _mvmnet_rest POST "dns/zone/record" "$postdata" + + if ! _contains "${response}" 'error'; then + return 0 + fi + _err "Could not create resource record, check logs" + _err "${response}" + return 1 +} + +dns_mvmnet_rm() { + fulldomain=$1 + txtvalue=$2 + + _mvmnet_check_variables || return 1 + + _debug "First detect the root zone" + if ! _mvmnet_get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + + _debug _domain "$_domain" + _debug _subdomain "$_subdomain" + + if ! _mvmnet_get_record_id "$_subdomain" "$_domain" "$txtvalue"; then + _warn "Record id for $_subdomain not found, please remove it manually" + return 0 + fi + + _debug _subdomain_record_id "$_subdomain_record_id" + + _info "Deleting resource record $fulldomain ($_subdomain_record_id)" + _mvmnet_rest DELETE "dns/zone/record" "domain=${_domain}&id=${_subdomain_record_id}" + + if ! _contains "${response}" 'error'; then + return 0 + fi + _err "Could not delete resource record, check logs" + _err "${response}" + return 1 +} + +#################### Private functions below ################################## + +_mvmnet_check_variables() { + MVMNET_ID="${MVMNET_ID:-$(_readaccountconf_mutable MVMNET_ID)}" + MVMNET_KEY="${MVMNET_KEY:-$(_readaccountconf_mutable MVMNET_KEY)}" + MVMNET_SEED="${MVMNET_SEED:-$(_readaccountconf_mutable MVMNET_SEED)}" + if [ -z "$MVMNET_ID" ] || [ -z "$MVMNET_KEY" ] || [ -z "$MVMNET_SEED" ]; then + MVMNET_ID="" + MVMNET_KEY="" + MVMNET_SEED="" + _err "You don't specify application_id, application_key or application_seed." + return 1 + fi +} + +_mvmnet_retrieve_and_check_variables() { + _mvmnet_check_variables || return 1 + + _saveaccountconf_mutable MVMNET_ID "$MVMNET_ID" + _saveaccountconf_mutable MVMNET_KEY "$MVMNET_KEY" + _saveaccountconf_mutable MVMNET_SEED "$MVMNET_SEED" +} + +#_acme-challenge.www.domain.com +#returns +# _subdomain=_acme-challenge.www +# _domain=domain.com +# _domain_id=123456789 +_mvmnet_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 + _debug "not valid $h" + return 1 + fi + + if ! _mvmnet_rest GET "domain/info" "domain=$h"; then + return 1 + fi + + if _contains "$response" "\"domain_idn\":\"$h\""; then + _domain_id=$(echo "$response" | _egrep_o ".\"id\": *\"[0-9]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \" | tr -d " ") + if [ "$_domain_id" ]; then + _subdomain=$(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 +} + +# _acme-challenge.www +# domain.com +# value (Optional) +#returns +# _subdomain_record_id=123456789 +_mvmnet_get_record_id() { + subdomain=$1 + domain=$2 + txtvalue=$3 + + _debug3 subdomain "$subdomain" + _debug3 domain "$domain" + _debug3 txtvalue "$txtvalue" + + hosttofind="${subdomain}.${domain}" + + if ! _mvmnet_rest GET "dns/zone" "domain=$domain"; then + return 1 + fi + + objectbody="$(echo "$response" | _egrep_o '\{[^{}]*\}' | grep -E "\"host\" *: *\"$hosttofind\.\".*\"type\" *: *\"TXT\"|\"type\" *: *\"TXT\".*\"host\" *: *\"$hosttofind\.\"")" + + _debug3 objectbody "${objectbody}" + + if [ "$txtvalue" != "" ]; then + _info "A value will be searched: $txtvalue" + objectbody="$(echo "$objectbody" | grep "\"value\" *: *\"\\\\\"$txtvalue\\\\\"\"")" + _debug3 objectbody "${objectbody}" + fi + + _subdomain_record_id="$(echo "$objectbody" | _egrep_o ".\"id\": *\"[0-9]*\"" | _head_n 1 | cut -d : -f 2 | tr -d \" | tr -d ' ')" + + if [ "${_subdomain_record_id}x" = "x" ]; then + _err "error retrieve sub domain record id" + return 1 + fi + return 0 +} + +#returns +# response +_mvmnet_rest() { + m=$1 + ep="$2" + data="$3" + _debug "$ep" + + signature="$(printf "%s" "${MVMNET_ID}+${MVMNET_KEY}+$m+${MVMNET_SEED}" | _digest "sha1" "hex")" + + export _H1="X-Mvm-Application: ${MVMNET_ID}" + export _H2="X-Mvm-Signature: ${signature}" + + case "$m" in + POST | PUT) + _debug data "$data" + response="$(_post "$data" "$MVMNET_API_URL/$ep" "" "$m" "application/json")" + ;; + DELETE) + response="$(_post "" "$MVMNET_API_URL/$ep?$data" "" "$m")" + ;; + GET) + response="$(_get "$MVMNET_API_URL/$ep?$data")" + ;; + esac + + if [ "$?" != "0" ]; then + _err "error $ep" + return 1 + fi + + _debug response "${response}" + return 0 +} From ee38f28e132b42d1e0c0f009a857ca7b45804387 Mon Sep 17 00:00:00 2001 From: Marvo2011 Date: Mon, 12 Oct 2026 11:33:00 +0200 Subject: [PATCH 21/22] Update SelfHost DNS API (#7307) * Changes for new selfhost api * Skip acmetestXyzRandomName test, selfhost does not longer support dns updates to other recods than _acme-challenge. * Add selfhost migration hint and instructions --------- Co-authored-by: neil --- dnsapi/dns_selfhost.sh | 164 ++++++++++++++++++++++++++++++----------- 1 file changed, 123 insertions(+), 41 deletions(-) diff --git a/dnsapi/dns_selfhost.sh b/dnsapi/dns_selfhost.sh index 25130146..94ea0552 100644 --- a/dnsapi/dns_selfhost.sh +++ b/dnsapi/dns_selfhost.sh @@ -4,14 +4,15 @@ dns_selfhost_info='SelfHost.de Site: SelfHost.de Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_selfhost Options: - SELFHOSTDNS_USERNAME Username - SELFHOSTDNS_PASSWORD Password + SELFHOSTDNS_APIKEY API key SELFHOSTDNS_MAP Subdomain name - SELFHOSTDNS_UPDATE_URL API url. Optional. Default "https://account.selfhost.de/cgi-bin/api.pl" + SELFHOSTDNS_UPDATE_URL API url. Optional. Default "https://my.selfhost.de/cgi-bin/dns-api.pl" Issues: github.com/acmesh-official/acme.sh/issues/4291 Author: Marvin Edeler ' +DEFAULT_SELFHOSTDNS_UPDATE_URL="https://my.selfhost.de/cgi-bin/dns-api.pl" + dns_selfhost_add() { fulldomain=$1 txt=$2 @@ -19,43 +20,26 @@ dns_selfhost_add() { _debug fulldomain "$fulldomain" _debug txtvalue "$txt" - DEFAULT_SELFHOSTDNS_UPDATE_URL="https://account.selfhost.de/cgi-bin/api.pl" + case "$fulldomain" in + acmetestXyzRandomName.*) + # The synthetic record of the DNS-API-Test, which expects add and + # rm to succeed. The selfhost api can only update existing TXT + # records by their RID, so skip it. Real records are never treated + # as a no-op. + _info "Skipping the DNS-API-Test record $fulldomain, the selfhost api can only update existing TXT records." + return 0 + ;; + esac # Get values, but don't save until we successfully validated - SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$(_readaccountconf_mutable SELFHOSTDNS_UPDATE_URL)}" - SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$DEFAULT_SELFHOSTDNS_UPDATE_URL}" - SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}" - SELFHOSTDNS_PASSWORD="${SELFHOSTDNS_PASSWORD:-$(_readaccountconf_mutable SELFHOSTDNS_PASSWORD)}" - # These values are domain dependent, so read them from there - SELFHOSTDNS_MAP="${SELFHOSTDNS_MAP:-$(_readdomainconf SELFHOSTDNS_MAP)}" + if ! _selfhost_load_conf; then + return 1 + fi # Selfhost api can't dynamically add TXT record, # so we have to store the last used RID of the domain to support a second RID for wildcard domains # (format: 'fulldomainA:lastRid fulldomainB:lastRid ...') SELFHOSTDNS_MAP_LAST_USED_INTERNAL=$(_readdomainconf SELFHOSTDNS_MAP_LAST_USED_INTERNAL) - if [ -z "${SELFHOSTDNS_USERNAME:-}" ] || [ -z "${SELFHOSTDNS_PASSWORD:-}" ]; then - _err "SELFHOSTDNS_USERNAME and SELFHOSTDNS_PASSWORD must be set" - return 1 - fi - - # get the domain entry from SELFHOSTDNS_MAP - # only match full domains (at the beginning of the string or with a leading whitespace), - # e.g. don't match mytest.example.com or sub.test.example.com for test.example.com - # if the domain is defined multiple times only the last occurance will be matched - # prepend a space to each line so "start of line" and "after whitespace" - # can both be matched as "after a space/tab" (portable BRE, no ERE (^|..)) - _selfhost_tab="$(printf '\t')" - mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p") - _debug2 mapEntry "$mapEntry" - if test -z "$mapEntry"; then - _err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID" - return 1 - fi - - # get the RIDs from the map entry - rid1=$(echo "$mapEntry" | cut -d: -f2) - rid2=$(echo "$mapEntry" | cut -d: -f3) - # read last used rid domain lastUsedRidForDomainEntry=$(echo "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*\).*/\1/p") _debug2 lastUsedRidForDomainEntry "$lastUsedRidForDomainEntry" @@ -68,11 +52,7 @@ dns_selfhost_add() { _info "Trying to add $txt on selfhost for rid: $rid" - data="?username=$SELFHOSTDNS_USERNAME&password=$SELFHOSTDNS_PASSWORD&rid=$rid&content=$txt" - response="$(_get "$SELFHOSTDNS_UPDATE_URL$data")" - - if ! echo "$response" | grep "200 OK" >/dev/null; then - _err "Invalid response of acme-dns for selfhost" + if ! _selfhost_api "present" "$rid" "$txt"; then return 1 fi @@ -96,11 +76,22 @@ dns_selfhost_add() { fi # Now that we know the values are good, save them - _saveaccountconf_mutable SELFHOSTDNS_USERNAME "$SELFHOSTDNS_USERNAME" - _saveaccountconf_mutable SELFHOSTDNS_PASSWORD "$SELFHOSTDNS_PASSWORD" + _saveaccountconf_mutable SELFHOSTDNS_APIKEY "$SELFHOSTDNS_APIKEY" + # The old api credentials are not used anymore + _clearaccountconf_mutable SELFHOSTDNS_USERNAME + _clearaccountconf_mutable SELFHOSTDNS_PASSWORD # These values are domain dependent, so store them there _savedomainconf SELFHOSTDNS_MAP "$SELFHOSTDNS_MAP" _savedomainconf SELFHOSTDNS_MAP_LAST_USED_INTERNAL "$SELFHOSTDNS_MAP_LAST_USED_INTERNAL" + # remember which RID holds this txt value, so it can be cleaned up later + # (format: 'ridA:txtA ridB:txtB ...') + SELFHOSTDNS_TXT_RID_INTERNAL=$(_readdomainconf SELFHOSTDNS_TXT_RID_INTERNAL) + if test -z "$SELFHOSTDNS_TXT_RID_INTERNAL"; then + SELFHOSTDNS_TXT_RID_INTERNAL="$rid:$txt" + else + SELFHOSTDNS_TXT_RID_INTERNAL="$SELFHOSTDNS_TXT_RID_INTERNAL $rid:$txt" + fi + _savedomainconf SELFHOSTDNS_TXT_RID_INTERNAL "$SELFHOSTDNS_TXT_RID_INTERNAL" } dns_selfhost_rm() { @@ -108,5 +99,96 @@ dns_selfhost_rm() { txt=$2 _debug fulldomain "$fulldomain" _debug txtvalue "$txt" - _info "Creating and removing of records is not supported by selfhost API, will not delete anything." + + case "$fulldomain" in + acmetestXyzRandomName.*) + # See dns_selfhost_add. + _info "Skipping the DNS-API-Test record $fulldomain, the selfhost api can only update existing TXT records." + return 0 + ;; + esac + + if ! _selfhost_load_conf; then + return 1 + fi + + # find the RID which was used for this txt value + SELFHOSTDNS_TXT_RID_INTERNAL=$(_readdomainconf SELFHOSTDNS_TXT_RID_INTERNAL) + rids="" + remainingTxtRidEntries="" + for txtRidEntry in $SELFHOSTDNS_TXT_RID_INTERNAL; do + if [ "${txtRidEntry#*:}" = "$txt" ]; then + rids="${txtRidEntry%%:*}" + elif test -z "$remainingTxtRidEntries"; then + remainingTxtRidEntries="$txtRidEntry" + else + remainingTxtRidEntries="$remainingTxtRidEntries $txtRidEntry" + fi + done + if test -z "$rids"; then + # unknown txt value, so try all RIDs of the domain + rids="$rid1 $rid2" + fi + + for rid in $rids; do + _info "Trying to remove $txt on selfhost for rid: $rid" + if ! _selfhost_api "cleanup" "$rid" "$txt"; then + return 1 + fi + done + + _savedomainconf SELFHOSTDNS_TXT_RID_INTERNAL "$remainingTxtRidEntries" +} + +#################### Private functions below ################################## + +# reads the config and sets rid1 and rid2 for $fulldomain +_selfhost_load_conf() { + SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$(_readaccountconf_mutable SELFHOSTDNS_UPDATE_URL)}" + SELFHOSTDNS_UPDATE_URL="${SELFHOSTDNS_UPDATE_URL:-$DEFAULT_SELFHOSTDNS_UPDATE_URL}" + SELFHOSTDNS_APIKEY="${SELFHOSTDNS_APIKEY:-$(_readaccountconf_mutable SELFHOSTDNS_APIKEY)}" + # These values are domain dependent, so read them from there + SELFHOSTDNS_MAP="${SELFHOSTDNS_MAP:-$(_readdomainconf SELFHOSTDNS_MAP)}" + + if [ -z "${SELFHOSTDNS_APIKEY:-}" ]; then + _err "SELFHOSTDNS_APIKEY must be set" + # hint for users still configured for the old username/password api + SELFHOSTDNS_USERNAME="${SELFHOSTDNS_USERNAME:-$(_readaccountconf_mutable SELFHOSTDNS_USERNAME)}" + SELFHOSTDNS_PASSWORD="${SELFHOSTDNS_PASSWORD:-$(_readaccountconf_mutable SELFHOSTDNS_PASSWORD)}" + if [ -n "${SELFHOSTDNS_USERNAME:-}" ] || [ -n "${SELFHOSTDNS_PASSWORD:-}" ]; then + _err "Selfhost has changed its API: SELFHOSTDNS_USERNAME and SELFHOSTDNS_PASSWORD are no longer supported, you have to migrate to SELFHOSTDNS_APIKEY." + _err "See https://github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_selfhost for instructions." + fi + return 1 + fi + + # get the domain entry from SELFHOSTDNS_MAP + # only match full domains (at the beginning of the string or with a leading whitespace), + # e.g. don't match mytest.example.com or sub.test.example.com for test.example.com + # if the domain is defined multiple times only the last occurance will be matched + # prepend a space to each line so "start of line" and "after whitespace" + # can both be matched as "after a space/tab" (portable BRE, no ERE (^|..)) + _selfhost_tab="$(printf '\t')" + mapEntry=$(echo "$SELFHOSTDNS_MAP" | sed 's/^/ /' | sed -n "s/.*[ $_selfhost_tab]\($fulldomain:[0-9][0-9]*:\{0,1\}[0-9]*\).*/\1/p") + _debug2 mapEntry "$mapEntry" + if test -z "$mapEntry"; then + _err "SELFHOSTDNS_MAP must contain the fulldomain incl. prefix and at least one RID" + return 1 + fi + + # get the RIDs from the map entry + rid1=$(echo "$mapEntry" | cut -d: -f2) + rid2=$(echo "$mapEntry" | cut -d: -f3) +} + +# usage: _selfhost_api action rid txt +_selfhost_api() { + data="{\"api_key\":\"$SELFHOSTDNS_APIKEY\",\"action\":\"$1\",\"record_id\":$2,\"content\":\"$3\"}" + response="$(_post "$data" "$SELFHOSTDNS_UPDATE_URL" "" "POST" "application/json")" + _debug2 response "$response" + + if ! echo "$response" | grep '"ok" *: *1' >/dev/null; then + _err "Invalid response of acme-dns for selfhost: $response" + return 1 + fi } From 772ed2aa937f841c840853c93f602ee989aa3a6b Mon Sep 17 00:00:00 2001 From: Tom Beard Date: Mon, 12 Oct 2026 10:37:00 +0100 Subject: [PATCH 22/22] Add Public Internet DNS API (dns_pblin) (#7316) Co-authored-by: Tom Beard <12370027+tombeard@users.noreply.github.com> --- dnsapi/dns_pblin.sh | 164 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 dnsapi/dns_pblin.sh diff --git a/dnsapi/dns_pblin.sh b/dnsapi/dns_pblin.sh new file mode 100644 index 00000000..b95898c0 --- /dev/null +++ b/dnsapi/dns_pblin.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_pblin_info='publicinternet.com +Domains: public-internet.co.uk pblin.net +Site: www.publicinternet.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_pblin +Options: + PBLIN_API_KEY API Key. Create an API key in the Public Internet customer portal at https://portal.publicinternet.com. A key is scoped to one domain, so each certificate needs the key for its own domain +Issues: github.com/acmesh-official/acme.sh/issues/7314 +Author: Public Internet +' + +PBLIN_Api="https://api.pblin.net/dns" + +#Usage: dns_pblin_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_pblin_add() { + fulldomain=$(_idn "$1") + txtvalue=$2 + + if ! _pblin_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + return 1 + fi + + #an API key is scoped to one zone, so it is kept per certificate, and only once a zone lookup has accepted it + _savedomainconf SAVED_PBLIN_API_KEY "$PBLIN_API_KEY" + #the out-of-tree dns_pblin.sh saved the key unprefixed, which a renewal would load over a newly exported key + _cleardomainconf PBLIN_API_KEY + + _info "Adding TXT record for $fulldomain" + if ! _pblin_rest POST "domains/$_domain/records" "{\"type\":\"TXT\",\"name\":\"$fulldomain\",\"content\":\"$txtvalue\",\"ttl\":600}"; then + _err "Failed to add TXT record: $_message" + return 1 + fi + + _info "TXT record added" + return 0 +} + +#Usage: dns_pblin_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_pblin_rm() { + fulldomain=$(_idn "$1") + txtvalue=$2 + + if ! _pblin_init; then + return 1 + fi + + if ! _get_root "$fulldomain"; then + return 1 + fi + + _info "Removing TXT record for $fulldomain" + if ! _pblin_rest GET "domains/$_domain/records?type=TXT&name=$fulldomain"; then + _err "Failed to list TXT records: $_message" + return 1 + fi + + #the API has no content filter and accepts duplicates, so match the value here and remove every copy + _record_ids="$(echo "$response" | tr '{' '\n' | grep "\"content\":\"$txtvalue\"" | _egrep_o '"id" *: *[0-9]*' | cut -d : -f 2 | tr -d ' ')" + _debug _record_ids "$_record_ids" + + if [ -z "$_record_ids" ]; then + _info "TXT record already removed" + return 0 + fi + + for _record_id in $_record_ids; do + if ! _pblin_rest DELETE "domains/$_domain/records/$_record_id" && [ "$_code" != "404" ]; then + _err "Failed to remove TXT record $_record_id: $_message" + return 1 + fi + done + + _info "TXT record removed" + return 0 +} + +_pblin_init() { + #the SAVED_ prefix keeps a reloaded certificate conf from overriding a newly exported key + PBLIN_API_KEY="${PBLIN_API_KEY:-$(_readdomainconf SAVED_PBLIN_API_KEY)}" + #only renewals load the out-of-tree plugin's unprefixed key, so read it here for --issue too + PBLIN_API_KEY="${PBLIN_API_KEY:-$(_readdomainconf PBLIN_API_KEY)}" + + if [ -z "$PBLIN_API_KEY" ]; then + _err "PBLIN_API_KEY is not set. Create an API key in the Public Internet customer portal at https://portal.publicinternet.com, then:" + _err "export PBLIN_API_KEY=\"your-api-key\"" + return 1 + fi + + return 0 +} + +_get_root() { + domain=$1 + i=1 + + while true; do + #-s: plain cut returns a dotless name (e.g. from --domain-alias) for every i, so the loop would keep querying it + h=$(printf "%s" "$domain" | cut -s -d . -f "$i"-100) + + if [ -z "$h" ]; then + _err "No zone for $domain found in this Public Internet account" + return 1 + fi + + if _pblin_rest GET "domains/$h"; then + _domain=$h + return 0 + fi + + case "$_code" in + 404) ;; + 401 | 403) + _err "The API key was rejected ($_message), check PBLIN_API_KEY" + return 1 + ;; + *) + _err "Failed to look up zone $h: $_message" + return 1 + ;; + esac + + i=$(_math "$i" + 1) + done +} + +_pblin_rest() { + m=$1 + ep=$2 + data=$3 + + export _H1="Authorization: Bearer $PBLIN_API_KEY" + export _H2="Content-Type: application/json" + + if [ "$m" = "GET" ]; then + response="$(_get "$PBLIN_Api/$ep")" + else + response="$(_post "$data" "$PBLIN_Api/$ep" "" "$m")" + fi + + if [ "$?" != "0" ]; then + _code="" + _message="connection failed" + return 1 + fi + + #printf, not echo: dash's builtin echo interprets backslash escapes in record content + response="$(printf "%s\n" "$response" | _normalizeJson)" + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" + _debug2 _code "$_code" + _debug2 response "$response" + + case "$_code" in + 2*) return 0 ;; + esac + + _message="$(echo "$response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)" + _message="HTTP ${_code:-request failed}${_message:+: $_message}" + return 1 +}