#!/usr/bin/env perl

# Declare version
our $VERSION = '2.0.0';

# English module for Perl::Critic compliance
use English qw( -no_match_vars );

# For constants
use Readonly;

# DBI
use DBI;

# Additional libraries for time and files
use IO::Handle;
use Time::Piece;

# For subnet matching and IP address validation
use Net::Subnet;

# IP validator
use Data::Validate::IP qw(is_ip is_public_ip);

# Configure default path to configuration files
# or read them from environment variables
my $cfg_anti_spam_path      = '/etc/postfix/anti-spam.conf';
my $cfg_sql_statements_path = '/etc/postfix/anti-spam-sql-st.conf';

if ( $ENV{'POSTFWD_ANTISPAM_MAIN_CONFIG_PATH'} ) {
    $cfg_anti_spam_path = $ENV{'POSTFWD_ANTISPAM_MAIN_CONFIG_PATH'};
}
if ( $ENV{'POSTFWD_ANTISPAM_SQL_STATEMENTS_CONFIG_PATH'} ) {
    $cfg_sql_statements_path =
      $ENV{'POSTFWD_ANTISPAM_SQL_STATEMENTS_CONFIG_PATH'};
}

# Main config file
use Config::Any::INI;
my $config_ref = Config::Any::INI->load($cfg_anti_spam_path);
my %config     = %{$config_ref};

# Set unique IP and Country limit for logging
my $LOGGING_IP_LIMIT;
my $LOGGING_COUNTRY_LIMIT;

if ( $config{logging}{ip_limit} ) {
    Readonly $LOGGING_IP_LIMIT => $config{logging}{ip_limit};
} else {
    Readonly $LOGGING_IP_LIMIT => 20;
}
if ( $config{logging}{country_limit} ) {
    Readonly $LOGGING_COUNTRY_LIMIT => $config{logging}{country_limit};
} else {
    Readonly $LOGGING_COUNTRY_LIMIT => 5;
}

# SQL statements config file
use Config::Any::General;
my $config_sql_ref = Config::Any::General->load($cfg_sql_statements_path);
my %config_sql     = %{$config_sql_ref};

# Logging
my $program = 'postfwd::anti-spam-plugin';
my $log_file_fh;

sub mylog {
    my ( $log_level, @errstr ) = @_;
    if ( $config{logging}{enable} ) {
        my $date      = localtime(time)->strftime('%F %T');
        my $final_str = "$date $program $log_level: ";

        foreach my $s (@errstr) {
            if ( length $s ) {
                $final_str = $final_str . $s;
            }
        }

        my $tmp = print {$log_file_fh} "$final_str\n";
    }
    return;
}

sub mylog_info {
    my @args = @_;
    mylog( "INFO[$PID]", @args );
    return;
}

sub mylog_err {
    my @args = @_;
    mylog( "ERROR[$PID]", @args );
    return;
}

sub mylog_fatal {
    my @args = @_;
    mylog( "FATAL[$PID]", @args );
    exit 1;
}

sub log_uniq_ip_spam {
    my ($uniq_ip_login_count, $user) = @_;
    if ($uniq_ip_login_count > $LOGGING_IP_LIMIT ) {
        mylog_info("User $user was logged from more than $LOGGING_IP_LIMIT IP addresses($uniq_ip_login_count)");
    }
    return;
}

sub log_uniq_country_spam {
    my ($uniq_country_login_count, $user) = @_;
    if ($uniq_country_login_count > $LOGGING_COUNTRY_LIMIT ) {
        mylog_info("User $user was logged from more than $LOGGING_COUNTRY_LIMIT countries($uniq_country_login_count)");
    }
    return;
}


if (   !$config{logging}{logfile}
    || !length $config{logging}{logfile}
    || $config{logging}{logfile} eq '\'\''
    || $config{logging}{logfile} eq '\'\'' )
{
    if ( $config{logging}{autoflush} ) {
        STDOUT->autoflush(1);
    }
    $log_file_fh = *STDOUT;
    mylog_info('Logging destination is STDOUT');
} else {
    open $log_file_fh, '>>', $config{logging}{logfile}
      or die "ERROR: Could not open file '$config{logging}{logfile}' $ERRNO\n";
    $log_file_fh->autoflush;
    mylog_info("Logging destination is file '$config{logging}{logfile}'");
}

mylog_info("Configuration file $cfg_anti_spam_path was loaded successfully");

# IP WHITELIST
# Do not whitelist any IP addresses by default
my $ip_whitelist = subnet_matcher qw(
    255.255.255.255/32
);

# Make sure that either ip_whitelist or ip_whitelist_path are used in configuration file
if ( ($config{app}{ip_whitelist} || length $config{app}{ip_whitelist}) &&
     ($config{app}{ip_whitelist_path} || length $config{app}{ip_whitelist_path}) ) {
    mylog_fatal('Both "ip_whitelist" and "ip_whitelist_path" are defined! Please choose only one method of whitelisting.');
}
# Set whitelist according to config variable ip_whitelist
if ( $config{app}{ip_whitelist} || length $config{app}{ip_whitelist} ) {
    $ip_whitelist = subnet_matcher(split /,/mxs, $config{app}{ip_whitelist});
    mylog_info('IP whitelist set to CIDRs: ', $config{app}{ip_whitelist});
}
# Read list of IP addresses to whitelist from file ip_whitelist_path and set whitelist according to it
if ( $config{app}{ip_whitelist_path} || length $config{app}{ip_whitelist_path} ) {
    open my $ip_whitelist_fh, '<:encoding(UTF-8)', $config{app}{ip_whitelist_path} or die "ERROR: Could not open file '$config{app}{ip_whitelist_path}' $ERRNO\n";

    my @ip_list;
    while (my $row = <$ip_whitelist_fh>) {
      chomp $row;
      if ( $row =~ m/^\s*\#/msx ) {
          next;
      }
      push @ip_list, $row;
    }

    $ip_whitelist = subnet_matcher(@ip_list);
    close $ip_whitelist_fh or die "ERROR: Could not close file '$config{app}{ip_whitelist_path}' $ERRNO\n";
    mylog_info('IP whitelist set to file: ', $config{app}{ip_whitelist_path});
    mylog_info('IP CIDRs in whitelist file: ', join(', ', @ip_list));
}


# GeoIP:
# Load GeoIP modules
use GeoIP2::Database::Reader;

if (! -e $config{app}{geoip_db_path}) {
    mylog_fatal("[GeoIP2] GeoIP Database file $config{app}{geoip_db_path} doesn't exist");
}
mylog_info("[GeoIP2] Loading GeoIP Database from file $config{app}{geoip_db_path}");

# GeoIP Version 2 check
my $gi = GeoIP2::Database::Reader->new(
    file    => $config{app}{geoip_db_path},
    locales => [ 'en' ]
);

my $gi_metadata = eval { $gi->metadata() };
if ( $EVAL_ERROR ) {
    mylog_fatal('[GeoIP2] Failed to get info about GeoIP database (v2 decoder)');
} else {
    mylog_info('[GeoIP2] Description: ', $gi_metadata->description()->{en});
    mylog_info('[GeoIP2] Database Edition: ', $gi_metadata->binary_format_major_version(), '.', $gi_metadata->binary_format_minor_version());
    mylog_info('[GeoIP2] Database Type: ', $gi_metadata->database_type());
    mylog_info('[GeoIP2] Build: ', $gi_metadata->build_epoch());
    mylog_info('[GeoIP2] IP Version: ', $gi_metadata->ip_version());
}

mylog_info("[GeoIP2] GeoIP2 database $config{app}{geoip_db_path} was loaded successfully");

sub geoip_country_code {
    my ($client_ip) = @_;
    my $cc;

    my $country = eval {
        $gi->country( ip => $client_ip );
    };
    if ( $EVAL_ERROR ) {
        if ($EVAL_ERROR =~ m/No record found for IP address/ims ) {
            mylog_info("[GeoIP2] Cannot find IP address [$client_ip] in GeoIP database");
            return;
        }
        if ($EVAL_ERROR =~ m/The IP address you provided (.*) is not a public IP address/ims ) {
            mylog_info("[GeoIP2] IP address [$client_ip] is not public");
            return;
        }
        if ($EVAL_ERROR =~ m/is not a valid IP/ims ) {
            mylog_info("[GeoIP2] Invalid IP address [$client_ip]");
            return;
        }
        if ($EVAL_ERROR =~ m/The IP address you provided (.*) is not a valid IPv4/ims ) {
            mylog_info("[GeoIP2] Invalid IP address [$client_ip]");
            return;
        }
    }
    my $country_rec = $country->country();

    $cc = eval { $country_rec->iso_code() };
    if ( $EVAL_ERROR ) {
        mylog_info("[GeoIP2] Country code for IP address [$client_ip] is empty");
        return;
    }
    return $cc;
}


# DB connection
# Update values to your DB connection in config file /etc/postfix/anti-spam.conf
my $dbh;
my $dsn =
"DBI:$config{database}{driver}:database=$config{database}{database};host=$config{database}{host};port=$config{database}{port}";
my %attr = ( RaiseError => 0, PrintError => 1, AutoCommit => 1 );
mylog_info("Starting postfwd plugin with dsn '$dsn'");

# Connect to DB, do 3 retries with 10 second timeout
Readonly my $DB_CONN_RETRIES => 3;
Readonly my $DB_CONN_TIMEOUT => 10;
for ( 1 .. $DB_CONN_RETRIES ) {
    $dbh = DBI->connect(
        $dsn,
        $config{database}{userid},
        $config{database}{password}, \%attr
    ) and last;
    mylog_err( 'Retry ', $_, '/3', ' - ', DBI->errstr );
    sleep $DB_CONN_TIMEOUT;
}
if ( !defined $dbh ) {
    mylog_fatal 'Could not connect to configured database after 3 retries';
} else {
    mylog_info('Database connection successful');
}

# Create table "postfwd_logins" if it does not exist
mylog_info('Creating table postfwd_logins if it does not exist');
my $create_table_sth = $dbh->prepare( $config_sql{create_table_st} )
  or mylog_fatal( $dbh->errstr );
$create_table_sth->execute() or mylog_fatal( $create_table_sth->errstr );
mylog_info(
    'Table was created successfully and plugin is correctly initialized.');

# Setup initial time for flushing database records older than interval set in config file
my $last_cache_flush = time;


# Function: Test if database connection is still alive
sub is_db_connection_alive {
    my $rc = $dbh->ping;
    if ( !$rc ) {
        mylog_info(
           "Database connection dropped (rc=$rc). Reconnecting to database."
        );
        $dbh = DBI->connect_cached(
            $dsn,
            $config{database}{userid},
            $config{database}{password}, \%attr
        ) or mylog_fatal( DBI->errstr );
    }
    return 1
}

# Function: Check if user exists in logins database
sub user_exists {
    my ($user) = @_;

    my $check_user_existence_sth =
      $dbh->prepare( $config_sql{check_user_existence_st} )
      or do {
          mylog_err( $dbh->errstr );
          return 0;
      };

    my $row_count = $check_user_existence_sth->execute($user);
    if ( $row_count == 0 ) {
        if ( $check_user_existence_sth->err ) {
            mylog_err( $check_user_existence_sth->errstr );
        }
        return 0;
    }

    return 1;
}


%postfwd_items_plugin = (

    'incr_client_country_login_count' => sub {

        my ($request) = shift;
        my ($result) = undef;
        $result->{incr_client_country_login_count} = 0;

        # Check if we still have DB connection
        is_db_connection_alive();

        # Clear old records after flush interval expired
        if ( ( $last_cache_flush + $config{app}{db_flush_interval} ) < time ) {
            mylog_info(
"Removing records which are older than $config{app}{db_flush_interval}"
            );

            my $clear_table_sth =
              $dbh->prepare( $config_sql{delete_old_logins_st} )
              or do { mylog_err( $dbh->errstr ); return $result; };
            $clear_table_sth->execute( $config{app}{db_flush_interval} )
              or do { mylog_err( $clear_table_sth->errstr ); return $result; };

            mylog_info("DB in pid $PID cleared!");
            $last_cache_flush = time;
        }

        # Check if IP address is in whitelist and return from function if yes
        if ( $ip_whitelist->($request->{client_address}) ) {
            return $result;
        }

        # Get sasl_username from request
        my $user = $request->{sasl_username};
        if ( !length $user || !($user) ) {
            return $result;
        }

        # Get client address
        my $client_ip = $request->{client_address};
        if ( !length $client_ip || !($client_ip) ) {
            return $result;
        }

        # Validate if IP address is IPv4 or IPv6 public address
        if (is_ip($client_ip)) {
            if (! is_public_ip($client_ip)) {
                mylog_info("'$client_ip' is not a public address");
                return $result;
            }
        } else {
            mylog_info("'$client_ip' is not a valid IPv4 or IPv6 address");
            return $result;
        }

        # Get country code from GeoIP module
        my $cc = geoip_country_code($client_ip);
        if ( !defined $cc ) {
            return $result;
        }

        # Check if user with given IP already has record
        my $check_row_existence_sth =
          $dbh->prepare( $config_sql{check_row_existence_st} )
          or do { mylog_err( $dbh->errstr ); return $result; };
        if ( !( $check_row_existence_sth->execute( $user, $client_ip, $cc ) ) )
        {
            mylog_err( $check_row_existence_sth->errstr );
            return $result;
        }

        # Check how many rows were returned (0 or more)
        my $row_count = $check_row_existence_sth->fetchrow_array;
        if ( $check_row_existence_sth->err ) {
            mylog_err( $check_row_existence_sth->errstr );
            return $result;
        }
        if ( $row_count == 0 ) {
            # Save new user mail into hash if it does not exists
            mylog_info("Inserting $user, $client_ip, $cc");
            my $insert_sth = $dbh->prepare( $config_sql{insert_st} )
              or do { mylog_err( $dbh->errstr ); return $result; };
            $insert_sth->execute( $user, $client_ip, $cc,
                localtime(time)->strftime('%F %T') )
              or do { mylog_err( $insert_sth->errstr ); return $result; };
        } else {
            # Increment or initialize login count for user and given IP/country
            mylog_info("Incrementing $user, $client_ip, $cc");
            my $increment_sth = $dbh->prepare( $config_sql{increment_st} )
              or do { mylog_err( $dbh->errstr ); return $result; };
            $increment_sth->execute( localtime(time)->strftime('%F %T'),
                $user, $client_ip, $cc )
              or do { mylog_err( $increment_sth->errstr ); return $result; };
        }

        # Get number of logins from given IP
        my $login_count_from_country_sth =
          $dbh->prepare( $config_sql{login_count_from_country_st} )
          or do { mylog_err( $dbh->errstr ); return $result; };
        if (
            !(
                $login_count_from_country_sth->execute(
                    $user, $client_ip, $cc
                )
            )
          )
        {
            mylog_err( $login_count_from_country_sth->errstr );
            return $result;
        }

        # Fetch number of logins from sth
        $result->{incr_client_country_login_count} =
          $login_count_from_country_sth->fetchrow_array;
        if ( !$result->{incr_client_country_login_count} ) {
            if ( $login_count_from_country_sth->err ) {
                mylog_err( $login_count_from_country_sth->errstr );
            }
            return $result;
        }

        mylog_info(
"Number of logins from IP $client_ip is $result->{incr_client_country_login_count}"
        );

        # Return number of logins from country last logged from
        return $result;
    },

    'client_uniq_country_login_count' => sub {

        my ($request) = shift;
        my ($result) = undef;
        $result->{client_uniq_country_login_count} = 0;

        # Check if we still have DB connection
        is_db_connection_alive();

        # Get sasl_username
        my $user = $request->{sasl_username};
        if ( !length $user || !($user) ) {
            return $result;
        }

        # Check if user already exists, if not return from function
        if (!user_exists($user)) {
            return $result;
        }

        # Get number of unique countries from which has user logged in
        my $num_countries_logs_sth =
          $dbh->prepare( $config_sql{num_countries_logs_st} )
          or do { mylog_err( $dbh->errstr ); return $result; };
        if ( !( $num_countries_logs_sth->execute($user) ) ) {
            mylog_err( $num_countries_logs_sth->errstr );
            return $result;
        }

        # Get first row of data
        $result->{client_uniq_country_login_count} =
          $num_countries_logs_sth->fetchrow_array;
        if ( !$result->{client_uniq_country_login_count} ) {
            if ( $num_countries_logs_sth->err ) {
                mylog_err( $num_countries_logs_sth->errstr );
            }
            return $result;
        }
        $request->{client_uniq_country_login_count} = $result->{client_uniq_country_login_count};

        # Print unique number of countries that user was logged in from
        mylog_info(
"Number of unique countries logged in from user [$user]: $result->{client_uniq_country_login_count}"
        );
        log_uniq_country_spam($result->{client_uniq_country_login_count}, $user);

        # Returns number of countries from which user logged in to an email via sasl
        return $result;
    },

    'client_uniq_ip_login_count' => sub {

        my ($request) = shift;
        my ($result) = undef;
        $result->{client_ip_login_count} = 0;

        # Check if we still have DB connection
        is_db_connection_alive();

        # Get sasl_username
        my $user = $request->{sasl_username};
        if ( !length $user || !($user) ) {
            return $result;
        }

        # Check if user already exists, if not return from function
        if (!user_exists($user)) {
            return $result;
        }

        # Get number of unique IPs from which has user logged in
        my $num_ip_logs_sth = $dbh->prepare( $config_sql{num_ip_logs_st} )
          or do { mylog_err( $dbh->errstr ); return $result; };
        if ( !( $num_ip_logs_sth->execute($user) ) ) {
            mylog_err( $num_ip_logs_sth->errstr );
            return $result;
        }

        # Get first row of data
        $result->{client_uniq_ip_login_count} = $num_ip_logs_sth->fetchrow_array;
        if ( !$result->{client_uniq_ip_login_count} ) {
            if ( $num_ip_logs_sth->err ) {
                mylog_err( $num_ip_logs_sth->errstr );
            }
            return $result;
        }
        $request->{client_uniq_ip_login_count} = $result->{client_uniq_ip_login_count};

        # Print unique number of IPs that user was logged in from
        mylog_info(
"Number of unique IPs logged in from user [$user]: $result->{client_uniq_ip_login_count}"
        );
        log_uniq_ip_spam($result->{client_uniq_ip_login_count}, $user);

        # Returns number of IPs from which user logged in to an email via sasl
        return $result;
    },
);

1;