Thuban
2017-03-29 3a37e640ba952bbaf726b38077f8d0adf6850fec
major update, better init script
4 files added
3 files modified
524 ■■■■■ changed files
Makefile 36 ●●●●● patch | view | raw | blame | history
README.md 18 ●●●●● patch | view | raw | blame | history
vilain 188 ●●●●● patch | view | raw | blame | history
vilain.1 41 ●●●●● patch | view | raw | blame | history
vilain.conf 34 ●●●●● patch | view | raw | blame | history
vilain.py 194 ●●●●● patch | view | raw | blame | history
vilain.rc 13 ●●●●● patch | view | raw | blame | history
Makefile
New file
@@ -0,0 +1,36 @@
# vilain - anti-bruteforce for OpenBSD
# See LICENSE file for copyright and license details.
#
# vilain version
VERSION = 0.3
# Customize below to fit your system
# paths
PREFIX = /usr/local
MANPREFIX = ${PREFIX}/man/man1/
install:
    @echo installing executable file to ${DESTDIR}${PREFIX}/bin
    @mkdir -p ${DESTDIR}${PREFIX}/bin
    @cp -f vilain ${DESTDIR}${PREFIX}/bin
    @echo installing script file to ${DESTDIR}${PREFIX}/sbin
    @cp -f vilain.py ${DESTDIR}${PREFIX}/sbin
    @chmod 755 ${DESTDIR}${PREFIX}/bin/vilain
    @chmod 644 ${DESTDIR}${PREFIX}/sbin/vilain.py
    @echo installing init script in /etc/rc.d
    @cp -f vilain.rc /etc/rc.d/vilain
    @chmod 755 /etc/rc.d/vilain
    @echo installing manual page to ${DESTDIR}${MANPREFIX}/man1
    @mkdir -p ${DESTDIR}${MANPREFIX}/
    @cp -f vilain.1 ${DESTDIR}${MANPREFIX}/vilain.1
    @chmod 644 ${DESTDIR}${MANPREFIX}/vilain.1
uninstall:
    @echo removing executable file from ${DESTDIR}${PREFIX}/bin
    @rm -f ${DESTDIR}${PREFIX}/bin/vilain
    @rm -f ${DESTDIR}${PREFIX}/sbin/vilain.py
    @echo removing manual page to ${DESTDIR}${MANPREFIX}/
    @rm -f ${DESTDIR}${MANPREFIX}/vilain.1
.PHONY: install uninstall
README.md
@@ -1,15 +1,9 @@
# vilain
Mimic fail2ban with pf for OpenBSD.
Inspired from http://www.vincentdelft.be/post/post_20161106
This repository is just for work.
See here for last vilain "stable" version : http://git.yeuxdelibad.net/vilain/
In pf.conf, add :
In pf.conf, add according to your configuration :
    table <vilain_bruteforce> persist
    block quick from <vilain_bruteforce> 
@@ -23,4 +17,14 @@
    pfctl -t vilain_bruteforce -T show
To start vilain at boot, add this in ``/etc/rc.local``
```
/usr/bin/tmux new -s vilain -d /usr/local/bin/vilain
```
Then, to attach to the tmux session, run :
```
tmux a -t vilain
```
vilain
@@ -1,179 +1,9 @@
#!/usr/bin/env python3.4
# -*- coding:Utf-8 -*-
"""
Author :      thuban <thuban@yeuxdelibad.net>
Licence :     MIT
Description : Mimic fail2ban with pf for OpenBSD.
              Inspired from http://www.vincentdelft.be/post/post_20161106
              In pf.conf, add :
                    table <vilain_bruteforce> persist
                    block quick from <vilain_bruteforce>
              You might want to add a cron task to remove old banned IP. As example, to ban for one day max :
                    pfctl -t vilain_bruteforce -T expire 86400
              To see banned IP :
                    pfctl -t vilain_bruteforce -T show
"""
import sys
import os
import configparser
import re
import time
import logging
import subprocess
from multiprocessing import Process, Queue, TimeoutError
configfile = "/etc/vilain.conf"
version = "0.1"
vilain_table = "vilain_bruteforce"
logfile = "/var/log/daemon"
if os.geteuid() != 0:
    print("Only root can use this tool")
    sys.exit()
# Configure logging
logger = logging.getLogger(__name__)
logging.basicConfig(filename=logfile,
                    format='%(asctime)s %(module)s:%(funcName)s:%(message)s',
                    datefmt='%H:%M:%S')
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler(sys.stdout)
logger.addHandler(ch)
# functions
def readconfig():
    if not os.path.isfile(configfile):
        logging.error("Can't read config file, exiting...")
        sys.exit(1)
    config = configparser.ConfigParser()
    config.read(configfile)
    return(config)
def load_config():
    c = readconfig()
    d = c.defaults()
    watch_while = int(d['watch_while'])
    maxtries = int(d['maxtries'])
    vilain_table = d['vilain_table']
    return(watch_while, maxtries, vilain_table)
def load_sections():
    c = readconfig()
    for s in c.sections():
        logfile = c.get(s,'logfile')
        regex = c.get(s,'regex')
        d = {'name' : s, 'logfile':logfile, 'regex':regex}
        yield d
def check_logs(logfile, regex, bad_ip_queue):
    """
    worker who put in bad_ip_queue bruteforce IP
    """
    if not os.path.isfile(logfile) :
        logger.warning("{} doesn't exist".format(logfile))
        return
    # Watch the file for changes
    stat = os.stat(logfile)
    size = stat.st_size
    mtime = stat.st_mtime
    RE = re.compile(regex)
    while True:
        time.sleep(0.5)
        stat = os.stat(logfile)
        if mtime < stat.st_mtime:
            mtime = stat.st_mtime
            with open(logfile, "rb") as f:
                f.seek(size)
                lines = f.readlines()
                ul = [ u.decode() for u in lines ]
                line = "".join(ul).strip()
                ret = RE.match(line)
                if ret:
                    bad_ip = ret.groups()[0]
                    bad_ip_queue.put(bad_ip)
            size = stat.st_size
def ban_ips(bad_ip_queue, watch_while, maxtries, vilain_table):
    """
    worker who ban IP on bad_ip_queue
    add IP in bad_ips_list
    record time when this IP has been seen in ip_seen_at = { ip:time }
    check number of occurence of the same ip in bad_ips_list
    if more than 3 : ban and clean of list
    check old ip in ip_seen_at : remove older than watch_while
    """
    bad_ips_list = []
    ip_seen_at = {}
    while True:
        try:
            ip = bad_ip_queue.get(0.5)
            logger.info("{} detected".format(ip))
            bad_ips_list.append(ip)
            ip_seen_at[ip] = time.time()
            n_ip = bad_ips_list.count(ip)
            if n_ip >= maxtries:
                logger.info("Blacklisting {}".format(ip))
                subprocess.call(["pfctl", "-t", vilain_table, "-T", "add", ip])
                ip_seen_at.pop(ip)
                while ip in bad_ips_list:
                    bad_ips_list.remove(ip)
            to_remove = []
            for recorded_ip, last_seen in ip_seen_at.items():
                if time.time() - last_seen >= watch_while:
                    logger.info("{} not seen since a long time, forgetting...".format(recorded_ip))
                    to_remove.append(recorded_ip)
            for i in to_remove:
                ip_seen_at.pop(i)
        except TimeoutError:
            pass
        except KeyboardInterrupt:
            sys.exit(0)
def main():
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    watch_while, maxtries, vilain_table = load_config()
    bad_ip_queue = Queue()
    workers = []
    for entry in load_sections():
        logger.info("Start vilain for {}".format(entry['name']))
        worker = Process(target=check_logs,
                        args=(entry['logfile'], entry['regex'], bad_ip_queue))
        workers.append(worker)
        worker.daemon = True
        worker.start()
    ban_worker = Process(target=ban_ips, args=(bad_ip_queue, watch_while, maxtries))
    workers.append(ban_worker)
    ban_worker.daemon = True
    ban_worker.start()
    for w in workers:
        w.join()
    return 0
if __name__ == '__main__':
    main()
# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
#!/bin/sh
PYTHONVERSION=$(ls -l /usr/local/bin/python3.* |grep -Eo "3\.[0-9]" |tail -n1)
PYTHON="/usr/local/bin/python$PYTHONVERSION"
if [ -x $PYTHON ]; then
    $PYTHON /usr/local/sbin/vilain.py >> /var/log/daemon 2>&1 &
else
    echo "Error : no python3 executable found"
fi
exit
vilain.1
New file
@@ -0,0 +1,41 @@
.
.TH vilain 28 "March 2017" "" "Fail2ban-like for OpenBSD"
.SH NAME
vilain \- fail2ban-like for OpenBSD
.
.SH SYNOPSIS
.RS
To use vilain :
  1. Copy configuration file in /etc/vilain.conf and configure according to your needs.
  2. Configure pf according to the example below
  3. start vilain via rcctl
You only need python >= 3.5 to use it.
.RE
.SH DESCRIPTION
.RS
In pf.conf, add according to your configuration :
    table <vilain_bruteforce> persist
    block quick from <vilain_bruteforce>
You might want to add a cron task to remove old banned IP. As example, to ban for one day max :
    pfctl -t vilain_bruteforce -T expire 86400
To see banned IP :
    pfctl -t vilain_bruteforce -T show
.RE
.SH LOGS
.RS
Watch vilain logs in /var/log/daemon.
.RE
.SH BUGS
.RS
Please let me know : <thuban@yeuxdelibad.net>
.RE
vilain.conf
@@ -2,12 +2,17 @@
# 24h + 5min
# Time to keep banned a bad ip
watch_while = 86700 
# Max tries before being bannes
# Max tries before being banned
maxtries = 3
# pf table to keep bad IP.
# remember to clean the table with this command in a cron job :
#     pfctl -t vilain_bruteforce -T expire 86400
#     /sbin/pfctl -t vilain_bruteforce -T expire 86400
vilain_table = vilain_bruteforce
### Ip ignored ###
[ignoreip]
ip1 = 92.150.160.157
ip2 = 92.150.160.156
### Guardians
#[name of the guardian]
@@ -22,3 +27,28 @@
logfile = /var/log/authlog
regex = .* Connection closed by ([\S]+) .*
#[http404]
#logfile = /var/www/logs/access.log
#regex = (?:\S+\s){1}(\S+).*\s404\s.*
[http401]
logfile = /var/www/logs/access.log
regex = (?:\S+\s){1}(\S+).*\s401\s.*
[http403]
logfile = /var/www/logs/access.log
regex = (?:\S+\s){1}(\S+).*\s403\s.*
[smtp]
logfile = /var/log/maillog
regex = .* event=failed-command address=([\S]+) .*
[dovecot]
logfile = /var/log/maillog
regex = .*auth failed.*rip=([\S]+),.*
[wordpress]
# don't use if you have wordpress
logfile = /var/www/logs/access.log
regex = (?:\S+\s){1}(\S+).*wp-login.php.*
vilain.py
New file
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
# -*- coding:Utf-8 -*-
"""
Author :      thuban <thuban@yeuxdelibad.net>
Licence :     MIT
Require : python >= 3.5
Description : Mimic fail2ban with pf for OpenBSD.
              Inspired from http://www.vincentdelft.be/post/post_20161106
              In pf.conf, add :
                    table <vilain_bruteforce> persist
                    block quick from <vilain_bruteforce>
              You might want to add a cron task to remove old banned IP. As example, to ban for one day max :
                    pfctl -t vilain_bruteforce -T expire 86400
              To see banned IP :
                    pfctl -t vilain_bruteforce -T show
"""
import sys
import os
import configparser
import re
import logging
import subprocess
import asyncio
import time
from multiprocessing import Process
configfile = "/etc/vilain.conf"
version = "0.3"
vilain_table = "vilain_bruteforce"
logfile = "/var/log/daemon"
sleeptime = 0.5
if os.geteuid() != 0:
    print("Only root can use this tool")
    sys.exit()
# Configure logging
logger = logging.getLogger(__name__)
logging.basicConfig(filename=logfile,
                    format='%(asctime)s %(module)s:%(funcName)s:%(message)s',
                    datefmt='%H:%M:%S')
logger.setLevel(logging.DEBUG)
ch = logging.StreamHandler(sys.stdout)
logger.addHandler(ch)
# functions
def readconfig():
    if not os.path.isfile(configfile):
        logging.error("Can't read config file, exiting...")
        sys.exit(1)
    config = configparser.ConfigParser()
    config.read(configfile)
    return(config)
def load_config():
    c = readconfig()
    d = c.defaults()
    watch_while = int(d['watch_while'])
    maxtries = int(d['maxtries'])
    vilain_table = d['vilain_table']
    if c.has_section('ignoreip'):
        ignoreip = [ i[1] for i in c.items('ignoreip') if i[0] not in c.defaults()]
    else:
        ignoreip = []
    return(watch_while, maxtries, vilain_table, ignoreip)
def load_sections():
    c = readconfig()
    for s in c.sections():
        if c.has_option(s,'logfile'):
            logfile = c.get(s,'logfile')
            regex = c.get(s,'regex')
            d = {'name' : s, 'logfile':logfile, 'regex':regex}
            yield d
class Vilain():
    def __init__(self):
        self.loop = asyncio.get_event_loop()
        self.watch_while, self.maxtries, self.vilain_table, self.ignore_ip = load_config()
        #self.bad_ip_queue = []
        self.bad_ip_queue = asyncio.Queue(loop=self.loop)
        for entry in load_sections():
            logger.info("Start vilain for {}".format(entry['name']))
            asyncio.ensure_future(self.check_logs(entry['logfile'], entry['regex'], entry['name']))
        asyncio.ensure_future(self.ban_ips())
    def start(self):
        try:
            self.loop.run_forever()
        except KeyboardInterrupt:
            self.loop.close()
        finally:
            self.loop.close()
    async def check_logs(self, logfile, regex, reason):
        """
        worker who put in bad_ip_queue bruteforce IP
        """
        if not os.path.isfile(logfile) :
            logger.warning("{} doesn't exist".format(logfile))
        else :
            # Watch the file for changes
            stat = os.stat(logfile)
            size = stat.st_size
            mtime = stat.st_mtime
            RE = re.compile(regex)
            while True:
                await asyncio.sleep(sleeptime)
                stat = os.stat(logfile)
                if mtime < stat.st_mtime:
                    mtime = stat.st_mtime
                    with open(logfile, "rb") as f:
                        f.seek(size)
                        lines = f.readlines()
                        ul = [ u.decode() for u in lines ]
                        line = "".join(ul).strip()
                        ret = RE.match(line)
                        if ret:
                            bad_ip = ret.groups()[0]
                            if bad_ip not in self.ignore_ip :
                                #self.bad_ip_queue.append({'ip' : bad_ip, 'reason' : reason})
                                await self.bad_ip_queue.put({'ip' : bad_ip, 'reason' : reason})
                    size = stat.st_size
    async def ban_ips(self):
        """
        worker who ban IP on bad_ip_queue
        add IP in bad_ips_list
        record time when this IP has been seen in ip_seen_at = { ip:time }
        check number of occurence of the same ip in bad_ips_list
        if more than 3 : ban and clean of list
        check old ip in ip_seen_at : remove older than watch_while
        """
        bad_ips_list = []
        ip_seen_at = {}
        while True:
            await asyncio.sleep(sleeptime)
            #if not len(s#elf.bad_ip_queue) > 0:
            #    continue
            ip_item = await self.bad_ip_queue.get()
            #ip_item = self.bad_ip_queue.pop()
            ip = ip_item['ip']
            reason = ip_item['reason']
            logger.info("{} detected, reason {}".format(ip, reason))
            bad_ips_list.append(ip)
            ip_seen_at[ip] = time.time()
            n_ip = bad_ips_list.count(ip)
            if n_ip >= self.maxtries:
                logger.info("Blacklisting {}".format(ip))
                subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip])
                ip_seen_at.pop(ip)
                while ip in bad_ips_list:
                    bad_ips_list.remove(ip)
            to_remove = []
            for recorded_ip, last_seen in ip_seen_at.items():
                if time.time() - last_seen >= self.watch_while:
                    logger.info("{} not seen since a long time, forgetting...".format(recorded_ip))
                    to_remove.append(recorded_ip)
            for i in to_remove:
                ip_seen_at.pop(i)
def main():
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    v = Vilain()
    p = Process(target=v.start())
    p.start()
    return 0
if __name__ == '__main__':
    main()
# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
vilain.rc
New file
@@ -0,0 +1,13 @@
#!/bin/sh
#
# $OpenBSD: vilain.rc,v 1.0 2017/03/28 17:58:46 Thuban$
daemon="/usr/local/bin/vilain"
. /etc/rc.d/rc.subr
pexp="python3.*vilain\.py"
rc_reload=NO
rc_cmd $1