diff --git a/dnsapi/dns_dnsmint.sh b/dnsapi/dns_dnsmint.sh new file mode 100644 index 00000000..4f7d0031 --- /dev/null +++ b/dnsapi/dns_dnsmint.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env sh +# shellcheck disable=SC2034 +dns_dnsmint_info='DNSMint.com + DNSMint mints hostnames on domains it operates and serves from its own + authoritative nameservers, so records are published through its API rather + than a zone you run. +Site: dnsmint.com +Docs: github.com/acmesh-official/acme.sh/wiki/dnsapi2#dns_dnsmint +Options: + DNSMINT_API_KEY API key. dns01:write is enough to issue certificates. +Issues: github.com/acmesh-official/acme.sh/issues/7251 +Author: DNSMint +' + +DNSMint_Api="${DNSMint_Api:-https://dnsmint.com/api}" + +######## Public functions ##################### + +#Usage: dns_dnsmint_add _acme-challenge.q7k4m2.example.dev "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_dnsmint_add() { + fulldomain=$1 + txtvalue=$2 + + _info "Using DNSMint" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _dnsmint_key; then + return 1 + fi + + # An ACME challenge goes to the DNS-01 endpoint, which derives the hostname + # itself and needs only dns01:write. Any other name is an ordinary record + # under a hostname, which is a different endpoint and a wider scope. + if _startswith "$fulldomain" "_acme-challenge."; then + if _dnsmint_challenge present "$fulldomain" "$txtvalue"; then + _info "Added, OK" + return 0 + fi + return 1 + fi + + if _dnsmint_record_add "$fulldomain" "$txtvalue"; then + _info "Added, OK" + return 0 + fi + return 1 +} + +#Usage: dns_dnsmint_rm _acme-challenge.q7k4m2.example.dev "XKrxpRBosdIKFzxW_CT3KLZNf6q0HG9i01zxXp5CPBs" +dns_dnsmint_rm() { + fulldomain=$1 + txtvalue=$2 + + _info "Using DNSMint" + _debug fulldomain "$fulldomain" + _debug txtvalue "$txtvalue" + + if ! _dnsmint_key; then + return 1 + fi + + if _startswith "$fulldomain" "_acme-challenge."; then + if _dnsmint_challenge cleanup "$fulldomain" "$txtvalue"; then + _info "Removed, OK" + return 0 + fi + return 1 + fi + + if _dnsmint_record_rm "$fulldomain" "$txtvalue"; then + _info "Removed, OK" + return 0 + fi + return 1 +} + +#################### Private functions below ################################## + +_dnsmint_key() { + DNSMINT_API_KEY="${DNSMINT_API_KEY:-$(_readaccountconf_mutable DNSMINT_API_KEY)}" + if [ -z "$DNSMINT_API_KEY" ]; then + DNSMINT_API_KEY="" + _err "You did not specify DNSMINT_API_KEY yet." + _err "Create a key with the dns01:write scope at https://dnsmint.com/dashboard" + _err "e.g." + _err "export DNSMINT_API_KEY=dnsm_xxxxxxxxxxxx_xxxxxxxx" + return 1 + fi + _saveaccountconf_mutable DNSMINT_API_KEY "$DNSMINT_API_KEY" + return 0 +} + +_dnsmint_headers() { + export _H1="Authorization: Bearer $DNSMINT_API_KEY" + export _H2="Accept: application/json" + export _H3="Content-Type: application/json" +} + +# One request. Sets $response and $_code; returns non-zero on a transport error. +_dnsmint_rest() { + _m="$1" + _ep="$2" + _data="$3" + + _dnsmint_headers + if [ "$_m" = "GET" ]; then + response="$(_get "$DNSMint_Api$_ep")" + else + _secure_debug2 _data "$_data" + response="$(_post "$_data" "$DNSMint_Api$_ep" "" "$_m")" + fi + _ret="$?" + _code="$(grep "^HTTP" "$HTTP_HEADER" | _tail_n 1 | cut -d " " -f 2 | tr -d "\\r\\n")" + _debug "http response code $_code" + _debug2 response "$response" + if [ "$_ret" != "0" ]; then + _err "error $_ep" + return 1 + fi + case "$_code" in + 2*) return 0 ;; + *) + # The API says why in the body - a key narrowed to another hostname, a + # name that is not live - and that is more use than the status alone. + _err "error $_ep: HTTP $_code $response" + return 1 + ;; + esac +} + +# The DNS-01 endpoint. It derives the hostname from the challenge name, so +# there is no zone to look up and no record id to track: the value published +# is the value removed. +_dnsmint_challenge() { + _action="$1" + _fqdn="$2" + _value="$3" + _dnsmint_rest POST "/httpreq/$_action" "{\"fqdn\":\"$_fqdn\",\"value\":\"$_value\"}" +} + +# Everything below here is for names that are not ACME challenges. A record +# under a hostname is addressed by the hostname's id and a name relative to +# it, so the hostname has to be found first. +_dnsmint_host() { + _name="$1" + _host_id="" + _host_sub="" + + if ! _dnsmint_rest GET "/v1/hostnames?limit=500"; then + return 1 + fi + + for _h in $(echo "$response" | _egrep_o '"hostname":"[^"]*"' | cut -d'"' -f4); do + case "$_name" in + *".$_h") + # Longest suffix wins, so a.b.example.dev prefers b.example.dev over + # example.dev when both are hostnames on the account. + if [ "${#_h}" -gt "${#_host_sub}" ]; then + _host_sub="$_h" + fi + ;; + esac + done + + if [ -z "$_host_sub" ]; then + _err "$_name is not under a hostname on this account" + return 1 + fi + + # The id sits next to the hostname in the same object. + _host_id="$(echo "$response" | _egrep_o "\"id\":\"[^\"]*\",\"hostname\":\"$_host_sub\"" | cut -d'"' -f4)" + if [ -z "$_host_id" ]; then + _err "could not read the id for $_host_sub" + return 1 + fi + + _record_name="${_name%".$_host_sub"}" + _debug _host_sub "$_host_sub" + _debug _record_name "$_record_name" + return 0 +} + +_dnsmint_record_add() { + _name="$1" + _value="$2" + + if ! _dnsmint_host "$_name"; then + return 1 + fi + _dnsmint_rest POST "/v1/hostnames/$_host_id/records" \ + "{\"name\":\"$_record_name\",\"type\":\"TXT\",\"text\":\"$_value\"}" +} + +_dnsmint_record_rm() { + _name="$1" + _value="$2" + + if ! _dnsmint_host "$_name"; then + return 1 + fi + if ! _dnsmint_rest GET "/v1/hostnames/$_host_id/records"; then + return 1 + fi + + # Records come back as {"id":...,"name":"","type":"TXT","ttl":..., + # "data":{...,"text":[""]}}. Match on the value so a name holding + # several TXT records loses only the one that was added. + # + # The replacement carries a literal newline: "\n" there is a GNU extension + # and BSD sed inserts the letter n, which would leave the whole reply on one + # line and match the first record under the hostname whatever its value. + # + # echo rather than printf "%s": the reply arrives with no trailing newline, + # and Solaris /usr/bin/sed discards an incomplete final line. Here that line + # is the entire reply, so every removal would report the record already gone. + _records="$( + echo "$response" | sed 's/},{/}\ +{/g' + )" + + _rid="" + while IFS= read -r _line; do + case "$_line" in + *"\"$_value\""*) + # _head_n 1 because a record object may carry a nested id under "data", + # and two lines in _rid would break the DELETE URL. + _rid="$(echo "$_line" | _egrep_o '"id":"[^"]*"' | cut -d'"' -f4 | _head_n 1)" + if [ -n "$_rid" ]; then + break + fi + ;; + esac + done <