Thuban
2017-04-11 616e3ded86f69ca0436a9415fc8203617b6f9c7a
commit | author | age
3a37e6 1 #!/usr/bin/env python3
T 2 # -*- coding:Utf-8 -*- 
3
4
5 """
6 Author :      thuban <thuban@yeuxdelibad.net>  
7 Licence :     MIT
8 Require : python >= 3.5
9
10 Description : Mimic fail2ban with pf for OpenBSD.
11               Inspired from http://www.vincentdelft.be/post/post_20161106
40cb2e 12               with improvements of vincendelft
3a37e6 13
T 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
31 configfile = "/etc/vilain.conf"
40cb2e 32 version = "0.4"
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 ch = logging.StreamHandler(sys.stdout)
T 47 logger.addHandler(ch)
48
49 # functions
50 def readconfig():
51     if not os.path.isfile(configfile):
52         logging.error("Can't read config file, exiting...")
53         sys.exit(1)
54
55     config = configparser.ConfigParser()
56     config.read(configfile)
57     return(config)
58
59 def load_config():
60     c = readconfig()
61     d = c.defaults()
62     watch_while = int(d['watch_while'])
40cb2e 63     default_maxtries = int(d['maxtries'])
3a37e6 64     vilain_table = d['vilain_table']
40cb2e 65     sleeptime = float(d['sleeptime'])
T 66     ignore_ips = []
3a37e6 67
T 68     if c.has_section('ignoreip'):
40cb2e 69         ignoreips = [ i[1] for i in c.items('ignoreip') if i[0] not in c.defaults()]
T 70     return(watch_while, default_maxtries, vilain_table, ignoreips, sleeptime)
3a37e6 71
T 72 def load_sections():
73     c = readconfig()
74     for s in c.sections():
75         if c.has_option(s,'logfile'):
76             logfile = c.get(s,'logfile')
77             regex = c.get(s,'regex')
40cb2e 78             #we take the default value of maxtries
T 79             maxtries = c.defaults()['maxtries']
80             if c.has_option(s,'maxtries'):
81                 #if we have a maxtries defined in the section, we overwrite the default
82                 maxtries = int(c.get(s,'maxtries'))
83             d = {'name' : s, 'logfile':logfile, 'regex':regex, 'maxtries': maxtries}
3a37e6 84             yield d
40cb2e 85
3a37e6 86
T 87 class Vilain():
88     def __init__(self):
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:
106             ret = ""
107         for res in ret.split():
108             ip = res.strip().decode('utf-8')
109             logger.debug('Add existing banned IPs in your pf table: {}'.format(ip))
110             #we assign the counter to 1, but for sure we don't know the real value 
111             self.ip_seen_at[ip]={'time':time.time(),'count':1}
112
3a37e6 113
T 114     def start(self):
115         try:
116             self.loop.run_forever()
117         except KeyboardInterrupt:
118             self.loop.close()
119         finally:
120             self.loop.close()
616e3d 121
3a37e6 122
40cb2e 123     async def check_logs(self, logfile, maxtries, regex, reason):
3a37e6 124         """
T 125         worker who put in bad_ip_queue bruteforce IP
126         """
127         if not os.path.isfile(logfile) :
128             logger.warning("{} doesn't exist".format(logfile))
129         else :
130             # Watch the file for changes
131             stat = os.stat(logfile)
132             size = stat.st_size
133             mtime = stat.st_mtime
134             RE = re.compile(regex)
135             while True:
40cb2e 136                 await asyncio.sleep(self.sleeptime)
3a37e6 137                 stat = os.stat(logfile)
T 138                 if mtime < stat.st_mtime:
40cb2e 139                     logger.debug("{} has been modified".format(logfile))
3a37e6 140                     mtime = stat.st_mtime
T 141                     with open(logfile, "rb") as f:
142                         f.seek(size)
143                         lines = f.readlines()
144                         ul = [ u.decode() for u in lines ]
145                         line = "".join(ul).strip()
146
147                         ret = RE.match(line)
40cb2e 148                         logger.debug('line:{}'.format(line))
3a37e6 149                         if ret:
T 150                             bad_ip = ret.groups()[0]
40cb2e 151                             if bad_ip not in self.ignore_ips :
T 152                                 logger.info('line match {} because of rule : {}'.format(bad_ip, reason))
3a37e6 153                                 await self.bad_ip_queue.put({'ip' : bad_ip, 'reason' : reason})
40cb2e 154                                 logger.debug('queue size: {}'.format(self.bad_ip_queue.qsize()))
T 155                             else:
156                                 logger.info('line match {}. But IP in ignore list'.format(bad_ip))
3a37e6 157                     size = stat.st_size
T 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} }
3a37e6 162         """
40cb2e 163         logger.info('ban_ips sarted with sleeptime={}'.format(self.sleeptime))
3a37e6 164         while True:
40cb2e 165             await asyncio.sleep(self.sleeptime)
3a37e6 166             ip_item = await self.bad_ip_queue.get()
40cb2e 167             logger.debug('ban_ips awake')
3a37e6 168             ip = ip_item['ip']
T 169             reason = ip_item['reason']
40cb2e 170             maxtries = ip_item['maxtries']
T 171             self.ip_seen_at.setdefault(ip,{'time':time.time(),'count':0})
172             self.ip_seen_at[ip]['count'] += 1
173             n_ip = self.ip_seen_at[ip]['count']
174             logger.info("{} detected, reason {}, count: {}, maxtries: {}".format(ip, reason, n_ip, maxtries))
175             if n_ip >= maxtries:
176                 ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip])
177                 logger.info("Blacklisting {}, return code:{}".format(ip, ret))
178                 self.ip_seen_at.pop(ip)
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
198             logger.debug('ban_ips end:{}'.format(self.ip_seen_at))
199
200
201
202
3a37e6 203
T 204
205
206
207
616e3d 208
T 209
210
211
3a37e6 212 def main():
T 213     os.chdir(os.path.dirname(os.path.abspath(__file__)))
214     v = Vilain()
b14b1a 215     v.start()
3a37e6 216     return 0
T 217
218 if __name__ == '__main__':
219     main()
220
221
222 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
223