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