Thuban
2017-09-10 df69e1277a175c4de2ef80eb0b87a3ef13ba248b
commit | author | age
3a37e6 1 #!/usr/bin/env python3
8f00aa 2 # -*- coding:Utf-8 -*-
3a37e6 3
T 4
5 """
8f00aa 6 Author :      thuban <thuban@yeuxdelibad.net>
90bc70 7               Vincent <vincent.delft@gmail.com>
3a37e6 8 Licence :     MIT
T 9 Require : python >= 3.5
10
11 Description : Mimic fail2ban with pf for OpenBSD.
12               Inspired from http://www.vincentdelft.be/post/post_20161106
13
8f00aa 14               In pf.conf, add :
3a37e6 15                     table <vilain_bruteforce> persist
8f00aa 16                     block quick from <vilain_bruteforce>
3a37e6 17
8f00aa 18               To see banned IP :
3a37e6 19                     pfctl -t vilain_bruteforce -T show
T 20 """
21
22 import sys
23 import os
24 import configparser
25 import re
26 import logging
c7ace8 27 import logging.handlers
3a37e6 28 import subprocess
T 29 import asyncio
30 import time
31
90bc70 32 CONFIGFILE = "/etc/vilain.conf"
b8b614 33 VERSION = "0.7"
3a37e6 34 vilain_table = "vilain_bruteforce"
8a94cc 35 LOGFILE = "/var/log/daemon"
3a37e6 36
T 37 if os.geteuid() != 0:
38     print("Only root can use this tool")
55a0ea 39     sys.exit(1)
3a37e6 40
8f00aa 41 # declare logger
3a37e6 42 logger = logging.getLogger(__name__)
8f00aa 43
Y 44 def configure_logging():
45     print('Log file : {}'.format(LOGFILE))
46     log_handler = logging.handlers.WatchedFileHandler(LOGFILE)
47     formatter = logging.Formatter(
48             '%(asctime)s %(module)s:%(funcName)s:%(message)s',
b8b614 49             '%Y-%m-%d %H:%M:%S')
8f00aa 50     log_handler.setFormatter(formatter)
Y 51     logger.addHandler(log_handler)
52     logger.setLevel(logging.INFO)
53
3a37e6 54
T 55 # functions
56 def readconfig():
90bc70 57     logger.info('Read config file: {}'.format(CONFIGFILE))
T 58     if not os.path.isfile(CONFIGFILE):
3a37e6 59         logging.error("Can't read config file, exiting...")
T 60         sys.exit(1)
61
62     config = configparser.ConfigParser()
90bc70 63     config.read(CONFIGFILE)
8f00aa 64     return (config, config.defaults())
3a37e6 65
8f00aa 66 def load_config(c, d):
3a37e6 67     watch_while = int(d['watch_while'])
8a94cc 68     VILAIN_TABLE = d['vilain_table']
90bc70 69     default_maxtries = int(d['maxtries'])
40cb2e 70     sleeptime = float(d['sleeptime'])
T 71     ignore_ips = []
3a37e6 72
T 73     if c.has_section('ignoreip'):
40cb2e 74         ignoreips = [ i[1] for i in c.items('ignoreip') if i[0] not in c.defaults()]
T 75     return(watch_while, default_maxtries, vilain_table, ignoreips, sleeptime)
3a37e6 76
8f00aa 77 def load_sections(c):
3a37e6 78     for s in c.sections():
T 79         if c.has_option(s,'logfile'):
8a94cc 80             LOGFILE = c.get(s,'logfile')
3a37e6 81             regex = c.get(s,'regex')
40cb2e 82             #we take the default value of maxtries
T 83             maxtries = c.defaults()['maxtries']
84             if c.has_option(s,'maxtries'):
85                 #if we have a maxtries defined in the section, we overwrite the default
86                 maxtries = int(c.get(s,'maxtries'))
8a94cc 87             d = {'name' : s, 'logfile':LOGFILE, 'regex':regex, 'maxtries': maxtries}
3a37e6 88             yield d
T 89
90 class Vilain():
8f00aa 91     def __init__(self, config, config_dict):
90bc70 92         logger.info('Start vilain version {}'.format(VERSION))
3a37e6 93         self.loop = asyncio.get_event_loop()
8f00aa 94         self.watch_while, self.default_maxtries, self.vilain_table, self.ignore_ips, self.sleeptime = load_config(config, config_dict)
40cb2e 95         self.ip_seen_at = {}
T 96         self.load_bad_ips()
3a37e6 97         self.bad_ip_queue = asyncio.Queue(loop=self.loop)
T 98
8f00aa 99         for entry in load_sections(config):
8a94cc 100             logger.info("Start vilain for {}".format(entry))
40cb2e 101             asyncio.ensure_future(self.check_logs(entry['logfile'], entry['maxtries'], entry['regex'], entry['name']))
3a37e6 102
T 103         asyncio.ensure_future(self.ban_ips())
616e3d 104         asyncio.ensure_future(self.clean_ips())
40cb2e 105
T 106     def load_bad_ips(self):
107         try:
108             ret = subprocess.check_output(["pfctl", "-t", self.vilain_table, "-T", "show"])
109         except:
90bc70 110             logger.warning("Failed to run pfctl -t {} -T show".format(self.vilain_table))
40cb2e 111             ret = ""
T 112         for res in ret.split():
113             ip = res.strip().decode('utf-8')
90bc70 114             logger.info('Add existing banned IPs in your pf table: {}'.format(ip))
8f00aa 115             #we assign the counter to 1, but for sure we don't know the real value
40cb2e 116             self.ip_seen_at[ip]={'time':time.time(),'count':1}
T 117
3a37e6 118
T 119     def start(self):
120         try:
8a94cc 121             logger.info('Run forever loop')
3a37e6 122             self.loop.run_forever()
T 123         except KeyboardInterrupt:
124             self.loop.close()
125         finally:
126             self.loop.close()
127
40cb2e 128     async def check_logs(self, logfile, maxtries, regex, reason):
3a37e6 129         """
T 130         worker who put in bad_ip_queue bruteforce IP
131         """
132         if not os.path.isfile(logfile) :
133             logger.warning("{} doesn't exist".format(logfile))
134         else :
135             # Watch the file for changes
136             stat = os.stat(logfile)
137             size = stat.st_size
8a94cc 138             inode = stat.st_ino
3a37e6 139             mtime = stat.st_mtime
T 140             RE = re.compile(regex)
141             while True:
40cb2e 142                 await asyncio.sleep(self.sleeptime)
3a37e6 143                 stat = os.stat(logfile)
8a94cc 144                 if size > stat.st_size and inode != stat.st_ino:
T 145                     logger.info("The file {} has rotated. We start from position 0".format(logfile))
146                     size = 0
147                     inode = stat.st_ino
148                 if mtime < stat.st_mtime and inode == stat.st_ino:
40cb2e 149                     logger.debug("{} has been modified".format(logfile))
3a37e6 150                     mtime = stat.st_mtime
T 151                     with open(logfile, "rb") as f:
8a94cc 152                         f.seek(size,0)
90bc70 153                         for bline in f.readlines():
T 154                             line = bline.decode().strip()
155                             ret = RE.match(line)
156                             logger.debug('line:{}'.format(line))
157                             if ret:
158                                 bad_ip = ret.groups()[0]
159                                 if bad_ip not in self.ignore_ips :
160                                     logger.info('line match {} the {} rule'.format(bad_ip, reason))
161                                     await self.bad_ip_queue.put({'ip' : bad_ip, 'maxtries': maxtries, 'reason' : reason})
162                                     logger.debug('queue size: {}'.format(self.bad_ip_queue.qsize()))
163                                 else:
f7eddd 164                                     logger.info('line match {}. But IP in ignore list'.format(bad_ip))
3a37e6 165                     size = stat.st_size
T 166
167     async def ban_ips(self):
168         """
40cb2e 169         record time when this IP has been seen in ip_seen_at = { ip:{'time':<time>,'count':<counter} }
90bc70 170         and ban with pf
3a37e6 171         """
7767ea 172         logger.info('ban_ips started')
3a37e6 173         while True:
T 174             ip_item = await self.bad_ip_queue.get()
40cb2e 175             logger.debug('ban_ips awake')
3a37e6 176             ip = ip_item['ip']
T 177             reason = ip_item['reason']
40cb2e 178             maxtries = ip_item['maxtries']
90bc70 179             self.ip_seen_at.setdefault(ip, {'time':time.time(),'count':0})
40cb2e 180             self.ip_seen_at[ip]['count'] += 1
T 181             n_ip = self.ip_seen_at[ip]['count']
182             logger.info("{} detected, reason {}, count: {}, maxtries: {}".format(ip, reason, n_ip, maxtries))
183             if n_ip >= maxtries:
184                 ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip])
b8b614 185                 logger.info("Blacklisting {}, reason {}, return code:{}".format(ip, reason, ret))
616e3d 186             #for debugging, this line allow us to see if the script run until here
T 187             logger.debug('ban_ips end:{}'.format(self.ip_seen_at))
188
189     async def clean_ips(self):
190         """
191         check old ip in ip_seen_at : remove older than watch_while
192         """
7767ea 193         logger.info('clean_ips started with sleeptime={}'.format(self.sleeptime))
616e3d 194         while True:
a11218 195             await asyncio.sleep(self.watch_while)
3a37e6 196             to_remove = []
40cb2e 197             for recorded_ip, data in self.ip_seen_at.items():
T 198                 if time.time() - data['time'] >= self.watch_while:
199                     ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "delete", recorded_ip])
200                     logger.info("{} not blocked any more, return code:{}".format(recorded_ip, ret))
3a37e6 201                     to_remove.append(recorded_ip)
40cb2e 202             for ip in to_remove:
T 203                 self.ip_seen_at.pop(ip)
204             #for debugging, this line allow us to see if the script run until here
25237d 205             logger.debug('clean_ips end:{}'.format(self.ip_seen_at))
616e3d 206
T 207
208
209
8f00aa 210 def main(config, config_dict):
3a37e6 211     os.chdir(os.path.dirname(os.path.abspath(__file__)))
8f00aa 212     v = Vilain(config, config_dict)
b14b1a 213     v.start()
3a37e6 214     return 0
T 215
216 if __name__ == '__main__':
90bc70 217     import argparse
T 218     parser = argparse.ArgumentParser(description="Vilain mimic fail2ban with pf for OpenBSD")
219     parser.add_argument('--debug','-d', action="store_true", help="run in debug mode")
220     parser.add_argument('--conf','-c', nargs="?", help="location of the config file")
221     parser.add_argument('--version','-v', action="store_true", help="Show the version and exit")
222     args = parser.parse_args()
223     if args.debug:
224         print("run in debug")
225         logger.setLevel(logging.DEBUG)
226         ch = logging.StreamHandler(sys.stdout)
227         logger.addHandler(ch)
228     if args.conf:
229         CONFIGFILE = args.conf
230     if args.version:
231         print("Version: ", VERSION)
232         sys.exit(0)
8f00aa 233     # read config
Y 234     config, config_dict = readconfig()
235     logfile = config_dict.get('vilain_log', None)
236     if logfile:
237         LOGFILE = logfile
238     configure_logging()
239     main(config, config_dict)
90bc70 240
T 241
242 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
3a37e6 243
T 244
245 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4