diff --git a/ChangeLog b/ChangeLog index 6e4964f3..29ab62f0 100644 --- a/ChangeLog +++ b/ChangeLog @@ -20,6 +20,9 @@ TODO: implementing of options resp. other tasks from PR #1346 * testSocket: sporadical bug repaired - wait for server thread starts a socket (listener) * testExecuteTimeoutWithNastyChildren: sporadical bug repaired - wait for pid file inside bash, kill tree in any case (gh-1155) +* purge database will be executed now (within observer). +* restoring currently banned ip after service restart fixed + (now < timeofban + bantime), ignore old log failures (already banned) ### New Features * IPv6 support: @@ -33,6 +36,9 @@ TODO: implementing of options resp. other tasks from PR #1346 - new conditional section functionality used in config resp. includes: - [Init?family=inet4] - IPv4 qualified hosts only - [Init?family=inet6] - IPv6 qualified hosts only +* Increment ban time (+ observer) functionality introduced. + Thanks Serg G. Brester (sebres) +* Database functionality extended with bad ips. ### Enhancements * huge increasing of fail2ban performance and especially test-cases performance (see gh-1109) diff --git a/MANIFEST b/MANIFEST index 512b1d03..56a53bec 100644 --- a/MANIFEST +++ b/MANIFEST @@ -195,6 +195,7 @@ fail2ban/server/jail.py fail2ban/server/jails.py fail2ban/server/jailthread.py fail2ban/server/mytime.py +fail2ban/server/observer.py fail2ban/server/server.py fail2ban/server/strptime.py fail2ban/server/ticket.py @@ -247,6 +248,7 @@ fail2ban/tests/files/config/apache-auth/digest_wrongrelm/.htpasswd fail2ban/tests/files/config/apache-auth/noentry/.htaccess fail2ban/tests/files/config/apache-auth/README fail2ban/tests/files/database_v1.db +fail2ban/tests/files/database_v2.db fail2ban/tests/files/filter.d/substition.conf fail2ban/tests/files/filter.d/testcase01.conf fail2ban/tests/files/filter.d/testcase-common.conf @@ -343,6 +345,7 @@ fail2ban/tests/files/testcase-wrong-char.log fail2ban/tests/filtertestcase.py fail2ban/tests/__init__.py fail2ban/tests/misctestcase.py +fail2ban/tests/observertestcase.py fail2ban/tests/samplestestcase.py fail2ban/tests/servertestcase.py fail2ban/tests/sockettestcase.py diff --git a/THANKS b/THANKS index c8a019a6..04e4cdc7 100644 --- a/THANKS +++ b/THANKS @@ -109,7 +109,7 @@ Russell Odom SATO Kentaro Sean DuBois Sebastian Arcus -Serg G. Brester +Serg G. Brester (sebres) Sireyessire silviogarbes Stefan Tatschner diff --git a/config/jail.conf b/config/jail.conf index b7c927e2..95b8e78b 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -44,6 +44,44 @@ before = paths-debian.conf # MISCELLANEOUS OPTIONS # +# "bantime.increment" allows to use database for searching of previously banned ip's to increase a +# default ban time using special formula, default it is banTime * 1, 2, 4, 8, 16, 32... +#bantime.increment = true + +# "bantime.rndtime" is the max number of seconds using for mixing with random time +# to prevent "clever" botnets calculate exact time IP can be unbanned again: +#bantime.rndtime = + +# "bantime.maxtime" is the max number of seconds using the ban time can reach (don't grows further) +#bantime.maxtime = + +# "bantime.factor" is a coefficient to calculate exponent growing of the formula or common multiplier, +# default value of factor is 1 and with default value of formula, the ban time +# grows by 1, 2, 4, 8, 16 ... +#bantime.factor = 1 + +# "bantime.formula" used by default to calculate next value of ban time, default value bellow, +# the same ban time growing will be reached by multipliers 1, 2, 4, 8, 16, 32... +#bantime.formula = ban.Time * (1<<(ban.Count if ban.Count<20 else 20)) * banFactor +# +# more aggressive example of formula has the same values only for factor "2.0 / 2.885385" : +#bantime.formula = ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor) + +# "bantime.multipliers" used to calculate next value of ban time instead of formula, coresponding +# previously ban count and given "bantime.factor" (for multipliers default is 1); +# following example grows ban time by 1, 2, 4, 8, 16 ... and if last ban count greater as multipliers count, +# always used last multiplier (64 in example), for factor '1' and original ban time 600 - 10.6 hours +#bantime.multipliers = 1 2 4 8 16 32 64 +# following example can be used for small initial ban time (bantime=60) - it grows more aggressive at begin, +# for bantime=60 the multipliers are minutes and equal: 1 min, 5 min, 30 min, 1 hour, 5 hour, 12 hour, 1 day, 2 day +#bantime.multipliers = 1 5 30 60 300 720 1440 2880 + +# "bantime.overalljails" (if true) specifies the search of IP in the database will be executed +# cross over all jails, if false (dafault), only current jail of the ban IP will be searched +#bantime.overalljails = false + +# -------------------- + # "ignoreip" can be an IP address, a CIDR mask or a DNS host. Fail2ban will not # ban a host which matches an address in this list. Several addresses can be # defined using space (and/or comma) separator. diff --git a/fail2ban/client/jailreader.py b/fail2ban/client/jailreader.py index 5f2e64b2..101cb167 100644 --- a/fail2ban/client/jailreader.py +++ b/fail2ban/client/jailreader.py @@ -106,6 +106,13 @@ class JailReader(ConfigReader): ["int", "maxretry", None], ["string", "findtime", None], ["string", "bantime", None], + ["bool", "bantime.increment", None], + ["string", "bantime.factor", None], + ["string", "bantime.formula", None], + ["string", "bantime.multipliers", None], + ["string", "bantime.maxtime", None], + ["string", "bantime.rndtime", None], + ["bool", "bantime.overalljails", None], ["string", "usedns", None], ["string", "failregex", None], ["string", "ignoreregex", None], @@ -225,6 +232,8 @@ class JailReader(ConfigReader): stream.append(["set", self.__name, "findtime", value]) elif opt == "bantime": stream.append(["set", self.__name, "bantime", value]) + elif opt.startswith("bantime."): + stream.append(["set", self.__name, opt, self.__opts[opt]]) elif opt == "usedns": stream.append(["set", self.__name, "usedns", value]) elif opt in ("failregex", "ignoreregex"): diff --git a/fail2ban/server/action.py b/fail2ban/server/action.py index f579c31e..dead08f6 100644 --- a/fail2ban/server/action.py +++ b/fail2ban/server/action.py @@ -569,8 +569,5 @@ class CommandAction(ActionBase): logSys.debug("Nothing to do") return True - _cmd_lock.acquire() - try: + with _cmd_lock: return Utils.executeCmd(realCmd, timeout, shell=True, output=False) - finally: - _cmd_lock.release() diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 050c12e6..946cdbdd 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -39,6 +39,7 @@ except ImportError: OrderedDict = None from .banmanager import BanManager +from .observer import Observers from .jailthread import JailThread from .action import ActionBase, CommandAction, CallingMap from .mytime import MyTime @@ -299,11 +300,18 @@ class Actions(JailThread, Mapping): if ticket: aInfo = CallingMap() bTicket = BanManager.createBanTicket(ticket) + if ticket.getBanTime() is not None: + bTicket.setBanTime(ticket.getBanTime()) + bTicket.setBanCount(ticket.getBanCount()) + if ticket.getRestored(): + bTicket.setRestored(True) ip = bTicket.getIP() aInfo["ip"] = ip aInfo["failures"] = bTicket.getAttempt() aInfo["time"] = bTicket.getTime() aInfo["matches"] = "\n".join(bTicket.getMatches()) + btime = bTicket.getBanTime(self.__banManager.getBanTime()) + # retarded merge info via twice lambdas : once for merge, once for matches/failures: if self._jail.database is not None: mi4ip = lambda overalljails=False, self=self, \ mi={'ip':ip, 'ticket':bTicket}: self.__getBansMerged(mi, overalljails) @@ -311,8 +319,20 @@ class Actions(JailThread, Mapping): aInfo["ipjailmatches"] = lambda: "\n".join(mi4ip().getMatches()) aInfo["ipfailures"] = lambda: mi4ip(True).getAttempt() aInfo["ipjailfailures"] = lambda: mi4ip().getAttempt() + + if btime != -1: + bendtime = aInfo["time"] + btime + # check ban is not too old : + if bendtime < MyTime.time(): + logSys.info('[%s] Ignore %s, expired bantime', self._jail.name, ip) + return False + if self.__banManager.addBanTicket(bTicket): - logSys.notice("[%s] Ban %s" % (self._jail.name, aInfo["ip"])) + # report ticket to observer, to check time should be increased and hereafter observer writes ban to database (asynchronous) + if Observers.Main is not None and not bTicket.getRestored(): + Observers.Main.add('banFound', bTicket, self._jail, btime) + logSys.notice("[%s] %sBan %s", self._jail.name, ('' if not bTicket.getRestored() else 'Restore '), aInfo["ip"]) + # do actions : for name, action in self._actions.iteritems(): try: action.ban(aInfo.copy()) @@ -324,8 +344,7 @@ class Actions(JailThread, Mapping): exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) return True else: - logSys.notice("[%s] %s already banned" % (self._jail.name, - aInfo["ip"])) + logSys.notice("[%s] %s already banned" % (self._jail.name, aInfo["ip"])) return False def __checkUnBan(self): diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index 46d40051..c9ce74d3 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -127,7 +127,7 @@ class Fail2BanDb(object): filename purgeage """ - __version__ = 2 + __version__ = 4 # Note all _TABLE_* strings must end in ';' for py26 compatibility _TABLE_fail2banDb = "CREATE TABLE fail2banDb(version INTEGER);" _TABLE_jails = "CREATE TABLE jails(" \ @@ -154,6 +154,8 @@ class Fail2BanDb(object): "jail TEXT NOT NULL, " \ "ip TEXT, " \ "timeofban INTEGER NOT NULL, " \ + "bantime INTEGER NOT NULL, " \ + "bancount INTEGER NOT NULL default 1, " \ "data JSON, " \ "FOREIGN KEY(jail) REFERENCES jails(name) " \ ");" \ @@ -161,8 +163,21 @@ class Fail2BanDb(object): "CREATE INDEX bans_jail_ip ON bans(jail, ip);" \ "CREATE INDEX bans_ip ON bans(ip);" \ + _TABLE_bips = "CREATE TABLE bips(" \ + "ip TEXT NOT NULL, " \ + "jail TEXT NOT NULL, " \ + "timeofban INTEGER NOT NULL, " \ + "bantime INTEGER NOT NULL, " \ + "bancount INTEGER NOT NULL default 1, " \ + "data JSON, " \ + "PRIMARY KEY(ip, jail), " \ + "FOREIGN KEY(jail) REFERENCES jails(name) " \ + ");" \ + "CREATE INDEX bips_timeofban ON bips(timeofban);" \ + "CREATE INDEX bips_ip ON bips(ip);" \ - def __init__(self, filename, purgeAge=24*60*60): + + def __init__(self, filename, purgeAge=24*60*60, outDatedFactor=3): self.maxEntries = 50 try: self._lock = RLock() @@ -171,6 +186,7 @@ class Fail2BanDb(object): detect_types=sqlite3.PARSE_DECLTYPES) self._dbFilename = filename self._purgeAge = purgeAge + self._outDatedFactor = outDatedFactor; self._bansMergedCache = {} @@ -253,6 +269,8 @@ class Fail2BanDb(object): cur.executescript(Fail2BanDb._TABLE_logs) # Bans cur.executescript(Fail2BanDb._TABLE_bans) + # BIPs (bad ips) + cur.executescript(Fail2BanDb._TABLE_bips) cur.execute("SELECT version FROM fail2banDb LIMIT 1") return cur.fetchone()[0] @@ -281,6 +299,20 @@ class Fail2BanDb(object): "UPDATE fail2banDb SET version = 2;" "COMMIT;" % Fail2BanDb._TABLE_logs) + if version < 3: + cur.executescript("BEGIN TRANSACTION;" + "CREATE TEMPORARY TABLE bans_temp AS SELECT jail, ip, timeofban, 600 as bantime, 1 as bancount, data FROM bans;" + "DROP TABLE bans;" + "%s;" + "INSERT INTO bans SELECT * from bans_temp;" + "DROP TABLE bans_temp;" + "COMMIT;" % Fail2BanDb._TABLE_bans) + if version < 4: + cur.executescript("BEGIN TRANSACTION;" + "%s;" + "UPDATE fail2banDb SET version = 4;" + "COMMIT;" % Fail2BanDb._TABLE_bips) + cur.execute("SELECT version FROM fail2banDb LIMIT 1") return cur.fetchone()[0] @@ -441,9 +473,13 @@ class Fail2BanDb(object): pass #TODO: Implement data parts once arbitrary match keys completed cur.execute( - "INSERT INTO bans(jail, ip, timeofban, data) VALUES(?, ?, ?, ?)", - (jail.name, ip, int(round(ticket.getTime())), + "INSERT INTO bans(jail, ip, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", + (jail.name, ip, int(round(ticket.getTime())), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(), ticket.getData())) + cur.execute( + "INSERT OR REPLACE INTO bips(ip, jail, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", + (ip, jail.name, int(round(ticket.getTime())), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(), + {"matches": ticket.getMatches(), "failures": ticket.getAttempt()})) @commitandrollback def delBan(self, cur, jail, ip): @@ -457,6 +493,9 @@ class Fail2BanDb(object): IP to be removed. """ queryArgs = (jail.name, str(ip)); + cur.execute( + "DELETE FROM bips WHERE jail = ? AND ip = ?", + queryArgs) cur.execute( "DELETE FROM bans WHERE jail = ? AND ip = ?", queryArgs); @@ -576,6 +615,93 @@ class Fail2BanDb(object): self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket + @commitandrollback + def getBan(self, cur, ip, jail=None, forbantime=None, overalljails=None, fromtime=None): + ip = str(ip) + if not overalljails: + query = "SELECT bancount, timeofban, bantime FROM bips" + else: + query = "SELECT sum(bancount), max(timeofban), sum(bantime) FROM bips" + query += " WHERE ip = ?" + queryArgs = [ip] + if not overalljails and jail is not None: + query += " AND jail=?" + queryArgs.append(jail.name) + if forbantime is not None: + query += " AND timeofban > ?" + queryArgs.append(MyTime.time() - forbantime) + if fromtime is not None: + query += " AND timeofban > ?" + queryArgs.append(fromtime) + if overalljails or jail is None: + query += " GROUP BY ip ORDER BY timeofban DESC LIMIT 1" + cur = self._db.cursor() + return cur.execute(query, queryArgs) + + @commitandrollback + def _getCurrentBans(self, cur, jail = None, ip = None, forbantime=None, fromtime=None): + if fromtime is None: + fromtime = MyTime.time() + queryArgs = [] + if jail is not None: + query = "SELECT ip, timeofban, bantime, bancount, data FROM bips WHERE jail=?" + queryArgs.append(jail.name) + else: + query = "SELECT ip, max(timeofban), bantime, bancount, data FROM bips WHERE 1" + if ip is not None: + query += " AND ip=?" + queryArgs.append(ip) + query += " AND (timeofban + bantime > ? OR bantime = -1)" + queryArgs.append(fromtime) + if forbantime is not None: + query += " AND timeofban > ?" + queryArgs.append(fromtime - forbantime) + if ip is None: + query += " GROUP BY ip ORDER BY ip, timeofban DESC" + cur = self._db.cursor() + return cur.execute(query, queryArgs) + + def getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): + tickets = [] + ticket = None + + results = list(self._getCurrentBans(jail=jail, ip=ip, forbantime=forbantime, fromtime=fromtime)) + + if results: + matches = [] + failures = 0 + for banip, timeofban, bantime, bancount, data in results: + #TODO: Implement data parts once arbitrary match keys completed + ticket = FailTicket(banip, timeofban, matches) + ticket.setAttempt(failures) + ticket.setBanTime(bantime) + ticket.setBanCount(bancount) + matches = [] + failures = 0 + matches.extend(data['matches']) + failures += data['failures'] + ticket.setAttempt(failures) + tickets.append(ticket) + + return tickets if ip is None else ticket + + def _cleanjails(self, cur): + """Remove empty jails jails and log files from database. + """ + cur.execute( + "DELETE FROM jails WHERE enabled = 0 " + "AND NOT EXISTS(SELECT * FROM bans WHERE jail = jails.name) " + "AND NOT EXISTS(SELECT * FROM bips WHERE jail = jails.name)") + + def _purge_bips(self, cur): + """Purge old bad ips (jails and log files from database). + Currently it is timed out IP, whose time since last ban is several times out-dated (outDatedFactor is default 3). + Permanent banned ips will be never removed. + """ + cur.execute( + "DELETE FROM bips WHERE timeofban < ? and bantime != -1 and (timeofban + (bantime * ?)) < ?", + (int(MyTime.time()) - self._purgeAge, self._outDatedFactor, int(MyTime.time()) - self._purgeAge)) + @commitandrollback def purge(self, cur): """Purge old bans, jails and log files from database. @@ -584,7 +710,6 @@ class Fail2BanDb(object): cur.execute( "DELETE FROM bans WHERE timeofban < ?", (MyTime.time() - self._purgeAge, )) - cur.execute( - "DELETE FROM jails WHERE enabled = 0 " - "AND NOT EXISTS(SELECT * FROM bans WHERE jail = jails.name)") + self._purge_bips(cur) + self._cleanjails(cur) diff --git a/fail2ban/server/failmanager.py b/fail2ban/server/failmanager.py index ee4b049d..1255cf21 100644 --- a/fail2ban/server/failmanager.py +++ b/fail2ban/server/failmanager.py @@ -75,7 +75,7 @@ class FailManager: def getMaxTime(self): return self.__maxTime - def addFailure(self, ticket, count=1): + def addFailure(self, ticket, count=1, observed=False): attempts = 1 with self.__lock: fid = ticket.getID() @@ -102,6 +102,9 @@ class FailManager: if len(matches) > self.maxEntries: fData.setMatches(matches[-self.maxEntries:]) except KeyError: + # not found - already banned - prevent to add failure if comes from observer: + if observed: + return # if already FailTicket - add it direct, otherwise create (using copy all ticket data): if isinstance(ticket, FailTicket): fData = ticket; diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 318d8e49..53bf3bb7 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -32,6 +32,7 @@ import sys from .failmanager import FailManagerEmpty, FailManager from .ipdns import DNSUtils, IPAddr +from .observer import Observers from .ticket import FailTicket from .jailthread import JailThread from .datedetector import DateDetector @@ -434,6 +435,9 @@ class Filter(JailThread): ) tick = FailTicket(ip, unixTime, lines, data=fail) self.failManager.addFailure(tick) + # report to observer - failure was found, for possibly increasing of it retry counter (asynchronous) + if Observers.Main is not None: + Observers.Main.add('failureFound', self.failManager, self.jail, tick) ## # Returns true if the line should be ignored. diff --git a/fail2ban/server/jail.py b/fail2ban/server/jail.py index 6345d300..d6f872a2 100644 --- a/fail2ban/server/jail.py +++ b/fail2ban/server/jail.py @@ -24,10 +24,13 @@ __copyright__ = "Copyright (c) 2004 Cyril Jaquier, 2011-2012 Lee Clemens, 2012 Y __license__ = "GPL" import logging +import math +import random import Queue from .actions import Actions from ..helpers import getLogger +from .mytime import MyTime # Gets the instance of the logger. logSys = getLogger(__name__) @@ -75,6 +78,8 @@ class Jail(object): self.__name = name self.__queue = Queue.Queue() self.__filter = None + # Extra parameters for increase ban time + self._banExtra = {}; logSys.info("Creating new jail '%s'" % self.name) if backend is not None: self._setBackend(backend) @@ -191,8 +196,8 @@ class Jail(object): Used by filter to add a failure for banning. """ self.__queue.put(ticket) - if self.database is not None: - self.database.addBan(self, ticket) + # add ban to database moved to observer (should previously check not already banned + # and increase ticket time if "bantime.increment" set) def getFailTicket(self): """Get a fail ticket from the jail. @@ -204,6 +209,75 @@ class Jail(object): except Queue.Empty: return False + def setBanTimeExtra(self, opt, value): + # merge previous extra with new option: + be = self._banExtra; + if value == '': + value = None + if value is not None: + be[opt] = value; + elif opt in be: + del be[opt] + logSys.info('Set banTime.%s = %s', opt, value) + if opt == 'increment': + if isinstance(value, str): + be[opt] = value.lower() in ("yes", "true", "ok", "1") + if be[opt] and self.database is None: + logSys.warning("ban time increment is not available as long jail database is not set") + if opt in ['maxtime', 'rndtime']: + if not value is None: + be[opt] = MyTime.str2seconds(value) + # prepare formula lambda: + if opt in ['formula', 'factor', 'maxtime', 'rndtime', 'multipliers'] or be.get('evformula', None) is None: + # split multifiers to an array begins with 0 (or empty if not set): + if opt == 'multipliers': + be['evmultipliers'] = [int(i) for i in (value.split(' ') if value is not None and value != '' else [])] + # if we have multifiers - use it in lambda, otherwise compile and use formula within lambda + multipliers = be.get('evmultipliers', []) + banFactor = eval(be.get('factor', "1")) + if len(multipliers): + evformula = lambda ban, banFactor=banFactor: ( + ban.Time * banFactor * multipliers[ban.Count if ban.Count < len(multipliers) else -1] + ) + else: + formula = be.get('formula', 'ban.Time * (1<<(ban.Count if ban.Count<20 else 20)) * banFactor') + formula = compile(formula, '~inline-conf-expr~', 'eval') + evformula = lambda ban, banFactor=banFactor, formula=formula: max(ban.Time, eval(formula)) + # extend lambda with max time : + if not be.get('maxtime', None) is None: + maxtime = be['maxtime'] + evformula = lambda ban, evformula=evformula: min(evformula(ban), maxtime) + # mix lambda with random time (to prevent bot-nets to calculate exact time IP can be unbanned): + if not be.get('rndtime', None) is None: + rndtime = be['rndtime'] + evformula = lambda ban, evformula=evformula: (evformula(ban) + random.random() * rndtime) + # set to extra dict: + be['evformula'] = evformula + #logSys.info('banTimeExtra : %s' % json.dumps(be)) + + def getBanTimeExtra(self, opt=None): + if opt is not None: + return self._banExtra.get(opt, None) + return self._banExtra + + def restoreCurrentBans(self): + """Restore any previous valid bans from the database. + """ + try: + if self.database is not None: + forbantime = None; + # use ban time as search time if we have not enabled a increasing: + if not self.getBanTimeExtra('increment'): + forbantime = self.actions.getBanTime() + for ticket in self.database.getCurrentBans(jail=self, forbantime=forbantime): + #logSys.debug('restored ticket: %s', ticket) + if not self.filter.inIgnoreIPList(ticket.getIP(), log_ignore=True): + # mark ticked was restored from database - does not put it again into db: + ticket.setRestored(True) + self.putFailTicket(ticket) + except Exception as e: # pragma: no cover + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + def start(self): """Start the jail, by starting filter and actions threads. @@ -212,12 +286,8 @@ class Jail(object): """ self.filter.start() self.actions.start() - # Restore any previous valid bans from the database - if self.database is not None: - for ticket in self.database.getBansMerged( - jail=self, bantime=self.actions.getBanTime()): - if not self.filter.inIgnoreIPList(ticket.getIP(), log_ignore=True): - self.__queue.put(ticket) + self.restoreCurrentBans() + logSys.info("Jail '%s' started" % self.name) def stop(self): diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py new file mode 100644 index 00000000..52db03e8 --- /dev/null +++ b/fail2ban/server/observer.py @@ -0,0 +1,490 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- +# vi: set ft=python sts=4 ts=4 sw=4 noet : + +# This file is part of Fail2Ban. +# +# 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-1301, USA. + +# Author: Serg G. Brester (sebres) +# +# This module was written as part of ban time increment feature. + +__author__ = "Serg G. Brester (sebres)" +__copyright__ = "Copyright (c) 2014 Serg G. Brester" +__license__ = "GPL" + +import threading +from .jailthread import JailThread +from .failmanager import FailManagerEmpty +import os, logging, time, datetime, math, json, random +import sys +from ..helpers import getLogger +from .mytime import MyTime +from .utils import Utils + +# Gets the instance of the logger. +logSys = getLogger(__name__) + +class ObserverThread(JailThread): + """Handles observing a database, managing bad ips and ban increment. + + Parameters + ---------- + + Attributes + ---------- + daemon + ident + name + status + active : bool + Control the state of the thread. + idle : bool + Control the idle state of the thread. + sleeptime : int + The time the thread sleeps for in the loop. + """ + + # observer is event driven and it sleep organized incremental, so sleep intervals can be shortly: + DEFAULT_SLEEP_INTERVAL = Utils.DEFAULT_SLEEP_INTERVAL / 10 + + def __init__(self): + # init thread + super(ObserverThread, self).__init__(name='Observer') + # before started - idle: + self.idle = True + ## Event queue + self._queue_lock = threading.RLock() + self._queue = [] + ## Event, be notified if anything added to event queue + self._notify = threading.Event() + ## Sleep for max 60 seconds, it possible to specify infinite to always sleep up to notifying via event, + ## but so we can later do some service "events" occurred infrequently directly in main loop of observer (not using queue) + self.sleeptime = 60 + # + self._timers = {} + self._paused = False + self.__db = None + self.__db_purge_interval = 60*60 + # observer is a not main thread: + self.daemon = True + + def __getitem__(self, i): + try: + return self._queue[i] + except KeyError: + raise KeyError("Invalid event index : %s" % i) + + def __delitem__(self, name): + try: + del self._queue[i] + except KeyError: + raise KeyError("Invalid event index: %s" % i) + + def __iter__(self): + return iter(self._queue) + + def __len__(self): + return len(self._queue) + + def __eq__(self, other): # Required for Threading + return False + + def __hash__(self): # Required for Threading + return id(self) + + def add_named_timer(self, name, starttime, *event): + """Add a named timer event to queue will start (and wake) in 'starttime' seconds + + Previous timer event with same name will be canceled and trigger self into + queue after new 'starttime' value + """ + t = self._timers.get(name, None) + if t is not None: + t.cancel() + t = threading.Timer(starttime, self.add, event) + self._timers[name] = t + t.start() + + def add_timer(self, starttime, *event): + """Add a timer event to queue will start (and wake) in 'starttime' seconds + """ + t = threading.Timer(starttime, self.add, event) + t.start() + + def pulse_notify(self): + """Notify wakeup (sets /and resets/ notify event) + """ + if not self._paused and self._notify: + self._notify.set() + #self._notify.clear() + + def add(self, *event): + """Add a event to queue and notify thread to wake up. + """ + ## lock and add new event to queue: + with self._queue_lock: + self._queue.append(event) + self.pulse_notify() + + def add_wn(self, *event): + """Add a event to queue withouth notifying thread to wake up. + """ + ## lock and add new event to queue: + with self._queue_lock: + self._queue.append(event) + + def call_lambda(self, l, *args): + l(*args) + + def run(self): + """Main loop for Threading. + + This function is the main loop of the thread. + + Returns + ------- + bool + True when the thread exits nicely. + """ + logSys.info("Observer start...") + ## first time create named timer to purge database each hour (clean old entries) ... + self.add_named_timer('DB_PURGE', self.__db_purge_interval, 'db_purge') + ## Mapping of all possible event types of observer: + __meth = { + 'failureFound': self.failureFound, + 'banFound': self.banFound, + # universal lambda: + 'call': self.call_lambda, + # system and service events: + 'db_set': self.db_set, + 'db_purge': self.db_purge, + # service events of observer self: + 'is_alive' : self.isAlive, + 'is_active': self.isActive, + 'start': self.start, + 'stop': self.stop, + 'nop': lambda:(), + 'shutdown': lambda:() + } + try: + ## check it self with sending is_alive event + self.add('is_alive') + ## if we should stop - break a main loop + while self.active: + self.idle = False + ## check events available and execute all events from queue + while not self._paused: + ## lock, check and pop one from begin of queue: + try: + ev = None + with self._queue_lock: + if len(self._queue): + ev = self._queue.pop(0) + if ev is None: + break + ## retrieve method by name + meth = __meth[ev[0]] + ## execute it with rest of event as variable arguments + meth(*ev[1:]) + except Exception as e: + #logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + logSys.error('%s', e, exc_info=True) + ## going sleep, wait for events (in queue) + n = self._notify + if n: + self.idle = True + n.wait(self.sleeptime) + ## wake up - reset signal now (we don't need it so long as we reed from queue) + n.clear() + if self._paused: + continue + else: + ## notify event deleted (shutdown) - just sleep a litle bit (waiting for shutdown events, prevent high cpu usage) + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) + ## stop by shutdown and empty queue : + if not self.is_full: + break + ## end of main loop - exit + logSys.info("Observer stopped, %s events remaining.", len(self._queue)) + #print("Observer stopped, %s events remaining." % len(self._queue)) + except Exception as e: + logSys.error('Observer stopped after error: %s', e, exc_info=True) + #print("Observer stopped with error: %s" % str(e)) + # clear all events - exit, for possible calls of wait_empty: + with self._queue_lock: + self._queue = [] + self.idle = True + return True + + def isAlive(self): + #logSys.debug("Observer alive...") + return True + + def isActive(self, fromStr=None): + # logSys.info("Observer alive, %s%s", + # 'active' if self.active else 'inactive', + # '' if fromStr is None else (", called from '%s'" % fromStr)) + return self.active + + def start(self): + with self._queue_lock: + if not self.active: + super(ObserverThread, self).start() + + def stop(self): + if self.active and self._notify: + wtime = 5 + logSys.info("Observer stop ... try to end queue %s seconds", wtime) + #print("Observer stop ....") + # just add shutdown job to make possible wait later until full (events remaining) + with self._queue_lock: + self.add_wn('shutdown') + #don't pulse - just set, because we will delete it hereafter (sometimes not wakeup) + n = self._notify + self._notify.set() + #self.pulse_notify() + self._notify = None + # wait max wtime seconds until full (events remaining) + self.wait_empty(wtime) + n.clear() + self.active = False + self.wait_idle(0.5) + + @property + def is_full(self): + with self._queue_lock: + return True if len(self._queue) else False + + def wait_empty(self, sleeptime=None): + """Wait observer is running and returns if observer has no more events (queue is empty) + """ + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) + if sleeptime is not None: + e = MyTime.time() + sleeptime + # block queue with not operation to be sure all really jobs are executed if nop goes from queue : + if self._notify is not None: + self.add_wn('nop') + if self.is_full and self.idle: + self.pulse_notify() + while self.is_full: + if sleeptime is not None and MyTime.time() > e: + break + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) + # wait idle to be sure the last queue element is processed (because pop event before processing it) : + self.wait_idle(0.001) + return not self.is_full + + + def wait_idle(self, sleeptime=None): + """Wait observer is running and returns if observer idle (observer sleeps) + """ + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) + if self.idle: + return True + if sleeptime is not None: + e = MyTime.time() + sleeptime + while not self.idle: + if sleeptime is not None and MyTime.time() > e: + break + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) + return self.idle + + @property + def paused(self): + return self._paused; + + @paused.setter + def paused(self, pause): + if self._paused == pause: + return + self._paused = pause + # wake after pause ended + self.pulse_notify() + + + @property + def status(self): + """Status of observer to be implemented. [TODO] + """ + return ('', '') + + ## ----------------------------------------- + ## [Async] database service functionality ... + ## ----------------------------------------- + + def db_set(self, db): + self.__db = db + + def db_purge(self): + logSys.info("Purge database event occurred") + if self.__db is not None: + self.__db.purge() + # trigger timer again ... + self.add_named_timer('DB_PURGE', self.__db_purge_interval, 'db_purge') + + ## ----------------------------------------- + ## [Async] ban time increment functionality ... + ## ----------------------------------------- + + def failureFound(self, failManager, jail, ticket): + """ Notify observer a failure for ip was found + + Observer will check ip was known (bad) and possibly increase an retry count + """ + # check jail active : + if not jail.isAlive(): + return + ip = ticket.getIP() + unixTime = ticket.getTime() + logSys.debug("[%s] Observer: failure found %s", jail.name, ip) + # increase retry count for known (bad) ip, corresponding banCount of it (one try will count than 2, 3, 5, 9 ...) : + banCount = 0 + retryCount = 1 + timeOfBan = None + try: + maxRetry = failManager.getMaxRetry() + db = jail.database + if db is not None: + for banCount, timeOfBan, lastBanTime in db.getBan(ip, jail): + retryCount = ((1 << (banCount if banCount < 20 else 20))/2 + 1) + # if lastBanTime == -1 or timeOfBan + lastBanTime * 2 > MyTime.time(): + # retryCount = maxRetry + break + retryCount = min(retryCount, maxRetry) + # check this ticket already known (line was already processed and in the database and will be restored from there): + if timeOfBan is not None and unixTime <= timeOfBan: + logSys.debug("[%s] Ignore failure %s before last ban %s < %s, restored", + jail.name, ip, unixTime, timeOfBan) + return + # for not increased failures observer should not add it to fail manager, because was already added by filter self + if retryCount <= 1: + return + # retry counter was increased - add it again: + logSys.info("[%s] Found %s, bad - %s, %s # -> %s%s", jail.name, ip, + datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S"), banCount, retryCount, + (', Ban' if retryCount >= maxRetry else '')) + # retryCount-1, because a ticket was already once incremented by filter self + retryCount = failManager.addFailure(ticket, retryCount - 1, True) + + # after observe we have increased count >= maxretry ... + if retryCount >= maxRetry: + # perform the banning of the IP now (again) + # [todo]: this code part will be used multiple times - optimize it later. + try: # pragma: no branch - exception is the only way out + while True: + ticket = failManager.toBan(ip) + jail.putFailTicket(ticket) + except FailManagerEmpty: + failManager.cleanup(MyTime.time()) + + except Exception as e: + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + + + class BanTimeIncr: + def __init__(self, banTime, banCount): + self.Time = banTime + self.Count = banCount + + def calcBanTime(self, jail, banTime, banCount): + be = jail.getBanTimeExtra() + return be['evformula'](self.BanTimeIncr(banTime, banCount)) + + def incrBanTime(self, jail, banTime, ticket): + """Check for IP address to increment ban time (if was already banned). + + Returns + ------- + float + new ban time. + """ + # check jail active : + if not jail.isAlive() or not jail.database: + return + be = jail.getBanTimeExtra() + ip = ticket.getIP() + orgBanTime = banTime + # check ip was already banned (increment time of ban): + try: + if banTime > 0 and be.get('increment', False): + # search IP in database and increase time if found: + for banCount, timeOfBan, lastBanTime in \ + jail.database.getBan(ip, jail, overalljails=be.get('overalljails', False)) \ + : + logSys.debug('IP %s was already banned: %s #, %s', ip, banCount, timeOfBan); + ticket.setBanCount(banCount); + # calculate new ban time + if banCount > 0: + banTime = be['evformula'](self.BanTimeIncr(banTime, banCount)) + ticket.setBanTime(banTime); + # check current ticket time to prevent increasing for twice read tickets (restored from log file besides database after restart) + if ticket.getTime() > timeOfBan: + logSys.info('[%s] IP %s is bad: %s # last %s - incr %s to %s' % (jail.name, ip, banCount, + datetime.datetime.fromtimestamp(timeOfBan).strftime("%Y-%m-%d %H:%M:%S"), + datetime.timedelta(seconds=int(orgBanTime)), datetime.timedelta(seconds=int(banTime)))); + else: + ticket.setRestored(True) + break + except Exception as e: + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + return banTime + + def banFound(self, ticket, jail, btime): + """ Notify observer a ban occured for ip + + Observer will check ip was known (bad) and possibly increase/prolong a ban time + Secondary we will actualize the bans and bips (bad ip) in database + """ + oldbtime = btime + ip = ticket.getIP() + logSys.debug("[%s] Observer: ban found %s, %s", jail.name, ip, btime) + try: + # if not permanent, not restored and ban time was not set - check time should be increased: + if btime != -1 and not ticket.getRestored() and ticket.getBanTime() is None: + btime = self.incrBanTime(jail, btime, ticket) + # if we should prolong ban time: + if btime == -1 or btime > oldbtime: + ticket.setBanTime(btime) + # if not permanent + if btime != -1: + bendtime = ticket.getTime() + btime + logtime = (datetime.timedelta(seconds=int(btime)), + datetime.datetime.fromtimestamp(bendtime).strftime("%Y-%m-%d %H:%M:%S")) + # check ban is not too old : + if bendtime < MyTime.time(): + logSys.debug('Ignore old bantime %s', logtime[1]) + return False + else: + logtime = ('permanent', 'infinite') + # increment count: + ticket.incrBanCount() + # if ban time was prolonged - log again with new ban time: + if btime != oldbtime: + logSys.notice("[%s] Increase Ban %s (%d # %s -> %s)", jail.name, + ip, ticket.getBanCount(), *logtime) + # add ticket to database, but only if was not restored (not already read from database): + if jail.database is not None and not ticket.getRestored(): + # add to database always only after ban time was calculated an not yet already banned: + jail.database.addBan(jail, ticket) + except Exception as e: + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + +# Global observer initial created in server (could be later rewriten via singleton) +class _Observers: + def __init__(self): + self.Main = None + +Observers = _Observers() diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 60eea1f3..54f00f6a 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -33,6 +33,7 @@ import signal import stat import sys +from .observer import Observers, ObserverThread from .jails import Jails from .filter import FileFilter, JournalFilter from .transmitter import Transmitter @@ -92,7 +93,7 @@ class Server: self.__prev_signals[s] = signal.getsignal(s) signal.signal(s, new) - def start(self, sock, pidfile, force=False, conf={}): + def start(self, sock, pidfile, force=False, observer=True, conf={}): # First set the mask to only allow access to owner os.umask(0077) # Second daemonize before logging etc, because it will close all handles: @@ -141,6 +142,12 @@ class Server: except IOError, e: logSys.error("Unable to create PID file: %s" % e) + # Create observers and start it: + if observer: + if Observers.Main is None: + Observers.Main = ObserverThread() + Observers.Main.start() + # Start the communication logSys.debug("Starting communication") try: @@ -154,6 +161,10 @@ class Server: os.remove(pidfile) except OSError, e: logSys.error("Unable to remove PID file: %s" % e) + # Stop observer and exit + if Observers.Main is not None: + Observers.Main.stop() + Observers.Main = None logSys.info("Exiting Fail2ban") def quit(self): @@ -184,10 +195,11 @@ class Server: self.quit = lambda: False def addJail(self, name, backend): + # Add jail hereafter: self.__jails.add(name, backend, self.__db) if self.__db is not None: self.__db.addJail(self.__jails[name]) - + def delJail(self, name): if self.__db is not None: self.__db.delJail(self.__jails[name]) @@ -375,6 +387,12 @@ class Server: def getBanTime(self, name): return self.__jails[name].actions.getBanTime() + + def setBanTimeExtra(self, name, opt, value): + self.__jails[name].setBanTimeExtra(opt, value) + + def getBanTimeExtra(self, name, opt): + return self.__jails[name].getBanTimeExtra(opt) def isStarted(self): return self.__asyncServer is not None and self.__asyncServer.isActive() @@ -496,7 +514,7 @@ class Server: try: handler.flush() handler.close() - except (ValueError, KeyError): # pragma: no cover + except (ValueError, KeyError): # pragma: no cover # Is known to be thrown after logging was shutdown once # with older Pythons -- seems to be safe to ignore there # At least it was still failing on 2.6.2-0ubuntu1 (jaunty) @@ -577,6 +595,8 @@ class Server: logSys.error( "Unable to import fail2ban database module as sqlite " "is not available.") + if Observers.Main is not None: + Observers.Main.db_set(self.__db) def getDatabase(self): return self.__db diff --git a/fail2ban/server/ticket.py b/fail2ban/server/ticket.py index 65ed83c3..ae767cdd 100644 --- a/fail2ban/server/ticket.py +++ b/fail2ban/server/ticket.py @@ -24,8 +24,6 @@ __author__ = "Cyril Jaquier" __copyright__ = "Copyright (c) 2004 Cyril Jaquier" __license__ = "GPL" -import sys - from ..helpers import getLogger from .ipdns import IPAddr from .mytime import MyTime @@ -36,6 +34,8 @@ logSys = getLogger(__name__) class Ticket: + RESTORED = 0x01 + def __init__(self, ip=None, time=None, matches=None, data={}, ticket=None): """Ticket constructor @@ -58,8 +58,9 @@ class Ticket: self._data['matches'] = matches or [] def __str__(self): - return "%s: ip=%s time=%s #attempts=%d matches=%r" % \ + return "%s: ip=%s time=%s bantime=%s bancount=%s #attempts=%d matches=%r" % \ (self.__class__.__name__.split('.')[-1], self.__ip, self._time, + self._banTime, self._banCount, self._data['failures'], self._data.get('matches', [])) def __repr__(self): @@ -94,7 +95,7 @@ class Ticket: def setBanTime(self, value): self._banTime = value; - def getBanTime(self, defaultBT = None): + def getBanTime(self, defaultBT=None): return (self._banTime if not self._banTime is None else defaultBT); def setBanCount(self, value): @@ -106,7 +107,7 @@ class Ticket: def getBanCount(self): return self._banCount; - def isTimedOut(self, time, defaultBT = None): + def isTimedOut(self, time, defaultBT=None): bantime = (self._banTime if not self._banTime is None else defaultBT); # permanent if bantime == -1: @@ -126,6 +127,15 @@ class Ticket: def getMatches(self): return self._data.get('matches', []) + def setRestored(self, value): + if value: + self._flags = Ticket.RESTORED + else: + self._flags &= ~(Ticket.RESTORED) + + def getRestored(self): + return self._flags & Ticket.RESTORED + def setData(self, *args, **argv): # if overwrite - set data and filter None values: if len(args) == 1: diff --git a/fail2ban/server/transmitter.py b/fail2ban/server/transmitter.py index e4b9d81a..bdc6b42c 100644 --- a/fail2ban/server/transmitter.py +++ b/fail2ban/server/transmitter.py @@ -243,6 +243,11 @@ class Transmitter: value = command[2] self.__server.setBanTime(name, value) return self.__server.getBanTime(name) + elif command[1].startswith("bantime."): + value = command[2] + opt = command[1][len("bantime."):] + self.__server.setBanTimeExtra(name, opt, value) + return self.__server.getBanTimeExtra(name, opt) elif command[1] == "banip": value = command[2] return self.__server.setBanIP(name,value) @@ -335,6 +340,9 @@ class Transmitter: # Action elif command[1] == "bantime": return self.__server.getBanTime(name) + elif command[1].startswith("bantime."): + opt = command[1][len("bantime."):] + return self.__server.getBanTimeExtra(name, opt) elif command[1] == "actions": return self.__server.getActions(name).keys() elif command[1] == "action": diff --git a/fail2ban/tests/action_d/test_smtp.py b/fail2ban/tests/action_d/test_smtp.py index 5c8b1923..86e7a368 100644 --- a/fail2ban/tests/action_d/test_smtp.py +++ b/fail2ban/tests/action_d/test_smtp.py @@ -29,16 +29,20 @@ else: from ..dummyjail import DummyJail -from ..utils import CONFIG_DIR, asyncserver - +from ..utils import CONFIG_DIR, asyncserver, Utils class TestSMTPServer(smtpd.SMTPServer): + def __init__(self, *args): + smtpd.SMTPServer.__init__(self, *args) + self.ready = False + def process_message(self, peer, mailfrom, rcpttos, data): self.peer = peer self.mailfrom = mailfrom self.rcpttos = rcpttos self.data = data + self.ready = True class SMTPActionTest(unittest.TestCase): @@ -74,8 +78,13 @@ class SMTPActionTest(unittest.TestCase): self._active = False self._loop_thread.join() + def _exec_and_wait(self, doaction): + self.smtpd.ready = False + doaction() + Utils.wait_for(lambda: self.smtpd.ready, 3) + def testStart(self): - self.action.start() + self._exec_and_wait(self.action.start) self.assertEqual(self.smtpd.mailfrom, "fail2ban") self.assertEqual(self.smtpd.rcpttos, ["root"]) self.assertTrue( @@ -83,7 +92,7 @@ class SMTPActionTest(unittest.TestCase): in self.smtpd.data) def testStop(self): - self.action.stop() + self._exec_and_wait(self.action.stop) self.assertEqual(self.smtpd.mailfrom, "fail2ban") self.assertEqual(self.smtpd.rcpttos, ["root"]) self.assertTrue( @@ -99,7 +108,7 @@ class SMTPActionTest(unittest.TestCase): 'ipmatches': "Test fail 1\nTest Fail2\nTest Fail3\n", } - self.action.ban(aInfo) + self._exec_and_wait(lambda: self.action.ban(aInfo)) self.assertEqual(self.smtpd.mailfrom, "fail2ban") self.assertEqual(self.smtpd.rcpttos, ["root"]) subject = "Subject: [Fail2Ban] %s: banned %s" % ( @@ -109,26 +118,26 @@ class SMTPActionTest(unittest.TestCase): "%i attempts" % aInfo['failures'] in self.smtpd.data) self.action.matches = "matches" - self.action.ban(aInfo) + self._exec_and_wait(lambda: self.action.ban(aInfo)) self.assertIn(aInfo['matches'], self.smtpd.data) self.action.matches = "ipjailmatches" - self.action.ban(aInfo) + self._exec_and_wait(lambda: self.action.ban(aInfo)) self.assertIn(aInfo['ipjailmatches'], self.smtpd.data) self.action.matches = "ipmatches" - self.action.ban(aInfo) + self._exec_and_wait(lambda: self.action.ban(aInfo)) self.assertIn(aInfo['ipmatches'], self.smtpd.data) def testOptions(self): - self.action.start() + self._exec_and_wait(self.action.start) self.assertEqual(self.smtpd.mailfrom, "fail2ban") self.assertEqual(self.smtpd.rcpttos, ["root"]) self.action.fromname = "Test" self.action.fromaddr = "test@example.com" self.action.toaddr = "test@example.com, test2@example.com" - self.action.start() + self._exec_and_wait(self.action.start) self.assertEqual(self.smtpd.mailfrom, "test@example.com") self.assertTrue("From: %s <%s>" % (self.action.fromname, self.action.fromaddr) in self.smtpd.data) diff --git a/fail2ban/tests/actionstestcase.py b/fail2ban/tests/actionstestcase.py index 8969db36..279290d1 100644 --- a/fail2ban/tests/actionstestcase.py +++ b/fail2ban/tests/actionstestcase.py @@ -149,7 +149,7 @@ class ExecuteActions(LogCaptureTestCase): "action2", os.path.join(TEST_FILES_DIR, "action.d/action_modifyainfo.py"), {}) - self.__jail.putFailTicket(FailTicket("1.2.3.4", 0)) + self.__jail.putFailTicket(FailTicket("1.2.3.4")) self.__actions._Actions__checkBan() # Will fail if modification of aInfo from first action propagates # to second action, as both delete same key diff --git a/fail2ban/tests/banmanagertestcase.py b/fail2ban/tests/banmanagertestcase.py index f47dc848..859bf22f 100644 --- a/fail2ban/tests/banmanagertestcase.py +++ b/fail2ban/tests/banmanagertestcase.py @@ -84,6 +84,24 @@ class AddFailure(unittest.TestCase): self.assertTrue(self.__banManager.addBanTicket(self.__ticket)) ticket = BanTicket('111.111.1.111', 1167605999.0) self.assertFalse(self.__banManager._inBanList(ticket)) + + def testBanTimeIncr(self): + ticket = BanTicket(self.__ticket.getIP(), self.__ticket.getTime()) + ## increase twice and at end permanent: + for i in (1000, 2000, -1): + self.__banManager.addBanTicket(self.__ticket) + ticket.setBanTime(i) + self.assertFalse(self.__banManager.addBanTicket(ticket)) + self.assertEqual(str(self.__banManager.getTicketByIP(ticket.getIP())), + "BanTicket: ip=%s time=%s bantime=%s bancount=0 #attempts=0 matches=[]" % (ticket.getIP(), ticket.getTime(), i)) + ## after permanent, it should remain permanent ban time (-1): + self.__banManager.addBanTicket(self.__ticket) + ticket.setBanTime(-1) + self.assertFalse(self.__banManager.addBanTicket(ticket)) + ticket.setBanTime(1000) + self.assertFalse(self.__banManager.addBanTicket(ticket)) + self.assertEqual(str(self.__banManager.getTicketByIP(ticket.getIP())), + "BanTicket: ip=%s time=%s bantime=%s bancount=0 #attempts=0 matches=[]" % (ticket.getIP(), ticket.getTime(), -1)) def testUnban(self): btime = self.__banManager.getBanTime() diff --git a/fail2ban/tests/config/filter.d/common.conf b/fail2ban/tests/config/filter.d/common.conf deleted file mode 120000 index 83e92474..00000000 --- a/fail2ban/tests/config/filter.d/common.conf +++ /dev/null @@ -1 +0,0 @@ -../../../../config/filter.d/common.conf \ No newline at end of file diff --git a/fail2ban/tests/config/filter.d/common.conf b/fail2ban/tests/config/filter.d/common.conf new file mode 100644 index 00000000..586f428a --- /dev/null +++ b/fail2ban/tests/config/filter.d/common.conf @@ -0,0 +1,64 @@ +# Generic configuration items (to be used as interpolations) in other +# filters or actions configurations +# + +[INCLUDES] + +# Load customizations if any available +after = common.local + + +[DEFAULT] + +# Daemon definition is to be specialized (if needed) in .conf file +_daemon = \S* + +# +# Shortcuts for easier comprehension of the failregex +# +# PID. +# EXAMPLES: [123] +__pid_re = (?:\[\d+\]) + +# Daemon name (with optional source_file:line or whatever) +# EXAMPLES: pam_rhosts_auth, [sshd], pop(pam_unix) +__daemon_re = [\[\(]?%(_daemon)s(?:\(\S+\))?[\]\)]?:? + +# extra daemon info +# EXAMPLE: [ID 800047 auth.info] +__daemon_extra_re = \[ID \d+ \S+\] + +# Combinations of daemon name and PID +# EXAMPLES: sshd[31607], pop(pam_unix)[4920] +__daemon_combs_re = (?:%(__pid_re)s?:\s+%(__daemon_re)s|%(__daemon_re)s%(__pid_re)s?:?) + +# Some messages have a kernel prefix with a timestamp +# EXAMPLES: kernel: [769570.846956] +__kernel_prefix = kernel: \[ *\d+\.\d+\] + +__hostname = \S+ + +# A MD5 hex +# EXAMPLES: 07:06:27:55:b0:e3:0c:3c:5a:28:2d:7c:7e:4c:77:5f +__md5hex = (?:[\da-f]{2}:){15}[\da-f]{2} + +# bsdverbose is where syslogd is started with -v or -vv and results in <4.3> or +# appearing before the host as per testcases/files/logs/bsd/*. +__bsd_syslog_verbose = <[^.]+\.[^.]+> + +__vserver = @vserver_\S+ + +__date_ambit = (?:\[\]) + +# Common line prefixes (beginnings) which could be used in filters +# +# [bsdverbose]? [hostname] [vserver tag] daemon_id spaces +# +# This can be optional (for instance if we match named native log files) +__prefix_line = %(__date_ambit)s?\s*(?:%(__bsd_syslog_verbose)s\s+)?(?:%(__hostname)s\s+)?(?:%(__kernel_prefix)s\s+)?(?:%(__vserver)s\s+)?(?:%(__daemon_combs_re)s\s+)?(?:%(__daemon_extra_re)s\s+)? + +# PAM authentication mechanism check for failures, e.g.: pam_unix, pam_sss, +# pam_ldap +__pam_auth = pam_unix + +# Author: Yaroslav Halchenko diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index d039c920..08f718fa 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -126,6 +126,33 @@ class DatabaseTest(LogCaptureTestCase): self.assertRaises(NotImplementedError, self.db.updateDb, Fail2BanDb.__version__ + 1) os.remove(self.db._dbBackupFilename) + def testUpdateDb2(self): + if Fail2BanDb is None or self.db.filename == ':memory:': # pragma: no cover + return + shutil.copyfile( + os.path.join(TEST_FILES_DIR, 'database_v2.db'), self.dbFilename) + self.db = Fail2BanDb(self.dbFilename) + self.assertEqual(self.db.getJailNames(), set(['pam-generic'])) + self.assertEqual(self.db.getLogPaths(), set(['/var/log/auth.log'])) + bans = self.db.getBans() + self.assertEqual(len(bans), 2) + # compare first ticket completely: + ticket = FailTicket("1.2.3.7", 1417595494, [ + u'Dec 3 09:31:08 f2btest test:auth[27658]: pam_unix(test:auth): authentication failure; logname= uid=0 euid=0 tty=test ruser= rhost=1.2.3.7', + u'Dec 3 09:31:32 f2btest test:auth[27671]: pam_unix(test:auth): authentication failure; logname= uid=0 euid=0 tty=test ruser= rhost=1.2.3.7', + u'Dec 3 09:31:34 f2btest test:auth[27673]: pam_unix(test:auth): authentication failure; logname= uid=0 euid=0 tty=test ruser= rhost=1.2.3.7' + ]) + ticket.setAttempt(3) + self.assertEqual(bans[0], ticket) + # second ban found also: + self.assertEqual(bans[1].getIP(), "1.2.3.8") + # updated ? + self.assertEqual(self.db.updateDb(Fail2BanDb.__version__), Fail2BanDb.__version__) + # further update should fail: + self.assertRaises(NotImplementedError, self.db.updateDb, Fail2BanDb.__version__ + 1) + # clean: + os.remove(self.db._dbBackupFilename) + def testAddJail(self): if Fail2BanDb is None: # pragma: no cover return @@ -365,8 +392,9 @@ class DatabaseTest(LogCaptureTestCase): "action_checkainfo", os.path.join(TEST_FILES_DIR, "action.d/action_checkainfo.py"), {}) - ticket = FailTicket("1.2.3.4", MyTime.time(), ['test', 'test']) + ticket = FailTicket("1.2.3.4") ticket.setAttempt(5) + ticket.setMatches(['test', 'test']) self.jail.putFailTicket(ticket) actions._Actions__checkBan() self.assertLogged("ban ainfo %s, %s, %s, %s" % (True, True, True, True)) diff --git a/fail2ban/tests/dummyjail.py b/fail2ban/tests/dummyjail.py index 19f97f4e..c7c139e3 100644 --- a/fail2ban/tests/dummyjail.py +++ b/fail2ban/tests/dummyjail.py @@ -28,6 +28,11 @@ from ..server.jail import Jail from ..server.actions import Actions +class DummyActions(Actions): + def checkBan(self): + return self._Actions__checkBan() + + class DummyJail(Jail): """A simple 'jail' to suck in all the tickets generated by Filter's """ @@ -36,7 +41,7 @@ class DummyJail(Jail): self.queue = [] super(DummyJail, self).__init__(name='DummyJail', backend=backend) self.__db = None - self.__actions = Actions(self) + self.__actions = DummyActions(self) def __len__(self): with self.lock: diff --git a/fail2ban/tests/failmanagertestcase.py b/fail2ban/tests/failmanagertestcase.py index 6e7bf367..11caf927 100644 --- a/fail2ban/tests/failmanagertestcase.py +++ b/fail2ban/tests/failmanagertestcase.py @@ -149,10 +149,10 @@ class AddFailure(unittest.TestCase): ticket_repr = repr(ticket) self.assertEqual( ticket_str, - 'FailTicket: ip=193.168.0.128 time=1167605999.0 #attempts=5 matches=[]') + 'FailTicket: ip=193.168.0.128 time=1167605999.0 bantime=None bancount=0 #attempts=5 matches=[]') self.assertEqual( ticket_repr, - 'FailTicket: ip=193.168.0.128 time=1167605999.0 #attempts=5 matches=[]') + 'FailTicket: ip=193.168.0.128 time=1167605999.0 bantime=None bancount=0 #attempts=5 matches=[]') self.assertFalse(not ticket) # and some get/set-ers otherwise not tested ticket.setTime(1000002000.0) @@ -160,7 +160,7 @@ class AddFailure(unittest.TestCase): # and str() adjusted correspondingly self.assertEqual( str(ticket), - 'FailTicket: ip=193.168.0.128 time=1000002000.0 #attempts=5 matches=[]') + 'FailTicket: ip=193.168.0.128 time=1000002000.0 bantime=None bancount=0 #attempts=5 matches=[]') def testbanNOK(self): self._addDefItems() diff --git a/fail2ban/tests/files/database_v1.db b/fail2ban/tests/files/database_v1.db index 20822671..fa2d7bb2 100644 Binary files a/fail2ban/tests/files/database_v1.db and b/fail2ban/tests/files/database_v1.db differ diff --git a/fail2ban/tests/files/database_v2.db b/fail2ban/tests/files/database_v2.db new file mode 100644 index 00000000..8954c8b5 Binary files /dev/null and b/fail2ban/tests/files/database_v2.db differ diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py new file mode 100644 index 00000000..3e4bfd10 --- /dev/null +++ b/fail2ban/tests/observertestcase.py @@ -0,0 +1,624 @@ +# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: t -*- +# vi: set ft=python sts=4 ts=4 sw=4 noet : + +# This file is part of Fail2Ban. +# +# 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-1301, USA. + +# Author: Serg G. Brester (sebres) +# + +__author__ = "Serg G. Brester (sebres)" +__copyright__ = "Copyright (c) 2014 Serg G. Brester" +__license__ = "GPL" + +import os +import sys +import unittest +import tempfile +import time + +from ..server.mytime import MyTime +from ..server.ticket import FailTicket +from ..server.failmanager import FailManager +from ..server.banmanager import BanManager +from ..server.observer import Observers, ObserverThread +from ..server.utils import Utils +from .utils import LogCaptureTestCase +from ..server.filter import Filter +from .dummyjail import DummyJail + +from .databasetestcase import getFail2BanDb, Fail2BanDb + + +class BanTimeIncr(LogCaptureTestCase): + + def setUp(self): + """Call before every test case.""" + super(BanTimeIncr, self).setUp() + self.__jail = DummyJail() + self.__jail.calcBanTime = self.calcBanTime + self.Observer = ObserverThread() + + def tearDown(self): + super(BanTimeIncr, self).tearDown() + + def calcBanTime(self, banTime, banCount): + return self.Observer.calcBanTime(self.__jail, banTime, banCount) + + def testDefault(self, multipliers = None): + a = self.__jail; + a.setBanTimeExtra('increment', 'true') + self.assertEqual(a.getBanTimeExtra('increment'), True) + a.setBanTimeExtra('maxtime', '1d') + self.assertEqual(a.getBanTimeExtra('maxtime'), 24*60*60) + a.setBanTimeExtra('rndtime', None) + a.setBanTimeExtra('factor', None) + # tests formulat or multipliers: + a.setBanTimeExtra('multipliers', multipliers) + # test algorithm and max time 24 hours : + self.assertEqual( + [a.calcBanTime(600, i) for i in xrange(1, 11)], + [1200, 2400, 4800, 9600, 19200, 38400, 76800, 86400, 86400, 86400] + ) + # with extra large max time (30 days): + a.setBanTimeExtra('maxtime', '30d') + # using formula the ban time grows always, but using multipliers the growing will stops with last one: + arr = [1200, 2400, 4800, 9600, 19200, 38400, 76800, 153600, 307200, 614400] + if multipliers is not None: + multcnt = len(multipliers.split(' ')) + if multcnt < 11: + arr = arr[0:multcnt-1] + ([arr[multcnt-2]] * (11-multcnt)) + self.assertEqual( + [a.calcBanTime(600, i) for i in xrange(1, 11)], + arr + ) + a.setBanTimeExtra('maxtime', '1d') + # change factor : + a.setBanTimeExtra('factor', '2'); + self.assertEqual( + [a.calcBanTime(600, i) for i in xrange(1, 11)], + [2400, 4800, 9600, 19200, 38400, 76800, 86400, 86400, 86400, 86400] + ) + # factor is float : + a.setBanTimeExtra('factor', '1.33'); + self.assertEqual( + [int(a.calcBanTime(600, i)) for i in xrange(1, 11)], + [1596, 3192, 6384, 12768, 25536, 51072, 86400, 86400, 86400, 86400] + ) + a.setBanTimeExtra('factor', None); + # change max time : + a.setBanTimeExtra('maxtime', '12h') + self.assertEqual( + [a.calcBanTime(600, i) for i in xrange(1, 11)], + [1200, 2400, 4800, 9600, 19200, 38400, 43200, 43200, 43200, 43200] + ) + a.setBanTimeExtra('maxtime', '24h') + ## test randomization - not possibe all 10 times we have random = 0: + a.setBanTimeExtra('rndtime', '5m') + self.assertTrue( + False in [1200 in [a.calcBanTime(600, 1) for i in xrange(10)] for c in xrange(10)] + ) + a.setBanTimeExtra('rndtime', None) + self.assertFalse( + False in [1200 in [a.calcBanTime(600, 1) for i in xrange(10)] for c in xrange(10)] + ) + # restore default: + a.setBanTimeExtra('multipliers', None) + a.setBanTimeExtra('factor', None); + a.setBanTimeExtra('maxtime', '24h') + a.setBanTimeExtra('rndtime', None) + + def testMultipliers(self): + # this multipliers has the same values as default formula, we test stop growing after count 9: + self.testDefault('1 2 4 8 16 32 64 128 256') + # this multipliers has exactly the same values as default formula, test endless growing (stops by count 31 only): + self.testDefault(' '.join([str(1<= (2,7): # pragma: no cover + raise unittest.SkipTest( + "Unable to import fail2ban database module as sqlite is not " + "available.") + elif Fail2BanDb is None: + return + _, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_") + self.db = getFail2BanDb(self.dbFilename) + self.jail = DummyJail() + self.jail.database = self.db + self.Observer = ObserverThread() + Observers.Main = self.Observer + + def tearDown(self): + """Call after every test case.""" + if Fail2BanDb is None: # pragma: no cover + return + # Cleanup + self.Observer.stop() + Observers.Main = None + os.remove(self.dbFilename) + super(BanTimeIncrDB, self).tearDown() + + def incrBanTime(self, ticket, banTime=None): + jail = self.jail; + if banTime is None: + banTime = ticket.getBanTime(jail.actions.getBanTime()) + ticket.setBanTime(None) + incrTime = self.Observer.incrBanTime(jail, banTime, ticket) + #print("!!!!!!!!! banTime: %s, %s, incr: %s " % (banTime, ticket.getBanCount(), incrTime)) + return incrTime + + + def testBanTimeIncr(self): + if Fail2BanDb is None: # pragma: no cover + return + jail = self.jail + self.db.addJail(jail) + # we tests with initial ban time = 10 seconds: + jail.actions.setBanTime(10) + jail.setBanTimeExtra('increment', 'true') + jail.setBanTimeExtra('multipliers', '1 2 4 8 16 32 64 128 256 512 1024 2048') + ip = "127.0.0.2" + # used as start and fromtime (like now but time independence, cause test case can run slow): + stime = int(MyTime.time()) + ticket = FailTicket(ip, stime, []) + # test ticket not yet found + self.assertEqual( + [self.incrBanTime(ticket, 10) for i in xrange(3)], + [10, 10, 10] + ) + # add a ticket banned + ticket.incrBanCount() + self.db.addBan(jail, ticket) + # get a ticket already banned in this jail: + self.assertEqual( + [(banCount, timeOfBan, lastBanTime) for banCount, timeOfBan, lastBanTime in self.db.getBan(ip, jail, None, False)], + [(1, stime, 10)] + ) + # incr time and ban a ticket again : + ticket.setTime(stime + 15) + self.assertEqual(self.incrBanTime(ticket, 10), 20) + ticket.incrBanCount() + self.db.addBan(jail, ticket) + # get a ticket already banned in this jail: + self.assertEqual( + [(banCount, timeOfBan, lastBanTime) for banCount, timeOfBan, lastBanTime in self.db.getBan(ip, jail, None, False)], + [(2, stime + 15, 20)] + ) + # get a ticket already banned in all jails: + self.assertEqual( + [(banCount, timeOfBan, lastBanTime) for banCount, timeOfBan, lastBanTime in self.db.getBan(ip, '', None, True)], + [(2, stime + 15, 20)] + ) + # check other optional parameters of getBan: + self.assertEqual( + [(banCount, timeOfBan, lastBanTime) for banCount, timeOfBan, lastBanTime in self.db.getBan(ip, forbantime=stime, fromtime=stime)], + [(2, stime + 15, 20)] + ) + # search currently banned and 1 day later (nothing should be found): + self.assertEqual( + self.db.getCurrentBans(forbantime=-24*60*60, fromtime=stime), + [] + ) + # search currently banned one ticket for ip: + restored_tickets = self.db.getCurrentBans(ip=ip) + self.assertEqual( + str(restored_tickets), + ('FailTicket: ip=%s time=%s bantime=20 bancount=2 #attempts=0 matches=[]' % (ip, stime + 15)) + ) + # search currently banned anywhere: + restored_tickets = self.db.getCurrentBans(fromtime=stime) + self.assertEqual( + str(restored_tickets), + ('[FailTicket: ip=%s time=%s bantime=20 bancount=2 #attempts=0 matches=[]]' % (ip, stime + 15)) + ) + # search currently banned: + restored_tickets = self.db.getCurrentBans(jail=jail, fromtime=stime) + self.assertEqual( + str(restored_tickets), + ('[FailTicket: ip=%s time=%s bantime=20 bancount=2 #attempts=0 matches=[]]' % (ip, stime + 15)) + ) + restored_tickets[0].setRestored(True) + self.assertTrue(restored_tickets[0].getRestored()) + # increase ban multiple times: + lastBanTime = 20 + for i in xrange(10): + ticket.setTime(stime + lastBanTime + 5) + banTime = self.incrBanTime(ticket, 10) + self.assertEqual(banTime, lastBanTime * 2) + ticket.incrBanCount() + self.db.addBan(jail, ticket) + lastBanTime = banTime + # increase again, but the last multiplier reached (time not increased): + ticket.setTime(stime + lastBanTime + 5) + banTime = self.incrBanTime(ticket, 10) + self.assertNotEqual(banTime, lastBanTime * 2) + self.assertEqual(banTime, lastBanTime) + ticket.incrBanCount() + self.db.addBan(jail, ticket) + lastBanTime = banTime + # add two tickets from yesterday: one unbanned (bantime already out-dated): + ticket2 = FailTicket(ip+'2', stime-24*60*60, []) + ticket2.setBanTime(12*60*60) + ticket2.incrBanCount() + self.db.addBan(jail, ticket2) + # and one from yesterday also, but still currently banned : + ticket2 = FailTicket(ip+'1', stime-24*60*60, []) + ticket2.setBanTime(36*60*60) + ticket2.incrBanCount() + self.db.addBan(jail, ticket2) + # search currently banned: + restored_tickets = self.db.getCurrentBans(fromtime=stime) + self.assertEqual(len(restored_tickets), 2) + self.assertEqual( + str(restored_tickets[0]), + 'FailTicket: ip=%s time=%s bantime=%s bancount=13 #attempts=0 matches=[]' % (ip, stime + lastBanTime + 5, lastBanTime) + ) + self.assertEqual( + str(restored_tickets[1]), + 'FailTicket: ip=%s time=%s bantime=%s bancount=1 #attempts=0 matches=[]' % (ip+'1', stime-24*60*60, 36*60*60) + ) + # search out-dated (give another fromtime now is -18 hours): + restored_tickets = self.db.getCurrentBans(fromtime=stime-18*60*60) + self.assertEqual(len(restored_tickets), 3) + self.assertEqual( + str(restored_tickets[2]), + 'FailTicket: ip=%s time=%s bantime=%s bancount=1 #attempts=0 matches=[]' % (ip+'2', stime-24*60*60, 12*60*60) + ) + # should be still banned + self.assertFalse(restored_tickets[1].isTimedOut(stime)) + self.assertFalse(restored_tickets[1].isTimedOut(stime)) + # the last should be timed out now + self.assertTrue(restored_tickets[2].isTimedOut(stime)) + self.assertFalse(restored_tickets[2].isTimedOut(stime-18*60*60)) + + # test permanent, create timed out: + ticket=FailTicket(ip+'3', stime-36*60*60, []) + self.assertTrue(ticket.isTimedOut(stime, 600)) + # not timed out - permanent jail: + self.assertFalse(ticket.isTimedOut(stime, -1)) + # not timed out - permanent ticket: + ticket.setBanTime(-1) + self.assertFalse(ticket.isTimedOut(stime, 600)) + self.assertFalse(ticket.isTimedOut(stime, -1)) + # timed out - permanent jail but ticket time (not really used behavior) + ticket.setBanTime(600) + self.assertTrue(ticket.isTimedOut(stime, -1)) + + # get currently banned pis with permanent one: + ticket.setBanTime(-1) + ticket.incrBanCount() + self.db.addBan(jail, ticket) + restored_tickets = self.db.getCurrentBans(fromtime=stime) + self.assertEqual(len(restored_tickets), 3) + self.assertEqual( + str(restored_tickets[2]), + 'FailTicket: ip=%s time=%s bantime=%s bancount=1 #attempts=0 matches=[]' % (ip+'3', stime-36*60*60, -1) + ) + # purge (nothing should be changed): + self.db.purge() + restored_tickets = self.db.getCurrentBans(fromtime=stime) + self.assertEqual(len(restored_tickets), 3) + # set short time and purge again: + ticket.setBanTime(600) + ticket.incrBanCount() + self.db.addBan(jail, ticket) + self.db.purge() + # this old ticket should be removed now: + restored_tickets = self.db.getCurrentBans(fromtime=stime) + self.assertEqual(len(restored_tickets), 2) + self.assertEqual(restored_tickets[0].getIP(), ip) + + # purge remove 1st ip + self.db._purgeAge = -48*60*60 + self.db.purge() + restored_tickets = self.db.getCurrentBans(fromtime=stime) + self.assertEqual(len(restored_tickets), 1) + self.assertEqual(restored_tickets[0].getIP(), ip+'1') + + # this should purge all bans, bips and logs - nothing should be found now + self.db._purgeAge = -240*60*60 + self.db.purge() + restored_tickets = self.db.getCurrentBans(fromtime=stime) + self.assertEqual(restored_tickets, []) + + # two separate jails : + jail1 = DummyJail(backend='polling') + jail1.database = self.db + self.db.addJail(jail1) + jail2 = DummyJail(backend='polling') + jail2.database = self.db + self.db.addJail(jail2) + ticket1 = FailTicket(ip, stime, []) + ticket1.setBanTime(6000) + ticket1.incrBanCount() + self.db.addBan(jail1, ticket1) + ticket2 = FailTicket(ip, stime-6000, []) + ticket2.setBanTime(12000) + ticket2.setBanCount(1) + ticket2.incrBanCount() + self.db.addBan(jail2, ticket2) + restored_tickets = self.db.getCurrentBans(jail=jail1, fromtime=stime) + self.assertEqual(len(restored_tickets), 1) + self.assertEqual( + str(restored_tickets[0]), + 'FailTicket: ip=%s time=%s bantime=%s bancount=1 #attempts=0 matches=[]' % (ip, stime, 6000) + ) + restored_tickets = self.db.getCurrentBans(jail=jail2, fromtime=stime) + self.assertEqual(len(restored_tickets), 1) + self.assertEqual( + str(restored_tickets[0]), + 'FailTicket: ip=%s time=%s bantime=%s bancount=2 #attempts=0 matches=[]' % (ip, stime-6000, 12000) + ) + # get last ban values for this ip separately for each jail: + for row in self.db.getBan(ip, jail1): + self.assertEqual(row, (1, stime, 6000)) + break + for row in self.db.getBan(ip, jail2): + self.assertEqual(row, (2, stime-6000, 12000)) + break + # get max values for this ip (over all jails): + for row in self.db.getBan(ip, overalljails=True): + self.assertEqual(row, (3, stime, 18000)) + break + # test restoring bans from database: + jail1.restoreCurrentBans() + self.assertEqual(str(jail1.getFailTicket()), + 'FailTicket: ip=%s time=%s bantime=%s bancount=1 #attempts=0 matches=[]' % (ip, stime, 6000) + ) + # jail2 does not restore any bans (because all ban tickets should be already expired: stime-6000): + jail2.restoreCurrentBans() + self.assertEqual(jail2.getFailTicket(), False) + + def testObserver(self): + if Fail2BanDb is None: # pragma: no cover + return + jail = self.jail + self.db.addJail(jail) + # we tests with initial ban time = 10 seconds: + jail.actions.setBanTime(10) + jail.setBanTimeExtra('increment', 'true') + # observer / database features: + obs = Observers.Main + obs.start() + obs.db_set(self.db) + # wait for start ready + obs.add('nop') + obs.wait_empty(5) + # purge database right now, but using timer, to test it also: + self.db._purgeAge = -240*60*60 + obs.add_named_timer('DB_PURGE', 0.001, 'db_purge') + # wait for timer ready + obs.wait_idle(0.025) + # wait for ready + obs.add('nop') + obs.wait_empty(5) + + stime = int(MyTime.time()) + # completelly empty ? + tickets = self.db.getBans() + self.assertEqual(tickets, []) + + # add failure: + ip = "127.0.0.2" + ticket = FailTicket(ip, stime-120, []) + failManager = FailManager() + failManager.setMaxRetry(3) + for i in xrange(3): + failManager.addFailure(ticket) + obs.add('failureFound', failManager, jail, ticket) + obs.wait_empty(5) + self.assertEqual(ticket.getBanCount(), 0) + # check still not ban : + self.assertTrue(not jail.getFailTicket()) + # add manually 4th times banned (added to bips - make ip bad): + ticket.setBanCount(4) + self.db.addBan(self.jail, ticket) + restored_tickets = self.db.getCurrentBans(jail=jail, fromtime=stime-120) + self.assertEqual(len(restored_tickets), 1) + # check again, new ticket, new failmanager: + ticket = FailTicket(ip, stime, []) + failManager = FailManager() + failManager.setMaxRetry(3) + # add once only - but bad - should be banned: + failManager.addFailure(ticket) + obs.add('failureFound', failManager, self.jail, ticket) + obs.wait_empty(5) + # wait until ticket transfered from failmanager into jail: + to = int(MyTime.time())+30 + while True: + ticket2 = jail.getFailTicket() + if ticket2: + break + time.sleep(Utils.DEFAULT_SLEEP_INTERVAL) + if MyTime.time() > to: # pragma: no cover + raise RuntimeError('unexpected timeout: wait 30 seconds instead of few ms.') + # check ticket and failure count: + self.assertFalse(not ticket2) + self.assertEqual(ticket2.getRetry(), failManager.getMaxRetry()) + + # wrap FailTicket to BanTicket: + failticket2 = ticket2 + ticket2 = BanManager.createBanTicket(failticket2) + self.assertEqual(ticket2, failticket2) + # add this ticket to ban (use observer only without ban manager): + obs.add('banFound', ticket2, jail, 10) + obs.wait_empty(5) + # increased? + self.assertEqual(ticket2.getBanTime(), 160) + self.assertEqual(ticket2.getBanCount(), 5) + + # check prolonged in database also : + restored_tickets = self.db.getCurrentBans(jail=jail, fromtime=stime) + self.assertEqual(len(restored_tickets), 1) + self.assertEqual(restored_tickets[0].getBanTime(), 160) + self.assertEqual(restored_tickets[0].getBanCount(), 5) + + # now using jail/actions: + ticket = FailTicket(ip, stime-60, ['test-expired-ban-time']) + jail.putFailTicket(ticket) + self.assertFalse(jail.actions.checkBan()) + + ticket = FailTicket(ip, MyTime.time(), ['test-actions']) + jail.putFailTicket(ticket) + self.assertTrue(jail.actions.checkBan()) + + obs.wait_empty(5) + restored_tickets = self.db.getCurrentBans(jail=jail, fromtime=stime) + self.assertEqual(len(restored_tickets), 1) + self.assertEqual(restored_tickets[0].getBanTime(), 320) + self.assertEqual(restored_tickets[0].getBanCount(), 6) + + # and permanent: + ticket = FailTicket(ip+'1', MyTime.time(), ['test-permanent']) + ticket.setBanTime(-1) + jail.putFailTicket(ticket) + self.assertTrue(jail.actions.checkBan()) + + obs.wait_empty(5) + ticket = FailTicket(ip+'1', MyTime.time(), ['test-permanent']) + ticket.setBanTime(600) + jail.putFailTicket(ticket) + self.assertFalse(jail.actions.checkBan()) + + obs.wait_empty(5) + restored_tickets = self.db.getCurrentBans(jail=jail, fromtime=stime) + self.assertEqual(len(restored_tickets), 2) + self.assertEqual(restored_tickets[1].getBanTime(), -1) + self.assertEqual(restored_tickets[1].getBanCount(), 1) + + # stop observer + obs.stop() + +class ObserverTest(LogCaptureTestCase): + + def setUp(self): + """Call before every test case.""" + super(ObserverTest, self).setUp() + + def tearDown(self): + """Call after every test case.""" + super(ObserverTest, self).tearDown() + + def testObserverBanTimeIncr(self): + obs = ObserverThread() + obs.start() + # wait for idle + obs.wait_idle(1) + # observer will replace test set: + o = set(['test']) + obs.add('call', o.clear) + obs.add('call', o.add, 'test2') + # wait for observer ready: + obs.wait_empty(1) + self.assertFalse(obs.is_full) + self.assertEqual(o, set(['test2'])) + # observer makes pause + obs.paused = True + # observer will replace test set, but first after pause ends: + obs.add('call', o.clear) + obs.add('call', o.add, 'test3') + obs.wait_empty(10 * Utils.DEFAULT_SLEEP_TIME) + self.assertTrue(obs.is_full) + self.assertEqual(o, set(['test2'])) + obs.paused = False + # wait running: + obs.wait_empty(1) + self.assertEqual(o, set(['test3'])) + + self.assertTrue(obs.isActive()) + self.assertTrue(obs.isAlive()) + obs.stop() + obs = None + + class _BadObserver(ObserverThread): + def run(self): + raise RuntimeError('run bad thread exception') + + def testObserverBadRun(self): + obs = ObserverTest._BadObserver() + # don't wait for empty by stop + obs.wait_empty = lambda v:() + # save previous hook, prevent write stderr and check hereafter __excepthook__ was executed + prev_exchook = sys.__excepthook__ + x = [] + sys.__excepthook__ = lambda *args: x.append(args) + try: + obs.start() + obs.stop() + obs.join() + self.assertTrue( Utils.wait_for( lambda: len(x) and self._is_logged("Unhandled exception"), 3) ) + finally: + sys.__excepthook__ = prev_exchook + self.assertLogged("Unhandled exception") + self.assertEqual(len(x), 1) + self.assertEqual(x[0][0], RuntimeError) + self.assertEqual(str(x[0][1]), 'run bad thread exception') diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 6c0f46cf..9b5ede76 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -905,6 +905,15 @@ class TransmitterLogging(TransmitterBase): self.assertEqual(self.transm.proceed(["set", "logtarget", "STDERR"]), (0, "STDERR")) self.assertEqual(self.transm.proceed(["flushlogs"]), (0, "flushed")) + def testBanTimeIncr(self): + self.setGetTest("bantime.increment", "true", True, jail=self.jailName) + self.setGetTest("bantime.rndtime", "30min", 30*60, jail=self.jailName) + self.setGetTest("bantime.maxtime", "1000 days", 1000*24*60*60, jail=self.jailName) + self.setGetTest("bantime.factor", "2", "2", jail=self.jailName) + self.setGetTest("bantime.formula", "ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor)", jail=self.jailName) + self.setGetTest("bantime.multipliers", "1 5 30 60 300 720 1440 2880", "1 5 30 60 300 720 1440 2880", jail=self.jailName) + self.setGetTest("bantime.overalljails", "true", "true", jail=self.jailName) + class JailTests(unittest.TestCase): @@ -992,9 +1001,10 @@ class LoggingTests(LogCaptureTestCase): badThread = _BadThread() badThread.start() badThread.join() - self.assertLogged("Unhandled exception") + self.assertTrue( Utils.wait_for( lambda: len(x) and self._is_logged("Unhandled exception"), 3) ) finally: sys.__excepthook__ = prev_exchook + self.assertLogged("Unhandled exception") self.assertEqual(len(x), 1) self.assertEqual(x[0][0], RuntimeError) diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 764536bf..269cdbcd 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -265,6 +265,7 @@ def gatherTests(regexps=None, opts=None): from . import sockettestcase from . import misctestcase from . import databasetestcase + from . import observertestcase from . import samplestestcase from . import fail2banclienttestcase from . import fail2banregextestcase @@ -290,7 +291,6 @@ def gatherTests(regexps=None, opts=None): tests = FilteredTestSuite() # Server - #tests.addTest(unittest.makeSuite(servertestcase.StartStop)) tests.addTest(unittest.makeSuite(servertestcase.Transmitter)) tests.addTest(unittest.makeSuite(servertestcase.JailTests)) tests.addTest(unittest.makeSuite(servertestcase.RegexTests)) @@ -331,6 +331,10 @@ def gatherTests(regexps=None, opts=None): tests.addTest(unittest.makeSuite(misctestcase.MyTimeTest)) # Database tests.addTest(unittest.makeSuite(databasetestcase.DatabaseTest)) + # Observer + tests.addTest(unittest.makeSuite(observertestcase.ObserverTest)) + tests.addTest(unittest.makeSuite(observertestcase.BanTimeIncr)) + tests.addTest(unittest.makeSuite(observertestcase.BanTimeIncrDB)) # Filter tests.addTest(unittest.makeSuite(filtertestcase.IgnoreIP))