Thuban
2017-04-11 40cb2e01be738ff57f489d936e2dddf1da624a7f
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())
40cb2e 100
T 101     def load_bad_ips(self):
102         try:
103             ret = subprocess.check_output(["pfctl", "-t", self.vilain_table, "-T", "show"])
104         except:
105             ret = ""
106         for res in ret.split():
107             ip = res.strip().decode('utf-8')
108             logger.debug('Add existing banned IPs in your pf table: {}'.format(ip))
109             #we assign the counter to 1, but for sure we don't know the real value 
110             self.ip_seen_at[ip]={'time':time.time(),'count':1}
111
3a37e6 112
T 113     def start(self):
114         try:
115             self.loop.run_forever()
116         except KeyboardInterrupt:
117             self.loop.close()
118         finally:
119             self.loop.close()
120
40cb2e 121     async def check_logs(self, logfile, maxtries, regex, reason):
3a37e6 122         """
T 123         worker who put in bad_ip_queue bruteforce IP
124         """
125         if not os.path.isfile(logfile) :
126             logger.warning("{} doesn't exist".format(logfile))
127         else :
128             # Watch the file for changes
129             stat = os.stat(logfile)
130             size = stat.st_size
131             mtime = stat.st_mtime
132             RE = re.compile(regex)
133             while True:
40cb2e 134                 await asyncio.sleep(self.sleeptime)
3a37e6 135                 stat = os.stat(logfile)
T 136                 if mtime < stat.st_mtime:
40cb2e 137                     logger.debug("{} has been modified".format(logfile))
3a37e6 138                     mtime = stat.st_mtime
T 139                     with open(logfile, "rb") as f:
140                         f.seek(size)
141                         lines = f.readlines()
142                         ul = [ u.decode() for u in lines ]
143                         line = "".join(ul).strip()
144
145                         ret = RE.match(line)
40cb2e 146                         logger.debug('line:{}'.format(line))
3a37e6 147                         if ret:
T 148                             bad_ip = ret.groups()[0]
40cb2e 149                             if bad_ip not in self.ignore_ips :
T 150                                 logger.info('line match {} because of rule : {}'.format(bad_ip, reason))
3a37e6 151                                 await self.bad_ip_queue.put({'ip' : bad_ip, 'reason' : reason})
40cb2e 152                                 logger.debug('queue size: {}'.format(self.bad_ip_queue.qsize()))
T 153                             else:
154                                 logger.info('line match {}. But IP in ignore list'.format(bad_ip))
3a37e6 155                     size = stat.st_size
T 156
157     async def ban_ips(self):
158         """
40cb2e 159         record time when this IP has been seen in ip_seen_at = { ip:{'time':<time>,'count':<counter} }
3a37e6 160
T 161         check old ip in ip_seen_at : remove older than watch_while
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)
3a37e6 179             to_remove = []
40cb2e 180             for recorded_ip, data in self.ip_seen_at.items():
T 181                 if time.time() - data['time'] >= self.watch_while:
182                     ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "delete", recorded_ip])
183                     logger.info("{} not blocked any more, return code:{}".format(recorded_ip, ret))
3a37e6 184                     to_remove.append(recorded_ip)
40cb2e 185             for ip in to_remove:
T 186                 self.ip_seen_at.pop(ip)
187             #for debugging, this line allow us to see if the script run until here
188             logger.debug('ban_ips end:{}'.format(self.ip_seen_at))
189
190
191
192
3a37e6 193
T 194
195
196
197
198 def main():
199     os.chdir(os.path.dirname(os.path.abspath(__file__)))
200     v = Vilain()
b14b1a 201     v.start()
3a37e6 202     return 0
T 203
204 if __name__ == '__main__':
205     main()
206
207
208 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
209