#!/usr/local/cpanel/3rdparty/bin/perl
use Cpanel::AdvConfig::dovecot ();
use Cpanel::FileUtils::Dir ();
use Term::ANSIColor qw{:constants};
$Term::ANSIColor::AUTORESET = 1;
our $LOGDIR = q{/var/log/};
our $CPANEL_CONFIG_FILE = q{/var/cpanel/cpanel.config};
our $EXIM_LOCALOPTS_FILE = q{/etc/exim.conf.localopts};
our $DOVECOT_CONF = q{/var/cpanel/conf/dovecot/main};
our $EXIM_MAINLOG = q{exim_mainlog};
our $MAILLOG = q{maillog};
our @RBLS = qw{ b.barracudacentral.org
our $ROTATED_LIMIT = 5; # I've seen users with hundreds of rotated logs before, we should safeguard to prevent msp from working against unreasonably large data set
my ( $all, $auth, $conf, $forwards, $help, $limit, $logdir, $queue, @rbl, $rbllist, $rotated, $rude, $threshold, $verbose );
) or die("Please see --help\n");
__PACKAGE__->main(@ARGV) unless caller();
print BOLD BRIGHT_BLUE ON_BLACK "[MSP-$VERSION] ";
print BOLD WHITE ON_BLACK "Mail Status Probe: Mail authentication statistics and configuration checker\n";
print "Usage: ./msp.pl --auth --rotated --rude\n";
print " ./msp.pl --conf --rbl [all|bl.spamcop.net,zen.spamhaus.org]\n\n";
printf( "\t%-15s %s\n", "--help", "print this help message");
# printf( "\t%-15s %s\n", "--all", "run all checks");
printf( "\t%-15s %s\n", "--auth", "print mail authentication statistics");
printf( "\t%-15s %s\n", "--conf", "print mail configuration info (e.g. require_secure_auth, smtpmailgidonly, etc.)");
# printf( "\t%-15s %s\n", "--forwards", "print forward relay statistics");
# printf( "\t%-15s %s\n", "--ignore", "ignore common statistics (e.g. cwd=/var/spool/exim)");
printf( "\t%-15s %s\n", "--limit", "limit statistics checks to n results (defaults to 10, set to 0 for no limit)");
printf( "\t%-15s %s\n", "--logdir", "specify an alternative logging directory, (defaults to /var/log)");
printf( "\t%-15s %s\n", "--maillog", "check maillog for common errors");
printf( "\t%-15s %s\n", "--queue", "print exim queue length");
# printf( "\t%-15s %s\n", "--quiet", "only print alarming information or statistics (requires --threshold)");
printf( "\t%-15s %s\n", "--rbl", "check IP's against provided blacklists(comma delimited)");
printf( "\t%-15s %s\n", "--rbllist", "list available RBL's");
printf( "\t%-15s %s\n", "--rotated", "check rotated exim logs");
printf( "\t%-15s %s\n", "--rude", "forgo nice/ionice settings");
printf( "\t%-15s %s\n", "--threshold", "limit statistics output to n threshold(defaults to 1)");
printf( "\t%-15s %s\n", "--verbose", "display all information");
die "MSP must be run as root\n" if ( $< != 0 );
print_help() if ( (!%opts) || ($opts{help}) );
conf_check() if ($opts{conf});
print_exim_queue() if ($opts{queue});
auth_check() if ($opts{auth});
maillog_check() if ($opts{maillog});
rbl_list() if ($opts{rbllist});
rbl_check($opts{rbl}) if ($opts{rbl});
print_bold_white("Checking Tweak Settings...\n");
print "--------------------------\n";
my %cpconf = get_conf( $CPANEL_CONFIG_FILE );
if ( $cpconf{'smtpmailgidonly'} ne 1 ) {
print_warn("Restrict outgoing SMTP to root, exim, and mailman (FKA SMTP Tweak) is disabled!\n");
} elsif ( $opts{verbose} ) {
print_info("Restrict outgoing SMTP to root, exim, and mailman (FKA SMTP Tweak) is enabled\n");
if ( $cpconf{'nobodyspam'} ne 1 ) {
print_warn("Prevent “nobody” from sending mail is disabled!\n");
} elsif ( $opts{verbose} ) {
print_info("Prevent “nobody” from sending mail is enabled\n");
if ( $cpconf{'popbeforesmtp'} ne 0 ) {
print_warn("Pop-before-SMTP is enabled!\n");
} elsif ( $opts{verbose} ) {
print_info("Pop-before-SMTP is disabled\n");
if ( $cpconf{'domainowner_mail_pass'} ne 0 ) {
print_warn("Mail authentication via domain owner password is enabled!\n");
} elsif ( $opts{verbose} ) {
print_info("Mail authentication via domain owner password is disabled\n");
# Check Exim Configuration
print_bold_white("Checking Exim Configuration...\n");
print "------------------------------\n";
my %exim_localopts_conf = get_conf( $EXIM_LOCALOPTS_FILE );
if ( $exim_localopts_conf{'allowweakciphers'} ne 0 ) {
print_warn("Allow weak SSL/TLS ciphers is enabled!\n");
} elsif ( $opts{verbose} ) {
print_info("Allow weak SSL/TLS ciphers is disabled\n");
if ( $exim_localopts_conf{'require_secure_auth'} ne 1 ) {
print_warn("Require clients to connect with SSL or issue the STARTTLS is disabled!\n");
} elsif ( $opts{verbose} ) {
print_info("Require clients to connect with SSL or issue the STARTTLS is enabled\n");
if ( $exim_localopts_conf{'systemfilter'} ne q{/etc/cpanel_exim_system_filter} ) {
print_warn("Custom System Filter File in use: $exim_localopts_conf{'systemfilter'}\n");
} elsif ( $opts{verbose} ) {
print_info("System Filter File is set to the default path: $exim_localopts_conf{'systemfilter'}\n");
# Check Dovecot Configuration
print_bold_white("Checking Dovecot Configuration...\n");
print "---------------------------------\n";
my $dovecot = Cpanel::AdvConfig::dovecot::get_config();
if ( $dovecot->{'protocols'} !~ m/imap/ ) {
print_warn("IMAP Protocol is disabled!\n");
if ( $dovecot->{'disable_plaintext_auth'} !~ m/no/ ) {
print_warn("Allow Plaintext Authentication is enabled!\n");
} elsif ( $opts{verbose} ) {
print_info("Allow Plaintext Authentication is disabled\n");
my @auth_local_user_hits;
# Exim regex search strings
my $auth_password_regex = qr{\sA=dovecot_(login|plain):([^\s]+)\s};
my $auth_sendmail_regex = qr{\scwd=([^\s]+)\s};
my $auth_local_user_regex = qr{\sU=([^\s]+)\s.*B=authenticated_local_user};
my $subject_regex = qr{\s<=\s.*T="([^"]+)"\s};
print_bold_white("Checking Mail Authentication statistics...\n");
print "------------------------------------------\n";
# Set logdir, ensure trailing slash, and bail if the provided logdir doesn't exist:
my $logdir = ($opts{logdir}) ? ($opts{logdir}) : $LOGDIR;
print_warn("$opts{logdir}: No such file or directory. Skipping spam check...\n\n");
for my $file ( grep { m/^exim_mainlog/ } @{ Cpanel::FileUtils::Dir::get_directory_nodes($logdir) } ) {
if ( ( $file =~ m/mainlog-/ ) && ( $logcount ne $ROTATED_LIMIT ) ) {
push @logfiles, $file if ( $file =~ m/mainlog$/ );
print_warn("Safeguard triggered... --rotated is limited to $ROTATED_LIMIT logs\n") if ( $logcount eq $ROTATED_LIMIT );
# Bail if we can't find any logs
return print_warn("Bailing, no exim logs found...\n\n") if (!@logfiles);
my %cpconf = get_conf( $CPANEL_CONFIG_FILE );
if ( ( !$opts{rude} ) && ( Cpanel::IONice::ionice( 'best-effort', exists $cpconf{'ionice_import_exim_data'} ? $cpconf{'ionice_import_exim_data'} : 6 ) ) ) {
print("Setting I/O priority to reduce system load: " . Cpanel::IONice::get_ionice() . "\n\n");
lOG: for my $log ( @logfiles ) {
if ( $log =~ /[.]gz$/ ) {
my @cmd = ( qw{ gunzip -c -f }, $logdir . $log );
if ( !open $fh, '-|', @cmd ) {
print_warn("Skipping $logdir/$log: Cannot open pipe to read stdout from command '@{ [ join ' ', @cmd ] }' : $!\n");
if ( !open $fh, '<', $logdir . $log ) {
print_warn("Skipping $logdir/$log: Cannot open for reading $!\n");
while ( my $block = Cpanel::IO::read_bytes_to_end_of_line( $fh, 65_535 ) ) {
foreach my $line ( split( m{\n}, $block ) ) {
push @auth_password_hits, $2 if ($line =~ $auth_password_regex);
push @auth_sendmail_hits, $1 if ($line =~ $auth_sendmail_regex);
push @auth_local_user_hits, $1 if ($line =~ $auth_local_user_regex);
push @subject_hits, $1 if ($line =~ $subject_regex);
print_bold_white("Emails sent via Password Authentication:\n");
if (@auth_password_hits) {
sort_uniq(@auth_password_hits);
print_bold_white("Directories where email was sent via sendmail/script:\n");
if (@auth_sendmail_hits) {
sort_uniq(@auth_sendmail_hits);
print_bold_white("Users who sent mail via local SMTP:\n");
if (@auth_local_user_hits) {
sort_uniq(@auth_local_user_hits);
print_bold_white("Subjects by commonality:\n");
sort_uniq(@subject_hits);
# Print exim queue length
print_bold_white("Exim Queue: ");
my $queue = get_exim_queue();
print_bold_red("$queue\n");
print_bold_green("$queue\n");
my $queue = timed_run_trap_stderr( 10, 'exim', '-bpc');
my @rbls = split( /,/, $rbls);
# Fetch IP's... should we only check mailips? this is more thorough...
# could ignore local through bogon regex?
return unless my $ips = get_ips();
# Uncomment the following for testing positive hits
# push @$ips, qw{ 127.0.0.2 };
# In cPanel 11.84, we switched to the libunbound resolver
my ($cp_numeric_version, $cp_original_version) = get_cpanel_version();
my $libunbound = (version_compare($cp_numeric_version, qw( < 11.84))) ? 0 : 1;
# If "all" is found in the --rbl arg, ignore rest, use default rbl list
# maybe we should append so that user can specify all and ones which are not included in the list?
@rbls = @RBLS if (grep { /\ball\b/i } @rbls);
print_bold_white("Checking IP's against RBL's...\n");
print "------------------------------\n";
my $ip_rev = join('.', reverse split('\.', $ip));
foreach my $rbl (@rbls) {
printf("\t%-25s ", $rbl);
$result = dns_query("$ip_rev.$rbl", 'A')->[0] || 0;
# This uses libunbound, which will return an aref, but we can always expect just one result here
$result = dns_query_pre_84("$ip_rev.$rbl", 'A') || 0;
if ( $result =~ /\A 127\.0\.0\./xms ) {
print_bold_red("LISTED\n");
print_bold_green("GOOD\n");
print_bold_white("Available RBL's:\n");
print "----------------\n";
foreach my $rbl (@RBLS) {
my $out_of_memory_regex = qr{lmtp\(([\w\.@]+)\): Fatal: \S+: Out of memory};
my $time_backwards_regex = qr{Fatal: Time just moved backwards by \d+ \w+\. This might cause a lot of problems, so I'll just kill myself now};
my $quotactl_failed_regex = qr{quota-fs: (quotactl\(Q_X?GETQUOTA, [\w/]+\) failed: .+)};
my $ioctl_failed_regex = qr{quota-fs: (ioctl\([\w/]+, Q_QUOTACTL\) failed: .+)};
my $invalid_nfs_regex = qr{quota-fs: (.+ is not a valid NFS device path)};
my $unrespponsive_rpc_regex = qr{quota-fs: (could not contact RPC service on .+)};
my $rquota_remote_regex = qr{quota-fs: (remote( ext)? rquota call failed: .+)};
my $rquota_eacces_regex = qr{quota-fs: (permission denied to( ext)? rquota service)};
my $rquota_compile_regex = qr{quota-fs: (rquota not compiled with group support)};
my $dovecot_compile_regex = qr{quota-fs: (Dovecot was compiled with Linux quota .+)};
my $unrec_code_regex = qr{quota-fs: (unrecognized status code .+)};
my $pyzor_timeout_regex = qr{Timeout: Did not receive a response from the pyzor server public\.pyzor\.org};
my $pyzor_unreachable = 0;
my $pyzor_unreachable_regex = qr{pyzor: check failed: Cannot connect to public.pyzor.org:24441: IO::Socket::INET: connect: Network is unreachable};
print_bold_white("Checking Maillog for common errors...\n");
print "-----------------------------------------\n";
# Set logdir, ensure trailing slash, and bail if the provided logdir doesn't exist:
my $logdir = ($opts{logdir}) ? ($opts{logdir}) : $LOGDIR;
print_warn("$opts{logdir}: No such file or directory. Skipping spam check...\n\n");
for my $file ( grep { m/^maillog/ } @{ Cpanel::FileUtils::Dir::get_directory_nodes($logdir) } ) {
if ( ( $file =~ m/maillog-/ ) && ( $logcount ne $ROTATED_LIMIT ) ) {
push @logfiles, $file if ( $file =~ m/maillog$/ );
print_warn("Safeguard triggered... --rotated is limited to $ROTATED_LIMIT logs\n") if ( $logcount eq $ROTATED_LIMIT );
# Bail if we can't find any logs
return print_warn("Bailing, no maillog found...\n\n") if (!@logfiles);
my %cpconf = get_conf( $CPANEL_CONFIG_FILE );
if ( ( !$opts{rude} ) && ( Cpanel::IONice::ionice( 'best-effort', exists $cpconf{'ionice_import_exim_data'} ? $cpconf{'ionice_import_exim_data'} : 6 ) ) ) {
print("Setting I/O priority to reduce system load: " . Cpanel::IONice::get_ionice() . "\n\n");
lOG: for my $log ( @logfiles ) {
if ( $log =~ /[.]gz$/ ) {
my @cmd = ( qw{ gunzip -c -f }, $logdir . $log );
if ( !open $fh, '-|', @cmd ) {
print_warn("Skipping $logdir/$log: Cannot open pipe to read stdout from command '@{ [ join ' ', @cmd ] }' : $!\n");
if ( !open $fh, '<', $logdir . $log ) {
print_warn("Skipping $logdir/$log: Cannot open for reading $!\n");
while ( my $block = Cpanel::IO::read_bytes_to_end_of_line( $fh, 65_535 ) ) {
foreach my $line ( split( m{\n}, $block ) ) {
push @out_of_memory, $1 if ($line =~ $out_of_memory_regex);
push @quota_failed, $1 if ($line =~ $quotactl_failed_regex);
++$pyzor_timeout if ($line =~ $pyzor_timeout_regex);
print_bold_white("LMTP quota issues:\n");
sort_uniq(@quota_failed);
print_bold_white("Email accounts triggering LMTP Out of memory:\n");
sort_uniq(@out_of_memory);
print_bold_white("Timeouts to public.pyzor.org:24441:\n");
if ($pyzor_timeout ne 0) {
print "Pyzor timed out $pyzor_timeout times\n";
# example: return if version_compare($ver_string, qw( >= 1.2.3.3 ));
# Must be no more than four version numbers separated by periods and/or underscores.
my ( $ver1, $mode, $ver2 ) = @_;
return if ( !defined($ver1) || ( $ver1 =~ /[^\._0-9]/ ) );
return if ( !defined($ver2) || ( $ver2 =~ /[^\._0-9]/ ) );
# Shamelessly copied the comparison logic out of Cpanel::Version::Compare
return if $_[0] eq $_[1];
return _version_cmp(@_) > 0;
return if $_[0] eq $_[1];
return _version_cmp(@_) < 0;
'==' => sub { return $_[0] eq $_[1] || _version_cmp(@_) == 0; },
'!=' => sub { return $_[0] ne $_[1] && _version_cmp(@_) != 0; },
return 1 if $_[0] eq $_[1];
return _version_cmp(@_) >= 0;
return 1 if $_[0] eq $_[1];
return _version_cmp(@_) <= 0;
return if ( !exists $modes{$mode} );
return $modes{$mode}->( $ver1, $ver2 );