vilain.py 9.03 KB
Newer Older
prx's avatar
prx committed
1
#!/usr/bin/env python3
prx's avatar
prx committed
2
# -*- coding:Utf-8 -*-
prx's avatar
prx committed
3 4 5


"""
prx's avatar
prx committed
6
Author :      thuban <thuban@yeuxdelibad.net>
prx's avatar
prx committed
7
              Vincent <vincent.delft@gmail.com>
prx's avatar
prx committed
8
              Yax https://blogduyax.madyanne.fr/
prx's avatar
prx committed
9 10 11 12 13 14
Licence :     MIT
Require : python >= 3.5

Description : Mimic fail2ban with pf for OpenBSD.
              Inspired from http://www.vincentdelft.be/post/post_20161106

prx's avatar
prx committed
15
              In pf.conf, add :
prx's avatar
prx committed
16
                    table <vilain_bruteforce> persist
prx's avatar
prx committed
17
                    block quick from <vilain_bruteforce>
prx's avatar
prx committed
18

prx's avatar
prx committed
19
              To see banned IP :
prx's avatar
prx committed
20 21 22 23 24 25 26 27
                    pfctl -t vilain_bruteforce -T show
"""

import sys
import os
import configparser
import re
import logging
prx's avatar
prx committed
28
import logging.handlers
prx's avatar
prx committed
29 30 31 32
import subprocess
import asyncio
import time

prx's avatar
prx committed
33
CONFIGFILE = "/etc/vilain.conf"
prx's avatar
prx committed
34
VERSION = "0.8.1"
prx's avatar
prx committed
35
vilain_table = "vilain_bruteforce"
prx's avatar
prx committed
36
LOGFILE = "/var/log/daemon"
prx's avatar
prx committed
37 38 39

if os.geteuid() != 0:
    print("Only root can use this tool")
prx's avatar
prx committed
40
    sys.exit(1)
prx's avatar
prx committed
41

prx's avatar
prx committed
42
# declare logger
prx's avatar
prx committed
43
logger = logging.getLogger(__name__)
prx's avatar
prx committed
44 45 46 47 48 49 50 51 52 53 54

def configure_logging():
    print('Log file : {}'.format(LOGFILE))
    log_handler = logging.handlers.WatchedFileHandler(LOGFILE)
    formatter = logging.Formatter(
            '%(asctime)s %(module)s:%(funcName)s:%(message)s',
            '%Y-%m-%d %H:%M:%S')
    log_handler.setFormatter(formatter)
    logger.addHandler(log_handler)
    logger.setLevel(logging.INFO)

prx's avatar
prx committed
55 56 57

# functions
def readconfig():
prx's avatar
prx committed
58 59
    logger.info('Read config file: {}'.format(CONFIGFILE))
    if not os.path.isfile(CONFIGFILE):
prx's avatar
prx committed
60 61 62 63
        logging.error("Can't read config file, exiting...")
        sys.exit(1)

    config = configparser.ConfigParser()
prx's avatar
prx committed
64
    config.read(CONFIGFILE)
prx's avatar
prx committed
65
    return (config, config.defaults())
prx's avatar
prx committed
66

prx's avatar
prx committed
67
def load_config(c, d):
prx's avatar
prx committed
68
    watch_while = int(d['watch_while'])
prx's avatar
prx committed
69
    VILAIN_TABLE = d['vilain_table']
prx's avatar
prx committed
70
    default_maxtries = int(d['maxtries'])
prx's avatar
prx committed
71 72
    sleeptime = float(d['sleeptime'])
    ignore_ips = []
prx's avatar
prx committed
73 74

    if c.has_section('ignoreip'):
prx's avatar
prx committed
75 76
        ignoreips = [ i[1] for i in c.items('ignoreip') if i[0] not in c.defaults()]
    return(watch_while, default_maxtries, vilain_table, ignoreips, sleeptime)
prx's avatar
prx committed
77

prx's avatar
prx committed
78
def load_sections(c):
prx's avatar
prx committed
79 80
    for s in c.sections():
        if c.has_option(s,'logfile'):
prx's avatar
prx committed
81
            LOGFILE = c.get(s,'logfile')
prx's avatar
prx committed
82
            regex = c.get(s,'regex')
prx's avatar
prx committed
83 84 85 86 87
            #we take the default value of maxtries
            maxtries = c.defaults()['maxtries']
            if c.has_option(s,'maxtries'):
                #if we have a maxtries defined in the section, we overwrite the default
                maxtries = int(c.get(s,'maxtries'))
prx's avatar
prx committed
88
            d = {'name' : s, 'logfile':LOGFILE, 'regex':regex, 'maxtries': maxtries}
prx's avatar
prx committed
89 90 91
            yield d

class Vilain():
prx's avatar
prx committed
92
    def __init__(self, config, config_dict):
prx's avatar
prx committed
93
        logger.info('Start vilain version {}'.format(VERSION))
prx's avatar
prx committed
94
        self.loop = asyncio.get_event_loop()
prx's avatar
prx committed
95
        self.watch_while, self.default_maxtries, self.vilain_table, self.ignore_ips, self.sleeptime = load_config(config, config_dict)
prx's avatar
prx committed
96 97
        self.ip_seen_at = {}
        self.load_bad_ips()
prx's avatar
prx committed
98 99
        self.bad_ip_queue = asyncio.Queue(loop=self.loop)

prx's avatar
prx committed
100
        for entry in load_sections(config):
prx's avatar
prx committed
101
            logger.info("Start vilain for {}".format(entry))
prx's avatar
prx committed
102
            asyncio.ensure_future(self.check_logs(entry['logfile'], entry['maxtries'], entry['regex'], entry['name']))
prx's avatar
prx committed
103 104

        asyncio.ensure_future(self.ban_ips())
105
        asyncio.ensure_future(self.clean_ips())
prx's avatar
prx committed
106

prx's avatar
prx committed
107 108 109 110
    def load_bad_ips(self):
        try:
            ret = subprocess.check_output(["pfctl", "-t", self.vilain_table, "-T", "show"])
        except:
prx's avatar
prx committed
111
            logger.warning("Failed to run pfctl -t {} -T show".format(self.vilain_table))
prx's avatar
prx committed
112 113 114
            ret = ""
        for res in ret.split():
            ip = res.strip().decode('utf-8')
prx's avatar
prx committed
115
            logger.info('Add existing banned IPs in your pf table: {}'.format(ip))
prx's avatar
prx committed
116
            #we assign the counter to 1, but for sure we don't know the real value
prx's avatar
prx committed
117 118 119
            self.ip_seen_at[ip]={'time':time.time(),'count':1}


prx's avatar
prx committed
120 121
    def start(self):
        try:
prx's avatar
prx committed
122
            logger.info('Run forever loop')
prx's avatar
prx committed
123 124 125 126 127 128
            self.loop.run_forever()
        except KeyboardInterrupt:
            self.loop.close()
        finally:
            self.loop.close()

prx's avatar
prx committed
129
    async def check_logs(self, logfile, maxtries, regex, reason):
prx's avatar
prx committed
130 131 132 133 134 135 136 137 138
        """
        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
prx's avatar
prx committed
139
            inode = stat.st_ino
prx's avatar
prx committed
140 141 142
            mtime = stat.st_mtime
            RE = re.compile(regex)
            while True:
prx's avatar
prx committed
143
                await asyncio.sleep(self.sleeptime)
prx's avatar
prx committed
144
                stat = os.stat(logfile)
prx's avatar
prx committed
145 146 147 148 149
                if size > stat.st_size and inode != stat.st_ino:
                    logger.info("The file {} has rotated. We start from position 0".format(logfile))
                    size = 0
                    inode = stat.st_ino
                if mtime < stat.st_mtime and inode == stat.st_ino:
prx's avatar
prx committed
150
                    logger.debug("{} has been modified".format(logfile))
prx's avatar
prx committed
151 152
                    mtime = stat.st_mtime
                    with open(logfile, "rb") as f:
prx's avatar
prx committed
153
                        f.seek(size,0)
prx's avatar
prx committed
154 155 156 157 158 159 160 161 162 163 164
                        for bline in f.readlines():
                            line = bline.decode().strip()
                            ret = RE.match(line)
                            logger.debug('line:{}'.format(line))
                            if ret:
                                bad_ip = ret.groups()[0]
                                if bad_ip not in self.ignore_ips :
                                    logger.info('line match {} the {} rule'.format(bad_ip, reason))
                                    await self.bad_ip_queue.put({'ip' : bad_ip, 'maxtries': maxtries, 'reason' : reason})
                                    logger.debug('queue size: {}'.format(self.bad_ip_queue.qsize()))
                                else:
prx's avatar
prx committed
165
                                    logger.info('line match {}. But IP in ignore list'.format(bad_ip))
prx's avatar
prx committed
166 167 168 169
                    size = stat.st_size

    async def ban_ips(self):
        """
prx's avatar
prx committed
170
        record time when this IP has been seen in ip_seen_at = { ip:{'time':<time>,'count':<counter} }
prx's avatar
prx committed
171
        and ban with pf
prx's avatar
prx committed
172
        """
prx's avatar
prx committed
173
        logger.info('ban_ips started')
prx's avatar
prx committed
174 175
        while True:
            ip_item = await self.bad_ip_queue.get()
prx's avatar
prx committed
176
            logger.debug('ban_ips awake')
prx's avatar
prx committed
177 178
            ip = ip_item['ip']
            reason = ip_item['reason']
prx's avatar
prx committed
179
            maxtries = ip_item['maxtries']
prx's avatar
prx committed
180
            self.ip_seen_at.setdefault(ip, {'time':time.time(),'count':0})
prx's avatar
prx committed
181 182 183 184 185
            self.ip_seen_at[ip]['count'] += 1
            n_ip = self.ip_seen_at[ip]['count']
            logger.info("{} detected, reason {}, count: {}, maxtries: {}".format(ip, reason, n_ip, maxtries))
            if n_ip >= maxtries:
                ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip])
prx's avatar
prx committed
186 187
                # now we can forget this ip
                self.ip_seen_at.pop(ip)
prx's avatar
prx committed
188
                logger.info("Blacklisting {}, reason {}, return code:{}".format(ip, reason, ret))
189 190 191 192 193 194 195
            #for debugging, this line allow us to see if the script run until here
            logger.debug('ban_ips end:{}'.format(self.ip_seen_at))

    async def clean_ips(self):
        """
        check old ip in ip_seen_at : remove older than watch_while
        """
prx's avatar
prx committed
196
        logger.info('clean_ips started with sleeptime={}'.format(self.sleeptime))
197
        while True:
prx's avatar
prx committed
198
            await asyncio.sleep(self.watch_while)
prx's avatar
prx committed
199 200
            for recorded_ip, data in self.ip_seen_at.items():
                if time.time() - data['time'] >= self.watch_while:
prx's avatar
prx committed
201
                    self.ip_seen_at.pop(recorded_ip)
prx's avatar
prx committed
202
            #for debugging, this line allow us to see if the script run until here
prx's avatar
prx committed
203
            ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "expire", self.watch_while])
prx's avatar
prx committed
204
            logger.debug('clean_ips end:{}'.format(self.ip_seen_at))
205 206 207 208




prx's avatar
prx committed
209
def main(config, config_dict):
prx's avatar
prx committed
210
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
prx's avatar
prx committed
211
    v = Vilain(config, config_dict)
prx's avatar
prx committed
212
    v.start()
prx's avatar
prx committed
213 214 215
    return 0

if __name__ == '__main__':
prx's avatar
prx committed
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
    import argparse
    parser = argparse.ArgumentParser(description="Vilain mimic fail2ban with pf for OpenBSD")
    parser.add_argument('--debug','-d', action="store_true", help="run in debug mode")
    parser.add_argument('--conf','-c', nargs="?", help="location of the config file")
    parser.add_argument('--version','-v', action="store_true", help="Show the version and exit")
    args = parser.parse_args()
    if args.debug:
        print("run in debug")
        logger.setLevel(logging.DEBUG)
        ch = logging.StreamHandler(sys.stdout)
        logger.addHandler(ch)
    if args.conf:
        CONFIGFILE = args.conf
    if args.version:
        print("Version: ", VERSION)
        sys.exit(0)
prx's avatar
prx committed
232 233 234 235 236 237 238
    # read config
    config, config_dict = readconfig()
    logfile = config_dict.get('vilain_log', None)
    if logfile:
        LOGFILE = logfile
    configure_logging()
    main(config, config_dict)
prx's avatar
prx committed
239 240 241


# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
prx's avatar
prx committed
242 243 244


# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4