#!/usr/bin/perl 

=head1 NAME

pfdhcplistener - listen to DHCP requests

=head1 SYNOPSIS

pfdhcplistener -i <interface> [options]

 Options:
   -d     Daemonize
   -h     Help

=cut

use warnings;
use strict;
use English qw( ‐no_match_vars ) ;  # Avoids regex performance penalty
use File::Basename qw(basename);
use Getopt::Std;
use Log::Log4perl;
use Net::Pcap 0.16;
use Pod::Usage;
use POSIX qw(:signal_h);

use constant INSTALL_DIR => '/usr/local/pf';

use lib INSTALL_DIR . "/lib";

use pf::action;
use pf::class;
use pf::config;
use pf::db;
use pf::iplog;
use pf::iptables;
use pf::locationlog;
use pf::node;
use pf::os;
use pf::person;
use pf::rawip;
use pf::services;
use pf::trigger;
use pf::util;
use pf::violation;

Log::Log4perl->init_and_watch( INSTALL_DIR . "/conf/log.conf", $LOG4PERL_RELOAD_TIMER );
my $logger = Log::Log4perl->get_logger( basename($0) );
Log::Log4perl::MDC->put( 'proc', basename($0) );
# storing process id instead of thread id in tid (more useful)
Log::Log4perl::MDC->put( 'tid',  $PID );

POSIX::sigaction(
    &POSIX::SIGHUP,
    POSIX::SigAction->new(
        'normal_sighandler', POSIX::SigSet->new(), &POSIX::SA_NODEFER
    )
) or $logger->logdie("pfdhcplistener: could not set SIGHUP handler: $!");

POSIX::sigaction(
    &POSIX::SIGTERM,
    POSIX::SigAction->new(
        'normal_sighandler', POSIX::SigSet->new(), &POSIX::SA_NODEFER
    )
) or $logger->logdie("pfdhcplistener: could not set SIGTERM handler: $!");

POSIX::sigaction(
    &POSIX::SIGINT,
    POSIX::SigAction->new(
        'normal_sighandler', POSIX::SigSet->new(), &POSIX::SA_NODEFER
    )
) or $logger->logdie("pfdhcplistener: could not set SIGINT handler: $!");


my %args;
getopts( 'dhi:', \%args );

my $daemonize = $args{d};
my $interface = $args{i};

pod2usage( -verbose => 1 ) if ( $args{h} || !$args{i} );

my %rogue_servers;

# standard signals and daemonize
daemonize() if ($daemonize);

# start dhcp monitor
if ( isenabled( $Config{'network'}{'dhcpdetector'} ) ) {
    my @devices = @listen_ints;
    push @devices, @dhcplistener_ints;
    @devices = get_dhcp_devs() if ( $Config{'network'}{'mode'} =~ /^dhcp$/i );
    foreach my $dev (@devices) {
        if ( $dev eq $interface ) {
            $logger->info("DHCP detector on $dev enabled");
            dhcp_detector($interface);
        }
    }
    $logger->warn("pfdhcplistener for $interface finished - this is bad");
}

END {
    if ( defined($interface) ) {
        deletepid("pfdhcplistener_$interface");
        $logger->info("stopping pfdhcplistener for interface $interface");
    }
}

exit(0);

=head1 SUBROUTINES

=over

=cut
sub dhcp_detector {
    my ($eth) = @_;
    my $filter = "udp and (port 67 or port 68)";
    my $filter_t;
    my $net;
    my $mask;
    my $opt = 1;
    my $err;
    my $pcap_t = Net::Pcap::pcap_open_live( $eth, 576, 1, 0, \$err );

    if (!defined($pcap_t)) {
        $logger->logdie("Unable to initiate packet capture. Is $eth an actual network interface?");
    }

    if ((Net::Pcap::compile( $pcap_t, \$filter_t, $filter, $opt, 0 )) == -1) {
        $logger->logdie("Unable to compile filter string '$filter'");
    }

    Net::Pcap::setfilter( $pcap_t, $filter_t );
    Net::Pcap::loop( $pcap_t, -1, \&process_pkt, $eth );
}

sub process_pkt {
    my ( $user_data, $hdr, $pkt ) = @_;
    listen_dhcp( $pkt, $user_data );
}

sub listen_dhcp {
    my ( $packet, $eth ) = @_;

    # decode src/dst MAC addrs
    my ( $dmac, $smac ) = unpack( 'H12H12', $packet );
    $smac = clean_mac($smac);
    $dmac = clean_mac($dmac);

    return if ( !valid_mac($smac) );

    # decode IP datagram
    my ($version, $tos,   $length, $id,    $flags,
        $ttl,     $proto, $chksum, $saddr, $daddr
    ) = unpack( 'CCnnnCCnNN', substr( $packet, 14 ) );
    my $ihl = $version & oct(17);
    $version >>= 4;
    my $src = int2ip($saddr);
    my $dst = int2ip($daddr);

    # decode UDP datagram
    my ( $sport, $dport, $len, $udpsum )
        = unpack( 'nnnn', substr( $packet, 14 + $ihl * 4 ) );

    # decode DHCP data
    my ($op,     $htype,  $hlen,   $hops,   $xid,
        $secs,   $dflags, $ciaddr, $yiaddr, $siaddr,
        $giaddr, $chaddr, $sname,  $file,   @options
        )
        = unpack( 'CCCCNnnNNNNH32A64A128C*',
        substr( $packet, 14 + $ihl * 4 + 8 ) );

    if ( !defined($chaddr) ) {
        $logger->debug("chaddr is undefined in DHCP packet");
        return;
    }

    $chaddr = clean_mac( substr( $chaddr, 0, 12 ) );
    if ( $chaddr ne "00:00:00:00:00:00" && !valid_mac($chaddr) ) {
        $logger->debug(
            "invalid CHADDR value ($chaddr) in DHCP packet from $smac ($src)"
        );
        return;
    }

    # There is activity from that mac, call node wakeup
    node_mac_wakeup($chaddr);

    # decode DHCP options
    # valid DHCP options field begins with 99,130,83,99...
    if ( !join( ":", splice( @options, 0, 4 ) ) =~ /^99:130:83:99$/ ) {
        $logger->debug("invalid DHCP options received from $chaddr");
        return;
    }

    # populate hash with DHCP options
    # ASCII-ify textual data and treat option 55 (parameter list) as an array
    my %options;
    while (@options) {
        my $code   = shift(@options);
        my $length = shift(@options);
        if ( $code != 0 ) {
            while ($length) {
                my $val = shift(@options);
                if (   $code == 15
                    || $code == 12
                    || $code == 60
                    || $code == 66
                    || $code == 67
                    || $code == 81
                    || $code == 4 )
                {
                    if ( defined($val) && $val != 0 && $val != 1 ) {
                        $val = chr($val);
                    } else {
                        $length--;
                        next;
                    }
                }
                push( @{ $options{$code} }, $val );
                $length--;
            }
        }
    }

    # opcode 1 = request, opcode 2 = reply

    #           Value   Message Type
    #           -----   ------------
    #             1     DHCPDISCOVER
    #             2     DHCPOFFER
    #             3     DHCPREQUEST
    #             4     DHCPDECLINE
    #             5     DHCPACK
    #             6     DHCPNAK
    #             7     DHCPRELEASE
    #             8     DHCPINFORM

    if ( $op == 2 ) {
        if ( defined( $options{'53'}[0] ) && $options{'53'}[0] == 2 ) {
            if ($yiaddr) {
                $yiaddr = int2ip($yiaddr);
                $logger->info(
                    "DHCPOFFER from $src ($smac) to host $chaddr ($yiaddr)");
            } else {
                $logger->warn("DHCPOFFER invalid IP $yiaddr for $chaddr");
                return;
            }

            # ignore DHCPOFFER from gateways
            # avoid rogue DHCP false positives
            if ( grep( { $_ eq $src } get_gateways() )
                || grep( { $_ eq $src }
                    split( /\s*,\s*/, $Config{'general'}{'dhcpservers'} ) ) )
            {
                $logger->debug(
                    "$src ($smac) appears to be a DHCP server or relay, adding $chaddr ($yiaddr) to hash"
                );

                #update_iplog($chaddr,$yiaddr);
                return;
            }

            my $date = POSIX::strftime( "%m/%d/%y %H:%M:%S", localtime );
            push @{ $rogue_servers{$smac} },
                sprintf( "%s: %15s to %s on interface %s\n",
                $date, $yiaddr, $chaddr, $eth );
            $logger->warn(
                "$src ($smac) was detected offering $yiaddr to $chaddr on $eth"
            );
            #TODO: merge this hackish violation into standard violation management in a new violation type
            `$bin_dir/pfcmd 'violation add vid=1100010, mac=$smac'`;
            if (scalar( @{ $rogue_servers{$smac} } )
                == $Config{'network'}{'rogueinterval'} )
            {
                my %rogue_message;
                $rogue_message{'subject'}
                    = "ROGUE DHCP SERVER DETECTED AT "
                    . uc($smac)
                    . " ($src) ON "
                    . uc($eth) . "\n";
                push @INC, $bin_dir;
                require pf::lookup::node;
                $rogue_message{'message'} = pf::lookup::node::lookup_node($smac) . "\n";
                $rogue_message{'message'}
                    = "Detected Offers\n---------------\n";
                while ( @{ $rogue_servers{$smac} } ) {
                    $rogue_message{'message'}
                        .= pop( @{ $rogue_servers{$smac} } );
                }
                pfmailer(%rogue_message);
            }
        } elsif ( defined( $options{'53'}[0] ) && $options{'53'}[0] == 5 ) {
            my $lease_length = get_lease_length();

            if ($yiaddr) {
                $yiaddr = int2ip($yiaddr);
                $logger->info(
                    "DHCPACK from $src ($smac) to host $chaddr ($yiaddr)"
                        . (
                        defined($lease_length)
                        ? " for $lease_length seconds"
                        : ""
                        )
                );
                update_iplog( $chaddr, $yiaddr, $lease_length );
                return;
            } elsif ($ciaddr) {
                $ciaddr = int2ip($ciaddr);
                $logger->info(
                    "DHCPACK CIADDR from $src ($smac) to host $chaddr ($ciaddr)"
                        . (
                        defined($lease_length)
                        ? " for $lease_length seconds"
                        : ""
                        )
                );
                update_iplog( $chaddr, $ciaddr, $lease_length );
                return;
            } else {
                $logger->warn(
                    "invalid DHCPACK from $src ($smac) to host $chaddr [$yiaddr - $ciaddr ] -"
                );
                return;
            }
        }
    } elsif ( $op == 1 ) {
        if ( defined( $options{'53'}[0] ) && $options{'53'}[0] == 1 ) {
            $logger->debug("DHCPDISCOVER from $chaddr");

        } elsif ( defined( $options{'53'}[0] ) && $options{'53'}[0] == 3 ) {
            $logger->debug("DHCPREQUEST from $chaddr");
            my $lease_length = get_lease_length(%options);
            my $client_ip = get_requested_ip(%options);
            if ($client_ip) {
                $logger->info(
                    "DHCPREQUEST from $chaddr ($client_ip)"
                    . ( defined($lease_length) ? " with lease of $lease_length seconds" : "")
                );
                update_iplog( $chaddr, $client_ip, $lease_length );
            }

        } elsif ( defined( $options{'53'}[0] ) && $options{'53'}[0] == 7 ) {
            $ciaddr = int2ip($ciaddr);
            $logger->debug("DHCPRELEASE from $chaddr ($ciaddr)");
            iplog_close($ciaddr);
            return;
        } elsif ( defined( $options{'53'}[0] ) && $options{'53'}[0] == 8 ) {
            $ciaddr = int2ip($ciaddr);
            $logger->info("DHCPINFORM from $chaddr ($ciaddr)");
            return;
        }

        my %tmp;
        $tmp{'dhcp_fingerprint'} = "";
        my $fingerprint_data = '';
        if ( defined( $options{'55'} ) ) {
            my $dhcp_fingerprint = join( ",", @{ $options{'55'} } );
            $tmp{'dhcp_fingerprint'} = $dhcp_fingerprint;
            my @fingerprint_info = dhcp_fingerprint_view($dhcp_fingerprint);
            if ( scalar(@fingerprint_info)
                && ( ref( $fingerprint_info[0] ) eq 'HASH' ) )
            {
                my @os_triggers;
                $fingerprint_data = "OS::".$fingerprint_info[0]->{'os_id'}." (".$fingerprint_info[0]->{'os'}.")";
                $logger->debug("$chaddr DHCP fingerprint is $fingerprint_data");
                $logger->debug( "sending OS::"
                        . $fingerprint_info[0]->{'os_id'} . " ("
                        . $fingerprint_info[0]->{'os'}
                        . ") trigger" );
                violation_trigger( $chaddr, $fingerprint_info[0]->{'os_id'},
                    "OS" );
                foreach my $os_trigger (@fingerprint_info) {
                    $logger->debug( "sending OS::"
                            . $os_trigger->{'class_id'} . " ("
                            . $os_trigger->{'class'}
                            . ") trigger" );
                    violation_trigger( $chaddr, $os_trigger->{'class_id'},
                        "OS" );
                }
            } else {
                $logger->warn("unknown DHCP fingerprint: $dhcp_fingerprint");
            }
        }
        # TODO why rely on mysql_date, we should use time instead
        $tmp{'last_dhcp'} = mysql_date();
        $tmp{'computername'} = join( "", @{ $options{'12'} } )
            if ( defined( $options{'12'} ) );

        if ( isenabled( $Config{'network'}{'dhcpoption82logger'} )
            && defined( $options{'82'} ) )
        {
            my ($switch, $vlan, $mod, $port);
            my %option_82;
            while ( @{ $options{'82'} } ) {
                my $subopt = shift( @{ $options{'82'} } );

                # this makes offset assumptions we probably shouldn't, but it should work fine for Cisco
                # assume all cidtype/ridtype always == 0
                shift( @{ $options{'82'} } );
                shift( @{ $options{'82'} } );
                my $len = shift( @{ $options{'82'} } );
                while ($len) {
                    my $val = shift( @{ $options{'82'} } );
                    push( @{ $option_82{$subopt} }, $val );
                    $len--;
                }
            }

            if ( defined( $option_82{'1'} ) ) {
                ($vlan, $mod, $port) = unpack( 'nCC', pack( "C*", @{ $option_82{'1'} } ) );
            }
            if ( defined( $option_82{'2'} ) ) {
                $tmp{'switch'} = clean_mac( join( ":", unpack( "H*", pack( "C*", @{ $option_82{'2'} } ) ) ) ); 
            }

            # TODO port should be translated into ifIndex
            locationlog_insert_closed($switch, $mod . '/' . $port, $vlan, $chaddr, '');
        }

        if ( !node_exist($chaddr) ) {
            $logger->info("UPDATE New node $chaddr");
            node_add_simple($chaddr);
        }

        my $modifying_node_log_message = '';
        foreach my $node_key ( keys %tmp ) {
            $modifying_node_log_message
                .= "$node_key = " . $tmp{$node_key} . ",";
        }
        chop($modifying_node_log_message);
        $logger->info("$chaddr requested an IP. "
            . "DHCP Fingerprint: $fingerprint_data. "
            . "Modifying node with $modifying_node_log_message"
        );
        my $result = node_modify( $chaddr, %tmp );
        foreach my $field (keys %tmp) {
            print "$field: $tmp{$field}\n";
        }
    } else {
        $logger->debug("unrecognized DHCP opcode from $chaddr: $op");
    }
}

sub update_iplog {
    my ( $srcmac, $srcip, $lease_length ) = @_;
    $logger->debug("$srcip && $srcmac");

    # return if MAC or IP is not valid
    if ( !valid_mac($srcmac) || !valid_ip($srcip) ) {
        $logger->error("invalid MAC or IP: $srcmac $srcip");
        return;
    }

    my $oldmac = ip2mac($srcip);
    my $oldip  = mac2ip($srcmac);

    if ( $oldmac && $oldmac ne $srcmac ) {
        $logger->info(
            "oldmac ($oldmac) and newmac ($srcmac) are different for $srcip - closing iplog entry"
        );
        iplog_close_now($srcip);
    }
    if ( $oldip && $oldip ne $srcip ) {
        $logger->info(
            "oldip ($oldip) and newip ($srcip) are different for $srcmac - closing iplog entry"
        );
        iplog_close_now($oldip);
    }
    if ( !node_exist($srcmac) ) {
        $logger->info("UPDATE New node $srcmac ($srcip)");
        node_add_simple($srcmac);
    }
    iplog_open( $srcmac, $srcip, $lease_length );
}

=item get_lease_length

Grab DHCP lease duration from DHCP Option 51. See RFC 2132 for details.

Option 51: IP Address Lease Time

=cut
sub get_lease_length {
    my (%options) = @_;

    my $lease_length;
    if ( exists( $options{51} ) ) {
        $lease_length = unpack( "N", pack( "C4", @{ $options{51} } ) );
    }

    return $lease_length;
}

=item get_requested_ip

Grab IP address from DHCP Option 50. See RFC 2132 for details.

Option 50: Requested IP Address

=cut
sub get_requested_ip {
    my (%options) = @_;

    my $req_ip;
    if ( exists( $options{50} ) ) {
        $req_ip = int2ip( unpack( "N", pack( "C4", @{$options{50}} ) ) );
    }

    return $req_ip;
}

sub daemonize {
    chdir '/' or $logger->logdie("Can't chdir to /: $!");
    open STDIN, '<', '/dev/null'
        or $logger->logdie("Can't read /dev/null: $!");
    my $log_file = "$install_dir/logs/pfdhcplistener_$interface";
    open STDOUT, '>>', "$log_file"
        or $logger->logdie("Can't write to $log_file: $!");

    defined( my $pid = fork )
        or $logger->logdie("pfdhcplistener: could not fork: $!");
    POSIX::_exit(0) if ($pid);
    if ( !POSIX::setsid() ) {
        $logger->error("could not start a new session: $!");
    }
    open STDERR, '>&STDOUT' or $logger->logdie("Can't dup stdout: $!");
    my $daemon_pid = createpid("pfdhcplistener_$interface");

    # updating Log4perl's pid info
    Log::Log4perl::MDC->put( 'tid',  $daemon_pid );
}

sub normal_sighandler {
    deletepid("pfdhcplistener_$interface");
    if ( threads->self->tid() == 0 ) {
        $logger->logdie(
            "pfdhcplistener: caught SIG" . $_[0] . " - terminating" );
    }
}

=back

=head1 BUGS AND LIMITATIONS

Probably

=head1 AUTHOR

Dave Laporte <dave@laportestyle.org>

Kevin Amorin <kev@amorin.org>

Dominik Gehl <dgehl@inverse.ca>

Olivier Bilodeau <obilodeau@inverse.ca>

=head1 COPYRIGHT

Copyright (C) 2005 Dave Laporte

Copyright (C) 2005 Kevin Amorin

Copyright (C) 2007-2011 Inverse inc.

This program is free software; you can redistribute it and/or
modify it under the terms of the GNU General Public License
as published by the Free Software Foundation; either version 2
of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program; if not, write to the Free Software
Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301,
USA.

=cut

