diff --git a/README.md b/README.md new file mode 100644 index 0000000..b97e4c7 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +## fail2ban-digest: email digest aggregator for fail2ban + +Fail2ban can be annoying for the large volumes of emails it can send. +Disabling email sending completely can solve the annoyance, but leaves +the Admin uninformed. With fail2ban-digest you can configure a arbitrary +period digest to be send, like + + + Hi, + + this is a digest email of banned IPs since 2017-05-15 00:11:41 UTC and 2017-05-15 01:00:03 + + 20 event(s) for IP 0.0.0.1 : 2017-05-14 03:20:54, 2017-05-14 04:03:28, 2017-05-14 04:45:03, 2017-05-14 05:27:02, 2017-05-14 06:50:40, 2017-05-14 07:18:32, 2017-05-14 08:00:38, 2017-05-14 08:43:21, 2017-05-14 09:26:09, 2017-05-14 10:22:46, 2017-05-14 10:50:49, 2017-05-14 11:32:33, 2017-05-14 12:28:12, 2017-05-14 13:38:53, 2017-05-14 14:21:42, 2017-05-14 15:04:04, 2017-05-14 15:46:40, 2017-05-14 16:43:08, 2017-05-14 17:25:12, 2017-05-14 18:07:17 + 4 event(s) for IP 0.0.0.2 : 2017-05-14 18:35:51, 2017-05-14 19:05:13, 2017-05-14 22:12:05, 2017-05-14 23:46:37 + 3 event(s) for IP 0.0.0.3 : 2017-05-14 19:43:37, 2017-05-14 21:42:28, 2017-05-15 00:52:59 + + Regards, + + Fail2ban digest + + +The digest can also be shown on the command line by calling + $ fail2ban_digest digest sshd + 2 event(s) for IP 0.0.0.1 : 2017-05-15 06:48:35, 2017-05-15 07:06:37 + 1 event(s) for IP 0.0.0.2 : 2017-05-15 16:09:51 + +Using the digest action for command line display will not empty the database by default, while send the email digest will empty it. +This can be changed by using the `--delete` and `--no-delete` options. + +Setup +------------- + +Required dependencies: +- Python 3.4 or newer is required. It might work with python 3.3, but testing was with 3.4 and newer only. +- [Fail2ban](https://github.com/fail2ban/fail2ban) version 0.9 or newer + +There is no setup script / build system at the moment. It might come in the future. + +Manual Installation: +- Copy `config/action.d/digest.conf` in `/etc/fail2ban/action.d` +- Create a directory for the database, a common location on most Linux systems can be `/var/lib/fail2ban/digest` +- If necessary adjust the path in `bin/fail2ban_digest` script to point to the correct `db_location` +- Copy `bin/fail2ban_digest` in a system folder +- Configure the sshd jail to use the digest action as shown in the `examples/jail.local` file +- Configure a periodic cron job to send the mail digest. An example for a daily mail can be found in examples/fail2ban-digest.cron + +License: +-------- + +Fail2Ban is free software; you can redistribute it and/or modify it under the +terms of the GNU General Public License as published by the Free Software +Foundation; either version 2 of the License, or (at your option) any later +version. + +Fail2Ban is distributed in the hope that it will be useful, but WITHOUT ANY +WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A +PARTICULAR PURPOSE. See the GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along with +Fail2Ban; if not, write to the Free Software Foundation, Inc., 51 Franklin +Street, Fifth Floor, Boston, MA 02110, USA + diff --git a/bin/fail2ban_digest b/bin/fail2ban_digest new file mode 100755 index 0000000..71fea35 --- /dev/null +++ b/bin/fail2ban_digest @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 + +# fail2ban-digest: an email digest aggregator for fail2ban. +# Copyright (C) 2017 Enrico Tagliavini +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +from datetime import datetime, timedelta +from email.message import EmailMessage +from smtplib import SMTP +from string import Template +from time import sleep +from traceback import print_exc + +import argparse +import atexit +import dbm +import errno +import os +import socket +import re +import sys + +db_location = '/var/lib/fail2ban/digest' +db_creation_date_key = 'db_creation_date' +db_date_format = '%Y-%m-%d %H:%M:%S' +default_mail_template = Template('''Hi,\n + this is a digest email of banned IPs since ${creation_date} UTC and ${date_now} + +${digest} + +Regards, + +Fail2ban digest +''') + +class store_yesno(argparse.Action): + def __init__(self, option_strings, dest, nargs = None, **kwargs): + if nargs is not None: + raise ValueError("nargs not allowed") + super(store_yesno, self).__init__(option_strings, dest, nargs = 0, **kwargs) + + def __call__(self, parser, namespace, values, option_string = None): + if option_string.startswith('--no-'): + setattr(namespace, self.dest, False) + else: + setattr(namespace, self.dest, True) + return + +ipv4_re = re.compile(r'^(\d){1,3}\.(\d){1,3}\.(\d){1,3}\.(\d){1,3}$') + +def ip_address(string): + valid = 2 + try: + socket.inet_pton(socket.AF_INET, string) + # this can match also stuff like '4' or '127.1' or '127.1.256' + # most likely a typo rather than intentional, so refuse it + if ipv4_re.search(string) is None: + valid -= 1 + except socket.error: + valid -= 1 + try: + socket.inet_pton(socket.AF_INET6, string) + except socket.error: + valid -= 1 + if valid > 0: + return string + else: + raise argparse.ArgumentTypeError('%s is not a valid IPv4 or IPv6 address' % string) + +def close_db(db_fd): + db_fd.close() + atexit.unregister(close_db) + return + +def db_busy_open(filename, flags, timeout): + start = datetime.utcnow() + threshold = timedelta(seconds = timeout) + while True: + try: + db = dbm.open(filename, flags) + break + except OSError as e: + if e.errno == errno.EAGAIN: # errno 11, try again in a moment + sleep(1) + else: + raise e + if datetime.utcnow() - start > threshold: + raise TimeoutError(errno.ETIMEDOUT, 'timeout while waiting for database file to be unlocked', filename) + atexit.register(close_db, db) + return db + +def add(db, ip): + db = db_busy_open(db_location + '/' + db + '.dbm', 'c', 30) + if db_creation_date_key not in db.keys(): + db[db_creation_date_key] = datetime.utcnow().strftime(db_date_format).encode('UTF-8') + event_date = ('%s, ' % datetime.now().strftime(db_date_format)).encode('UTF-8') + try: + db[ip] += event_date + except KeyError: + db[ip] = event_date + close_db(db) + return + +def digest(db, delete): + db_file = db_location + '/' + db + '.dbm' + db = db_busy_open(db_file, 'r', 30) # this is just a trick to lock the DB, to have a consistent copy + if delete: + #os.rename(db_file, db_file + '.digest') # just in case we want to delay the removal in the future + os.unlink(db_file) + try: + db_creation_date = db[db_creation_date_key].decode('UTF-8') + except KeyError as e: + db_creation_date = 'not found' + event_list = [] + for ip in db.keys(): + if ip.decode('UTF-8') == db_creation_date_key: + continue + event_list.append((ip.decode('UTF-8'), db[ip].decode('UTF-8').split(', ')[:-1])) + close_db(db) + event_list.sort(key = lambda x: len(x[1]), reverse = True) + msg = '' + for ip, events in event_list: + msg += '%3d event(s) for IP %-42s: %s\n' %(len(events), ip, ', '.join(events)) + return (db_creation_date, msg) + +def mail_digest(db, mail_to, mail_from, delete): + msg = EmailMessage() + date_now = datetime.utcnow().strftime(db_date_format) + creation_date, dgst = digest(db, delete) + msg.set_content(default_mail_template.substitute( + creation_date = creation_date, + date_now = date_now, + digest = dgst, + )) + msg['To'] = mail_to + msg['From'] = mail_from + msg['Subject'] = '[Fail2Ban] %s: digest for %s %s' % (db, socket.gethostname(), date_now) + mta = SMTP(host = 'localhost') + mta.send_message(msg) + mta.quit() + return + +def main(args): + if args.cmd == 'add': + add(args.database, args.ip) + elif args.cmd == 'digest': + print(digest(args.database, args.delete)[1]) + elif args.cmd == 'maildigest': + mail_digest(args.database, args.to, args.mail_from, args.delete) + return + +if __name__ == '__main__': + progname = os.path.basename(sys.argv[0]) + parser = argparse.ArgumentParser( + prog = progname, + description = 'Gather fail2ban events to process periodically and generate a digest', + epilog = 'use `%s command --help\' to get help about a specific command' % progname, + ) + subparsers = parser.add_subparsers(title = 'available commands') + + subcommands = {} + sc = 'add' + subcommands[sc] = subparsers.add_parser( + sc, + description = 'Add a new event to the DB' + ) + subcommands[sc].add_argument( + 'database', + help = 'database where to store the event' + ) + + subcommands[sc].add_argument( + 'ip', + type = ip_address, + help = 'offending IP address, both IPv4 and IPv6 are accepted' + ) + subcommands[sc].set_defaults(cmd = sc) + + sc = 'digest' + subcommands[sc] = subparsers.add_parser( + sc, + description = 'print database digest to standard output' + ) + subcommands[sc].add_argument( + 'database', + help = 'database to generate the digest from' + ) + subcommands[sc].add_argument( + '--delete', '--no-delete', + action = store_yesno, + default = False, + help = 'do / don\'t delete current database, next call to add will create a new empty one' + ) + subcommands[sc].set_defaults(cmd = sc) + + sc = 'maildigest' + subcommands[sc] = subparsers.add_parser( + sc, + description = 'send database digest via email' + ) + subcommands[sc].add_argument( + 'database', + help = 'database to generate the digest from' + ) + subcommands[sc].add_argument( + '--delete', '--no-delete', + action = store_yesno, + default = True, + help = 'do / don\'t delete current database, next call to add will create a new empty one' + ) + subcommands[sc].add_argument( + '--mail-from', + action = 'store', + default = 'Fail2ban at {0} '.format(socket.gethostname()), + help = 'Use FROM address for the email From header. Default: Fail2ban at {0} (automatically detected system hostname)'.format(socket.gethostname()) + ) + subcommands[sc].add_argument( + '--to', + action = 'store', + default = 'root', + help = 'send email to specified user / address. Default is root' + ) + subcommands[sc].set_defaults(cmd = sc) + + + args = parser.parse_args(sys.argv[1:]) + #print(args) + try: + main(args) + except KeyboardInterrupt: + sys.exit(1) + except Exception: + print_exc() + sys.exit(1) + diff --git a/config/action.d/digest.conf b/config/action.d/digest.conf new file mode 100644 index 0000000..bb1f23a --- /dev/null +++ b/config/action.d/digest.conf @@ -0,0 +1,27 @@ +# Fail2Ban configuration file +# +# Author: Enrico Tagliavini +# +# + +[INCLUDES] + +# this will send and email for jail start / stop. Can be removed if not needed +before = sendmail-common.conf + +[Definition] + +# Option: actionban +# Notes.: Command executed when banning an IP. Take care that the +# command is executed with Fail2Ban user rights. +# Tags: See jail.conf(5) man page +# Values: CMD +# +actionban = /usr/local/bin/fail2ban_digest add + +[Init] + +# Default name of the chain +# +name = default + diff --git a/examples/fail2ban-digest.cron b/examples/fail2ban-digest.cron new file mode 100644 index 0000000..d6abdff --- /dev/null +++ b/examples/fail2ban-digest.cron @@ -0,0 +1 @@ +0 3 * * * root /usr/local/bin/fail2ban_digest maildigest sshd diff --git a/examples/jail.local b/examples/jail.local new file mode 100644 index 0000000..38872f7 --- /dev/null +++ b/examples/jail.local @@ -0,0 +1,10 @@ +[DEFAULT] +destemail = root + +action_digest = %(banaction)s[name=%(__name__)s, port="%(port)s", protocol="%(protocol)s", chain="%(chain)s"] + digest[name=%(__name__)s, chain="%(chain)s"] + +[sshd] +enabled = true +action = %(action_digest)s +