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