#!/bin/bash
# RFC 2136 dynamic dns updater
# Requires bind9-dnsutils
if [ $# -eq 0 ]; then
  echo "Usage: $0 <zone> [record name] [record type] [record data...]
  Unset RNAME or RTYPE will print full AXFR listing
  Unset RDATA will cause record deletion
  Set RDATA will set up a delete/add transaction

  example: $0 example.org www.debug MX 5 mail.example.org.

\"keyname\", \"keysig\", and \"keydata\" must either be provided as environment variables, e.g.:
 keyname=\"bob\" keysig=\"hmac-sha512\" keydata=\"Base64+Encoded+Data==\" $0
...or as lowercase ~/.netrc entries. e.g.:
  machine ns1.example.org keyname bob keysig hmac-sha512 keydata Base64+Encoded+Data==
  "

  exit 1
fi

set -e
set -x

#shopt -s nocasematch # bashism
# bash can advantageously do string comparison tests case-insensitively using [[ "" == "" ]]

ZONE="$1"
RNAME="$2"
RTYPE="$3"
#RDATA="$4" # handled below

if [ -z "$AUTHORITY" ]; then # identify zone authority if not set by user environment
  SOA="$(dig +short -t SOA "$ZONE")" # Start Of Authority
  AUTHORITY="${SOA%% *}" # substring first word
fi
if [ -z "$NS" ]; then # identify zone nameservers if not set by user environment
  NS="$(dig +short -t NS "$ZONE" @"$AUTHORITY")" # Name Servers
fi

# Dig up TSIG key from the environment or netrc
netrc_match() {
  NETRC_MATCH_HOSTS="$1"
  NETRC_MATCH_PORTS="${2:-21 990}"
  NETRC_MATCH_KEYS="${3:-login password}"
  if [ -n "$4" ]; then
    NETRC="$4"
  elif [ -z "$NETRC" ]; then
    NETRC=~/.netrc
  fi
  # Let IFS take care of all whitespace deduplication and equivalence for us
  for word in $( while IFS= read -r line; do printf '%s\n' "$line"; done <"$NETRC" ); do # bash: $(<"$NETRC")
    if [ "$mode" = "value" ]; then
      value="$word"
      mode="key" # next $word is key
      if [ "$key" = machine ]; then # bash: nocasematch
        unset    host_matched
        unset    port_matched
        unset machine_matched
        host="${value%:*}" # filter everything after first :
        port="${value#*:}" # filter everything before last :
        if [ "$host" = "$port" ]; then # if there was no :
          port_matched=y               # wildcard match
        else
          for match_port in $NETRC_MATCH_PORTS; do
            [ "$match_port" = "$port" ] && port_matched=y && break
          done
        fi
        [ -n "$port_matched" ] || continue
        for match_host in $NETRC_MATCH_HOSTS; do
          # it's ambiguous how relative-vs-absolute hostnames should be compared
          [ "${match_host#"${match_host%?}"}" = "." ] && match_host="${match_host%.*}" # normalize to relative hostname
          [ "${host#"${host%?}"}"             = "." ] &&       host="${host%.*}"       # normalize to relative hostname
          [ "$match_host"    = "$host"    ] && host_matched=y && break   # compare relative hostnames # bash: nocasematchify
          #[ "${match_host}." = "${host}." ] && host_matched=y && break   # compare absolute hostnames # bash: nocasematchify
        done
        [ -n "$host_matched" ] && [ -n "$port_matched" ] && machine_matched=y
      elif [ -n "$machine_matched" ]; then
        unset todo
        for match_key in $NETRC_MATCH_KEYS; do
          if [ "$key" = "$match_key" ]; then # bash: nocasematchify
            eval "NETRC_KEY_$match_key=\"\$value\"" # bash: printf -v $match_key "%s" $value
          fi
          [ -z "$( eval echo \"\$NETRC_KEY_"$match_key"\" )" ] && todo=y # bash: ${!match_key}
        done
        [ -z $todo ] && break # no NETRC_MATCH_KEYS left unassigned
      fi
    else # mode == "key"
      key="$word"
      case "$key" in
        default) machine_matched=y ;; # "default" key does not have a value, but it does intrinsically match
        *) mode="value" ;; # next word is value
      esac
    fi
  done
}

if [ -z "$keyname" ] || [ -z "$keysig" ] || [ -z "$keydata" ]; then
  netrc_match "$AUTHORITY $ZONE" "53" "keyname keysig keydata"
  keyname="$NETRC_KEY_keyname"
  keysig="$NETRC_KEY_keysig"
  keydata="$NETRC_KEY_keydata"
fi

if [ $# -ge 3 ]; then

  if [ $# -ge 4 ]; then
    # RDATA might need careful quoting
    # TXT records can technically have multiple separate quoted text fields, but I'm not implementing that until someone pipes up with a usecase.
    case "$RTYPE" in
      TXT) RDATA="\"${@:4}\"" ;;
      *)   RDATA="${@:4}" ;;
    esac
    ADDLINE="update add    $RNAME.$ZONE 3600 IN $RTYPE $RDATA"
  fi

  nsupdate -v <<END_DNS
server $AUTHORITY
zone $ZONE
key $keysig:$keyname $keydata
update delete $RNAME.$ZONE      IN $RTYPE
$ADDLINE
send
END_DNS

fi

# Print full AXFR record table
# using -k and a builtin to avoid cmdline publication

dig +norecurse -k <( printf 'key "%s" { algorithm %s; secret "%s"; };' "$keyname" "$keysig" "$keydata" ) -t AXFR @"$AUTHORITY" "$ZONE"

# Report master/slave propagation status
if [ $# -ge 3 ]; then
  [ $# -eq 3 ] && set +e # if deleting a record, tolerate record query errors
  host -r -t "$RTYPE" "$RNAME.$ZONE" "$AUTHORITY"
  sleep 1 # provide time to propagate
  for SERVER in $NS; do
    #[ "$SERVER" != "$AUTHORITY" ] && echo -n "$SERVER: " && dig +norecurse +short -t "$RTYPE" "$RNAME.$ZONE" @"$SERVER"
    [ "$SERVER" != "$AUTHORITY" ] && host -r -t "$RTYPE" "$RNAME.$ZONE" "$SERVER"
  done
fi
