#!/usr/bin/perl


$ENV{PATH} = "/usr/sbin:/usr/bin:/sbin:/bin";
use constant IPSET => "/usr/sbin/ipset";

my $RANGE = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])';
my $IPREGEX = "($RANGE\.$RANGE\.$RANGE\.$RANGE)";

use strict;
use IO::Socket;
use IO::Socket::INET;
use IO::Select;
use Sys::Syslog;

my $geoip;
if (eval { require Geo::IP }) {
    Geo::IP->import;
    $geoip = Geo::IP->new(GEOIP_MEMORY_CACHE());
}

my %opt = (
    progname => "syslog-blacklist",
    listen => "127.0.0.1:2222",
    pidfile => "/var/run/syslog-blacklist.pid",
    blacklist => "blacklist", # name of ipset table
    blocked => "1h",
    skipcountry => "",
    config => "/etc/syslog-blacklist.conf",
    logfile => undef,
    );

my %flag = (
    daemon => 1,
    debug => 0, 
    trace => 0,
   );

my $running = 1;
my $reload = 0;

my %blocks;

my @rules;
my @skipCountries;

while(@ARGV) {
    my $arg = shift(@ARGV);
    if($arg =~ m/^--(progname|listen|pidfile|blacklist|blocked|config|logfile|skipcountry)=(.*)$/) {
	$opt{$1} = $2;
    } elsif($arg =~ m/^--no-(pidfile)$/) {
	undef $opt{$1};
    } elsif($arg =~ m/^--(no-)?(daemon|debug|trace)$/) {
	$flag{$2} = not $1;
    } else {
	die(<<USAGE);
Usage: $0 [--progname=] [--listen=127.0.0.1:2222] [--pidfile=] [--logfile=]
    [--blacklist=] [--blocked=] [--config=] [--no-daemon ] [--debug] [--trace]
 --progname=  is name for syslogging
 --listen=    port to listen for udp packages on
 --pidfile=   where to store pid
 --blacklist= name of ipset table to store blacklistings in
 --blocked=   host log to be blacklisted for (in sec.)
 --skipcountry=	list of GeoIP country codes to skip blocking of
 --config=    rules file
 --no-daemon  run in the foreground
 --debug      print log to stderr
USAGE
    }
}

sub blocktime ( $ ) {
    my($blocked) = @_;
    if($blocked =~ m/^(\d+)$/i) {
        $blocked = $1;
    } elsif($blocked =~ m/^(\d+)s$/i) {
        $blocked = $1;
    } elsif($blocked =~ m/^(\d+)m$/i) {
        $blocked = 60 * $1;
    } elsif($blocked =~ m/^(\d+)h$/i) {
        $blocked = 60 * 60 * $1;
    } else {
        die("Invalid blocked\n");
    }
    return $blocked;
}

$opt{blocked} = blocktime $opt{blocked};

if ($opt{skipcountry}) {
	push (@skipCountries, split(/\s*,\s*/, $opt{skipcountry}));
}

#
# Logfile
#
sub logfile() {
    if(defined $opt{logfile}) {
	die("Cannot open logfile: $opt{logfile}: $!\n")
	    unless(open(STDERR, "+>>", $opt{logfile}));
    }
}
logfile();

sub read_config() {
    my $rules = [];

    die("Cannot open configfile: $opt{config}: $!\n")
	unless(open(CONFIG, $opt{config}));
    my $scope = "";
    my $blocked = 0;
    while($_ = <CONFIG>) {
	s/^\s+//ms;
	s/\s+$//ms;
	next if(m/^(#.*)?$/);
	if(m/^\[(.*)\]$/) {
	    $scope = $1;
	    $blocked = $opt{blocked};
	} elsif(/^\d+[hms]?$/i) {
	    $blocked = blocktime $_;
	} elsif($scope) {
	    my($pre,$post) = split(/\{IP_NUMBER\}/, $_, 2);
	    my $o = $pre;
	    $o =~ s/\\././g;
	    $o = 0 + $o =~ m/\((?!=)/g ;
	    my $re = eval { qr{$pre$IPREGEX$post} };
	    die("Cannot parse: $pre.$IPREGEX.$post Reason: $@\n") if($@);
	    push(@$rules, {regex => $re, offset => $o, scope => $scope, timeout => $blocked});
	    print(STDERR "$re => $scope\n") if($flag{trace});
	} else {
	    die("Configfile should start with a [...] scope\n");
	}
    }
    close(CONFIG);

    die("No rules defined in configfile: $opt{config}\n") unless(@$rules);
    @rules = @$rules;
}

# return true if ip is in country whitelist
# return false if geoip support is not present
sub country_whitelist {
    my ($ip) = @_;
    return 0 unless $geoip;
    my $country = $geoip->country_code_by_addr($ip);
    return grep { $_ eq $country } @skipCountries;
}

read_config();

use constant MONTH => qw( Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec );

sub timestamp() {
    my($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime();
    return  sprintf("%s %2d %02d:%02d:%02d",
		    (MONTH)[$mon], $mday, $hour, $min, $sec);
}


#
# Open pidfile
#
if(defined $opt{pidfile}) {
    die("Cannot open pidfile: $opt{pidfile}: $!\n")
	unless(open(PID, ">", $opt{pidfile}));
    truncate(PID, 0);
}


#
# Daemonize
#
if($flag{daemon}) {
    die("Cannot create pipe: $!\n")
	unless(pipe(R, W));

    my $pid = fork();
    die if(not defined $pid);
    if($pid) {
	close(PID);
	close(W);
	$_ = <R>;
	close(R);
	unless(defined $_) {
	    unlink($opt{pidfile});
	    exit(1);
	}
	exit(0);
    }

    # Child
    close(R);
}



my $sock = new IO::Socket::INET(Proto => "udp",
                                LocalAddr => $opt{listen},
                                ReuseAddr => 1);

die("Create udp socket: $!\n") unless($sock);
die("Nonblocking socket: $!\n") unless($sock->blocking(0));
printf(STDERR "Listening\n") if($flag{trace});

openlog($opt{progname}, "ndelay,pid", "local0");


#
# Write Pid
#
if(defined $opt{pidfile}) {
    print(PID $$,"\n");
    close(PID);
}


#
# Daemon success
#
if($flag{daemon}) {
    print(W "OK");
    close(W);
}

$SIG{HUP} = sub {
    $reload = 1;
};
$SIG{INTR} = $SIG{TERM} = sub {
    $running = 0;
};

#
# Main loop
#
while($running) {
    if($reload) {
	eval 'read_config();';
	if($@) {
	    printf(STDERR "%s: config reload error (reverting to old) %s\n", timestamp(), $@) if($flag{debug} or defined $opt{logfile});
	    syslog("LOG_WARNING", "%s: config reload error (reverting to old) %s\n", timestamp(), $@);
	}
	logfile();
	$reload = 0;
    }

    printf(STDERR "Waiting\n") if($flag{trace});
    next unless(IO::Select->new($sock)->can_read());
    printf(STDERR "Waited\n") if($flag{trace});
    my $pkg;
    next unless($sock->recv($pkg, 256, 0));
    
    print STDERR "PKG:", $pkg,"\n" if($flag{trace});
    next unless($pkg =~ m/^<\d+>\w+\s+\d+\s\d\d:\d\d:\d\d\s+\S+\s+(.*)$/);

    $_ = $1;
    print STDERR "LINE:",$_,"\n" if($flag{trace});
    for my $rule (@rules) {
	print STDERR "RULE:", $rule->{regex},"\n" if($flag{trace});
	if(my @matches = $_ =~ $rule->{regex}) {
	    my $ip = splice(@matches, $rule->{offset}, 1);
            if (country_whitelist($ip)) {
                printf(STDERR "%s: Not blacklisting %s for %s (%s) - ip belongs to whitelist country\n",
                       timestamp(), $ip, $rule->{scope}, join(', ', @matches)) if($flag{debug} or defined $opt{logfile});
                syslog("LOG_INFO", "Not blacklisting %s for %s (%s) - it belongs to whitelist country", $ip, $rule->{scope}, join(', ', @matches));
                next;
            }

	    printf(STDERR "%s: blacklisting %s for %s (%s)\n",
		   timestamp(), $ip, $rule->{scope}, join(', ', @matches)) if($flag{debug} or defined $opt{logfile});
	    syslog("LOG_INFO", "blacklisting %s for %s (%s)", $ip, $rule->{scope}, join(', ', @matches));
	    if($rule->{timeout}) {
		if(system(IPSET, "--add", $opt{blacklist}, $ip, "timeout", $rule->{timeout})) {
		    syslog("LOG_WARNING", "blacklisting %s for %s: %s", $ip, $rule->{scope}, $!);
		}
	    }
	    last;
	}
    }
}

$sock->close();

if(defined $opt{pidfile}) {
    unlink($opt{pidfile});
}
exit(0);

__END__

=pod

=head1  NAME

syslog-blacklist - logging intrusion attempts

=head1 SYNOPSIS

syslog-blacklist [--progname=] [--listen=ip:port] [--pidfile=]
    [--logfile=] [--blacklist=] [--blocked=] [--config=] [--no-daemon ]
    [--debug] [--trace]

=head1 DESCRIPTION

A tool mimicing a syslog daemon, which based on patternmatching (using regexp) stores ip addresses in ipset(1) sets with a timeout, used for blocking intrusion attepmts.

The options are as follows:

--progname= name for syslogging

--listen= port to listen for udp packages on (default: 127.0.0.1:2222)

--pidfile= where to store pid

--blacklist= name of ipset table to store blacklistings in

--blocked= host log to be blacklisted for (in sec.)

--config= rules file

--no-daemon run in the foreground

--debug print log to stderr

--trace also log network packages print log to stderr

The rules file has the following syntax:

[rule name]
timeout
pattern
pattern?

The rule name is used for logging.

The timeout can be suffixed by 's', 'm' or 'h' telling if its measured in seconds minutes or hours.

The pattern(s) contain {IP_NUMBER} to match an ipv4 number to block.


=head1 SEE ALSO

ipset(1), iptables(1)


=head1 FILES

B</etc/syslog-blacklist.conf>

=head1 AUTHOR

Morten Bogeskov <source.bogeskov.dk>
