#!/bin/bash

# Dynamically update a ISC Bind name server according to RFC2136
# by using nsupdate with TSIG keys to register or remove an IP address.

# Let this script execute from cron, from ISC dhclient as an exit hook and/or from ifupdown.
# Using cron with a short interval is recommended for dynamic, public IPs in a combination
# with other mechanisms.

# ddnsupdate [--quiet|-q|--version|-v|--help|-h|-?] [<reason> [<new_ip_address>]]
#
#		<reason> is the reason as given by ISC dhclient and additionally
#		NONDHCP and NONDHCP6. Setting a <reason> is not meant to happen
#		in normal operation.


VERSION="1.0.22"
VERSION_INFO="(c) 2013-2024 by Adrian Zaugg under GNU General Public License Version 2."


# Hostname including domain to set an IP for
HOSTNAME=""

# TTL to set in seconds
TTL=120

# Force recheck of registered address in DNS after that many hours
# 0: recheck always; unset: recheck never
# set to recheck always if the public interface is not on this host
FORCE_RECHECK_HOURS=0

# Set of expected IPs as a space separated list. If the current address is not within these IPs,
# set a fixed fallback address for your host in the DNS (see below). Use numerical notation to be sure
# this works. Leave empty to disable this functionality.
EXPECTED_IPS=""

# Enter an ip you want to tell your DNS, when you don't have an expected IP. Use numerical form.
FALLBACKIP=""

# Enter an IP you want to tell your DNS, when you don't have a connection or
# if the IP was withdrawn. Leaving empty deletes a previously associated record.
DOWNIP=""

# Use the public IP (e.g. when behind a NAT router) instead of DHCP assigned or fixed IP, defaults to true.
PUBLIC_IP=true

# Service to determine the public IP address
# You may set multiple services using bash array notation: MYIP_SERVICES=(<service1> <service2> ...)
MYIP_SERVICES=(https://dns.3eck.net/ https://ip.door.ch/)

# Type of service: raw|iponly|checkip ; defaults to checkip
#    "raw", "iponly" and <empty> are equivalent, the service must return the IP address alone
#    with "checkip" the service must return "Current IP Address: " followed by the IP address
#
# If you have defined multiple service above set the type for each service using bash array notation:
# MYIP_SERVICES_TYPES=(<type_of_service1> <type_of_service2> ...)
MYIP_SERVICES_TYPES=(checkip iponly)

# name server to update
SERVER=""

# TSIG private key file for authentication on your name server
#
# Nsupdate supoorts two formats of key files: Either use the output from tsig-keygen (resp. ddns-confgen),
# which is the same format as used in your server's named.conf[.local] or use dnssec-keygen (deprecated). 
#
# To generate your key using tsig-keygen:
#
#	tsig-keygen -a sha512 <name_of_key_with_trailing_dot> > <keyfile>
#		where: <name_of_key_with_trailing_dot> is best named after your dynamic host's FQDN
#
# 	Secure the file the generated <keyfile> and reference it using: KEYFILE="<keyfile>"
#
#
# To generate your key using dnssec-keygen:
#
#       dnssec-keygen -a HMAC-SHA512 -b 512 -n HOST -K . <name_of_key_with_trailing_dot>
#
#               where: <name_of_key_with_trailing_dot> is best named after your dynamic host's FQDN
#
#	Both files xxx.private and xxx.key need to be next to each other. Reference the private key file below.
#
KEYFILE=""

# QUAD9 public DNS (only for public IPs needed)
QUAD9="9.9.9.9"

# Host to ping to determine your connection state (only for public IPs needed).
UP_TEST_TARGET="$QUAD9"

# Path to fping: Set manually, if it isn't in your PATH.
PING=""

# Max number of ping pakets to send before giving up. Time increases exponentially,
# use a number < 7. See man fping.
RETRIES=4

# set to true for debug output
DEBUG=true



# --------- do not edit below ---------


# answer of fping to reachable hosts
ALIVE_ANSWER="is alive"

# detect help switch
if [ "$(echo -n " $@ " | grep -c -E '^.* -h .*$|^.* -\? .*$|^.* --help .*$')" -gt 0 ]; then
    echo
    echo "Dynamically update a ISC Bind name server according to RFC2136 by using nsupdate with"
    echo "TSIG keys to register or remove an IP address."
    echo
    echo "Let this script execute from cron, from ISC dhclient as an exit hook and/or from ifupdown."
    echo "Using cron with a short interval is recommended for dynamic, public IPs in a combination"
    echo "with other mechanisms."
    echo
    echo
    echo "	ddnsupdate [--quiet|-q|--version|-v|--help|-h|-?] [<reason> [<new_ip_address>]]"
    echo
    echo "		<reason> is the reason as given by ISC dhclient and additionally"
    echo "		NONDHCP and NONDHCP6. Setting a <reason> is not meant to happen"
    echo "		in normal operation."
    echo
    echo
    echo "For testing use:"
    echo "	ddnsupdate NONDHCP|NONDHCP6 <ipv4_address>|<ipv6_address>"
    echo "		-> register an address for the configured host"
    echo
    echo "	ddnsupdate DELETE|DELETE6"
    echo "		-> remove a registered address for the configured host"
    echo
    echo "	ddnsupdate"
    echo "		-> get ip and do register"
    echo
    exit 0
fi

# detect version switch
if [ "$(echo -n " $@ " | grep -c -E '^.* -v .*$|^.* --version .*$')" -gt 0 ]; then
    echo "$(basename $0) v$VERSION"
    echo -e "\t$VERSION_INFO"
    exit 0
fi

# detect silent switch (must preceede arguments)
if [ "$(echo -n " $@ " | grep -c -E '^.* -q .*$|^.* --quiet .*$')" -gt 0 ]; then
    DEBUG=false
    debug_flag="-q"
    shift
fi

# check HOSTNAME
if [ -z "$HOSTNAME" ]; then
    echo "[ERROR] You need to supply a hostname. Edit the script and set the HOSTNAME variable in the script header." >&2
    exit 1
fi

# get the zone from hostname
ZONE="$(echo "$HOSTNAME" | sed -e "s/^[^.]\+\.//")"
if [ "$ZONE" = "$HOSTNAME" ]; then
    echo "[ERROR] The hostname set must include its domain name. Edit the script and set the HOSTNAME variable including the host's domain name in the script header." >&2
    exit 1
fi

# check SERVER
if [ -z "$SERVER" ]; then
    echo "[ERROR] You need to supply a name server to update. Edit the script and set the SERVER variable in the script header accordingly." >&2
    exit 1
fi

# Temporary file to hold the ip address
TMPFILE=/tmp/ddns_ip

# set PUBLIC_IP default
if [ "$PUBLIC_IP" != "false" ]; then
    PUBLIC_IP=true

    # check ip service
    if [[ -z "$MYIP_SERVICE" && ${#MYIP_SERVICES[@]} -eq 0 ]]; then
        echo "[ERROR] To determine the public IP an external service is needed. Please set the MYIP_SERVICE variable in the script header." >&2
        exit 1
    elif [[ -n "$MYIP_SERVICE" && ${#MYIP_SERVICES[@]} -eq 0 ]]; then
	# rewrite from old style variable name to array
	MYIP_SERVICES[0]="$MYIP_SERVICE"
	MYIP_SERVICES_TYPES[0]="$MYIP_SERVICE_TYPE"
	unset MYIP_SERVICE
	unset MYIP_SERVICE_TYPE
    fi

    # check service type
    for ((i=0;i<${#MYIP_SERVICES[@]};i++)); do
	if [[ "${MYIP_SERVICES_TYPES[$i]}" = "raw" || "${MYIP_SERVICES_TYPES[$i]}" = "iponly" ]]; then
	    MYIP_SERVICES_TYPES[$i]="iponly"
	elif [[ -z "${MYIP_SERVICES_TYPES[$i]}" || "${MYIP_SERVICES_TYPES[$i]}" = "checkip" ]]; then
	    MYIP_SERVICES_TYPES[$i]="checkip"
	else
	    echo "[ERROR] Uknown type of service: \"${MYIP_SERVICES_TYPES[$i]}\". Please set the variable MYIP_SERVICES_TYPES in the script header to \"iponly\", \"raw\" or \"checkip\"." >&2
	    exit 1
	fi
    done

    # check fping
    if [ -z "$PING" ]; then
        PING="$(which fping)"
    fi
    if [ ! -x "$PING" ]; then
        echo "[ERROR] The program \"fping\" is needed for proper operation with public IPs. Please check." >&2
        exit 1
    fi
fi

# check key file
if [ -z "$KEYFILE" ]; then
    echo "[ERROR] A key file must be given. Please set the KEYFILE variable in the script header." >&2
    exit 1
elif [ ! -f "$KEYFILE" ]; then
    echo "[ERROR] The given key file \"$KEYFILE\" doesn't exist or is not a file. Please correct." >&2
    exit 1
fi


# Obtain new IP address
obtain_ip() {
    # IP may be passed by DHCP, retrieved from the interface or from a public IP service

    if [ -z "$reason" ]; then
	# not called by DHCP

	if $PUBLIC_IP; then
	    # use a public ip service
	    local new_ip_collector=""

	    # collect public address of all address families
	    for ipv in 4 6; do
		get_public_ip
		if [ -n "$new_ip_address" ]; then
		    new_ip_collector="$new_ip_collector"$'\n'"$new_ip_address"
		fi
	    done
	    # remove first line, which is empty
	    new_ip_address="$(echo "$new_ip_collector" | tail -n+2)"
	    if [ -z "$new_ip_address" ]; then
		echo "[ERROR] No DNS update will be performed. Unable to determine public IP." >&2
		exit 1
	    fi

	else
	    # determine address of interface where default gateway is set
	    interface="$(ip route show default | sed -e "s/^default via [^ ]\+ dev \([^ ]\+\) .*$/\1/")"
	    if [ -n "$interface" ]; then
		new_ip_address="$(ip addr show "$interface" scope global | grep inet | sed -e "s/^.*inet6\{0,1\} \([^ ]\+\)\/[0-9]\+ .*$/\1/")"
	    fi
	    # no default route or no address on interface, so assume we're down
	    if [[ -z "$new_ip_address" || -z "$interface" ]]; then
		set_down_ip
	    fi
	fi

    elif [[ "$PUBLIC_IP" = "true"  && "$reason" != "NONDHCP" && "$reason" != "NONDHCP6" && "$reason" != "DELETE" && "$reason" != "DELETE6" ]]; then
        # obtain ip from an external service
        get_ip_family
        get_public_ip

        # check against expected IPs
	check_expected_ips

    elif [[ -n "$1" ]]; then
        # get IP from command line
        new_ip_address="$1"
        get_ip_family

        # check against expected IPs
	check_expected_ips
    fi
}

# determine IP version
get_ip_family() {
    if [ -n "$reason" ]; then
	# determine IP version
	if [ ${reason:$((${#reason}-1)):1} = "6" ]; then
		ipv=6
	else
		ipv=4
	fi
    else
	echo "[ERROR] Unable to determine IP address family, probably due to a coding error." >&2
	exit 255
    fi
}

# get public IP from myip service
get_public_ip() {

    # get_public_ip is called without an argument, it calls itself with the IP service to use
    if [ -z "$*" ]; then
	unset ERRMSG
	protocol_error=false

	# loop through each service in the array and call myself recursively
	for ((i=0;i<${#MYIP_SERVICES[@]};i++)); do
	    get_public_ip "${MYIP_SERVICES[$i]}" "${MYIP_SERVICES_TYPES[$i]}"
	    ERR=$?

	    if [[ "$ERR" -eq 0 && -n "$new_ip_address" ]]; then
		# all good
		ERRMSG="$ERRMSG"$'\n'"[INFO] Public IPv$ipv address $new_ip_address received from $MYIP_SERVICE."
		break;
	    elif [ "$ERR" -eq 2 ]; then
		# remember protocol error
		protocol_error=true
	    fi
	done
	ERRMSG="$(echo "$ERRMSG" | tail -n +2 )"
	if [ $i -eq ${#MYIP_SERVICES[@]} ]; then
	    # all services gave an error or warning
	    if [[ "$ERR" -eq 1 && ! $protocol_error ]]; then
		echo "$ERRMSG" >&2
		echo "[WARN] All IP service(s) failed for IPv$ipv requests." >&2
		return 1
	    elif [[ "$ERR" -eq 3 && ! $protocol_error ]]; then
		# we're probably offline
		set_down_ip
		reason="DOWN"
	    fi
	fi
	$DEBUG && echo "$ERRMSG" >&2
        return 0

    else
	# get_public_ip was called with an argument, get it
	MYIP_SERVICE="$1"
	MYIP_SERVICE_TYPE="$2"
    fi

    MYIP_SERVICE_IP="$(echo "$MYIP_SERVICE" | sed -e "s%^https\{0,1\}://%%" -e "s%/.*$%%")"

    new_ip_address="$(curl -$ipv -s --max-time 10 "$MYIP_SERVICE" 2>&1)"
    ERR=$?

    # treat curl result
    if [ $ERR -ne 0 ]; then

        # are we online?
	ping_target "$UP_TEST_TARGET"
	putt_rv=$?

        if [[ "$PING_ANSWER" = "$ALIVE_ANSWER" && ( $ERR -eq 7 || $ERR -eq 6 || $ERR -eq 28 ) ]]; then
	    # we're online but ip service has a network error:
	    # either IP service is unreachable or we used the wrong ip version

	    # ping IP service
	    ping_target "$MYIP_SERVICE_IP"
	    pmis_rv=$?
            if [ "$PING_ANSWER" = "$ALIVE_ANSWER" ]; then
		# wrong protocol used
		unset new_ip_address
		ERRMSG="$ERRMSG"$'\n'"[WARN] IP service $MYIP_SERVICE_IP does not answer to my IPv$ipv requests."
		return 2
	    elif [ $pmis_rv -eq 1 ]; then
		ERRMSG="$ERRMSG"$'\n'"[ERROR] IP service $MYIP_SERVICE_IP does not answer to pings or gave a network error."
		return 1
	    elif [ $pmis_rv -eq 2 ]; then
		ERRMSG="$ERRMSG"$'\n'"[ERROR] DNS lookup of IP Service $MYIP_SERVICE_IP failed."
		return 1
	    else
		ERRMSG="$ERRMSG"$'\n'"[ERROR] IP Service $MYIP_SERVICE_IP failed."
		return 1
	    fi

	elif [ $putt_rv -gt 2 ]; then
            # we're probably offline
            ERRMSG="$ERRMSG"$'\n'"[ERROR] Got error $PING_ERRNUM from fping $(head -1 "$PING_ANSWER") pinging $MYIP_SERVICE_IP."
	    return 3
        else
            # don't change IP, we just don't know without our ip service
            if [ ! -z "$new_ip_address" ]; then
                ERRMSG="$ERRMSG"$'\n'"[ERROR] IP Service $MYIP_SERVICE_IP failed: $new_ip_address."
            else
                ERRMSG="$ERRMSG"$'\n'"[ERROR] IP Service $MYIP_SERVICE_IP failed (curl error $ERR)."
            fi
            return 1
        fi

    elif [ -z "$new_ip_address" ]; then
        # connection is working, service did return an empty string
        ERRMSG="$ERRMSG"$'\n'"[ERROR] IP Service $MYIP_SERVICE_IP was reachable, but returned an empty string."
        return 1
    else
	# no error from curl: extract IP from checkip service
	if [ "$MYIP_SERVICE_TYPE" = "checkip" ]; then
	    new_ip_address="$(echo "$new_ip_address" | html2text | grep -m 1 "Current IP Address: " | sed -e "s/^Current IP Address: //g")"
	    if [ -z "$new_ip_address" ]; then
		ERRMSG="$ERRMSG"$'\n'"[ERROR] The IP Service under $MYIP_SERVICE_IP seems to not return the ip address in the correct form or is not a checkip service at all."
		return 1
	    fi
	fi
    fi
    return 0
}

# Check IP against list of expected IPs
check_expected_ips() {

    if [[ -n "$new_ip_address" && -n "$EXPECTED_IPS" ]]; then

        # convert expected ips to all numerical
        for ip in $EXPECTED_IPS; do
            if [ $(echo "$ip" | grep -cE "^[0-9.:a-f]+$") -eq 0 ]; then
                # resolve
                ip="$(dig $ip +short)"
                [ -z "$ip" ] && continue;
            fi
            expected_ips="$expected_ips $ip"
        done
        EXPECTED_IPS="${expected_ips:1}"

        # check against expected ip
        found=false
        for ip in $EXPECTED_IPS; do
            if [ "$new_ip_address" = "$ip" ]; then
                    found=true
                    break;
            fi
        done
        if ! $found; then
            # not an expected IP go to fallback
            if [ -n "$FALLBACKIP" ]; then
                $DEBUG && echo "Unexpected IP found ($new_ip_address). Using fallback: $FALLBACKIP" >&2
                new_ip_address="$FALLBACKIP"
            else
                $DEBUG && echo "Unexpected IP found ($new_ip_address). No fallback given, considering host as down." >&2
                set_down_ip
                reason="DOWN"
            fi
        fi
    fi
}

# ping a target
ping_target() {
    PING_ANSWER="$($PING -B 2 -r $RETRIES -p 50 "$1" 2>&1)"
    PING_ERRNUM=$?
    if [ $PING_ERRNUM -eq 0 ]; then
	PING_ANSWER="$ALIVE_ANSWER"
    fi
    return $PING_ERRNUM
}

# set ip to unassigned or DOWNIP
set_down_ip() {

    if [ ! -z "$DOWNIP" ]; then
        new_ip_address="$DOWNIP"
    else
        new_ip_address="unassigned"
    fi
}

# read previous IP from disk
get_previous_ip() {
    if [ -e "${TMPFILE}${ipv}" ]; then
	OLDIP="$(< "${TMPFILE}${ipv}")"
    else
        OLDIP=unassigned
    fi
}

# delete state file
forget_previous_ip() {

    if [ -e "${TMPFILE}${ipv}" ]; then
        rm "${TMPFILE}${ipv}"
    fi
}

# check the currently registered IP in DNS
get_registered_ip() {
    local regip

    # determine record type
    if [ $ipv -eq 6 ]; then
            recordtype="AAAA"
    else
            recordtype="A"
    fi

    # get currently registered IP from our DNS we update
    regip="$(dig $recordtype +noall +short +timeout=5 "$HOSTNAME" @"$SERVER" 2>/dev/null)"
    if [[ $? -eq 9 && -z "$regip" ]]; then
	# get from our resolver since there was no answer
	regip="$(dig $recordtype +noall +short +timeout=5 "$HOSTNAME" 2>/dev/null)"
    fi

    # write to registration file or delete it
    if [ -n "$regip" ]; then
	echo "$regip" > "${TMPFILE}${ipv}"
    else
	rm "${TMPFILE}${ipv}"
    fi
}

# recheck registered address
recheck_registered_address() {
    # check registration file existence and recheck is enabled
    if [[ -f "${TMPFILE}${ipv}" && -n "$FORCE_RECHECK_HOURS" ]]; then
	# calculate grace period
	if [ $(( $(date +%s) - $(stat -c %Y "${TMPFILE}${ipv}") )) -ge $((FORCE_RECHECK_HOURS*3600)) ]; then
	    get_registered_ip
	fi
    fi
}

# return update command
construct_update_cmd() {

    local recordtype
    local update_data
    local update_data_prepared
    local oldip

    # determine record type
    if [ $ipv -eq 6 ]; then
            recordtype="AAAA"
    else
            recordtype="A"
    fi

    # prepare data
    update_data_prepared="${HOSTNAME}. $TTL IN $recordtype"

    case "$1" in

        add|update)
            if [ "$OLDIP" != "unassigned" ]; then
		while read -r oldip; do
                    update_data="${update_data}$(construct_update_cmd delete)"$'\n'
		done <<< $OLDIP
            fi
            echo "${update_data}update add $update_data_prepared $new_ip_address"
            ;;

        delete)
            echo "update delete $update_data_prepared"
            ;;
    esac
}

# notify DNS if we have a new ip
ddns_ip_do() {
    local update_cmd

    # read previous IP
    get_previous_ip

    # prepare command
    update_cmd="$(construct_update_cmd "$1")"

    case "$1" in

        add|update)
            # determine whether ip has changed and
            if [ "$new_ip_address" != "$OLDIP" ]; then
                # ip changed most probably
                update_dns "$update_cmd" "$HOSTNAME $new_ip_address"
                rv=$?
                if [ $rv -eq 0 ]; then
                        $DEBUG && echo "   $(echo "$OLDIP" | tr '\n' ' ')--> $new_ip_address"
                        echo $new_ip_address > "${TMPFILE}${ipv}"
                else
                        echo -e "[ERROR] Failed to update hostname in DNS: \n\t$ERR"
                        forget_previous_ip
                fi
            else
                $DEBUG && echo "No IPv$ipv address change detected." >&2
            fi
            ;;

        delete)
            update_dns "$update_cmd" "Deleting record $HOSTNAME"
            rv=$?
            forget_previous_ip
            ;;
    esac
    return $rv
}

# execute nsupdate update function
update_dns() {
        $DEBUG && echo -n "Updating DNS on $SERVER ($reason): $2..." >&2
        ERR="$(nsupdate -v -k "$KEYFILE" 2>&1 << \
EOF
server $SERVER
$1
send
EOF
        )"

        if [ $? -ne 0 ]; then
                $DEBUG && echo "  failed: $ERR" >&2
                return 1
        else
                $DEBUG && echo " ok." >&2
                return 0
        fi
}

# get reason from dhclient variable
reason="$1"

# act on dhcp reason
case "$reason" in

    "")

	# called by cron or manually (no reason given)
	# obtain ip, determine ip version, set reason and call myself

	# obtain ip
	obtain_ip

	# determine debug mode of bash
	if [ $(echo "$-" | grep -c "x") -eq 1 ]; then
	    bash_debug="-x"
	else
	    bash_debug="+x"
	fi

	# loop over all addresses in new_ip_address and call myself
	while read -r ip; do
	    if [ -z "$ip" ]; then
		echo "[ERROR] No IP obtained. This is probably a bug." >&2
		continue;
	    elif [[ "${ip}." =~ ^([0-2]{0,1}[0-9]{1,2}\.){4}$ ]]; then
		bash "$bash_debug" "$0" $debug_flag "NONDHCP" "$ip"
	    elif [ "$ip" = "unsassigned" ]; then
		bash "$bash_debug" "$0" $debug_flag "DELETE"
		bash "$bash_debug" "$0" $debug_flag "DELETE6"
	    else
		bash "$bash_debug" "$0" $debug_flag "NONDHCP6" "$ip"
	    fi
	done <<< "$new_ip_address"
	rv=0
	;;


    BOUND|RENEW|REBIND|REBOOT|BOUND6|RENEW6|REBIND6|REBOOT6|NONDHCP|NONDHCP6)

	# determine IP to update
	if [ -z "$2" ]; then
	    echo "[ERROR] An IP address as a second argument to reason \"$reason\" is mandatory." >&2
	    exit 1
	fi

	# get IP of host
	obtain_ip "$2"

	# check registration
	recheck_registered_address

	# update ip if needed
        ddns_ip_do update
        rv=$?
        ;;


    EXPIRE|FAIL|DOWN|RELEASE|STOP|EXPIRE6|RELEASE6|STOP6|DELETE|DELETE6)
        # notify DNS we're down

        get_ip_family
        set_down_ip
        ddns_ip_do delete
        rv=$?
        ;;

    PREINIT|TIMEOUT|PREINIT6|TIMEOUT6)
        # ignore
	rv=0
        ;;

    *)
        # Unknown DHCP reason
        echo "$(basename "$0"): DHCP reason $reason unknown. Nothing done." >&2
        rv=1
        ;;
esac

exit $rv


# Hook choice -> Problem: enter hook RELEASE acts after connection is down!
is_hook() {
    if [[ -n "$hook" && "dhclient-$1-hook" != "$hook" ]]; then
        exit 0
    fi
}
        # run as enter hook only
        hook="$3"
        is_hook enter
