Thuban
2017-08-09 7767eaae2e36c239d3c1a33290186e11e3906642
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"
8a94cc 34 LOGFILE = "/var/log/daemon"
3a37e6 35
T 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__)
8a94cc 42 logging.basicConfig(filename=LOGFILE,
3a37e6 43                     format='%(asctime)s %(module)s:%(funcName)s:%(message)s',
T 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'])
8a94cc 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'):
8a94cc 75             LOGFILE = c.get(s,'logfile')
3a37e6 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'))
8a94cc 82             d = {'name' : s, 'logfile':LOGFILE, 'regex':regex, 'maxtries': maxtries}
3a37e6 83             yield d
T 84
85 class Vilain():
86     def __init__(self):
90bc70 87         logger.info('Start vilain version {}'.format(VERSION))
3a37e6 88         self.loop = asyncio.get_event_loop()
40cb2e 89         self.watch_while, self.default_maxtries, self.vilain_table, self.ignore_ips, self.sleeptime = load_config()
T 90         self.ip_seen_at = {}
91         self.load_bad_ips()
3a37e6 92         self.bad_ip_queue = asyncio.Queue(loop=self.loop)
T 93
94         for entry in load_sections():
8a94cc 95             logger.info("Start vilain for {}".format(entry))
40cb2e 96             asyncio.ensure_future(self.check_logs(entry['logfile'], entry['maxtries'], entry['regex'], entry['name']))
3a37e6 97
T 98         asyncio.ensure_future(self.ban_ips())
616e3d 99         asyncio.ensure_future(self.clean_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:
90bc70 105             logger.warning("Failed to run pfctl -t {} -T show".format(self.vilain_table))
40cb2e 106             ret = ""
T 107         for res in ret.split():
108             ip = res.strip().decode('utf-8')
90bc70 109             logger.info('Add existing banned IPs in your pf table: {}'.format(ip))
40cb2e 110             #we assign the counter to 1, but for sure we don't know the real value 
T 111             self.ip_seen_at[ip]={'time':time.time(),'count':1}
112
3a37e6 113
T 114     def start(self):
115         try:
8a94cc 116             logger.info('Run forever loop')
3a37e6 117             self.loop.run_forever()
T 118         except KeyboardInterrupt:
119             self.loop.close()
120         finally:
121             self.loop.close()
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
8a94cc 133             inode = stat.st_ino
3a37e6 134             mtime = stat.st_mtime
T 135             RE = re.compile(regex)
136             while True:
40cb2e 137                 await asyncio.sleep(self.sleeptime)
3a37e6 138                 stat = os.stat(logfile)
8a94cc 139                 if size > stat.st_size and inode != stat.st_ino:
T 140                     logger.info("The file {} has rotated. We start from position 0".format(logfile))
141                     size = 0
142                     inode = stat.st_ino
143                 if mtime < stat.st_mtime and inode == stat.st_ino:
40cb2e 144                     logger.debug("{} has been modified".format(logfile))
3a37e6 145                     mtime = stat.st_mtime
T 146                     with open(logfile, "rb") as f:
8a94cc 147                         f.seek(size,0)
90bc70 148                         for bline in f.readlines():
T 149                             line = bline.decode().strip()
150                             ret = RE.match(line)
151                             logger.debug('line:{}'.format(line))
152                             if ret:
153                                 bad_ip = ret.groups()[0]
154                                 if bad_ip not in self.ignore_ips :
155                                     logger.info('line match {} the {} rule'.format(bad_ip, reason))
156                                     await self.bad_ip_queue.put({'ip' : bad_ip, 'maxtries': maxtries, 'reason' : reason})
157                                     logger.debug('queue size: {}'.format(self.bad_ip_queue.qsize()))
158                                 else:
f7eddd 159                                     logger.info('line match {}. But IP in ignore list'.format(bad_ip))
3a37e6 160                     size = stat.st_size
T 161
162     async def ban_ips(self):
163         """
40cb2e 164         record time when this IP has been seen in ip_seen_at = { ip:{'time':<time>,'count':<counter} }
90bc70 165         and ban with pf
3a37e6 166         """
7767ea 167         logger.info('ban_ips started')
3a37e6 168         while True:
T 169             ip_item = await self.bad_ip_queue.get()
40cb2e 170             logger.debug('ban_ips awake')
3a37e6 171             ip = ip_item['ip']
T 172             reason = ip_item['reason']
40cb2e 173             maxtries = ip_item['maxtries']
90bc70 174             self.ip_seen_at.setdefault(ip, {'time':time.time(),'count':0})
40cb2e 175             self.ip_seen_at[ip]['count'] += 1
T 176             n_ip = self.ip_seen_at[ip]['count']
177             logger.info("{} detected, reason {}, count: {}, maxtries: {}".format(ip, reason, n_ip, maxtries))
178             if n_ip >= maxtries:
179                 ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip])
180                 logger.info("Blacklisting {}, return code:{}".format(ip, ret))
616e3d 181             #for debugging, this line allow us to see if the script run until here
T 182             logger.debug('ban_ips end:{}'.format(self.ip_seen_at))
183
184     async def clean_ips(self):
185         """
186         check old ip in ip_seen_at : remove older than watch_while
187         """
7767ea 188         logger.info('clean_ips started with sleeptime={}'.format(self.sleeptime))
616e3d 189         while True:
a11218 190             await asyncio.sleep(self.watch_while)
3a37e6 191             to_remove = []
40cb2e 192             for recorded_ip, data in self.ip_seen_at.items():
T 193                 if time.time() - data['time'] >= self.watch_while:
194                     ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "delete", recorded_ip])
195                     logger.info("{} not blocked any more, return code:{}".format(recorded_ip, ret))
3a37e6 196                     to_remove.append(recorded_ip)
40cb2e 197             for ip in to_remove:
T 198                 self.ip_seen_at.pop(ip)
199             #for debugging, this line allow us to see if the script run until here
25237d 200             logger.debug('clean_ips end:{}'.format(self.ip_seen_at))
616e3d 201
T 202
203
204
3a37e6 205 def main():
T 206     os.chdir(os.path.dirname(os.path.abspath(__file__)))
207     v = Vilain()
b14b1a 208     v.start()
3a37e6 209     return 0
T 210
211 if __name__ == '__main__':
90bc70 212     import argparse
T 213     parser = argparse.ArgumentParser(description="Vilain mimic fail2ban with pf for OpenBSD")
214     parser.add_argument('--debug','-d', action="store_true", help="run in debug mode")
215     parser.add_argument('--conf','-c', nargs="?", help="location of the config file")
216     parser.add_argument('--version','-v', action="store_true", help="Show the version and exit")
217     args = parser.parse_args()
218     if args.debug:
219         print("run in debug")
220         logger.setLevel(logging.DEBUG)
221         ch = logging.StreamHandler(sys.stdout)
222         logger.addHandler(ch)
223     if args.conf:
224         CONFIGFILE = args.conf
225     if args.version:
226         print("Version: ", VERSION)
227         sys.exit(0)
228     main()
229
230
231 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
3a37e6 232
T 233
234 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
235