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