Si vous constatez la perte de tickets, de commentaires ou de projets, veuillez consulter la page https://status.framasoft.org/incident/626.

vilain.py 9.03 KB
Newer Older
Thuban's avatar
Thuban committed
1
#!/usr/bin/env python3
Thuban's avatar
Thuban committed
2
# -*- coding:Utf-8 -*-
Thuban's avatar
Thuban committed
3 4 5


"""
Thuban's avatar
Thuban committed
6
Author :      thuban <thuban@yeuxdelibad.net>
Thuban's avatar
Thuban committed
7
              Vincent <vincent.delft@gmail.com>
Thuban's avatar
Thuban committed
8
              Yax https://blogduyax.madyanne.fr/
Thuban's avatar
Thuban committed
9 10 11 12 13 14
Licence :     MIT
Require : python >= 3.5

Description : Mimic fail2ban with pf for OpenBSD.
              Inspired from http://www.vincentdelft.be/post/post_20161106

Thuban's avatar
Thuban committed
15
              In pf.conf, add :
Thuban's avatar
Thuban committed
16
                    table <vilain_bruteforce> persist
Thuban's avatar
Thuban committed
17
                    block quick from <vilain_bruteforce>
Thuban's avatar
Thuban committed
18

Thuban's avatar
Thuban committed
19
              To see banned IP :
Thuban's avatar
Thuban committed
20 21 22 23 24 25 26 27
                    pfctl -t vilain_bruteforce -T show
"""

import sys
import os
import configparser
import re
import logging
Thuban's avatar
Thuban committed
28
import logging.handlers
Thuban's avatar
Thuban committed
29 30 31 32
import subprocess
import asyncio
import time

Thuban's avatar
Thuban committed
33
CONFIGFILE = "/etc/vilain.conf"
Thuban's avatar
up ver  
Thuban committed
34
VERSION = "0.8.1"
Thuban's avatar
Thuban committed
35
vilain_table = "vilain_bruteforce"
Thuban's avatar
Thuban committed
36
LOGFILE = "/var/log/daemon"
Thuban's avatar
Thuban committed
37 38 39

if os.geteuid() != 0:
    print("Only root can use this tool")
Thuban's avatar
Thuban committed
40
    sys.exit(1)
Thuban's avatar
Thuban committed
41

Thuban's avatar
Thuban committed
42
# declare logger
Thuban's avatar
Thuban committed
43
logger = logging.getLogger(__name__)
Thuban's avatar
Thuban committed
44 45 46 47 48 49 50 51 52 53 54

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)

Thuban's avatar
Thuban committed
55 56 57

# functions
def readconfig():
Thuban's avatar
Thuban committed
58 59
    logger.info('Read config file: {}'.format(CONFIGFILE))
    if not os.path.isfile(CONFIGFILE):
Thuban's avatar
Thuban committed
60 61 62 63
        logging.error("Can't read config file, exiting...")
        sys.exit(1)

    config = configparser.ConfigParser()
Thuban's avatar
Thuban committed
64
    config.read(CONFIGFILE)
Thuban's avatar
Thuban committed
65
    return (config, config.defaults())
Thuban's avatar
Thuban committed
66

Thuban's avatar
Thuban committed
67
def load_config(c, d):
Thuban's avatar
Thuban committed
68
    watch_while = int(d['watch_while'])
Thuban's avatar
Thuban committed
69
    VILAIN_TABLE = d['vilain_table']
Thuban's avatar
Thuban committed
70
    default_maxtries = int(d['maxtries'])
Thuban's avatar
Thuban committed
71 72
    sleeptime = float(d['sleeptime'])
    ignore_ips = []
Thuban's avatar
Thuban committed
73 74

    if c.has_section('ignoreip'):
Thuban's avatar
Thuban committed
75 76
        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)
Thuban's avatar
Thuban committed
77

Thuban's avatar
Thuban committed
78
def load_sections(c):
Thuban's avatar
Thuban committed
79 80
    for s in c.sections():
        if c.has_option(s,'logfile'):
Thuban's avatar
Thuban committed
81
            LOGFILE = c.get(s,'logfile')
Thuban's avatar
Thuban committed
82
            regex = c.get(s,'regex')
Thuban's avatar
Thuban committed
83 84 85 86 87
            #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'))
Thuban's avatar
Thuban committed
88
            d = {'name' : s, 'logfile':LOGFILE, 'regex':regex, 'maxtries': maxtries}
Thuban's avatar
Thuban committed
89 90 91
            yield d

class Vilain():
Thuban's avatar
Thuban committed
92
    def __init__(self, config, config_dict):
Thuban's avatar
Thuban committed
93
        logger.info('Start vilain version {}'.format(VERSION))
Thuban's avatar
Thuban committed
94
        self.loop = asyncio.get_event_loop()
Thuban's avatar
Thuban committed
95
        self.watch_while, self.default_maxtries, self.vilain_table, self.ignore_ips, self.sleeptime = load_config(config, config_dict)
Thuban's avatar
Thuban committed
96 97
        self.ip_seen_at = {}
        self.load_bad_ips()
Thuban's avatar
Thuban committed
98 99
        self.bad_ip_queue = asyncio.Queue(loop=self.loop)

Thuban's avatar
Thuban committed
100
        for entry in load_sections(config):
Thuban's avatar
Thuban committed
101
            logger.info("Start vilain for {}".format(entry))
Thuban's avatar
Thuban committed
102
            asyncio.ensure_future(self.check_logs(entry['logfile'], entry['maxtries'], entry['regex'], entry['name']))
Thuban's avatar
Thuban committed
103 104

        asyncio.ensure_future(self.ban_ips())
105
        asyncio.ensure_future(self.clean_ips())
Thuban's avatar
Thuban committed
106

Thuban's avatar
Thuban committed
107 108 109 110
    def load_bad_ips(self):
        try:
            ret = subprocess.check_output(["pfctl", "-t", self.vilain_table, "-T", "show"])
        except:
Thuban's avatar
Thuban committed
111
            logger.warning("Failed to run pfctl -t {} -T show".format(self.vilain_table))
Thuban's avatar
Thuban committed
112 113 114
            ret = ""
        for res in ret.split():
            ip = res.strip().decode('utf-8')
Thuban's avatar
Thuban committed
115
            logger.info('Add existing banned IPs in your pf table: {}'.format(ip))
Thuban's avatar
Thuban committed
116
            #we assign the counter to 1, but for sure we don't know the real value
Thuban's avatar
Thuban committed
117 118 119
            self.ip_seen_at[ip]={'time':time.time(),'count':1}


Thuban's avatar
Thuban committed
120 121
    def start(self):
        try:
Thuban's avatar
Thuban committed
122
            logger.info('Run forever loop')
Thuban's avatar
Thuban committed
123 124 125 126 127 128
            self.loop.run_forever()
        except KeyboardInterrupt:
            self.loop.close()
        finally:
            self.loop.close()

Thuban's avatar
Thuban committed
129
    async def check_logs(self, logfile, maxtries, regex, reason):
Thuban's avatar
Thuban committed
130 131 132 133 134 135 136 137 138
        """
        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
Thuban's avatar
Thuban committed
139
            inode = stat.st_ino
Thuban's avatar
Thuban committed
140 141 142
            mtime = stat.st_mtime
            RE = re.compile(regex)
            while True:
Thuban's avatar
Thuban committed
143
                await asyncio.sleep(self.sleeptime)
Thuban's avatar
Thuban committed
144
                stat = os.stat(logfile)
Thuban's avatar
Thuban committed
145 146 147 148 149
                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:
Thuban's avatar
Thuban committed
150
                    logger.debug("{} has been modified".format(logfile))
Thuban's avatar
Thuban committed
151 152
                    mtime = stat.st_mtime
                    with open(logfile, "rb") as f:
Thuban's avatar
Thuban committed
153
                        f.seek(size,0)
Thuban's avatar
Thuban committed
154 155 156 157 158 159 160 161 162 163 164
                        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:
Thuban's avatar
Thuban committed
165
                                    logger.info('line match {}. But IP in ignore list'.format(bad_ip))
Thuban's avatar
Thuban committed
166 167 168 169
                    size = stat.st_size

    async def ban_ips(self):
        """
Thuban's avatar
Thuban committed
170
        record time when this IP has been seen in ip_seen_at = { ip:{'time':<time>,'count':<counter} }
Thuban's avatar
Thuban committed
171
        and ban with pf
Thuban's avatar
Thuban committed
172
        """
Thuban's avatar
typo  
Thuban committed
173
        logger.info('ban_ips started')
Thuban's avatar
Thuban committed
174 175
        while True:
            ip_item = await self.bad_ip_queue.get()
Thuban's avatar
Thuban committed
176
            logger.debug('ban_ips awake')
Thuban's avatar
Thuban committed
177 178
            ip = ip_item['ip']
            reason = ip_item['reason']
Thuban's avatar
Thuban committed
179
            maxtries = ip_item['maxtries']
Thuban's avatar
Thuban committed
180
            self.ip_seen_at.setdefault(ip, {'time':time.time(),'count':0})
Thuban's avatar
Thuban committed
181 182 183 184 185
            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])
Thuban's avatar
Thuban committed
186 187
                # now we can forget this ip
                self.ip_seen_at.pop(ip)
Thuban's avatar
Thuban committed
188
                logger.info("Blacklisting {}, reason {}, return code:{}".format(ip, reason, ret))
189 190 191 192 193 194 195
            #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
        """
Thuban's avatar
typo  
Thuban committed
196
        logger.info('clean_ips started with sleeptime={}'.format(self.sleeptime))
197
        while True:
Thuban's avatar
Thuban committed
198
            await asyncio.sleep(self.watch_while)
Thuban's avatar
Thuban committed
199 200
            for recorded_ip, data in self.ip_seen_at.items():
                if time.time() - data['time'] >= self.watch_while:
Thuban's avatar
Thuban committed
201
                    self.ip_seen_at.pop(recorded_ip)
Thuban's avatar
Thuban committed
202
            #for debugging, this line allow us to see if the script run until here
Thuban's avatar
Thuban committed
203
            ret = subprocess.call(["pfctl", "-t", self.vilain_table, "-T", "expire", self.watch_while])
Thuban's avatar
debug  
Thuban committed
204
            logger.debug('clean_ips end:{}'.format(self.ip_seen_at))
205 206 207 208




Thuban's avatar
Thuban committed
209
def main(config, config_dict):
Thuban's avatar
Thuban committed
210
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
Thuban's avatar
Thuban committed
211
    v = Vilain(config, config_dict)
Thuban's avatar
Thuban committed
212
    v.start()
Thuban's avatar
Thuban committed
213 214 215
    return 0

if __name__ == '__main__':
Thuban's avatar
Thuban committed
216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
    import argparse
    parser = argparse.ArgumentParser(description="Vilain mimic fail2ban with pf for OpenBSD")
    parser.add_argument('--debug','-d', action="store_true", help="run in debug mode")
    parser.add_argument('--conf','-c', nargs="?", help="location of the config file")
    parser.add_argument('--version','-v', action="store_true", help="Show the version and exit")
    args = parser.parse_args()
    if args.debug:
        print("run in debug")
        logger.setLevel(logging.DEBUG)
        ch = logging.StreamHandler(sys.stdout)
        logger.addHandler(ch)
    if args.conf:
        CONFIGFILE = args.conf
    if args.version:
        print("Version: ", VERSION)
        sys.exit(0)
Thuban's avatar
Thuban committed
232 233 234 235 236 237 238
    # read config
    config, config_dict = readconfig()
    logfile = config_dict.get('vilain_log', None)
    if logfile:
        LOGFILE = logfile
    configure_logging()
    main(config, config_dict)
Thuban's avatar
Thuban committed
239 240 241


# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4
Thuban's avatar
Thuban committed
242 243 244


# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4