Intro (skipable)

Good news: I’ve received my first non-spam email through this blog and thus gained a collaborator for my Dynadot-DNS-Bash project!

GitHub-user pradoh97 got in touch with me, offering an improved version of my dynadot-api-bash script from my previous blog post.

They’ve noticed that my original code couldn’t handle special URI characters, as well as mx-records, for which the API actually expects two values. So they made some (much needed) adjustments. Since they didn’t find a repo of the code for a pull-request[1], they created their own repository for it and kindly invited me as an collaborator in it.

The New Script

Over the last few days, pradoh97 kindly reworked my primitive first draft into something much better looking and better working. At the time of writing, this is their current version of the code:

[Click to Open] Pradoh97’s modified version of the initial script (Commit bdcb610)
#!/bin/bash
#includes TLD: example.com
domain=$(expr match "$CERTBOT_DOMAIN" '.*\.\(.*\..*\)')

#Extracts a subdomain, for www.example.com it would extract www
subdomain=$(expr match "$CERTBOT_DOMAIN" '\(.*\)\..*\..*')

apiKey=$(cat ./apikey)
apiUrl="https://api.dynadot.com/api3.json"

#Response of the get_dns and set_dns2 methods from Dynadot API
getResponseFile="./getResponse.xml"
setResponseFile="./setResponse.xml"

logFile="./logfile.log"
domainNodes="/GetDnsResponse/GetDnsContent/NameServerSettings"
maindomainNodes="$domainNodes/MainDomains"
subdomainNodes="$domainNodes/SubDomains"

# Initialize empty string for storing the previous main domain records in API-call format
mainRecords=""

# Initialize empty string for storing the subdomain records in API-call format
subRecords=""

# Control flag to check if the record already exists:
# 0: did not exist
# 1: did exist
recordExists=0

# Control flag to check if the record already exists and needs to be changed:
# 0: did not change
# 1: did change
recordChanged=0

# Dynadot index for the record to be modified or created
recordIndex=0

# Index for the last record
lastRecordIndex=0

#The subdomain key in dynadot
newRecord="_acme-challenge.$subdomain"
[[ "${subdomain}" == '*' ]]  && newRecord="_acme-challenge"

###
# Writes to a log file ($logFile), by default it writes <hh:mm:ss message>.
# $1 the <message> to be writen
#
# $2: set it to 1 to include the day, month and year before hh:mm:ss
# omit or use any other value to keep the default format.
function writeLog(){
  if [ ! -z $2 ] && [ $2 -eq 1 ]; then
    # Output includes the day, month and year
    echo "[$(date +'%D %H:%M:%S')] $1" >> $logFile
  else
    echo "[$(date +'%H:%M:%S')] $1" >> $logFile
  fi
}
writeLog '----------Begin log----------' 1
writeLog "Will attempt creating $newRecord for $domain with value $CERTBOT_VALIDATION"

#Installs libxml2-utils and jq
function installPrereqs(){
  libxmlInstalled=$(apt -qq list libxml2-utils 2>/dev/null | grep "instal")
  jqInstalled=$(apt -qq list jq 2>/dev/null | grep "instal")
  
  if [[ ! -n $libxmlInstalled ]]; then
    writeLog "Installing libxml2-utils"
    apt install libxml2-utils -y
  fi

  if [[ ! -n $jqInstalled ]]; then
    writeLog "Installing jq"
    apt install jq -y
  fi
}

#Get dns current settings
function getCurrentDNSSetings(){
  curl -s -o $getResponseFile "https://api.dynadot.com/api3.xml?key=${apiKey}&command=get_dns&domain=${domain}"

  #Check if response is valid
  responseCode="$(echo "cat /GetDnsResponse/GetDnsHeader/ResponseCode/text()" | xmllint --nocdata --shell ${getResponseFile} | sed '1d;$d')"
  if [ "$responseCode" -ne 0 ]; then
      writeLog "Error: Response Code $responseCode"

      #Api keys from dynadot are 42 chars long
      if [ ${#apiKey} -lt 42 ];then
        writeLog "The API key should be 42 characters long, this one is ${#apiKey}"
      fi

      exit 1
  fi
}

# Create a string with the API-call format of the main records from the current config.
function formatMainRecords(){
  mainEntriesCount="$(xmllint --xpath "count($maindomainNodes/*)" $getResponseFile)"

  # Iterate through main domain records
  index=0
  while [ $index -lt "$mainEntriesCount" ]; do

      # XML-nodes index starts at 1
      xmlIndex=$index+1

      # Read and store the type of the current main record
      type="$(echo "cat $maindomainNodes/MainDomainRecord[$xmlIndex]/RecordType/text()" | xmllint --nocdata --shell $getResponseFile | sed '1d;$d')"
      
      # Make the type lowercase and removes trailing or leading spaces
      type=${type,,}
      type=$(echo "$type" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')

      # Read and store the value of the current main record
      value="$(echo "cat $maindomainNodes/MainDomainRecord[$xmlIndex]/Value/text()" | xmllint --nocdata --shell $getResponseFile | sed '1d;$d')"
      
      # Encode special chars for parameters
      value=$(jq -rn --arg x "$value" '$x|@uri')

      # Reformat the received data into the needed API-format and append it to the mainRecords variable
      if [[ $type == "mx" ]]; then
        
        value2="$(echo "cat $maindomainNodes/MainDomainRecord[$xmlIndex]/Value2/text()" | xmllint --nocdata --shell $getResponseFile | sed '1d;$d')"
        value2=$(jq -rn --arg x "$value2" '$x|@uri')
        
        mainRecords+="&main_record_type$index=$type&main_record$index=$value&main_recordx$index=$value2"

      else
        mainRecords+="&main_record_type$index=$type&main_record$index=$value"
      fi

      ((index++))
  done
}

function formatSubRecords(){
  subEntriesCount="$(xmllint --xpath "count($subdomainNodes/*)" $getResponseFile)"

  # Iterate through subdomain records
  index=0
  while [ $index -le "$subEntriesCount" ]; do

      # IMPORTANT: The XML-nodes index starts at 1!
      xmlIndex=$index+1

      subhost="$(echo "cat $subdomainNodes/SubDomainRecord[$xmlIndex]/Subhost/text()" | xmllint --nocdata --shell $getResponseFile | sed '1d;$d')"
      type="$(echo "cat $subdomainNodes/SubDomainRecord[$xmlIndex]/RecordType/text()" | xmllint --nocdata --shell $getResponseFile | sed '1d;$d')"
      
      #Makes the type lowercase and removes trailing or leading spaces
      type=${type,,}
      type=$(echo "$type" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')

      value="$(echo "cat $subdomainNodes/SubDomainRecord[$xmlIndex]/Value/text()" | xmllint --nocdata --shell $getResponseFile | sed '1d;$d')"

      if [ -n "$type" ]; then
        # Check if the record exists and replace the current value.
        if [ "$subhost" = $newRecord ] && [ $type == "txt" ]; then
            # Set the flag to indicate that the record exists
            recordExists=1

            if [ $value != "$CERTBOT_VALIDATION" ]; then
              # Overwrite the value that is stored in the TXT-record to the needed challenge key
              value="$CERTBOT_VALIDATION"

              # Unset flag to indicate that a record was indeed changed
              recordChanged=1
              recordIndex=$index
            fi
            echo "Found"
        fi

        # Encode special chars for parameters
        value=$(jq -rn --arg x "$value" '$x|@uri')

        # Reformat the received data into the needed API-format and append it to the subRecords variable
        if [[ $type == "mx" ]]; then
          value2="$(echo "cat $subdomainNodes/SubDomainRecord[$xmlIndex]/Value2/text()" | xmllint --nocdata --shell $getResponseFile | sed '1d;$d')"
          value2=$(jq -rn --arg x "$value2" '$x|@uri')

          subRecords+="&subdomain$index=$subhost&sub_record_type$index=$type&sub_record$index=$value&sub_recordx$index=$value"
        else
          subRecords+="&subdomain$index=$subhost&sub_record_type$index=$type&sub_record$index=$value"
        fi
      fi
      
      lastRecordIndex=$index
      ((index++))
  done
}

# Returns 1 if there are changes to be pushed via set_dns2 API call.
# Returns 0 if no changes are needed (does not do an API call)
function changesIntroduced(){
  
  doChanges=0

  if [ $recordExists -eq 0 ]; then
    doChanges=1
    $recordIndex = $lastRecordIndex
    writeLog "Challenge record $newRecord not found."
    writeLog "Will create $newRecord on index $recordIndex with value $CERTBOT_VALIDATION."
  fi
  if [ $recordChanged -eq 1 ]; then
    doChanges=1
    writeLog "Challenge record $newRecord found."
    writeLog "Will replace $newRecord value, on index $recordIndex, with $CERTBOT_VALIDATION."
  fi

  subRecords+="&subdomain$recordIndex=$newRecord&sub_record_type$recordIndex=txt&sub_record$recordIndex=$CERTBOT_VALIDATION"

  echo $doChanges
}

installPrereqs
getCurrentDNSSetings
formatMainRecords
formatSubRecords

if [ $(changesIntroduced) -eq 1 ]; then
  # Combine everything into one api command/request
  apiRequest="key=$apiKey&command=set_dns2&domain=$domain$mainRecords$subRecords"

  # Combine api-url and -request into the finished command
  fullRequest="$apiUrl?$apiRequest"
  curl -s "$fullRequest" > $setResponseFile

  # For DNS propagation
  sleep 60
fi

Changelog

  • lowercase and trim of record types (so "MX" is now "mx")

  • added a secondary value for priority

  • encode URI special characters, so a parameter with the following value zone=south would be encoded into zone%20south, otherwise the request is illformed

  • reduced the offset of the while loop so there’s no gap between the last record and the new one. If the last record was 2nd, it would create the 4th rather than the 3rd.

  • use of certbot environment variables

  • camelcased all the variables (this of course changes nothing at all)

  • added prerequisites installation

  • added timestamps to log file

  • moved code to functions

  • added validation for api call

  • separate responses for get and set

  • added API length check

TODO-List

Pradoh97 also started a TODO-list of things we still need to do at some point.

What’s to Come

Neither pradoh97 nor me have a lot of free time, but we’ll try to get some work done on the script and I’ll see if I can’t set aside a few hours to finally start working on a fully automated setup with certbot. If anyone reading this is interested in helping out in any way, throw me an email, open a pull-request or issue, or send us a carrier pigeon.


1. Because I didn’t create one…​ Did I mention already I’m an IT-Neanderthal?