#!/bin/sh ################################################################# # # @(#) ddnsupd.sh (c) 2007-2024 by H. Zuleger HZNET # # Try to update public IP addresses in the DNS with TSIG # or SIG(0) authenticated dynamic update messages (RFC3007) # Even gss-tsig is supported since end of 2017 # # This script expects an environment w/ plain routed # (propably NATed) internet access and unblocked port # 53 tcp/udp. # # Option -n suppress the execution of the nsupdate command # Option -d is to turn on verbose messaging of nsupdate # Option -v prints some additional messages on how the # script works. # Option -k is for the keyfile, -h for the hostname, # -i for the interface and -t for the ttl time. # # This script is typically run as part of a NetworkManager # dispatcher with argument "add" in case of an ifup event # and with argument "del" in case of a pre-down event. # See NetworkManager(8) for details. # # "ddnsupd show" can be used to get the IP address currently # known by DNS. # # "ddnsupd showall" prints any resource record under the # host label # # The parameter "sshfp" can be used to update the ssh # fingerprint of the host # # With "add" the current _public_ ip adresses (ipv4 and/or # ipv6) are updated # # The key used to authenticate can now generated with # the "createkey tsig" or "createkey sig0" command. # # Oct 2017 # * Added code to update SSHFP resource records # * show command print ANY records not only A and AAAA # * Support for BSD like Unix Systems (MacOS X) added # * Use of (already initialized) HOSTNAME variable # * Call of rdisc6 removed from this script. # * No longer necessary to set "dev" # * Option -n added to suppress the update itself and just # show the update message instead # * Support for key files only (It's no longer possible to # define the key in the script) # * createkey tsig|sig0 command added # # Dec 2017 # * Option -g for GSS-TSIG authentication added # With this option, ddnsupd uses the host/$hostname@REALM # credential found in the keytab file to authenticate # itself against the master server. # The ticket is stored in a dedicated cache file # (KRB5CCNAME=/tmp/krb5cc_ddnsupd) # # May 2018 # * Exit status changed from 1 to 0 in case of "No update # needed" and "No public ip address found" # # July 2018 # * Remove deprecated addresses (preferred lifetime = 0) # from ipv6 candidate list # # Sep 2020 # * Setting the TTL of the generated SIG0 key. # This is to simplify the update of the KEY record # * Option -t also to change the key TTL value # * Increase the default SIG0 key size to 3072 bits # * Use compability mode of dnssec-keygen (-C) # * Option -k supports file and keyid syntax # # Dec 2020 # * Fixed bug in SIG(0) key requisition # # May 2021 # * Bug fixing in getkeyfilename() # * New option -l to include deprecated addresses # (preferred lifetime == 0) in the add update # * "update" mode added to start nsupdate only # A tilde character is replaced by the HOSTNAME, e.g. # echo 'add ~ TXT "This is a test"' | ddnsupd update # * Option -A & -D puts "add " or "del " in fromnt of each line # echo "~ TXT" | ddnsupd -D update # # June 2021 # * Typo in "link local" IPv4 addresses # * IPv6 document addresses skipped # * help message polished # * option -p added # # Nov 2022 # * Better usage message # * "test" command added # # Mar 2024 # * "genrr" cmd added # * Fixed bug in tilde substitution (FQDN with dot) # ################################################################# # set the path to find uname, basename, dig, nsupdate, ip/ifconfig... # ... and optional hostname and ssh-keygen PATH=/usr/local/bin:/usr/bin:/bin:/sbin:/usr/local/sbin ###### default parameter settings dev="" keydir="$HOME/keys" # directory or keyfile #master="ns1.example.de" # The master ns for the domain #HOSTNAME="horst.example.net" # Set the hostname here if not # already set or $HOSTNAME is # not a FQDN KEYTTL=604800 # 1w SSHFPTTL=604800 # 1w TTL=7200 # 2 hours # SIG(0) key algorithm and size sig0alg="RSASHA256" sig0size="2048" sig0size="3072" ###### default parameter settings (end) # try to get the hostname if not already set test -z "$HOSTNAME" && HOSTNAME=`hostname` ###### determine operating system os=`uname` ###### init other vars PROGNAME=`basename $0` debug="" # set to "-d" for debug output verbose="" # set to 1 for verbose output gss="" # use kerberos auth priv="" # add private addresses updcmd="" nsupdate="nsupdate" depr="-e deprecated" usage() { if test -n "$1" then echo "$PROGNAME: $1" 1>&2 echo 1>&2 fi echo "usage: $PROGNAME help" 1>&2 echo "usage: $PROGNAME [-v] [-h host] show|showall|test" 1>&2 echo "usage: $PROGNAME [-v] [-h host] [-dn] [-g|-k kspec] [-t ttl] [-l] [-i dev] add|del" 1>&2 echo "usage: $PROGNAME [-v] [-h host] [-dn] [-g|-k kspec] [-t ttl] [-A|-D] update" 1>&2 echo "usage: $PROGNAME [-v] [-h host] [-dn] [-g|-k kspec] [-t ttl] sshfp" 1>&2 echo "usage: $PROGNAME [-v] [-h host] [-t ttl] [gen]rr [| ...]" 1>&2 echo "usage: $PROGNAME [-v] [-h host] createkey sig0|tsig" 1>&2 # echo 1>&2 echo " -v turn on verbose mode" 1>&2 echo " -d turn on debug mode of nsupdate" 1>&2 echo " -n prevent the execution of nsupdate" 1>&2 echo " -i the network interface used for address acquisition (def: all interfaces)" 1>&2 echo " -t the ttl value of the RR (default: $TTL, key-ttl: $KEYTTL, sshfp-ttl: $SSHFPTTL )" 1>&2 echo " -h host the fqdn label (hostname) of the dns record (default is \"$HOSTNAME\")" 1>&2 echo " -g use the kerberos host principal (gss) for authentication" 1>&2 echo " -k key the key filename (tsig or SIG(0)) or a key directory or a keyid" 1>&2 echo " of a key file within the default key directory (\"$keydir\")" 1>&2 echo " -l add deprecated addresses (lifetime of 0) to the update message" 1>&2 echo " -p add private addresses (RFC1918 & RFC4193) to the update message" 1>&2 echo " help this message" 1>&2 echo " show show the current A and AAAA resource records of the host" 1>&2 echo " showall show all resource records of the host" 1>&2 echo " test test if the DNS is up to date (set exit status)" 1>&2 echo " del remove all A and AAAA records of the host" 1>&2 echo " add add all public, non-temp and active IP addresses of the host" 1>&2 echo " (implicit remove all RR before)" 1>&2 echo " sshfp add the SSHFP of the host" 1>&2 echo " update read the update messages for the host from stdin" 1>&2 echo " * filters out lines starting with a semicolon and empty ones" 1>&2 echo " * replaces a tilde character with the hostname" 1>&2 echo " * use -A or -D to put \"add\" or \"del\" in front of each line" 1>&2 echo " genrr print for each parameter an A or AAAA resource record on stdout" 1>&2 echo " createkey create a new SIG0 or TSIG key for the host" 1>&2 exit 1 } # takes a key id or a key filename, plus the fqdn of the host # generates the key file out of an key id # checks if a key file is readable # returns path to keyfile getkeyfilename() { case "$1" in [0-9]*) # parameter starts with digit ? non_digits=`echo $1 | tr -d "[0-9]"` if test -n "$non_digits" # is non_digits not empty? then usage "keyid option contains non digit chars ($1)" exit 1 fi dir="$keydir" kfile="K$2+*+$1" ;; */*) # Kfile path ? dir=`dirname $1` kfile=`basename $1` ;; *) # single Kfile name? dir="$keydir" kfile=$1 ;; esac case $kfile in *.key) kfile=`basename $kfile .key` kfile="${kfile}.private" ;; *.) kfile="${kfile}private" ;; *.private) ;; esac kfile="$dir/$kfile" test "$verbose" && echo "; kfile = \"$kfile\"" 1>&2 echo $kfile } LANG="C" export LANG ipv4="" ipv6="" while test $# -gt 0 do case $1 in -h) test $# -le 1 && usage "option $1 requires parameter" shift HOSTNAME="$1" ;; -k) test $# -le 1 && usage "option $1 requires parameter" shift keyopt="$1" ;; -n) nsupdate="cat; echo nsupdate " # print on stdout instead of starting nsupdate ;; -d) debug="-d" ;; -g) gss="-g" KRB5CCNAME=/tmp/krb5cc_ddnsupd export KRB5CCNAME ;; -l) depr="" ;; -p) priv="1" ;; -v) verbose=1 ;; -t) test $# -le 1 && usage "option $1 requires parameter" shift TTL="$1" SSHFPTTL="$1" KEYTTL="$1" ;; -i) test $# -le 1 && usage "option $1 requires parameter" shift dev="$1" ;; -A) updcmd="s/^/add /" ;; -D) updcmd="s/^/del /" ;; -*) usage ;; *) break ;; esac shift done test -z "$HOSTNAME" && usage "Couldn't determine hostname" 1>&2 HOSTNAME=`echo "$HOSTNAME" | sed -e "s/\.$//"` test "$verbose" && echo "; Operating System = \"$os\"" 1>&2 test "$verbose" && echo "; Hostname = \"$HOSTNAME\"" 1>&2 cmd="$1" case "$cmd" in help) usage ;; add|del*|show*|sshfp|test|upd*) ;; *rr|*RR) shift if test $# -gt 0 # write ip args to stdout then echo $* | tr " " "\012" else # read ip addresses from stdin cat fi | while read ip rest_of_line do case $ip in *[0123456789].[0123456789]*) rrtype="A" ;; *[0123456789abcdefABCDEF]:[0123456789abcdefABCDEF]*) rrtype="AAAA" ;; *) rrtype="txt" ;; *) continue ;; esac echo "~ $TTL IN $rrtype $ip" done exit $? ;; create*) if test "$2" = tsig -o "$2" = TSIG then test -f $keydir/$HOSTNAME.secret && usage "TSIG key already found in $keydir" umask 0077 test "$verbose" && echo "tsig-keygen $HOSTNAME > $keydir/$HOSTNAME.secret" 1>&2 tsig-keygen $HOSTNAME > $keydir/$HOSTNAME.secret else # for a description of the protocol field & KEY flags see RFC2535 test "$verbose" && echo "dnssec-keygen -C -a $sig0alg -b $sig0size -L $KEYTTL -n HOST -T KEY -t NOCONF -K $keydir $HOSTNAME" 1>&2 dnssec-keygen -C -a $sig0alg -b $sig0size -L $KEYTTL -n HOST -T KEY -t NOCONF -K $keydir $HOSTNAME fi exit $? ;; "") usage "no command given"; ;; *) usage "illegal command $cmd"; ;; esac # split-off hostname in host and domain part set -- `echo $HOSTNAME | sed 's/\./ /'` host=$1 domain=$2 test -z "$domain" && usage "No domain found in hostname" 1>&2 # try to get the authoritative master name server if not already set if test -z "$master" then set -- `dig +short SOA $domain` master="$1" if test -z "$master" then usage "Unable to get authoritative master for domain $domain" 1>&2 fi fi test "$verbose" && echo "; Host = \"$host\"" 1>&2 test "$verbose" && echo "; Domain = \"$domain\"" 1>&2 test "$verbose" && echo "; Server = \"$master\"" 1>&2 test "$verbose" && echo "; TTL = \"$TTL\"" 1>&2 test "$verbose" && echo "; SSHFP TTL = \"$SSHFPTTL\"" 1>&2 test "$verbose" && echo "; KEY TTL = \"$KEYTTL\"" 1>&2 # just show what is currently in the zone under this label if test "$cmd" = "showall" then dig +multi +norec +noall +answer $domain SOA @$master dig +vc +norec +noall +answer +nocrypto $host.$domain ANY @$master | grep -v RRSIG | sort -k 4 exit $? fi if test "$cmd" = "show" then dig +norec +noall +answer $host.$domain A @$master dig +norec +noall +answer $host.$domain AAAA @$master exit $? fi # for all other operations we need a key to authenticate ourself to the master name server if test -n "$keyopt" # keyfile set via -k ? then keyfile=`getkeyfilename $keyopt $HOSTNAME` keyfileopt="-k $keyfile" else if test -n "$gss" # Is GSS authentication requested ? then keyfile="/etc/krb5.keytab" # set default keytab file keyfileopt="" else if test -d $keydir # is keydir indeed a directory? then keyfile="$keydir/$HOSTNAME.secret" # look for a tsig key file in it test -f $keyfile || # if not found, try a ... keyfile="`ls $keydir/K$HOSTNAME.+*.private | head -1`" # ...SIG0 key file else keyfile="$keydir" # keydir is (probably) the file itself fi keyfileopt="-k $keyfile" fi fi test "$verbose" && echo "; KeyFile = \"$keyfile\"" 1>&2 test ! -f "$keyfile" && usage "keyfile $keyfile not found" # try to get a kerberos ticket if GSS-TSIG is requested if test -n "$gss" then test ! -r "$keyfile" && usage "couldn't read kerberos keytab file $keyfile" test "$verbose" && echo "klist -s || kinit -k -t $keyfile -p host/$host.$domain" 1>&2 klist -s || kinit -k -t $keyfile -p host/$host.$domain fi # this command is to remove old entries manually if test $cmd = "del" then test "$verbose" && echo "Starting: \"$nsupdate $debug $keyfileopt $gss\"" 1>&2 { echo "server $master" echo "zone $domain" echo "update delete $host.$domain IN A" echo "update delete $host.$domain IN AAAA" echo "send" } | eval $nsupdate $debug $keyfileopt $gss #test -n "$gss" && kdestroy exit $? fi # the next command is to add/replace SSH fingerprints if test $cmd = "sshfp" then if type ssh-keygen > /dev/null 2>&1 && ssh-keygen -r $host.$domain >/dev/null then test "$verbose" && { echo "update delete $host.$domain IN SSHFP" 1>&2 echo "; ssh-keygen -r $host.$domain | sed \"s/^/update add /\" | sed \"s/ IN / $SSHFPTTL IN /\"" 1>&2 ssh-keygen -r $host.$domain | sed "s/^/update add /" | sed "s/ IN / $SSHFPTTL IN /" 1>&2 } { echo "server $master" echo "zone $domain" echo "update delete $host.$domain IN SSHFP" echo "send" ssh-keygen -r $host.$domain | sed "s/^/update add /" | sed "s/ IN / $SSHFPTTL IN /" echo "send" } | eval $nsupdate -v $debug $keyfileopt $gss #test -n "$gss" && kdestroy else echo "no ssh-keygen command or ssh key found" 1>&2 fi exit $? fi # run nsupdate and read dynamic dns commands from stdin if test $cmd = "update" then test "$verbose" && echo "Starting: \"$nsupdate $debug $keyfileopt $gss\"" 1>&2 { echo "server $master" echo "zone ${domain}." echo "ttl $TTL" grep -v -e '^[ ]*$' | grep -v "^;" | sed -e "s/~/$HOSTNAME./g" -e "$updcmd" echo "send" } | eval $nsupdate $debug $keyfileopt $gss #test -n "$gss" && kdestroy exit $? fi if test $cmd != "add" -a $cmd != "test" then usage "illegal command $cmd" fi # from here on we are dealing with ip addresses ... # ... so take a look at the current ip addresses on active interfaces or on $dev case $os in Linux) test -n "$dev" && dev="dev $dev" ipv4list=`ip addr show $dev | sed -n "/inet /s/^[ ]*inet \([0-9][0-9.]*\).*/\1/p"` ipv6list=`ip -6 addr show $dev scope global | grep inet6 | grep -v -e temporary $depr | sed -n "/inet6 /s/^[ ]*inet6 \([23][0-9a-fA-F:]*\)\/.*/\1/p"` ;; *) ipv4list=`ifconfig $dev | grep "inet " | cut -d" " -f2` ipv6list=`ifconfig $dev | grep inet6 | grep -v -e temporary $depr | cut -d" " -f2 | grep "^[23][0-9a-fA-F:]"` ;; esac test "$verbose" && echo "Raw IPv4:" \"$ipv4list\" 1>&2 test "$verbose" && echo "Raw IPv6:" \"$ipv6list\" 1>&2 checkip4=`echo "0$ipv4list" | tr -d "0-9."` checkip6=`echo "0$ipv6list" | tr -d "a-fA-F0-9: "` if test -n "$checkip4" -o -n "$checkip6" then usage "error: parsing of ip address on interface $dev failed: $ipv4/$ipv6" fi # remove private and other non global addresses ipv4l="" for ipv4 in $ipv4list do case $ipv4 in # CGNAT (RFC6598) 100.64.0.0/10 100.6[456789].*|100.[789][0123456789].*|100.1[0123456789].*|100.12[01234567].*) ;; # so called "link local" 169.254.*) ;; # rfc 1918 10.*|172.1[6789].*|172.2[0123456789].*|172.30.*|172.31*|192.168.*) test -n "$priv" && ipv4l="$ipv4l $ipv4" ;; # localhost & special purpose or document addresses 127.*|192.0.0.*|192.0.2.*|198.51.100.*|203.0.113.*) ;; *) ipv4l="$ipv4l $ipv4" ;; esac done # same for ipv6 ipv6l="" for ipv6 in $ipv6list do case $ipv6 in fd??:*) test -n "$priv" && ipv6l="$ipv6l $ipv6" ;; 2001:0[dD][bB]8:*|2001:[dD][bB]8:*) ;; 2???:*|3???:*) ipv6l="$ipv6l $ipv6" ;; esac done ipv4l=`echo "$ipv4l" | sed "s/^ *//"` ipv6l=`echo "$ipv6l" | sed "s/^ *//"` test "$verbose" && echo "Public IPv4:" \"$ipv4l\" 1>&2 test "$verbose" && echo "Public IPv6:" \"$ipv6l\" 1>&2 if test -z "$ipv4l" -a -z "$ipv6l" then test "$verbose" && echo "No public ip address found" 1>&2 exit 0 fi # now take a look on what's already in the DNS dnsipv4=`dig +short A $host.$domain @$master` dnsipv6=`dig +short AAAA $host.$domain @$master` test "$verbose" && echo "Published IPv4:" \"$dnsipv4\" 1>&2 test "$verbose" && echo "Published IPv6:" \"$dnsipv6\" 1>&2 # mix this with the local ip addresses found and filter out duplicates # if any ip address remains: DNS has a different view than that what's local uniqv4=`echo $ipv4l $dnsipv4 | tr " " "\012" | sort | uniq -u` uniqv6=`echo $ipv6l $dnsipv6 | tr " " "\012" | sort | uniq -u` test "$verbose" && echo "Diff ipv4 (published/public):" \"$uniqv4\" 1>&2 test "$verbose" && echo "Diff ipv6 (published/public):" \"$uniqv6\" 1>&2 # test if not only a single ip address is in any of the combined list if test -z "$uniqv4" -a -z "$uniqv6" then if test $cmd = test then echo "DNS is up to date" else test "$verbose" && echo "No update needed " 1>&2 fi exit 0 elif test $cmd = test then echo "DNS is outdated" exit 1 fi test "$verbose" && echo "Starting: \"$nsupdate $debug $keyfileopt $gss\"" 1>&2 { echo "server $master" echo "zone $domain" test -n "$key" && echo "key $algo:$kname $key" # at first delete old entrys echo "update delete $host.$domain IN A" echo "update delete $host.$domain IN AAAA" echo "send" # now add the new ones for ipv4 in $ipv4l do test -n "$ipv4" && echo "update add $host.$domain $TTL IN A $ipv4" done for ipv6 in $ipv6l do test -n "$ipv6" && echo "update add $host.$domain $TTL IN AAAA $ipv6" done test "$verbose" && echo "show" echo "send" # echo "answer" } | eval $nsupdate $debug $keyfileopt $gss #test -n "$gss" && kdestroy