#!/bin/bash

# Update DNS with TSIG key and nsupdate called by CGI

# Use the parameter to pass in the URL:
#	host	FQDN of host to update
#	ipv4	numerical IPv4 address of host
#	ipv6	numerical IPv6 address of host without prefix length
#	keyname	TSIG keyname without trailing dot
#	keyalgo	TSIG key algorithm
#	key	TSIG key as string, must be given as last parameter!
#
#
# For an AVM Fritz!Box use the line below, replace the <server address> tag and let
# the rest as is for custom dyndns updates:
#
# https://<server address>/nsupdate?host=<domain>&ipv4=<ipaddr>&ipv6=<ip6addr>&keyname=<username>&keyalgo=hmac-sha512&key=<pass>
#
# For a FreedomBox use:
# https://<server address>/nsupdate?host=<Domain>&ipv4=<Ip>&keyname=<User>&keyalgo=hmac-sha512&key=<Pass>


# ************************************************************************************************************************ #
#                                                                                                                          #
# SECURITY ADVICE: Do not log the query string of requests to this script on your webserver to                             #
#                  avoid writing your TSIG keys to your log files in clear text!                                           #
#                                                                                                                          #
#		   For Apache's HTTPd use something like:                                                                  #
#                                                                                                                          #
#		   LogFormat "%h %l %u %t \"%m %U %H\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined_wo_querystring  #
#		   SetEnvIfExpr "%{QUERY_STRING} =~ /key=.+/" key_in_query                                                 #
#		   CustomLog "/var/log/apache2/access.log" combined_wo_querystring env=key_in_query                        #
#                                                                                                                          #
# ************************************************************************************************************************ #


VERSION=0.9
VERSION_INFO="(c) 2021-2023 by Adrian Zaugg under GNU General Public License Version 3 or later."


# name server address to update records
NAME_SERVER=""

# time to live for created entries
TTL=120

# Allow clients to update a host name only, if they set the ip to the address they sent the request from.
# This check does not protect from malicious clients, for this rather keep your update key safe. It may
# makes it a little bit harder for an attacker or safes you from your own mistakes.
# Set to true or false (default).
RESTRICT_TO_OWN_IP=false

# If you restricted to own ip, deny updates from unknown clients. Set to true (default) or false.
DENY_UNKNOWN_CLIENT=true


# ---------- do not edit below ----------
set -e

# limit size of posted data to LIMIT number of Bytes
LIMIT=1024

# execute nsupdate update function
update_dns() {
	local update_statement=""

	# get IP version and prepare statements
	if [ "$IPV4" != "-" ]; then
		update_statement="${update_statement}update delete $HOST A"$'\n'
		if [ "$IPV4" != "deleted" ]; then
			update_statement="${update_statement}update add $HOST $TTL A $IPV4"$'\n'
		fi
	fi
	if [ "$IPV6" != "-" ]; then
		update_statement="${update_statement}update delete $HOST AAAA"$'\n'
		if [ "$IPV6" != "deleted" ]; then
			update_statement="${update_statement}update add $HOST $TTL AAAA $IPV6"$'\n'
		fi
	fi
	if [[ -z "$IPV4" && -z "$IPV6" ]]; then
		return 1
	else
		# remove empty lines
		update_statement="$(echo "${update_statement}" | grep -v '^$')"
	fi

	ERR="$(nsupdate -v -k "$KEYFILE" 2>&1 << \
EOF
server $NAME_SERVER
zone $ZONE
$update_statement
send
EOF
	)"

	return $?
}


# generate tmp key file
generate_keyfile() {
	local tmpkeyfile
	local keyname=$1
	local algo=$2
	local key=$3

	# create tmp file
	tmpkeyfile="$(mktemp --suffix=".tmp" --tmpdir="/tmp" -t ddnsupdate_web_XXXXXXXX)"
	chmod go-rwxs "$tmpkeyfile"

	# create named.conf-style key file
	echo "key \"${keyname}.\" {" >> "$tmpkeyfile"
	echo "  algorithm ${algo};" >> "$tmpkeyfile"
	echo "  secret \"${key}\";" >> "$tmpkeyfile"
	echo "};" >> "$tmpkeyfile"

	# return keyfile
	echo "$tmpkeyfile"
};


# HTML header and footer
HTML_HEADER="<html><body>"
HTML_FOOTER="</body></html>"

# output CGI header
echo -e "Content-type: text/html\n\n"
echo -e '<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN">'"\n"
echo "$HTML_HEADER"

# check NAME_SERVER is set
if [ -z "$NAME_SERVER" ]; then
	echo "[ERROR] setup incomplete"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] ERROR: You need to set the variable NAME_SERVER in the script" >&2
	exit 0
fi

# Set default for RESTRICT_TO_OWN_IP
if [ -z "$RESTRICT_TO_OWN_IP" ]; then
	# set default (false)
	RESTRICT_TO_OWN_IP=false
elif [[ "$RESTRICT_TO_OWN_IP" != "true" && "$RESTRICT_TO_OWN_IP" != "false" ]]; then
	echo "[ERROR] setup incomplete"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] ERROR: You need to set the variable RESTRICT_TO_OWN_IP to \"true\" or \"false\"." >&2
	exit 0
fi

# Set default for DENY_UNKNOWN_CLIENT
if $RESTRICT_TO_OWN_IP; then
	# ensure DENY_UNKNOWN_CLIENT is set
	if [ -z "$DENY_UNKNOWN_CLIENT" ]; then
		# set default (true)
		DENY_UNKNOWN_CLIENT=true
	elif [[ "$DENY_UNKNOWN_CLIENT" != "true" && "$DENY_UNKNOWN_CLIENT" != "false" ]]; then
		echo "[ERROR] setup incomplete"
		echo "$HTML_FOOTER"
		echo "[$(basename "$0")] ERROR: You need to set the variable DENY_UNKNOWN_CLIENT to \"true\" or \"false\"." >&2
		exit 0
	fi
fi

# Limit, sanitize a bit and save querystring
QS="$(cat | dd count=${LIMIT} 2>/dev/null | tr -d '"(),[];?!|#\*_$')"
# get instead of post, if nothing was posted
if [ -z "$QS" ]; then QS="$QUERY_STRING"; fi
# add ampersand at the end of query string for easier parsing
QS="${QS}"'&'

# get client address
if [[ -n "$HTTP_X_FORWARDED_FOR" && "$HTTP_X_FORWARDED_FOR" != "$REMOTE_ADDR" ]]; then
	# proxied request
	UPDATE_CLIENT="$HTTP_X_FORWARDED_FOR"
elif [ -n "$REMOTE_ADDR" ]; then
	UPDATE_CLIENT="$REMOTE_ADDR"
else
	UPDATE_CLIENT="unknown"
fi


# parse query string

# host
HOST="$(echo "$QS" | sed -e "s/^.*host=\([^&]*\)&.*$/\1/")"
if [ "$HOST" = "$QS" ]; then
	echo "[ERROR] mandatory argument missing"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] host missing in request (client $UPDATE_CLIENT)" >&2
	exit 0
elif [ $(echo "$HOST" | egrep -c "[^0-9a-z._-]") -gt 0 ]; then
	echo "[ERROR] host format error"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] host \"$HOST\" (client $UPDATE_CLIENT): format error" >&2
	exit 0
fi

# ipv4 address
REGISTERED_ADDRESS4="$UPDATE_CLIENT"
if [ $(echo "$QS" | grep -c "ipv4=") -eq 1 ]; then
	# search for empty ipv4 parameter
	if [ $(echo "$QS" | grep -F -c "ipv4=&") -eq 1 ]; then
		IPV4=deleted
		REGISTERED_ADDRESS4="$(dig +short A "$HOST" @"$NAME_SERVER" 2>/dev/null)"
		if [ $? -ne 0 ]; then
		     # ns failure: just continue
		     REGISTERED_ADDRESS4="$UPDATE_CLIENT"
		elif [ -z "$REGISTERED_ADDRESS4" ]; then
			echo "[WARN] IP already deleted"
			echo "$HTML_FOOTER"
			echo "[$(basename "$0")] IPv4 address already deleted (client $UPDATE_CLIENT)" >&2
		fi

	# validate ipv4 address
	else
		IPV4="$(echo "$QS" | sed -e "s/^.*ipv4=\([0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\.[0-9]\{1,3\}\)&.*$/\1/")"
		if [[ "$IPV4" = "$QS" || "$(echo "$IPV4" | egrep -c '^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$')" -ne 1 ]]; then
			echo "[ERROR] IP address format error"
			echo "$HTML_FOOTER"
			echo "[$(basename "$0")] IPv4 address \"$IPV4\" (client $UPDATE_CLIENT): format error" >&2
			exit 0
		fi
	fi
fi

# ipv6 address
REGISTERED_ADDRESS6="$UPDATE_CLIENT"
if [ $(echo "$QS" | grep -c "ipv6=") -eq 1 ]; then
	# search for empty ipv6 parameter
	if [ $(echo "$QS" | grep -F -c "ipv6=&") -eq 1 ]; then
		IPV6=deleted
		REGISTERED_ADDRESS6="$(dig +short AAAA "$HOST" @"$NAME_SERVER" 2>/dev/null)"
		if [ $? -ne 0 ]; then
		     # ns failure: just continue
		     REGISTERED_ADDRESS6="$UPDATE_CLIENT"
		elif [ -z "$REGISTERED_ADDRESS6" ]; then
			echo "[WARN] IP already deleted"
			echo "$HTML_FOOTER"
			echo "[$(basename "$0")] IPv6 address already deleted (client $UPDATE_CLIENT)" >&2
		fi

	# validate ipv6 address
	else
		IPV6="$(echo "$QS" | sed -e "s/^.*ipv6=\([0-9a-fA-F:]\{2,\}\)&.*$/\1/")"
		# validate input and set to current season on failure
		if [[ "$IPV6" = "$QS" || "$(echo "$IPV6" | egrep -c '^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$')" -ne 1 ]]; then
			echo "[ERROR] IP address format error"
			echo "$HTML_FOOTER"
			echo "[$(basename "$0")] IPv6 address \"$IPV6\" (client $UPDATE_CLIENT): format error" >&2
			exit 0
		fi
	fi
fi

# either ipv4 or ipv6 must be given
if [ -z "${IPV4}${IPV6}" ]; then
	echo "[ERROR] mandatory argument missing"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] IP address missing in request (client $UPDATE_CLIENT)" >&2
	exit 0
else
	if [ -z "$IPV4" ]; then IPV4="-"; fi
	if [ -z "$IPV6" ]; then IPV6="-"; fi
fi

# key name
KEYNAME="$(echo "$QS" | sed -e "s/^.*keyname=\([^&]*\)&.*$/\1/")"
if [ "$KEYNAME" = "$QS" ]; then
	echo "[ERROR] mandatory argument missing"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] keyname missing in request (client $UPDATE_CLIENT)" >&2
	exit 0
elif [ $(echo "$KEYNAME" | egrep -c "[^0-9a-z._-]") -gt 0 ]; then
	echo "[ERROR] key name format error"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] keyname \"$KEYNAME\" (client $UPDATE_CLIENT): format error" >&2
	exit 0
fi

# key algo
KEYALGO="$(echo "$QS" | sed -e "s/^.*keyalgo=\([^&]*\)&.*$/\1/")"
if [ "$KEYALGO" = "$QS" ]; then
	echo "[ERROR] mandatory argument missing"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] keyalgo missing in request (client $UPDATE_CLIENT)" >&2
	exit 0
elif [ $(echo "$KEYALGO" | egrep -vc "hmac-sha512") -gt 0 ]; then
	echo "[ERROR] key algorithm unknown"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] keyalgo \"$KEYALGO\" unknown (client $UPDATE_CLIENT): only hmac-sha512 accepted (client $UPDATE_CLIENT)" >&2
	exit 0
fi

# key
KEY="$(echo "$QS" | sed -e "s/^.*key=\([^&]*\)&$/\1/")"
if [[ "$KEY" != "$QS" && $(echo "$KEY" | egrep -c '%3D%3D$') -eq 1 ]]; then
	# urlencoded key; decode
	KEY="$(urlencode -d <<< echo "$KEY" | head -1)"
fi
if [ "$KEY" = "$QS" ]; then
	echo "[ERROR] mandatory argument missing"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] key missing in request (client $UPDATE_CLIENT)" >&2
	exit 0
elif [[ $(echo "$KEY" | egrep -c "[^0-9a-zA-Z/+=]") -gt 0 || $(echo "$KEY" | egrep -c '==$') -ne 1 ]]; then
	echo "[ERROR] key format error"
	echo "$HTML_FOOTER"
	echo "[$(basename "$0")] key \"$KEY\" (client $UPDATE_CLIENT): format error" >&2
	exit 0
fi

# only allowed to update own IP
# The check does not work for a second address to update which is not of the same ip familiy as the update request is sent with
if $RESTRICT_TO_OWN_IP; then
	NOT_ALLOWED=true
	if [[ "${UPDATE_CLIENT}." =~ ^([0-2]{0,1}[0-9]{1,2}\.){4}$ ]]; then
		# update requested via ipv4
		if [ "$IPV4" = '-' ]; then
			echo "[$(basename "$0")] $UPDATE_CLIENT tried to manipulate $HOST over IPv4 without ipv4 parameter. Denied." >&2
		elif [ "$UPDATE_CLIENT" != "$REGISTERED_ADDRESS4" ]; then
			echo "[$(basename "$0")] $UPDATE_CLIENT tried to delete address of $HOST: only deleting own IP allowed" >&2
		elif [[ "$UPDATE_CLIENT" != "$IPV4" && "$IPV4" != 'deleted' ]]; then
			echo "[$(basename "$0")] $UPDATE_CLIENT tried to set address of $HOST to $IPV4: only setting own IP allowed" >&2
		else
			NOT_ALLOWED=false
		fi
	elif [[ "${UPDATE_CLIENT}:" =~ ^([0-9a-fA-F]{1,4}:{1,3}){1,8}$ ]]; then
		# update requested via ipv6
		if [ "$IPV6" = '-' ]; then
			echo "[$(basename "$0")] $UPDATE_CLIENT tried to manipulate $HOST over IPv6 without ipv6 parameter. Denied." >&2
		elif [ "$UPDATE_CLIENT" != "$REGISTERED_ADDRESS6" ]; then
			echo "[$(basename "$0")] $UPDATE_CLIENT tried to delete address of $HOST: only deleting own IP allowed" >&2
		elif [[ "$UPDATE_CLIENT" != "$IPV6"  && "$IPV6" != 'deleted' ]]; then
			echo "[$(basename "$0")] $UPDATE_CLIENT tried to set address of $HOST to $IPV6: only settingcown IP allowed" >&2
		else
			NOT_ALLOWED=false
		fi
	elif $DENY_UNKNOWN_CLIENT; then
			echo "[$(basename "$0")] Unknown client denied setting address of $HOST to IPv4:$IPV4, IPv6:$IPV6." >&2
	else
		NOT_ALLOWED=false
	fi
	if $NOT_ALLOWED; then
		echo "[ERROR] not allowed"
		echo "$HTML_FOOTER"
		exit 0
	fi
fi

# get the zone from hostname
ZONE="$(echo "$HOST" | sed -e "s/^[^.]\+\.//")"

# generate keyfile for nsupdate
KEYFILE="$(generate_keyfile "$KEYNAME" "$KEYALGO" "$KEY")"

# update dns
update_dns
if [ $? -eq 0 ]; then
	echo "ok."
	echo "Updated DNS successfully: $HOST IPv4:$IPV4 IPv6:$IPV6 (Zone $ZONE) on server $NAME_SERVER (client $UPDATE_CLIENT)" >&2
else
	echo "[ERROR] update failed"
	echo "[$(basename "$0")] DNS update failed for $HOST (tried by $UPDATE_CLIENT): $ERR" >&2
fi
echo "$HTML_FOOTER"

# delete keyfile
rm "$KEYFILE"
