Thuban
2017-04-13 55a0ea30afb18ca92538528c23d675d67ef98796
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")
55a0ea 38     sys.exit(1)
3a37e6 39
T 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))
616e3d 179             #for debugging, this line allow us to see if the script run until here
T 180             logger.debug('ban_ips end:{}'.format(self.ip_seen_at))
181
182     async def clean_ips(self):
183         """
184         check old ip in ip_seen_at : remove older than watch_while
185         """
186         logger.info('clean_ips sarted with sleeptime={}'.format(self.sleeptime))
187         while True:
188             await asyncio.sleep(self.sleeptime)
3a37e6 189             to_remove = []
40cb2e 190             for recorded_ip, data in self.ip_seen_at.items():
T 191                 if time.time() - data['time'] >= self.watch_while:
192                     ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "delete", recorded_ip])
193                     logger.info("{} not blocked any more, return code:{}".format(recorded_ip, ret))
3a37e6 194                     to_remove.append(recorded_ip)
40cb2e 195             for ip in to_remove:
T 196                 self.ip_seen_at.pop(ip)
197             #for debugging, this line allow us to see if the script run until here
25237d 198             logger.debug('clean_ips end:{}'.format(self.ip_seen_at))
616e3d 199
T 200
201
202
3a37e6 203 def main():
T 204     os.chdir(os.path.dirname(os.path.abspath(__file__)))
205     v = Vilain()
b14b1a 206     v.start()
3a37e6 207     return 0
T 208
209 if __name__ == '__main__':
90bc70 210     import argparse
T 211     parser = argparse.ArgumentParser(description="Vilain mimic fail2ban with pf for OpenBSD")
212     parser.add_argument('--debug','-d', action="store_true", help="run in debug mode")
213     parser.add_argument('--conf','-c', nargs="?", help="location of the config file")
214     parser.add_argument('--version','-v', action="store_true", help="Show the version and exit")
215     args = parser.parse_args()
216     if args.debug:
217         print("run in debug")
218         logger.setLevel(logging.DEBUG)
219         ch = logging.StreamHandler(sys.stdout)
220         logger.addHandler(ch)
221     if args.conf:
222         CONFIGFILE = args.conf
223     if args.version:
224         print("Version: ", VERSION)
225         sys.exit(0)
226     main()
227
228
229 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
3a37e6 230
T 231
232 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
233