#!/usr/bin/perl -w # # $Id: dhcpd-report,v 1.53 2003/08/01 02:10:55 jmates Exp $ # # The author disclaims all copyrights and releases this script into the # public domain. # # Run perldoc(1) on this file for additional documentation. # ###################################################################### # # REQUIREMENTS require 5; use strict; ###################################################################### # # MODULES use Carp; # better error reporting use IO::File; # File IO, part of core use Getopt::Std; # command line option processing use Fcntl qw(:DEFAULT :flock); # for file locking use POSIX qw(strftime); # pretty date formatting use Time::Local; # to do reverse of gmtime() # various output modules needed set below, with eval, as some don't # come standard with perl... ###################################################################### # # VARIABLES my $VERSION; ($VERSION = '$Revision: 1.53 $ ') =~ s/[^0-9.]//g; my (%opts, $lease_file, $data, $stats, $syslog_name, $conf_file, $date); my ( $dumper, $syslog, $logger, $logger_file, $report, $xml, $quiet, $tsv, $ranges, $netaddrip, $files, $pool ); $logger_file = "/usr/bin/logger"; # what we report ourselves as in syslog output $syslog_name = "DHCPSTATS"; # only enable certain output routines if needed modules load properly # if there's a better way to do this, let me know... eval { require NetAddr::IP; }; # per-range summary unless ($@) { $netaddrip = 1; require NetAddr::IP; } eval { require Data::Dumper; }; # output perl data structures unless ($@) { $dumper = 1; require Data::Dumper; } eval { require Sys::Syslog; }; # output to syslog(3) unless ($@) { $syslog = 1; require Sys::Syslog; } if (-x $logger_file) { $logger = 1; } ###################################################################### # # MAIN # parse command-line options getopts('h?stdxrlfqc:p', \%opts); help() if exists $opts{'h'} or exists $opts{'?'}; # figure out what output methods they want $quiet = 1 if exists $opts{'q'}; $dumper = 0 unless $dumper and exists $opts{'d'}; $tsv = 1 if exists $opts{'t'}; $xml = 1 if exists $opts{'x'}; $files = 1 if exists $opts{'f'}; $pool = 1 if exists $opts{'p'}; # default to syslog over logger, if both mentioned $syslog = 0 unless $syslog and exists $opts{'s'}; $logger = 0 unless $logger and exists $opts{'l'} and not exists $opts{'s'}; $conf_file = $opts{'c'} if exists $opts{'c'}; # pull off leases file $lease_file = shift or help(); $date = time; # timestamp of when we ran # read in the optional dhcpd.conf file if ($conf_file) { open CFILE, $conf_file or die "Couldn't open $conf_file: $!\n"; unless (flock CFILE, LOCK_SH | LOCK_NB) { local $| = 1; warn "Waiting for lock on $conf_file ...\n"; flock CFILE, LOCK_SH or die "Can't lock $conf_file: $!\n"; } $ranges = parse_conf_file(*CFILE); flock CFILE, LOCK_UN; close CFILE; } # read in the leases... if ($lease_file eq '-') { ($data, $stats) = parse_lease_file(*STDIN); } else { # read from file w/ read lock open FILE, $lease_file or die "Couldn't open $lease_file: $!\n"; unless (flock FILE, LOCK_SH | LOCK_NB) { local $| = 1; warn "Waiting for lock on $lease_file ...\n"; flock FILE, LOCK_SH or die "Can't lock $lease_file: $!\n"; } ($data, $stats) = parse_lease_file(*FILE); flock FILE, LOCK_UN; close FILE; } # sanity checking before output stage... unless ($data) { $lease_file = 'STDIN' if $lease_file eq '-'; die "No data gained from $lease_file, quitting!\n"; } # do the various output stuff, as required unless ($quiet) { if ($files) { do_files($data, $stats); } elsif ($dumper) { print Data::Dumper->Dump([$date, $stats, $data], [qw(date stats data)]); } elsif ($tsv) { do_tsv($data, $stats); } elsif ($xml) { do_xml($data, $stats); } else { do_report($data, $stats); } } if ($syslog) { Sys::Syslog::openlog($syslog_name, 'cons', 'daemon'); Sys::Syslog::syslog('info', 'active: ' . $stats->{'active'} . ', leases: ' . $stats->{'leases'} . ', inactive: ' . $stats->{'inactive'} . ', renewed: ' . $stats->{'renewed'} . ', abandoned: ' . $stats->{'abandoned'}); Sys::Syslog::closelog(); } elsif ($logger) { system($logger_file, '-p', "daemon.info", '-t', $syslog_name, 'active: ' . $stats->{'active'} . ', leases: ' . $stats->{'leases'} . ', inactive: ' . $stats->{'inactive'} . ', renewed: ' . $stats->{'renewed'} . ', abandoned: ' . $stats->{'abandoned'}); } exit; ###################################################################### # # SUBROUTINES # accepts filehandle typeglob, loops over it, populates a custom data # structure, then passes back a ref to said hash of anon hashes. # # returns undef if nothing was gained from the file sub parse_lease_file { my $fh = shift; my ($cur_ip, $data, $stats); # initialize some values... $stats->{'renewed'} = 0; $stats->{'abandoned'} = 0; $stats->{'inactive'} = 0; $stats->{'leases'} = 0; while (<$fh>) { next if /^(\#|\})/; # skip irrelevant lines # set active lease IP if match a lease line if ( /^lease\s+(?!0)((?:(?:[01]?\d\d?|2[0-4]\d|25[0-5])\.){3} (?:[01]?\d\d?|2[0-4]\d|25[0-5]))\s+\{/x ) { $cur_ip = $1; $stats->{'leases'}++; # this is why the zero is important if (exists $data->{$cur_ip}) { $data->{$cur_ip}->[0]->{'status'} = 'renewed'; unshift @{$data->{$cur_ip}}, {}; $stats->{'renewed'}++; } next; } # only extract lease-specific info if we have a current ip! if ($cur_ip) { my ($key, $value) = $_ =~ /^\s+([a-z -]+[a-z-]+) # match keys like "starts" \s*\"?(.+?)?\"?\; # pull out key's value /x; # change 'hardware ethernet' for XML compatibility $key = 'mac' if $key eq 'hardware ethernet'; # convert their wacky GMT date into epoch format if ($key eq 'starts' || $key eq 'ends') { my ($year, $mon, $day, $hh, $mm, $ss) = $value =~ m!(\d{4})/(\d\d)/(\d\d)\s(\d\d):(\d\d):(\d\d)!; $mon--; # months need to be 0 .. 11 $value = timegm($ss, $mm, $hh, $day, $mon, $year); } # check for abandoned leases if ($key eq 'abandoned') { if ($netaddrip) { my $ip = NetAddr::IP->new($cur_ip) or warn "Failed to make an IP address out of $cur_ip"; if ($ip) { for my $i (0 .. $#$ranges) { if ( $ip ge $ranges->[$i]->[0] and $ip le $ranges->[$i]->[1]) { $ranges->[$i]->[4]++; last; } } } } $data->{$cur_ip}->[0]->{'status'} = 'abandoned'; $stats->{'abandoned'}++; next; } # the zero is important $data->{$cur_ip}->[0]->{$key} = $value; } } # skip out early if nothing to summarize on... return unless keys %$data; # find inactive leases among the first order entries and record # their IP in stats. Inactive are those whose end date is in # the past, but are not abandoned. for (keys %$data) { unless (defined $data->{$_}->[0]->{'status'}) { if ($data->{$_}->[0]->{'ends'} < $date) { if ($netaddrip) { my $ip = NetAddr::IP->new($_) or warn "Failed to make an IP address out of $_"; if ($ip) { for my $i (0 .. $#$ranges) { if ( $ip ge $ranges->[$i]->[0] and $ip le $ranges->[$i]->[1]) { $ranges->[$i]->[3]++; last; } } } } $data->{$_}->[0]->{'status'} = 'inactive'; $stats->{'inactive'}++; } else { if ($netaddrip) { my $ip = NetAddr::IP->new($_) or warn "Failed to make an IP address out of $_"; if ($ip) { for my $i (0 .. $#$ranges) { if ( $ip ge $ranges->[$i]->[0] and $ip le $ranges->[$i]->[1]) { $ranges->[$i]->[2]++; last; } } } } $data->{$_}->[0]->{'status'} = 'active'; $stats->{'active'}++; } } } return $data, $stats; } # for gleaning information from dhcpd.conf sub parse_conf_file { my $fh = shift; my @ranges = (); return \@ranges unless $netaddrip; while (my $line = <$fh>) { if ($pool and $line =~ /range\s+([\d\.]+)\s+([\d\.]+)\s*;/) { chomp $line; my $sip = NetAddr::IP->new($1); my $eip = NetAddr::IP->new($2); unless ($sip and $eip) { warn "Did not understand range '$line'\n"; next; } push @ranges, [$sip, $eip, 0, 0, 0]; } elsif (not $pool and $line =~ /subnet\s+([\d\.]+)\s+netmask\s+([\d\.]+)/) { chomp $line; my $ip = NetAddr::IP->new($1, $2); unless ($ip) { warn "Did not understand subnet '$line'\n"; next; } push @ranges, [$ip->network, $ip->broadcast, 0, 0, 0]; } } return \@ranges; } # for a simple easy-to-read human output to STDOUT sub do_report { my ($data, $stats) = @_; print "DHCP usage stats from $lease_file at " . strftime("%a, %d %b %Y %H:%M:%S %Z", localtime($date)) . ":\n\n"; print "Active leases: " . $stats->{'active'} . " of the " . $stats->{'leases'} . " leases on record.\n\n"; print "Also have " . $stats->{'inactive'} . " inactive, " . $stats->{'renewed'} . " renewed leases, and " . $stats->{'abandoned'} . " abandoned.\n\n"; if ($netaddrip) { for my $r (@$ranges) { my $size = $r->[1]->numeric - $r->[0]->numeric + 1; print( ($pool ? "Range " : "Subnet "), $r->[0]->addr, " - ", $r->[1]->addr, " ($size available): $r->[2] active, ", "$r->[3] inactive, $r->[4] abandoned\n" ); } } } # File - per - subnet - or - range report useful for feeding snmp servers sub do_files { my ($data, $stats) = @_; push @$ranges, [ NetAddr::IP->new('default')->network, NetAddr::IP->new('default')->broadcast, $stats->{active}, $stats->{inactive}, $stats->{abandoned} ]; my @name = ('active', 'inactive', 'abandoned'); for my $r (@$ranges) { for my $i (0 .. 2) { my $name = $r->[0]->addr . '.' . $name[$i]; my $fh = IO::File->new(">$name"); unless ($fh) { warn "Failed to create output file $name: $!\n"; next; } print $fh $r->[$i + 2], "\n"; $fh->close; } } } # line-per-lease output format easy to sic other command utilities at sub do_tsv { my ($data, $stats) = @_; for my $ip (sort keys %$data) { for my $entry (@{$data->{$ip}}) { print 'IP=', $ip; for (sort keys %$entry) { print "\t", $_; print '=', $entry->{$_} if defined $entry->{$_}; } print "\n"; } } } # XML output (probably not valid XML, but hey, it's my first stab! :) sub do_xml { my ($data, $stats) = @_; print '', "\n"; print '', "\n\n"; print '', "\n"; print ' ', strftime("%a, %d %b %Y %H:%M:%S %Z", localtime($date)), "\n\n"; print " \n"; print " <$_>", $stats->{$_}, "\n" for keys %$stats; print " \n\n"; for my $ip (sort keys %$data) { for my $entry (@{$data->{$ip}}) { print ' \n"; for (keys %$entry) { next if $_ eq 'status'; # convert epoch to redable date... if ($_ eq 'starts' || $_ eq 'ends') { $entry->{$_} = strftime("%a, %d %b %Y %H:%M:%S %Z", localtime($entry->{$_})); } if (defined $entry->{$_}) { print " <$_>", $entry->{$_}, "\n"; } else { print " <$_/>\n"; } } print " \n\n"; } } print '', "\n"; } # a generic help blarb sub help { print <<"HELP"; Usage: $0 [opts] /path/to/dhcpd.leases A dhcpd.leases file information gleaner. Options for version $VERSION: -h/-? Display this message. -c xx Read dhcpd.conf information from file xx. Output formats possibly available are: -r Output a brief, human readable report (default). -d Data::Dumper to dump perl structures, if available. -t TSV output of results. -x XML output of results. -f Output a set of files per range or subnet -q Quiet, turns off above output methods. -s Sys::Syslog to summarize to syslog, if available. -p Use the 'pool' declaration instead of the 'subnet' declaration -l Use logger(1) to summarize to syslog, if available. Run perldoc(1) on this script for additional documentation. HELP exit; } ###################################################################### # # DOCUMENTATION =head1 NAME dhcpd_report.pl - informative data from dhcpd.leases =head1 SYNOPSIS For a quick report on the current status in human-readable output: $ dhcpd_report.pl /etc/dhcpd.leases See L<"EXAMPLES"> for more uses. =head1 DESCRIPTION This script aims to extract the data contained in the ISC DHCPD dhcpd.leases(5) file into a more workable/readable format. Brief summary notes can be sent to syslog, or XML snapshots made to facilitate translation to a webpage. If supplied with the dhcpd.conf(5) file for the server, data can be additionally broken down by either subnet or range definitions. The ISC DHCP suite is currently available from: http://www.isc.org/products/DHCP/ =head2 Normal Usage $ dhcpd_report.pl [options] (dhcpd.leases|-) See L<"OPTIONS"> for details on the command line switches supported. You can replace the path to the leases file with a - character to have the script pull the leases information from STDIN. Without arguments, a brief help blarb is printed out. =head1 OPTIONS This script currently supports the following command line switches: =over 4 =item B<-h>, B<-?> Prints a brief usage note about the script. =item B<-c> I Read in dhcpd.conf(5) information from the specified file location. =back =head2 Output formats Note that some of the output formats may not be available, as they could rely on a perl module or application not present on your system: =over 4 =item B<-r> Output a brief, human readable report. This option will be used if no other output options are specified, otherwise, the precedence currently favors -f over -d over -x over -r. =item B<-d> Use L to output the internal perl data structures, if available. =item B<-t> TSV output of results, subkeyed on key=value parameters. =item B<-x> XML output of results. =item B<-f> When used with -c, send output to a set of files suitable for use as data sources in SNMP or other applications. =item B<-q> Turn off above output methods that use STDOUT. Ideal for getting just syslog information, using -s or -l, below. =item B<-s> Use L to summarize to syslog, if available. See L<"BUGS"> for why there is also a -l option. =item B<-l> Use logger(1) to summarize to syslog, if available. See the VARIABLES section of the script for the default location logger is looked for. =item B<-p> When analyzing the config file (using -c) to generate usage reports on individual segments of the managed pool, provide information for each range instead of each subnet which is the default. =back If both -s and -l options are specified, -s will be used. Use of either is possible in conjunction with one of the other options that send results to STDOUT. See L<"EXAMPLES"> for more details. =head1 EXAMPLES To both echo to syslog and output a XML report to a file: $ dhcpd_report.pl -xs /etc/dhcpd.leases > /var/log/dhcp/current.xml From crontab(5), to place hourly reports into syslog using logger without generating anything to STDOUT: 0 * * * * /usr/local/bin/dhcpd_report.pl -lq /etc/dhcpd.leases =head1 FILES This script depends on a dhcpd.leases file being available for parsing. Common locations include /etc or /var/lib/dhcpd, depending on your system. The dhcpd(8) binary should show you the location, if all else fails: # strings /usr/sbin/dhcpd | grep dhcpd.leases Optionally, the script also needs access to the dhcpd.conf file, which is typically found under /etc. =head1 BUGS =head2 Reporting Bugs Newer versions of this script may be available from: http://sial.org/code/perl/ If the bug is in the latest version, send a report to the author. Patches that fix problems or add new features are welcome. =head2 Known Issues On certain systems, L does not work, e.g. RedHat Linux distributions where there is no syslog.ph header file generated by h2ph. For that reason, there is also a -l option that makes a system call to logger(1). =head1 SEE ALSO crontab(5), dhcpd.conf(5), dhcpd.leases(5), logger(1), perl(1) =head1 AUTHOR Jeremy Mates, http://sial.org/contact/ Luis Muñoz contributed code for configuration file parsing, subnet/range support and SNMP file output. =head1 COPYRIGHT The author disclaims all copyrights and releases this script into the public domain. =head1 VERSION $Id: dhcpd-report,v 1.53 2003/08/01 02:10:55 jmates Exp $ =head1 SCRIPT CATEGORIES Networking =cut