1
0
Fork 0
mirror of https://github.com/ethauvin/fail2ban-digest.git synced 2025-04-26 10:58:12 -07:00

publish to github

This commit is contained in:
Enrico Tagliavini 2017-05-15 16:45:04 +02:00
parent 84e11044c8
commit 3066a12a4f
5 changed files with 348 additions and 0 deletions

62
README.md Normal file
View file

@ -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

248
bin/fail2ban_digest Executable file
View file

@ -0,0 +1,248 @@
#!/usr/bin/env python3
# fail2ban-digest: an email digest aggregator for fail2ban.
# Copyright (C) 2017 Enrico Tagliavini <enrico.tagliavini@gmail.com>
#
# 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} <fail2ban@{0}>'.format(socket.gethostname()),
help = 'Use FROM address for the email From header. Default: Fail2ban at {0} <fail2ban@{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)

View file

@ -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 <name> <ip>
[Init]
# Default name of the chain
#
name = default

View file

@ -0,0 +1 @@
0 3 * * * root /usr/local/bin/fail2ban_digest maildigest sshd

10
examples/jail.local Normal file
View file

@ -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