| | |
| | | |
| | | Description : Mimic fail2ban with pf for OpenBSD. |
| | | Inspired from http://www.vincentdelft.be/post/post_20161106 |
| | | with improvements of vincendelft |
| | | |
| | | In pf.conf, add : |
| | | table <vilain_bruteforce> persist |
| | | block quick from <vilain_bruteforce> |
| | | |
| | | You might want to add a cron task to remove old banned IP. As example, to ban for one day max : |
| | | pfctl -t vilain_bruteforce -T expire 86400 |
| | | |
| | | To see banned IP : |
| | | pfctl -t vilain_bruteforce -T show |
| | |
| | | import subprocess |
| | | import asyncio |
| | | import time |
| | | from multiprocessing import Process |
| | | |
| | | configfile = "/etc/vilain.conf" |
| | | version = "0.3" |
| | | version = "0.4" |
| | | vilain_table = "vilain_bruteforce" |
| | | logfile = "/var/log/daemon" |
| | | sleeptime = 0.5 |
| | | |
| | | if os.geteuid() != 0: |
| | | print("Only root can use this tool") |
| | |
| | | logging.basicConfig(filename=logfile, |
| | | format='%(asctime)s %(module)s:%(funcName)s:%(message)s', |
| | | datefmt='%H:%M:%S') |
| | | logger.setLevel(logging.DEBUG) |
| | | logger.setLevel(logging.INFO) |
| | | ch = logging.StreamHandler(sys.stdout) |
| | | logger.addHandler(ch) |
| | | |
| | |
| | | c = readconfig() |
| | | d = c.defaults() |
| | | watch_while = int(d['watch_while']) |
| | | maxtries = int(d['maxtries']) |
| | | default_maxtries = int(d['maxtries']) |
| | | vilain_table = d['vilain_table'] |
| | | sleeptime = float(d['sleeptime']) |
| | | ignore_ips = [] |
| | | |
| | | if c.has_section('ignoreip'): |
| | | ignoreip = [ i[1] for i in c.items('ignoreip') if i[0] not in c.defaults()] |
| | | else: |
| | | ignoreip = [] |
| | | return(watch_while, maxtries, vilain_table, ignoreip) |
| | | ignoreips = [ i[1] for i in c.items('ignoreip') if i[0] not in c.defaults()] |
| | | return(watch_while, default_maxtries, vilain_table, ignoreips, sleeptime) |
| | | |
| | | def load_sections(): |
| | | c = readconfig() |
| | |
| | | if c.has_option(s,'logfile'): |
| | | logfile = c.get(s,'logfile') |
| | | regex = c.get(s,'regex') |
| | | d = {'name' : s, 'logfile':logfile, 'regex':regex} |
| | | #we take the default value of maxtries |
| | | maxtries = c.defaults()['maxtries'] |
| | | if c.has_option(s,'maxtries'): |
| | | #if we have a maxtries defined in the section, we overwrite the default |
| | | maxtries = int(c.get(s,'maxtries')) |
| | | d = {'name' : s, 'logfile':logfile, 'regex':regex, 'maxtries': maxtries} |
| | | yield d |
| | | |
| | | |
| | | class Vilain(): |
| | | def __init__(self): |
| | | self.loop = asyncio.get_event_loop() |
| | | self.watch_while, self.maxtries, self.vilain_table, self.ignore_ip = load_config() |
| | | #self.bad_ip_queue = [] |
| | | self.watch_while, self.default_maxtries, self.vilain_table, self.ignore_ips, self.sleeptime = load_config() |
| | | self.ip_seen_at = {} |
| | | self.load_bad_ips() |
| | | self.bad_ip_queue = asyncio.Queue(loop=self.loop) |
| | | |
| | | for entry in load_sections(): |
| | | logger.info("Start vilain for {}".format(entry['name'])) |
| | | asyncio.ensure_future(self.check_logs(entry['logfile'], entry['regex'], entry['name'])) |
| | | asyncio.ensure_future(self.check_logs(entry['logfile'], entry['maxtries'], entry['regex'], entry['name'])) |
| | | |
| | | asyncio.ensure_future(self.ban_ips()) |
| | | asyncio.ensure_future(self.clean_ips()) |
| | | |
| | | def load_bad_ips(self): |
| | | try: |
| | | ret = subprocess.check_output(["pfctl", "-t", self.vilain_table, "-T", "show"]) |
| | | except: |
| | | ret = "" |
| | | for res in ret.split(): |
| | | ip = res.strip().decode('utf-8') |
| | | logger.debug('Add existing banned IPs in your pf table: {}'.format(ip)) |
| | | #we assign the counter to 1, but for sure we don't know the real value |
| | | self.ip_seen_at[ip]={'time':time.time(),'count':1} |
| | | |
| | | |
| | | def start(self): |
| | | try: |
| | |
| | | finally: |
| | | self.loop.close() |
| | | |
| | | async def check_logs(self, logfile, regex, reason): |
| | | |
| | | async def check_logs(self, logfile, maxtries, regex, reason): |
| | | """ |
| | | worker who put in bad_ip_queue bruteforce IP |
| | | """ |
| | |
| | | mtime = stat.st_mtime |
| | | RE = re.compile(regex) |
| | | while True: |
| | | await asyncio.sleep(sleeptime) |
| | | await asyncio.sleep(self.sleeptime) |
| | | stat = os.stat(logfile) |
| | | if mtime < stat.st_mtime: |
| | | logger.debug("{} has been modified".format(logfile)) |
| | | mtime = stat.st_mtime |
| | | with open(logfile, "rb") as f: |
| | | f.seek(size) |
| | |
| | | line = "".join(ul).strip() |
| | | |
| | | ret = RE.match(line) |
| | | logger.debug('line:{}'.format(line)) |
| | | if ret: |
| | | bad_ip = ret.groups()[0] |
| | | if bad_ip not in self.ignore_ip : |
| | | #self.bad_ip_queue.append({'ip' : bad_ip, 'reason' : reason}) |
| | | if bad_ip not in self.ignore_ips : |
| | | logger.info('line match {} because of rule : {}'.format(bad_ip, reason)) |
| | | await self.bad_ip_queue.put({'ip' : bad_ip, 'reason' : reason}) |
| | | logger.debug('queue size: {}'.format(self.bad_ip_queue.qsize())) |
| | | else: |
| | | logger.info('line match {}. But IP in ignore list'.format(bad_ip)) |
| | | size = stat.st_size |
| | | |
| | | async def ban_ips(self): |
| | | """ |
| | | worker who ban IP on bad_ip_queue |
| | | add IP in bad_ips_list |
| | | record time when this IP has been seen in ip_seen_at = { ip:time } |
| | | |
| | | check number of occurence of the same ip in bad_ips_list |
| | | if more than 3 : ban and clean of list |
| | | |
| | | check old ip in ip_seen_at : remove older than watch_while |
| | | record time when this IP has been seen in ip_seen_at = { ip:{'time':<time>,'count':<counter} } |
| | | """ |
| | | |
| | | bad_ips_list = [] |
| | | ip_seen_at = {} |
| | | logger.info('ban_ips sarted with sleeptime={}'.format(self.sleeptime)) |
| | | while True: |
| | | await asyncio.sleep(sleeptime) |
| | | #if not len(s#elf.bad_ip_queue) > 0: |
| | | # continue |
| | | await asyncio.sleep(self.sleeptime) |
| | | ip_item = await self.bad_ip_queue.get() |
| | | #ip_item = self.bad_ip_queue.pop() |
| | | logger.debug('ban_ips awake') |
| | | ip = ip_item['ip'] |
| | | reason = ip_item['reason'] |
| | | logger.info("{} detected, reason {}".format(ip, reason)) |
| | | bad_ips_list.append(ip) |
| | | ip_seen_at[ip] = time.time() |
| | | n_ip = bad_ips_list.count(ip) |
| | | if n_ip >= self.maxtries: |
| | | logger.info("Blacklisting {}".format(ip)) |
| | | subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip]) |
| | | ip_seen_at.pop(ip) |
| | | while ip in bad_ips_list: |
| | | bad_ips_list.remove(ip) |
| | | maxtries = ip_item['maxtries'] |
| | | self.ip_seen_at.setdefault(ip,{'time':time.time(),'count':0}) |
| | | self.ip_seen_at[ip]['count'] += 1 |
| | | n_ip = self.ip_seen_at[ip]['count'] |
| | | logger.info("{} detected, reason {}, count: {}, maxtries: {}".format(ip, reason, n_ip, maxtries)) |
| | | if n_ip >= maxtries: |
| | | ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "add", ip]) |
| | | logger.info("Blacklisting {}, return code:{}".format(ip, ret)) |
| | | self.ip_seen_at.pop(ip) |
| | | #for debugging, this line allow us to see if the script run until here |
| | | logger.debug('ban_ips end:{}'.format(self.ip_seen_at)) |
| | | |
| | | async def clean_ips(self): |
| | | """ |
| | | check old ip in ip_seen_at : remove older than watch_while |
| | | """ |
| | | logger.info('clean_ips sarted with sleeptime={}'.format(self.sleeptime)) |
| | | while True: |
| | | await asyncio.sleep(self.sleeptime) |
| | | to_remove = [] |
| | | for recorded_ip, last_seen in ip_seen_at.items(): |
| | | if time.time() - last_seen >= self.watch_while: |
| | | logger.info("{} not seen since a long time, forgetting...".format(recorded_ip)) |
| | | for recorded_ip, data in self.ip_seen_at.items(): |
| | | if time.time() - data['time'] >= self.watch_while: |
| | | ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "delete", recorded_ip]) |
| | | logger.info("{} not blocked any more, return code:{}".format(recorded_ip, ret)) |
| | | to_remove.append(recorded_ip) |
| | | for i in to_remove: |
| | | ip_seen_at.pop(i) |
| | | |
| | | for ip in to_remove: |
| | | self.ip_seen_at.pop(ip) |
| | | #for debugging, this line allow us to see if the script run until here |
| | | logger.debug('clean_ips end:{}'.format(self.ip_seen_at)) |
| | | |
| | | |
| | | |
| | |
| | | def main(): |
| | | os.chdir(os.path.dirname(os.path.abspath(__file__))) |
| | | v = Vilain() |
| | | p = Process(target=v.start()) |
| | | p.start() |
| | | v.start() |
| | | return 0 |
| | | |
| | | if __name__ == '__main__': |