diff --git a/config/action.d/nginx-block-map.conf b/config/action.d/nginx-block-map.conf
new file mode 100644
index 00000000..33c15f9c
--- /dev/null
+++ b/config/action.d/nginx-block-map.conf
@@ -0,0 +1,108 @@
+# Fail2Ban configuration file for black-listing via nginx
+#
+# Author: Serg G. Brester (aka sebres)
+#
+# To use 'nginx-block-map' action you should define some special blocks in your nginx configuration,
+# and use it hereafter in your locations (to notify fail2ban by failure, resp. nginx by ban).
+#
+# Example (argument "token_id" resp. cookie "session_id" used here as unique identifier for user):
+#
+# http {
+# ...
+# # maps to check user is blacklisted (banned in f2b):
+# #map $arg_token_id $blck_lst_tok { include blacklisted-tokens.map; }
+# map $cookie_session_id $blck_lst_ses { include blacklisted-sessions.map; }
+# ...
+# # special log-format to notify fail2ban about failures:
+# log_format f2b_session_errors '$msec failure "$cookie_session_id" - $remote_addr - $remote_user '
+# ;# '"$request" $status $bytes_sent '
+# # '"$http_referer" "$http_user_agent"';
+#
+# # location checking blacklisted values:
+# location ... {
+# # check banned sessionid:
+# if ($blck_lst_ses != "") {
+# try_files "" @f2b-banned;
+# }
+# ...
+# # notify fail2ban about a failure inside nginx:
+# error_page 401 = @notify-f2b;
+# ...
+# }
+# ...
+# # location for return with "403 Forbidden" if banned:
+# location @f2b-banned {
+# default_type text/html;
+# return 403 "
+#
+# You are banned!";
+# }
+# ...
+# # location to notify fail2ban about a failure inside nginx:
+# location @notify-f2b {
+# access_log /var/log/nginx/f2b-auth-errors.log f2b_session_errors;
+# }
+# }
+# ...
+#
+# Note that quote-character (and possibly other special characters) are not allowed currently as session-id.
+# Thus please add any session-id validation rule in your locations (or in the corresponding backend-service),
+# like in example below:
+#
+# location ... {
+# if ($cookie_session_id !~ "^[\w\-]+$") {
+# return 403 "Wrong session-id"
+# }
+# ...
+# }
+#
+# The parameters for jail corresponding log-format (f2b_session_errors):
+#
+# [nginx-blck-lst]
+# filter =
+# datepattern = ^Epoch
+# failregex = ^ failure "[^"]+" -
+# usedns = no
+#
+# The same log-file can be used for IP-related jail (additionally to session-related, to ban very bad IPs):
+#
+# [nginx-blck-ip]
+# maxretry = 100
+# filter =
+# datepattern = ^Epoch
+# failregex = ^ failure "[^"]+" -
+# usedns = no
+#
+
+[Definition]
+
+# path to configuration of nginx (used to target nginx-instance in multi-instance system,
+# and as path for the blacklisted map):
+srv_cfg_path = /etc/nginx/
+
+# cmd-line arguments to supply to test/reload nginx:
+#srv_cmd = nginx -c %(srv_cfg_path)s/nginx.conf
+srv_cmd = nginx
+
+# first test configuration is correct, hereafter send reload signal:
+blck_lst_reload = %(srv_cmd)s -qt; if [ $? -eq 0 ]; then
+ %(srv_cmd)s -s reload; if [ $? -ne 0 ]; then echo 'reload failed.'; fi;
+ fi;
+
+# map-file for nginx, can be redefined using `action = nginx-block-map[blck_lst_file="/path/file.map"]`:
+blck_lst_file = %(srv_cfg_path)s/blacklisted-sessions.map
+
+# Action definition:
+
+actionstart_on_demand = false
+actionstart = touch '%(blck_lst_file)s'
+
+actionflush = truncate -s 0 '%(blck_lst_file)s'; %(blck_lst_reload)s
+
+actionstop = %(actionflush)s
+
+actioncheck =
+
+actionban = echo "\\\\ 1;" >> '%(blck_lst_file)s'; %(blck_lst_reload)s
+
+actionunban = id=$(echo "" | sed -e 's/[]\/$*.^|[]/\\&/g'); sed -i "/$id 1;/d" %(blck_lst_file)s; %(blck_lst_reload)s
diff --git a/fail2ban/tests/fail2banclienttestcase.py b/fail2ban/tests/fail2banclienttestcase.py
index 083ce815..befef926 100644
--- a/fail2ban/tests/fail2banclienttestcase.py
+++ b/fail2ban/tests/fail2banclienttestcase.py
@@ -115,11 +115,12 @@ fail2banserver.PRODUCTION = False
def _out_file(fn, handle=logSys.debug):
"""Helper which outputs content of the file at HEAVYDEBUG loglevels"""
- handle('---- ' + fn + ' ----')
- for line in fileinput.input(fn):
- line = line.rstrip('\n')
- handle(line)
- handle('-'*30)
+ if (handle != logSys.debug or logSys.getEffectiveLevel() <= logging.DEBUG):
+ handle('---- ' + fn + ' ----')
+ for line in fileinput.input(fn):
+ line = line.rstrip('\n')
+ handle(line)
+ handle('-'*30)
def _write_file(fn, mode, *lines):
@@ -127,8 +128,19 @@ def _write_file(fn, mode, *lines):
f.write('\n'.join(lines))
f.close()
+def _read_file(fn):
+ f = None
+ try:
+ f = open(fn)
+ return f.read()
+ finally:
+ if f is not None:
+ f.close()
-def _start_params(tmp, use_stock=False, logtarget="/dev/null", db=":memory:"):
+
+def _start_params(tmp, use_stock=False, use_stock_cfg=None,
+ logtarget="/dev/null", db=":memory:", jails=("",), create_before_start=None
+):
cfg = pjoin(tmp, "config")
if db == 'auto':
db = pjoin(tmp, "f2b-db.sqlite3")
@@ -138,8 +150,7 @@ def _start_params(tmp, use_stock=False, logtarget="/dev/null", db=":memory:"):
"""Filters list of 'files' to contain only directories (under dir)"""
return [f for f in files if isdir(pjoin(dir, f))]
shutil.copytree(STOCK_CONF_DIR, cfg, ignore=ig_dirs)
- os.symlink(os.path.abspath(pjoin(STOCK_CONF_DIR, "action.d")), pjoin(cfg, "action.d"))
- os.symlink(os.path.abspath(pjoin(STOCK_CONF_DIR, "filter.d")), pjoin(cfg, "filter.d"))
+ use_stock_cfg = ('action.d', 'filter.d')
# replace fail2ban params (database with memory):
r = re.compile(r'^dbfile\s*=')
for line in fileinput.input(pjoin(cfg, "fail2ban.conf"), inplace=True):
@@ -170,13 +181,21 @@ def _start_params(tmp, use_stock=False, logtarget="/dev/null", db=":memory:"):
"",
)
_write_file(pjoin(cfg, "jail.conf"), "w",
- "[INCLUDES]", "",
- "[DEFAULT]", "",
- "",
+ *((
+ "[INCLUDES]", "",
+ "[DEFAULT]", "tmp = " + tmp, "",
+ )+jails)
)
if unittest.F2B.log_level < logging.DEBUG: # pragma: no cover
_out_file(pjoin(cfg, "fail2ban.conf"))
_out_file(pjoin(cfg, "jail.conf"))
+ # link stock actions and filters:
+ if use_stock_cfg and STOCK:
+ for n in use_stock_cfg:
+ os.symlink(os.path.abspath(pjoin(STOCK_CONF_DIR, n)), pjoin(cfg, n))
+ if create_before_start:
+ for n in create_before_start:
+ _write_file(n % {'tmp': tmp}, 'w', '')
# parameters (sock/pid and config, increase verbosity, set log, etc.):
vvv, llev = (), "INFO"
if unittest.F2B.log_level < logging.INFO: # pragma: no cover
@@ -191,17 +210,13 @@ def _start_params(tmp, use_stock=False, logtarget="/dev/null", db=":memory:"):
)
def _get_pid_from_file(pidfile):
- f = pid = None
+ pid = None
try:
- f = open(pidfile)
- pid = f.read()
+ pid = _read_file(pidfile)
pid = re.match(r'\S+', pid).group()
return int(pid)
except Exception as e: # pragma: no cover
logSys.debug(e)
- finally:
- if f is not None:
- f.close()
return pid
def _kill_srv(pidfile):
@@ -297,12 +312,13 @@ def with_foreground_server_thread(startextra={}):
finally:
DefLogSys.info('=== within server: end. ===')
self.pruneLog()
- # stop:
- self.execSuccess(startparams, "stop")
- # wait for end:
- Utils.wait_for(lambda: phase.get('end', None) is not None, MAX_WAITTIME)
- self.assertTrue(phase.get('end', None))
- self.assertLogged("Shutdown successful", "Exiting Fail2ban")
+ # stop (if still running):
+ if not phase.get('end', None):
+ self.execSuccess(startparams, "stop")
+ # wait for end:
+ Utils.wait_for(lambda: phase.get('end', None) is not None, MAX_WAITTIME)
+ self.assertTrue(phase.get('end', None))
+ self.assertLogged("Shutdown successful", "Exiting Fail2ban", all=True)
finally:
if th:
# we start client/server directly in current process (new thread),
@@ -1155,3 +1171,74 @@ class Fail2banServerTest(Fail2banClientServerBase):
self.assertLogged(
"Jail 'test-jail1' stopped",
"Jail 'test-jail1' started", all=True)
+
+ # test action.d/nginx-block-map.conf --
+ @with_foreground_server_thread(startextra={
+ # create log-file (avoid "not found" errors):
+ 'create_before_start': ('%(tmp)s/blck-failures.log',),
+ # we need action.d/nginx-block-map.conf:
+ 'use_stock_cfg': ('action.d',),
+ # jail-config:
+ 'jails': (
+ '[nginx-blck-lst]',
+ 'backend = polling',
+ 'usedns = no',
+ 'logpath = %(tmp)s/blck-failures.log',
+ 'action = nginx-block-map[blck_lst_reload="", blck_lst_file="%(tmp)s/blck-lst.map"]',
+ 'filter =',
+ 'datepattern = ^Epoch',
+ 'failregex = ^ failure "[^"]+" - ',
+ 'maxretry = 1', # ban by first failure
+ 'enabled = true',
+ )
+ })
+ def testServerActions_NginxBlockMap(self, tmp, startparams):
+ cfg = pjoin(tmp, "config")
+ lgfn = '%(tmp)s/blck-failures.log' % {'tmp': tmp}
+ mpfn = '%(tmp)s/blck-lst.map' % {'tmp': tmp}
+ # ban sessions (write log like nginx does it with f2b_session_errors log-format):
+ _write_file(lgfn, "w+",
+ str(int(MyTime.time())) + ' failure "125-000-001" - 192.0.2.1',
+ str(int(MyTime.time())) + ' failure "125-000-002" - 192.0.2.1',
+ str(int(MyTime.time())) + ' failure "125-000-003" - 192.0.2.1',
+ str(int(MyTime.time())) + ' failure "125-000-004" - 192.0.2.1',
+ str(int(MyTime.time())) + ' failure "125-000-005" - 192.0.2.1',
+ )
+ # check all sessions are banned (and blacklisted in map-file):
+ self.assertLogged(
+ "[nginx-blck-lst] Ban 125-000-001",
+ "[nginx-blck-lst] Ban 125-000-002",
+ "[nginx-blck-lst] Ban 125-000-003",
+ "[nginx-blck-lst] Ban 125-000-004",
+ "[nginx-blck-lst] Ban 125-000-005",
+ "Banned 5 / 5, 5 ticket(s)",
+ all=True, wait=MID_WAITTIME
+ )
+ _out_file(mpfn)
+ mp = _read_file(mpfn)
+ self.assertIn('\\125-000-001 1;\n', mp)
+ self.assertIn('\\125-000-002 1;\n', mp)
+ self.assertIn('\\125-000-003 1;\n', mp)
+ self.assertIn('\\125-000-004 1;\n', mp)
+ self.assertIn('\\125-000-005 1;\n', mp)
+
+ # unban 1, 2 and 5:
+ self.execSuccess(startparams, 'unban', '125-000-001', '125-000-002', '125-000-005')
+ _out_file(mpfn)
+ # check really unbanned but other sessions are still present (blacklisted in map-file):
+ mp = _read_file(mpfn)
+ self.assertNotIn('\\125-000-001 1;\n', mp)
+ self.assertNotIn('\\125-000-002 1;\n', mp)
+ self.assertNotIn('\\125-000-005 1;\n', mp)
+ self.assertIn('\\125-000-003 1;\n', mp)
+ self.assertIn('\\125-000-004 1;\n', mp)
+
+ # stop server and wait for end:
+ self.execSuccess(startparams, 'stop')
+ self.assertLogged("Shutdown successful", "Exiting Fail2ban", all=True, wait=MID_WAITTIME)
+
+ # check flushed (all sessions were deleted from map-file):
+ self.assertLogged("[nginx-blck-lst] Flush ticket(s) with nginx-block-map")
+ _out_file(mpfn)
+ mp = _read_file(mpfn)
+ self.assertEqual(mp, '')