mirror of
https://github.com/ethauvin/fail2ban-digest.git
synced 2025-04-25 10:37:12 -07:00
publish to github
This commit is contained in:
parent
84e11044c8
commit
3066a12a4f
5 changed files with 348 additions and 0 deletions
62
README.md
Normal file
62
README.md
Normal 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
248
bin/fail2ban_digest
Executable 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)
|
||||
|
27
config/action.d/digest.conf
Normal file
27
config/action.d/digest.conf
Normal 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
|
||||
|
1
examples/fail2ban-digest.cron
Normal file
1
examples/fail2ban-digest.cron
Normal file
|
@ -0,0 +1 @@
|
|||
0 3 * * * root /usr/local/bin/fail2ban_digest maildigest sshd
|
10
examples/jail.local
Normal file
10
examples/jail.local
Normal 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
|
||||
|
Loading…
Add table
Add a link
Reference in a new issue