#!/usr/bin/perl

=head1 NAME

pfbandwidthd - PacketFence inline bandwidth accounting daemon

=head1 SYNOPSIS

pfbandwidthd

=cut

use warnings;
use strict;
use Readonly;
use File::Basename qw(basename);
use Getopt::Std;
use Pod::Usage;
use POSIX qw(:signal_h);

use Net::Interface;
use Net::Pcap;
use NetAddr::IP;
use NetPacket::Ethernet;
use NetPacket::IP;
use IO::Select;
use Socket;
use Systemd::Daemon qw{ -soft };

BEGIN {
    # log4perl init
    use constant INSTALL_DIR => '/usr/local/pf';
    use lib INSTALL_DIR . "/lib";
    use pf::log(service => 'pfbandwidthd');
}

use pf::config qw(
    %Config
    %ConfigNetworks
);
use pf::util;
use pf::services::util;
use pf::inline::accounting;
use pf::ConfigStore::Interface;

# initialization
# --------------
# assign process name (see #1464)
our $PROGRAM_NAME = $0 = "pfbandwidthd";

my $logger = get_logger( basename($PROGRAM_NAME) );

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

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

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

our $RUNNING = 1;

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

my $daemonize = $args{d};

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

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

my %listen_handler;
my %ip_stats = ();
my %seen_inline_ips;
my $last_stats_dump = 0;
my $min_stats_dump_interval = $Config{'inline'}{'layer3_accounting_sync_interval'}; # Stats will be written to the DB at ~this inverval

# setup our inline networks
my @l3_accounting_networks;
foreach my $network ( keys %ConfigNetworks ) {
    if ( pf::config::is_network_type_inline($network) ) {
        my $inline_obj = NetAddr::IP->new( $network, $ConfigNetworks{$network}{'netmask'} );
        push @l3_accounting_networks, $inline_obj;
    }
}

# pcap setup
my %pcap_stats;
my $err = '';
my $dev;
my $snaplen = 64; # Should be enough to get iphdr + tcphdr
my $promisc = 0;
my $to_ms = 0;

my @devs;

my $net; # not used
my $mask;

my $cs = pf::ConfigStore::Interface->new;
foreach my $interface ( @{$cs->readAllIds} ) {
    $interface = "interface $interface";
    if (defined($Config{$interface}{'enforcement'}) && pf::config::is_type_inline($Config{$interface}{'enforcement'})) {
        if ($interface =~ /interface\s(.*)/) {
            if (Net::Pcap::lookupnet($1, \$net, \$mask, \$err) == -1) {
                $logger->warn("Unable to lookup net: $err");
            } else {
                push @devs ,  Net::Pcap::pcap_open_live($1, $snaplen, $promisc, $to_ms, \$err);
            }
        }
    }
}

my $pcap_filter_str = "ether proto \\ip";
$pcap_filter_str .= " and not " . pcap_filter_from_local_interfaces();
$pcap_filter_str .= " and " . pcap_filter_from_networks(@l3_accounting_networks);

# Compile + apply the filter
$logger->debug("pcap_filter: $pcap_filter_str");
my $pcap_filter;

my $read_set = new IO::Select();

foreach my $pcap (@devs) {
    if (Net::Pcap::compile($pcap, \$pcap_filter, $pcap_filter_str, 1, $mask) == -1) {
        $logger->warn("Unable to compile pcap filter: $!");
    }
    Net::Pcap::setnonblock($pcap, 1, \$err);
    Net::Pcap::pcap_breakloop($pcap);
    my $pcap_fd = Net::Pcap::pcap_get_selectable_fd($pcap);
    if ($pcap_fd < 0) {
        $logger->warn("cannot get selectable fd");
    } else {
        my $pcap_fh = IO::Handle->new();
        $pcap_fh->fdopen($pcap_fd, "r");
        $listen_handler{$pcap_fd} = $pcap;
        $read_set->add($pcap_fh);
    }
}

# Change user to pf
dropprivs("pf", "pf");
Systemd::Daemon::notify( READY => 1, STATUS => "Ready", unset => 1 );

while ($RUNNING) {
    my @select_set = $read_set->can_read;
    foreach my $pcap (@select_set) {
        my $ready_fd = $pcap->fileno;
        Net::Pcap::dispatch($listen_handler{$ready_fd}, -1, \&process_packet, $listen_handler{$ready_fd});
    }
}

sub process_packet {
    my ($user_data, $header, $packet) = @_;
    my $len = $header->{len};
    my ($src, $dst) = &packet_addresses($packet);

    my $inline_ip;
    # figure out packet direction
    if (&ip_in_inline_subnets($src, @l3_accounting_networks)) {
      $inline_ip = $src;
      $ip_stats{$inline_ip}{outbytes} += $len;
    } elsif (&ip_in_inline_subnets($dst, @l3_accounting_networks)) {
      $inline_ip = $dst;
      $ip_stats{$inline_ip}{inbytes} += $len;
    }

    if ($inline_ip) {
        # set firstseen and/or update lastmodified
        if (!defined($ip_stats{$inline_ip}{firstseen})) {
          $ip_stats{$inline_ip}{firstseen} = $header->{tv_sec};
        }
        $ip_stats{$inline_ip}{lastmodified} = $header->{tv_sec};
    }

    if ($header->{tv_sec} - $last_stats_dump >= $min_stats_dump_interval) {
        $logger->debug("saving stats");
        &save_stats($header);
        Net::Pcap::stats($user_data, \%pcap_stats);
        $logger->debug("pcap stats ps_recv:" . $pcap_stats{ps_recv} .
                       " ps_drop: " . $pcap_stats{ps_drop} .
                       " ps_ifdrop:" .  $pcap_stats{ps_ifdrop});
    }
}

sub packet_addresses {
  my ($packet) = @_;

  my @addresses;
  my $ether = NetPacket::Ethernet->decode($packet);
  my $ip = NetPacket::IP->decode($ether->{'data'});

  return ($ip->{src_ip}, $ip->{dest_ip});
}

sub ip_in_inline_subnets {
  my ($ip_txt, @subnets) = @_;

  # return early if ip is known
  return 1 if (defined($seen_inline_ips{$ip_txt}));

   my $ip = new NetAddr::IP($ip_txt);
   foreach my $subnet (@subnets) {
      if ($ip->within($subnet)) {
          $seen_inline_ips{$ip_txt} = 1;
          return 1;
      }
   }

   return 0;
}

sub save_stats {
  my ($pcap_header) = @_;
  foreach my $ip (keys %ip_stats) {
    my $inbytes = $ip_stats{$ip}{inbytes} ? $ip_stats{$ip}{inbytes} : 0;
    my $outbytes = $ip_stats{$ip}{outbytes} ? $ip_stats{$ip}{outbytes} : 0;
    my $ret = inline_accounting_update_session_for_ip($ip, $inbytes, $outbytes,
                                                      $ip_stats{$ip}{firstseen}, $ip_stats{$ip}{lastmodified});
    $logger->warn("Error saving stats for $ip: $!") if (!$ret);
  }
  # FIXME what should we do if we had an error inserting? drop everything except those with error?
  %ip_stats=();
  $last_stats_dump = $pcap_header->{tv_sec};
}

# builds a pcap filter like this:
#   (net 127.0.0.0/24 or net 192.168.0.0/24 or net 192.168.1.0/24 or net 192.168.3.0/24)
sub pcap_filter_from_networks {
    my @networks = @_;

    my @networks_cidr;
    foreach my $network (@networks) {
      push @networks_cidr, $network->network();
    }
    return "(net " . join(" or net ", @networks_cidr) . ")";
}

sub pcap_filter_from_local_interfaces {
  # return the ip address of all local interfaces as a pcap filter string:
  #  ( host 127.0.0.1 or host 192.168.1.1 or host 192.168.2.1 ... )
  my @addresses;

  my @interfaces =   Net::Interface->interfaces();
  foreach my $interface (@interfaces) {
    foreach my $address ($interface->address(AF_INET())) {
      push @addresses, Net::Interface::inet_ntoa($address);
    }
    foreach my $broadcast ($interface->broadcast(AF_INET())) {
      my $broadcast_str = Net::Interface::inet_ntoa($broadcast);
      if (grep(!/^$broadcast_str$/, @addresses)) {
        push @addresses, $broadcast_str;
      }
    }
  }

  return "( host ". join(" or host ", @addresses) . " )";
}

sub dropprivs {
  my ($user, $group) = @_;
  my $uid = getpwnam($user);
  my $gid = getgrnam($group);

  $logger->logdie("Can't drop privileges") if (!$uid || !$gid);

  POSIX::setgid($gid) or $logger->logdie("Can't setgid to $group: $!");;
  POSIX::setuid($uid) or $logger->logdie("Can't setuid to $user: $!");
}

sub normal_sighandler {
    $RUNNING = 0;
    Systemd::Daemon::notify( STOPPING => 1 );
    deletepid("pfbandwidthd");
    if ( threads->self->tid() == 0 ) {
        $logger->logdie(
            "pfbandwidthd: caught SIG" . $_[0] . " - terminating" );
    }
}


=head1 BUGS AND LIMITATIONS

Probably

=head1 COPYRIGHT

Copyright (C) 2005-2019 Inverse inc.

=head1 LICENSE

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

