#!/usr/bin/perl

use strict;
use utf8;
use warnings;
use 5.010;

use Data::Dump qw(pp);           # depends: libdata-dump-perl
use Getopt::Long qw(GetOptions); # depends: perl-base
use noris::IO::AutoEncoding;     # depends: libnoris-io-autoencoding-perl
use Nmap::Parser;                # depends: libnmap-parser-perl
use MIME::Lite;                  # depends: libmime-lite-perl
use Template;                    # depends: libtemplate-perl
use XML::Simple;                 # depends: libxml-simple-perl
use Net::IP;                     # depends: libnet-ip-perl
use Parallel::ForkManager;       # depends: libparallel-forkmanager-perl
use Time::HiRes qw/time/;
use Socket qw/inet_aton AF_INET/;
use JSON::XS;                    # depends: libjson-xs-perl
use Fcntl qw(:flock);
use Encode qw/encode_utf8/;
use Data::Dumper;

GetOptions(
    'config=s'        => \( my $Config = 'config.xml' ),
    'debug=i'         => \( my $Debug = 0 ),
    'fqdn=s'          => \my @FQDN,
    'ip=s'            => \my @IPs,
    'I|ohne-ip=s'     => \my @OhneIPs,
    'kunden=s'        => \my @Kunden,
    'K|ohne-kunden=s' => \my @OhneKunden,
    'mailtemplate=s'  => \( my $MailTemplate = ''),
    'sendmails!'      => \( my $SendMails = 0 ),
    'from-address=s'  => \( my $FromAddress = 'postmaster@noris.net'),
    'jobs=i'          => \( my $Jobs = 10 ),
    'logfile=s'       => \( my $LogFile = 'remote_scan.log' ),
    'help|?'          => sub {
        exec perldoc => -F => $0
          or die qq(Cannot execute "perldoc -F $0": $!\n);
    },
) or exit 1;

die "Cannot find Config file '$Config'!" unless -f $Config;
die "Need --mailtemplate!" if $SendMails and !$MailTemplate;

my $StartTime = scalar localtime; 
sub IPbyHostname {
    my $raw_addr = ( gethostbyname( $_[0] ) )[4];
    my @octets   = unpack( 'C4', $raw_addr );
    return join( '.', @octets );
}

my %StateMappings = (
    'filtered'      => 'closed',
    'open|filtered' => 'open',
    'filtered|open' => 'open',
);

sub scan {
    my ( $ip, $protocol, $ports, $expected_state, $stats ) = @_;
    debug( "Scanning '$ip' Protocol '$protocol' Ports '" . pp($ports) . "' State '$expected_state'", 1);

    my $np = Nmap::Parser->new;

    my @args = ('-T Insane', '-PN');
    if ($protocol eq 'tcp') {
        push @args, '-sS';
    }
    elsif ($protocol eq 'udp') {
        push @args, '-sU';
    }
    else {
        warn "Protocol $protocol kenn ich nicht!";
    }
    push @args, '-p', join(',', @$ports);

    my $args = join ' ', @args;

    $np->parsescan('/usr/bin/nmap', $args, $ip);

    my $host = $np->get_host($ip);
    unless ($host) {
        die "Error while retrieving scan data for IP '$ip' with command line '$args'\n";
    }

    my $method = "${protocol}_ports";
    my %seen_ports;
    my $res;
    for my $port ( $host->$method() ) {
        my $method  = "${protocol}_port_state";
        my $state   = $host->$method($port);
        # sometimes ports are reported as open|filtered
        # we consider those "closed".
        $state = 'closed' unless $state eq 'open';

        debug(' Port ' . $port . ' is ' . $state, 3);
        $stats->{protocols}{$protocol}{$state}++;
        push @{ $seen_ports{ $port} }, $state;
        my $join_port_states = join '|', @{ $seen_ports{ $port } };
        debug('  Join Portstates ' . $join_port_states, 3);
        unless (
                $state =~ m/$expected_state/
            || ( defined $StateMappings{$state} && $StateMappings{$state} =~ m/$expected_state/ )
            || ( defined $StateMappings{$join_port_states} && $StateMappings{$join_port_states} =~ m/$state/ )
            )
        {
            my $mapped_state = $StateMappings{$state} || $StateMappings{$join_port_states} || $state;
            push @{ $res->{$mapped_state} }, $port;
        }
    }

    debug( '  Scan Result:' . pp($res), 2 );
    return $res;
}
sub debug {
    my ( $msg, $dl ) = @_;
    $dl = 1 unless $dl;
    print STDERR "PID($$): $msg\n" if $dl <= $Debug;
}

# Parse Options
for (@FQDN) {
    my $hostip = IPbyHostname($_);
    push @IPs, $hostip unless grep /^$hostip$/, @IPs;
}

# Parse Config File
my $xml  = new XML::Simple;
my $Data = $xml->XMLin( $Config, ForceArray => [ 'customer', 'ip', 'open', 'closed' ] );
debug( 'Data:' . pp($Data), 3 );


my $tt = Template->new( { ABSOLUTE => 1 } ) || die "$Template::ERROR\n";

my $pm = Parallel::ForkManager->new($Jobs);

for my $c (keys %{ $Data->{customer} }) {
    $pm->start and next;
    my $res = scan_customer($c, $Data->{customer}{$c});

    # write logs
    open my $LogFH, '>>', $LogFile;
    if ($LogFH) {
        if (flock $LogFH, LOCK_SH) {
            my $JSON = JSON::XS->new()->utf8->indent(0);
            say { $LogFH } $JSON->encode($res);

        }
        else {
            warn "Cannot obtain lock on log file '$LogFile': $!\n";
        }
        close $LogFH;
    }
    else {
        warn "Cannot open log file '$LogFile' for writing: $!\n";
    }

    $pm->finish;
}
$pm->wait_all_children;


sub scan_customer {
    my ($customer, $customerdata) = @_;
    debug( "Customer $customer", 2 );
    next if @OhneKunden and  grep /^$customer$/, @OhneKunden;
    next if @Kunden     and !grep /^$customer$/, @Kunden;

    my $CustomerFails;
    my $Customer = {
        id   => $Data->{customer}->{$customer}->{number},
        name => $customer
    };
    debug( 'Customerdata:' . pp($customerdata), 3 );

    my %customerstats = (
        customer    => $customer,
        start_time  => time,
        ips         => {},
    );

    # Reading ToScan
    my $IPsToScan = {};
    for my $ip ( keys %{ $customerdata->{ip} } ) {
        debug ("Reading IP:$ip", 2);
        next if @OhneIPs and  grep /^$ip$/, @OhneIPs;
        next if @IPs     and !grep /^$ip$/, @IPs;

        my $netip = new Net::IP ($ip) || die;
        do {{
            my $ToScan;
            my $ScanFail;
            my $ipdata = $customerdata->{ip}->{$ip};
            $customerstats{ips}{$ip}{specs} = $ipdata;
            my $scanip = $netip->ip();
            next if @OhneIPs and  grep /^$scanip$/, @OhneIPs;
            next if @IPs     and !grep /^$scanip$/, @IPs;
            debug( ' Will scan IP:' . $scanip, 2 );
            debug( ' |- Ipdata:' . pp($ipdata), 3 );
            for my $state (qw(closed open)) {
                for my $door ( @{ $ipdata->{$state} } ) {
                    my $protocol = $door->{protocol};
                    my $port     = $door->{port};
                    if ( $protocol eq 'any' ) {
                        push @{ $ToScan->{$state}->{tcp}->{ports} }, $port;
                        push @{ $ToScan->{$state}->{udp}->{ports} }, $port;
                    }
                    else {
                        push @{ $ToScan->{$state}->{$protocol}->{ports} }, $port;
                    }
                }
            }
            debug( "  ToScan:" . pp($ToScan), 3 );
            map { $IPsToScan->{$scanip}->{$_} = $ToScan->{$_} } keys %{ $ToScan };
        }} while ( ++$netip );
    }
    debug( "IPs that will be scanned:\n" . pp($IPsToScan), 2 );
    debug( "-------------------------", 2 );

    # Scanning IPs in IPsToScan
    for my $scanip ( keys %{ $IPsToScan } ) {
        my %ipstats = (
            %{ $customerstats{ips}{$scanip} // {} },
            start_time  => time,
        );
        debug ("Scanning IP:$scanip", 2);
        my $ScanFail;
        my $ToScan = $IPsToScan->{$scanip};

        debug( " |- State, Ports: " . pp($ToScan), 2 );
        my $scanresult;
        for my $prot ( keys %{ $ToScan->{closed} } ) {
            $scanresult =
              scan( $scanip, $prot, $ToScan->{closed}->{$prot}->{ports}, 'closed', \%ipstats );
            debug( '  Scan: Closed Protocol:' . $prot . ':' . pp($scanresult), 2 );

            while ( my $isopen = shift @{ $scanresult->{open} } ) {
                push @{ $ScanFail->{isopen}->{$prot} }, $isopen
                  if ( $ToScan->{open} and !grep /^$isopen$/, @{ $ToScan->{open}->{$prot}->{ports} } )
                    or !$ToScan->{open};
            }
            delete $scanresult->{open};
            
            for ( keys %$scanresult ) {
                warn "  Close Scan: Undefined Port State '$_'";
                delete $scanresult->{$_};
            }
        }

        for my $prot ( keys %{ $ToScan->{open} } ) {
            $scanresult = scan( $scanip, $prot, $ToScan->{open}->{$prot}->{ports}, 'open', \%ipstats );
            debug( '  Scan: Open Protocol:' . $prot . ':' . pp($scanresult), 2 );

            if ( $scanresult->{closed} ) {
                push @{ $ScanFail->{isclosed}->{$prot} }, @{ $scanresult->{closed} };
                delete $scanresult->{closed};
            }
            for ( keys %$scanresult ) {
                warn "  Open Scan : Undefined Port State '$_'";
                delete $scanresult->{$_};
            }
        }

        if ($ScanFail) {
            my $inetaddr = inet_aton($scanip);
            my $hostname = gethostbyaddr($inetaddr, AF_INET);
            $ScanFail->{hostname} = $hostname if $hostname;
        }
        debug( '  ScanFails:' . pp($ScanFail), 2 );
        $CustomerFails->{$scanip} = $ScanFail if $ScanFail;
        $ipstats{scanfail}        = $ScanFail if $ScanFail;
        $ipstats{finish_time}     = time;
        $customerstats{ips}{$scanip} = \%ipstats;
    }
    $customerstats{finish_time} = time;
    debug( 'CustomerScanFails:' . pp($CustomerFails), 1 );
    debug( "-------------------------", 2 );

    # Generating and Sending E-Mail
    if ( $CustomerFails ) {
        my $vars = {
            email   => $Data->{customer}->{$customer}->{mail}->{to},
            ipfails => $CustomerFails,
            kunde   => $Customer,
            start_time => $StartTime,
        };

        if ( $SendMails ) {
            my $mailtext;
            $tt->process( $MailTemplate, $vars, \$mailtext )
                || die $tt->error(), "\n";
            debug( "Mailtext:\n$mailtext\n", 1 );

            # create a new MIME Lite based email
            my $msg = MIME::Lite->new(
                Encoding => 'quoted-printable',
                Type     => 'text/plain; charset=utf-8',
                From     => $FromAddress,
                To       => join( ', ', $Data->{customer}->{$customer}->{mail}->{to} ),
                Subject  => "Remote Scan von #$Customer->{id}:$Customer->{name} fehlgeschlagen",
                Data     => encode_utf8($mailtext),
            );

            # Send Error Mails
            debug( '=' x 80 . "\n" . $msg->as_string, 2 );
            MIME::Lite->send( "sendmail", FromSender => $FromAddress);
            $msg->send();
        }
        else {
            debug( 'Vars:' . pp($vars), 1 );
        }
    }
    return \%customerstats;
}

__END__

=head1 NAME

remote_scan - Scannen von IP-Adressen und Alarmieren

=head1 SYNOPSE

    remote_scan -template remote_scan.tt2 

=head1 BESCHREIBUNG

Das Tool scannt IP-Adressen nach offenen und geschlossenen Ports, je nachdem
was im XML Config File definiert ist.
Sollten die Ports vom definierten Standard abweichen wird eine E-Mail an die im 
Configfile angegebenen E-Mail-Adressen verschickt.

=head1 RÜCKGABEWERT

=head1 OPTIONEN

=over 4

=item -config CONFIGFILE

Remote Scan Config File mit allen Kunden und Daten die zum Scannen gebraucht werden

Default: C<config.xml>

=item -debug DEBUGLEVEL

Je höher die Zahl desto gesprächiger das Skript

Default: C<0>

=item -mailtemplate

Das Template das zur E-Mail erzeugung benutzt werden soll. Wird keins angegeben,
dann wird ein Dump der Variablen, die an das Template übergeben werden,
ausgegeben.

=item -sendmails

Wird diese Option angegeben dann werden E-Mails an die in der Config
angegebenen E-Mail Adressen geschickt

Default: C<es werden keine gesendet>

=item -jobs

Anzahl der maximal parallel ausgeführten Scans.

Default: C<10>.

-item -logfile

Name der Logfile für statische Auswertungen.

Default C<remote_scan.log>

=item -help -?

um (nur) diese Dokumentation anzeigen zu lassen

=back

=head1 VERHALTENSSTEURUNG

=over 4

=item -kunden

scannt alle IP-Adressen des Kunden, die in der #Beispielconfig gefunden werden;
falls der Kunde nicht bekannt ist, wird eine Fehlermeldung ausgegeben; falls eine
Kundennummer übergeben wird, wird diese anstatt des Kundennamens verwendet

=item -K -ohne-kunden

scannt alle Kunden, die in der #Beispielconfig gefunden wurden, lässt
allerdings den/die mit dieser Option übergebene(n) Kunden aus

=item -ip

scannt (nur) den mit dieser Option übergebenen IP-Adress-Bereich (auch Einzeladressen);
falls dieser IP-Adress-Bereich in der #Beispielconfig gefunden wurde, wird das Scan-Ergebnis
mit den Soll-Werten verglichen und ausgegeben; falls der IP-Adress-Bereich nicht gefunden
wurde, wird (nur) das Scan-Ergebnis und eine zusätzliche Warnung ausgegeben

=item -I -ohne-ip

scannt alle in der #Beispielconfig gefundenen IP-Adress-Bereiche (aller Kunden), lässt
allerdings den/die mit dieser Option übergene(n) IP-Adress-Bereich aus

=item -fqdn

scannt (nur) den mit dieser Option übergebenen FQDN; falls die zugehörige IP-Adresse
in der #Beispielconfig gefunden wurde, wird das Scan-Ergebnis mit den Soll-Werten
verglichen und ausgegeben; falls die IP-Adresse nicht gefunden wurde, wird (nur)
das Scan-Ergebnis und eine zusätzliche Warnung ausgegeben

Alle o.g. Optionen sind mehrfach ( -k bla -k foo -I 1.2.3.4 ) miteinander kombinierbar.

=item --from-address=test@example.com

Dient als Absenderadresse (From und Sender) der versandten E-Mails.

=back

=head1 AUTOREN

 Stelios Gikas <entwicklung@noris.net>
 Stelios Gikas <10310175@ticket.noris.net>

