Files
acme.sh/dnsapi/dns_optidata.sh
T
Eduardo LangnerandClaude Opus 5 cb0ef1c30c Add Optidata Cloud DNS API (dns_optidata) (#7243)
* Add Optidata Cloud DNS API (dns_optidata)

* dns_optidata: portable TXT match, drop undocumented "internal" case

Review feedback on #7243:

1. grep -F is not portable: Solaris /usr/bin/grep has neither -F nor --.
   The DNS test only passed there because the CI job prepends
   /usr/gnu/bin to PATH. txtvalue is base64url ([A-Za-z0-9_-]), so it
   needs no JSON escaping and a case pattern matches it with a shell
   builtin, without depending on any grep extension.

2. Dropped the undocumented "internal" exception from the zone status
   notice in _get_root. It only silenced an _info message, and a dns-01
   challenge has to resolve publicly anyway, so an internal zone can
   never validate through this hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 11:48:31 +08:00

445 lines
16 KiB
Bash

#!/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 <https://github.com/optidatacloud>
'
# 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=<record fqdn> resolve the zone containing the name
# GET dns-zones/<zone_id> read one zone (OPTIDATA_Zone_ID)
# POST dns-zones/<zone_id>/recordsets create / upsert the TXT record set
# DELETE dns-zones/<zone_id>/recordsets?name&type&value remove one TXT value
#
# Every response is wrapped in {"success":true,"data":...}; errors are flat
# {"status_code":<n>,"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 '<list response>' '<lowercase fqdn>'
# 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 '<json>' 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 '<query without the leading ?>'
# 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
}