diff --git a/dnsapi/dns_opteamax.sh b/dnsapi/dns_opteamax.sh new file mode 100644 index 00000000..0d8dcf18 --- /dev/null +++ b/dnsapi/dns_opteamax.sh @@ -0,0 +1,229 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_opteamax_info='Opteamax.de +Site: opteamax.de +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_opteamax +Options: + OPTEAMAX_Token API token. Create it in the customer panel under "API-Tokens"; it starts with "oxt_". + OPTEAMAX_Api API endpoint. Default "https://api.opteam.ax/api/v2". Optional. +Issues: github.com/acmesh-official/acme.sh/issues/7245 +Author: Jens Ott +' + +OPTEAMAX_Api_Default="https://api.opteam.ax/api/v2" + +######## Public functions ##################### + +#Usage: dns_opteamax_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_opteamax_add() { + fulldomain=$1 + txtvalue=$2 + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _opteamax_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + return 1 + fi + _debug _domain "$_domain" + _debug _domain_id "$_domain_id" + + _info "Adding the TXT record" + _opteamax_body="{\"type\":\"TXT\",\"name\":\"$fulldomain.\",\"content\":\"$txtvalue\",\"ttl\":300}" + if ! _opteamax_rest POST "/dns/domains/$_domain_id/records/" "$_opteamax_body"; then + return 1 + fi + if ! _contains "$response" "data_id"; then + _err "Could not add the TXT record: $response" + return 1 + fi + + _info "TXT record added" + return 0 +} + +#Usage: dns_opteamax_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_opteamax_rm() { + fulldomain=$1 + txtvalue=$2 + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _opteamax_init; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + return 1 + fi + _debug _domain "$_domain" + _debug _domain_id "$_domain_id" + + _debug "Getting the record id" + if ! _opteamax_rest GET "/dns/domains/$_domain_id/records/"; then + return 1 + fi + + # Only this challenge's record may go: a wildcard certificate puts two TXT + # values on the same name, and acme.sh removes them one call at a time. + # PowerDNS hands TXT content back in wire format, so the value arrives inside + # escaped quotes ("content": "\"\""); matching from the field name up + # to the next field keeps the value pinned to the content field without + # spelling out those backslashes. _head_n 1 guarantees a single id even if an + # aborted earlier run left the same value behind twice. + _record_id=$( + echo "$response" | _opteamax_split | + grep '"type": *"TXT"' | + grep "\"name\": *\"$(_opteamax_re "$fulldomain")\.\"" | + grep "\"content\":[^,]*$txtvalue" | + _egrep_o '"data_id": *"[^"]*"' | + sed 's/.*"data_id": *"//;s/"$//' | + _head_n 1 + ) + _debug _record_id "$_record_id" + + if [ -z "$_record_id" ]; then + _info "No such TXT record, nothing to remove." + return 0 + fi + + _info "Removing the TXT record" + if ! _opteamax_rest DELETE "/dns/domains/$_domain_id/records/$_record_id/"; then + return 1 + fi + + _info "TXT record removed" + return 0 +} + +#################### Private functions below ################################## + +# Read and check the credentials, and remember them for the renewal. +_opteamax_init() { + OPTEAMAX_Token="${OPTEAMAX_Token:-$(_readaccountconf_mutable OPTEAMAX_Token)}" + OPTEAMAX_Api="${OPTEAMAX_Api:-$(_readaccountconf_mutable OPTEAMAX_Api)}" + + if [ -z "$OPTEAMAX_Token" ]; then + _err "You have not set OPTEAMAX_Token yet." + _err "Create an API token in the customer panel under \"API-Tokens\" and export it:" + _err "export OPTEAMAX_Token=\"oxt_...\"" + return 1 + fi + + if [ -z "$OPTEAMAX_Api" ]; then + OPTEAMAX_Api="$OPTEAMAX_Api_Default" + else + # A trailing slash would make every request path a double slash, which + # Django answers with a redirect that drops the request body. + OPTEAMAX_Api=$(echo "$OPTEAMAX_Api" | sed 's|/*$||') + _saveaccountconf_mutable OPTEAMAX_Api "$OPTEAMAX_Api" + fi + _saveaccountconf_mutable OPTEAMAX_Token "$OPTEAMAX_Token" + return 0 +} + +#_acme-challenge.www.domain.com +#returns +# _domain=domain.com +# _domain_id=1234 +_get_root() { + domain=$1 + + # One request for the account's zones, then the name is walked up against + # them locally: the longest match wins, so a delegated subzone beats its + # parent. + if ! _opteamax_rest GET "/dns/domains/"; then + return 1 + fi + _zones=$(echo "$response" | _opteamax_split) + + i=1 + while true; do + h=$(printf "%s" "$domain" | cut -d . -f "$i"-100) + if [ -z "$h" ]; then + break + fi + + _domain_id=$( + echo "$_zones" | + grep "\"domain\": *\"$(_opteamax_re "$h")\.\{0,1\}\"" | + _egrep_o '"domain_id": *[0-9]*' | + tr -d ' ' | cut -d : -f 2 | _head_n 1 + ) + if [ "$_domain_id" ]; then + _domain="$h" + return 0 + fi + + i=$(_math "$i" + 1) + done + + # Only reached when the walk ran out of labels -- a failed request returns + # above, so this really does mean the account holds no zone for the name. + _err "Could not find a zone for $domain in your Opteamax account." + return 1 +} + +# Put one JSON object per line so a record's id can be read off the same line +# as its name and content. +_opteamax_split() { + sed 's/}, *{/}#{/g' | tr '#' '\n' +} + +# Escape a domain name for use in a grep pattern: the dots are literal. +_opteamax_re() { + echo "$1" | sed 's/\./\\./g' +} + +# method endpoint [body] +_opteamax_rest() { + m="$1" + ep="$2" + data="$3" + _debug "$ep" + + export _H1="Authorization: Bearer $OPTEAMAX_Token" + export _H2="Content-Type: application/json" + export _H3="Accept: application/json" + + if [ "$m" = "GET" ]; then + response="$(_get "$OPTEAMAX_Api$ep")" + else + _debug data "$data" + response="$(_post "$data" "$OPTEAMAX_Api$ep" "" "$m")" + fi + + if [ "$?" != "0" ]; then + _err "Error talking to $OPTEAMAX_Api$ep" + return 1 + fi + _debug2 response "$response" + + # A proxy or gateway error comes back as an HTML page with a 5xx status, and + # the http helpers only report transport failures -- so without this check the + # caller parses an error page as data and reports something misleading, such + # as the zone not existing. + case "$response" in + "{"* | "["*) ;; + *) + _err "Unexpected response from $OPTEAMAX_Api$ep (not JSON):" + _err "$(echo "$response" | _head_n 3)" + return 1 + ;; + esac + + # The API answers an authentication or permission problem with a JSON body + # and a 4xx status; the http helpers only report transport errors, so the + # body is what tells us the call was refused. + if _contains "$response" '"detail"'; then + _err "The API refused the request: $response" + return 1 + fi + + return 0 +}