diff --git a/dnsapi/dns_bergdns.sh b/dnsapi/dns_bergdns.sh new file mode 100644 index 00000000..8660b0d5 --- /dev/null +++ b/dnsapi/dns_bergdns.sh @@ -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 +' + +_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 +}