#!/usr/bin/perl -w
#
# This script collects statistics about the use of DNS servers. It can handle
# statistics of both BIND (named) and Windows DNS servers. The script gets the
# statistics over a remote connection, either one which delivers the output on
# standard output, or one which provides a shell prompt. It feeds the results
# into Xymon.
#
# This script is inspired by script xymon-rnamedstats.sh written by Jerimy
# Laidman. This script uses (almost) the same format for the parameters in the
# xymon hosts configuration file. It also generates similar names for the RRD
# files and the same DS names.
#
# Hosts to be queried for DNS-service statistics are flagged with the RNAMED
# key in the Xymon hosts.cfg file. This key allows for parameters to be
# specified. The format is of the RNAMED key is either
# RNAMED:"parametername(parametervalue)[,parametername(parametervalue)]"
# or
# "RNAMED:parametername(parametervalue)[,parametername(parametervalue)]"
#
# Allowed parameters are:
# cmd(<commandline>) : shell command to build a connection
# source((bind|dnscmd)) : selection of source, bind or dnscmd
# statsfile(<filename>) : name of the named statistics file
# testname(<testname>) : name of the test, by default "trends"
# title(<testtitle>) : a one-line title of the (non-trends) test
#
# In the command line, a few substitutable variables may be specified. They are
# replaced by their current value upon querying the host:
# %{H} : Host name as defined in the xymon hosts.cfg file
# %{h} : Host name, but without any domain name
# %{I} : IP address as defined in the xymon hosts.cfg file
#
# Note that this script is memoryless by design. This implies that it is not
# possible to check the query-rate against a threshold, as the rate is not
# known in this script. The rate is determined by RRD.
#
# Note with respect to pre-BIND 9.6 statistics:
# success The number of successful queries made to the server or zone.
# A successful query is defined as query which returns a NOERROR
# response with at least one answer RR.
# referral The number of queries which resulted in referral responses.
# nxrrset The number of queries which resulted in NXRRSET responses
# with no data.
# nxdomain The number of queries which resulted in NXDOMAIN responses.
# failure The number of queries which resulted in a failure response
# other than those above.
# recursion The number of queries which caused the server to perform
# recursion in order to find the final answer.
#
# Each query received by the server will cause exactly one of success,
# referral, nxrrset, nxdomain, or failure to be incremented, and may
# additionally cause the recursion counter to be incremented too.
#
# Written by W.J.M. Nelis, wim.nelis@nlr.nl, 2012.12
#
use strict ;
use Time::Piece ; # Format time
use Time::Local ;
#
# Installation constants.
# -----------------------
#
# Define the level of debugging output to the standard output / logfile:
# 0= none, 1= input and output, 2= intermediate results too, 3= name mapping too
#
my $Debug = 0 ; # Flag: Enable debug output
#
# Define the parameters to reach the Xymon server.
#
my $XyDisp= $ENV{XYMONSERVERHOSTNAME} ; # Name of monitor server
my $XySend= $ENV{XYMON} ; # Monitor interface program
my $XyHome= $ENV{XYMONHOME} ; # Home directory
my $FmtDate= "%Y.%m.%d %H:%M:%S" ; # Default date format
$FmtDate= $ENV{XYMONDATEFORMAT} if exists $ENV{XYMONDATEFORMAT} ;
#
# Define the command to send to Xymon to retrieve the current configuration
# lines containing the bindstat test parameters.
#
my $XyGrep= "$XyHome/bin/xymongrep RNAMED:*" ;
#
# Define the default values for the parameters which can be specified with the
# RNAMED keyword in the Xymon hosts configuration file.
#
my $DefPar= { source => 'bind',
statsfile => '/var/named/named_stats.txt',
testname => 'trends',
title => 'DNS statistics'
} ;
#
# Define the mapping of the long, hierarchical names of the statistics onto
# pairs of an RRD file name and a shorter, RRD-compatible name.
#
my %MapName= (
'OldStyle.success' => [ 'bindstats.rrd', 'RSqrysuccess' ],
'OldStyle.referral' => [ 'bindstats.rrd', 'RSqryreferral' ],
'OldStyle.recursion' => [ 'bindstats.rrd', 'RSqryrecursion' ],
'OldStyle.nxrrset' => [ 'bindstats.rrd', 'RSrcodenxrrset' ],
'OldStyle.nxdomain' => [ 'bindstats.rrd', 'RSrcodenxdomain' ],
'OldStyle.failure' => [ 'bindstats.rrd', 'RSrcodefailure' ],
'Name_Server_Statistics.queries_resulted_in_successful_answer' => [ 'bindstats.rrd', 'RSqrysuccess' ],
'Name_Server_Statistics.queries_resulted_in_referral_answer' => [ 'bindstats.rrd', 'RSqryreferral' ],
'Name_Server_Statistics.queries_caused_recursion' => [ 'bindstats.rrd', 'RSqryrecursion' ],
'Name_Server_Statistics.queries_resulted_in_nxrrset' => [ 'bindstats.rrd', 'RSrcodenxrrset' ],
'Name_Server_Statistics.queries_resulted_in_NXDOMAIN' => [ 'bindstats.rrd', 'RSrcodenxdomain' ],
'Name_Server_Statistics.other_query_failures' => [ 'bindstats.rrd', 'RSrcodefailure' ],
'Queries.Total.Standard' => [ 'wdnsstats.rrd', 'Query' ],
'Recursion.Query.Queries_Recursed' => [ 'wdnsstats.rrd', 'Recurse' ],
'Recursion.Failures.Recurse_Failures' => [ 'wdnsstats.rrd', 'RcrsFail' ],
'Packet_Dynamic_Update.Updates_Received.Updates_Received' => [ 'wdnsstats.rrd', 'DynUpdRcv' ],
'Packet_Dynamic_Update.Updates_Received.Rejected' => [ 'wdnsstats.rrd', 'DynUpdRej' ],
'Error_Stats.UNDEF.ServFail' => [ 'wdnsstats.rrd', 'ServFail' ],
'Error_Stats.UNDEF.NxDomain' => [ 'wdnsstats.rrd', 'NxDomain' ],
'Error_Stats.UNDEF.NxRRSet' => [ 'wdnsstats.rrd', 'NxRRSet' ],
) ;
#
# Define the printf format to write a statistic, DS name and value, into the
# 'trends' message for Xymon. All counters are treated as DERIVE, rather than
# COUNTER, in order to avoid huge spikes whenever the BIND service is restarted.
# A restart will result in a negative value, which is suppressed from the graph
# by setting the minimal value to zero.
#
my $DsDef= "DS:%s:DERIVE:600:0:U %d\n" ;
#
# Global variables.
# -----------------
#
my $Result= '' ; # Status message to Xymon
my @Work = () ; # Parameters from Xymon hosts config
my %Stat = () ; # Bind statistics
my @Lines = () ; # Just a bunch of line images
my $I ; # Loop control variable
#
# Function LogMessage is invoked to output a debugging message.
#
sub LogMessage($) {
my $Msg= shift ; chomp $Msg ; # Line without end-of-line
my $Now= localtime ; # Build object with UTS
$Now= $Now->strftime( "%Y.%m.%d %H:%M:%S" ) ;
my $Clr= (caller(1))[3] ; # Name of calling function
$Clr= 'MAIN' unless defined $Clr ;
$Clr=~ s/^.+\:\:// ; # Remove package name
print "$Now $Clr: $Msg\n" ;
} # of LogMessage
#
# Function BuildEpochTime takes two strings, one describing a date, the other
# one describing a time. It returns the associated epoch time.
#
sub BuildEpochTime($$) {
my @Time= ( 0 ) ; # Elements of date and time
my $Time ; # Time stamp
push @Time, $2, $1 if $_[1]=~ m/^\s?(\d+):(\d+)/ ;
push @Time, $1, $2, $3 if $_[0]=~ m/^(?:[a-z]+)?\s?(\d+)-(\d+)-(\d+)/i ;
return 0 unless scalar(@Time) == 6 ;
$Time[4]-- ; # Adjust month ordinal
return timelocal( @Time ) ; # Timestamp
} # of BuildEpochTime
#
# Function BuildWorkList takes the lines from the Xymon hosts configuration
# file, extracts the relevant parts and saves them in list @Work.
#
sub BuildWorkList() {
my ($IP,$HN,$AllPars) ; # Host parameters
my ($RNamed,$Pars) ; # RNAMED parameters
my $W ; # Ref to element of @Work
$I= -1 ;
foreach ( @Lines ) {
$I++ ; %{$Work[$I]}= %$DefPar ; # Copy default values
$W= $Work[$I] ; # Short cut
$$W{ERROR}= 0 ; # No error found (yet)
LogMessage( "Input: $_" ) if $Debug ;
# Handle the fixed Xymon parameters, the IP address and the name of the host.
chomp ;
($IP,$HN,$AllPars)= m/^\s*([\d\.]+)\s+([\w\.]+)\s+#\s*(.*?)\s*$/ ;
$$W{IP}= $IP ; $$W{Host}= $HN ; # Save host parameters
# Handle keyword RNAMED: extract all its parameters. Two formats are allowed,
# one in which the whole parameter string is enclosed between double quotes and
# one in which the part after RNAMED: is enclosed between double quotes.
($RNamed,$Pars)= $AllPars=~ m/\"(RNAMED:(.+?))\"/ ;
unless ( defined $RNamed ) {
($RNamed,$Pars)= $AllPars=~ m/(RNAMED:\"(.+?))\"/ ;
$RNamed=~ s/^RNAMED:\"/RNAMED:/ ; # Remove double quote
} # of unless
LogMessage( "RNamed: $RNamed" ) if $Debug > 1 ;
while ( $Pars=~ s/^([a-z]+)\((.+?)\)(?:\s*,\s*)?// ) {
$$W{$1}= $2 ;
LogMessage( "RNAMED par: $1 = $2" ) if $Debug > 1 ;
} # of while
if ( $Pars ) {
Logmessage( "Error at $HN in \"$RNamed\"" ) ;
$$W{ERROR}= 1 ;
} # of unless
} # of foreach
} # of BuildWorkList
#
# Function PolishName returns the input string, after replacing all
# non-alphanumeric characters by an underscore.
#
sub PolishName($) {
my $Name= $_[0] ; # Fetch name
$Name=~ tr/-_0-9a-zA-Z/_/c ;
$Name=~ s/_{2,}//g ;
LogMessage( " \"$_[0]\" into \"$Name\"" ) if $Debug > 2 ;
return $Name ;
} # of PolishName
#
# Function Recent takes a time stamp and returns that value if the time stamp
# lies between now and 10 minutes ago. If the clock of the DNS server is up to
# 10 seconds ahead wrt the time on the Xymon server, the current time at the
# Xymon server is returned. In all other cases it will return a false value.
#
sub Recent($$) {
my $Age= time - $_[1] ; # Age
if ( $Age < -10 ) {
LogMessage( "$_[0]: Statistics too young" ) ;
return 0 ; # Return a false value
} elsif ( $Age < 0 ) {
return time; # Correct for time slack
} elsif ( $Age > 600 ) {
LogMessage( "$_[0]: Statistics too old" ) ;
return 0 ; # Return a false value
} else {
return $_[1] ; # Return a true value
} # of else
} # of Recent
#
# Function QueryServer retrieves the dns statistics from one server. All
# information needed is passed in the work list entry. The retrieved information
# is written to global list @Lines.
#
# The script to be executed at the (remote) bind server is stripped to the
# minimum. Add error detection and a session time limit.
#
sub QueryServer($) {
my $W= shift ; # Ref to worklist item
my $SHost= (split(/\./,$$W{Host}))[0] ; # Short host name
my $Cmd = $$W{cmd} ; # Retrieve command
if ( index($Cmd,' ') < 0 ) {
$Cmd.= " $$W{Host}" ; # Append hostname
} else {
$Cmd=~ s/%\{H\}/$$W{Host}/g ; # Substitute full host name
$Cmd=~ s/%\{h\}/$SHost/g ; # Substitute short host name
$Cmd=~ s/%\{I\}/$$W{IP}/g ; # Substitute IP address
} #of else
if ( $$W{source} eq 'bind' ) {
my $StdIn= "{ echo \"cat $$W{statsfile}\" ; echo \"exit\" ; }" ;
$Cmd= "$StdIn | $Cmd" ; # Build script
} # of if
LogMessage( " Cmd = $Cmd" ) if $Debug ;
@Lines= `$Cmd` ; # Retrieve standard output
} # of QueryServer
#
# Function ParseBindStatistics takes the raw statistics of a BIND server and
# stores it in a multi-level data-structure.
#
sub ParseBindStatistics($) {
my $W= shift ; # Ref to worklist item
my $Section= '' ; # Hierarchy of statistics
my $View = '' ;
my ($Var,$Val) ;
foreach ( @Lines ) {
chomp ;
next if m/^\s*$/ ; # Skip empty line
next if m/^---/ ; # Skip end-of-statistics line
next if m/^\[---.*---\]$/ ;
if ( m/^\+\+\+ Statistics Dump \+\+\+ \((\d+)\)/ ) {
$Val= Recent( $$W{Host}, $1 ) ; # Check time of measurement
return unless $Val ; # Return if not a recent sample
$Stat{ToM}= $Val ; # Save time of measurement
$Section= 'OldStyle' ; # Assume pre BIND-9.6
$View = '' ;
} elsif ( m/^\+\+\s+(.+?)\s*\+\+$/ ) {
$Section= PolishName( $1 ) ;
$View = '' ;
} elsif ( m/^\[Common\]$/ ) {
$View = 'Common' ;
} elsif ( m/^\[View:\s+(.+)\]$/ ) {
$View = $1 ;
} elsif ( m/^\s*(\d+)\s+(.+?)\s*$/ ) {
$Val= $1 ; $Var= PolishName( $2 ) ;
$Stat{Long}{$Section}{$View}{$Var}= $Val ;
LogMessage( " $Section - $View - $Var = $Val" ) if $Debug ;
} elsif ( m/^\s*([a-z]+)\s+(\d+)\s*$/ ) {
$Var= PolishName( $1 ) ; $Val= $2 ;
$Stat{Long}{$Section}{$View}{$Var}= $Val ;
LogMessage( " $Section - $View - $Var = $Val" ) if $Debug ;
} else {
LogMessage( "Error - Unexpected BIND statistics line at $$W{Host}" ) ;
LogMessage( " $_" ) ;
$$W{ERROR}= 1 ;
} # of else
} # of foreach
} # of ParseBindStatistics
#
# Function ParseDnscmdStatistics uses the hierarchical structure of the Windows
# DNS service statistics to identify and extract all values.
#
# The statistics are divided into sections, each section is divided in one or
# more views. A view consists of one or more lines containing the name of a
# variable and its value. A section starts with the section name in the leftmost
# column and is followed by a line of dashes. A view has the same format as a
# section, but it is not followed by a line of dashes. Moreover, a view can be
# given a value. This is considered to be a shorthand notation, that is
# <AView> = <AValue>
# is considered to be equivalent to
# <AView>:
# <Aview> = <AValue>
# Within a view two levels of variables are defined, a top-level and a
# sub-level. The indentation of a top-level line is short, the indentation of
# a sub-level line is longer. Each section has its own indentation settings.
# Sub-level lines are ignored.
#
# The statistics are augmented with a time stamp at the start. The first line
# specifies the date, using format dd-mm-yyyy, the second line the time of
# collecting the statistics, using format hh:mm.
#
sub ParseDnscmdStatistics($) {
my $W= shift ; # Ref to worklist entry
my $Candidate= undef ; # Name of section or view
my $Section = undef ; # Name of section
my $View = undef ; # Name of view
my $Variable = undef ; # Name of variable
my $Value = undef ; # Its value
my $Indent0 = undef ; # RE for top-level indent
my $Indent1 ; # RE for sub-level indent
return if @Lines < 4 ;
my $Date= shift @Lines ; # Fetch time of this set of
my $Time= shift @Lines ; # statistics
$Time= BuildEpochTime( $Date, $Time ) ;
$Time= Recent( $$W{Host}, $Time ) ;
return unless $Time ; # Exit if sample is outdated
$Stat{ToM}= $Time ; # Save time of retrieval
foreach ( @Lines ) {
chomp ; # Remove trailing Lf
s/\cM$// ; # Remove trailing Cr
s/\(.+?\)// ; # Remove (text)
next if m/^\s*$/ ; # Skip an empty line
# Handle a name found on the previous line, which can be either the name of
# a section of the name of a view.
if ( defined $Candidate ) {
if ( m/^\-+\s*$/ ) {
$Section= $Candidate ;
$Candidate= undef ;
$View = undef ;
$Indent0 = undef ;
$Variable = undef ;
next ;
} else {
$View= $Candidate ;
$Candidate= undef ;
$Variable = undef ;
} # of else
} # of if
# Handle the (potential) section header.
if ( m/^([A-Z][\w ]+?)\s*:?\s*$/ ) {
$Candidate= PolishName( $1 ) ;
# Handle the view header.
# } elsif ( m/^([A-Z][\w ]+):\s*$/ ) {
# $Candidate= PolishName( $1 ) ;
} elsif ( m/^([A-Z][\w ]+?)\s*=\s+(\d+)\s*$/ ) {
$View= PolishName( $1 ) ;
$Variable= $View ;
$Value = $2 ;
$Stat{Long}{$Section}{$View}{$Variable}= $Value ;
LogMessage( " $Section - $View - $Variable = $Value" ) if $Debug ;
# Handle a line, either a top-level of a sub-level definition.
} elsif ( ! defined $Indent0 ) {
if ( m/^(\s+)([A-Z][\w\- ]+?)\s*=\s*(\d+)$/ ) {
$Indent0 = $1 ; # Indentation of top-level
$Indent1 = $1 . '\s+' ; # Indentation of sub-level
$Variable= PolishName( $2 ) ;
$Value = $3 ;
$View= 'UNDEF' unless defined $View ;
$Stat{Long}{$Section}{$View}{$Variable}= $Value ;
LogMessage( " $Section - $View - $Variable = $Value" ) if $Debug ;
} else {
LogMessage( "Error - Unexpected WinDns statistics line at $$W{Host}" ) ;
LogMessage( " $_" ) ;
$$W{ERROR}= 1 ;
} # of else
} elsif ( m/^$Indent0([A-Z][\w\- ]+?)\s*=\s*(\d+)\s*$/ ) {
$Variable= PolishName( $1 ) ;
$Value = $2 ;
$View= 'UNDEF' unless defined $View ;
$Stat{Long}{$Section}{$View}{$Variable}= $Value ;
LogMessage( " $Section - $View - $Variable = $Value" ) if $Debug ;
} elsif ( m/^$Indent0[A-Z][\w\- ]+:?\s*$/ ) {
next ; # Ignore another deeper level
} elsif ( m/^$Indent1[A-Z]/ ) {
next ; # Ignore this deep statistic
# Handle the remaining cases.
} elsif ( m/^Command completed successfully./ ) {
last ; # End of statistics found
} else {
LogMessage( "Error - Unexpected WinDns statistics line at $$W{Host}" ) ;
LogMessage( " $_" ) ;
$$W{ERROR}= 1 ;
} # of else
} # of foreach
} # of ParseDnscmdStatistics
#
# Function ParseStatistics invokes the appropriate parser, depending on the
# source of the statistical information.
#
sub ParseStatistics($) {
my $W= shift ; # Ref to work list item
if ( $$W{source} eq 'bind' ) {
ParseBindStatistics( $W ) ;
} elsif ( $$W{source} eq 'dnscmd' ) {
ParseDnscmdStatistics( $W ) ;
} # of elsif
} # of ParseStatistics
#
# Function MapName takes the results of one BIND server. It maps the (long)
# names of the variables onto short ones, which are usable as DS-names in an
# RRD file. At the same time, a default value is given for unknown variables.
#
sub MapName() {
my $LongName ; # Fully qualified name
my $M ; # Ref into statistics result hash
foreach my $Section ( keys %{$Stat{Long}} ) {
foreach ( keys %MapName ) {
next unless index($_,$Section) == 0;
$M= $MapName{$_} ;
$Stat{Short}{$$M[0]}{$$M[1]}= 0 ;
} # of foreach
#
foreach my $View ( keys %{$Stat{Long}{$Section}} ) {
foreach my $Stat ( keys %{$Stat{Long}{$Section}{$View}} ) {
$LongName= "$Section.$View.$Stat" ;
$LongName=~ s/\.\./\./ ; # Remove empty view name
next unless exists $MapName{$LongName} ;
LogMessage( "save \"$LongName\"" ) if $Debug > 1 ;
$M= $MapName{$LongName} ; # Ref into mapping
$Stat{Short}{$$M[0]}{$$M[1]}= $Stat{Long}{$Section}{$View}{$Stat} ;
} # of foreach
} # of foreach
} # of foreach
} # of MapName
#
# Build the Xymon message and inform Xymon. The message can be sent in one of
# two formats: a 'trends' message or a 'status' message. The former includes
# the name of the RRD file and the DS definition, requiring no additional config
# of Xymon. The latter shows up as a separate column with a graph, and requires
# the use of an additional server-side script (and configuration) to distribute
# the NCV data to the RRD files.
#
sub InformXymon($) {
my $Work = shift ; # Ref to work descriptor
my $XyTest= $$Work{testname} ; # Name of test
my $XyHost= $$Work{Host} ; # Name of BIND server
my $Colour= 'green' ; # Initial test status
my $Now ; # Time of retrieval of statistics
my ($Rrd,$DS) ; # Loop control variables
my $Key ;
if ( $XyTest ne 'trends' ) {
$Now= localtime( $Stat{ToM} ) ;
$Now= $Now->strftime( $FmtDate ) ;
#
# Save the results in NCV format, using extended names which include the name
# of the RRD file. In Xymon, this data is channeled to a script defined in
# the --extra-script parameter of rrdstatus.
#
$Result = "<!--\n" ;
foreach $Rrd ( sort keys %{$Stat{Short}} ) {
foreach $DS ( sort keys %{$Stat{Short}{$Rrd}} ) {
$Key= $Rrd ; $Key=~ s/\.rrd$// ;
$Result.= "$Key/$DS : $Stat{Short}{$Rrd}{$DS}\n" ;
} # of foreach
} # of foreach
$Result.= "-->" ;
$Result = "\"status $XyHost.$$Work{testname} $Colour $Now\n" .
"$$Work{title}\n" .
$Result . "\"\n" ;
`$XySend $XyDisp $Result` ; # Inform Xymon
} else { # Format is 'trends'
#
# Save the results in the special 'trends message' format. It consists of one
# or more sections formatted like:
# [<RrdFileName>]
# DS:<DsSpecification> <DsValue>
#
$Result= '' ;
foreach $Rrd ( sort keys %{$Stat{Short}} ) {
next unless keys %{$Stat{Short}{$Rrd}} ;
$Result.= "[$Rrd]\n" ;
foreach $DS ( sort keys %{$Stat{Short}{$Rrd}} ) {
$Result.= sprintf( $DsDef, $DS, $Stat{Short}{$Rrd}{$DS} ) ;
} # of foreach
} # of foreach
if ( $Result ne '' ) {
$Result ="\"data $XyHost.trends\n" . $Result . "\"\n" ;
LogMessage( "XySend: $Result" ) if $Debug ;
`$XySend $XyDisp $Result` ; # Inform Xymon
} # of if
} # of else
} # of InformXymon
#
# MAIN PROGRAM.
# -------------
#
unless ( defined $XyDisp ) {
LogMessage( 'Error: no Xymon environment defined' ) ;
exit ;
} # of unless
@Lines= `$XyGrep` ; # Get list of DNS servers
exit unless @Lines ; # Stop if nothing to do
BuildWorkList ; # Build nice list of work to do
foreach ( @Work ) {
next if $$_{ERROR} ; # Skip in case of RNAMED syntax error
%Stat= () ; # Clear statistics save area
QueryServer( $_ ) ; # Query next server
ParseStatistics( $_ ) ; # Extract statistics and format them
MapName ; # Map long name onto short RRD name
InformXymon( $_ ) ; # Send data to Xymon
} # of foreach