#!/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;