#!/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 }