Compare commits

..
14 Commits
  • 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.
  • 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 <noreply@anthropic.com>
    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) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01G7ohY7h4RUSNJ2ES14NMmY
    
    ---------
    
    Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
  • Use UTF-16LE encoding in Fritzbox script (#7288)
    instead of the glibc specifc UTF16LE alias that does not exist libiconv
  • Add Healthchecks.io notify hook (#7282)
    * Add Healthchecks.io notify hook
    
    Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
    
    * 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 <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
  • 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.
  • 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.
  • 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.
  • 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
4 changed files with 567 additions and 40 deletions
+164 -38
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
VER=3.1.6
VER=3.1.7
PROJECT_NAME="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
@@ -2800,6 +2851,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 +2906,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 +2958,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
@@ -3365,7 +3439,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
@@ -3475,12 +3555,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
@@ -4420,9 +4510,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
@@ -4501,8 +4593,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
@@ -5590,11 +5689,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 +8027,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"
+2 -2
View File
@@ -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/^.*<Challenge>//' -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
+348
View File
@@ -0,0 +1,348 @@
#!/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 "60". "0" does not wait.
Issues: github.com/acmesh-official/acme.sh/issues/7261
Author: Kenny Kropp <https://github.com/kekropp>
'
_BERGDNS_DEFAULT_URL='https://bergdns.at/v1'
_BERGDNS_DEFAULT_TTL='60'
_BERGDNS_DEFAULT_WAIT='60'
######## 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)
_debug _bergdns_rrset_id "$_bergdns_rrset_id"
_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
# 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"
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#/*$##')
# 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"
# 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
}
# 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() {
_bergdns_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
_bergdns_zone_lines=$(echo "$response" | tr '{' '\n')
_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 "$_bergdns_cand" in
*.*) _bergdns_cand=${_bergdns_cand#*.} ;;
*) _bergdns_cand="" ;;
esac
done
_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
}
# 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() {
_bergdns_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
_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() {
_bergdns_fqdn=$1
if [ "$BERGDNS_PROPAGATION_TIMEOUT" = "0" ] || [ -z "$_bergdns_rrset_id" ]; then
return 0
fi
_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: $_bergdns_fqdn is served by every secondary after ${_bergdns_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
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
_bergdns_waited=$((_bergdns_waited + 5))
done
_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 and _bergdns_status. On failure also sets _bergdns_error and,
# where the API itself answered, _bergdns_code.
_bergdns_rest() {
_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"
_bergdns_url="$BERGDNS_API_URL/$_bergdns_endpoint"
_debug _bergdns_url "$_bergdns_url"
# 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
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 [ "$_bergdns_ret" != "0" ]; then
_bergdns_error="the request to $_bergdns_url could not be made"
return 1
fi
_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
_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
}
+53
View File
@@ -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
}