Adds dns_opteamax.sh, solving dns-01 challenges through the Opteamax customer API (api.opteam.ax). Authentication is a Bearer token created in the customer panel; the zone is detected by walking the name up against the account's zone list, so subzones and DNS alias mode both work, and rm only removes the value it was given so a wildcard's two TXT records survive each other.
230 lines
6.5 KiB
Bash
230 lines
6.5 KiB
Bash
#!/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 <jo@opteamax.de>
|
|
'
|
|
|
|
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": "\"<value>\""); 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
|
|
}
|