From 3a37e640ba952bbaf726b38077f8d0adf6850fec Mon Sep 17 00:00:00 2001 From: Thuban <thuban@yeuxdelibad.net> Date: Wed, 29 Mar 2017 12:13:09 +0000 Subject: [PATCH] major update, better init script --- vilain.conf | 34 +++ vilain.py | 194 +++++++++++++++++++++ Makefile | 36 ++++ vilain.rc | 13 + vilain | 188 +------------------- vilain.1 | 41 ++++ README.md | 18 + 7 files changed, 336 insertions(+), 188 deletions(-) diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e153e0b --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +# vilain - anti-bruteforce for OpenBSD +# See LICENSE file for copyright and license details. +# +# vilain version +VERSION = 0.3 + +# Customize below to fit your system +# paths +PREFIX = /usr/local +MANPREFIX = ${PREFIX}/man/man1/ + +install: + @echo installing executable file to ${DESTDIR}${PREFIX}/bin + @mkdir -p ${DESTDIR}${PREFIX}/bin + @cp -f vilain ${DESTDIR}${PREFIX}/bin + @echo installing script file to ${DESTDIR}${PREFIX}/sbin + @cp -f vilain.py ${DESTDIR}${PREFIX}/sbin + @chmod 755 ${DESTDIR}${PREFIX}/bin/vilain + @chmod 644 ${DESTDIR}${PREFIX}/sbin/vilain.py + @echo installing init script in /etc/rc.d + @cp -f vilain.rc /etc/rc.d/vilain + @chmod 755 /etc/rc.d/vilain + @echo installing manual page to ${DESTDIR}${MANPREFIX}/man1 + @mkdir -p ${DESTDIR}${MANPREFIX}/ + @cp -f vilain.1 ${DESTDIR}${MANPREFIX}/vilain.1 + @chmod 644 ${DESTDIR}${MANPREFIX}/vilain.1 + + +uninstall: + @echo removing executable file from ${DESTDIR}${PREFIX}/bin + @rm -f ${DESTDIR}${PREFIX}/bin/vilain + @rm -f ${DESTDIR}${PREFIX}/sbin/vilain.py + @echo removing manual page to ${DESTDIR}${MANPREFIX}/ + @rm -f ${DESTDIR}${MANPREFIX}/vilain.1 + +.PHONY: install uninstall diff --git a/README.md b/README.md index 821e44e..2c90965 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,9 @@ # vilain - - Mimic fail2ban with pf for OpenBSD. Inspired from http://www.vincentdelft.be/post/post_20161106 -This repository is just for work. -See here for last vilain "stable" version : http://git.yeuxdelibad.net/vilain/ - - -In pf.conf, add : +In pf.conf, add according to your configuration : table <vilain_bruteforce> persist block quick from <vilain_bruteforce> @@ -23,4 +17,14 @@ pfctl -t vilain_bruteforce -T show +To start vilain at boot, add this in ``/etc/rc.local`` +``` +/usr/bin/tmux new -s vilain -d /usr/local/bin/vilain +``` + +Then, to attach to the tmux session, run : + +``` +tmux a -t vilain +``` diff --git a/vilain b/vilain index f0ea83e..3282278 100755 --- a/vilain +++ b/vilain @@ -1,179 +1,9 @@ -#!/usr/bin/env python3.4 -# -*- coding:Utf-8 -*- - - -""" -Author : thuban <thuban@yeuxdelibad.net> -Licence : MIT - -Description : Mimic fail2ban with pf for OpenBSD. - Inspired from http://www.vincentdelft.be/post/post_20161106 - - 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 sys -import os -import configparser -import re -import time -import logging -import subprocess -from multiprocessing import Process, Queue, TimeoutError - -configfile = "/etc/vilain.conf" -version = "0.1" -vilain_table = "vilain_bruteforce" -logfile = "/var/log/daemon" - -if os.geteuid() != 0: - print("Only root can use this tool") - sys.exit() - -# Configure logging -logger = logging.getLogger(__name__) -logging.basicConfig(filename=logfile, - format='%(asctime)s %(module)s:%(funcName)s:%(message)s', - datefmt='%H:%M:%S') -logger.setLevel(logging.DEBUG) -ch = logging.StreamHandler(sys.stdout) -logger.addHandler(ch) - -# functions -def readconfig(): - 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) - -def load_config(): - c = readconfig() - d = c.defaults() - watch_while = int(d['watch_while']) - maxtries = int(d['maxtries']) - vilain_table = d['vilain_table'] - return(watch_while, maxtries, vilain_table) - -def load_sections(): - c = readconfig() - for s in c.sections(): - logfile = c.get(s,'logfile') - regex = c.get(s,'regex') - d = {'name' : s, 'logfile':logfile, 'regex':regex} - yield d - - -def check_logs(logfile, regex, bad_ip_queue): - """ - worker who put in bad_ip_queue bruteforce IP - """ - if not os.path.isfile(logfile) : - logger.warning("{} doesn't exist".format(logfile)) - return - # Watch the file for changes - stat = os.stat(logfile) - size = stat.st_size - mtime = stat.st_mtime - RE = re.compile(regex) - while True: - time.sleep(0.5) - stat = os.stat(logfile) - if mtime < stat.st_mtime: - mtime = stat.st_mtime - with open(logfile, "rb") as f: - f.seek(size) - lines = f.readlines() - ul = [ u.decode() for u in lines ] - line = "".join(ul).strip() - - ret = RE.match(line) - if ret: - bad_ip = ret.groups()[0] - bad_ip_queue.put(bad_ip) - size = stat.st_size - -def ban_ips(bad_ip_queue, watch_while, maxtries, vilain_table): - """ - 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 - """ - - bad_ips_list = [] - ip_seen_at = {} - while True: - try: - ip = bad_ip_queue.get(0.5) - logger.info("{} detected".format(ip)) - bad_ips_list.append(ip) - ip_seen_at[ip] = time.time() - - n_ip = bad_ips_list.count(ip) - if n_ip >= maxtries: - logger.info("Blacklisting {}".format(ip)) - subprocess.call(["pfctl", "-t", vilain_table, "-T", "add", ip]) - ip_seen_at.pop(ip) - while ip in bad_ips_list: - bad_ips_list.remove(ip) - - to_remove = [] - for recorded_ip, last_seen in ip_seen_at.items(): - if time.time() - last_seen >= watch_while: - logger.info("{} not seen since a long time, forgetting...".format(recorded_ip)) - to_remove.append(recorded_ip) - for i in to_remove: - ip_seen_at.pop(i) - - except TimeoutError: - pass - except KeyboardInterrupt: - sys.exit(0) - -def main(): - os.chdir(os.path.dirname(os.path.abspath(__file__))) - - watch_while, maxtries, vilain_table = load_config() - bad_ip_queue = Queue() - workers = [] - - for entry in load_sections(): - logger.info("Start vilain for {}".format(entry['name'])) - worker = Process(target=check_logs, - args=(entry['logfile'], entry['regex'], bad_ip_queue)) - workers.append(worker) - worker.daemon = True - worker.start() - - ban_worker = Process(target=ban_ips, args=(bad_ip_queue, watch_while, maxtries)) - workers.append(ban_worker) - ban_worker.daemon = True - ban_worker.start() - - for w in workers: - w.join() - - return 0 - -if __name__ == '__main__': - main() - - -# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 - +#!/bin/sh +PYTHONVERSION=$(ls -l /usr/local/bin/python3.* |grep -Eo "3\.[0-9]" |tail -n1) +PYTHON="/usr/local/bin/python$PYTHONVERSION" +if [ -x $PYTHON ]; then + $PYTHON /usr/local/sbin/vilain.py >> /var/log/daemon 2>&1 & +else + echo "Error : no python3 executable found" +fi +exit diff --git a/vilain.1 b/vilain.1 new file mode 100644 index 0000000..7977626 --- /dev/null +++ b/vilain.1 @@ -0,0 +1,41 @@ +. +.TH vilain 28 "March 2017" "" "Fail2ban-like for OpenBSD" +.SH NAME +vilain \- fail2ban-like for OpenBSD +. +.SH SYNOPSIS +.RS +To use vilain : + 1. Copy configuration file in /etc/vilain.conf and configure according to your needs. + 2. Configure pf according to the example below + 3. start vilain via rcctl +You only need python >= 3.5 to use it. +.RE + +.SH DESCRIPTION +.RS +In pf.conf, add according to your configuration : + + 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 +.RE + +.SH LOGS +.RS +Watch vilain logs in /var/log/daemon. +.RE + + +.SH BUGS +.RS +Please let me know : <thuban@yeuxdelibad.net> +.RE + diff --git a/vilain.conf b/vilain.conf index 8fe5ffb..255438e 100644 --- a/vilain.conf +++ b/vilain.conf @@ -2,12 +2,17 @@ # 24h + 5min # Time to keep banned a bad ip watch_while = 86700 -# Max tries before being bannes +# Max tries before being banned maxtries = 3 # pf table to keep bad IP. # remember to clean the table with this command in a cron job : -# pfctl -t vilain_bruteforce -T expire 86400 +# /sbin/pfctl -t vilain_bruteforce -T expire 86400 vilain_table = vilain_bruteforce + +### Ip ignored ### +[ignoreip] +ip1 = 92.150.160.157 +ip2 = 92.150.160.156 ### Guardians #[name of the guardian] @@ -22,3 +27,28 @@ logfile = /var/log/authlog regex = .* Connection closed by ([\S]+) .* +#[http404] +#logfile = /var/www/logs/access.log +#regex = (?:\S+\s){1}(\S+).*\s404\s.* + +[http401] +logfile = /var/www/logs/access.log +regex = (?:\S+\s){1}(\S+).*\s401\s.* + +[http403] +logfile = /var/www/logs/access.log +regex = (?:\S+\s){1}(\S+).*\s403\s.* + +[smtp] +logfile = /var/log/maillog +regex = .* event=failed-command address=([\S]+) .* + +[dovecot] +logfile = /var/log/maillog +regex = .*auth failed.*rip=([\S]+),.* + +[wordpress] +# don't use if you have wordpress +logfile = /var/www/logs/access.log +regex = (?:\S+\s){1}(\S+).*wp-login.php.* + diff --git a/vilain.py b/vilain.py new file mode 100755 index 0000000..24e2ff4 --- /dev/null +++ b/vilain.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +# -*- coding:Utf-8 -*- + + +""" +Author : thuban <thuban@yeuxdelibad.net> +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 <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 sys +import os +import configparser +import re +import logging +import subprocess +import asyncio +import time +from multiprocessing import Process + +configfile = "/etc/vilain.conf" +version = "0.3" +vilain_table = "vilain_bruteforce" +logfile = "/var/log/daemon" +sleeptime = 0.5 + +if os.geteuid() != 0: + print("Only root can use this tool") + sys.exit() + +# Configure logging +logger = logging.getLogger(__name__) +logging.basicConfig(filename=logfile, + format='%(asctime)s %(module)s:%(funcName)s:%(message)s', + datefmt='%H:%M:%S') +logger.setLevel(logging.DEBUG) +ch = logging.StreamHandler(sys.stdout) +logger.addHandler(ch) + +# functions +def readconfig(): + 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) + +def load_config(): + c = readconfig() + d = c.defaults() + watch_while = int(d['watch_while']) + maxtries = int(d['maxtries']) + vilain_table = d['vilain_table'] + + 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) + +def load_sections(): + c = readconfig() + for s in c.sections(): + if c.has_option(s,'logfile'): + logfile = c.get(s,'logfile') + regex = c.get(s,'regex') + d = {'name' : s, 'logfile':logfile, 'regex':regex} + 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.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.ban_ips()) + + def start(self): + try: + self.loop.run_forever() + except KeyboardInterrupt: + self.loop.close() + finally: + self.loop.close() + + async def check_logs(self, logfile, 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 + mtime = stat.st_mtime + RE = re.compile(regex) + while True: + await asyncio.sleep(sleeptime) + stat = os.stat(logfile) + if mtime < stat.st_mtime: + mtime = stat.st_mtime + with open(logfile, "rb") as f: + f.seek(size) + lines = f.readlines() + ul = [ u.decode() for u in lines ] + line = "".join(ul).strip() + + ret = RE.match(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}) + await self.bad_ip_queue.put({'ip' : bad_ip, 'reason' : reason}) + 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 + """ + + bad_ips_list = [] + ip_seen_at = {} + while True: + await asyncio.sleep(sleeptime) + #if not len(s#elf.bad_ip_queue) > 0: + # continue + ip_item = await self.bad_ip_queue.get() + #ip_item = self.bad_ip_queue.pop() + 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) + + 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)) + to_remove.append(recorded_ip) + for i in to_remove: + ip_seen_at.pop(i) + + + + + +def main(): + os.chdir(os.path.dirname(os.path.abspath(__file__))) + v = Vilain() + p = Process(target=v.start()) + p.start() + return 0 + +if __name__ == '__main__': + main() + + +# vim: tabstop=4 expandtab shiftwidth=4 softtabstop=4 + diff --git a/vilain.rc b/vilain.rc new file mode 100755 index 0000000..e3f73bf --- /dev/null +++ b/vilain.rc @@ -0,0 +1,13 @@ +#!/bin/sh +# +# $OpenBSD: vilain.rc,v 1.0 2017/03/28 17:58:46 Thuban$ + +daemon="/usr/local/bin/vilain" + +. /etc/rc.d/rc.subr + +pexp="python3.*vilain\.py" +rc_reload=NO + +rc_cmd $1 + -- Gitblit v1.9.3