#!/bin/sh
#
#	program:	dlint
#	usage:		dlint [-n] zone
#	options:	-n   no recursion
#	purpose:	To scan through a DNS zone domain hierarchy and report certain
#			possible configuration problems found therein.
#	output:		A verbose description of what was found in comments,
#			with warnings and error messages of any problems.
#			Output is intended to be computer-parsable.
#			Usage message gets printed on stderr.
#	exit value:	0 if everything looks right
#			1 if nothing worse than a warning was found
#			2 if any errors were found
#			3 for usage error (i.e., incorrect command line options)
#
# Copyright (C) 1993-1998 Paul A. Balyoz <pab@domtools.com>
#
#    This program is free software; you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation; either version 2 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program; if not, write to the Free Software
#    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#
#
# NOTES
#
#  * Handling localhost (127.0.0.1) is hard, how should it really be done?
#    If you define localhost.<domain> in many domains, you are screwed when you
#    look up 1.0.0.127.in-addr.arpa because it can only point to one of them.
#    Now maybe you think that 1.0.0.127.in-addr.arpa should point to "localhost."
#    But will all software on all computers really query "localhost." (in the root
#    domain), or will they actually be querying "localhost" (no dot, so resolver
#    considers it in the current domain)?
#    Current Solution: special-case "localhost" 127.0.0.1.
#    The only localhost-related things we check now are:
#	* 1.0.0.127.in-addr.arpa. points to some host that doesn't point back to
#		127.0.0.1 (normal TEST 3a checking),
#	* if hostname "localhost" in any domain maps to an IP address other than
#		127.0.0.1 (or host has address 127.0.0.1 but isn't named localhost),
#	* 1.0.0.127.in-addr.arpa. doesn't point to hostname "localhost" in any
#		domain (or it has host "localhost" but wrong in-addr.arpa address).
#

# Path to standard bin dirs on many platforms.
# Be sure this path includes the directory that holds your dig executable:
if test x"$PATH" = x""; then	# for security purposes
	PATH="/usr/ucb:/usr/bsd:/bin:/usr/bin:/usr/local/bin:/usr/share/bin:/usr/com/bin"
else
	PATH="${PATH}:/usr/ucb:/usr/bsd:/bin:/usr/bin:/usr/local/bin:/usr/share/bin:/usr/com/bin"
fi
export PATH

VERSION=1.3.3

# ----------- BEGIN CONFIGURATIONS -------------------------

# RR filter from DiG output format to all FQDN on every line format.
# Change this path for your site!  See Makefile.
rrfilt="/usr/local/bin/digparse"

# ------------- END CONFIGURATIONS -------------------------


TMPNS=/var/tmp/dlintns.$$
TMPZONE=/var/tmp/dlintzone.$$
TMPPTR=/var/tmp/dlintptr.$$
TMPA=/var/tmp/dlinta.$$
TMPSUBDOMS=/var/tmp/dlintsubdoms.$$
TMPERR=/var/tmp/dlinterr.$$
TMPSERIALS=/var/tmp/dlintserials.$$

trap "rm -f $TMPNS $TMPZONE $TMPPTR $TMPA $TMPSUBDOMS $TMPERR; exit 4" 1 2 3 15

usage() {
	echo 'usage: dlint [-n] zone' 2>&1
	echo '       example zones:  yoursite.com.  3.2.1.in-addr.arpa.' 2>&1
	exit 3
}

if test $# -lt 1 -o $# -gt 2; then
	usage
fi

#
# Configure for System V echo or BSD echo, whichever we have.
#
if test `echo -n hello|wc -l` -eq 0; then
	echoc=''
	echon='-n'
else
	echoc='\c'
	echon=''
fi

#
# Check if dig is installed
#
ver=`dig | grep DiG | head -1 | sed -e 's/.*DiG \([0-9.]*\).*/\1/'`
ans=`echo $ver | awk '$1 >= 2.1 {print "ok"; exit}'`		# floating point math
if test x"$ans" != x"ok"; then
	echo ';; This program requires DiG version 2.1 or newer, which I cannot find.'
	exit 3
fi

#
# Options (change these if you need to)
#
digopts='+ret=2 +pfset=0x2024'

#
# Other things you might need to change
#
# Filter that converts input to lowercase
tolower='tr A-Z a-z'

#
# Initialize flags (leave these alone)
#
exitcode=0 norecurse=false silent=false domain='' inaddrdomain=false

#
# Determine type of domain (forward or inverse)
#    arg 1 = domain name with ending period.
#    returns:  "inverse" or "forward" on stdout
#
domaintype () {
	lcdom=`echo "$1" | $tolower`
	case $lcdom in
		*in-addr.arpa.)
			echo "inverse"
			;;
		*)
			echo "forward"
			;;
	esac
}

#
# Parse command-line arguments
#
for i do
	case "$i" in
		-n)		norecurse=true
				;;

		-silent)	silent=true
				;;

		*)		if test x"$domain" = x""; then
					domain="$i"
				else
					usage
				fi
				;;
	esac
done

# Reverse-sense flags

if $silent; then
	notsilent=false
else
	notsilent=true
fi
if $norecurse; then
	recurse=false
else
	recurse=true
fi

# No domain or empty domain specified

if test x"$domain" = x""; then
	usage
fi

# Determine if domain is inverse-address or not

ans=`echo $domain | $tolower | awk '/.in-addr.arpa/ {print "ok"; exit}'`
if test x"$ans" = x"ok"; then
	inaddrdomain=true
fi

#
# Print welcome message if not calling self recursively
#
if $notsilent; then
	echo ";; dlint version $VERSION, Copyright (C) 1998 Paul A. Balyoz <pab@domtools.com>"
	echo ";;     Dlint comes with ABSOLUTELY NO WARRANTY."
	echo ";;     This is free software, and you are welcome to redistribute it"
	echo ";;     under certain conditions.  Type 'man dlint' for details."

	echo ";; command line: $0 $*"
	echo $echon ";; flags:$echoc"
	if $inaddrdomain; then
		echo $echon " inaddr-domain$echoc"
	else
		echo $echon " normal-domain$echoc"
	fi
	if $norecurse; then
		echo $echon " not-recursive$echoc"
	else
		echo $echon " recursive$echoc"
	fi
	echo "."
	echo ";; run starting: `date`"
fi

echo ";; ============================================================"
echo ";; Now linting $domain"

#
# Identify all nameservers for this zone
#
dig NS $domain $digopts | $rrfilt | awk '$2=="NS" {print $3}' > $TMPNS
if test ! -s $TMPNS; then
	echo "ERROR: no name servers found for domain $domain"
	echo "	That domain is probably not a zone.  Remove the leftmost portion of the name and try again."
	echo ";; ============================================================"
	echo ";; dlint of $domain run ending with errors."
	echo ";; run ending: `date`"
	rm -f $TMPNS $TMPZONE $TMPPTR $TMPA $TMPSUBDOMS $TMPERR
	exit 2
fi

#
# TEST 1
# Check all zone nameservers' SOA RRs for serial number similarity.
# If they have < 2 nameservers, complain.
#
responding=
if test `wc -l < $TMPNS` -eq 1; then
	echo "WARNING: only 1 nameserver found for zone $domain"
	echo "	Every zone should have 2 or more nameservers at all times."
	test $exitcode -lt 1 && exitcode=1
else
	echo ";; Checking serial numbers per nameserver"
	rm -f $TMPSERIALS
	for ns in `cat $TMPNS`; do
		# Sanity check nameserver's name
		if test x`domaintype $ns` = x"inverse"; then
			echo "WARNING: nameserver $ns has in-addr.arpa. in its name which is bad; skipping."
			echo "	I'll bet you left off its full domain name on the NS record, as in:"
			echo "	$domain	IN	NS	someserver"
			echo "	You should append the fully qualified domain name with ending period, as in:"
			echo "	$domain	IN	NS	someserver.your.domain.com."
			continue
		fi
		# Ask this nameserver for domain's SOA record
		serial=`dig @$ns $domain SOA $digopts 2> $TMPERR | $rrfilt | \
				awk '$2=="SOA" {print $5; exit}'`
		# Eliminate nameservers that couldn't return an SOA for zone $ns
		if test ! -s $TMPERR; then
			echo ";;     $serial $ns";
			echo "$serial $ns" >> $TMPSERIALS
		else
			echo "WARNING: nameserver $ns returned an error when asked for SOA of $domain; skipping."
			responding=" responding"
		fi
	done
	if test ! -s $TMPSERIALS; then
		echo "ERROR: no good name servers found for domain $domain"
		echo "	Aborting run."
		echo ";; ============================================================"
		echo ";; dlint of $domain run ending with errors."
		echo ";; run ending: `date`"
		rm -f $TMPNS $TMPZONE $TMPPTR $TMPA $TMPSUBDOMS $TMPERR $TMPSERIALS
		exit 2
	fi
	if test `awk '{print $1}' < $TMPSERIALS | sort -u | wc -l` -gt 1; then
		echo "WARNING: nameservers don't seem to agree on the zone's serial number."
		echo "	Dlint will query nameserver with largest serial number first."
		test $exitcode -lt 1 && exitcode=1
	else
		echo ";; All$responding nameservers agree on the serial number."
	fi
	# Re-order nameservers from highest SOA serial number to lowest.
	# This also removes bogus nameservers from $TMPNS.
	sort +0nr $TMPSERIALS | awk '{print $2}' > $TMPNS
	rm -f $TMPSERIALS
fi


#
# SETUP FOR TESTS 2 AND 3
# Transfer this whole zone to a temporary file
#
echo ";; Now caching whole zone (this could take a minute)"
i=1
badns=true
while test $i -le `wc -l < $TMPNS`; do
	badns=false
	ns=`tail +$i $TMPNS | head -1`
	echo ";; trying nameserver $ns"
	dig @$ns $domain AXFR $digopts 2> $TMPERR | $rrfilt > $TMPZONE
	if test `wc -l < $TMPERR` -eq 0; then
		break
	fi
	echo "WARNING: nameserver $ns is not responding properly to queries; skipping."
	badns=true
	test $exitcode -lt 1 && exitcode=1
	i=`expr $i + 1`
done
if $badns; then
	echo "ERROR: could not find any working nameservers for $domain"
	echo ";; ============================================================"
	echo ";; dlint of $domain run ending with errors."
	echo ";; run ending: `date`"
	rm -f $TMPNS $TMPZONE $TMPPTR $TMPA $TMPSUBDOMS $TMPERR
	test $exitcode -lt 2 && exitcode=2
	exit $exitcode
fi

#
# TEST 2
# Look for all zone records with "#" as first character (illegal) --
# they probably thought they were commenting out a line!
#
grep '^#' $TMPZONE > $TMPA
if test $? -eq 0; then
	echo "ERROR: some zone records begin with '#' character which is illegal."
	test $exitcode -lt 2 && exitcode=2
	len=`wc -l < $TMPA`
	if test $len -lt 5; then
		echo "	Use ';' for comment symbol, not '#'!  Offending records:"
		sed -e 's/^/		/' < $TMPA
	else
		echo "	Use ';' for comment symbol, not '#'!  First 5 offending records:"
		head -5 $TMPA | sed -e 's/^/		/'
	fi
fi

#
# TEST 3a (for in-addr.arpa domains)
# All PTR records' hosts must have an A record with the same address,
# unless that PTR rec is a network name instead of a host [RFC1101]
# (see later tests).  But we don't know if it's really a network or
# just a host with a missing A record, so we report it.
# Any PTR record for 1.0.0.127.in-addr.arpa should point to a host named
# "localhost" (in any domain), and vice-versa.
#
# BUG: We assume all X.X.X.X.in-addr.arpa format names are those of hosts,
#      and all others (less than 4 X's) are networks.  But if you happen to
#      be doing subnetting such that the number of host bits < 8, then your
#      subnets will have 4 octets too, which we don't handle properly.
#      Before CIDR, this couldn't be done right without strict RFC1101
#      adherance, which nobody really cared about except myself.
#      With CIDR it may be possible, I need to sit down and think about it.
#
if $inaddrdomain; then
	awk '!/^;/ && $2=="PTR"' < $TMPZONE | sort -u > $TMPPTR
	i=0
	len=`wc -l < $TMPPTR`
	if test $len -gt 0; then
		echo ";;" $len "PTR records found."
	else
		echo "ERROR: no PTR records found."
		test $exitcode -lt 2 && exitcode=2
	fi
	while test $i -lt $len; do
		i=`expr $i + 1`
		set `tail +$i $TMPPTR | head -1`
		inaddr=$1 host=$3
		# if not 4 numeric octets, assume it's a network address.
		num=`echo $inaddr | tr . '\012' | awk '{r++} /^in-addr$/ {print r - 1}'`
		if test 0"$num" -ne 4; then
			continue
		fi
		# this may hold more than one address if host is multihomed or a gateway:
		addr=`dig A $host $digopts | $rrfilt | awk '$2=="A" {print $3}'`
		if test x"$addr" = x""; then
			case $inaddr in
			    '#'*)
				echo "ERROR: illegal domain name $inaddr has a PTR record."
				echo "	Use ';' for zone file comments, not '#'!"
				test $exitcode -lt 2 && exitcode=2
				;;
			    *)
				echo "WARNING: \"$inaddr PTR $host\", but $host has no A record."
				echo "	But that's OK only if it's a network or other special name instead of a host."
				test $exitcode -lt 1 && exitcode=1
				;;
			esac
			continue
		fi
		ina=`echo $inaddr | awk -F. '{print $4 "." $3 "." $2 "." $1}'`
		a=`echo "$addr" | awk "/^$ina\$/ {print}"`
		if test x"$a" != x""; then
#			echo ";; $inaddr and $addr match."
			:
		else
			echo "ERROR: \"$inaddr PTR $host\", but the address of $host is really $addr"
			test $exitcode -lt 2 && exitcode=2
		fi

		# If record is 1.0.0.127.in-addr.arpa., make sure hostname is localhost.*
		if test x"$ina" = x"127.0.0.1"; then
			hostname=`echo $host | awk -F. '{print $1}' | $tolower`
			if test x"$hostname" != x"localhost"; then
				echo "WARNING: \"$inaddr PTR $host\", but it should point to localhost instead."
				echo "	This could confuse some computers (particularly Unix) in that domain."
				test $exitcode -lt 1 && exitcode=1
			fi
		fi

		# If record has host named localhost.*, make sure PTR rec is 1.0.0.127.in-addr.arpa.
		hostname=`echo $host | awk -F. '{print $1}' | $tolower`
		if test x"$hostname" = x"localhost"; then
			if test x"$ina" != x"127.0.0.1"; then
				echo "WARNING: \"$inaddr PTR $host\", but only 1.0.0.127.in-addr.arpa. should point to localhost."
				echo "	This could confuse some computers (particularly Unix) in that domain."
				test $exitcode -lt 1 && exitcode=1
			fi
		fi
	done

#
# TEST 3b (for regular domains)
# All hosts with A records must have reverse in-addr.arpa PTR records
# and they should point back to the same host name.
# Any host named "localhost" in any domain should have IP address 127.0.0.1,
# and vice-versa.
#
# BUG: Sometimes there will be a special host in a domain that has an A record
#      pointing to some host which has a different name in _another_ zone.
#      Example:  info.nau.edu is really pumpkin.ucc.nau.edu in disguise.
#      This is currently reported as an error, there's no way to tell it is
#      intentional.  (not sure how to deal with this)
#
else
	awk '!/^;/ && $2=="A"' < $TMPZONE | sort -u > $TMPA
	i=0
	len=`wc -l < $TMPA`
	if test $len -gt 0; then
		echo ";;" $len "A records found."
	else
		echo "ERROR: no A records found."
		test $exitcode -lt 2 && exitcode=2
	fi
	while test $i -lt $len; do
		i=`expr $i + 1`
		set `tail +$i $TMPA | head -1`
		host=$1 addr=$3
		inaddr=`echo $addr | awk -F. '{print $4 "." $3 "." $2 "." $1 ".in-addr.arpa."}'`
		inhost=`dig PTR $inaddr $digopts | $rrfilt | awk '$2=="PTR" {print $3}'`
		if test x"$inhost" = x""; then
			case $host in
			    '#'*)
				echo "ERROR: illegal domain name $host has an A record."
				echo "	Use ';' for zone file comments, not '#'!"
				;;
			    *)
				echo "ERROR: $host has an A record of $addr, but no reverse PTR record for $inaddr can be found on nameserver $ns"
				echo "	The following resource record should be added:"
				echo "	$inaddr	IN	PTR	$host"
				;;
			esac
			test $exitcode -lt 2 && exitcode=2
			continue
		fi
		numptrs=`echo "$inhost" | wc -l`
		# numptrs ends up with lots of spaces in it, so don't put it inside quotes...
		if test $numptrs -gt 1; then
			echo "ERROR: $inaddr has" $numptrs "PTR records, but there should be only 1."
		fi
		lhost=`echo $host | $tolower`
		multipleinhosts="$inhost"
		foundit=0
		for inhost in $multipleinhosts; do
			linhost=`echo $inhost | $tolower`
			if test x"$linhost" = x"$lhost"; then
				foundit=1
			fi
		done
		if test x"$addr" != x"127.0.0.1"; then
			if test $foundit -eq 0; then
				soa=`dig @$ns SOA $host $digopts | $rrfilt | awk '$2=="SOA" {print "ok";exit}'`
				if test x"$soa" = x"ok"; then
					echo "WARNING: the zone $host has an A record but no reverse PTR record.  This is probably OK."
					test $exitcode -lt 1 && exitcode=1
				else
					message="ERROR"
					test $exitcode -lt 2 && exitcode=2
					if test $numptrs -eq 1; then
						echo "$message: \"$host A $addr\", but the PTR record for $inaddr is \"$inhost\""
					else
						# NOTE: don't remove 2nd "echo", it's necessary:
						echo "$message: \"$host A $addr\", but the PTR records for $inaddr are \"`echo $multipleinhosts`\""
					fi
					echo "	One of the above two records are wrong unless the host is a name server or mail server."
					echo "	To have 2 names for 1 address on any other hosts, replace the A record"
					if test $numptrs -eq 1; then
						echo "	with a CNAME record:"
					else
						echo "	with a CNAME record referring to the proper host, for example:"
					fi
					echo "	$host	IN	CNAME	$inhost"
					continue
				fi
			fi

		else
			# IP address is 127.0.0.1 -- make sure hostname is localhost.*
			hostname=`echo $lhost | awk -F. '{print $1}'`
			if test x"$hostname" != x"localhost"; then
				echo "WARNING: \"$host A $addr\", but only localhost should be 127.0.0.1."
				echo "	This could confuse some computers (particularly Unix) in that domain."
				test $exitcode -lt 1 && exitcode=1
			fi
		fi

		# if hostname is localhost.*, make sure IP address is 127.0.0.1
		hostname=`echo $lhost | awk -F. '{print $1}'`
		if test x"$hostname" = x"localhost" -a x"$addr" != x"127.0.0.1"; then
			echo "WARNING: \"$host A $addr\", but localhost should always be 127.0.0.1."
			echo "	This could confuse some computers (particularly Unix) in that domain."
			test $exitcode -lt 1 && exitcode=1
		fi
	done
fi


##############################
# OTHER TESTS GO HERE
##############################


#
# Recursively traverse all sub-domains beneath this domain
#

if $recurse; then
	dig @$ns $domain AXFR $digopts | $rrfilt | awk '$2=="NS" {print $1}' | grep -iv "^$domain\$" | sort -u > $TMPSUBDOMS
	if test -s $TMPSUBDOMS; then
		i=1
		len=`wc -l < $TMPSUBDOMS`
		while test $i -le $len; do
			line=`sed -e "$i!d" < $TMPSUBDOMS`

			# run ourself to analyze the subdomain
			$0 -silent $line
			status=$?
			case $status in
			    3)	exitcode=$status
				break ;;
			    4)	exitcode=$status
				break ;;
			    *)	if test $status -gt $exitcode; then
					exitcode=$status
				fi ;;
			esac
			i=`expr $i + 1`
		done
	else
		echo ";; no subzones found below $domain, so no recursion will take place."
	fi
fi

#
# Quit with proper error code
#
echo ";; ============================================================"
echo $echon ";; dlint of $domain run ending $echoc"
case $exitcode in
	0)	echo "normally." ;;
	1)	echo "with warnings." ;;
	2)	echo "with errors." ;;
	3)	echo "due to usage error." ;;
	4)	echo "due to signal interruption." ;;
esac
echo ";; run ending: `date`"
rm -f $TMPNS $TMPZONE $TMPPTR $TMPA $TMPSUBDOMS $TMPERR
exit $exitcode
