Compare commits

..
9 Commits
7 changed files with 53 additions and 605 deletions
+5 -28
View File
@@ -6,9 +6,6 @@ name: Mirror version tag
# pointing to the same object, so both forms exist.
# No retrigger loop: the tag filter never matches a "v"-prefixed tag, and
# refs created with GITHUB_TOKEN do not fire workflows anyway.
# The job mirrors first and then fails when the pushed tag is lightweight,
# which is what the release form produces: that tag can never carry a
# signature, so the failure has to be loud.
on:
push:
@@ -29,39 +26,19 @@ jobs:
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then
echo "Tag v$TAG already exists, nothing to do."
exit 0
fi
# Mirror the object the pushed tag actually points at: the commit
# for a lightweight tag, the tag object itself for an annotated or
# signed one. Pointing the mirror at the commit would strip the
# signature, so "git verify-tag v3.1.3" would fail while
# "git verify-tag 3.1.3" succeeds.
_ref="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq '.object.sha + " " + .object.type')"
sha="${_ref%% *}"
objtype="${_ref##* }"
sha="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.sha)"
if [ -z "$sha" ] || [ "$sha" = "null" ]; then
echo "Could not resolve refs/tags/$TAG"
exit 1
fi
if gh api "repos/$REPO/git/ref/tags/v$TAG" >/dev/null 2>&1; then
echo "Tag v$TAG already exists, nothing to do."
else
gh api "repos/$REPO/git/refs" -f ref="refs/tags/v$TAG" -f sha="$sha"
echo "Created tag v$TAG -> $sha"
fi
- name: Check that the tag is signable
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
run: |
# A release published from the GitHub UI creates the tag server-side
# as a lightweight ref, which points straight at a commit and can
# never carry a signature (3.1.5 shipped that way, see issue 7273).
# The tag has to be created locally with "git tag -s" and pushed
# BEFORE the release is published, then selected on the release form.
objtype="$(gh api "repos/$REPO/git/ref/tags/$TAG" --jq .object.type)"
if [ "$objtype" != "tag" ]; then
echo "::error::refs/tags/$TAG points at a $objtype, not a tag object. It is a lightweight tag and carries no signature. Recreate it locally with 'git tag -s $TAG' and push it before publishing the release."
exit 1
fi
echo "refs/tags/$TAG is a tag object."
+4 -4
View File
@@ -233,7 +233,7 @@ acme.sh -h
#### 🔏 Verify a Release
Release tags from `3.1.6` on are signed with the maintainer's SSH key. The
Release tags from `3.1.5` on are signed with the maintainer's SSH key. The
signing happens on the maintainer's machine, so the private key is never
available to CI. The public half is [`allowed_signers`](allowed_signers) in
this repository. From a clone:
@@ -243,7 +243,7 @@ git config gpg.ssh.allowedSignersFile allowed_signers
```
```bash
git verify-tag 3.1.6
git verify-tag 3.1.5
```
The signature covers the tag object, which pins the commit and therefore the
@@ -251,10 +251,10 @@ whole tree, so a good signature verifies every file at that release and no
separate tarball checksum is needed. Build a tarball from the verified tag:
```bash
git archive --format=tar.gz --prefix=acme.sh-3.1.6/ 3.1.6 > acme.sh-3.1.6.tar.gz
git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz
```
> ⚠️ Tags up to `3.1.5` are unsigned.
> ⚠️ Tags up to `3.1.4` predate the signing key and are unsigned.
---
+37 -163
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
VER=3.1.7
VER=3.1.5
PROJECT_NAME="acme.sh"
@@ -2140,7 +2140,6 @@ _resethttp() {
__HTTP_INITIALIZED=""
_ACME_CURL=""
_ACME_WGET=""
_ACME_WGET2=""
ACME_HTTP_NO_REDIRECTS=""
}
@@ -2191,15 +2190,7 @@ _inithttp() {
fi
if [ -z "$_ACME_WGET" ] && _exists "wget"; then
#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
@@ -2209,8 +2200,7 @@ _inithttp() {
_ACME_WGET="$_ACME_WGET --max-redirect 0 "
fi
if [ "$DEBUG" ] && [ "$DEBUG" -ge "2" ]; then
#the -d demultiplexing after each request expects wget 1.x output
if [ -z "$_ACME_WGET2" ] && _contains "$($_ACME_WGET --help 2>&1)" "--debug"; then
if [ "$_ACME_WGET" ] && _contains "$($_ACME_WGET --help 2>&1)" "--debug"; then
_ACME_WGET="$_ACME_WGET -d "
fi
fi
@@ -2230,30 +2220,6 @@ _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"
@@ -2349,15 +2315,7 @@ _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 [ "$_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")"
@@ -2383,9 +2341,6 @@ _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
@@ -2479,9 +2434,6 @@ _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
@@ -2549,9 +2501,6 @@ _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
@@ -2851,35 +2800,6 @@ _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"
@@ -2906,30 +2826,40 @@ _setopt() {
return 1
;;
esac
if ! __text="$(cat "$__conf")"; then
_err "Cannot read $__conf."
return 1
if [ -n "$(_tail_c 1 <"$__conf")" ]; then
echo >>"$__conf"
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="$(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="$(printf -- "%s\n" "$__text" | sed "s|^#$__opt$__sep.*$|$__opt$__sep$__val$__end|")" || __sed_err=1
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
_debug3 APP
__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
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
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"
fi
_debug3 "$(grep -n "^$__opt$__sep" "$__conf")"
}
@@ -2958,11 +2888,7 @@ _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
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
printf -- "%s\n" "$_conf_data" | sed "/^$_sdkey *=.*$/d" >"$_c_c_f"
else
_err "Config file is empty, cannot clear"
fi
@@ -3439,13 +3365,7 @@ __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
@@ -3555,22 +3475,12 @@ _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
@@ -4510,11 +4420,9 @@ _regAccount() {
_secure_debug3 _eab_kid "$_eab_kid"
_secure_debug3 _eab_hmac_key "$_eab_hmac_key"
_email="$(_getAccountEmail)"
#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 [ "$_email" ]; then
_savecaconf "CA_EMAIL" "$_email"
fi
if [ "$ACME_DIRECTORY" = "$CA_ZEROSSL" ]; then
if [ -z "$_eab_kid" ] || [ -z "$_eab_hmac_key" ]; then
@@ -4593,15 +4501,8 @@ _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
@@ -5689,9 +5590,11 @@ issue() {
_on_issue_err "$_post_hook"
return 1
fi
# 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
# 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
_info "ARI 'replaces' rejected by CA, retrying newOrder without 'replaces'."
if ! _send_signed_request "$ACME_NEW_ORDER" "$_newOrderObj}"; then
_err "Error creating new order."
@@ -8027,35 +7930,6 @@ _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 -4
View File
@@ -4,19 +4,17 @@
# maintainer and is never available to CI, so a compromise of the build
# pipeline cannot produce a tag that verifies against this file.
#
# Signing starts at 3.1.6; tags up to 3.1.5 are unsigned.
#
# Fingerprint: SHA256:M60qVafm/NUywQHXAkoQcj2v6KgkfrdSXv6mPejUUeE
#
# To verify a release tag, from a clone of this repository:
#
# git config gpg.ssh.allowedSignersFile allowed_signers
# git verify-tag 3.1.6
# git verify-tag 3.1.5
#
# A good signature covers the tag object, which pins the commit, which pins
# the whole tree -- so verifying the tag verifies every file at that
# release. Build a tarball from the verified tag with:
#
# git archive --format=tar.gz --prefix=acme.sh-3.1.6/ 3.1.6 > acme.sh-3.1.6.tar.gz
# git archive --format=tar.gz --prefix=acme.sh-3.1.5/ 3.1.5 > acme.sh-3.1.5.tar.gz
#
github@neilpang.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBTjI0HBJn3uhfT2DsNcFybfAZi3ADbIacMpz1BItKdB
+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 UTF-16LE | _digest md5 hex)"
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | iconv -f ASCII -t UTF16LE | _digest md5 hex)"
elif _exists uconv; then
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF-16LE | _digest md5 hex)"
_fritzbox_hash="$(printf "%s-%s" "${_fritzbox_challenge}" "${DEPLOY_FRITZBOX_PASSWORD}" | uconv -f ASCII -t UTF16LE | _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
@@ -1,348 +0,0 @@
#!/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
@@ -1,53 +0,0 @@
#!/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
}