#!/usr/bin/env python3 # -*- coding:Utf-8 -*- """ Author : thuban Vincent Licence : MIT Require : python >= 3.5 Description : Mimic fail2ban with pf for OpenBSD. Inspired from http://www.vincentdelft.be/post/post_20161106 In pf.conf, add : table persist block quick from To see banned IP : pfctl -t vilain_bruteforce -T show """ import sys import os import configparser import re import logging import logging.handlers import subprocess import asyncio import time CONFIGFILE = "/etc/vilain.conf" VERSION = "0.7" vilain_table = "vilain_bruteforce" LOGFILE = "/var/log/daemon" if os.geteuid() != 0: print("Only root can use this tool") sys.exit(1) # declare logger logger = logging.getLogger(__name__) def configure_logging(): print('Log file : {}'.format(LOGFILE)) log_handler = logging.handlers.WatchedFileHandler(LOGFILE) formatter = logging.Formatter( '%(asctime)s %(module)s:%(funcName)s:%(message)s', '%Y-%m-%d %H:%M:%S') log_handler.setFormatter(formatter) logger.addHandler(log_handler) logger.setLevel(logging.INFO) # functions def readconfig(): logger.info('Read config file: {}'.format(CONFIGFILE)) if not os.path.isfile(CONFIGFILE): logging.error("Can't read config file, exiting...") sys.exit(1) config = configparser.ConfigParser() config.read(CONFIGFILE) return (config, config.defaults()) def load_config(c, d): watch_while = int(d['watch_while']) VILAIN_TABLE = d['vilain_table'] default_maxtries = int(d['maxtries']) sleeptime = float(d['sleeptime']) ignore_ips = [] if c.has_section('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): for s in c.sections(): if c.has_option(s,'logfile'): LOGFILE = c.get(s,'logfile') regex = c.get(s,'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, config, config_dict): logger.info('Start vilain version {}'.format(VERSION)) self.loop = asyncio.get_event_loop() self.watch_while, self.default_maxtries, self.vilain_table, self.ignore_ips, self.sleeptime = load_config(config, config_dict) self.ip_seen_at = {} self.load_bad_ips() self.bad_ip_queue = asyncio.Queue(loop=self.loop) for entry in load_sections(config): logger.info("Start vilain for {}".format(entry)) 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: logger.warning("Failed to run pfctl -t {} -T show".format(self.vilain_table)) ret = "" for res in ret.split(): ip = res.strip().decode('utf-8') logger.info('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: logger.info('Run forever loop') self.loop.run_forever() except KeyboardInterrupt: self.loop.close() finally: self.loop.close() async def check_logs(self, logfile, maxtries, regex, reason): """ worker who put in bad_ip_queue bruteforce IP """ if not os.path.isfile(logfile) : logger.warning("{} doesn't exist".format(logfile)) else : # Watch the file for changes stat = os.stat(logfile) size = stat.st_size inode = stat.st_ino mtime = stat.st_mtime RE = re.compile(regex) while True: await asyncio.sleep(self.sleeptime) stat = os.stat(logfile) if size > stat.st_size and inode != stat.st_ino: logger.info("The file {} has rotated. We start from position 0".format(logfile)) size = 0 inode = stat.st_ino if mtime < stat.st_mtime and inode == stat.st_ino: logger.debug("{} has been modified".format(logfile)) mtime = stat.st_mtime with open(logfile, "rb") as f: f.seek(size,0) for bline in f.readlines(): line = bline.decode().strip() ret = RE.match(line) logger.debug('line:{}'.format(line)) if ret: bad_ip = ret.groups()[0] if bad_ip not in self.ignore_ips : logger.info('line match {} the {} rule'.format(bad_ip, reason)) await self.bad_ip_queue.put({'ip' : bad_ip, 'maxtries': maxtries, '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): """ record time when this IP has been seen in ip_seen_at = { ip:{'time':