diff --git a/dnsapi/dns_optidata.sh b/dnsapi/dns_optidata.sh new file mode 100644 index 00000000..757e2353 --- /dev/null +++ b/dnsapi/dns_optidata.sh @@ -0,0 +1,444 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_optidata_info='Optidata Cloud +Site: console.optidata.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_optidata +Options: + OPTIDATA_Token DNS API key. Create a key of kind DNS in the Optidata Console (API Keys); it starts with "ocs_". + OPTIDATA_Api API base URL. Default "https://console.optidata.com". + OPTIDATA_Location Location code or UUID of the zone. Only needed when the zone lives outside the account default location and the lookup cannot tell. Optional. + OPTIDATA_Zone_ID Zone ID. Pins the zone and skips the zone lookup. Optional. +Issues: github.com/acmesh-official/acme.sh/issues/7241 +Author: Eduardo Langner +' + +# Port of dnsapi/dns_cf.sh (CloudFlare) to the Optidata Cloud DNS API served by +# ocs-backend. Routes used, all under $OPTIDATA_Api/api/v1 and authenticated +# with the x-api-key header: +# +# GET dns-zones?name= resolve the zone containing the name +# GET dns-zones/ read one zone (OPTIDATA_Zone_ID) +# POST dns-zones//recordsets create / upsert the TXT record set +# DELETE dns-zones//recordsets?name&type&value remove one TXT value +# +# Every response is wrapped in {"success":true,"data":...}; errors are flat +# {"status_code":,"message":"...","error":"..."}. + +OPTIDATA_DEFAULT_API="https://console.optidata.com" +OPTIDATA_TTL=120 +OPTIDATA_MAX_ATTEMPTS=3 + +######## Public functions ##################### + +#Usage: dns_optidata_add _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_optidata_add() { + fulldomain=$1 + txtvalue=$2 + _info "Using Optidata Cloud DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _optidata_load_config; then + return 1 + fi + _optidata_save_config + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Adding TXT record $fulldomain" + # A record set is unique per (name, type) and the apex and wildcard + # challenges share the same name, so the second value has to be merged into + # the existing set: that is what upsert does. require_active_zone makes the + # API fail now (409) instead of queueing a record that would only be + # published after delegation, long after the ACME server gave up. + _body="{\"type\":\"TXT\",\"name\":\"$(_optidata_json_escape "$fulldomain")\",\"records\":[\"$(_optidata_json_escape "$txtvalue")\"],\"ttl\":$OPTIDATA_TTL,\"upsert\":true,\"require_active_zone\":true}" + if _optidata_rest POST "dns-zones/$_domain_id/recordsets$(_optidata_query "")" "$_body"; then + # The API echoes the whole record set back. The value is base64url + # ([A-Za-z0-9_-]), so it needs no JSON escaping and a case pattern matches + # it without depending on a grep that supports -F (Solaris grep does not). + case "$response" in + *"$txtvalue"*) + _info "Added, OK" + return 0 + ;; + esac + _err "The API accepted the record but the value is missing from the record set: $response" + return 1 + fi + _optidata_report_error "Add txt record error." + return 1 +} + +#Usage: dns_optidata_rm _acme-challenge.www.domain.com "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_optidata_rm() { + fulldomain=$1 + txtvalue=$2 + _info "Using Optidata Cloud DNS API" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _optidata_load_config; then + return 1 + fi + + _debug "First detect the root zone" + if ! _get_root "$fulldomain"; then + _err "invalid domain" + return 1 + fi + _debug _domain_id "$_domain_id" + _debug _sub_domain "$_sub_domain" + _debug _domain "$_domain" + + _info "Removing TXT record $fulldomain" + # Deleting by value keeps the other challenge value (wildcard + apex) in + # place; the API drops the whole record set once its last value goes away. + _q="name=$(printf "%s" "$fulldomain" | _url_encode)&type=TXT&value=$(printf "%s" "$txtvalue" | _url_encode)" + if _optidata_rest DELETE "dns-zones/$_domain_id/recordsets$(_optidata_query "$_q")"; then + if printf "%s\n" "$response" | tr -d " " | grep '"deleted":true' >/dev/null; then + _info "Removed, OK" + else + _info "Record value not found, nothing to remove." + fi + return 0 + fi + _optidata_report_error "Delete txt record error." + return 1 +} + +#################### Private functions below ################################## + +# Reads the settings from the environment or from the saved acme.sh config and +# validates them. Shared by add and rm, which run in separate subshells. +_optidata_load_config() { + OPTIDATA_Token="${OPTIDATA_Token:-$(_readdomainconf OPTIDATA_Token)}" + OPTIDATA_Token="${OPTIDATA_Token:-$(_readaccountconf_mutable OPTIDATA_Token)}" + OPTIDATA_Api="${OPTIDATA_Api:-$(_readaccountconf_mutable OPTIDATA_Api)}" + OPTIDATA_Location="${OPTIDATA_Location:-$(_readdomainconf OPTIDATA_Location)}" + OPTIDATA_Location="${OPTIDATA_Location:-$(_readaccountconf_mutable OPTIDATA_Location)}" + OPTIDATA_Zone_ID="${OPTIDATA_Zone_ID:-$(_readdomainconf OPTIDATA_Zone_ID)}" + OPTIDATA_Zone_ID="${OPTIDATA_Zone_ID:-$(_readaccountconf_mutable OPTIDATA_Zone_ID)}" + + # Keys are pasted with quotes or blanks often enough to be worth cleaning. + OPTIDATA_Token="$(printf "%s" "$OPTIDATA_Token" | tr -d '" ')" + if [ -z "$OPTIDATA_Token" ]; then + OPTIDATA_Token="" + _err "You did not specify OPTIDATA_Token yet." + _err "Create a DNS API key in the Optidata Console (API Keys) and export it:" + _err "export OPTIDATA_Token=ocs_xxxxxxxxxxxxxxxx" + return 1 + fi + if ! _startswith "$OPTIDATA_Token" "ocs_"; then + OPTIDATA_Token="" + _err 'OPTIDATA_Token must be an Optidata API key: it starts with "ocs_". Did you copy the entire key?' + return 1 + fi + + OPTIDATA_Api="${OPTIDATA_Api:-$OPTIDATA_DEFAULT_API}" + OPTIDATA_Api="$(printf "%s\n" "$OPTIDATA_Api" | sed 's:/*$::')" + case "$OPTIDATA_Api" in + http://* | https://*) ;; + *) + _err "OPTIDATA_Api must be an http(s) URL, e.g. $OPTIDATA_DEFAULT_API" + return 1 + ;; + esac + _debug OPTIDATA_Api "$OPTIDATA_Api" + _debug OPTIDATA_Location "$OPTIDATA_Location" + _debug OPTIDATA_Zone_ID "$OPTIDATA_Zone_ID" + return 0 +} + +# Persists the settings so renewals work without the environment, following +# dns_cf.sh: with a pinned zone the key lives in the domain config (so a +# zone-restricted key can be used per certificate), otherwise in the account +# config. +_optidata_save_config() { + if [ "$OPTIDATA_Zone_ID" ]; then + _savedomainconf OPTIDATA_Token "$OPTIDATA_Token" + _savedomainconf OPTIDATA_Zone_ID "$OPTIDATA_Zone_ID" + if [ "$OPTIDATA_Location" ]; then + _savedomainconf OPTIDATA_Location "$OPTIDATA_Location" + else + _cleardomainconf OPTIDATA_Location + fi + else + _saveaccountconf_mutable OPTIDATA_Token "$OPTIDATA_Token" + if [ "$OPTIDATA_Location" ]; then + _saveaccountconf_mutable OPTIDATA_Location "$OPTIDATA_Location" + else + _clearaccountconf_mutable OPTIDATA_Location + fi + _clearaccountconf_mutable OPTIDATA_Zone_ID + _clearaccountconf OPTIDATA_Zone_ID + fi + + if [ "$OPTIDATA_Api" != "$OPTIDATA_DEFAULT_API" ]; then + _saveaccountconf_mutable OPTIDATA_Api "$OPTIDATA_Api" + else + _clearaccountconf_mutable OPTIDATA_Api + fi +} + +#_acme-challenge.www.domain.com +#returns +# _sub_domain=_acme-challenge.www +# _domain=domain.com +# _domain_id=a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 +# _zone_location=3f2b46f2-4f14-44e2-8e21-1b6c17f2a9d1 (empty when the API does not report one) +_get_root() { + domain=$1 + _domain_lc="$(printf "%s\n" "$domain" | _lower_case | sed 's/\.$//')" + _domain="" + _domain_id="" + _sub_domain="" + _zone_location="" + _zone_status="" + + if [ "$OPTIDATA_Zone_ID" ]; then + _debug "Using the pinned zone" "$OPTIDATA_Zone_ID" + if ! _optidata_rest GET "dns-zones/$OPTIDATA_Zone_ID$(_optidata_query "")"; then + _optidata_report_error "Can not read zone $OPTIDATA_Zone_ID." + return 1 + fi + _zone_json="$response" + else + # The API returns every zone that contains the name, most specific first. + if ! _optidata_rest GET "dns-zones?name=$(printf "%s" "$_domain_lc" | _url_encode)"; then + _optidata_report_error "Zone lookup for $domain failed." + return 1 + fi + if printf "%s\n" "$response" | tr -d " " | grep '"data":\[\]' >/dev/null; then + _err "No Optidata DNS zone contains $domain." + _err "Check that the zone exists in this account and that the API key is allowed to access it." + return 1 + fi + # Pick the longest zone that is a suffix of the name ourselves as well, so + # an API that ignores the name filter still resolves the right zone. + _zone_json="$(_optidata_pick_zone "$response" "$_domain_lc")" + if [ -z "$_zone_json" ]; then + _err "No Optidata DNS zone contains $domain: $response" + return 1 + fi + fi + + _domain_id="$(_optidata_json_string "$_zone_json" id)" + _domain="$(_optidata_json_string "$_zone_json" zone_name)" + if [ -z "$_domain" ]; then + _domain="$(_optidata_json_string "$_zone_json" name)" + fi + _domain="$(printf "%s\n" "$_domain" | _lower_case | sed 's/\.$//')" + _zone_location="$(_optidata_json_string "$_zone_json" location)" + _zone_status="$(_optidata_json_string "$_zone_json" status)" + _debug _zone_location "$_zone_location" + _debug _zone_status "$_zone_status" + + if [ -z "$_domain_id" ] || [ -z "$_domain" ]; then + _err "Could not read the zone id and name from the API response: $response" + return 1 + fi + + if [ "$_domain_lc" = "$_domain" ]; then + _sub_domain="" + else + case "$_domain_lc" in + *".$_domain") + _sub_domain="${_domain_lc%".$_domain"}" + ;; + *) + _err "Zone $_domain ($_domain_id) does not contain $domain." + return 1 + ;; + esac + fi + + if [ "$_zone_status" ] && [ "$_zone_status" != "ACTIVE" ]; then + _info "Zone $_domain has status $_zone_status; records are only published once the zone is ACTIVE (delegated to the Optidata name servers)." + fi + return 0 +} + +# Usage: _optidata_pick_zone '' '' +# Prints the JSON of the zone with the longest name that is the fqdn itself or +# one of its parents. Zones are flat objects, so splitting on "},{" is safe. +_optidata_pick_zone() { + _pz_json="$1" + _pz_name="$2" + _pz_objects="$(printf "%s\n" "$_pz_json" | sed 's/}, *{/}\ +{/g')" + _pz_count="$(printf "%s\n" "$_pz_objects" | wc -l | tr -d " ")" + _pz_best="" + _pz_best_len=0 + _pz_i=1 + while [ "$_pz_i" -le "$_pz_count" ]; do + _pz_obj="$(printf "%s\n" "$_pz_objects" | sed -n "${_pz_i}p")" + _pz_zone="$(_optidata_json_string "$_pz_obj" zone_name)" + if [ -z "$_pz_zone" ]; then + _pz_zone="$(_optidata_json_string "$_pz_obj" name)" + fi + _pz_zone="$(printf "%s\n" "$_pz_zone" | _lower_case | sed 's/\.$//')" + if [ "$_pz_zone" ]; then + case "$_pz_name" in + "$_pz_zone" | *".$_pz_zone") + if [ "${#_pz_zone}" -gt "$_pz_best_len" ]; then + _pz_best="$_pz_obj" + _pz_best_len="${#_pz_zone}" + fi + ;; + esac + fi + _pz_i=$(_math "$_pz_i" + 1) + done + printf "%s" "$_pz_best" +} + +# Usage: _optidata_json_string '' key +# Prints the string value of the first "key" in the JSON, nothing when the key +# is absent or not a string (e.g. "location":null). +_optidata_json_string() { + printf "%s\n" "$1" | _egrep_o "\"$2\": *\"[^\"]*\"" | _head_n 1 | sed 's/^"[^"]*": *"//; s/"$//' +} + +# Escapes a value for use inside a JSON string literal. +_optidata_json_escape() { + printf "%s\n" "$1" | sed 's/\\/\\\\/g; s/"/\\"/g' +} + +# Usage: _optidata_query '' +# Appends the location (explicit OPTIDATA_Location, else the one reported by +# the zone lookup) and prints the query string with its leading "?", or +# nothing when there is nothing to send. +_optidata_query() { + _oq="$1" + _oq_loc="${OPTIDATA_Location:-$_zone_location}" + if [ "$_oq_loc" ]; then + if [ "$_oq" ]; then + _oq="$_oq&location=$(printf "%s" "$_oq_loc" | _url_encode)" + else + _oq="location=$(printf "%s" "$_oq_loc" | _url_encode)" + fi + fi + if [ "$_oq" ]; then + printf "?%s" "$_oq" + fi +} + +# Usage: _optidata_rest METHOD 'endpoint under /api/v1' [json body] +# Sets $response to the normalized JSON body. Returns 0 on a success envelope; +# otherwise sets $_optidata_status / $_optidata_message and returns 1. Rate +# limits and upstream hiccups (429, 502-504) are retried a few times. +_optidata_rest() { + _m=$1 + _ep=$2 + _data=$3 + _debug "$_m $_ep" + + # Hooks share one shell and _get/_post always send _H1 to _H5, so the unused + # slots have to be cleared: otherwise a previous provider's header (an auth + # header, for instance) is sent to the Optidata endpoint. + export _H1="Accept: application/json" + export _H2="Content-Type: application/json" + export _H3="x-api-key: $OPTIDATA_Token" + export _H4="" + export _H5="" + + _url="$OPTIDATA_Api/api/v1/$_ep" + _optidata_status="" + _optidata_message="" + _attempt=1 + while true; do + # A failed request leaves the previous status line in the header file, which + # would then be read as this request's response code. + if [ -z "$HTTP_HEADER" ]; then + _err "HTTP header file is not initialized" + return 1 + fi + : >"$HTTP_HEADER" || return 1 + if [ "$_m" = "GET" ]; then + response="$(_get "$_url")" + else + _debug2 data "$_data" + response="$(_post "$_data" "$_url" "" "$_m")" + fi + _ret="$?" + if [ "$_ret" != "0" ]; then + _err "Request to $_url failed. Is OPTIDATA_Api correct and reachable?" + return 1 + fi + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d '\r\n')" + _debug "http response code" "$_code" + response="$(printf "%s\n" "$response" | _normalizeJson)" + _debug2 response "$response" + + if printf "%s\n" "$response" | tr -d " " | grep '"success":true' >/dev/null; then + return 0 + fi + + _optidata_status="$(printf "%s\n" "$response" | _egrep_o '"status_code": *[0-9]*' | _head_n 1 | cut -d : -f 2 | tr -d " ")" + if [ -z "$_optidata_status" ]; then + _optidata_status="$_code" + fi + _optidata_message="$(_optidata_json_string "$response" message)" + if [ -z "$_optidata_message" ]; then + # Validation errors carry an array of messages. + _optidata_message="$(printf "%s\n" "$response" | _egrep_o '"message": *\[[^]]*\]' | _head_n 1 | sed 's/^"message": *\[//; s/\]$//' | tr -d '"')" + fi + + case "$_optidata_status" in + 429 | 502 | 503 | 504) + if [ "$_attempt" -lt "$OPTIDATA_MAX_ATTEMPTS" ]; then + _wait="$(grep -i "^Retry-After:" "$HTTP_HEADER" | _tail_n 1 | cut -d : -f 2 | tr -d ' \r\n')" + case "$_wait" in + '' | *[!0-9]*) _wait=5 ;; + esac + if [ "$_wait" -gt 60 ]; then + _wait=60 + fi + _info "Optidata API answered HTTP $_optidata_status; retrying in ${_wait}s (attempt $_attempt of $OPTIDATA_MAX_ATTEMPTS)." + _sleep "$_wait" + _attempt=$(_math "$_attempt" + 1) + continue + fi + ;; + esac + return 1 + done +} + +# Usage: _optidata_report_error 'what failed' +# Logs the API error captured by _optidata_rest plus a hint for the usual causes. +_optidata_report_error() { + _err "$1" + if [ "$_optidata_message" ]; then + _err "Optidata API answered HTTP ${_optidata_status:-?}: $_optidata_message" + elif [ "$_optidata_status" ]; then + _err "Optidata API answered HTTP $_optidata_status: $response" + fi + case "$_optidata_status" in + 401) + _err "Check OPTIDATA_Token: it must be a valid, enabled Optidata API key (it starts with ocs_). Did you copy the entire key?" + ;; + 402) + _err "The account is blocked for billing reasons. Check the payment method in the Optidata Console." + ;; + 403) + _err "The key must be a DNS API key with the dns_zones scope, the permissions zones_read, records_create, records_update and records_delete, and access to this zone." + ;; + 404) + _err "The zone was not found. If it lives outside the account default location, set OPTIDATA_Location to its location code or UUID." + ;; + 409) + _err "The zone is not delegated to the Optidata name servers yet. Point the domain NS records to them and retry once the zone status is ACTIVE." + ;; + 429) + _err "The Optidata API rate limit was reached. Retry in a minute." + ;; + esac +}