#!/usr/bin/perl

# $Id: check_zone_auth,v 1.14 2020/02/11 05:24:06 wessels Exp $
#
# check_zone_auth
#
# nagios plugin to check that all authoritative nameservers for a zone
# have the same NS RRset and the same serial number.
#
# Can also check that the NS RRset is equal to specific nameservers
# passed on the command line.


# Copyright (c) 2008, The Measurement Factory, Inc. All rights reserved.
# 
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions
# are met:
# 
# Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
# Redistributions in binary form must reproduce the above copyright
# notice, this list of conditions and the following disclaimer in the
# documentation and/or other materials provided with the distribution.
# Neither the name of The Measurement Factory nor the names of its
# contributors may be used to endorse or promote products derived
# from this software without specific prior written permission.
# 
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
# POSSIBILITY OF SUCH DAMAGE.

# USAGE
#
# define command {
#   command_name    check-zone-auth
#   command_line    /usr/local/libexec/nagios-local/check_zone_auth -Z $HOSTADDRESS$
# }
# 
# define service {
#   name		   dns-auth-service
#   check_command	   check-zone-auth
#   ...
# }
# 
# define host {
#   use dns-zone
#   host_name zone.example.com
#   alias ZONE example.com
# }
# 
# define service {
#   use dns-auth-service
#   host_name zone.example.com
# }

# CONTRIBUTORS:
#
# Matt Christian

use warnings;
use strict;

use Getopt::Std;
use Net::DNS::Resolver;
use Net::DNS::Resolver::Recurse;
use Time::HiRes qw ( gettimeofday tv_interval);
use List::Util qw ( shuffle );

use vars qw( %opts @refs $zone $expected_ns_rrset $data $start $stop );
getopts('Z:N:d', \%opts);
usage() unless $opts{Z};
usage() if $opts{h};
$zone = $opts{Z};
$zone =~ s/^zone\.//;
$expected_ns_rrset = $opts{N} ? join(',', sort split(',', lc($opts{N}))) : undef;

@refs = qw (
a.root-servers.net
b.root-servers.net
c.root-servers.net
d.root-servers.net
e.root-servers.net
f.root-servers.net
g.root-servers.net
h.root-servers.net
i.root-servers.net
j.root-servers.net
k.root-servers.net
l.root-servers.net
m.root-servers.net
);

$start = [gettimeofday()];
do_recursion();
do_queries();
$stop = [gettimeofday()];
do_analyze();

sub do_recursion {
	my $done = 0;
	my $res = Net::DNS::Resolver->new;
	do {
		print STDERR "\nRECURSE\n" if $opts{d};
		my $pkt;
		foreach my $ns (shuffle @refs) {
			print STDERR "sending query for $zone NS to $ns\n" if $opts{d};
			$res->nameserver($ns);
			$res->udp_timeout(5);
			$pkt = $res->send($zone, 'NS');
			last if $pkt;
		}
		critical("No response to seed query") unless $pkt;
		critical($pkt->header->rcode . " from " . $pkt->answerfrom)
			unless ($pkt->header->rcode eq 'NOERROR');
		add_nslist_to_data($pkt);
		@refs = ();
		foreach my $rr ($pkt->answer) {
			next unless ($rr->type eq 'NS');
			next unless names_equal($rr->name, $zone);
			$done = 1;
		}
		foreach my $rr ($pkt->authority) {
			next unless ($rr->type eq 'NS');
			print STDERR $rr->string, "\n" if $opts{d};
			push (@refs, $rr->nsdname);
		}
	} while (! $done);
}


sub do_queries {
#
#	Net::DNS::Resolver::Recurse has some less-than-desirable
#	properties.  For one it seems to generate many more queries
#	than necessary.  Also it seems to have a tough time when
# 	IPv6 is involved.  For now this is disabled in favor
#	of a custom, simple recursor
#
#	my $recres = Net::DNS::Resolver::Recurse->new;
#	$recres->recursion_callback(sub {
#		my $p = shift;
#		#
#		# This debugging below is commented out because it
#		# generates a 'Variable "%opts" may be unavailable'
#		# warning when ePN (embedded perl nagios) is in use.
#		#
#		#print STDERR $p->string if $opts{d};
#		add_nslist_to_data($p);
#	});
#	my $seed = $recres->query_dorecursion($zone, 'SOA');
#	critical("No response to seed query") unless $seed;
#	$recres = undef;
#
#	critical($seed->header->rcode . " from " . $seed->answerfrom)
#		unless ($seed->header->rcode eq 'NOERROR');
#	print STDERR $seed->string if $opts{d};
#	add_nslist_to_data($seed);
	
	my $n;
	do {
		$n = 0;
		foreach my $ns (keys %$data) {
			next if $data->{$ns}->{done};
			print STDERR "\nQUERY $ns\n" if $opts{d};

			my $pkt = send_query($zone, 'SOA', $ns);
			add_nslist_to_data($pkt);
			$data->{$ns}->{queries}->{SOA} = $pkt;

			if ($pkt && $pkt->header->nscount == 0) {
				my $ns_pkt = send_query($zone, 'NS', $ns);
				add_nslist_to_data($ns_pkt);
				$data->{$ns}->{queries}->{NS} = $ns_pkt;
			}

			print STDERR "done with $ns\n" if $opts{d};
			$data->{$ns}->{done} = 1;
			$n++;
		}
	} while ($n);
}

sub do_analyze {
	my $maxserial = 0;
	my $nscount = 0;
	foreach my $ns (keys %$data) {
		print STDERR "\nANALYZE $ns\n" if $opts{d};
		my $soa_pkt = $data->{$ns}->{queries}->{SOA};
		critical("No response from $ns") unless $soa_pkt;
		print STDERR $soa_pkt->string if $opts{d};
		critical($soa_pkt->header->rcode . " from $ns")
			unless ($soa_pkt->header->rcode eq 'NOERROR');
		critical("$ns is lame") unless $soa_pkt->header->ancount;
		my $serial = soa_serial($soa_pkt);
		$maxserial = $serial if ($serial > $maxserial);
		$nscount++;
	}
	warning("No nameservers found.  Is '$zone' a zone?") if ($nscount < 1);
	warning("Only one auth NS") if ($nscount < 2);
	if ($expected_ns_rrset) {
		my $got_ns_rrset = join(',', sort keys %$data);
		critical("Unexpected NS RRset: $got_ns_rrset")
			unless $expected_ns_rrset eq $got_ns_rrset;
	}
	foreach my $ns (keys %$data) {
		my $soa_pkt = $data->{$ns}->{queries}->{SOA};
		my $ns_pkt = $data->{$ns}->{queries}->{NS};

		# see if this nameserver lists all nameservers
		#
		my %all_ns;
		foreach my $data_ns (keys %$data) { $all_ns{$data_ns} = 1; }
		foreach my $soa_ns (get_nslist($soa_pkt)) { delete $all_ns{$soa_ns}; }
		foreach my $ns_ns (get_nslist($ns_pkt)) { delete $all_ns{$ns_ns}; }
		if (keys %all_ns) {
			warning("$ns does not include " .
				join(',', keys %all_ns) .
				" in NS RRset");
		}

		warning("$ns claims is it not authoritative") unless $soa_pkt->header->aa;

		my $serial = soa_serial($soa_pkt);
		warning("$ns serial ($serial) is less than the maximum ($maxserial)") if ($serial < $maxserial);
	}
	success("$nscount nameservers, serial $maxserial");
}

sub add_nslist_to_data {
	my $pkt = shift;
	foreach my $ns (get_nslist($pkt)) {
		print STDERR "adding NS $ns\n" if $opts{d};
		$data->{$ns}->{done} |= 0;
	}
}

sub soa_serial {
	my $pkt = shift;
	foreach my $rr ($pkt->answer) {
		next unless ($rr->type eq 'SOA');
		next unless names_equal($rr->name, $zone);
		return $rr->serial;
	}
	return 0;
}

sub success {
	output('OK', shift);
	exit(0);
}

sub warning {
	output('WARNING', shift);
	exit(1);
}

sub critical {
	output('CRITICAL', shift);
	exit(2);
}

sub output {
	my $state = shift;
	my $msg = shift;
	$stop = [gettimeofday()] unless $stop;
	my $latency = tv_interval($start, $stop);
	printf "ZONE %s: %s; (%.2fs) |time=%.6fs;;;0.000000\n",
		$state,
		$msg,
		$latency,
		$latency;
}

sub usage {
	print STDERR "usage: $0 -Z zone [-N ns1,ns2,ns3]\n";
	print STDERR "\t-Z specifies the zone to test\n";
	print STDERR "\t-N optionally specifies the expected NS RRset\n";
	exit 3;
}

sub send_query {
	my $qname = shift;
	my $qtype = shift;
	my $server = shift;
	my $res = Net::DNS::Resolver->new;
	$res->nameserver($server) if $server;
	return $res->send($qname, $qtype);
}

sub get_nslist {
	my $pkt = shift;
	return () unless $pkt;
	my @nslist = ();
	foreach my $rr ($pkt->authority) {
		next unless ($rr->type eq 'NS');
		next unless names_equal($rr->name, $zone);
		push(@nslist, lc($rr->nsdname));
	}
	return @nslist if @nslist;
	#
	# look for NS records in answer section too
	#
	foreach my $rr ($pkt->answer) {
		next unless ($rr->type eq 'NS');
		next unless names_equal($rr->name, $zone);
		push(@nslist, lc($rr->nsdname));
	}
	return @nslist;
}

sub names_equal {
	my $a = shift;
	my $b = shift;
	$a =~ s/\.$//;
	$b =~ s/\.$//;
	lc($a) eq lc($b);
}
