Thuban
2017-03-29 3a37e640ba952bbaf726b38077f8d0adf6850fec
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
12
13               In pf.conf, add : 
14                     table <vilain_bruteforce> persist
15                     block quick from <vilain_bruteforce> 
16
17               You might want to add a cron task to remove old banned IP. As example, to ban for one day max : 
18                     pfctl -t vilain_bruteforce -T expire 86400
19
20               To see banned IP : 
21                     pfctl -t vilain_bruteforce -T show
22 """
23
24 import sys
25 import os
26 import configparser
27 import re
28 import logging
29 import subprocess
30 import asyncio
31 import time
32 from multiprocessing import Process
33
34 configfile = "/etc/vilain.conf"
35 version = "0.3"
36 vilain_table = "vilain_bruteforce"
37 logfile = "/var/log/daemon"
38 sleeptime = 0.5
39
40 if os.geteuid() != 0:
41     print("Only root can use this tool")
42     sys.exit()
43
44 # Configure logging
45 logger = logging.getLogger(__name__)
46 logging.basicConfig(filename=logfile,
47                     format='%(asctime)s %(module)s:%(funcName)s:%(message)s',
48                     datefmt='%H:%M:%S')
49 logger.setLevel(logging.DEBUG)
50 ch = logging.StreamHandler(sys.stdout)
51 logger.addHandler(ch)
52
53 # functions
54 def readconfig():
55     if not os.path.isfile(configfile):
56         logging.error("Can't read config file, exiting...")
57         sys.exit(1)
58
59     config = configparser.ConfigParser()
60     config.read(configfile)
61     return(config)
62
63 def load_config():
64     c = readconfig()
65     d = c.defaults()
66     watch_while = int(d['watch_while'])
67     maxtries = int(d['maxtries'])
68     vilain_table = d['vilain_table']
69
70     if c.has_section('ignoreip'):
71         ignoreip = [ i[1] for i in c.items('ignoreip') if i[0] not in c.defaults()]
72     else:
73         ignoreip = []
74     return(watch_while, maxtries, vilain_table, ignoreip)
75
76 def load_sections():
77     c = readconfig()
78     for s in c.sections():
79         if c.has_option(s,'logfile'):
80             logfile = c.get(s,'logfile')
81             regex = c.get(s,'regex')
82             d = {'name' : s, 'logfile':logfile, 'regex':regex}
83             yield d
84
85 class Vilain():
86     def __init__(self):
87         self.loop = asyncio.get_event_loop()
88         self.watch_while, self.maxtries, self.vilain_table, self.ignore_ip = load_config()
89         #self.bad_ip_queue = []
90         self.bad_ip_queue = asyncio.Queue(loop=self.loop)
91
92         for entry in load_sections():
93             logger.info("Start vilain for {}".format(entry['name']))
94             asyncio.ensure_future(self.check_logs(entry['logfile'], entry['regex'], entry['name']))
95
96         asyncio.ensure_future(self.ban_ips())
97
98     def start(self):
99         try:
100             self.loop.run_forever()
101         except KeyboardInterrupt:
102             self.loop.close()
103         finally:
104             self.loop.close()
105
106     async def check_logs(self, logfile, regex, reason):
107         """
108         worker who put in bad_ip_queue bruteforce IP
109         """
110         if not os.path.isfile(logfile) :
111             logger.warning("{} doesn't exist".format(logfile))
112         else :
113             # Watch the file for changes
114             stat = os.stat(logfile)
115             size = stat.st_size
116             mtime = stat.st_mtime
117             RE = re.compile(regex)
118             while True:
119                 await asyncio.sleep(sleeptime)
120                 stat = os.stat(logfile)
121                 if mtime < stat.st_mtime:
122                     mtime = stat.st_mtime
123                     with open(logfile, "rb") as f:
124                         f.seek(size)
125                         lines = f.readlines()
126                         ul = [ u.decode() for u in lines ]
127                         line = "".join(ul).strip()
128
129                         ret = RE.match(line)
130                         if ret:
131                             bad_ip = ret.groups()[0]
132                             if bad_ip not in self.ignore_ip :
133                                 #self.bad_ip_queue.append({'ip' : bad_ip, 'reason' : reason})
134                                 await self.bad_ip_queue.put({'ip' : bad_ip, 'reason' : reason})
135                     size = stat.st_size
136
137     async def ban_ips(self):
138         """
139         worker who ban IP on bad_ip_queue
140         add IP in bad_ips_list 
141         record time when this IP has been seen in ip_seen_at = { ip:time }
142
143         check number of occurence of the same ip in bad_ips_list
144         if more than 3 : ban and clean of list
145
146         check old ip in ip_seen_at : remove older than watch_while
147         """
148
149         bad_ips_list = []
150         ip_seen_at = {}
151         while True:
152             await asyncio.sleep(sleeptime)
153             #if not len(s#elf.bad_ip_queue) > 0:
154             #    continue
155             ip_item = await self.bad_ip_queue.get()
156             #ip_item = self.bad_ip_queue.pop()
157             ip = ip_item['ip']
158             reason = ip_item['reason']
159             logger.info("{} detected, reason {}".format(ip, reason))
160             bad_ips_list.append(ip)
161             ip_seen_at[ip] = time.time()
162             n_ip = bad_ips_list.count(ip)
163             if n_ip >= self.maxtries:
164                 logger.info("Blacklisting {}".format(ip))
165                 subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip])
166                 ip_seen_at.pop(ip)
167                 while ip in bad_ips_list:
168                     bad_ips_list.remove(ip)
169
170             to_remove = []
171             for recorded_ip, last_seen in ip_seen_at.items():
172                 if time.time() - last_seen >= self.watch_while:
173                     logger.info("{} not seen since a long time, forgetting...".format(recorded_ip))
174                     to_remove.append(recorded_ip)
175             for i in to_remove:
176                 ip_seen_at.pop(i)
177
178
179
180
181
182 def main():
183     os.chdir(os.path.dirname(os.path.abspath(__file__)))
184     v = Vilain()
185     p = Process(target=v.start())
186     p.start()
187     return 0
188
189 if __name__ == '__main__':
190     main()
191
192
193 # vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
194