#!/usr/bin/perl -w
# $Id: check_zone 1842 2021-07-08 14:25:00Z willem $

=head1 NAME

check_zone - Check a DNS zone for errors

=head1 SYNOPSIS

C<check_zone> [ C<-r> ][ C<-v> ] I<domain> [ I<class> ]

=head1 DESCRIPTION

Checks a DNS zone for errors.  Current checks are:

=over 4

=item *

Checks the domain's SOA from each of the domain's name servers. The SOA serial numbers should match. This program's output cannot be trusted if they do not.

=item *

Tries to perform an AXFR from each of the domain's name servers. This test helps to detect whether the name server is blocking AXFR.

=item *

Checks that all A records have corresponding PTR records. For each A record its PTR's name is match checked.

=item *

Checks that all PTR records match an A record (sometimes they match a CNAME). Check the PTR's name against the A record.

=item *

Checks that hosts listed in NS, MX, and CNAME records have
A records. Checks for NS and CNAME records not pointing to another CNAME (i.e., they must directly resolve to an A record). That test may be somewhat controversial because, in many cases, a MX to a CNAME or a CNAME to another CNAME will resolve; however, in DNS circles it isn't a recommended practise.

=item *

Check each record processed for being with the class requested. This is an internal integrity check.

=back

=head1 OPTIONS

=over 4

=item C<-r>

Perform a recursive check on subdomains.

=item C<-v>

Verbose.

=item C<-a alternate_domain>

Treat <alternate_domain> as equal to <domain>. This is useful when supporting a change of domain names (eg from myolddomain.example.net to mynewdomain.example.net) where the PTR records can point to only one of the two supported domains (which are otherwise identical).

=item C<-e exception_file>

Ignore exceptions in file <exception_file>. File format can be space-separated domain pairs, one pair per line, or it can be straight output from this program itself (for simple cut-and-paste functionality). 
This allows for skipping entries that are odd or unusual, but not causing problems. Note: this only works with A - PTR checks.

=back

=head1 AUTHORS

Originally developed by Michael Fuhr (mfuhr@dimensional.com) and
hacked--with furor--by Dennis Glatting
(dennis.glatting@software-munitions.com).

"-a" and "-e" options added by Paul Archer

=head1 SEE ALSO

L<perl(1)>, L<axfr>, L<check_soa>, L<mx>, L<perldig>, L<Net::DNS>

=head1 BUGS

A query for an A RR against a name that is a CNAME may not follow the CNAME to an A RR.

There isn't a mechanism to insure records are returned from an authoritative source.

There appears to be a bug in the resolver AXFR routine where, if one server cannot be contacted, the routine doesn't try another in its list.

=cut


#require 'assert.pl';

use strict;
use warnings;
use Carp;
use vars qw($opt_r);
use vars qw($opt_v);
use vars qw($opt_a);
use vars qw($opt_e);

use Getopt::Std;
use File::Basename;
use IO::Socket;
use Net::DNS;

getopts("rva:e:");

die "Usage: ", basename($0), " [ -r -v ] [ -a alternate_domain] [ -e eqivalent_domains_file ] domain [ class ]\n"
    unless (@ARGV >= 1) && (@ARGV <= 2);


our $exit_status = 0;
local $SIG{__WARN__} = sub {$exit_status=1 ; print STDERR @_ };



$opt_r = 1;

our $main_domain=$ARGV[0];
our %exceptions = parse_exceptions_file();
foreach my $key (sort keys %exceptions) {
	print "$key:\t";
	foreach my $val (@{$exceptions{$key}}) {
		print "$val ";
	}
	print "\n";
}

check_domain(@ARGV);
exit $exit_status;

sub assert { croak 'violated assertion' unless shift; return }


sub parse_exceptions_file {
	my %exceptions;
	my $file = $opt_e || "";
	return %exceptions unless ( -r $file);
	open( my $fh, '<', $file );
	die "Couldn't read $file: $!" unless $fh;
	while (<$fh>) {
		chomp;
		#print "      raw line: $_\n";
		next if /^$/;
		next if /^\s*#/;
		s/#.*$//;
		s/^\s*//;
		s/\s*$//;
		s/'//g;
		my ($left, $right) = (split /[\s:]+/, $_)[0, -1];
		push @{$exceptions{$left}}, $right;
		#print "processed line: $line\n";
		
	}
	close($fh);
	return %exceptions;
}



sub check_domain {

    my ( $domain, $class ) = @_;
    my $ns;
    my @zone;

    $class ||= "IN";

    print "-" x 70, "\n";
    print "$domain (class $class)\n";
    print "\n";

    my $res = Net::DNS::Resolver->new();
    $res->defnames( 0 );
    $res->retry( 2 );


    my( $nspack, $ns_rr, @nsl );

    # Get a list of name servers for the domain.
    # Error-out if the query isn't satisfied.
    #
    
    $nspack = $res->query( $domain, 'NS', $class );
    unless( defined( $nspack )) {
    
        warn "Couldn't find nameservers for $domain: ",
             $res->errorstring, "\n";
        return;
    }

    printf( "List of name servers returned from '%s'\n", $res->answerfrom );
    foreach my $ns_rr ( $nspack->answer ) {
        
        $ns_rr->print if( $opt_v );

        assert( $class eq $ns_rr->class );
        assert( 'NS' eq $ns_rr->type );

        if( $ns_rr->name eq $domain ) {
        
            print "\t", $ns_rr->rdatastr, "\n";
            push @nsl, $ns_rr->rdatastr;
        } else {
        
            warn( "asked for '$domain', got '%s'\n", $ns_rr->rdatastr );
        }
    }
    print "\n";

    warn( "\tZone has no NS records\n" ) if( scalar( @nsl ) == 0 );


    # Transfer the zone from each of the name servers. 
    # The zone is transferred for several reasons. 
    # First, so the check routines won't (an efficiency 
    # issue) and second, to see if we can.
    #
    
    $res->nameservers( @nsl );

    foreach my $ns ( @nsl ) {
    
        $res->nameservers( $ns );
        
        my @local_zone = $res->axfr( $domain, $class );
        unless( @local_zone ) {
    
            warn "Zone transfer from '", $ns, "' failed: ", 
                    $res->errorstring, "\n";
        }
        @zone = @local_zone if( ! @zone );
    }

    # Query each name server for the zone
    # and check the zone's SOA serial number.
    # 
    
    print "checking SOA records\n";
    check_soa( $domain, $class, \@nsl );
    print "\n";

    
    # Check specific record types.
    #

    print "checking NS records\n";
    check_ns( $domain, $class, \@nsl, \@zone );
    print "\n";

    print "checking A records\n";
    check_a( $domain, $class, \@nsl, \@zone );
    print "\n";

    print "checking PTR records\n";
    check_ptr( $domain, $class, \@nsl, \@zone );
    print "\n";

    print "checking MX records\n";
    check_mx( $domain, $class, \@nsl, \@zone );
    print "\n";

    print "checking CNAME records\n";
    check_cname( $domain, $class, \@nsl, \@zone );
    print "\n";


    # Recurse?
    #

    if( $opt_r ) {
   
        my %subdomains;

        print "checking subdomains\n\n";

        # Get a list of NS records from the zone that 
        # are not for the zone (i.e., they're subdomains).
        #
        
        foreach ( grep { $_->type eq 'NS' and $_->name ne $domain } @zone ) {

            $subdomains{$_->name} = 1;
        }

        # For each subdomain, check it.
        #

        foreach ( sort keys %subdomains ) {

            check_domain($_, $class);
        }
    }
    return;
}

sub check_soa {

    my( $domain, $class, $nsl ) = @_;
    my( $soa_sn, $soa_diff ) = ( 0, 0 );
    my( $ns, $soa_rr );
    my $rr_count = 0;

    my $res = Net::DNS::Resolver->new();

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->recurse( 0 );

    # Contact each name server and get the
    #   SOA for the somain.
    #
    
    foreach my $ns ( @$nsl ) {
    
        my $soa = 0;
        my $nspack;
        
        # Query the name server and test
        # for a result.
        #
        
        $res->nameservers( $ns );

        $nspack = $res->query( $domain, "SOA", $class );
        unless( defined( $nspack )) {
    
            warn "Couldn't get SOA from '$ns'\n";
            next;
        }

        # Look at each SOA for the domain from the
        # name server. Specifically, look to see if
        # its serial number is different across
        # the name servers.
        #
        
        foreach my $soa_rr ( $nspack->answer ) {

            $soa_rr->print if( $opt_v );

            assert( $class eq $soa_rr->class );
            assert( 'SOA' eq $soa_rr->type );
            
            print "\t$ns:\t", $soa_rr->serial, "\n";

            # If soa_sn is zero then an SOA serial number
            # hasn't been recorded. In that case record
            # the serial number. If the serial number 
            # doesn't match a previously recorded one then
            # indicate they are different.
            #
            # If the serial numbers are different then you
            # cannot really trust the remainder of the test.
            #
            
            if( $soa_sn ) {
            
                $soa_diff = 1 if ( $soa_sn != $soa_rr->serial );
            } else {
            
                $soa_sn = $soa_rr->serial;
            }
        }
        
        ++$rr_count;
    }
    
    print "\t*** SOAs are different!\n" if( $soa_diff );
    print "$rr_count SOA RRs checked.\n";
    return;
}

sub check_ptr {

    my( $domain, $class, $nsl, $zone ) = @_;

    my $res = Net::DNS::Resolver->new();
    my $ptr_rr;
    my $rr_count = 0;

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    foreach my $ptr_rr ( grep { $_->type eq 'PTR' } @$zone ) {

        my @types;
    
        $ptr_rr->print if( $opt_v );

        assert( $class eq $ptr_rr->class );
        assert( 'PTR' eq $ptr_rr->type );

        print "\tchecking PTR rr '$ptr_rr' to PTR\n" if( $opt_v );

        @types = types4name( $ptr_rr->ptrdname, $domain, $class, $nsl );
        if( grep { $_ eq 'A' } @types ) {

            xcheck_ptr2a( $ptr_rr, $domain, $class, $nsl );
        } else {
        
            warn "\t'", $ptr_rr->ptrdname, 
                    "' doesn't resolve to an A RR (RRs are '",
                    join( ', ', @types ), "')\n";

        }

        
        ++$rr_count;
    }

    print "$rr_count PTR RRs checked.\n";
    return;
}

sub check_ns {

    my( $domain, $class, $nsl, $zone ) = @_;
    my $res = Net::DNS::Resolver->new();
    my $ns_rr;
    my $rr_count = 0;

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    # Go through the zone data and process
    # all NS RRs for the zone (delegation
    # NS RRs are ignored). Specifically, 
    # check to see if the indicate name server
    # is a CNAME RR and the name resolves to an A
    # RR. Check to insure the address resolved
    # against the name has an associated PTR RR.
    #

    foreach my $ns_rr ( grep { $_->type eq 'NS' } @$zone ) {

        my @types;

        $ns_rr->print if( $opt_v );

        assert( $class eq $ns_rr->class );
        assert( 'NS' eq $ns_rr->type );

        next if( $ns_rr->name ne $domain );
        
        printf( "rr nsdname:  %s\n", $ns_rr->nsdname ) if $opt_v;

        @types = types4name( $ns_rr->nsdname, $domain, $class, $nsl );
        if( grep { $_ eq 'A' } @types ) {
        
            xcheck_name( $ns_rr->nsdname, $domain, $class, $nsl );
        } else {
        
            warn "\t'", $ns_rr->nsdname, 
                    "' doesn't resolve to an A RR (RRs are '",
                    join( ', ', @types ), "')\n";
        }
        ++$rr_count;
    }
    
    print "$rr_count NS RRs checked.\n";
    return;
}

sub check_a {

    my( $domain, $class, $nsl, $zone ) = @_;

    my $res = Net::DNS::Resolver->new();
    my $a_rr;
    my $rr_count = 0;

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    # Go through the zone data and process
    # all A RRs. Specifically, check to insure
    # each A RR matches a PTR RR and the PTR RR
    # matches the A RR.
    #

    foreach my $a_rr ( grep { $_->type eq 'A' } @$zone ) {

        $a_rr->print if( $opt_v );

        assert( $class eq $a_rr->class );
        assert( 'A' eq $a_rr->type );

        print "\tchecking A RR '", $a_rr->address, "' to PTR\n" if( $opt_v );

        xcheck_a2ptr( $a_rr, $domain, $class, $nsl );
        
        ++$rr_count;
    }

    print "$rr_count A RRs checked.\n";
    return;
}


sub check_mx {

    my( $domain, $class, $nsl, $zone ) = @_;

    my $res = Net::DNS::Resolver->new();
    my $mx_rr;
    my $rr_count = 0;

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    # Go through the zone data and process
    # all MX RRs. Specifically, check to insure
    # each MX RR resolves to an A RR and the 
    # A RR has a matching PTR RR.
    #

    foreach my $mx_rr ( grep { $_->type eq 'MX' } @$zone ) {

        $mx_rr->print if( $opt_v );

        assert( $class eq $mx_rr->class );
        assert( 'MX' eq $mx_rr->type );

        print "\tchecking MX RR '", $mx_rr->exchange, "' to A\n" if( $opt_v );

        xcheck_name( $mx_rr->exchange, $domain, $class, $nsl );
        
        ++$rr_count;
    }

    print "$rr_count MX RRs checked.\n";
    return;
}

sub check_cname {

    my( $domain, $class, $nsl, $zone ) = @_;

    my $res = Net::DNS::Resolver->new();
    my $cname_rr;
    my $rr_count = 0;

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    # Go through the zone data and process
    # all CNAME RRs. Specifically, check to insure
    # each CNAME RR resolves to an A RR and the 
    # A RR has a matching PTR RR.
    #

    foreach my $cname_rr ( grep { $_->type eq 'CNAME' } @$zone ) {

        my @types;

        $cname_rr->print if( $opt_v );

        assert( $class eq $cname_rr->class );
        assert( 'CNAME' eq $cname_rr->type );

        print "\tchecking CNAME RR '", $cname_rr->cname, "' to A\n" 
            if( $opt_v );

        @types = types4name( $cname_rr->cname, $domain, $class, $nsl );
        if( grep { $_ eq 'A' } @types ) {
         
            xcheck_name( $cname_rr->cname, $domain, $class, $nsl );
        } else {
        
            warn "\t'", $cname_rr->cname, 
                    "' doesn't resolve to an A RR (RRs are '",
                    join( ', ', @types ), "')\n";
        }
    
        ++$rr_count;
    }

    print "$rr_count CNAME RRs checked.\n";
    return;
}

sub check_w_equivs_and_exceptions {
	my ($left, $comp, $right) = @_;

	if (defined $exceptions{$left}) {
		foreach my $rval (@{$exceptions{$left}}) {
			$left = $right if ($rval eq $right);
		}
	}

	if ($opt_a){
		$left =~ s/\.?$opt_a$//;
		$left =~ s/\.?$main_domain$//;
		$right =~ s/\.?$opt_a$//;
		$right =~ s/\.?$main_domain$//;
	}
	return (eval { "\"$left\" $comp \"$right\"" } );
}

sub xcheck_a2ptr {

    my( $a_rr, $domain, $class, $nsl ) = @_;

    my $res = Net::DNS::Resolver->new();

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    assert( $class eq $a_rr->class );
    assert( 'A' eq $a_rr->type );

    # Request a PTR RR against the A RR.
    # A missing PTR RR is an error.
    #

    my $ans = $res->query( $a_rr->address, 'PTR', $class );
    if( defined( $ans )) {

        my $ptr_rr;
        foreach my $ptr_rr ( $ans->answer ) {

            $ptr_rr->print if( $opt_v );

            assert( $class eq $ptr_rr->class );
            assert( 'PTR' eq $ptr_rr->type );

            warn( "\t'", $a_rr->name, "' has address '", 
                    $a_rr->address, "' but PTR is '",  
                    $ptr_rr->ptrdname, "'\n" )
		if( check_w_equivs_and_exceptions($a_rr->name, "ne", $ptr_rr->ptrdname) );

            warn( "\t'", $a_rr->name, "' has address '", 
                    $a_rr->address, "' but PTR is '", 
                    ip_ptr2a_str( $ptr_rr->name ), "'\n" )
                if( $a_rr->address ne ip_ptr2a_str( $ptr_rr->name ));
        }
    } else {
    
        warn( "\tNO PTR RR for '", $a_rr->name, 
                "' at address '", $a_rr->address,"'\n" );
    }
    return;
}


sub xcheck_ptr2a {

    my( $ptr_rr, $domain, $class, $nsl ) = @_;

    my $res = Net::DNS::Resolver->new();

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    assert( $class eq $ptr_rr->class );
    assert( 'PTR' eq $ptr_rr->type );

    # Request an A RR against the PTR RR.
    # A missing A RR is an error.
    #

    my $ans = $res->query( $ptr_rr->ptrdname, 'A', $class );
    if( defined( $ans )) {
            
        my $a_rr;
        foreach my $a_rr ( $ans->answer ) {

            $a_rr->print if( $opt_v );

            assert( $class eq $a_rr->class );
            assert( 'A' eq $a_rr->type );

            warn( "\tPTR RR '", $ptr_rr->name, "' has name '", 
                    $ptr_rr->ptrdname, "' but A query returned '", 
                    $a_rr->name, "'\n" )
                if( check_w_equivs_and_exceptions($ptr_rr->ptrdname, "ne", $a_rr->name) );

            warn( "\tPTR RR '", $ptr_rr->name, "' has address '", 
                    ip_ptr2a_str( $ptr_rr->name ), 
                    "' but A query returned '", $a_rr->address, "'\n" )
                if( ip_ptr2a_str( $ptr_rr->name ) ne $a_rr->address );
        }
    } else {
    
        warn( "\tNO A RR for '", $ptr_rr->ptrdname, 
                "' at address '", ip_ptr2a_str( $ptr_rr->address ), "'\n" );
    }
    return;
}


sub xcheck_name {

    my( $name, $domain, $class, $nsl ) = @_;

    my $res = Net::DNS::Resolver->new();

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    # Get the A RR for the name.
    #

    my $ans = $res->query( $name, 'A', $class );
    if( defined( $ans )) {
        
        # There is one or more A RRs.
        # For each A RR do a reverse look-up
        # and verify the PTR matches the A.
        #

        my $a_rr;
        foreach my $a_rr ( $ans->answer ) {

            $a_rr->print if( $opt_v );

            assert( $class eq $a_rr->class );
            assert( 'A' eq $a_rr->type );

            warn( "\tQuery for '$name' returned A RR name '", 
                    $a_rr->name, "'\n" ) 
                if( check_w_equivs_and_exceptions($name, "ne", $a_rr->name) );

            xcheck_a2ptr( $a_rr, $domain, $class, $nsl );
        }
    } else {
        
        warn( "\t", $name, " has no A RR\n" );
    }
    return;
}


sub types4name {

    my( $name, $domain, $class, $nsl ) = @_;

    my $res = Net::DNS::Resolver->new();
    my @rr_types;

    $res->defnames( 0 );
    $res->retry( 2 );
    $res->nameservers( @$nsl );

    # Get the RRs for the name.
    #

    my $ans = $res->query( $name, 'ANY', $class );
    if( defined( $ans )) {
        
        my $any_rr;
        foreach my $any_rr ( $ans->answer ) {

            $any_rr->print if( $opt_v );

            assert( $class eq $any_rr->class );
            
            push @rr_types, ( $any_rr->type );
        }
    } else {
        
        warn( "\t'", $name, "' doesn't resolve.\n" );
    }
    
    # If there were no RRs for the name then
    # return the RR types of ???
    #
    
    push @rr_types, ( '???' ) if( ! @rr_types );

    return @rr_types;
}


sub ip_ptr2a_str {

    my( $d, $c, $b, $a ) = ip_parts( $_[0]);

    return "$a.$b.$c.$d";
}



sub ip_parts {

    my $ip = $_[0];
    assert( $ip ne '' );

    if( $ip =~ /(\d+)\.(\d+)\.(\d+)\.(\d+)/oi ) {

        return ( $1, $2, $3, $4 );
    } else {

        warn "Unable to parse '$ip'\n";
    }

    assert( 0 );
    return;
}




