#! /usr/bin/perl
# © 2014 Heiko Schlittermann <hs@schlittermann.de>

=head1 NAME

 check_dns-serial - check the dns serial number from multiple sources

=head1 SYNOPSIS

 check_dns-serial [options] DOMAINS

=head1 DESCRIPTION

B<check_dns-serial> is designed as a Icinga/Nagios plugin to verify that
all responsible NS have the same serial number for their zones.

Domains we are not responsible for are marked as B<critical>.

The list of domains may consist of the following items:

=over

=item I<domain>

A plain domain name.

=item B<file://>I<file>

A file name containing the domains, line by line.

=item B<local:>

This item uses the output of C<named-checkconf -p> to get the list of
master/slave zones. The 127.in-addr.arpa, 168.192.in-addr.arpa, and
0.in-addr.arpa, and 127.in-addr.arpa zones are suppressed.

=back

=cut

use 5.014;
use strict;
use warnings;
use Getopt::Long qw(GetOptionsFromArray);
use Net::DNS;
use Pod::Usage;
use Smart::Comments;

sub uniq { my %h; @h{@_} = (); return keys %h; }

# return a list of the zones known to the local
# bind
sub get_local_zones {
    my @conf;
    open(my $z, '-|', 'named-checkconf -p');
    while (<$z>) {
        state $line;
        s/^\s*(.*?)\s*$/$1 /;
        chomp($line .= $_);    # continuation line
        if (/\A\}/) {          # config item done
            $line =~ s/\s$//;
            push @conf, $line;
            $line = '';
        }
    }
    return grep { 
	# FIXME: 172.0 .. 172.31 is missing
	not /\b(?:0|127|10|168\.192|255)\.in-addr\.arpa$/ and
	not /^localhost$/;
    } map { /zone\s"(\S+)"\s/ } grep { /type (?:master|slave)/ } @conf;
}

sub get_domains {
    my @sources = @_;
    my @domains = ();

    foreach my $src (@sources) {

        if ($src =~ m{^(?:(/.*)|file://(/.*))}) {
            open(my $f, '<', $1) or die "$0: Can't open $1 for reading: $!\n";
            push @domains, map { /^\s*(\S+)\s*/ } <$f>;
            next;
        }

        if ($src =~ m{^local:}) {
            push @domains, get_local_zones;
            next;
        }

        push @domains, $src;
    }

    return @domains;
}

# return a list of "official" nameservers
sub ns {
    my $domain = shift;
    ### assert: @_ % 2 == 0
    my %resflags = (nameservers => [qw/8.8.8.8/], @_);
    my $aa = delete $resflags{aa};
    my $nameservers = $resflags{nameservers};
    my @ns;

    my $r = Net::DNS::Resolver->new(%resflags);
    my $q = $r->query($domain, 'NS') or die $r->errorstring, "\@@$nameservers\n";

    die "no aa @@$nameservers\n" if $aa and not $q->header->aa;
    push @ns, map { $_->nsdname } grep { $_->type eq 'NS' } $q->answer;

    return sort @ns;
}

sub serial {
    my $domain = shift;
    my %resflags = (nameservers => [qw/8.8.8.8/], @_);
    my $nameservers = $resflags{nameservers};

    my $r = Net::DNS::Resolver->new(%resflags);
    my $q = $r->query($domain, 'SOA') or die $r->errorstring, "\@@$nameservers\n";
    return (map { $_->serial } grep { $_->type eq 'SOA' } $q->answer)[0];
}

# - the nameservers known from the ns records
# - from the primary master if this is not one of the
#   NS for the zone
# - from a list of additional (hidden) servers
#
# OK - if the serial numbers are in sync
# WARNING - if there is some difference
# CRITICAL - if the serial cannot be found at one of the sources

sub ns_ok {
    my ($domain, $reference) = @_;

    my @our = sort +ns($domain, nameservers => [$reference], aa => 1);
    my @their = sort +ns($domain);

    {
        local $" = "\0";
        return 1 if "@our" eq "@their";
    }

    local $" = ', ';
    die "NS differ (our @our) vs (their @their)\n";
}

sub main {
    my @argv          = @_;
    my $opt_reference = '127.0.0.1';
    my $opt_progress  = -t;

    GetOptionsFromArray(
        \@argv,
        'reference=s' => \$opt_reference,
        'progress!'   => \$opt_progress,
        'h|help'      => sub { pod2usage(-verbose => 1, -exit => 0) },
        'm|man'       => sub {
            pod2usage(
                -verbose   => 2,
                -exit      => 0,
                -noperldoc => system('perldoc -V 2>/dev/null 1>&2')
            );
        }
      )
      and @argv
      or pod2usage;
    my @domains = get_domains(@argv);

    my (@OK, %CRITICAL);
    foreach my $domain (@domains) {
        print STDERR "$domain " if $opt_progress;
        eval { ns_ok($domain, $opt_reference) };
        if ($@) { $CRITICAL{$domain} = $@ }
        else    { push @OK, $domain }
        say STDERR $@ ? 'not ok' : 'ok' if $opt_progress;
    }

    #    use DDP;
    #    p @OK;
    #    p %CRITICAL;

    if (my $n = keys %CRITICAL) {
        print "CRITICAL: $n of " . @domains . " domains\n",
          map { "$_: $CRITICAL{$_}" } sort keys %CRITICAL;
        return 2;
    }

    say 'OK: ' . @OK . ' domains checked';
    return 0;

}

exit main @ARGV unless caller;

__END__

=head1 OPTIONS

=over

=item B<--reference>=I<address>

The address of the reference server for our own domains (default: 127.0.0.1)

=item B<--progress>

Tell about the progress. (default: on if input is connected to a terminal)

=back

=cut
