#!/usr/bin/perl -w

# This is BlockSSHD which protects computers from SSH brute force attacks by
# dynamically blocking IP addresses using iptables based on log entries.
# BlockSSHD is modified from BruteForceBlocker v1.2.3 by Daniel Gerzo
 
# Copyright (C) 2006, James Turnbull
# Support for pf added by Anton - valqk@webreality.org - http://www.webreality.org

# 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

use strict;
use warnings;

use Sys::Syslog;
use Sys::Hostname;
use Tie::File;
use File::Tail;
use Net::DNS::Resolver;
use Getopt::Long;

use POSIX qw(setsid);
use vars qw($opt_d $opt_h $opt_v $opt_stop);

$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin';

my $version = "0.8";

our $cfg;

# This is where the configuration file is located
 require '/usr/local/etc/blocksshd.conf';

my $work = {
        ipv4            => '(?:\d{1,3}\.){3}\d{1,3}',   # regexp to match ipv4 address
        ipv6            => '[\da-fA-F:]+',              # regexp to match ipv6 address
        fqdn            => '[\da-z\-.]+\.[a-z]{2,4}',   # regexp to match fqdn
        hostname        => hostname,                    # get hostname
};

Getopt::Long::Configure('bundling');
GetOptions
        ("start" => \$opt_d, "daemon"  => \$opt_d, "d" => \$opt_d,
         "h" => \$opt_h, "help"        => \$opt_h,
         "v" => \$opt_v, "version"     => \$opt_v,
         "stop" => \$opt_stop);

if ($opt_d) {
         if (-e $cfg->{pid_file})
            { die "BlockSSHD is already running!\n" }

         # Fork daemon   
         chdir '/' || die "Can't change directory to /: $!";
         umask 0;        
         open(STDIN,  "+>/dev/null");
         open(STDOUT, "+>&STDIN");
         open(STDERR, "+>&STDIN");
         defined(my $pid = fork) || die "Can't fork: $!";
         exit if $pid;
         setsid || die "Can't start a new session: $!";

         # Record PID
         open (PID,">$cfg->{pid_file}") || die ("Cannot open BlockSSHD PID file $cfg->{pid_file}!\n");
         print PID "$$\n";
         close PID;
}

if ($opt_stop) {
        exithandler();
}

if ($opt_h) {
        print_help();
        exit;
}

if ($opt_v) {
        print "BlockSSHD version $version\n";
        exit;
}

openlog('blocksshd', 'pid', 'auth');

my $alarmed = 0; # ALRM state
my %count = (); # hash used to store total number of failed tries
my %timea = (); # hash used to store last time when IP was active
my %timeb = (); # hash used to store time when IP was blocked
my $res   = Net::DNS::Resolver->new;

# Watch signals

$SIG{'ALRM'} = \&unblock;
$SIG{'INT'}  = \&exithandler;
$SIG{'QUIT'} = \&exithandler;
$SIG{'KILL'} = \&exithandler;
$SIG{'TERM'} = \&exithandler;

# Notify of startup

syslog('notice', "Starting BlockSSHD");

# Create iptables chain

setup();

# Clear existing rules

flush();

# Restore previously blocked rules

if($cfg->{restore_blocked} == 1) {
    restore_blocked();
}

# The core process

my $ref=tie *FH, "File::Tail", (name=>$cfg->{logfile},
                        maxinterval=>$cfg->{logcheck},
                        interval=> 10,
                        errmode=> "return");

if ( $cfg->{unblock} == 1) {
alarm( ($cfg->{unblock_timeout} /2) );
}

while (<FH>) {
    if( $alarmed ) {
        $alarmed = 0;
        next;
      }   

    if (
        /.*Failed (password) .* from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn}) port [0-9]+/i ||
        /.*(Invalid|Illegal) user .* from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn})$/i ||
        /.*Failed .* for (invalid|illegal) user * from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn}) port [0-9]+ .*/i ||
        /.*Failed .* for (invalid|illegal) user .* from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn})/i ||
        /.*(Postponed) .* for .* from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn}) port [0-9]+ .*/i ||
        /.*Did not receive (identification) string from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn})$/i ||
        /.*Bad protocol version (identification) .* from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn})$/i ||
        /.* login attempt for (nonexistent) user from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn})$/i ||
        /.* bad (password) attempt for '.*' from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn}):[0-9]+/i ||
        /.*unknown (user) .* from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn}).*/i ||
        /.*User .* (from) ($work->{ipv4}|$work->{ipv6}|$work->{fqdn}) not allowed because.*/i ||
        /.*USER.*no such (user) found from ($work->{ipv4}|$work->{ipv6}|$work->{fqdn}).*/i
       ) {
        my $IP = $2;
            if ( $IP =~ /$work->{fqdn}/i) {
                foreach my $type (qw(AAAA A)) {
                    my $query = $res->search($IP, $type);
                    if ($query) {
                        foreach my $rr ($query->answer) {
                            block($rr->address);
                        }
                    }
                }
            } else {
                block($IP);
              }
        }
}

closelog();

sub block {
    # Confirm iptables table is created
    setup();
 
    my ($IP) = shift or die "Missing IP address!\n";
    
    # check to see if IP address already blocked
    
    if($cfg->{os} eq 'linux') {
    my ($exists) = system("$cfg->{iptables} -n -L $cfg->{chain} | grep -q '$IP'");
    if ($exists == 0) {
       return;
       }
    }
    elsif($cfg->{os} eq 'bsd') {
    my ($exists) = system("$cfg->{pfctl} -t $cfg->{chain} -T show| grep -q '$IP'"); 
    if ($exists == 0) {
       return;
       }
    }
    
    # Reset IP count if timeout exceeded 
    if ($timea{$IP} && ($timea{$IP} < time - $cfg->{timeout})) {
        syslog('notice', "Resetting $IP count, since it wasn't active for more than $cfg->{timeout} seconds");
        delete $count{$IP};
    }
    $timea{$IP} = time;

    # increase the total number of failed attempts
    $count{$IP}++;

    if ($count{$IP} < $cfg->{max_attempts}+1) {
        syslog('notice', "$IP was logged with a total count of $count{$IP} failed attempts");
    }
    if ($count{$IP} >= $cfg->{max_attempts}+1) {
        syslog('notice', "IP $IP reached the maximum number of failed attempts!");
        system_block($IP);
    }
}

sub system_block {
    my $IP=shift or die("Can't find IP to block.\n");
    if (!grep { /$IP/ } @{$cfg->{whitelist}}) {
        if($cfg->{os} eq 'linux') {
            syslog('notice', "Blocking $IP in iptables table $cfg->{chain}.");
            system("$cfg->{iptables} -I $cfg->{chain} -p tcp --dport 22 -s $IP -j DROP") == 0 || syslog('notice', "Couldn't add $IP to firewall");
        }
        if($cfg->{os} eq 'bsd') {
            syslog('notice', "Blocking $IP in pf table $cfg->{chain}.");
            system("$cfg->{pfctl} -t $cfg->{chain} -T add $IP") == 0 || syslog('notice', "Couldn't add $IP to firewall");
        }
        $timeb{$IP} = time;
        # send mail if it is configured
        if ($cfg->{send_email} eq '1') {
            notify($IP);
        }
        if ($cfg->{restore_blocked} eq '1') {
            log_ip($IP);
        }
    }
}

sub setup {
         # Check and setup iptables table if missing
           if($cfg->{os} eq 'linux') {
                system("$cfg->{iptables} -L $cfg->{chain} | grep -qs '$cfg->{chain}'") == 0 ||
                system("$cfg->{iptables} -N $cfg->{chain}");
            }
}

sub flush {
         # Flush any existing firewall rules
           syslog('notice', "Flushing existing rules in $cfg->{chain}.");
           if($cfg->{os} eq 'linux') {
                system("$cfg->{iptables} -F $cfg->{chain}") == 0 || syslog('notice', "Unable to flush existing firewalls rules from $cfg->{chain}");
           } elsif($cfg->{os} eq 'bsd') {
                system("$cfg->{pfctl} -t $cfg->{chain} -T flush") == 0 || syslog('notice', "Unable to flush existing firewalls rules from $cfg->{chain}");
           } else {
                die("No operating system specified in blocksshd.conf configuration file.");
            }
}

sub unblock {
            # unblock old IPs based on timeout
            $alarmed = 1;

            if($cfg->{os} eq 'linux') {
                open IPT, "$cfg->{iptables} -n -L $cfg->{chain} |";

                     while(<IPT>) {
                     chomp;
                     next if ($_ !~ /^DROP/);
                     my ($target, $prot, $opt, $source, $dest, $prot2, $dport) = split(' ', $_);
                        while ( my ($block_ip, $block_time) = each(%timeb) ) {
                              if (($block_ip eq $source) && ($block_time < time - $cfg->{unblock_timeout})) {
                              syslog('notice', "Unblocking IP address $block_ip.");
                              system("$cfg->{iptables} -D $cfg->{chain} -p tcp --dport 22 -s $block_ip -j DROP ") == 0 || syslog('notice', "Couldn't unblock $block_ip from firewall.");
                               if( -e $cfg->{log_ips} && ((-s $cfg->{log_ips}) > 0)) {
                                 unlog_ip($block_ip);
                               }
                              delete $timeb{$block_ip};
                              delete $timea{$block_ip};
                              delete $count{$block_ip};
                              }
                         }
                     }
                     
                close IPT;
 
            } elsif($cfg->{os} eq 'bsd') {
                open IPT, "$cfg->{pfctl} -t $cfg->{chain} -T show|" || syslog('error',"Can't open $cfg->{pfctl} for reading.");
            
                     while(<IPT>) {
                     s/^\s+//;
                     my $source=$_; 
                        while ( my ($block_ip, $block_time) = each(%timeb) ) {
                              if (($block_ip eq $source) && ($block_time < time - $cfg->{unblock_timeout})) {
                              syslog('notice', "Unblocking IP address $block_ip.");
                              system("$cfg->{pfctl} -t $cfg->{chain} -T delete $block_ip") == 0 || syslog('notice', "Couldn't unblock $block_ip from firewall.");
                               if( -e $cfg->{log_ips} && ((-s $cfg->{log_ips}) > 0)) {
                                 unlog_ip($block_ip);
                               }
                              delete $timeb{$block_ip};
                              delete $timea{$block_ip};
                              delete $count{$block_ip};
                              }
                        }
                     }
                 
                close IPT;

             } else {
                die("No operating system specified in blocksshd.conf configuration file.");
             }
                   
            alarm( ($cfg->{unblock_timeout}/2) );
}

sub log_ip {
    my $IP = shift or die("Can't get IP to log!\n");
    my $inlist=0;
    if( -e $cfg->{log_ips} && ((-s $cfg->{log_ips}) > 0)) {
        open LOG,"<$cfg->{log_ips}" || die("Can't open $cfg->{log_ips}\n");
        while(<LOG>) {
            chomp;
            if($_ eq $IP) {
                $inlist=1;
                last;
            }
        }
        close LOG;
    }
    if($inlist == 0) {
        open LOG,">>$cfg->{log_ips}" || die("Can't open $cfg->{log_ips}\n");
        print LOG "$IP\n";
        close LOG;
    }
}

sub unlog_ip {
    my $block_ip = shift or die("Can't get IP to unlog!\n");
    my @file;

     if( -e $cfg->{log_ips} && ((-s $cfg->{log_ips}) > 0)) {
    
        tie @file, 'Tie::File', $cfg->{log_ips};
        @file=grep { $_ ne $block_ip } @file;
        untie @file;
    
        syslog('notice',"Removed unblocked IP address ($block_ip) from log file $cfg->{log_ips}");
     }  
}

sub restore_blocked {
    if( -e $cfg->{log_ips} && ((-s $cfg->{log_ips}) > 0)) {
        open RLOG,"<$cfg->{log_ips}" || die("Can't open $cfg->{log_ips}\n");
        while(<RLOG>) {
            chomp;
            if(/$work->{ipv4}|$work->{ipv6}|$work->{fqdn}/i) {
                syslog('notice',"Blocking IP $_ - previously blocked and saved in $cfg->{log_ips}");
                system_block($_);
            }
             else {
                syslog('notice',"Invalid IP address ($_) found in $cfg->{log_ips}");
            }
        }
        close (RLOG);
    }
}

sub notify {
    # send notification emails
    my ($IP) = shift or die "Missing IP address!\n";       
   
    syslog('notice', "Sending notification email to $cfg->{email}");
    system("printf '%b' '$work->{hostname}: BlockSSHD blocking $IP' | $cfg->{mail} -s 'BlockSSHD blocking notification' $cfg->{email}");
}

sub exithandler {
    if (-e $cfg->{pid_file})
    {
        my $pid=`/bin/cat $cfg->{pid_file}`;
        system("/bin/kill -9 $pid");
        unlink($cfg->{pid_file});
        die "BlockSSHD exiting\n";
    } else {
        die "BlockSSHD is not running!\n";
    }
}

sub print_help {
    print "BlockSSHD command line options\n";
    print "-d | --daemon | --start  Start BlockSSHD in daemon mode\n";
    print "--stop                   Stop BlockSSHD\n";
    print "-h | --help              Print this help text\n";
    print "-v | --version           Display version\n";
}
