#!/usr/bin/bash
# shellcheck disable=SC2034
dns_nexdns_info='NexDNS
Site: nexdns.tech
Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_nexdns
Options:
 NEXDNS_Token API token. Can be created at https://nexdns.tech/settings/api-keys
 NEXDNS_Api API base url. Default "https://api.nexdns.tech/v1". Optional.
Issues: github.com/acmesh-official/acme.sh/issues/7179
Author: NexDNS <https://github.com/nexdns>
'

NEXDNS_Api_Default="https://api.nexdns.tech/v1"

########  Public functions #####################

#Usage: dns_nexdns_add   _acme-challenge.www.example.com   "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_nexdns_add() {
  fulldomain=$1
  txtvalue=$2

  if ! _nexdns_init; then
    return 1
  fi

  _saveaccountconf_mutable NEXDNS_Token "$NEXDNS_Token"
  if [ "$NEXDNS_Api" != "$NEXDNS_Api_Default" ]; then
    _saveaccountconf_mutable NEXDNS_Api "$NEXDNS_Api"
  else
    _clearaccountconf_mutable NEXDNS_Api
  fi

  _debug "First detect the root zone"
  if ! _get_root "$fulldomain"; then
    _err "Cannot find the zone of $fulldomain in this NexDNS account."
    return 1
  fi
  _debug _sub_domain "$_sub_domain"
  _debug _domain "$_domain"
  _debug _domain_id "$_domain_id"

  _info "Adding the TXT record for $fulldomain"
  if ! _nexdns_rest POST "zones/$_domain_id/records" "{\"name\":\"$_sub_domain\",\"type\":\"TXT\",\"content\":\"$txtvalue\",\"ttl\":120}"; then
    return 1
  fi

  _info "The TXT record has been added."
  return 0
}

#Usage: dns_nexdns_rm   _acme-challenge.www.example.com   "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs"
dns_nexdns_rm() {
  fulldomain=$1
  txtvalue=$2

  if ! _nexdns_init; then
    return 1
  fi

  _debug "First detect the root zone"
  if ! _get_root "$fulldomain"; then
    _err "Cannot find the zone of $fulldomain in this NexDNS account."
    return 1
  fi
  _debug _sub_domain "$_sub_domain"
  _debug _domain "$_domain"
  _debug _domain_id "$_domain_id"

  _info "Removing the TXT record for $fulldomain"
  if ! _nexdns_rest GET "zones/$_domain_id/records?type=TXT&name=$_sub_domain"; then
    return 1
  fi

  #All the challenge records share one name and one type, so the value is the
  #only thing that tells them apart. A certificate covering example.com and
  #*.example.com puts two of them at the same name at the same time.
  _record_id="$(echo "$response" | tr '{' "\n" | grep -- "$txtvalue" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
  _debug _record_id "$_record_id"

  if [ -z "$_record_id" ]; then
    _info "The TXT record is already gone, nothing to remove."
    return 0
  fi

  if ! _nexdns_rest DELETE "zones/$_domain_id/records/$_record_id"; then
    return 1
  fi

  _info "The TXT record has been removed."
  return 0
}

####################  Private functions below ##################################

#Reads the token and the api url, and applies the default url.
_nexdns_init() {
  NEXDNS_Token="${NEXDNS_Token:-$(_readaccountconf_mutable NEXDNS_Token)}"
  NEXDNS_Api="${NEXDNS_Api:-$(_readaccountconf_mutable NEXDNS_Api)}"

  if [ -z "$NEXDNS_Token" ]; then
    _err "You have not set NEXDNS_Token yet."
    _err "Create one at https://nexdns.tech/settings/api-keys, on a plan that includes API access, then:"
    _err "export NEXDNS_Token=\"your-api-token\""
    return 1
  fi

  if [ -z "$NEXDNS_Api" ]; then
    NEXDNS_Api="$NEXDNS_Api_Default"
  fi
  #A trailing slash would make every request path begin with a double slash.
  NEXDNS_Api="$(echo "$NEXDNS_Api" | sed 's|/*$||')"
  _debug NEXDNS_Api "$NEXDNS_Api"

  return 0
}

#_acme-challenge.www.example.com
#returns
# _sub_domain=_acme-challenge.www
# _domain=example.com
# _domain_id=Zm9vYmFy
_get_root() {
  domain=$1
  i=1
  p=1

  while true; do
    h=$(printf "%s" "$domain" | cut -d . -f "$i"-100)
    _debug h "$h"
    if [ -z "$h" ]; then
      #not valid
      return 1
    fi

    if ! _nexdns_rest GET "zones?search=$h&per_page=100"; then
      return 1
    fi

    #search matches on a substring, so the page can also hold zones that merely
    #contain h. Take the id of the one whose name is exactly h.
    _domain_id="$(echo "$response" | tr '{' "\n" | grep "\"name\":\"$h\"" | _egrep_o '"id":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
    if [ "$_domain_id" ]; then
      _sub_domain=$(printf "%s" "$domain" | cut -d . -f 1-"$p")
      _domain=$h
      return 0
    fi

    p=$i
    i=$(_math "$i" + 1)
  done
}

#Usage: _nexdns_rest  GET|POST|DELETE  path  [body]  [attempt]
_nexdns_rest() {
  m=$1
  ep=$2
  data=$3
  attempt=${4:-1}
  _debug "$ep"

  export _H1="Authorization: Bearer $NEXDNS_Token"
  export _H2="Content-Type: application/json"
  export _H3="Accept: application/json"

  if [ "$m" = "GET" ]; then
    response="$(_get "$NEXDNS_Api/$ep")"
  else
    _debug2 data "$data"
    response="$(_post "$data" "$NEXDNS_Api/$ep" "" "$m" "application/json")"
  fi

  if [ "$?" != "0" ]; then
    _err "error $ep"
    return 1
  fi

  #A single certificate costs a handful of requests, but a renewal sweep over
  #many of them meets the account's per-minute budget, and that run is
  #unattended. Retry-After is treated as a floor: an api may report the time one
  #token needs at an average rate and name a second when nothing frees for a
  #minute, so the wait grows on its own across attempts.
  if [ "$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")" = "429" ]; then
    if [ "$attempt" -ge 4 ]; then
      _err "$m $ep failed: rate limited, and the wait budget is spent"
      return 1
    fi

    _retry_after="$(grep -i "^Retry-After" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d : -f 2 | tr -d " \r\n")"
    _backoff="$(_math "$attempt" \* 15)"
    #The header may also carry an http date. Anything but a plain count of
    #seconds falls through to the backoff rather than being parsed: guessing
    #wrong about a date is worse than waiting a known interval, and comparing a
    #date numerically would abort the hook outright.
    case "$_retry_after" in
    "" | *[!0-9]*) _retry_after="$_backoff" ;;
    *)
      if [ "$_retry_after" -lt "$_backoff" ]; then
        _retry_after="$_backoff"
      fi
      ;;
    esac

    #A wait longer than this is a refusal rather than a schedule, and sleeping
    #it out would hold the hook for the length of the window. Hand the run back
    #instead, so the next cron pass picks it up.
    if [ "$_retry_after" -gt 120 ]; then
      _err "$m $ep failed: rate limited for ${_retry_after}s, longer than this hook will wait"
      return 1
    fi

    _info "Rate limited by the NexDNS API; retrying in $_retry_after seconds."
    _sleep "$_retry_after"

    _nexdns_rest "$m" "$ep" "$data" "$(_math "$attempt" + 1)"
    return $?
  fi

  #Whitespace between a key and its value would defeat every match made on the
  #body, here and in the callers.
  response="$(echo "$response" | _normalizeJson)"
  _debug2 response "$response"

  #The status line decides success, not the body: a delete answers 204 with no
  #body at all, and a record whose own content contains "error": would otherwise
  #turn a stored value into a reported failure. The body is read only for the
  #message once the status says the request was rejected.
  _code="$(grep "^HTTP" "$HTTP_HEADER" 2>/dev/null | _tail_n 1 | cut -d " " -f 2 | tr -d "\r\n")"
  _debug2 _code "$_code"
  case "$_code" in
  "" | 2*)
    return 0
    ;;
  esac

  #A rejected request carries {"error":{"code":..,"message":..}}, so say what the
  #api says went wrong.
  _message="$(echo "$response" | _egrep_o '"message":"[^"]*"' | _head_n 1 | cut -d '"' -f 4)"
  if [ -z "$_message" ]; then
    _message="status $_code"
  fi
  _err "$m $ep failed: $_message"

  return 1
}
