The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
#!/usr/bin/perl
use strict;
use warnings;
use Getopt::Long;
use Proc::Daemon;
use Sys::Syslog;


$::PROGRAM = "netsnmp-pcap";
$::VERSION = "0.95";


#
# main
#
MAIN: {
    run() unless caller();
}


#
# run()
# ---
sub run {
    # default options
    my %options = (
        config  => "/etc/snmp/pcap.conf",
        debug   => 0,
        detach  => 1,
    );

    # parse options
    Getopt::Long::Configure(qw< no_auto_abbrev no_ignore_case >);
    GetOptions(\%options, qw{
        help|usage|h!  man!  version|V!
        config|c=s  base_oid|base-oid|B=s
        debug|d:i  detach|daemon|D!  pidfile=s
    }) or pod2usage(0);

    # handle --version, --usage and --help
    $options{man}       and pod2usage(2);
    $options{help}      and pod2usage(1);
    $options{version}   and print "$::PROGRAM v$::VERSION\n" and exit;

    # become a daemon
    if ($options{detach}) {
        if (Proc::Daemon->VERSION < 0.04) {
            # old API
            Proc::Daemon::Init();

            if ($options{pidfile}) {
                open my $fh, ">", $options{pidfile} or syslog(err =>
                    "error: can't write to pidfile '$options{pidfile}': $!")
                    and exit 1;
                print {$fh} $$;
                close $fh;
            }
        }
        else {
            # new API
            Proc::Daemon::Init({ pid_file => $options{pidfile} });
        }
    }

    # configure syslog
    openlog($::PROGRAM, "ndelay,pid,nofatal,perror", "daemon");
    syslog(info => "$::PROGRAM v$::VERSION starting");

    # run the program
    App::netsnmp_pcap->run(\%options);
}


#
# pod2usage()
# ---------
sub pod2usage {
    my ($n) = @_;
    require Pod::Usage;
    Pod::Usage::pod2usage({ -exitval => 0, -verbose => $n, -noperldoc => 1 });
}


# --------------------------------------------------------------------
package App::netsnmp_pcap;
use strict;
use warnings;
use Net::Pcap qw< :functions >;
use NetSNMP::ASN;
use POE;
use POE::Component::NetSNMP::agent;
use POE::Component::Pcap;
use Sys::Syslog;


use constant {
    BASE_OID    => ".1.3.6.1.4.1.12325.1.1112",
    AGENT_ALIAS => "snmp_agent",
    ETHERNET_HEADER_LENGTH  => 14,
};

my (%oid, %type, @fields);


#
# run()
# ---
sub run {
    my ($class, $opts) = @_;

    # init some global variables
    my $base_oid = $opts->{base_oid} || BASE_OID;
    %oid = (
        pcapCount   => "$base_oid.1",
        pcapIndex   => "$base_oid.2.1.0",
        pcapDescr   => "$base_oid.2.1.1",
        pcapDevice  => "$base_oid.2.1.2",
        pcapFilter  => "$base_oid.2.1.3",
        pcapOctets  => "$base_oid.2.1.4",
        pcapPackets => "$base_oid.2.1.5",
    );
    %type = (
        pcapIndex   => ASN_INTEGER,
        pcapDescr   => ASN_OCTET_STR,
        pcapDevice  => ASN_OCTET_STR,
        pcapFilter  => ASN_OCTET_STR,
        pcapOctets  => ASN_COUNTER,
        pcapPackets => ASN_COUNTER,
    );
    @fields = keys %type;

    # parse the configuration
    my %handlers;
    my @monitors = parse_config($opts->{config});

    # foreach monitor..
    for my $monitor (@monitors) {
        next unless defined $monitor;
        my $id   = $monitor->{pcapIndex};
        my $name = "pcap_$monitor->{pcapIndex}";

        # .. spawn a dedicated component for watching it
        POE::Component::Pcap->spawn(
            Alias       => $name,
            Device      => $monitor->{pcapDevice},
            Filter      => $monitor->{pcapFilter},
            Dispatch    => "update_counters_for_$name",
            Session     => "main",
        );

        # .. and create the corresponding handler, in the main session,
        # to do the actual counting
        $handlers{"update_counters_for_$name"} = sub {
            my ($heap, $args) = @_[ HEAP, ARG0 ];
            my $pkt_header = $args->[0][0];
            return if $pkt_header->{len} < ETHERNET_HEADER_LENGTH;
            $heap->{monitors}[$id]{pcapPackets}++;
            $heap->{monitors}[$id]{pcapOctets}
                += $pkt_header->{len} - ETHERNET_HEADER_LENGTH;
        };
    }

    # spawn the AgentX component, that takes care of the communication
    # with the SNMP daemon
    POE::Component::NetSNMP::agent->spawn(
        Alias       => AGENT_ALIAS,
        AgentX      => 1,
        AutoHandle  => $base_oid,
        Errback     => "error_handler",
        Debug       => $opts->{debug} > 1 ? 1 : 0,
    );

    my @poe_opts;
    push @poe_opts, options => { trace => 1, debug => 1, default => 1 }
        if $opts->{debug} > 0;

    # spawn the main session
    POE::Session->create(
        heap => {
            monitors        => \@monitors,
            update_delay    => 10,
        },

        inline_states => {
            _start => sub {
                my ($kernel, $heap) = @_[ KERNEL, HEAP ];
                $kernel->alias_set("main");
                $kernel->yield("update_tree");

                for my $monitor (@{ $heap->{monitors} }) {
                    next unless defined $monitor;
                    $kernel->post("pcap_$monitor->{pcapIndex}", "run");
                }
            },
            update_tree     => \&update_tree,
            error_handler   => \&error_handler,
            %handlers,
        },

        @poe_opts,
    );

    POE::Kernel->run;
}


#
# parse_config()
# ------------
sub parse_config {
    my ($path) = @_;

    my @monitors;
    open my $fh, "<", $path
        or syslog(err => "error: can't read $path: $!") and exit 1;

    while (defined(my $line = <$fh>)) {
        next if $line =~ /^#/;

        if ($line =~ /^(pcap\w+)\.(\d+)\s+=\s+"([^"]*)"$/) {
            $monitors[$2]{$1} = $3;
            $monitors[$2]{pcapIndex} = $2;
        }
    }

    close $fh;

    return @monitors
}


#
# update_tree()
# -----------
sub update_tree {
    my ($kernel, $heap) = @_[ KERNEL, HEAP ];

    my $monitors = $heap->{monitors};

    $kernel->post(AGENT_ALIAS, add_oid_entry =>
        $oid{pcapCount}, ASN_INTEGER, $monitors->[-1]{pcapIndex});

    for my $monitor (@$monitors) {
        next unless defined $monitor;

        for my $field (@fields) {
            $kernel->post(AGENT_ALIAS, add_oid_entry =>
                "$oid{$field}.$monitor->{pcapIndex}",
                $type{$field}, $monitor->{$field},
            );
        }
    }

    $kernel->delay(update_tree => $heap->{update_delay});
}


#
# error_handler()
# -------------
sub error_handler {
    my (@args) = @_[ARG0 .. $#_];
    syslog(err => "error: @args");
}


1

__END__

=head1 NAME

netsnmp-pcap - SNMP extension which captures network traffic and reports
the number of packets captured, and the throughput

=for ExtUtils::MakeMaker
netsnmp::pcap::perl - SNMP extension which captures network traffic
and reports the number of packets captured, and the throughput

=head1 SYNOPSIS

    netsnmp-pcap [--config /etc/snmp/pcap.conf] [--debug [n]]
    netsnmp-pcap { --help | --man | --version }


=head1 OPTIONS

=head2 Program options

=over

=item B<-B>, B<--base-oid> I<OID>

Specify the base OID to server the table from. Default to the same as
C<bsnmpd-pcap>, .1.3.6.1.4.1.12325.1.1112

=item B<-c>, B<--config> I<path>

Specify the path to the configuration file. Default to F</etc/snmp/pcap.conf>

=item B<-d>, B<--debug> [I<level>]

Enable debug mode, i.e., traces POE events.

=item B<-D>, B<--detach>

Tell the program to detach itself from the terminal and become a daemon.
Use C<--no-detach> to prevent this.

=item B<-p>, B<--pidfile> I<path>

Specify the path to a file to write the PID of the daemon.

=back

=head2 Help options

=over

=item B<-h>, B<--help>

Print a short usage description, then exit.

=item B<--man>

Print the manual page of the program, then exit.

=item B<-V>, B<--version>

Print the program name and version, then exit.

=back


=head1 DESCRIPTION

This program is a port of B<bsnmpd-pcap>, the pcap plugin for FreeBSD's
bsnmpd, as an AgentX for Net-SNMP, written in Perl. It allows you to
measure arbitrary network traffic, in packets or octets, using the pcap(3)
library. Multiple flows of traffic can be measured by setting as many
network monitors, with different filters.


=head1 MIBS

The counters are available as a table under the same OID as bsnmpd-pcap,
but this can be changed using the C<--base-oid> option:

    .1.3.6.1.4.1.12325.1.1112

The following entries are provided, where I<N> is the index:

=over

=item pcapCount(1)

the number of network monitors present

=item pcapTable(2).pcapEntry(1).pcapIndex(0).I<N>

the index of the network monitor

=item pcapTable(2).pcapEntry(1).pcapDescr(1).I<N>

a human description of the netowrk monitor (may be empty)

=item pcapTable(2).pcapEntry(1).pcapDevice(2).I<N>

the network device that traffic is being monitored on

=item pcapTable(2).pcapEntry(1).pcapFilter(3).I<N>

the pcap(3) filter used to select certain network packets for monitoring

=item pcapTable(2).pcapEntry(1).pcapOctets(4).I<N>

the number of octets seen by the monitor

=item pcapTable(2).pcapEntry(1).pcapPackets(5).I<N>

the number of packets seen by the monitor

=back


=head1 CONFIGURATION

The configuration format is the same as bsnmpd-pcap, where you define
the network monitors by setting the corresponding SNMP functions.
Here is an example which defines three network monitors:

    %pcap
    pcapDescr.1  = "ARP, ICMP and VRRP traffic"
    pcapDevice.1 = "eth0"
    pcapFilter.1 = "arp or icmp or vrrp"

    pcapDescr.2  = "DNS traffic"
    pcapDevice.2 = "eth0"
    pcapFilter.2 = "port domain"

    pcapDescr.3  = "HTTP traffic"
    pcapDevice.3 = "eth0"
    pcapFilter.3 = "port http or port https"

and the corresponding result from snmpwalk:

    # snmpwalk -v2c -On -c public localhost .1.3.6.1.4.1.12325.1.1112
    .1.3.6.1.4.1.12325.1.1112.1 = INTEGER: 3
    .1.3.6.1.4.1.12325.1.1112.2.1.0.1 = INTEGER: 1
    .1.3.6.1.4.1.12325.1.1112.2.1.0.2 = INTEGER: 2
    .1.3.6.1.4.1.12325.1.1112.2.1.0.3 = INTEGER: 3
    .1.3.6.1.4.1.12325.1.1112.2.1.1.1 = STRING: "ARP, ICMP and VRRP traffic"
    .1.3.6.1.4.1.12325.1.1112.2.1.1.2 = STRING: "DNS traffic"
    .1.3.6.1.4.1.12325.1.1112.2.1.1.3 = STRING: "HTTP traffic"
    .1.3.6.1.4.1.12325.1.1112.2.1.2.1 = STRING: "eth0"
    .1.3.6.1.4.1.12325.1.1112.2.1.2.2 = STRING: "eth0"
    .1.3.6.1.4.1.12325.1.1112.2.1.2.3 = STRING: "eth0"
    .1.3.6.1.4.1.12325.1.1112.2.1.3.1 = STRING: "arp or icmp or vrrp"
    .1.3.6.1.4.1.12325.1.1112.2.1.3.2 = STRING: "port domain"
    .1.3.6.1.4.1.12325.1.1112.2.1.3.3 = STRING: "port http or port https"
    .1.3.6.1.4.1.12325.1.1112.2.1.4.1 = Counter32: 56
    .1.3.6.1.4.1.12325.1.1112.2.1.4.2 = Counter32: 1347
    .1.3.6.1.4.1.12325.1.1112.2.1.4.3 = Counter32: 29137
    .1.3.6.1.4.1.12325.1.1112.2.1.5.1 = Counter32: 2
    .1.3.6.1.4.1.12325.1.1112.2.1.5.2 = Counter32: 15
    .1.3.6.1.4.1.12325.1.1112.2.1.5.3 = Counter32: 53
    End of MIB


=head1 CAVEATS

Because this program is based on L<POE::Component::NetSNMP::agent>,
it suffers from the same problem, which is that when the snmpd daemon
it is connected to dies, the default POE loop will spin over the
half-closed Unix socket, eating 100% of CPU until the daemon is restarted
and the sub-agent has reconnected. A workaround is to use an alternative
event loop: POE::Loop::AnyEvent, POE::Loop::EV and POE::XS::Loop::EPoll
have been tested to not expose this problem.

To select the event loop, set the C<POE_EVENT_LOOP> environment variable
to its name: C<POE_EVENT_LOOP=POE::Loop::AnyEvent>


=head1 SEE ALSO

L<snmpd(1)>, L<bsnmpd-pcap(8)>, L<pcap(3)>, L<tcpdump(1)>

http://www.net-snmp.org/

http://thewalter.net/stef/software/bsnmp-pcap/

http://www.tcpdump.org/


=head1 AUTHOR

Sebastien Aperghis-Tramoni (sebastien@aperghis.net)

=cut