#!/bin/bash
# run with -h to see help
VERSION="3.4.3 [13 Mar 2026]"
CONFFILE="$(dirname "$0")/$(basename "$0" .sh)-$(hostname).conf"
set -o pipefail
COLUMNS=$(stty size 2>/dev/null||echo 80); COLUMNS=${COLUMNS##* }
TTL=3600
TEST="y"

valid_ipv4() {
	# Return code 0 if parameter1 is valid dotted decimal ipv4, 2 if it is valid CIDR (/16->/32), else 1. No text output.
	# If optional parameter2 is set (to anything) and parameter1 is not a CIDR then it treats a valid 'reserved' ip
	#   (e.g. local LAN ip) as invalid - see https://en.wikipedia.org/wiki/Reserved_IP_addresses
	# Example      : valid_ipv4 $ip no_local || echo "'$ip' is not a valid global ip"
	# Notes        : return code 1 for unusual formats e.g. leading zeroes (032.013.123.345) or with spaces (19. 23.  3.100)
	#                derived from code at https://stackoverflow.com/questions/50084229/bash-check-if-cidr-address-is-valid
	# Version      : v2.60312
	local IFS
	local IP
	local QUATRAIN
	local OK_RETURN_CODE
	OK_RETURN_CODE=0
	echo "$1"|grep -q "[^./[:digit:]]" && { [[ -n $DEBUG ]] && echo "Not all digits or slashes or full stops"; return 1; } # fail if not all digits slashes or full stops
	echo "$1"|grep -q "\b0[0-9]" && { [[ -n $DEBUG ]] && echo "Leading zeroes"; return 1; } # fail if any quatrains start with leading zeroes (but an actual zero quatrain is permitted)
	[[ $(echo "$1"|awk -F'.' '{print NF}') -eq 4 ]] || { [[ -n $DEBUG ]] && echo "Not 4 dot-separated quatrains"; return 1; } # fail if not 4 dot-separated quatrains
	# Parse "a.b.c.d/n" into five separate variables
	IFS="./" read -ra QUATRAIN <<< "$1"
	[[ ${#QUATRAIN[@]} -eq 4 || ${#QUATRAIN[@]} -eq 5 ]] || return 1 # fail if there are not 4 (for IP) or 5 (for CIDR) extracted values
	# Convert IP address from quad notation to integer
	IP=$((QUATRAIN[0] * 256 ** 3 + QUATRAIN[1] * 256 ** 2 + QUATRAIN[2] * 256 + QUATRAIN[3]))
	if [[ -z ${QUATRAIN[4]} ]]; then
		QUATRAIN[4]=32 # not a CIDR so simulate with '/32'
	elif [[ ${QUATRAIN[4]} -ge 0 && ${QUATRAIN[4]} -le 32 ]]; then
		OK_RETURN_CODE=2 # valid CIDR suffix
	else
		return 1 # bad CIDR suffix
	fi
	# Remove upper bits and check that all $N lower bits are 0
	if [[ $((IP % 2**(32-QUATRAIN[4]))) == 0 ]]; then
		if [[ $OK_RETURN_CODE -eq 0 && -n $2 ]]; then
			# exclude reserved IPs (not if CIDR)
			OK_RETURN_CODE=$(( 1 - $(echo "$1"|grepcidr -cxve '0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.0.0.0/24 192.0.2.0/24 192.88.99.0/24 192.168.0.0/16 198.18.0.0/15 198.51.100.0/24 203.0.113.0/24 224.0.0.0/4 233.252.0.0/24 240.0.0.0/4 255.255.255.255/32') ))
		fi
		return $OK_RETURN_CODE
	else
		return 1 # NOT OK!
	fi
}


digmulti() {
	# supply server as @$1 and dns name to be resolved as $2, or just dns name as $1
	dig +short "$1" "$2"|tr '\n' ' '|head -c-1
}

while getopts ":c:dfhlqrw" optname; do
    case "$optname" in
		"c")	CONFFILE="$OPTARG";;
		"d")	DEBUG="y";;
		"f")	FORCE="y";;
		"h")	HELP="y";;
		"l")	CHANGELOG="y";;
		"q")	QUIET="y";;
		"r")	unset TEST;;
		"w")	COLUMNS=30000;;
		"?")	echo "Unknown option $OPTARG">&2; exit 1;;
		":")	echo "No argument value for option $OPTARG">&2; exit 1;;
		*)	echo "Unknown error while processing options">&2; exit 1;;
    esac
done
THIS=$(basename "$0")
[[ -z $QUIET ]] && echo -e "\n$THIS v$VERSION by Dominic (try -h for help)\n${THIS//?/=}\n"
if [ -n "$HELP" ]; then
	echo -e "Compare local machine's current external ip address with the DNS records \
pointing to it on the specified domain's Mythic Beasts nameservers, then use curl to fix latter if different. \
Also compare local machine's external reverse DNS with its internal FQDN \
(in /etc/hosts) and update latter if different. Also if (but only if) the previous \
internal FQDN is hard-coded as myhostname in Postfix's main.cf, update it there too.

Requires ability to login at mythic-beasts.com with the DNS password set up there. \
Makes use of their DNS APIv1 (which must be enabled per domain) - see \
https://www.mythic-beasts.com/support/api/primary.

Intended for running as regular cron job on a machine with dynamic external \
ip whose DNS records - held on the Mythic Beast nameservers - and local FQDN need to be kept \
accurate - such as a (Postfix) mail server.

For a DNS subdomain which resolves to more than one ip address (one of which \
is the local machine's ip), ensure that it is preceded on the SUBDOMAINS= \
line by a subdomain which *is* unique to this ip address. \
$THIS will attempt to identify the correct \
entry to alter based on a modified IP found in the preceding entry.

Note that, unless run with quiet option, conf file settings (see below) are \
shown on stdout, except PASS and (if present) API_KEY_* values.

Exit Codes:
  0 - no changes needed, all is ok
  1 - there was a problem and/or failed to make all required changes
  2 - made some changes successfully

Conffile: $THIS requires a conf file (see options) which must contain 3 or 4 \
definitions:
DOMAIN=\"[put your DNS domain here]\"
SUBDOMAINS=\"[put one or more subdomain names here, space-separated]\"
PASS=\"[put your DNS password here]\"
TTL=\"[put your TTL value here]\" (this entry is optional - default value is $TTL)

For example -
    DOMAIN=\"mydomain.com\"
    SUBDOMAINS=\"vps32 mail1\"
    PASS=\"ajfdfjsdk\"

Options: -c [conffile] - use the named conffile instead of the default (which is \
$(basename "$0" .sh)-[hostname].conf in the same directory as $THIS)
         -f - force mode, update records even if no changes seem to be required
         -h - show this help and exit
         -l - show changelog and exit
         -q - be quiet unless ip mismatch is found or there is a problem
         -r - actually make changes (not just testing)

Dependency: curl

License: Copyright © 2026 Dominic Raferd. Licensed under the Apache License, \
Version 2.0 (the \"License\"); you may not use this file except in compliance \
with the License. You may obtain a copy of the License at \
https://www.apache.org/licenses/LICENSE-2.0. Unless required by applicable \
law or agreed to in writing, software distributed under the License is \
distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY \
KIND, either express or implied. See the License for the specific language \
governing permissions and limitations under the License.
"|fold -sw"$COLUMNS"
fi
if [[ -n $CHANGELOG ]]; then
	[[ -n $HELP ]] && echo "Changelog:"
	echo "\
3.4 [10 Mar 2026]: use mythic APIv1 REPLACE command, hide some dig error messages, skip checking mythic NS
3.3 [13 Sep 2025]: make substantially shellcheck-compliant
3.2 [09 Nov 2023]: use mythic APIv1 for all original lookup, not dig, more shellcheck compliance
3.1 [27 Oct 2023]: small changes to the Description text
3.0 [09 Oct 2023]: added output of start date/time
2.9 [23 Jun 2022]: do not abort if unable to obtain reverse DNS of external ip
2.8 [18 Jun 2022]: minor bugfix
2.7 [14 Oct 2020]: also look up nameservers
2.6 [09 Aug 2020]: exit silently if there is no internet connection
2.5 [05 Jun 2020]: minor text layout update
2.4 [17 Feb 2020]: abort if unable to obtain reverse DNS of external ip
2.3 [28 Nov 2019]: very small bugfix to text output
2.2 [25 Sep 2019]: small bugfix to text output
2.1 [05 Sep 2019]: add -r option, runs in test mode by default, improved text output
2.0 [28 Jun 2019]: small change to error report
1.9 [02 Jun 2019]: show conf file info (unless quiet), text output layout improvements
1.8 [03 Apr 2018]: bugfix - exit code 2 was being given if unable to obtain external ip - changed to 1
1.7 [29 Jan 2018]: text (to stderr) in quiet mode if an ip is changed on \$NAMESERVER
1.6 [18 Jan 2018]: minor exit code fix
1.5 [14 Jan 2018]: added exit code 2, also update main.cf myhostname
1.4 [12 Jan 2018]: text updates
1.3 [07 Jan 2018]: wait for Mythic Beasts DNS to update
1.2 [01 Jan 2018]: minor text changes
1.1 [29 Dec 2017]: minor changes, trying to bugfix
1.0 [19 Sep 2017]: allow checking/modification where a DNS name resolves to more than one ip (including this one)
0.9 [18 Aug 2017]: adapted from 123-dns-sync.sh to work for Mythic Beasts, allow @ as subdomain
0.8 [06 Jan 2017]: minor tweak to text output if there is a problem
0.7 [04 Dec 2016]: make generic, rename from r...-c......-ip-updated.sh to 123-dns-sync.sh
0.6 [28 Nov 2016]: add test for internal fqdn vs reversedns, rename from r...-c......ip-notify.sh to r...-c......-ip-updated.sh
0.5 [31 Oct 2016]: add test for valid ip
0.4 [16 Oct 2016]: retrieve current ip from opendns.com by default, fall back to log entry by noip2 script
0.3 [24 May 2016]: retrieve ips from authoritative nameservers not cache
0.2 [20 May 2016]: add use of \$FIXERPROG and option -t
0.1 [14 Apr 2016]: first version
"|fold -sw"$COLUMNS"
fi
[[ -n $HELP$CHANGELOG ]] && exit 0
[[ -n $TEST && -z $QUIET ]] && echo -e "Running in test mode\n"
EXTERNALIP=$(dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null)
# give up *silently* if DNS is not working (presumably we don't have internet access)
# - this avoids piling up repeated output (e.g. in mail queue)
valid_ipv4 "$EXTERNALIP" no_reserved  || { logger -st "${THIS}[$$]" -- "($(id -un)) Found invalid external ip '$EXTERNALIP', aborting"; exit 1; }
CUMERR=0; OK=0; FIXED=0
# check/get the reversedns vs internal fqdn
REVERSEDNS=$(dig +short -x "$EXTERNALIP"); REVERSEDNS=${REVERSEDNS%.}
FQDN=$(hostname -f)
#[[ $REVERSEDNS != $FQDN && -n $QUIET ]] && unset QUIET && echo -e "\n$THIS v$VERSION running at $(date +"%F %T")\n"
[[ -z $QUIET ]] && echo -e "Started                                : $(date +'%F %T')\nExternal ip (WAN, via myip.opendns.com): $EXTERNALIP\nInternally-set FQDN (via hostname -f)  : $FQDN\nExternal FQDN (via ReverseDNS)         : $REVERSEDNS"
if [[ -z $REVERSEDNS ]]; then
	[[ -z $QUIET ]] && echo "  Unable to determine reverse DNS of our external ip $EXTERNALIP"
	[[ -z $FQDN ]] && { logger -st "${THIS}[$$]" -- "($(id -un)) Unable to determine any FQDN for $EXTERNALIP, aborting"; exit 1; }
	[[ -z $QUIET ]] && echo "  ... so will presume that the internally-set FQDN is definitive"
	REVERSEDNS=$FQDN
elif [[ $REVERSEDNS == "$FQDN" ]]; then
	[[ -z $QUIET ]] && echo -e "  - already match: ok\n"
fi
if [[ $REVERSEDNS != "$FQDN" ]]; then
	echo "Our internally-set FQDN $FQDN does not match ReverseDNS $REVERSEDNS"
	unset QUIET
	if [[ -z $TEST ]]; then
		echo "  updating to $REVERSEDNS in /etc/hosts"
		sed -i "/^127\.0\.1\.1/s/\s.*/\t$REVERSEDNS $(hostname)/" /etc/hosts
	else
		echo "  in non-test mode would update to $REVERSEDNS in /etc/hosts"
	fi
	if command -v postconf >/dev/null; then
		POSTFIX_CONFIGDIR=$(postconf|grep ^config_directory|cut -d" " -f3-)
		if [[ -s $POSTFIX_CONFIGDIR/main.cf ]]; then
			POSTFIX_MYHOSTNAME=$(sed -n 's/^myhostname\s*=\s*//;T;s/\s*#.*//;p' "$POSTFIX_CONFIGDIR/main.cf")
			if [[ $POSTFIX_MYHOSTNAME == "$FQDN" ]]; then
				echo "Postfix myhostname '$POSTFIX_MYHOSTNAME' matches internally-set FQDN $FQDN"
				if [[ -z $TEST ]]; then
					echo "  updating it to $REVERSEDNS (in $POSTFIX_CONFIGDIR/main.cf)"
					sed -i 's/^myhostname\s*=.*/myhostname = '"$REVERSEDNS/" "$POSTFIX_CONFIGDIR/main.cf"
				else
					echo "  in non-test mode would update it to $REVERSEDNS (in $POSTFIX_CONFIGDIR/main.cf)"
				fi
			elif [[ $POSTFIX_MYHOSTNAME == "$REVERSEDNS" ]]; then
				echo "Postfix myhostname '$POSTFIX_MYHOSTNAME' already matches $REVERSEDNS, no update required"
			else
				echo "Postfix myhostname '$POSTFIX_MYHOSTNAME' is an unexpected value, please update manually to '$REVERSEDNS' in $POSTFIX_CONFIGDIR/main.cf"
			fi
		else
			echo "(Postfix configuration not found)"
		fi
	fi
fi

[[ -s $CONFFILE ]] || { echo "Can't find conffile $CONFFILE, aborting" >&2; exit 1; }
source "$CONFFILE"
[[ -z $QUIET ]] && echo "Obtained from conffile '$CONFFILE':" && sed -E 's/#.*//;/^$/d;/^(PASS|API_KEY_[A-Z]+)=/{s/=.*/=***/};s/^/  /' "$CONFFILE"
[[ -z $DOMAIN || -z $SUBDOMAINS || -z $PASS ]] && { echo "Definitions in $CONFFILE inadequate. aborting" >&2; exit 1; }
NAMESERVER="$(dig +short "$DOMAIN" NS|sed 's/\.$//'|head -n1)"
echo "$NAMESERVER"|grep -q mythic-beasts || { echo "Unexpected nameserver(s) found '$NAMESERVER', aborting" >&2; exit 1; }
[[ -z $QUIET ]] && echo "Checking/updating settings for this machine on $NAMESERVER per $CONFFILE:"
for MYHOSTNAME in $SUBDOMAINS; do
	[[ $MYHOSTNAME = "@" ]] && CHECKFOR=$DOMAIN || CHECKFOR=$MYHOSTNAME.$DOMAIN
	# shellcheck disable=SC2207
	IPBYDNS=( $(curl -sS --data "domain=${DOMAIN}&password=${PASS}&command=LIST" https://dnsapi4.mythic-beasts.com|grep -E "^$MYHOSTNAME\s+[1-9][0-9]*\s+A"|awk '{print $NF}') ) || { echo "An error occurred retrieving data for domain $DOMAIN, subdomain $MYHOSTNAME from mythic-beasts using APIv1, skipping" >&2; continue; }
	[[ -z $QUIET ]] && echo -en "  Host '$CHECKFOR' type 'A':\n    Found ${#IPBYDNS[@]} record(s) at $NAMESERVER - data ${IPBYDNS[*]}"
	if echo "${IPBYDNS[@]}"|grep -qF "$EXTERNALIP"; then
		[[ -z $QUIET ]] && echo " - matches our external ip: ok"
		(( OK++ ))
		[[ -z $FORCE ]] && continue
		[[ -z $QUIET ]] && echo -n "    Force mode, so continuing"
	elif [[ -z $QUIET ]]; then
		echo -n " - does not match our external ip"
	fi
	[[ -z $QUIET ]] && echo
	# shellcheck disable=SC2068
	for IP in ${IPBYDNS[@]}; do
		# shellcheck disable=SC2178
		[[ $IP == "$PREV_IPBYDNS" ]] && { unset IPBYDNS; IPBYDNS=$IP; break; }
		(( ROW++ ))
	done
	[[ ${#IPBYDNS[@]} -eq 1 ]] || { echo "Failed to retrieve '$CHECKFOR' ip from servers $NAMESERVER (returned '${IPBYDNS[*]}'), skipping" >&2; (( CUMERR++ )); continue; }
	PREV_IPBYDNS="${IPBYDNS[0]}"
	valid_ipv4 "${IPBYDNS[0]}" no_reserved || { echo -e "Retrieving ip for $CHECKFOR from $NAMESERVER:\n  unexpected response '${IPBYDNS[0]}'\n  skipping" >&2; (( CUMERR++ )); continue; }
	echo -e "    host '$CHECKFOR' type 'A' data '${IPBYDNS[0]}' - altering data to '$EXTERNALIP'"
	if [[ -n $TEST ]]; then
		echo -e "    If not test mode, would do:\n      curl -sS --data \"domain=${DOMAIN}&password=***&command=DELETE $MYHOSTNAME $TTL A ${IPBYDNS[0]}&command=ADD $MYHOSTNAME $TTL A $EXTERNALIP\" https://dnsapi4.mythic-beasts.com"; FIXERR=0
	else
		curl -sS --data "domain=${DOMAIN}&password=${PASS}&command=REPLACE $MYHOSTNAME $TTL A $EXTERNALIP" https://dnsapi4.mythic-beasts.com 2>&1|sed 's/^/      /;1iServer response:'|sed '1s/^/    /'|tee -a "/tmp/$THIS.tmp"
		FIXERR=$?
		grep -Fq ERR "/tmp/$THIS.tmp" && FIXERR=99
	fi
	if [[ $FIXERR -eq 0 ]]; then
		[[ -z $TEST ]] && echo "    Completed"
		NEW_IPBYDNS=$(curl -sS --data "domain=${DOMAIN}&password=${PASS}&command=LIST" https://dnsapi4.mythic-beasts.com|grep -E "^$MYHOSTNAME\s+[1-9][0-9]*\s+A"|awk '{print $NF}') || { echo "An error occurred retrieving data for domain $DOMAIN, subdomain $MYHOSTNAME from mythic-beasts using APIv1, skipping" >&2; continue; }
		echo "    APIv1 for $MYHOSTNAME returns '$NEW_IPBYDNS'"
		#echo -n "    Waiting for DNS update to be effective on $NAMESERVER"
		## mythic-beasts can take a couple of minutes to update their DNS, so wait...
		#for ((i=0; i<=25; i++)); do
		#	NEW_IPBYDNS=$(digmulti "@$NAMESERVER" "$CHECKFOR" 2>/dev/null)
		#	[[ $NEW_IPBYDNS == "$EXTERNALIP" || -n $TEST ]] && break
		#	echo -n "."
		#	sleep 10s
		#done
		#echo -en "\n    "
		if [[ -z $TEST ]]; then
			if [[ $NEW_IPBYDNS == "$EXTERNALIP" ]]; then
				(( FIXED++ )); echo -n "OK - "
				if [[ -n $QUIET ]]; then
					echo "Fixed $CHECKFOR on $NAMESERVER to point to $EXTERNALIP instead of $PREV_IPBYDNS" >&2
				fi
			else
				(( CUMERR++ )); echo -n "FAILED - "
			fi
		fi
		echo "DNS lookup on $NAMESERVER: $CHECKFOR returns '$NEW_IPBYDNS'"
		# if we have a local bind, update it
		command -v rndc >/dev/null 2>&1 && rndc flushname "$MYHOSTNAME.$DOMAIN" && echo "    Updated local DNS cache: $CHECKFOR now returns '$(digmulti "$CHECKFOR" 2>/dev/null)'"
	else
		echo "An error occurred:"|cat - "/tmp/$THIS.tmp"|sed 's/^/    /' >&2
		(( CUMERR++ ))
	fi
done
if [[ -f /tmp/$THIS.tmp ]]; then
	if [[ -n $DEBUG ]]; then
		echo -e "Retained:\n  "
		ls -l "/tmp/$THIS.tmp"
	else
		rm -- "/tmp/$THIS.tmp"
	fi
fi
TOT=$((OK+FIXED+CUMERR))
MSG="$OK/$TOT DNS entries for $EXTERNALIP already ok"
[[ $FIXED -ne 0 ]] && MSG="$MSG, $FIXED/$TOT successfully fixed"
[[ $CUMERR -ne 0 ]] && MSG="$MSG, $CUMERR/$TOT could not be confirmed as fixed"
[[ -z $QUIET ]] && echo -e "  $MSG"
logger -t "${THIS}[$$]" -- "($(id -un)) $MSG"
if [[ $CUMERR -gt 0 ]]; then
	exit 1
elif [[ $FIXED -gt 0 ]]; then
	exit 2
else
	exit 0
fi
