From 6f7c9b7d0f5c637672ed215e726e5d6ace29b664 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 29 Apr 2014 18:01:57 +0200 Subject: [PATCH 01/60] introduced new feature "ban time exponential increasing": "bantimeextra.enabled" in jail.conf allows to use database for searching of previously banned ip's to increase a default ban time using special formula, by default, each next ban it will be original banTime * 1, 2, 4, 8, 16, 32... see "jail.conf" for some other options of "bantimeextra"; additional we can configure a little randomization of ban time, to prevent "clever" botnets calculate exact time IP can be unbanned. WARNING: by first start the server upgrades sqlite database (table "bans" will recreated with another schema); --- config/jail.conf | 24 ++++++++ fail2ban/client/jailreader.py | 9 +++ fail2ban/server/actions.py | 102 ++++++++++++++++++++++++++++++--- fail2ban/server/banmanager.py | 9 ++- fail2ban/server/database.py | 90 ++++++++++++++++++++++++++++- fail2ban/server/jail.py | 26 ++++++--- fail2ban/server/server.py | 6 ++ fail2ban/server/ticket.py | 27 ++++++++- fail2ban/server/transmitter.py | 8 +++ 9 files changed, 278 insertions(+), 23 deletions(-) diff --git a/config/jail.conf b/config/jail.conf index 96b3096f..05aa96c4 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -44,6 +44,30 @@ before = paths-debian.conf # MISCELLANEOUS OPTIONS # +# "bantimeextra.enabled" 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... +bantimeextra.enabled = true +# "bantimeextra.findtime" is the max number of seconds that we search in the database, +# if it is not specified - whole database will be used for ban searching +# (please observe current "dbpurgeage" value of fail2ban.conf). +bantimeextra.findtime = 24*60*60 +# "bantimeextra.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: +bantimeextra.rndtime = 5*60 +# "bantimeextra.maxtime" is the max number of seconds using the ban time can reach (don't grows further) +bantimeextra.maxtime = 24*60*60 +# "bantimeextra.factor" is a coefficient to calculate exponent growing of the formula, +# by default value of factor "2.0 / 2.885385" and default value of formula, the ban time +# grows by 1, 2, 4, 8, 16 ... +#bantimeextra.factor = 2.0 / 2.885385 +# "bantimeextra.formula" used to calculate next value of ban time; +#bantimeextra.formula = banTime * math.exp(float(banCount)*banFactor)/math.exp(1*banFactor) +# "bantimeextra.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 +#bantimeextra.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 separator. diff --git a/fail2ban/client/jailreader.py b/fail2ban/client/jailreader.py index 5735b021..85b136d9 100644 --- a/fail2ban/client/jailreader.py +++ b/fail2ban/client/jailreader.py @@ -93,6 +93,13 @@ class JailReader(ConfigReader): ["int", "maxretry", None], ["int", "findtime", None], ["int", "bantime", None], + ["bool", "bantimeextra.enabled", False], + ["string", "bantimeextra.findtime", None], + ["string", "bantimeextra.factor", None], + ["string", "bantimeextra.formula", None], + ["string", "bantimeextra.maxtime", None], + ["string", "bantimeextra.rndtime", None], + ["bool", "bantimeextra.overalljails", None], ["string", "usedns", None], ["string", "failregex", None], ["string", "ignoreregex", None], @@ -198,6 +205,8 @@ class JailReader(ConfigReader): stream.append(["set", self.__name, "findtime", self.__opts[opt]]) elif opt == "bantime": stream.append(["set", self.__name, "bantime", self.__opts[opt]]) + elif opt.startswith("bantimeextra."): + stream.append(["set", self.__name, opt, self.__opts[opt]]) elif opt == "usedns": stream.append(["set", self.__name, "usedns", self.__opts[opt]]) elif opt == "failregex": diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index d5799ca1..73848b55 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -25,7 +25,7 @@ __copyright__ = "Copyright (c) 2004 Cyril Jaquier" __license__ = "GPL" import time, logging -import os +import os, datetime, math, json, random import sys if sys.version_info >= (3, 3): import importlib.machinery @@ -83,6 +83,8 @@ class Actions(JailThread, Mapping): self._actions = dict() ## The ban manager. self.__banManager = BanManager() + ## Extra parameters for increase ban time + self._banExtra = {'maxtime': 24*60*60}; def add(self, name, pythonModule=None, initOpts=None): """Adds a new action. @@ -240,6 +242,71 @@ class Actions(JailThread, Mapping): logSys.debug(self._jail.name + ": action terminated") return True + def setBanTimeExtra(self, opt, value): + # merge previous extra with new option: + be = self._banExtra; + be[opt] = value; + logSys.info('Set banTimeExtra.%s = %s', opt, value) + if opt == 'enabled': + be[opt] = bool(value) + if bool(value) and self._jail.database is None: + logSys.warning("banTimeExtra is not available as long jail database is not set") + if opt in ['findtime', 'maxtime', 'rndtime']: + if not value is None: + be[opt] = eval(value) + if opt == 'factor' or be.get('factor', None) is None: + be['factor'] = eval(be.get('factor', "2.0 / 2.885385")); + # prepare formula : + if opt in ['formula', 'maxtime', 'rndtime'] or be.get('evformula', None) is None: + be['formula'] = be.get('formula', 'banTime * math.exp(float(banCount)*banFactor)/math.exp(1*banFactor)') + evformula = be['formula']; + evformula = ('max(banTime, %s)' % evformula) + if not be.get('maxtime', None) is None: + evformula = ('min(%s, %s)' % (evformula, be['maxtime'])) + # mix with random time (to prevent botnet calc exact time IP can be unbanned): + if not be.get('rndtime', None) is None: + evformula = ('(%s + random.random() * %s)' % (evformula, be['rndtime'])) + # set to extra dict: + be['evformula'] = evformula + #logSys.info('banTimeExtra : %s' % json.dumps(be)) + + def getBanTimeExtra(self, opt): + return self._banExtra.get(opt, None) + + def incrBanTime(self, bTicket, ip): + """Check for IP address to increment ban time (if was already banned). + + Returns + ------- + float + new ban time. + """ + orgBanTime = self.__banManager.getBanTime() + banTime = orgBanTime + # check ip was already banned (increment time of ban): + try: + be = self._banExtra; + if banTime > 0 and be.get('enabled', False): + banFactor = be['factor']; + # search IP in database and increase time if found: + for banCount, timeOfBan, lastBanTime in \ + self._jail.database.getBan(ip, self._jail, be.get('findtime', None), be.get('overalljails', False) \ + ): + #logSys.debug('IP %s was already banned: %s #, %s' % (ip, banCount, timeOfBan)); + bTicket.setBanCount(banCount); + # calculate new ban time + banTime = eval(be['evformula']) + bTicket.setBanTime(banTime); + logSys.info('[%s] %s was already banned: %s # at last %s - increase time %s to %s' % (self._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)))); + break + except Exception as e: + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + #logSys.error('%s', e, exc_info=True) + + return banTime + def __checkBan(self): """Check for IP address to ban. @@ -255,25 +322,46 @@ class Actions(JailThread, Mapping): if ticket != False: aInfo = CallingMap() bTicket = BanManager.createBanTicket(ticket) - aInfo["ip"] = bTicket.getIP() + if ticket.getBanTime() is not None: + bTicket.setBanTime(ticket.getBanTime()) + bTicket.setBanCount(ticket.getBanCount()) + 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()); if self._jail.database is not None: aInfo["ipmatches"] = lambda: "\n".join( self._jail.database.getBansMerged( - ip=bTicket.getIP()).getMatches()) + ip=ip).getMatches()) aInfo["ipjailmatches"] = lambda: "\n".join( self._jail.database.getBansMerged( - ip=bTicket.getIP(), jail=self._jail).getMatches()) + ip=ip, jail=self._jail).getMatches()) aInfo["ipfailures"] = lambda: "\n".join( self._jail.database.getBansMerged( - ip=bTicket.getIP()).getAttempt()) + ip=ip).getAttempt()) aInfo["ipjailfailures"] = lambda: "\n".join( self._jail.database.getBansMerged( - ip=bTicket.getIP(), jail=self._jail).getAttempt()) + ip=ip, jail=self._jail).getAttempt()) + try: + # if ban time was not set: + if bTicket.getBanTime() is None: + btime = self.incrBanTime(bTicket, ip) + bTicket.setBanTime(btime); + except Exception as e: + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + #logSys.error('%s', e, exc_info=True) + if self.__banManager.addBanTicket(bTicket): - logSys.notice("[%s] Ban %s" % (self._jail.name, aInfo["ip"])) + if self._jail.database is not None: + # add to database always only after ban time was calculated an not yet already banned: + # if ticked was not restored from database - put it into database: + if not ticket.getRestored(): + self._jail.database.addBan(self._jail, bTicket) + logSys.notice("[%s] %sBan %s (%d # %s -> %s)" % (self._jail.name, ('Resore ' if ticket.getRestored() else ''), + aInfo["ip"], bTicket.getBanCount(), datetime.timedelta(seconds=int(btime)), + datetime.datetime.fromtimestamp(aInfo["time"] + btime).strftime("%Y-%m-%d %H:%M:%S"))) for name, action in self._actions.iteritems(): try: action.ban(aInfo) diff --git a/fail2ban/server/banmanager.py b/fail2ban/server/banmanager.py index fc9eb948..5d614cf5 100644 --- a/fail2ban/server/banmanager.py +++ b/fail2ban/server/banmanager.py @@ -129,8 +129,11 @@ class BanManager: #@staticmethod def createBanTicket(ticket): ip = ticket.getIP() - #lastTime = ticket.getTime() - lastTime = MyTime.time() + # if ticked was restored from database - set time of original restored ticket: + if ticket.getRestored(): + lastTime = ticket.getTime() + else: + lastTime = MyTime.time() banTicket = BanTicket(ip, lastTime, ticket.getMatches()) banTicket.setAttempt(ticket.getAttempt()) return banTicket @@ -197,7 +200,7 @@ class BanManager: # Gets the list of ticket to remove. unBanList = [ticket for ticket in self.__banList - if ticket.getTime() < time - self.__banTime] + if ticket.getTime() < time - ticket.getBanTime(self.__banTime)] # Removes tickets. self.__banList = [ticket for ticket in self.__banList diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index 93186222..136f3e7a 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -87,7 +87,7 @@ class Fail2BanDb(object): filename purgeage """ - __version__ = 2 + __version__ = 3 # Note all _TABLE_* strings must end in ';' for py26 compatibility _TABLE_fail2banDb = "CREATE TABLE fail2banDb(version INTEGER);" _TABLE_jails = "CREATE TABLE jails(" \ @@ -114,6 +114,8 @@ class Fail2BanDb(object): "jail TEXT NOT NULL, " \ "ip TEXT, " \ "timeofban INTEGER NOT NULL, " \ + "bantime INTEGER NOT NULL, " \ + "bancount INTEGER NOT NULL, " \ "data JSON, " \ "FOREIGN KEY(jail) REFERENCES jails(name) " \ ");" \ @@ -121,6 +123,9 @@ class Fail2BanDb(object): "CREATE INDEX bans_jail_ip ON bans(jail, ip);" \ "CREATE INDEX bans_ip ON bans(ip);" \ + # todo: for performance reasons create a table with currently banned unique jails-ips only (with last ban of time and bantime). + # check possible view performance instead of new table; + def __init__(self, filename, purgeAge=24*60*60): try: self._lock = Lock() @@ -220,6 +225,16 @@ 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;" + "UPDATE fail2banDb SET version = 3;" + "COMMIT;" % Fail2BanDb._TABLE_bans) + cur.execute("SELECT version FROM fail2banDb LIMIT 1") return cur.fetchone()[0] @@ -367,8 +382,8 @@ 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, ticket.getIP(), ticket.getTime(), + "INSERT INTO bans(jail, ip, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", + (jail.name, ticket.getIP(), ticket.getTime(), ticket.getBanTime(), ticket.getBanCount() + 1, {"matches": ticket.getMatches(), "failures": ticket.getAttempt()})) @@ -472,6 +487,75 @@ class Fail2BanDb(object): self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket + def getBan(self, ip, jail=None, forbantime=None, overalljails=None): + #query = "SELECT count(ip), max(timeofban) FROM bans WHERE ip = ?" + if overalljails is None or not overalljails: + query = "SELECT bancount, max(timeofban), bantime FROM bans" + else: + query = "SELECT max(bancount), max(timeofban), bantime FROM bans" + query += " WHERE ip = ?" + queryArgs = [ip] + if (overalljails is None or 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) + query += " GROUP BY ip ORDER BY timeofban DESC LIMIT 1" + cur = self._db.cursor() + return cur.execute(query, queryArgs) + + def _getCurrentBans(self, jail = None, ip = None, forbantime=None): + #query = "SELECT count(ip), max(timeofban) FROM bans WHERE ip = ?" + query = "SELECT ip, max(timeofban), bantime, bancount, data FROM bans WHERE 1" + queryArgs = [] + if jail is not None: + query += " AND jail=?" + queryArgs.append(jail.name) + if ip is not None: + query += " AND ip=?" + queryArgs.append(ip) + query += " AND timeofban + bantime > ?" + queryArgs.append(MyTime.time()) + if forbantime is not None: + query += " AND timeofban > ?" + queryArgs.append(MyTime.time() - forbantime) + query += " GROUP BY ip ORDER BY ip, timeofban DESC" + cur = self._db.cursor() + #logSys.debug((query, queryArgs)); + return cur.execute(query, queryArgs) + + def getCurrentBans(self, jail = None, ip = None, forbantime=None): + if forbantime is None: + cacheKey = (ip, jail) + if cacheKey in self._bansMergedCache: + return self._bansMergedCache[cacheKey] + + tickets = [] + ticket = None + + results = list(self._getCurrentBans(jail=jail, ip=ip, forbantime=forbantime)) + + 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) + + if forbantime is None: + self._bansMergedCache[cacheKey] = tickets if ip is None else ticket + return tickets if ip is None else ticket + @commitandrollback def purge(self, cur): """Purge old bans, jails and log files from database. diff --git a/fail2ban/server/jail.py b/fail2ban/server/jail.py index a7c174ae..8e99e780 100644 --- a/fail2ban/server/jail.py +++ b/fail2ban/server/jail.py @@ -188,8 +188,8 @@ class Jail: 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 actions (should previously check not already banned + # and increase ticket time if "bantimeextra.enabled" set) def getFailTicket(self): """Get a fail ticket from the jail. @@ -210,11 +210,23 @@ class Jail: 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()): - self.__queue.put(ticket) + try: + if self.database is not None: + forbantime = None; + if self.actions.getBanTimeExtra('enabled'): + forbantime = self.actions.getBanTimeExtra('findtime') + if forbantime is None: + 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()): + # mark ticked was restored from database - does not put it again into db: + ticket.setRestored(True) + self.__queue.put(ticket) + except Exception as e: + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + #logSys.error('%s', e, exc_info=True) + logSys.info("Jail '%s' started" % self.name) def stop(self): diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 1bf8dcbb..a10d5ab3 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -302,6 +302,12 @@ class Server: def getBanTime(self, name): return self.__jails[name].actions.getBanTime() + + def setBanTimeExtra(self, name, opt, value): + self.__jails[name].actions.setBanTimeExtra(opt, value) + + def getBanTimeExtra(self, name, opt): + return self.__jails[name].actions.getBanTimeExtra(opt) # Status def status(self): diff --git a/fail2ban/server/ticket.py b/fail2ban/server/ticket.py index 8d036b2d..6c7c2f2c 100644 --- a/fail2ban/server/ticket.py +++ b/fail2ban/server/ticket.py @@ -40,14 +40,17 @@ class Ticket: """ self.setIP(ip) + self.__restored = False; + self.__banCount = 0; + self.__banTime = None; self.__time = time self.__attempt = 0 self.__file = None self.__matches = matches or [] def __str__(self): - return "%s: ip=%s time=%s #attempts=%d matches=%r" % \ - (self.__class__.__name__.split('.')[-1], self.__ip, self.__time, self.__attempt, self.__matches) + 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.__attempt, self.__matches) def __repr__(self): return str(self) @@ -75,7 +78,19 @@ class Ticket: def getTime(self): return self.__time - + + def setBanTime(self, value): + self.__banTime = value; + + def getBanTime(self, defaultBT = None): + return (self.__banTime if not self.__banTime is None else defaultBT); + + def setBanCount(self, value): + self.__banCount = value; + + def getBanCount(self): + return self.__banCount; + def setAttempt(self, value): self.__attempt = value @@ -85,6 +100,12 @@ class Ticket: def getMatches(self): return self.__matches + def setRestored(self, value): + self.__restored = value + + def getRestored(self): + return self.__restored + class FailTicket(Ticket): pass diff --git a/fail2ban/server/transmitter.py b/fail2ban/server/transmitter.py index 2baf00a7..471cbea4 100644 --- a/fail2ban/server/transmitter.py +++ b/fail2ban/server/transmitter.py @@ -222,6 +222,11 @@ class Transmitter: value = command[2] self.__server.setBanTime(name, int(value)) return self.__server.getBanTime(name) + elif command[1].startswith("bantimeextra."): + value = command[2] + opt = command[1][len("bantimeextra."):] + 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) @@ -300,6 +305,9 @@ class Transmitter: # Action elif command[1] == "bantime": return self.__server.getBanTime(name) + elif command[1].startswith("bantimeextra."): + opt = command[1][len("bantimeextra."):] + return self.__server.getBanTimeExtra(name, opt) elif command[1] == "actions": return self.__server.getActions(name).keys() elif command[1] == "action": From ccf07c4b21ac3720e0660972c3c2853cffc1157b Mon Sep 17 00:00:00 2001 From: sebres Date: Mon, 5 May 2014 14:47:50 +0200 Subject: [PATCH 02/60] - some bug fixed to pass all test cases; - database_v1.db/bans/jail-name bug fixed - cause of different jail name in jails and bans, in test case (by updateDb): FOREIGN KEY constraint failed: $ sqlite3 fail2ban/tests/files/database_v1.db sqlite> select distinct jail from bans; DummyJail #16244880 with 0 tickets sqlite> select distinct name from jails; DummyJail #29162448 with 0 tickets sqlite> update bans set jail = (select distinct name from jails); --- config/jail.conf | 8 ++++---- fail2ban/server/database.py | 4 ++-- fail2ban/tests/failmanagertestcase.py | 6 +++--- fail2ban/tests/files/database_v1.db | Bin 15360 -> 15360 bytes 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/config/jail.conf b/config/jail.conf index 05aa96c4..d3d49234 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -46,16 +46,16 @@ before = paths-debian.conf # "bantimeextra.enabled" 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... -bantimeextra.enabled = true +#bantimeextra.enabled = true # "bantimeextra.findtime" is the max number of seconds that we search in the database, # if it is not specified - whole database will be used for ban searching # (please observe current "dbpurgeage" value of fail2ban.conf). -bantimeextra.findtime = 24*60*60 +#bantimeextra.findtime = 24*60*60 # "bantimeextra.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: -bantimeextra.rndtime = 5*60 +#bantimeextra.rndtime = 5*60 # "bantimeextra.maxtime" is the max number of seconds using the ban time can reach (don't grows further) -bantimeextra.maxtime = 24*60*60 +#bantimeextra.maxtime = 24*60*60 # "bantimeextra.factor" is a coefficient to calculate exponent growing of the formula, # by default value of factor "2.0 / 2.885385" and default value of formula, the ban time # grows by 1, 2, 4, 8, 16 ... diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index 136f3e7a..de575bcf 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -115,7 +115,7 @@ class Fail2BanDb(object): "ip TEXT, " \ "timeofban INTEGER NOT NULL, " \ "bantime INTEGER NOT NULL, " \ - "bancount INTEGER NOT NULL, " \ + "bancount INTEGER NOT NULL default 1, " \ "data JSON, " \ "FOREIGN KEY(jail) REFERENCES jails(name) " \ ");" \ @@ -383,7 +383,7 @@ class Fail2BanDb(object): #TODO: Implement data parts once arbitrary match keys completed cur.execute( "INSERT INTO bans(jail, ip, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", - (jail.name, ticket.getIP(), ticket.getTime(), ticket.getBanTime(), ticket.getBanCount() + 1, + (jail.name, ticket.getIP(), ticket.getTime(), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount() + 1, {"matches": ticket.getMatches(), "failures": ticket.getAttempt()})) diff --git a/fail2ban/tests/failmanagertestcase.py b/fail2ban/tests/failmanagertestcase.py index 1f99d161..c5cf2412 100644 --- a/fail2ban/tests/failmanagertestcase.py +++ b/fail2ban/tests/failmanagertestcase.py @@ -96,10 +96,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(ticket == False) # and some get/set-ers otherwise not tested ticket.setTime(1000002000.0) @@ -107,7 +107,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.__failManager.setMaxRetry(10) diff --git a/fail2ban/tests/files/database_v1.db b/fail2ban/tests/files/database_v1.db index 2082267184e3467aef9e8a48c659de334da7cce1..fa2d7bb287d35ef04ccddc5e0b7de823c25bbe9b 100644 GIT binary patch delta 48 ycmZpuXsDPV%_uTa#+g-wLH8Nc#uR_`$+M)zH=j}$X8|*QXun_wGrk*hasU90 Date: Mon, 5 May 2014 15:17:22 +0200 Subject: [PATCH 03/60] code review --- fail2ban/client/jailreader.py | 2 +- fail2ban/server/actions.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/fail2ban/client/jailreader.py b/fail2ban/client/jailreader.py index 85b136d9..4d3f2801 100644 --- a/fail2ban/client/jailreader.py +++ b/fail2ban/client/jailreader.py @@ -93,7 +93,7 @@ class JailReader(ConfigReader): ["int", "maxretry", None], ["int", "findtime", None], ["int", "bantime", None], - ["bool", "bantimeextra.enabled", False], + ["bool", "bantimeextra.enabled", None], ["string", "bantimeextra.findtime", None], ["string", "bantimeextra.factor", None], ["string", "bantimeextra.formula", None], diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 73848b55..5b1c738a 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -248,8 +248,9 @@ class Actions(JailThread, Mapping): be[opt] = value; logSys.info('Set banTimeExtra.%s = %s', opt, value) if opt == 'enabled': - be[opt] = bool(value) - if bool(value) and self._jail.database is None: + if isinstance(value, str): + be[opt] = value.lower() in ("yes", "true", "ok", "1") + if be[opt] and self._jail.database is None: logSys.warning("banTimeExtra is not available as long jail database is not set") if opt in ['findtime', 'maxtime', 'rndtime']: if not value is None: @@ -346,7 +347,7 @@ class Actions(JailThread, Mapping): ip=ip, jail=self._jail).getAttempt()) try: # if ban time was not set: - if bTicket.getBanTime() is None: + if not ticket.getRestored() and bTicket.getBanTime() is None: btime = self.incrBanTime(bTicket, ip) bTicket.setBanTime(btime); except Exception as e: From c48e404e63a6a2a70e64ffa995909e7365e48c6f Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 6 May 2014 16:07:16 +0200 Subject: [PATCH 04/60] option "multipliers" added, how proposed from @yarikoptic; the calculate formula is rewritten to lambda / compiled solution (up to 10 million times per seconds); code review; --- config/jail.conf | 19 ++++++++++++- fail2ban/client/jailreader.py | 1 + fail2ban/server/actions.py | 50 ++++++++++++++++++++++++++--------- 3 files changed, 56 insertions(+), 14 deletions(-) diff --git a/config/jail.conf b/config/jail.conf index d3d49234..1ee0e08c 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -47,21 +47,38 @@ before = paths-debian.conf # "bantimeextra.enabled" 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... #bantimeextra.enabled = true + # "bantimeextra.findtime" is the max number of seconds that we search in the database, # if it is not specified - whole database will be used for ban searching # (please observe current "dbpurgeage" value of fail2ban.conf). #bantimeextra.findtime = 24*60*60 + # "bantimeextra.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: #bantimeextra.rndtime = 5*60 + # "bantimeextra.maxtime" is the max number of seconds using the ban time can reach (don't grows further) #bantimeextra.maxtime = 24*60*60 + # "bantimeextra.factor" is a coefficient to calculate exponent growing of the formula, -# by default value of factor "2.0 / 2.885385" and default value of formula, the ban time +# for formula solution by default value of factor is "2.0 / 2.885385" and with default value of formula, the ban time # grows by 1, 2, 4, 8, 16 ... +# for multipliers solution by default value of factor is "1" and the ban time grows by specified multipliers +# corresponding ban count only #bantimeextra.factor = 2.0 / 2.885385 + # "bantimeextra.formula" used to calculate next value of ban time; #bantimeextra.formula = banTime * math.exp(float(banCount)*banFactor)/math.exp(1*banFactor) + +# "bantimeextra.multipliers" used to calculate next value of ban time instead of formula, coresponding +# previously ban count and given "bantimeextra.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 +#bantimeextra.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 +#bantimeextra.multipliers = 1 5 30 60 300 720 1440 2880 + # "bantimeextra.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 #bantimeextra.overalljails = false diff --git a/fail2ban/client/jailreader.py b/fail2ban/client/jailreader.py index 4d3f2801..06f70277 100644 --- a/fail2ban/client/jailreader.py +++ b/fail2ban/client/jailreader.py @@ -97,6 +97,7 @@ class JailReader(ConfigReader): ["string", "bantimeextra.findtime", None], ["string", "bantimeextra.factor", None], ["string", "bantimeextra.formula", None], + ["string", "bantimeextra.multipliers", None], ["string", "bantimeextra.maxtime", None], ["string", "bantimeextra.rndtime", None], ["bool", "bantimeextra.overalljails", None], diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 5b1c738a..0013eb49 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -242,10 +242,18 @@ class Actions(JailThread, Mapping): logSys.debug(self._jail.name + ": action terminated") return True + class BanTimeIncr: + def __init__(self, banTime, banCount): + self.Time = banTime + self.Count = banCount + def setBanTimeExtra(self, opt, value): # merge previous extra with new option: be = self._banExtra; - be[opt] = value; + if value is not None: + be[opt] = value; + else: + del be[opt] logSys.info('Set banTimeExtra.%s = %s', opt, value) if opt == 'enabled': if isinstance(value, str): @@ -255,18 +263,31 @@ class Actions(JailThread, Mapping): if opt in ['findtime', 'maxtime', 'rndtime']: if not value is None: be[opt] = eval(value) - if opt == 'factor' or be.get('factor', None) is None: - be['factor'] = eval(be.get('factor', "2.0 / 2.885385")); - # prepare formula : - if opt in ['formula', 'maxtime', 'rndtime'] or be.get('evformula', None) is None: - be['formula'] = be.get('formula', 'banTime * math.exp(float(banCount)*banFactor)/math.exp(1*banFactor)') - evformula = be['formula']; - evformula = ('max(banTime, %s)' % evformula) + # 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', []) + if len(multipliers): + banFactor = eval(be.get('factor', "1")) + evformula = lambda ban, banFactor=banFactor: ( + ban.Time * banFactor * multipliers[ban.Count if ban.Count < len(multipliers) else -1] + ) + else: + banFactor = eval(be.get('factor', "2.0 / 2.885385")) + formula = be.get('formula', 'ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*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: - evformula = ('min(%s, %s)' % (evformula, be['maxtime'])) - # mix with random time (to prevent botnet calc exact time IP can be unbanned): + 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: - evformula = ('(%s + random.random() * %s)' % (evformula, be['rndtime'])) + 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)) @@ -274,6 +295,9 @@ class Actions(JailThread, Mapping): def getBanTimeExtra(self, opt): return self._banExtra.get(opt, None) + def calcBanTime(self, banTime, banCount): + return self._banExtra['evformula'](self.BanTimeIncr(banTime, banCount)) + def incrBanTime(self, bTicket, ip): """Check for IP address to increment ban time (if was already banned). @@ -288,7 +312,6 @@ class Actions(JailThread, Mapping): try: be = self._banExtra; if banTime > 0 and be.get('enabled', False): - banFactor = be['factor']; # search IP in database and increase time if found: for banCount, timeOfBan, lastBanTime in \ self._jail.database.getBan(ip, self._jail, be.get('findtime', None), be.get('overalljails', False) \ @@ -296,7 +319,8 @@ class Actions(JailThread, Mapping): #logSys.debug('IP %s was already banned: %s #, %s' % (ip, banCount, timeOfBan)); bTicket.setBanCount(banCount); # calculate new ban time - banTime = eval(be['evformula']) + if banCount > 0: + banTime = be['evformula'](self.BanTimeIncr(banTime, banCount)) bTicket.setBanTime(banTime); logSys.info('[%s] %s was already banned: %s # at last %s - increase time %s to %s' % (self._jail.name, ip, banCount, datetime.datetime.fromtimestamp(timeOfBan).strftime("%Y-%m-%d %H:%M:%S"), From 237706e39fb7ed95271c74312664fc512c498a77 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 6 May 2014 17:57:11 +0200 Subject: [PATCH 05/60] ban time incr: 1st test case added, to test it stand-alone: python ./bin/fail2ban-testcases -l debug 'BanTimeIncr' --- fail2ban/server/actions.py | 4 +- fail2ban/tests/actionstestcase.py | 114 ++++++++++++++++++++++++++++++ fail2ban/tests/utils.py | 1 + 3 files changed, 118 insertions(+), 1 deletion(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 0013eb49..01c2a761 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -250,9 +250,11 @@ class Actions(JailThread, Mapping): 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; - else: + elif opt in be: del be[opt] logSys.info('Set banTimeExtra.%s = %s', opt, value) if opt == 'enabled': diff --git a/fail2ban/tests/actionstestcase.py b/fail2ban/tests/actionstestcase.py index ed0fb619..f3a2ceae 100644 --- a/fail2ban/tests/actionstestcase.py +++ b/fail2ban/tests/actionstestcase.py @@ -140,3 +140,117 @@ class ExecuteActions(LogCaptureTestCase): self.__actions.stop() self.__actions.join() self.assertTrue(self._is_logged("Failed to stop")) + + +# Author: Serg G. Brester (sebres) +# + +__author__ = "Serg Brester" +__copyright__ = "Copyright (c) 2014 Serg G. Brester" + +class BanTimeIncr(LogCaptureTestCase): + + def setUp(self): + """Call before every test case.""" + super(BanTimeIncr, self).setUp() + self.__jail = DummyJail() + self.__actions = Actions(self.__jail) + self.__tmpfile, self.__tmpfilename = tempfile.mkstemp() + + def tearDown(self): + super(BanTimeIncr, self).tearDown() + os.remove(self.__tmpfilename) + + def testMultipliers(self): + a = self.__actions; + a.setBanTimeExtra('maxtime', '24*60*60') + a.setBanTimeExtra('rndtime', None) + a.setBanTimeExtra('factor', None) + a.setBanTimeExtra('multipliers', '1 2 4 8 16 32 64 128 256') + 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', '30*24*60*60') + self.assertEqual( + [a.calcBanTime(600, i) for i in xrange(1, 11)], + [1200, 2400, 4800, 9600, 19200, 38400, 76800, 153600, 153600, 153600] + ) + a.setBanTimeExtra('maxtime', '24*60*60') + # 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] + ) + a.setBanTimeExtra('factor', None); + # change max time : + a.setBanTimeExtra('maxtime', '12*60*60') + 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', '24*60*60') + ## test randomization - not possibe all 10 times we have random = 0: + a.setBanTimeExtra('rndtime', '5*60') + 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', '24*60*60') + a.setBanTimeExtra('rndtime', None) + + def testFormula(self): + a = self.__actions; + a.setBanTimeExtra('maxtime', '24*60*60') + a.setBanTimeExtra('rndtime', None) + a.setBanTimeExtra('factor', None) + ## use default formula: + a.setBanTimeExtra('multipliers', None) + self.assertEqual( + [int(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', '30*24*60*60') + self.assertEqual( + [int(a.calcBanTime(600, i)) for i in xrange(1, 11)], + [1200, 2400, 4800, 9600, 19200, 38400, 76800, 153601, 307203, 614407] + ) + a.setBanTimeExtra('maxtime', '24*60*60') + # change factor : + a.setBanTimeExtra('factor', '1'); + self.assertEqual( + [int(a.calcBanTime(600, i)) for i in xrange(1, 11)], + [1630, 4433, 12051, 32758, 86400, 86400, 86400, 86400, 86400, 86400] + ) + a.setBanTimeExtra('factor', None); + # change max time : + a.setBanTimeExtra('maxtime', '12*60*60') + self.assertEqual( + [int(a.calcBanTime(600, i)) for i in xrange(1, 11)], + [1200, 2400, 4800, 9600, 19200, 38400, 43200, 43200, 43200, 43200] + ) + a.setBanTimeExtra('maxtime', '24*60*60') + ## test randomization - not possibe all 10 times we have random = 0: + a.setBanTimeExtra('rndtime', '5*60') + self.assertTrue( + False in [1200 in [int(a.calcBanTime(600, 1)) for i in xrange(10)] for c in xrange(10)] + ) + a.setBanTimeExtra('rndtime', None) + self.assertFalse( + False in [1200 in [int(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', '24*60*60') + a.setBanTimeExtra('rndtime', None) + diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 85c1d929..bfd11074 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -165,6 +165,7 @@ def gatherTests(regexps=None, no_network=False): tests.addTest(unittest.makeSuite(servertestcase.RegexTests)) tests.addTest(unittest.makeSuite(actiontestcase.CommandActionTest)) tests.addTest(unittest.makeSuite(actionstestcase.ExecuteActions)) + tests.addTest(unittest.makeSuite(actionstestcase.BanTimeIncr)) # FailManager tests.addTest(unittest.makeSuite(failmanagertestcase.AddFailure)) # BanManager From 14167ed77870e31b08b495ac1fe6b084d8c41c2d Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 6 May 2014 20:14:23 +0200 Subject: [PATCH 06/60] ban time incr: 2st test case added (code optimized for test cases), to test both stand-alone: python ./bin/fail2ban-testcases -l debug 'BanTimeIncr' --- fail2ban/server/actions.py | 5 +- fail2ban/server/database.py | 16 ++-- fail2ban/tests/databasetestcase.py | 128 +++++++++++++++++++++++++++++ fail2ban/tests/utils.py | 1 + 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 01c2a761..962ecd2b 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -300,7 +300,7 @@ class Actions(JailThread, Mapping): def calcBanTime(self, banTime, banCount): return self._banExtra['evformula'](self.BanTimeIncr(banTime, banCount)) - def incrBanTime(self, bTicket, ip): + def incrBanTime(self, bTicket): """Check for IP address to increment ban time (if was already banned). Returns @@ -308,6 +308,7 @@ class Actions(JailThread, Mapping): float new ban time. """ + ip = bTicket.getIP() orgBanTime = self.__banManager.getBanTime() banTime = orgBanTime # check ip was already banned (increment time of ban): @@ -374,7 +375,7 @@ class Actions(JailThread, Mapping): try: # if ban time was not set: if not ticket.getRestored() and bTicket.getBanTime() is None: - btime = self.incrBanTime(bTicket, ip) + btime = self.incrBanTime(bTicket) bTicket.setBanTime(btime); except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index de575bcf..b75501cc 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -505,7 +505,9 @@ class Fail2BanDb(object): cur = self._db.cursor() return cur.execute(query, queryArgs) - def _getCurrentBans(self, jail = None, ip = None, forbantime=None): + def _getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): + if fromtime is None: + fromtime = MyTime.time() #query = "SELECT count(ip), max(timeofban) FROM bans WHERE ip = ?" query = "SELECT ip, max(timeofban), bantime, bancount, data FROM bans WHERE 1" queryArgs = [] @@ -516,17 +518,17 @@ class Fail2BanDb(object): query += " AND ip=?" queryArgs.append(ip) query += " AND timeofban + bantime > ?" - queryArgs.append(MyTime.time()) + queryArgs.append(fromtime) if forbantime is not None: query += " AND timeofban > ?" - queryArgs.append(MyTime.time() - forbantime) + queryArgs.append(fromtime - forbantime) query += " GROUP BY ip ORDER BY ip, timeofban DESC" cur = self._db.cursor() #logSys.debug((query, queryArgs)); return cur.execute(query, queryArgs) - def getCurrentBans(self, jail = None, ip = None, forbantime=None): - if forbantime is None: + def getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): + if forbantime is None and jail is not None: cacheKey = (ip, jail) if cacheKey in self._bansMergedCache: return self._bansMergedCache[cacheKey] @@ -534,7 +536,7 @@ class Fail2BanDb(object): tickets = [] ticket = None - results = list(self._getCurrentBans(jail=jail, ip=ip, forbantime=forbantime)) + results = list(self._getCurrentBans(jail=jail, ip=ip, forbantime=forbantime, fromtime=fromtime)) if results: matches = [] @@ -552,7 +554,7 @@ class Fail2BanDb(object): ticket.setAttempt(failures) tickets.append(ticket) - if forbantime is None: + if forbantime is None and jail is not None: self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 84101c50..baea6eeb 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -278,3 +278,131 @@ class DatabaseTest(unittest.TestCase): self.db.purge() # Should leave jail as ban present self.assertEqual(len(self.db.getJailNames()), 1) self.assertEqual(len(self.db.getBans(jail=self.jail)), 1) + + +# Author: Serg G. Brester (sebres) +# + +__author__ = "Serg Brester" +__copyright__ = "Copyright (c) 2014 Serg G. Brester" + +class BanTimeIncr(unittest.TestCase): + + def setUp(self): + """Call before every test case.""" + if Fail2BanDb is None and sys.version_info >= (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 = Fail2BanDb(self.dbFilename) + + def tearDown(self): + """Call after every test case.""" + if Fail2BanDb is None: # pragma: no cover + return + # Cleanup + os.remove(self.dbFilename) + + def testBanTimeIncr(self): + if Fail2BanDb is None: # pragma: no cover + return + jail = DummyJail() + jail.database = self.db + self.db.addJail(jail) + a = jail.actions + # we tests with initial ban time = 10 seconds: + a.setBanTime(10) + a.setBanTimeExtra('enabled', 'true') + a.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( + [a.incrBanTime(ticket) for i in xrange(3)], + [10, 10, 10] + ) + # add a ticket banned + 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(a.incrBanTime(ticket), 20) + 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)] + ) + # 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 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: + for i in xrange(10): + ticket.setTime(stime + lastBanTime + 5) + banTime = a.incrBanTime(ticket) + self.assertEqual(banTime, lastBanTime * 2) + self.db.addBan(jail, ticket) + lastBanTime = banTime + # increase again, but the last multiplier reached (time not increased): + ticket.setTime(stime + lastBanTime + 5) + banTime = a.incrBanTime(ticket) + self.assertNotEqual(banTime, lastBanTime * 2) + self.assertEqual(banTime, lastBanTime) + 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) + 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) + 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) + ) diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index bfd11074..8c45a1b6 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -184,6 +184,7 @@ def gatherTests(regexps=None, no_network=False): tests.addTest(unittest.makeSuite(misctestcase.CustomDateFormatsTest)) # Database tests.addTest(unittest.makeSuite(databasetestcase.DatabaseTest)) + tests.addTest(unittest.makeSuite(databasetestcase.BanTimeIncr)) # Filter tests.addTest(unittest.makeSuite(filtertestcase.IgnoreIP)) From 6c8327e39f66110d7ab237c57b9b416f6bcf0dca Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 6 May 2014 20:24:49 +0200 Subject: [PATCH 07/60] indentation level fix --- fail2ban/tests/databasetestcase.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index baea6eeb..99c050a3 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -323,8 +323,8 @@ class BanTimeIncr(unittest.TestCase): ticket = FailTicket(ip, stime, []) # test ticket not yet found self.assertEqual( - [a.incrBanTime(ticket) for i in xrange(3)], - [10, 10, 10] + [a.incrBanTime(ticket) for i in xrange(3)], + [10, 10, 10] ) # add a ticket banned self.db.addBan(jail, ticket) @@ -347,7 +347,7 @@ class BanTimeIncr(unittest.TestCase): [(banCount, timeOfBan, lastBanTime) for banCount, timeOfBan, lastBanTime in self.db.getBan(ip, '', None, True)], [(2, stime + 15, 20)] ) - # search currently banned and 1 day later (nothing should be found): + # search currently banned and 1 day later (nothing should be found): self.assertEqual( self.db.getCurrentBans(forbantime=-24*60*60, fromtime=stime), [] From 3a75c8a752df5b37c0fd7869e61c6617d052ee80 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 6 May 2014 20:14:23 +0200 Subject: [PATCH 08/60] ban time incr: 2st test case added (code optimized for test cases), to test both stand-alone: python ./bin/fail2ban-testcases -l debug 'BanTimeIncr' --- fail2ban/server/actions.py | 5 +- fail2ban/server/database.py | 16 ++-- fail2ban/tests/databasetestcase.py | 128 +++++++++++++++++++++++++++++ fail2ban/tests/utils.py | 1 + 4 files changed, 141 insertions(+), 9 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 01c2a761..962ecd2b 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -300,7 +300,7 @@ class Actions(JailThread, Mapping): def calcBanTime(self, banTime, banCount): return self._banExtra['evformula'](self.BanTimeIncr(banTime, banCount)) - def incrBanTime(self, bTicket, ip): + def incrBanTime(self, bTicket): """Check for IP address to increment ban time (if was already banned). Returns @@ -308,6 +308,7 @@ class Actions(JailThread, Mapping): float new ban time. """ + ip = bTicket.getIP() orgBanTime = self.__banManager.getBanTime() banTime = orgBanTime # check ip was already banned (increment time of ban): @@ -374,7 +375,7 @@ class Actions(JailThread, Mapping): try: # if ban time was not set: if not ticket.getRestored() and bTicket.getBanTime() is None: - btime = self.incrBanTime(bTicket, ip) + btime = self.incrBanTime(bTicket) bTicket.setBanTime(btime); except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index de575bcf..b75501cc 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -505,7 +505,9 @@ class Fail2BanDb(object): cur = self._db.cursor() return cur.execute(query, queryArgs) - def _getCurrentBans(self, jail = None, ip = None, forbantime=None): + def _getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): + if fromtime is None: + fromtime = MyTime.time() #query = "SELECT count(ip), max(timeofban) FROM bans WHERE ip = ?" query = "SELECT ip, max(timeofban), bantime, bancount, data FROM bans WHERE 1" queryArgs = [] @@ -516,17 +518,17 @@ class Fail2BanDb(object): query += " AND ip=?" queryArgs.append(ip) query += " AND timeofban + bantime > ?" - queryArgs.append(MyTime.time()) + queryArgs.append(fromtime) if forbantime is not None: query += " AND timeofban > ?" - queryArgs.append(MyTime.time() - forbantime) + queryArgs.append(fromtime - forbantime) query += " GROUP BY ip ORDER BY ip, timeofban DESC" cur = self._db.cursor() #logSys.debug((query, queryArgs)); return cur.execute(query, queryArgs) - def getCurrentBans(self, jail = None, ip = None, forbantime=None): - if forbantime is None: + def getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): + if forbantime is None and jail is not None: cacheKey = (ip, jail) if cacheKey in self._bansMergedCache: return self._bansMergedCache[cacheKey] @@ -534,7 +536,7 @@ class Fail2BanDb(object): tickets = [] ticket = None - results = list(self._getCurrentBans(jail=jail, ip=ip, forbantime=forbantime)) + results = list(self._getCurrentBans(jail=jail, ip=ip, forbantime=forbantime, fromtime=fromtime)) if results: matches = [] @@ -552,7 +554,7 @@ class Fail2BanDb(object): ticket.setAttempt(failures) tickets.append(ticket) - if forbantime is None: + if forbantime is None and jail is not None: self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 84101c50..99c050a3 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -278,3 +278,131 @@ class DatabaseTest(unittest.TestCase): self.db.purge() # Should leave jail as ban present self.assertEqual(len(self.db.getJailNames()), 1) self.assertEqual(len(self.db.getBans(jail=self.jail)), 1) + + +# Author: Serg G. Brester (sebres) +# + +__author__ = "Serg Brester" +__copyright__ = "Copyright (c) 2014 Serg G. Brester" + +class BanTimeIncr(unittest.TestCase): + + def setUp(self): + """Call before every test case.""" + if Fail2BanDb is None and sys.version_info >= (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 = Fail2BanDb(self.dbFilename) + + def tearDown(self): + """Call after every test case.""" + if Fail2BanDb is None: # pragma: no cover + return + # Cleanup + os.remove(self.dbFilename) + + def testBanTimeIncr(self): + if Fail2BanDb is None: # pragma: no cover + return + jail = DummyJail() + jail.database = self.db + self.db.addJail(jail) + a = jail.actions + # we tests with initial ban time = 10 seconds: + a.setBanTime(10) + a.setBanTimeExtra('enabled', 'true') + a.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( + [a.incrBanTime(ticket) for i in xrange(3)], + [10, 10, 10] + ) + # add a ticket banned + 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(a.incrBanTime(ticket), 20) + 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)] + ) + # 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 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: + for i in xrange(10): + ticket.setTime(stime + lastBanTime + 5) + banTime = a.incrBanTime(ticket) + self.assertEqual(banTime, lastBanTime * 2) + self.db.addBan(jail, ticket) + lastBanTime = banTime + # increase again, but the last multiplier reached (time not increased): + ticket.setTime(stime + lastBanTime + 5) + banTime = a.incrBanTime(ticket) + self.assertNotEqual(banTime, lastBanTime * 2) + self.assertEqual(banTime, lastBanTime) + 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) + 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) + 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) + ) diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index bfd11074..8c45a1b6 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -184,6 +184,7 @@ def gatherTests(regexps=None, no_network=False): tests.addTest(unittest.makeSuite(misctestcase.CustomDateFormatsTest)) # Database tests.addTest(unittest.makeSuite(databasetestcase.DatabaseTest)) + tests.addTest(unittest.makeSuite(databasetestcase.BanTimeIncr)) # Filter tests.addTest(unittest.makeSuite(filtertestcase.IgnoreIP)) From 7d17fb5c6c4734c0f9fa143042f4ebf6b15371c0 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 6 May 2014 20:55:41 +0200 Subject: [PATCH 09/60] python >= 3.x, local variable 'lastBanTime' reference bug fixed --- fail2ban/tests/databasetestcase.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 99c050a3..05bd8f9f 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -367,6 +367,7 @@ class BanTimeIncr(unittest.TestCase): 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 = a.incrBanTime(ticket) From 0121e09907c5eea262f96dfd5886515a3a18d29e Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 7 May 2014 13:28:04 +0200 Subject: [PATCH 10/60] default formula faster and more readable, comparable with "multipliers", like 2**N, default factor for both solutions is 1 now --- config/jail.conf | 16 +++++++------- fail2ban/server/actions.py | 5 ++--- fail2ban/tests/actionstestcase.py | 35 +++++++++++++++++++++++++------ 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/config/jail.conf b/config/jail.conf index 1ee0e08c..361ea758 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -60,15 +60,17 @@ before = paths-debian.conf # "bantimeextra.maxtime" is the max number of seconds using the ban time can reach (don't grows further) #bantimeextra.maxtime = 24*60*60 -# "bantimeextra.factor" is a coefficient to calculate exponent growing of the formula, -# for formula solution by default value of factor is "2.0 / 2.885385" and with default value of formula, the ban time +# "bantimeextra.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 ... -# for multipliers solution by default value of factor is "1" and the ban time grows by specified multipliers -# corresponding ban count only -#bantimeextra.factor = 2.0 / 2.885385 +#bantimeextra.factor = 1 -# "bantimeextra.formula" used to calculate next value of ban time; -#bantimeextra.formula = banTime * math.exp(float(banCount)*banFactor)/math.exp(1*banFactor) +# "bantimeextra.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... +#bantimeextra.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" : +#bantimeextra.formula = ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor) # "bantimeextra.multipliers" used to calculate next value of ban time instead of formula, coresponding # previously ban count and given "bantimeextra.factor" (for multipliers default is 1); diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 962ecd2b..9a2dfa13 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -272,14 +272,13 @@ class Actions(JailThread, Mapping): 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): - banFactor = eval(be.get('factor', "1")) evformula = lambda ban, banFactor=banFactor: ( ban.Time * banFactor * multipliers[ban.Count if ban.Count < len(multipliers) else -1] ) else: - banFactor = eval(be.get('factor', "2.0 / 2.885385")) - formula = be.get('formula', 'ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor)') + 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 : diff --git a/fail2ban/tests/actionstestcase.py b/fail2ban/tests/actionstestcase.py index f3a2ceae..d246c144 100644 --- a/fail2ban/tests/actionstestcase.py +++ b/fail2ban/tests/actionstestcase.py @@ -161,21 +161,29 @@ class BanTimeIncr(LogCaptureTestCase): super(BanTimeIncr, self).tearDown() os.remove(self.__tmpfilename) - def testMultipliers(self): + def testDefault(self, multipliers = None): a = self.__actions; a.setBanTimeExtra('maxtime', '24*60*60') a.setBanTimeExtra('rndtime', None) a.setBanTimeExtra('factor', None) - a.setBanTimeExtra('multipliers', '1 2 4 8 16 32 64 128 256') + # 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', '30*24*60*60') + # 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)], - [1200, 2400, 4800, 9600, 19200, 38400, 76800, 153600, 153600, 153600] + arr ) a.setBanTimeExtra('maxtime', '24*60*60') # change factor : @@ -184,6 +192,12 @@ class BanTimeIncr(LogCaptureTestCase): [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', '12*60*60') @@ -207,13 +221,21 @@ class BanTimeIncr(LogCaptureTestCase): a.setBanTimeExtra('maxtime', '24*60*60') 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< Date: Wed, 14 May 2014 11:21:31 +0200 Subject: [PATCH 11/60] "magic" formula for auto increasing of retry count for known (bad) ip, corresponding banCount of it (one try will count than 2, 3, 5, 9 ...) --- fail2ban/server/actions.py | 32 +++++++---- fail2ban/server/banmanager.py | 36 ++++++++---- fail2ban/server/database.py | 91 ++++++++++++++++++++++------- fail2ban/server/faildata.py | 4 +- fail2ban/server/failmanager.py | 6 +- fail2ban/server/filter.py | 23 +++++++- fail2ban/server/ticket.py | 8 +++ fail2ban/tests/databasetestcase.py | 92 ++++++++++++++++++++++++++++++ 8 files changed, 239 insertions(+), 53 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 9a2dfa13..0cc15bc6 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -324,9 +324,13 @@ class Actions(JailThread, Mapping): if banCount > 0: banTime = be['evformula'](self.BanTimeIncr(banTime, banCount)) bTicket.setBanTime(banTime); - logSys.info('[%s] %s was already banned: %s # at last %s - increase time %s to %s' % (self._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)))); + # check current ticket time to prevent increasing for twice read tickets (restored from log file besides database after restart) + if bTicket.getTime() > timeOfBan: + logSys.info('[%s] %s was already banned: %s # at last %s - increase time %s to %s' % (self._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: + bTicket.setRestored(True) break except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) @@ -372,23 +376,27 @@ class Actions(JailThread, Mapping): self._jail.database.getBansMerged( ip=ip, jail=self._jail).getAttempt()) try: - # if ban time was not set: - if not ticket.getRestored() and bTicket.getBanTime() is None: + # if not permanent, not restored and ban time was not set: + if btime != -1 and not ticket.getRestored() and bTicket.getBanTime() is None: btime = self.incrBanTime(bTicket) - bTicket.setBanTime(btime); + bTicket.setBanTime(btime); except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) #logSys.error('%s', e, exc_info=True) + if btime != -1: + logtime = (datetime.timedelta(seconds=int(btime)), + datetime.datetime.fromtimestamp(aInfo["time"] + btime).strftime("%Y-%m-%d %H:%M:%S")) + else: + logtime = ('permanent', 'infinite') if self.__banManager.addBanTicket(bTicket): if self._jail.database is not None: # add to database always only after ban time was calculated an not yet already banned: # if ticked was not restored from database - put it into database: - if not ticket.getRestored(): + if not ticket.getRestored() and not bTicket.getRestored(): self._jail.database.addBan(self._jail, bTicket) - logSys.notice("[%s] %sBan %s (%d # %s -> %s)" % (self._jail.name, ('Resore ' if ticket.getRestored() else ''), - aInfo["ip"], bTicket.getBanCount(), datetime.timedelta(seconds=int(btime)), - datetime.datetime.fromtimestamp(aInfo["time"] + btime).strftime("%Y-%m-%d %H:%M:%S"))) + logSys.notice("[%s] %sBan %s (%d # %s -> %s)" % ((self._jail.name, ('Resore ' if ticket.getRestored() else ''), + aInfo["ip"], bTicket.getBanCount()) + logtime)) for name, action in self._actions.iteritems(): try: action.ban(aInfo) @@ -399,8 +407,8 @@ 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 (%d # %s -> %s)" % ((self._jail.name, + aInfo["ip"], bTicket.getBanCount()) + logtime)) return False def __checkUnBan(self): diff --git a/fail2ban/server/banmanager.py b/fail2ban/server/banmanager.py index 5d614cf5..8d0ae191 100644 --- a/fail2ban/server/banmanager.py +++ b/fail2ban/server/banmanager.py @@ -130,10 +130,11 @@ class BanManager: def createBanTicket(ticket): ip = ticket.getIP() # if ticked was restored from database - set time of original restored ticket: - if ticket.getRestored(): - lastTime = ticket.getTime() - else: - lastTime = MyTime.time() + # we should always use correct time to calculate correct end time (ban time is variable now, + # + possible double banning by restore from database and from log file) + lastTime = ticket.getTime() + # if not ticket.getRestored(): + # lastTime = MyTime.time() banTicket = BanTicket(ip, lastTime, ticket.getMatches()) banTicket.setAttempt(ticket.getAttempt()) return banTicket @@ -149,11 +150,25 @@ class BanManager: def addBanTicket(self, ticket): try: self.__lock.acquire() - if not self._inBanList(ticket): - self.__banList.append(ticket) - self.__banTotal += 1 - return True - return False + # check already banned + for i in self.__banList: + if ticket.getIP() == i.getIP(): + # if already permanent + btorg, torg = i.getBanTime(self.__banTime), i.getTime() + if btorg == -1: + return False + # if given time is less than already banned time + btnew, tnew = ticket.getBanTime(self.__banTime), ticket.getTime() + if btnew != -1 and tnew + btnew <= torg + btorg: + return False + # we have longest ban - set new (increment) ban time + i.setTime(tnew) + i.setBanTime(btnew) + return False + # not yet banned - add new + self.__banList.append(ticket) + self.__banTotal += 1 + return True finally: self.__lock.release() @@ -199,8 +214,7 @@ class BanManager: return list() # Gets the list of ticket to remove. - unBanList = [ticket for ticket in self.__banList - if ticket.getTime() < time - ticket.getBanTime(self.__banTime)] + unBanList = [ticket for ticket in self.__banList if ticket.isTimedOut(time, self.__banTime)] # Removes tickets. self.__banList = [ticket for ticket in self.__banList diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index b75501cc..85b6160b 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -87,7 +87,7 @@ class Fail2BanDb(object): filename purgeage """ - __version__ = 3 + __version__ = 4 # Note all _TABLE_* strings must end in ';' for py26 compatibility _TABLE_fail2banDb = "CREATE TABLE fail2banDb(version INTEGER);" _TABLE_jails = "CREATE TABLE jails(" \ @@ -123,10 +123,20 @@ class Fail2BanDb(object): "CREATE INDEX bans_jail_ip ON bans(jail, ip);" \ "CREATE INDEX bans_ip ON bans(ip);" \ - # todo: for performance reasons create a table with currently banned unique jails-ips only (with last ban of time and bantime). - # check possible view performance instead of new table; + _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): try: self._lock = Lock() self._db = sqlite3.connect( @@ -134,6 +144,7 @@ class Fail2BanDb(object): detect_types=sqlite3.PARSE_DECLTYPES) self._dbFilename = filename self._purgeAge = purgeAge + self._outDatedFactor = outDatedFactor; self._bansMergedCache = {} @@ -198,6 +209,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] @@ -232,8 +245,12 @@ class Fail2BanDb(object): "%s;" "INSERT INTO bans SELECT * from bans_temp;" "DROP TABLE bans_temp;" - "UPDATE fail2banDb SET version = 3;" "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] @@ -386,6 +403,11 @@ class Fail2BanDb(object): (jail.name, ticket.getIP(), ticket.getTime(), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount() + 1, {"matches": ticket.getMatches(), "failures": ticket.getAttempt()})) + cur.execute( + "INSERT OR REPLACE INTO bips(ip, jail, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", + (ticket.getIP(), jail.name, ticket.getTime(), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount() + 1, + {"matches": ticket.getMatches(), + "failures": ticket.getAttempt()})) @commitandrollback def _getBans(self, cur, jail=None, bantime=None, ip=None): @@ -487,42 +509,47 @@ class Fail2BanDb(object): self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket - def getBan(self, ip, jail=None, forbantime=None, overalljails=None): - #query = "SELECT count(ip), max(timeofban) FROM bans WHERE ip = ?" - if overalljails is None or not overalljails: - query = "SELECT bancount, max(timeofban), bantime FROM bans" + def getBan(self, ip, jail=None, forbantime=None, overalljails=None, fromtime=None): + if not overalljails: + query = "SELECT bancount, timeofban, bantime FROM bips" else: - query = "SELECT max(bancount), max(timeofban), bantime FROM bans" + query = "SELECT max(bancount), max(timeofban), max(bantime) FROM bips" query += " WHERE ip = ?" queryArgs = [ip] - if (overalljails is None or not overalljails) and jail is not None: + 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) - query += " GROUP BY ip ORDER BY timeofban DESC LIMIT 1" + 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() + #logSys.debug((query, queryArgs)); return cur.execute(query, queryArgs) def _getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): if fromtime is None: fromtime = MyTime.time() - #query = "SELECT count(ip), max(timeofban) FROM bans WHERE ip = ?" - query = "SELECT ip, max(timeofban), bantime, bancount, data FROM bans WHERE 1" queryArgs = [] if jail is not None: - query += " AND jail=?" + 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 > ?" + query += " AND (timeofban + bantime > ? OR bantime = -1)" queryArgs.append(fromtime) if forbantime is not None: query += " AND timeofban > ?" queryArgs.append(fromtime - forbantime) - query += " GROUP BY ip ORDER BY ip, timeofban DESC" + if ip is None: + query += " GROUP BY ip ORDER BY ip, timeofban DESC" cur = self._db.cursor() #logSys.debug((query, queryArgs)); return cur.execute(query, queryArgs) @@ -558,15 +585,35 @@ class Fail2BanDb(object): self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket - @commitandrollback - def purge(self, cur): + 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): """Purge old bans, jails and log files from database. """ + cur = self._db.cursor() self._bansMergedCache = {} 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)") + affected = cur.rowcount + self._purge_bips(cur) + affected += cur.rowcount + if affected: + self._cleanjails(cur) diff --git a/fail2ban/server/faildata.py b/fail2ban/server/faildata.py index 232a492d..937c1cfa 100644 --- a/fail2ban/server/faildata.py +++ b/fail2ban/server/faildata.py @@ -52,8 +52,8 @@ class FailData: def getMatches(self): return self.__matches - def inc(self, matches=None): - self.__retry += 1 + def inc(self, matches=None, count=1): + self.__retry += count self.__matches += matches or [] def setLastTime(self, value): diff --git a/fail2ban/server/failmanager.py b/fail2ban/server/failmanager.py index 548e6adb..e4a18f8c 100644 --- a/fail2ban/server/failmanager.py +++ b/fail2ban/server/failmanager.py @@ -84,7 +84,7 @@ class FailManager: finally: self.__lock.release() - def addFailure(self, ticket): + def addFailure(self, ticket, count=1): try: self.__lock.acquire() ip = ticket.getIP() @@ -95,11 +95,11 @@ class FailManager: if fData.getLastReset() < unixTime - self.__maxTime: fData.setLastReset(unixTime) fData.setRetry(0) - fData.inc(matches) + fData.inc(matches, count) fData.setLastTime(unixTime) else: fData = FailData() - fData.inc(matches) + fData.inc(matches, count) fData.setLastReset(unixTime) fData.setLastTime(unixTime) self.__failList[ip] = fData diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index fb5aeb3d..521e4fbc 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -420,9 +420,26 @@ class Filter(JailThread): if self.inIgnoreIPList(ip): logSys.info("[%s] Ignore %s" % (self.jail.name, ip)) continue - logSys.info("[%s] Found %s" % (self.jail.name, ip)) - ## print "D: Adding a ticket for %s" % ((ip, unixTime, [line]),) - self.failManager.addFailure(FailTicket(ip, unixTime, lines)) + # increase retry count for known (bad) ip, corresponding banCount of it (one try will count than 2, 3, 5, 9 ...) : + banCount = 0 + retryCount = 1 + db = self.jail.database + if db is not None: + try: + for banCount, timeOfBan, lastBanTime in db.getBan(ip, self.jail): + retryCount = ((1 << (banCount if banCount < 20 else 20))/2 + 1) + # if lastBanTime == -1 or timeOfBan + lastBanTime * 2 > MyTime.time(): + # retryCount = self.failManager.getMaxRetry() + break + retryCount = min(retryCount, self.failManager.getMaxRetry()) + except Exception as e: + #logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + logSys.error('%s', e, exc_info=True) + if banCount == 1 and retryCount == 1: + logSys.info("[%s] Found %s" % (self.jail.name, ip)) + else: + logSys.info("[%s] Found %s, %s # -> %s" % (self.jail.name, ip, banCount, retryCount)) + self.failManager.addFailure(FailTicket(ip, unixTime, lines), retryCount) ## # Returns true if the line should be ignored. diff --git a/fail2ban/server/ticket.py b/fail2ban/server/ticket.py index 6c7c2f2c..d339dbb3 100644 --- a/fail2ban/server/ticket.py +++ b/fail2ban/server/ticket.py @@ -91,6 +91,14 @@ class Ticket: def getBanCount(self): return self.__banCount; + def isTimedOut(self, time, defaultBT = None): + bantime = (self.__banTime if not self.__banTime is None else defaultBT); + # permanent + if bantime == -1: + return False + # timed out + return (time > self.__time + bantime) + def setAttempt(self, value): self.__attempt = value diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 05bd8f9f..3e32683a 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -407,3 +407,95 @@ class BanTimeIncr(unittest.TestCase): 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) + 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) + 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() + jail1.database = self.db + self.db.addJail(jail1) + jail2 = DummyJail() + jail2.database = self.db + self.db.addJail(jail2) + ticket1 = FailTicket(ip, stime, []) + ticket1.setBanTime(6000) + self.db.addBan(jail1, ticket1) + ticket2 = FailTicket(ip, stime-6000, []) + ticket2.setBanTime(12000) + ticket2.setBanCount(1) + 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, (2, stime, 12000)) + break From 99c9cbf4702112d14d0693797170598edd96ced1 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 14 May 2014 12:17:28 +0200 Subject: [PATCH 12/60] code review, manually ban uses by addFailure the count "maxRetry" directly; log ticket time (found in line) --- fail2ban/server/filter.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 521e4fbc..d36474c7 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -21,7 +21,7 @@ __author__ = "Cyril Jaquier and Fail2Ban Contributors" __copyright__ = "Copyright (c) 2004 Cyril Jaquier, 2011-2013 Yaroslav Halchenko" __license__ = "GPL" -import logging, re, os, fcntl, sys, locale, codecs +import logging, re, os, fcntl, sys, locale, codecs, datetime from .failmanager import FailManagerEmpty, FailManager from .ticket import FailTicket @@ -309,8 +309,7 @@ class Filter(JailThread): logSys.warning('Requested to manually ban an ignored IP %s. User knows best. Proceeding to ban it.' % ip) unixTime = MyTime.time() - for i in xrange(self.failManager.getMaxRetry()): - self.failManager.addFailure(FailTicket(ip, unixTime)) + self.failManager.addFailure(FailTicket(ip, unixTime), self.failManager.getMaxRetry()) # Perform the banning of the IP now. try: # pragma: no branch - exception is the only way out @@ -433,12 +432,15 @@ class Filter(JailThread): break retryCount = min(retryCount, self.failManager.getMaxRetry()) except Exception as e: - #logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - logSys.error('%s', e, exc_info=True) - if banCount == 1 and retryCount == 1: - logSys.info("[%s] Found %s" % (self.jail.name, ip)) - else: - logSys.info("[%s] Found %s, %s # -> %s" % (self.jail.name, ip, banCount, retryCount)) + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + #logSys.error('%s', e, exc_info=True) + try: + logSys.info( + ("[%s] Found %s - %s" % (self.jail.name, ip, datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S"))) + + ((", %s # -> %s" % (banCount, retryCount)) if banCount != 1 or retryCount != 1 else '') + ) + except Exception as e: + logSys.error('%s', e, exc_info=True) self.failManager.addFailure(FailTicket(ip, unixTime, lines), retryCount) ## From f492aa7ac9d892fbb35a0fbf90c31f24d87a2447 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 14 May 2014 12:32:30 +0200 Subject: [PATCH 13/60] remove affected check, to delete jails always (pass testPurge) --- fail2ban/server/database.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index 85b6160b..28fd0532 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -602,18 +602,14 @@ class Fail2BanDb(object): "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): + @commitandrollback + def purge(self, cur): """Purge old bans, jails and log files from database. """ - cur = self._db.cursor() self._bansMergedCache = {} cur.execute( "DELETE FROM bans WHERE timeofban < ?", (MyTime.time() - self._purgeAge, )) - affected = cur.rowcount self._purge_bips(cur) - affected += cur.rowcount - if affected: - self._cleanjails(cur) + self._cleanjails(cur) From ec3ed0e4aed1bf9e45ba4c45e717f1814eaa7018 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 14 May 2014 16:01:35 +0200 Subject: [PATCH 14/60] introduced string to seconds (str2seconds) for configuration entries with time; todo: expands it for all time config entries; --- fail2ban/server/actions.py | 2 +- fail2ban/server/mytime.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 0cc15bc6..053ab60d 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -264,7 +264,7 @@ class Actions(JailThread, Mapping): logSys.warning("banTimeExtra is not available as long jail database is not set") if opt in ['findtime', 'maxtime', 'rndtime']: if not value is None: - be[opt] = eval(value) + 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): diff --git a/fail2ban/server/mytime.py b/fail2ban/server/mytime.py index 96c7f8ab..43362501 100644 --- a/fail2ban/server/mytime.py +++ b/fail2ban/server/mytime.py @@ -21,7 +21,7 @@ __author__ = "Cyril Jaquier" __copyright__ = "Copyright (c) 2004 Cyril Jaquier" __license__ = "GPL" -import time, datetime +import time, datetime, re ## # MyTime class. @@ -88,3 +88,31 @@ class MyTime: else: return time.localtime(MyTime.myTime) localtime = staticmethod(localtime) + + ## + # Wraps string expression like "1h 2m 3s" into number contains seconds (3723). + # The string expression will be evaluated as mathematical expression, spaces between each groups + # will be wrapped to "+" operand (only if any operand does not specified between). + # Because of case insensitivity and overwriting with minutes ("m" or "mm"), the short replacement for month + # are "mo" or "mon" (like %b by date formating). + # Ex: 1hour+30min = 5400 + # 0d 1h 30m = 5400 + # 1year-6mo = 15778800 + # 6 months = 15778800 + # warn: month is not 30 days, it is a year in seconds / 12, the leap years will be respected also: + # >>>> float(Test.str2seconds("1month")) / 60 / 60 / 24 + # 30.4375 + # >>>> float(Test.str2seconds("1year")) / 60 / 60 / 24 + # 365.25 + # + # @returns number (calculated seconds from expression "val") + + #@staticmethod + def str2seconds(val): + for rexp, rpl in ( + (r"days?|da|dd?", 24*60*60), (r"week?|wee?|ww?", 7*24*60*60), (r"months?|mon?", (365*3+366)*24*60*60/4/12), (r"years?|yea?|yy?", (365*3+366)*24*60*60/4), + (r"seconds?|sec?|ss?", 1), (r"minutes?|min?|mm?", 60), (r"hours?|ho|hh?", 60*60), + ): + val = re.sub(r"(?i)(?<=[\d\s])(%s)\b" % rexp, "*"+str(rpl), val) + val = re.sub(r"(\d)\s+(\d)", r"\1+\2", val); + return eval(val) From addfea6614ddffe8e1e3b64fd673ec93f90bd8a7 Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 15 May 2014 15:16:53 +0200 Subject: [PATCH 15/60] static method forgotten; --- fail2ban/server/mytime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/fail2ban/server/mytime.py b/fail2ban/server/mytime.py index 43362501..43f5dabd 100644 --- a/fail2ban/server/mytime.py +++ b/fail2ban/server/mytime.py @@ -116,3 +116,4 @@ class MyTime: val = re.sub(r"(?i)(?<=[\d\s])(%s)\b" % rexp, "*"+str(rpl), val) val = re.sub(r"(\d)\s+(\d)", r"\1+\2", val); return eval(val) + str2seconds = staticmethod(str2seconds) \ No newline at end of file From 02055ba4eb6c5c85c35f626637eeec4741d4d652 Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 5 Jun 2014 14:09:43 +0200 Subject: [PATCH 16/60] ignore already known tickets (from filter after restart); bug fixing and optimizing; --- fail2ban/server/actions.py | 32 +++++++++++++++--------------- fail2ban/server/database.py | 2 +- fail2ban/server/filter.py | 17 +++++++++------- fail2ban/tests/databasetestcase.py | 2 +- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 053ab60d..9fa2eb4b 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -361,25 +361,25 @@ class Actions(JailThread, Mapping): aInfo["failures"] = bTicket.getAttempt() aInfo["time"] = bTicket.getTime() aInfo["matches"] = "\n".join(bTicket.getMatches()) - btime = bTicket.getBanTime(self.__banManager.getBanTime()); + btime = bTicket.getBanTime(self.__banManager.getBanTime()) if self._jail.database is not None: - aInfo["ipmatches"] = lambda: "\n".join( - self._jail.database.getBansMerged( - ip=ip).getMatches()) - aInfo["ipjailmatches"] = lambda: "\n".join( - self._jail.database.getBansMerged( - ip=ip, jail=self._jail).getMatches()) - aInfo["ipfailures"] = lambda: "\n".join( - self._jail.database.getBansMerged( - ip=ip).getAttempt()) - aInfo["ipjailfailures"] = lambda: "\n".join( - self._jail.database.getBansMerged( - ip=ip, jail=self._jail).getAttempt()) + aInfo["ipmatches"] = lambda jail=self._jail: "\n".join( + jail.database.getBansMerged(ip=ip).getMatches() + ) + aInfo["ipjailmatches"] = lambda jail=self._jail: "\n".join( + jail.database.getBansMerged(ip=ip, jail=jail).getMatches() + ) + aInfo["ipfailures"] = lambda jail=self._jail: \ + jail.database.getBansMerged(ip=ip).getAttempt() + aInfo["ipjailfailures"] = lambda jail=self._jail: \ + jail.database.getBansMerged(ip=ip, jail=jail).getAttempt() try: # if not permanent, not restored and ban time was not set: if btime != -1 and not ticket.getRestored() and bTicket.getBanTime() is None: btime = self.incrBanTime(bTicket) - bTicket.setBanTime(btime); + bTicket.setBanTime(btime) + if bTicket.getRestored(): + ticket.setRestored(True) except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) #logSys.error('%s', e, exc_info=True) @@ -393,10 +393,10 @@ class Actions(JailThread, Mapping): if self._jail.database is not None: # add to database always only after ban time was calculated an not yet already banned: # if ticked was not restored from database - put it into database: - if not ticket.getRestored() and not bTicket.getRestored(): + if not ticket.getRestored(): self._jail.database.addBan(self._jail, bTicket) logSys.notice("[%s] %sBan %s (%d # %s -> %s)" % ((self._jail.name, ('Resore ' if ticket.getRestored() else ''), - aInfo["ip"], bTicket.getBanCount()) + logtime)) + aInfo["ip"], bTicket.getBanCount()+1) + logtime)) for name, action in self._actions.iteritems(): try: action.ban(aInfo) diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index f752561c..db48e748 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -516,7 +516,7 @@ class Fail2BanDb(object): if not overalljails: query = "SELECT bancount, timeofban, bantime FROM bips" else: - query = "SELECT max(bancount), max(timeofban), max(bantime) FROM bips" + query = "SELECT sum(bancount), max(timeofban), sum(bantime) FROM bips" query += " WHERE ip = ?" queryArgs = [ip] if not overalljails and jail is not None: diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index d36474c7..70136392 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -422,6 +422,7 @@ class Filter(JailThread): # 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 db = self.jail.database if db is not None: try: @@ -434,13 +435,15 @@ class Filter(JailThread): except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) #logSys.error('%s', e, exc_info=True) - try: - logSys.info( - ("[%s] Found %s - %s" % (self.jail.name, ip, datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S"))) - + ((", %s # -> %s" % (banCount, retryCount)) if banCount != 1 or retryCount != 1 else '') - ) - except Exception as e: - logSys.error('%s', e, exc_info=True) + # 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("Ignore line for %s before last ban %s < %s" + % (ip, unixTime, timeOfBan)) + continue + logSys.info( + ("[%s] Found %s - %s" % (self.jail.name, ip, datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S"))) + + ((", %s # -> %s" % (banCount, retryCount)) if banCount != 1 or retryCount != 1 else '') + ) self.failManager.addFailure(FailTicket(ip, unixTime, lines), retryCount) ## diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 07f80e45..dcf62b0b 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -506,5 +506,5 @@ class BanTimeIncr(unittest.TestCase): break # get max values for this ip (over all jails): for row in self.db.getBan(ip, overalljails=True): - self.assertEqual(row, (2, stime, 12000)) + self.assertEqual(row, (3, stime, 18000)) break From 681bc2ef07ebdf749ccef624d8d598de42b0c6b6 Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 6 Jun 2014 18:44:59 +0200 Subject: [PATCH 17/60] observer functionality introduced (asynchronous events in separate service thread); ban time increment feature nearly completely moved into observer; purge database will be called hourly in observer; bug fixing and code review; --- config/jail.conf | 39 ++- fail2ban/client/jailreader.py | 24 +- fail2ban/server/actions.py | 136 ++------- fail2ban/server/failmanager.py | 11 +- fail2ban/server/filter.py | 33 +- fail2ban/server/jail.py | 66 +++- fail2ban/server/mytime.py | 7 +- fail2ban/server/observer.py | 464 +++++++++++++++++++++++++++++ fail2ban/server/server.py | 17 +- fail2ban/server/ticket.py | 3 + fail2ban/server/transmitter.py | 12 +- fail2ban/tests/actionstestcase.py | 136 --------- fail2ban/tests/databasetestcase.py | 219 -------------- fail2ban/tests/dummyjail.py | 32 +- fail2ban/tests/observertestcase.py | 445 +++++++++++++++++++++++++++ fail2ban/tests/utils.py | 7 +- 16 files changed, 1093 insertions(+), 558 deletions(-) create mode 100644 fail2ban/server/observer.py create mode 100644 fail2ban/tests/observertestcase.py diff --git a/config/jail.conf b/config/jail.conf index 275d72c6..0862b800 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -44,46 +44,41 @@ before = paths-debian.conf # MISCELLANEOUS OPTIONS # -# "bantimeextra.enabled" allows to use database for searching of previously banned ip's to increase a +# "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... -#bantimeextra.enabled = true +#bantime.increment = true -# "bantimeextra.findtime" is the max number of seconds that we search in the database, -# if it is not specified - whole database will be used for ban searching -# (please observe current "dbpurgeage" value of fail2ban.conf). -#bantimeextra.findtime = 24*60*60 - -# "bantimeextra.rndtime" is the max number of seconds using for mixing with random time +# "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: -#bantimeextra.rndtime = 5*60 +#bantime.rndtime = 5*60 -# "bantimeextra.maxtime" is the max number of seconds using the ban time can reach (don't grows further) -#bantimeextra.maxtime = 24*60*60 +# "bantime.maxtime" is the max number of seconds using the ban time can reach (don't grows further) +#bantime.maxtime = -# "bantimeextra.factor" is a coefficient to calculate exponent growing of the formula or common multiplier, +# "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 ... -#bantimeextra.factor = 1 +#bantime.factor = 1 -# "bantimeextra.formula" used by default to calculate next value of ban time, default value bellow, +# "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... -#bantimeextra.formula = ban.Time * (1<<(ban.Count if ban.Count<20 else 20)) * banFactor +#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" : -#bantimeextra.formula = ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor) +#bantime.formula = ban.Time * math.exp(float(ban.Count+1)*banFactor)/math.exp(1*banFactor) -# "bantimeextra.multipliers" used to calculate next value of ban time instead of formula, coresponding -# previously ban count and given "bantimeextra.factor" (for multipliers default is 1); +# "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 -#bantimeextra.multipliers = 1 2 4 8 16 32 64 +#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 -#bantimeextra.multipliers = 1 5 30 60 300 720 1440 2880 +#bantime.multipliers = 1 5 30 60 300 720 1440 2880 -# "bantimeextra.overalljails" (if true) specifies the search of IP in the database will be executed +# "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 -#bantimeextra.overalljails = false +#bantime.overalljails = false # -------------------- diff --git a/fail2ban/client/jailreader.py b/fail2ban/client/jailreader.py index 06f70277..1d368c62 100644 --- a/fail2ban/client/jailreader.py +++ b/fail2ban/client/jailreader.py @@ -90,17 +90,17 @@ class JailReader(ConfigReader): ["string", "logpath", None], ["string", "logencoding", None], ["string", "backend", "auto"], - ["int", "maxretry", None], - ["int", "findtime", None], - ["int", "bantime", None], - ["bool", "bantimeextra.enabled", None], - ["string", "bantimeextra.findtime", None], - ["string", "bantimeextra.factor", None], - ["string", "bantimeextra.formula", None], - ["string", "bantimeextra.multipliers", None], - ["string", "bantimeextra.maxtime", None], - ["string", "bantimeextra.rndtime", None], - ["bool", "bantimeextra.overalljails", None], + ["int", "maxretry", None], + ["string", "findtime", None], + ["string", "bantime", None], + ["bool", "bantime.increment", None], + ["string", "bantime.findtime", 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], @@ -206,7 +206,7 @@ class JailReader(ConfigReader): stream.append(["set", self.__name, "findtime", self.__opts[opt]]) elif opt == "bantime": stream.append(["set", self.__name, "bantime", self.__opts[opt]]) - elif opt.startswith("bantimeextra."): + elif opt.startswith("bantime."): stream.append(["set", self.__name, opt, self.__opts[opt]]) elif opt == "usedns": stream.append(["set", self.__name, "usedns", self.__opts[opt]]) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 9fa2eb4b..47482440 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -25,7 +25,7 @@ __copyright__ = "Copyright (c) 2004 Cyril Jaquier" __license__ = "GPL" import time, logging -import os, datetime, math, json, random +import os, datetime import sys if sys.version_info >= (3, 3): import importlib.machinery @@ -38,6 +38,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 @@ -83,8 +84,6 @@ class Actions(JailThread, Mapping): self._actions = dict() ## The ban manager. self.__banManager = BanManager() - ## Extra parameters for increase ban time - self._banExtra = {'maxtime': 24*60*60}; def add(self, name, pythonModule=None, initOpts=None): """Adds a new action. @@ -166,6 +165,7 @@ class Actions(JailThread, Mapping): # @param value the time def setBanTime(self, value): + value = MyTime.str2seconds(value) self.__banManager.setBanTime(value) logSys.info("Set banTime = %s" % value) @@ -242,102 +242,6 @@ class Actions(JailThread, Mapping): logSys.debug(self._jail.name + ": action terminated") return True - class BanTimeIncr: - def __init__(self, banTime, banCount): - self.Time = banTime - self.Count = banCount - - 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 banTimeExtra.%s = %s', opt, value) - if opt == 'enabled': - if isinstance(value, str): - be[opt] = value.lower() in ("yes", "true", "ok", "1") - if be[opt] and self._jail.database is None: - logSys.warning("banTimeExtra is not available as long jail database is not set") - if opt in ['findtime', '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): - return self._banExtra.get(opt, None) - - def calcBanTime(self, banTime, banCount): - return self._banExtra['evformula'](self.BanTimeIncr(banTime, banCount)) - - def incrBanTime(self, bTicket): - """Check for IP address to increment ban time (if was already banned). - - Returns - ------- - float - new ban time. - """ - ip = bTicket.getIP() - orgBanTime = self.__banManager.getBanTime() - banTime = orgBanTime - # check ip was already banned (increment time of ban): - try: - be = self._banExtra; - if banTime > 0 and be.get('enabled', False): - # search IP in database and increase time if found: - for banCount, timeOfBan, lastBanTime in \ - self._jail.database.getBan(ip, self._jail, be.get('findtime', None), be.get('overalljails', False) \ - ): - #logSys.debug('IP %s was already banned: %s #, %s' % (ip, banCount, timeOfBan)); - bTicket.setBanCount(banCount); - # calculate new ban time - if banCount > 0: - banTime = be['evformula'](self.BanTimeIncr(banTime, banCount)) - bTicket.setBanTime(banTime); - # check current ticket time to prevent increasing for twice read tickets (restored from log file besides database after restart) - if bTicket.getTime() > timeOfBan: - logSys.info('[%s] %s was already banned: %s # at last %s - increase time %s to %s' % (self._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: - bTicket.setRestored(True) - break - except Exception as e: - logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - #logSys.error('%s', e, exc_info=True) - - return banTime - def __checkBan(self): """Check for IP address to ban. @@ -356,12 +260,15 @@ class Actions(JailThread, Mapping): 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()) + # [todo] move merging to observer - here we could read already merged info from database (faster); if self._jail.database is not None: aInfo["ipmatches"] = lambda jail=self._jail: "\n".join( jail.database.getBansMerged(ip=ip).getMatches() @@ -373,30 +280,25 @@ class Actions(JailThread, Mapping): jail.database.getBansMerged(ip=ip).getAttempt() aInfo["ipjailfailures"] = lambda jail=self._jail: \ jail.database.getBansMerged(ip=ip, jail=jail).getAttempt() - try: - # if not permanent, not restored and ban time was not set: - if btime != -1 and not ticket.getRestored() and bTicket.getBanTime() is None: - btime = self.incrBanTime(bTicket) - bTicket.setBanTime(btime) - if bTicket.getRestored(): - ticket.setRestored(True) - except Exception as e: - logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - #logSys.error('%s', e, exc_info=True) if btime != -1: + bendtime = aInfo["time"] + btime logtime = (datetime.timedelta(seconds=int(btime)), - datetime.datetime.fromtimestamp(aInfo["time"] + btime).strftime("%Y-%m-%d %H:%M:%S")) + datetime.datetime.fromtimestamp(bendtime).strftime("%Y-%m-%d %H:%M:%S")) + # check ban is not too old : + if bendtime < MyTime.time(): + logSys.info('[%s] Ignore %s, expiered bantime - %s', self._jail.name, ip, logtime[1]) + return False else: logtime = ('permanent', 'infinite') + if self.__banManager.addBanTicket(bTicket): - if self._jail.database is not None: - # add to database always only after ban time was calculated an not yet already banned: - # if ticked was not restored from database - put it into database: - if not ticket.getRestored(): - self._jail.database.addBan(self._jail, bTicket) - logSys.notice("[%s] %sBan %s (%d # %s -> %s)" % ((self._jail.name, ('Resore ' if ticket.getRestored() else ''), - aInfo["ip"], bTicket.getBanCount()+1) + logtime)) + # report ticket to observer, to check time should be increased and hereafter observer writes ban to database (asynchronous) + if not bTicket.getRestored(): + Observers.Main.add('banFound', bTicket, self._jail, btime) + logSys.notice("[%s] %sBan %s (%d # %s -> %s)", self._jail.name, ('' if not bTicket.getRestored() else 'Restore '), + aInfo["ip"], bTicket.getBanCount()+(1 if not bTicket.getRestored() else 0), *logtime) + # do actions : for name, action in self._actions.iteritems(): try: action.ban(aInfo) diff --git a/fail2ban/server/failmanager.py b/fail2ban/server/failmanager.py index e4a18f8c..fc7a9f25 100644 --- a/fail2ban/server/failmanager.py +++ b/fail2ban/server/failmanager.py @@ -84,7 +84,7 @@ class FailManager: finally: self.__lock.release() - def addFailure(self, ticket, count=1): + def addFailure(self, ticket, count=1, observed = False): try: self.__lock.acquire() ip = ticket.getIP() @@ -98,6 +98,9 @@ class FailManager: fData.inc(matches, count) fData.setLastTime(unixTime) else: + ## not found - already banned - prevent to add failure if comes from observer: + if observed: + return fData = FailData() fData.inc(matches, count) fData.setLastReset(unixTime) @@ -138,13 +141,13 @@ class FailManager: if self.__failList.has_key(ip): del self.__failList[ip] - def toBan(self): + def toBan(self, ip = None): try: self.__lock.acquire() - for ip in self.__failList: + for ip in ([ip] if ip != None and ip in self.__failList else self.__failList): data = self.__failList[ip] if data.getRetry() >= self.__maxRetry: - self.__delFailure(ip) + del self.__failList[ip] # Create a FailTicket from BanData failTicket = FailTicket(ip, data.getLastTime(), data.getMatches()) failTicket.setAttempt(data.getRetry()) diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 70136392..0e13bba4 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -24,6 +24,7 @@ __license__ = "GPL" import logging, re, os, fcntl, sys, locale, codecs, datetime from .failmanager import FailManagerEmpty, FailManager +from .observer import Observers from .ticket import FailTicket from .jailthread import JailThread from .datedetector import DateDetector @@ -185,6 +186,7 @@ class Filter(JailThread): # @param value the time def setFindTime(self, value): + value = MyTime.str2seconds(value) self.__findTime = value self.failManager.setMaxTime(value) logSys.info("Set findtime = %s" % value) @@ -314,7 +316,7 @@ class Filter(JailThread): # Perform the banning of the IP now. try: # pragma: no branch - exception is the only way out while True: - ticket = self.failManager.toBan() + ticket = self.failManager.toBan(ip) self.jail.putFailTicket(ticket) except FailManagerEmpty: self.failManager.cleanup(MyTime.time()) @@ -419,32 +421,13 @@ class Filter(JailThread): if self.inIgnoreIPList(ip): logSys.info("[%s] Ignore %s" % (self.jail.name, ip)) continue - # 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 - db = self.jail.database - if db is not None: - try: - for banCount, timeOfBan, lastBanTime in db.getBan(ip, self.jail): - retryCount = ((1 << (banCount if banCount < 20 else 20))/2 + 1) - # if lastBanTime == -1 or timeOfBan + lastBanTime * 2 > MyTime.time(): - # retryCount = self.failManager.getMaxRetry() - break - retryCount = min(retryCount, self.failManager.getMaxRetry()) - except Exception as e: - logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - #logSys.error('%s', e, exc_info=True) - # 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("Ignore line for %s before last ban %s < %s" - % (ip, unixTime, timeOfBan)) - continue logSys.info( - ("[%s] Found %s - %s" % (self.jail.name, ip, datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S"))) - + ((", %s # -> %s" % (banCount, retryCount)) if banCount != 1 or retryCount != 1 else '') + "[%s] Found %s - %s", self.jail.name, ip, datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S") ) - self.failManager.addFailure(FailTicket(ip, unixTime, lines), retryCount) + tick = FailTicket(ip, unixTime, lines) + self.failManager.addFailure(tick) + # report to observer - failure was found, for possibly increasing of it retry counter (asynchronous) + 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 8e99e780..9d4eec29 100644 --- a/fail2ban/server/jail.py +++ b/fail2ban/server/jail.py @@ -23,9 +23,10 @@ __author__ = "Cyril Jaquier, Lee Clemens, Yaroslav Halchenko" __copyright__ = "Copyright (c) 2004 Cyril Jaquier, 2011-2012 Lee Clemens, 2012 Yaroslav Halchenko" __license__ = "GPL" -import Queue, logging +import Queue, logging, math, random from .actions import Actions +from .mytime import MyTime # Gets the instance of the logger. logSys = logging.getLogger(__name__) @@ -72,8 +73,11 @@ class Jail: 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) - self._setBackend(backend) + if backend is not None: + self._setBackend(backend) def __repr__(self): return "%s(%r)" % (self.__class__.__name__, self.name) @@ -189,7 +193,7 @@ class Jail: """ self.__queue.put(ticket) # add ban to database moved to actions (should previously check not already banned - # and increase ticket time if "bantimeextra.enabled" set) + # and increase ticket time if "bantime.increment" set) def getFailTicket(self): """Get a fail ticket from the jail. @@ -201,6 +205,57 @@ class Jail: 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 start(self): """Start the jail, by starting filter and actions threads. @@ -213,9 +268,8 @@ class Jail: try: if self.database is not None: forbantime = None; - if self.actions.getBanTimeExtra('enabled'): - forbantime = self.actions.getBanTimeExtra('findtime') - if forbantime is 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) diff --git a/fail2ban/server/mytime.py b/fail2ban/server/mytime.py index 43f5dabd..684a7a0c 100644 --- a/fail2ban/server/mytime.py +++ b/fail2ban/server/mytime.py @@ -94,7 +94,7 @@ class MyTime: # The string expression will be evaluated as mathematical expression, spaces between each groups # will be wrapped to "+" operand (only if any operand does not specified between). # Because of case insensitivity and overwriting with minutes ("m" or "mm"), the short replacement for month - # are "mo" or "mon" (like %b by date formating). + # are "mo" or "mon". # Ex: 1hour+30min = 5400 # 0d 1h 30m = 5400 # 1year-6mo = 15778800 @@ -109,8 +109,11 @@ class MyTime: #@staticmethod def str2seconds(val): + if isinstance(val, (int, long, float, complex)): + return val for rexp, rpl in ( - (r"days?|da|dd?", 24*60*60), (r"week?|wee?|ww?", 7*24*60*60), (r"months?|mon?", (365*3+366)*24*60*60/4/12), (r"years?|yea?|yy?", (365*3+366)*24*60*60/4), + (r"days?|da|dd?", 24*60*60), (r"week?|wee?|ww?", 7*24*60*60), (r"months?|mon?", (365*3+366)*24*60*60/4/12), + (r"years?|yea?|yy?", (365*3+366)*24*60*60/4), (r"seconds?|sec?|ss?", 1), (r"minutes?|min?|mm?", 60), (r"hours?|ho|hh?", 60*60), ): val = re.sub(r"(?i)(?<=[\d\s])(%s)\b" % rexp, "*"+str(rpl), val) diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py new file mode 100644 index 00000000..84981cdc --- /dev/null +++ b/fail2ban/server/observer.py @@ -0,0 +1,464 @@ +# 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 time, logging +import threading +import os, datetime, math, json, random +import sys +if sys.version_info >= (3, 3): + import importlib.machinery +else: + import imp +from .jailthread import JailThread +from .mytime import MyTime + +# Gets the instance of the logger. +logSys = logging.getLogger(__name__) + +class ObserverThread(threading.Thread): + """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. + """ + + def __init__(self): + self.active = False + self.idle = False + ## 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._started = False + self._timers = {} + self._paused = False + self.__db = None + self.__db_purge_interval = 60*60 + # start thread + super(ObserverThread, self).__init__(name='Observer') + # 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 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.is_alive, + 'is_active': self.is_active, + 'start': self.start, + 'stop': self.stop, + '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: + ## going sleep, wait for events (in queue) + self.idle = True + self._notify.wait(self.sleeptime) + # does not clear notify event here - we use pulse (and clear it inside) ... + # ## wake up - reset signal now (we don't need it so long as we reed from queue) + # if self._notify: + # self._notify.clear() + if self._paused: + continue + 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) + ## end of main loop - exit + except Exception as e: + logSys.error('Observer stopped after error: %s', e, exc_info=True) + #print("Observer stopped with error: %s" % str(e)) + self.idle = True + return True + logSys.info("Observer stopped, %s events remaining.", len(self._queue)) + #print("Observer stopped, %s events remaining." % len(self._queue)) + self.idle = True + return True + + def is_alive(self): + #logSys.debug("Observer alive...") + return True + + def is_active(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: + self.active = True + super(ObserverThread, self).start() + + def stop(self): + logSys.info("Observer stop ...") + #print("Observer stop ....") + self.active = False + if self._notify: + # just add shutdown job to make possible wait later until full (events remaining) + self.add('shutdown') + self.pulse_notify() + self._notify = None + # wait max 5 seconds until full (events remaining) + self.wait_empty(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) + """ + if not self.is_full: + return True + if sleeptime is not None: + e = MyTime.time() + sleeptime + while self.is_full: + if sleeptime is not None and MyTime.time() > e: + break + time.sleep(0.1) + return not self.is_full + + + def wait_idle(self, sleeptime=None): + """Wait observer is running and returns if observer idle (observer sleeps) + """ + time.sleep(0.001) + 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(0.1) + 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.is_alive(): + return + ip = ticket.getIP() + unixTime = ticket.getTime() + logSys.info("[%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: + 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 = failManager.getMaxRetry() + break + retryCount = min(retryCount, failManager.getMaxRetry()) + # 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.info("[%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, ban", jail.name, ip, + datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S"), banCount, retryCount) + # remove matches from this ticket, because a ticket was already added by filter self + ticket.setMatches(None) + # retryCount-1, because a ticket was already once incremented by filter self + failManager.addFailure(ticket, retryCount - 1, True) + + # after observe we have increased count >= maxretry ... + if retryCount >= failManager.getMaxRetry(): + # 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 Exception: + failManager.cleanup(MyTime.time()) + + except Exception as e: + logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + #logSys.error('%s', e, exc_info=True) + + + 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.is_alive(): + 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) + #logSys.error('%s', e, exc_info=True) + 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.info("[%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.info('Ignore old bantime %s', logtime[1]) + return False + else: + logtime = ('permanent', 'infinite') + # 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()+1, *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) + #logSys.error('%s', e, exc_info=True) + +# 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 b9efdfe8..34c984d9 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -27,6 +27,7 @@ __license__ = "GPL" from threading import Lock, RLock import logging, logging.handlers, sys, os, signal +from .observer import Observers, ObserverThread from .jails import Jails from .filter import FileFilter, JournalFilter from .transmitter import Transmitter @@ -101,6 +102,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): @@ -124,10 +129,16 @@ class Server: def addJail(self, name, backend): + # Create an observer if not yet created and start it: + if Observers.Main is None: + Observers.Main = ObserverThread() + Observers.Main.start() + # Add jail hereafter: self.__jails.add(name, backend, self.__db) if self.__db is not None: self.__db.addJail(self.__jails[name]) - + Observers.Main.db_set(self.__db) + def delJail(self, name): if self.__db is not None: self.__db.delJail(self.__jails[name]) @@ -304,10 +315,10 @@ class Server: return self.__jails[name].actions.getBanTime() def setBanTimeExtra(self, name, opt, value): - self.__jails[name].actions.setBanTimeExtra(opt, value) + self.__jails[name].setBanTimeExtra(opt, value) def getBanTimeExtra(self, name, opt): - return self.__jails[name].actions.getBanTimeExtra(opt) + return self.__jails[name].getBanTimeExtra(opt) # Status def status(self): diff --git a/fail2ban/server/ticket.py b/fail2ban/server/ticket.py index d339dbb3..7d731637 100644 --- a/fail2ban/server/ticket.py +++ b/fail2ban/server/ticket.py @@ -105,6 +105,9 @@ class Ticket: def getAttempt(self): return self.__attempt + def setMatches(self, matches): + self.__matches = matches + def getMatches(self): return self.__matches diff --git a/fail2ban/server/transmitter.py b/fail2ban/server/transmitter.py index 471cbea4..01c2bcd7 100644 --- a/fail2ban/server/transmitter.py +++ b/fail2ban/server/transmitter.py @@ -203,7 +203,7 @@ class Transmitter: return self.__server.getUseDns(name) elif command[1] == "findtime": value = command[2] - self.__server.setFindTime(name, int(value)) + self.__server.setFindTime(name, value) return self.__server.getFindTime(name) elif command[1] == "datepattern": value = command[2] @@ -220,11 +220,11 @@ class Transmitter: # command elif command[1] == "bantime": value = command[2] - self.__server.setBanTime(name, int(value)) + self.__server.setBanTime(name, value) return self.__server.getBanTime(name) - elif command[1].startswith("bantimeextra."): + elif command[1].startswith("bantime."): value = command[2] - opt = command[1][len("bantimeextra."):] + opt = command[1][len("bantime."):] self.__server.setBanTimeExtra(name, opt, value) return self.__server.getBanTimeExtra(name, opt) elif command[1] == "banip": @@ -305,8 +305,8 @@ class Transmitter: # Action elif command[1] == "bantime": return self.__server.getBanTime(name) - elif command[1].startswith("bantimeextra."): - opt = command[1][len("bantimeextra."):] + 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() diff --git a/fail2ban/tests/actionstestcase.py b/fail2ban/tests/actionstestcase.py index d246c144..d4a54cdb 100644 --- a/fail2ban/tests/actionstestcase.py +++ b/fail2ban/tests/actionstestcase.py @@ -141,139 +141,3 @@ class ExecuteActions(LogCaptureTestCase): self.__actions.join() self.assertTrue(self._is_logged("Failed to stop")) - -# Author: Serg G. Brester (sebres) -# - -__author__ = "Serg Brester" -__copyright__ = "Copyright (c) 2014 Serg G. Brester" - -class BanTimeIncr(LogCaptureTestCase): - - def setUp(self): - """Call before every test case.""" - super(BanTimeIncr, self).setUp() - self.__jail = DummyJail() - self.__actions = Actions(self.__jail) - self.__tmpfile, self.__tmpfilename = tempfile.mkstemp() - - def tearDown(self): - super(BanTimeIncr, self).tearDown() - os.remove(self.__tmpfilename) - - def testDefault(self, multipliers = None): - a = self.__actions; - a.setBanTimeExtra('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', '30*24*60*60') - # 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', '24*60*60') - # 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', '12*60*60') - 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', '24*60*60') - ## test randomization - not possibe all 10 times we have random = 0: - a.setBanTimeExtra('rndtime', '5*60') - 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', '24*60*60') - 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 = Fail2BanDb(self.dbFilename) - - def tearDown(self): - """Call after every test case.""" - if Fail2BanDb is None: # pragma: no cover - return - # Cleanup - os.remove(self.dbFilename) - - def testBanTimeIncr(self): - if Fail2BanDb is None: # pragma: no cover - return - jail = DummyJail() - jail.database = self.db - self.db.addJail(jail) - a = jail.actions - # we tests with initial ban time = 10 seconds: - a.setBanTime(10) - a.setBanTimeExtra('enabled', 'true') - a.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( - [a.incrBanTime(ticket) for i in xrange(3)], - [10, 10, 10] - ) - # add a ticket banned - 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(a.incrBanTime(ticket), 20) - 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)] - ) - # 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 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 = a.incrBanTime(ticket) - self.assertEqual(banTime, lastBanTime * 2) - self.db.addBan(jail, ticket) - lastBanTime = banTime - # increase again, but the last multiplier reached (time not increased): - ticket.setTime(stime + lastBanTime + 5) - banTime = a.incrBanTime(ticket) - self.assertNotEqual(banTime, lastBanTime * 2) - self.assertEqual(banTime, lastBanTime) - 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) - 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) - 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) - 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) - 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() - jail1.database = self.db - self.db.addJail(jail1) - jail2 = DummyJail() - jail2.database = self.db - self.db.addJail(jail2) - ticket1 = FailTicket(ip, stime, []) - ticket1.setBanTime(6000) - self.db.addBan(jail1, ticket1) - ticket2 = FailTicket(ip, stime-6000, []) - ticket2.setBanTime(12000) - ticket2.setBanCount(1) - 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 diff --git a/fail2ban/tests/dummyjail.py b/fail2ban/tests/dummyjail.py index 77ab785e..4ca7e108 100644 --- a/fail2ban/tests/dummyjail.py +++ b/fail2ban/tests/dummyjail.py @@ -24,17 +24,18 @@ __license__ = "GPL" from threading import Lock +from ..server.jail import Jail from ..server.actions import Actions -class DummyJail(object): +class DummyJail(Jail, object): """A simple 'jail' to suck in all the tickets generated by Filter's """ def __init__(self): self.lock = Lock() self.queue = [] - self.idle = False - self.database = None - self.actions = Actions(self) + super(DummyJail, self).__init__(name='DummyJail', backend=None) + self.__db = None + self.__actions = Actions(self) def __len__(self): try: @@ -63,3 +64,26 @@ class DummyJail(object): @property def name(self): return "DummyJail #%s with %d tickets" % (id(self), len(self)) + + @property + def idle(self): + return False; + + @idle.setter + def idle(self, value): + pass + + @property + def database(self): + return self.__db; + + @database.setter + def database(self, value): + self.__db = value; + + @property + def actions(self): + return self.__actions; + + def is_alive(self): + return True; diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py new file mode 100644 index 00000000..3e7c5fda --- /dev/null +++ b/fail2ban/tests/observertestcase.py @@ -0,0 +1,445 @@ +# 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.observer import Observers, ObserverThread +from .utils import LogCaptureTestCase +from .dummyjail import DummyJail +try: + from ..server.database import Fail2BanDb +except ImportError: + Fail2BanDb = None + + +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') + a.setBanTimeExtra('maxtime', '1d') + 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 = Fail2BanDb(self.dbFilename) + self.jail = None + self.Observer = ObserverThread() + + def tearDown(self): + """Call after every test case.""" + super(BanTimeIncrDB, self).tearDown() + if Fail2BanDb is None: # pragma: no cover + return + # Cleanup + os.remove(self.dbFilename) + + 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 = DummyJail() + self.jail = jail + jail.database = self.db + 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 + 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) + 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)] + ) + # 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 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) + 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) + 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) + 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) + 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) + 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) + 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() + jail1.database = self.db + self.db.addJail(jail1) + jail2 = DummyJail() + jail2.database = self.db + self.db.addJail(jail2) + ticket1 = FailTicket(ip, stime, []) + ticket1.setBanTime(6000) + self.db.addBan(jail1, ticket1) + ticket2 = FailTicket(ip, stime-6000, []) + ticket2.setBanTime(12000) + ticket2.setBanCount(1) + 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 + + +class ObserverTest(unittest.TestCase): + + def setUp(self): + """Call before every test case.""" + #super(ObserverTest, self).setUp() + pass + + def tearDown(self): + #super(ObserverTest, self).tearDown() + pass + + def testObserverBanTimeIncr(self): + obs = ObserverThread() + obs.start() + # wait for idle + obs.wait_idle(0.1) + # observer will sleep 0.5 second (in busy state): + o = set(['test']) + obs.add('call', o.clear) + obs.add('call', o.add, 'test2') + obs.wait_empty(1) + self.assertFalse(obs.is_full) + self.assertEqual(o, set(['test2'])) + # observer makes pause + obs.paused = True + # observer will sleep 0.5 second after pause ends: + obs.add('call', o.clear) + obs.add('call', o.add, 'test3') + obs.wait_empty(0.25) + 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.is_active()) + self.assertTrue(obs.is_alive()) + obs.stop() + obs = None diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 8e3252e2..75186ee1 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -68,6 +68,7 @@ def gatherTests(regexps=None, no_network=False): from . import sockettestcase from . import misctestcase from . import databasetestcase + from . import observertestcase from . import samplestestcase if not regexps: # pragma: no cover @@ -91,7 +92,6 @@ def gatherTests(regexps=None, no_network=False): tests.addTest(unittest.makeSuite(servertestcase.RegexTests)) tests.addTest(unittest.makeSuite(actiontestcase.CommandActionTest)) tests.addTest(unittest.makeSuite(actionstestcase.ExecuteActions)) - tests.addTest(unittest.makeSuite(actionstestcase.BanTimeIncr)) # FailManager tests.addTest(unittest.makeSuite(failmanagertestcase.AddFailure)) # BanManager @@ -110,7 +110,10 @@ def gatherTests(regexps=None, no_network=False): tests.addTest(unittest.makeSuite(misctestcase.CustomDateFormatsTest)) # Database tests.addTest(unittest.makeSuite(databasetestcase.DatabaseTest)) - tests.addTest(unittest.makeSuite(databasetestcase.BanTimeIncr)) + # 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)) From e7bd8ed619fc5b5e4622d32352ef7178d7c042cf Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 6 Jun 2014 19:52:42 +0200 Subject: [PATCH 18/60] not used import removed --- fail2ban/server/observer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 84981cdc..e4fb7426 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -33,7 +33,6 @@ if sys.version_info >= (3, 3): import importlib.machinery else: import imp -from .jailthread import JailThread from .mytime import MyTime # Gets the instance of the logger. From bb0a181056883c0087eac6c0addf017bc883b8de Mon Sep 17 00:00:00 2001 From: sebres Date: Sat, 7 Jun 2014 04:37:06 +0200 Subject: [PATCH 19/60] testcases extended and observer optimized to run test cases faster; code review --- fail2ban/server/actions.py | 2 +- fail2ban/server/database.py | 11 +-- fail2ban/server/filter.py | 3 +- fail2ban/server/observer.py | 70 +++++++++++------- fail2ban/server/ticket.py | 3 + fail2ban/tests/observertestcase.py | 109 +++++++++++++++++++++++++++-- 6 files changed, 154 insertions(+), 44 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 98b8aaa9..9148931f 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -292,7 +292,7 @@ class Actions(JailThread, Mapping): if self.__banManager.addBanTicket(bTicket): # report ticket to observer, to check time should be increased and hereafter observer writes ban to database (asynchronous) - if not bTicket.getRestored(): + if Observers.Main and not bTicket.getRestored(): Observers.Main.add('banFound', bTicket, self._jail, btime) logSys.notice("[%s] %sBan %s (%d # %s -> %s)", self._jail.name, ('' if not bTicket.getRestored() else 'Restore '), aInfo["ip"], bTicket.getBanCount()+(1 if not bTicket.getRestored() else 0), *logtime) diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index db48e748..2699034a 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -400,12 +400,12 @@ class Fail2BanDb(object): #TODO: Implement data parts once arbitrary match keys completed cur.execute( "INSERT INTO bans(jail, ip, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", - (jail.name, ticket.getIP(), ticket.getTime(), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount() + 1, + (jail.name, ticket.getIP(), ticket.getTime(), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(), {"matches": ticket.getMatches(), "failures": ticket.getAttempt()})) cur.execute( "INSERT OR REPLACE INTO bips(ip, jail, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", - (ticket.getIP(), jail.name, ticket.getTime(), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount() + 1, + (ticket.getIP(), jail.name, ticket.getTime(), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(), {"matches": ticket.getMatches(), "failures": ticket.getAttempt()})) @@ -558,11 +558,6 @@ class Fail2BanDb(object): return cur.execute(query, queryArgs) def getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): - if forbantime is None and jail is not None: - cacheKey = (ip, jail) - if cacheKey in self._bansMergedCache: - return self._bansMergedCache[cacheKey] - tickets = [] ticket = None @@ -584,8 +579,6 @@ class Fail2BanDb(object): ticket.setAttempt(failures) tickets.append(ticket) - if forbantime is None and jail is not None: - self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket def _cleanjails(self, cur): diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 5fd6be01..7e8755a3 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -427,7 +427,8 @@ class Filter(JailThread): tick = FailTicket(ip, unixTime, lines) self.failManager.addFailure(tick) # report to observer - failure was found, for possibly increasing of it retry counter (asynchronous) - Observers.Main.add('failureFound', self.failManager, self.jail, tick) + if Observers.Main: + Observers.Main.add('failureFound', self.failManager, self.jail, tick) ## # Returns true if the line should be ignored. diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index e4fb7426..63e38bfa 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -70,7 +70,6 @@ class ObserverThread(threading.Thread): ## but so we can later do some service "events" occurred infrequently directly in main loop of observer (not using queue) self.sleeptime = 60 # - self._started = False self._timers = {} self._paused = False self.__db = None @@ -124,11 +123,11 @@ class ObserverThread(threading.Thread): t.start() def pulse_notify(self): - """Notify wakeup (sets and resets notify event) + """Notify wakeup (sets /and resets/ notify event) """ if not self._paused and self._notify: self._notify.set() - self._notify.clear() + #self._notify.clear() def add(self, *event): """Add a event to queue and notify thread to wake up. @@ -138,6 +137,13 @@ class ObserverThread(threading.Thread): 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) @@ -168,6 +174,7 @@ class ObserverThread(threading.Thread): 'is_active': self.is_active, 'start': self.start, 'stop': self.stop, + 'nop': lambda:(), 'shutdown': lambda:() } try: @@ -177,13 +184,13 @@ class ObserverThread(threading.Thread): while self.active: ## going sleep, wait for events (in queue) self.idle = True - self._notify.wait(self.sleeptime) - # does not clear notify event here - we use pulse (and clear it inside) ... - # ## wake up - reset signal now (we don't need it so long as we reed from queue) - # if self._notify: - # self._notify.clear() - if self._paused: - continue + n = self._notify + if n: + 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 self.idle = False ## check events available and execute all events from queue while not self._paused: @@ -203,13 +210,14 @@ class ObserverThread(threading.Thread): #logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) logSys.error('%s', e, exc_info=True) ## 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)) - self.idle = True - return True - logSys.info("Observer stopped, %s events remaining.", len(self._queue)) - #print("Observer stopped, %s events remaining." % len(self._queue)) + # clear all events - exit, for possible calls of wait_empty: + with self._queue_lock: + self._queue = [] self.idle = True return True @@ -230,16 +238,22 @@ class ObserverThread(threading.Thread): super(ObserverThread, self).start() def stop(self): - logSys.info("Observer stop ...") - #print("Observer stop ....") - self.active = False - if self._notify: + 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) - self.add('shutdown') - self.pulse_notify() + 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 5 seconds until full (events remaining) - self.wait_empty(5) + self.active = False + # wait max wtime seconds until full (events remaining) + self.wait_empty(wtime) + n.clear() + self.wait_idle(0.5) @property def is_full(self): @@ -249,14 +263,16 @@ class ObserverThread(threading.Thread): def wait_empty(self, sleeptime=None): """Wait observer is running and returns if observer has no more events (queue is empty) """ - if not self.is_full: - return True + # block queue with not operation to be sure all really jobs are executed if nop goes from queue : + self._queue.append(('nop',)) if sleeptime is not None: e = MyTime.time() + sleeptime while self.is_full: if sleeptime is not None and MyTime.time() > e: break - time.sleep(0.1) + time.sleep(0.01) + # wait idle to be sure the last queue element is processed (because pop event before processing it) : + self.wait_idle(0.01) return not self.is_full @@ -271,7 +287,7 @@ class ObserverThread(threading.Thread): while not self.idle: if sleeptime is not None and MyTime.time() > e: break - time.sleep(0.1) + time.sleep(0.01) return self.idle @property @@ -443,6 +459,8 @@ class ObserverThread(threading.Thread): 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, diff --git a/fail2ban/server/ticket.py b/fail2ban/server/ticket.py index 7d731637..3dfb0c99 100644 --- a/fail2ban/server/ticket.py +++ b/fail2ban/server/ticket.py @@ -88,6 +88,9 @@ class Ticket: def setBanCount(self, value): self.__banCount = value; + def incrBanCount(self, value = 1): + self.__banCount += value; + def getBanCount(self): return self.__banCount; diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py index 3e7c5fda..096d6181 100644 --- a/fail2ban/tests/observertestcase.py +++ b/fail2ban/tests/observertestcase.py @@ -32,6 +32,7 @@ import time from ..server.mytime import MyTime from ..server.ticket import FailTicket +from ..server.failmanager import FailManager from ..server.observer import Observers, ObserverThread from .utils import LogCaptureTestCase from .dummyjail import DummyJail @@ -174,7 +175,8 @@ class BanTimeIncr(LogCaptureTestCase): a.setBanTimeExtra('rndtime', None) -class BanTimeIncrDB(LogCaptureTestCase): +class BanTimeIncrDB(unittest.TestCase): +#class BanTimeIncrDB(LogCaptureTestCase): def setUp(self): """Call before every test case.""" @@ -187,8 +189,10 @@ class BanTimeIncrDB(LogCaptureTestCase): return _, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_") self.db = Fail2BanDb(self.dbFilename) - self.jail = None + self.jail = DummyJail() + self.jail.database = self.db self.Observer = ObserverThread() + Observers.Main = self.Observer def tearDown(self): """Call after every test case.""" @@ -196,6 +200,8 @@ class BanTimeIncrDB(LogCaptureTestCase): if Fail2BanDb is None: # pragma: no cover return # Cleanup + self.Observer.stop() + Observers.Main = None os.remove(self.dbFilename) def incrBanTime(self, ticket, banTime=None): @@ -211,9 +217,7 @@ class BanTimeIncrDB(LogCaptureTestCase): def testBanTimeIncr(self): if Fail2BanDb is None: # pragma: no cover return - jail = DummyJail() - self.jail = jail - jail.database = self.db + jail = self.jail self.db.addJail(jail) # we tests with initial ban time = 10 seconds: jail.actions.setBanTime(10) @@ -229,6 +233,7 @@ class BanTimeIncrDB(LogCaptureTestCase): [10, 10, 10] ) # add a ticket banned + ticket.incrBanCount() self.db.addBan(jail, ticket) # get a ticket already banned in this jail: self.assertEqual( @@ -238,6 +243,7 @@ class BanTimeIncrDB(LogCaptureTestCase): # 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( @@ -274,6 +280,7 @@ class BanTimeIncrDB(LogCaptureTestCase): 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): @@ -281,15 +288,18 @@ class BanTimeIncrDB(LogCaptureTestCase): 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) @@ -331,6 +341,7 @@ class BanTimeIncrDB(LogCaptureTestCase): # 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) @@ -344,6 +355,7 @@ class BanTimeIncrDB(LogCaptureTestCase): 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: @@ -373,10 +385,12 @@ class BanTimeIncrDB(LogCaptureTestCase): 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) @@ -402,6 +416,86 @@ class BanTimeIncrDB(LogCaptureTestCase): self.assertEqual(row, (3, stime, 18000)) break + 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 + time.sleep(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: + i = 50 + while True: + ticket2 = jail.getFailTicket() + if ticket2: + break + time.sleep(0.1) + # check ticket and failure count: + self.assertFalse(not ticket2) + self.assertEqual(ticket2.getAttempt(), failManager.getMaxRetry()) + + # 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) + + # stop observer + obs.stop() class ObserverTest(unittest.TestCase): @@ -419,16 +513,17 @@ class ObserverTest(unittest.TestCase): obs.start() # wait for idle obs.wait_idle(0.1) - # observer will sleep 0.5 second (in busy state): + # 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 sleep 0.5 second after pause ends: + # observer will replace test set, but first after pause ends: obs.add('call', o.clear) obs.add('call', o.add, 'test3') obs.wait_empty(0.25) From bb6655e696f8f068c5a90d540a1a6626544859a0 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 10 Jun 2014 10:24:55 +0200 Subject: [PATCH 20/60] small fix and clarifying code and log messages --- fail2ban/server/actions.py | 6 +++--- fail2ban/server/filter.py | 2 +- fail2ban/server/observer.py | 14 ++++++++------ 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 9148931f..4d08e8cd 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -292,10 +292,10 @@ class Actions(JailThread, Mapping): if self.__banManager.addBanTicket(bTicket): # report ticket to observer, to check time should be increased and hereafter observer writes ban to database (asynchronous) - if Observers.Main and not bTicket.getRestored(): + if Observers.Main is not None and not bTicket.getRestored(): Observers.Main.add('banFound', bTicket, self._jail, btime) - logSys.notice("[%s] %sBan %s (%d # %s -> %s)", self._jail.name, ('' if not bTicket.getRestored() else 'Restore '), - aInfo["ip"], bTicket.getBanCount()+(1 if not bTicket.getRestored() else 0), *logtime) + logSys.notice("[%s] %sBan %s (%s # %s -> %s)", self._jail.name, ('' if not bTicket.getRestored() else 'Restore '), + aInfo["ip"], (bTicket.getBanCount() if bTicket.getRestored() else '_'), *logtime) # do actions : for name, action in self._actions.iteritems(): try: diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 7e8755a3..9fed0ac1 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -427,7 +427,7 @@ class Filter(JailThread): tick = FailTicket(ip, unixTime, lines) self.failManager.addFailure(tick) # report to observer - failure was found, for possibly increasing of it retry counter (asynchronous) - if Observers.Main: + if Observers.Main is not None: Observers.Main.add('failureFound', self.failManager, self.jail, tick) ## diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 63e38bfa..fa5a5f62 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -343,14 +343,15 @@ class ObserverThread(threading.Thread): 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 = failManager.getMaxRetry() + # retryCount = maxRetry break - retryCount = min(retryCount, failManager.getMaxRetry()) + 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.info("[%s] Ignore failure %s before last ban %s < %s, restored" @@ -360,15 +361,16 @@ class ObserverThread(threading.Thread): if retryCount <= 1: return # retry counter was increased - add it again: - logSys.info("[%s] Found %s, bad - %s, %s # -> %s, ban", jail.name, ip, - datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S"), banCount, retryCount) + 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 '')) # remove matches from this ticket, because a ticket was already added by filter self ticket.setMatches(None) # retryCount-1, because a ticket was already once incremented by filter self failManager.addFailure(ticket, retryCount - 1, True) # after observe we have increased count >= maxretry ... - if retryCount >= failManager.getMaxRetry(): + 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 @@ -464,7 +466,7 @@ class ObserverThread(threading.Thread): # 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()+1, *logtime) + 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: From 00fdf5ce0a7309b9575d04463c136bdf1e2394fa Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 10 Jun 2014 12:31:55 +0200 Subject: [PATCH 21/60] test cases extended; code review --- fail2ban/server/jail.py | 24 ++++++++++++++---------- fail2ban/server/observer.py | 9 +++++++-- fail2ban/tests/dummyjail.py | 4 ++-- fail2ban/tests/observertestcase.py | 15 +++++++++++++-- 4 files changed, 36 insertions(+), 16 deletions(-) diff --git a/fail2ban/server/jail.py b/fail2ban/server/jail.py index 9d4eec29..d87395de 100644 --- a/fail2ban/server/jail.py +++ b/fail2ban/server/jail.py @@ -192,7 +192,7 @@ class Jail: Used by filter to add a failure for banning. """ self.__queue.put(ticket) - # add ban to database moved to actions (should previously check not already banned + # add ban to database moved to observer (should previously check not already banned # and increase ticket time if "bantime.increment" set) def getFailTicket(self): @@ -256,15 +256,9 @@ class Jail: return self._banExtra.get(opt, None) return self._banExtra - def start(self): - """Start the jail, by starting filter and actions threads. - - Once stated, also queries the persistent database to reinstate - any valid bans. + def restoreCurrentBans(self): + """Restore any previous valid bans from the database. """ - self.filter.start() - self.actions.start() - # Restore any previous valid bans from the database try: if self.database is not None: forbantime = None; @@ -276,11 +270,21 @@ class Jail: if not self.filter.inIgnoreIPList(ticket.getIP()): # mark ticked was restored from database - does not put it again into db: ticket.setRestored(True) - self.__queue.put(ticket) + self.putFailTicket(ticket) except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) #logSys.error('%s', e, exc_info=True) + def start(self): + """Start the jail, by starting filter and actions threads. + + Once stated, also queries the persistent database to reinstate + any valid bans. + """ + self.filter.start() + self.actions.start() + self.restoreCurrentBans() + logSys.info("Jail '%s' started" % self.name) def stop(self): diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index fa5a5f62..930acc87 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -263,10 +263,15 @@ class ObserverThread(threading.Thread): def wait_empty(self, sleeptime=None): """Wait observer is running and returns if observer has no more events (queue is empty) """ - # block queue with not operation to be sure all really jobs are executed if nop goes from queue : - self._queue.append(('nop',)) + time.sleep(0.001) + if not self.is_full: + return not self.is_full 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 : + 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 diff --git a/fail2ban/tests/dummyjail.py b/fail2ban/tests/dummyjail.py index 4ca7e108..c5624f02 100644 --- a/fail2ban/tests/dummyjail.py +++ b/fail2ban/tests/dummyjail.py @@ -30,10 +30,10 @@ from ..server.actions import Actions class DummyJail(Jail, object): """A simple 'jail' to suck in all the tickets generated by Filter's """ - def __init__(self): + def __init__(self, backend=None): self.lock = Lock() self.queue = [] - super(DummyJail, self).__init__(name='DummyJail', backend=None) + super(DummyJail, self).__init__(name='DummyJail', backend=backend) self.__db = None self.__actions = Actions(self) diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py index 096d6181..6b8400ff 100644 --- a/fail2ban/tests/observertestcase.py +++ b/fail2ban/tests/observertestcase.py @@ -35,6 +35,7 @@ from ..server.ticket import FailTicket from ..server.failmanager import FailManager from ..server.observer import Observers, ObserverThread from .utils import LogCaptureTestCase +from ..server.filter import Filter from .dummyjail import DummyJail try: from ..server.database import Fail2BanDb @@ -60,7 +61,9 @@ class BanTimeIncr(LogCaptureTestCase): 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: @@ -377,10 +380,10 @@ class BanTimeIncrDB(unittest.TestCase): self.assertEqual(restored_tickets, []) # two separate jails : - jail1 = DummyJail() + jail1 = DummyJail(backend='polling') jail1.database = self.db self.db.addJail(jail1) - jail2 = DummyJail() + jail2 = DummyJail(backend='polling') jail2.database = self.db self.db.addJail(jail2) ticket1 = FailTicket(ip, stime, []) @@ -415,6 +418,14 @@ class BanTimeIncrDB(unittest.TestCase): 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 From a82cc3bcbffa22f4b52bb545192f9ad228ab5e0e Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 10 Jun 2014 13:24:13 +0200 Subject: [PATCH 22/60] prevent to early exit from main loop (tast case bug by multi-threaded execution / wait for completion); idle state fixed (if observer really sleeps only); --- fail2ban/server/observer.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 930acc87..f6171a98 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -182,15 +182,6 @@ class ObserverThread(threading.Thread): self.add('is_alive') ## if we should stop - break a main loop while self.active: - ## going sleep, wait for events (in queue) - self.idle = True - n = self._notify - if n: - 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 self.idle = False ## check events available and execute all events from queue while not self._paused: @@ -209,6 +200,21 @@ class ObserverThread(threading.Thread): 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(0.001) + ## 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)) @@ -249,10 +255,10 @@ class ObserverThread(threading.Thread): self._notify.set() #self.pulse_notify() self._notify = None - self.active = False # wait max wtime seconds until full (events remaining) self.wait_empty(wtime) n.clear() + self.active = False self.wait_idle(0.5) @property @@ -264,8 +270,6 @@ class ObserverThread(threading.Thread): """Wait observer is running and returns if observer has no more events (queue is empty) """ time.sleep(0.001) - if not self.is_full: - return not self.is_full 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 : From 6ecd7ddddf1b9425cfac466a015f0c5f4255f1a6 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 10 Jun 2014 13:45:29 +0200 Subject: [PATCH 23/60] testExecuteTimeout fixed: give a test still 1 second, because system could be too busy --- fail2ban/tests/actiontestcase.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/fail2ban/tests/actiontestcase.py b/fail2ban/tests/actiontestcase.py index f1ea77ce..5a58149f 100644 --- a/fail2ban/tests/actiontestcase.py +++ b/fail2ban/tests/actiontestcase.py @@ -186,8 +186,10 @@ class CommandActionTest(LogCaptureTestCase): # Should take a minute self.assertRaises( RuntimeError, CommandAction.executeCmd, 'sleep 60', timeout=2) - self.assertAlmostEqual(time.time() - stime, 2, places=0) - self.assertTrue(self._is_logged('sleep 60 -- timed out after 2 seconds')) + # give a test still 1 second, because system could be too busy + self.assertTrue(time.time() >= stime + 2 and time.time() <= stime + 3) + self.assertTrue(self._is_logged('sleep 60 -- timed out after 2 seconds') + or self._is_logged('sleep 60 -- timed out after 3 seconds')) self.assertTrue(self._is_logged('sleep 60 -- killed with SIGTERM')) def testCaptureStdOutErr(self): From 819e4eb540b318ccde3d8fd33b8f0688396a71a1 Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 19 Jun 2014 18:06:07 +0200 Subject: [PATCH 24/60] relict of obsolete code removed; --- fail2ban/client/jailreader.py | 1 - 1 file changed, 1 deletion(-) diff --git a/fail2ban/client/jailreader.py b/fail2ban/client/jailreader.py index 1d368c62..bcabd04b 100644 --- a/fail2ban/client/jailreader.py +++ b/fail2ban/client/jailreader.py @@ -94,7 +94,6 @@ class JailReader(ConfigReader): ["string", "findtime", None], ["string", "bantime", None], ["bool", "bantime.increment", None], - ["string", "bantime.findtime", None], ["string", "bantime.factor", None], ["string", "bantime.formula", None], ["string", "bantime.multipliers", None], From de0beeff9f50665f14d7eff0b13b9d03308c8417 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 24 Jun 2014 11:12:22 +0200 Subject: [PATCH 25/60] new test cases added (increase coverage); prepared to merge with upstream/master; --- fail2ban/tests/banmanagertestcase.py | 21 +++++++++++++ fail2ban/tests/dummyjail.py | 6 +++- fail2ban/tests/observertestcase.py | 45 +++++++++++++++++++++++++++- fail2ban/tests/servertestcase.py | 8 +++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/fail2ban/tests/banmanagertestcase.py b/fail2ban/tests/banmanagertestcase.py index 7dcb73a7..ee3546ba 100644 --- a/fail2ban/tests/banmanagertestcase.py +++ b/fail2ban/tests/banmanagertestcase.py @@ -42,6 +42,9 @@ class AddFailure(unittest.TestCase): def testAdd(self): self.assertEqual(self.__banManager.size(), 1) + self.assertEqual(self.__banManager.getBanTotal(), 1) + self.__banManager.setBanTotal(0) + self.assertEqual(self.__banManager.getBanTotal(), 0) def testAddDuplicate(self): self.assertFalse(self.__banManager.addBanTicket(self.__ticket)) @@ -55,3 +58,21 @@ class AddFailure(unittest.TestCase): 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)) + diff --git a/fail2ban/tests/dummyjail.py b/fail2ban/tests/dummyjail.py index c5624f02..ad0180ed 100644 --- a/fail2ban/tests/dummyjail.py +++ b/fail2ban/tests/dummyjail.py @@ -27,6 +27,10 @@ from threading import Lock from ..server.jail import Jail from ..server.actions import Actions +class DummyActions(Actions): + def checkBan(self): + return self._Actions__checkBan() + class DummyJail(Jail, object): """A simple 'jail' to suck in all the tickets generated by Filter's """ @@ -35,7 +39,7 @@ class DummyJail(Jail, object): self.queue = [] super(DummyJail, self).__init__(name='DummyJail', backend=backend) self.__db = None - self.__actions = Actions(self) + self.__actions = DummyActions(self) def __len__(self): try: diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py index 6b8400ff..e8d813cf 100644 --- a/fail2ban/tests/observertestcase.py +++ b/fail2ban/tests/observertestcase.py @@ -258,11 +258,22 @@ class BanTimeIncrDB(unittest.TestCase): [(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( @@ -482,7 +493,6 @@ class BanTimeIncrDB(unittest.TestCase): obs.add('failureFound', failManager, self.jail, ticket) obs.wait_empty(5) # wait until ticket transfered from failmanager into jail: - i = 50 while True: ticket2 = jail.getFailTicket() if ticket2: @@ -505,6 +515,39 @@ class BanTimeIncrDB(unittest.TestCase): 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() diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 7b05a9b6..48eed035 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -763,6 +763,14 @@ 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): From e70406d5a17f5dd01bd09a52142eaf4895c521e4 Mon Sep 17 00:00:00 2001 From: sebres Date: Mon, 7 Jul 2014 10:28:46 +0200 Subject: [PATCH 26/60] @commitandrollback decorator added; missing logging module reference added; --- fail2ban/server/database.py | 8 ++++---- fail2ban/server/observer.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index 53ec87ba..1f4f7ca8 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -512,7 +512,8 @@ class Fail2BanDb(object): self._bansMergedCache[cacheKey] = tickets if ip is None else ticket return tickets if ip is None else ticket - def getBan(self, ip, jail=None, forbantime=None, overalljails=None, fromtime=None): + @commitandrollback + def getBan(self, cur, ip, jail=None, forbantime=None, overalljails=None, fromtime=None): if not overalljails: query = "SELECT bancount, timeofban, bantime FROM bips" else: @@ -531,10 +532,10 @@ class Fail2BanDb(object): if overalljails or jail is None: query += " GROUP BY ip ORDER BY timeofban DESC LIMIT 1" cur = self._db.cursor() - #logSys.debug((query, queryArgs)); return cur.execute(query, queryArgs) - def _getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): + @commitandrollback + def _getCurrentBans(self, cur, jail = None, ip = None, forbantime=None, fromtime=None): if fromtime is None: fromtime = MyTime.time() queryArgs = [] @@ -554,7 +555,6 @@ class Fail2BanDb(object): if ip is None: query += " GROUP BY ip ORDER BY ip, timeofban DESC" cur = self._db.cursor() - #logSys.debug((query, queryArgs)); return cur.execute(query, queryArgs) def getCurrentBans(self, jail = None, ip = None, forbantime=None, fromtime=None): diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 40751941..2db8144d 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -26,7 +26,7 @@ __copyright__ = "Copyright (c) 2014 Serg G. Brester" __license__ = "GPL" import threading -import os, time, datetime, math, json, random +import os, logging, time, datetime, math, json, random import sys from ..helpers import getLogger from .mytime import MyTime From 6d2b596bb41729e17cafad2f40a3c2933a195878 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 8 Jul 2014 10:56:19 +0200 Subject: [PATCH 27/60] observer inherits from JailThread + test cases for bad run; --- fail2ban/server/jailthread.py | 4 ++-- fail2ban/server/observer.py | 4 ++-- fail2ban/tests/observertestcase.py | 30 +++++++++++++++++++++++++----- 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/fail2ban/server/jailthread.py b/fail2ban/server/jailthread.py index 2627cebf..fa135b71 100644 --- a/fail2ban/server/jailthread.py +++ b/fail2ban/server/jailthread.py @@ -47,8 +47,8 @@ class JailThread(Thread): The time the thread sleeps for in the loop. """ - def __init__(self): - super(JailThread, self).__init__() + def __init__(self, name=None): + super(JailThread, self).__init__(name=name) ## Control the state of the thread. self.active = False ## Control the idle state of the thread. diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 2db8144d..7f73d3d4 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -26,6 +26,7 @@ __copyright__ = "Copyright (c) 2014 Serg G. Brester" __license__ = "GPL" import threading +from .jailthread import JailThread import os, logging, time, datetime, math, json, random import sys from ..helpers import getLogger @@ -34,7 +35,7 @@ from .mytime import MyTime # Gets the instance of the logger. logSys = getLogger(__name__) -class ObserverThread(threading.Thread): +class ObserverThread(JailThread): """Handles observing a database, managing bad ips and ban increment. Parameters @@ -236,7 +237,6 @@ class ObserverThread(threading.Thread): def start(self): with self._queue_lock: if not self.active: - self.active = True super(ObserverThread, self).start() def stop(self): diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py index e8d813cf..bc484f07 100644 --- a/fail2ban/tests/observertestcase.py +++ b/fail2ban/tests/observertestcase.py @@ -551,16 +551,15 @@ class BanTimeIncrDB(unittest.TestCase): # stop observer obs.stop() -class ObserverTest(unittest.TestCase): +class ObserverTest(LogCaptureTestCase): def setUp(self): """Call before every test case.""" - #super(ObserverTest, self).setUp() - pass + super(ObserverTest, self).setUp() def tearDown(self): - #super(ObserverTest, self).tearDown() - pass + """Call after every test case.""" + super(ObserverTest, self).tearDown() def testObserverBanTimeIncr(self): obs = ObserverThread() @@ -592,3 +591,24 @@ class ObserverTest(unittest.TestCase): self.assertTrue(obs.is_alive()) 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) + obs.start() + obs.stop() + obs = None + self.assertTrue(self._is_logged("Unhandled exception")) + sys.__excepthook__ = prev_exchook + self.assertEqual(len(x), 1) + self.assertEqual(x[0][0], RuntimeError) + self.assertEqual(str(x[0][1]), 'run bad thread exception') From 53a30a2d42fa9cc114335cc7328651637f12162c Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 16 Sep 2014 13:50:32 +0200 Subject: [PATCH 28/60] prevent completely read of big files first time (after start of service), initial seek to start time using half-interval search algorithm (see issue #795) --- fail2ban/server/filter.py | 92 +++++++++++++++++++++++++++++- fail2ban/server/filtergamin.py | 2 +- fail2ban/server/filterpoll.py | 4 +- fail2ban/server/filterpyinotify.py | 2 +- fail2ban/tests/filtertestcase.py | 9 +++ 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index e235909b..d63fef5f 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -21,7 +21,7 @@ __author__ = "Cyril Jaquier and Fail2Ban Contributors" __copyright__ = "Copyright (c) 2004 Cyril Jaquier, 2011-2013 Yaroslav Halchenko" __license__ = "GPL" -import re, os, fcntl, sys, locale, codecs, datetime +import re, os, fcntl, sys, locale, codecs, datetime, logging from .failmanager import FailManagerEmpty, FailManager from .observer import Observers @@ -653,7 +653,7 @@ class FileFilter(Filter): # MyTime.time()-self.findTime. When a failure is detected, a FailTicket # is created and is added to the FailManager. - def getFailures(self, filename): + def getFailures(self, filename, startTime = None): container = self.getFileContainer(filename) if container is None: logSys.error("Unable to get failures in " + filename) @@ -675,6 +675,11 @@ class FileFilter(Filter): logSys.exception(e) return False + # prevent completely read of big files first time (after start of service), initial seek to start time using half-interval search algorithm: + if container.getPos() == 0 and startTime is not None: + # startTime = MyTime.time() - self.getFindTime() + self.seekToTime(container, startTime) + # yoh: has_content is just a bool, so do not expect it to # change -- loop is exited upon break, and is not entered at # all if upon container opening that one was empty. If we @@ -692,6 +697,76 @@ class FileFilter(Filter): db.updateLog(self.jail, container) return True + ## + # Seeks to line with date (search using half-interval search algorithm), to start polling from it + # + + def seekToTime(self, container, date): + fs = container.getFileSize() + if logSys.getEffectiveLevel() <= logging.DEBUG: + logSys.debug("Seek to find time %s (%s), file size %s", date, + datetime.datetime.fromtimestamp(date).strftime("%Y-%m-%d %H:%M:%S"), fs) + date -= 0.009 + minp = 0 + maxp = fs + lastpos = 0 + lastFew = 0 + lastTime = None + cntr = 0 + unixTime = None + lasti = 0 + movecntr = 3 + while maxp > minp: + i = minp + (maxp - minp) / 2 + pos = container.seek(i) + cntr += 1 + # within next 5 lines try to find any legal datetime: + lncntr = 5; + dateTimeMatch = None + llen = 0 + i = pos + while True: + line = container.readline() + if not line: + break + llen += len(line) + l = line.rstrip('\r\n') + timeMatch = self.dateDetector.matchTime(l) + if timeMatch: + dateTimeMatch = self.dateDetector.getTime(l[timeMatch.start():timeMatch.end()]) + if not dateTimeMatch and lncntr: + lncntr -= 1 + continue + break + # if we can't move (position not changed) + if i + llen == lasti: + movecntr -= 1 + if movecntr <= 0: + break + lasti = i + llen; + # not found at this step - stop searching + if not dateTimeMatch: + break + unixTime = dateTimeMatch[0] + if round(unixTime) == round(date): + break + if unixTime >= date: + maxp = i + else: + minp = i + llen + lastFew = pos; + lastTime = unixTime + lastpos = pos + # if found position have a time greater as given - use smallest time we have found + if unixTime is None or unixTime > date: + unixTime = lastTime + lastpos = container.seek(lastFew, False) + else: + lastpos = container.seek(lastpos, False) + if logSys.getEffectiveLevel() <= logging.DEBUG: + logSys.debug("Position %s from %s, found time %s (%s) within %s seeks", lastpos, fs, unixTime, + (datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S") if unixTime is not None else ''), cntr) + @property def status(self): """Status of Filter plus files being monitored. @@ -744,6 +819,9 @@ class FileContainer: def getFileName(self): return self.__filename + def getFileSize(self): + return os.path.getsize(self.__filename); + def setEncoding(self, encoding): codecs.lookup(encoding) # Raises LookupError if invalid self.__encoding = encoding @@ -790,6 +868,16 @@ class FileContainer: self.__handler.seek(self.__pos) return True + def seek(self, offs, endLine = True): + h = self.__handler + # seek to given position + h.seek(offs, 0) + # goto end of next line + if endLine: + h.readline() + # get current real position + return h.tell() + def readline(self): if self.__handler is None: return "" diff --git a/fail2ban/server/filtergamin.py b/fail2ban/server/filtergamin.py index c9e8e31c..6d80d403 100644 --- a/fail2ban/server/filtergamin.py +++ b/fail2ban/server/filtergamin.py @@ -76,7 +76,7 @@ class FilterGamin(FileFilter): TODO -- RF: this is a common logic and must be shared/provided by FileFilter """ - self.getFailures(path) + self.getFailures(path, MyTime.time() - self.getFindTime()) try: while True: ticket = self.failManager.toBan() diff --git a/fail2ban/server/filterpoll.py b/fail2ban/server/filterpoll.py index f37be431..90fd2b20 100644 --- a/fail2ban/server/filterpoll.py +++ b/fail2ban/server/filterpoll.py @@ -83,6 +83,7 @@ class FilterPoll(FileFilter): # @return True when the thread exits nicely def run(self): + cntr = 0 while self.active: if logSys.getEffectiveLevel() <= 6: logSys.log(6, "Woke up idle=%s with %d files monitored", @@ -92,7 +93,8 @@ class FilterPoll(FileFilter): for container in self.getLogPath(): filename = container.getFileName() if self.isModified(filename): - self.getFailures(filename) + self.getFailures(filename, (MyTime.time() - self.getFindTime()) if not cntr else None) + cntr += 1 self.__modified = True if self.__modified: diff --git a/fail2ban/server/filterpyinotify.py b/fail2ban/server/filterpyinotify.py index 784a7e53..0fa3b026 100644 --- a/fail2ban/server/filterpyinotify.py +++ b/fail2ban/server/filterpyinotify.py @@ -102,7 +102,7 @@ class FilterPyinotify(FileFilter): TODO -- RF: this is a common logic and must be shared/provided by FileFilter """ - self.getFailures(path) + self.getFailures(path, MyTime.time() - self.getFindTime()) try: while True: ticket = self.failManager.toBan() diff --git a/fail2ban/tests/filtertestcase.py b/fail2ban/tests/filtertestcase.py index 1fa3116e..a462cf80 100644 --- a/fail2ban/tests/filtertestcase.py +++ b/fail2ban/tests/filtertestcase.py @@ -872,6 +872,15 @@ class GetFailures(unittest.TestCase): self.filter.getFailures(GetFailures.FILENAME_03) _assert_correct_last_attempt(self, self.filter, output) + def testGetFailures03_seek(self): + # same test as above but with seek to 'Aug 14 11:55:04' - so other output ... + output = ('203.162.223.135', 5, 1124013544.0) + + self.filter.addLogPath(GetFailures.FILENAME_03) + self.filter.addFailRegex("error,relay=,.*550 User unknown") + self.filter.getFailures(GetFailures.FILENAME_03, output[2] - 4*60 + 1) + _assert_correct_last_attempt(self, self.filter, output) + def testGetFailures04(self): output = [('212.41.96.186', 4, 1124013600.0), ('212.41.96.185', 4, 1124017198.0)] From c1637e97b2e210a6351e356aa351f9efe6109c3c Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 16 Sep 2014 17:06:49 +0200 Subject: [PATCH 29/60] now polling backend only: prevent completely read of big files first time (after start of service), initial seek to start time using half-interval search algorithm (see issue #795): disabled for gamin and pyinotify backends; --- fail2ban/server/filtergamin.py | 2 +- fail2ban/server/filterpoll.py | 9 ++++++--- fail2ban/server/filterpyinotify.py | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/fail2ban/server/filtergamin.py b/fail2ban/server/filtergamin.py index 6d80d403..c9e8e31c 100644 --- a/fail2ban/server/filtergamin.py +++ b/fail2ban/server/filtergamin.py @@ -76,7 +76,7 @@ class FilterGamin(FileFilter): TODO -- RF: this is a common logic and must be shared/provided by FileFilter """ - self.getFailures(path, MyTime.time() - self.getFindTime()) + self.getFailures(path) try: while True: ticket = self.failManager.toBan() diff --git a/fail2ban/server/filterpoll.py b/fail2ban/server/filterpoll.py index 90fd2b20..59e7c1e9 100644 --- a/fail2ban/server/filterpoll.py +++ b/fail2ban/server/filterpoll.py @@ -55,6 +55,7 @@ class FilterPoll(FileFilter): ## The time of the last modification of the file. self.__prevStats = dict() self.__file404Cnt = dict() + self.__initial = dict() logSys.debug("Created FilterPoll") ## @@ -83,7 +84,6 @@ class FilterPoll(FileFilter): # @return True when the thread exits nicely def run(self): - cntr = 0 while self.active: if logSys.getEffectiveLevel() <= 6: logSys.log(6, "Woke up idle=%s with %d files monitored", @@ -93,8 +93,11 @@ class FilterPoll(FileFilter): for container in self.getLogPath(): filename = container.getFileName() if self.isModified(filename): - self.getFailures(filename, (MyTime.time() - self.getFindTime()) if not cntr else None) - cntr += 1 + # set start time as now - find time for first usage only (prevent performance bug with polling of big files) + self.getFailures(filename, + (MyTime.time() - self.getFindTime()) if not self.__initial.get(filename) else None + ) + self.__initial[filename] = True self.__modified = True if self.__modified: diff --git a/fail2ban/server/filterpyinotify.py b/fail2ban/server/filterpyinotify.py index 0fa3b026..784a7e53 100644 --- a/fail2ban/server/filterpyinotify.py +++ b/fail2ban/server/filterpyinotify.py @@ -102,7 +102,7 @@ class FilterPyinotify(FileFilter): TODO -- RF: this is a common logic and must be shared/provided by FileFilter """ - self.getFailures(path, MyTime.time() - self.getFindTime()) + self.getFailures(path) try: while True: ticket = self.failManager.toBan() From 0dce32405f8336b5df585faf51083059ee40e7ca Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 16 Sep 2014 17:27:21 +0200 Subject: [PATCH 30/60] python3 compatibility fix --- fail2ban/server/filter.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index d63fef5f..53756ab9 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -717,7 +717,7 @@ class FileFilter(Filter): lasti = 0 movecntr = 3 while maxp > minp: - i = minp + (maxp - minp) / 2 + i = round(minp + (maxp - minp) / 2) pos = container.seek(i) cntr += 1 # within next 5 lines try to find any legal datetime: From 96de888ac7470b53becf08e243cc46531237337a Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 16 Sep 2014 17:51:57 +0200 Subject: [PATCH 31/60] python3/pypy compatibility fix --- fail2ban/server/filter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 53756ab9..9640eb40 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -717,7 +717,7 @@ class FileFilter(Filter): lasti = 0 movecntr = 3 while maxp > minp: - i = round(minp + (maxp - minp) / 2) + i = int(minp + (maxp - minp) / 2) pos = container.seek(i) cntr += 1 # within next 5 lines try to find any legal datetime: @@ -748,7 +748,7 @@ class FileFilter(Filter): if not dateTimeMatch: break unixTime = dateTimeMatch[0] - if round(unixTime) == round(date): + if int(unixTime) == int(date): break if unixTime >= date: maxp = i From 6c2937affc9e23a82710d66acc263a70c25d6802 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 16 Sep 2014 18:12:21 +0200 Subject: [PATCH 32/60] python3/pypy compatibility fix + removing obsolete code --- fail2ban/server/filter.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 9640eb40..65e78142 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -748,8 +748,6 @@ class FileFilter(Filter): if not dateTimeMatch: break unixTime = dateTimeMatch[0] - if int(unixTime) == int(date): - break if unixTime >= date: maxp = i else: From 2b38d46fb5a6a73a27f2ff4bb04394a8b77e1a53 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 23 Sep 2014 19:57:55 +0200 Subject: [PATCH 33/60] actions: bug fix in lambdas in checkBan, because getBansMerged could return None (purge resp. asynchronous addBan), make the logic all around more stable; test cases: extended with test to check action together with database functionality (ex.: to verify lambdas in checkBan); database: getBansMerged should work within lock, using reentrant lock (cause call of getBans inside of getBansMerged); --- fail2ban/server/actions.py | 53 +++++++++++--- fail2ban/server/database.py | 71 ++++++++++--------- fail2ban/tests/databasetestcase.py | 23 +++++- .../tests/files/action.d/action_checkainfo.py | 14 ++++ 4 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 fail2ban/tests/files/action.d/action_checkainfo.py diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 15a16e99..8a1d8ec9 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -246,6 +246,44 @@ class Actions(JailThread, Mapping): logSys.debug(self._jail.name + ": action terminated") return True + def __getBansMerged(self, mi, idx): + """Helper for lamda to get bans merged once + + This function never returns None for ainfo lambdas - always a ticket (merged or single one) + and prevents any errors through merging (to guarantee ban actions will be executed). + [TODO] move merging to observer - here we could wait for merge and read already merged info from a database + + Parameters + ---------- + mi : dict + initial for lambda should contains {ip, ticket} + idx : str + key to get a merged bans : + 'all' - bans merged for all jails + 'jail' - bans merged for current jail only + + Returns + ------- + BanTicket + merged or self ticket only + """ + if idx in mi: + return mi[idx] if mi[idx] is not None else mi['ticket'] + try: + jail=self._jail + ip=mi['ip'] + mi[idx] = None + if idx == 'all': + mi[idx] = jail.database.getBansMerged(ip=ip) + elif idx == 'jail': + mi[idx] = jail.database.getBansMerged(ip=ip, jail=jail) + except Exception as e: + logSys.error( + "Failed to get %s bans merged, jail '%s': %s", + idx, jail.name, e, + exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) + return mi[idx] if mi[idx] is not None else mi['ticket'] + def __checkBan(self): """Check for IP address to ban. @@ -272,16 +310,13 @@ class Actions(JailThread, Mapping): aInfo["time"] = bTicket.getTime() aInfo["matches"] = "\n".join(bTicket.getMatches()) btime = bTicket.getBanTime(self.__banManager.getBanTime()) - # [todo] move merging to observer - here we could read already merged info from database (faster); + # retarded merge info via twice lambdas : once for merge, once for matches/failures: if self._jail.database is not None: - aInfo["ipmatches"] = lambda jail=self._jail: "\n".join( - jail.database.getBansMerged(ip=ip).getMatches()) - aInfo["ipjailmatches"] = lambda jail=self._jail: "\n".join( - jail.database.getBansMerged(ip=ip, jail=jail).getMatches()) - aInfo["ipfailures"] = lambda jail=self._jail: \ - jail.database.getBansMerged(ip=ip).getAttempt() - aInfo["ipjailfailures"] = lambda jail=self._jail: \ - jail.database.getBansMerged(ip=ip, jail=jail).getAttempt() + mi4ip = lambda idx, self=self, mi={'ip':ip, 'ticket':bTicket}: self.__getBansMerged(mi, idx) + aInfo["ipmatches"] = lambda: "\n".join(mi4ip('all').getMatches()) + aInfo["ipjailmatches"] = lambda: "\n".join(mi4ip('jail').getMatches()) + aInfo["ipfailures"] = lambda: mi4ip('all').getAttempt() + aInfo["ipjailfailures"] = lambda: mi4ip('jail').getAttempt() if btime != -1: bendtime = aInfo["time"] + btime diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index 8fdadbd6..8227dea5 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -27,7 +27,7 @@ import sqlite3 import json import locale from functools import wraps -from threading import Lock +from threading import RLock from .mytime import MyTime from .ticket import FailTicket @@ -138,7 +138,7 @@ class Fail2BanDb(object): def __init__(self, filename, purgeAge=24*60*60, outDatedFactor=3): try: - self._lock = Lock() + self._lock = RLock() self._db = sqlite3.connect( filename, check_same_thread=False, detect_types=sqlite3.PARSE_DECLTYPES) @@ -397,6 +397,10 @@ class Fail2BanDb(object): del self._bansMergedCache[(ticket.getIP(), jail)] except KeyError: pass + try: + del self._bansMergedCache[(ticket.getIP(), None)] + except KeyError: + pass #TODO: Implement data parts once arbitrary match keys completed cur.execute( "INSERT INTO bans(jail, ip, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)", @@ -496,40 +500,41 @@ class Fail2BanDb(object): in a list. When `ip` argument passed, a single `Ticket` is returned. """ - cacheKey = None - if bantime is None or bantime < 0: - cacheKey = (ip, jail) - if cacheKey in self._bansMergedCache: - return self._bansMergedCache[cacheKey] + with self._lock: + cacheKey = None + if bantime is None or bantime < 0: + cacheKey = (ip, jail) + if cacheKey in self._bansMergedCache: + return self._bansMergedCache[cacheKey] - tickets = [] - ticket = None + tickets = [] + ticket = None - results = list(self._getBans(ip=ip, jail=jail, bantime=bantime)) - if results: - prev_banip = results[0][0] - matches = [] - failures = 0 - for banip, timeofban, data in results: - #TODO: Implement data parts once arbitrary match keys completed - if banip != prev_banip: - ticket = FailTicket(prev_banip, prev_timeofban, matches) - ticket.setAttempt(failures) - tickets.append(ticket) - # Reset variables - prev_banip = banip - matches = [] - failures = 0 - matches.extend(data['matches']) - failures += data['failures'] - prev_timeofban = timeofban - ticket = FailTicket(banip, prev_timeofban, matches) - ticket.setAttempt(failures) - tickets.append(ticket) + results = list(self._getBans(ip=ip, jail=jail, bantime=bantime)) + if results: + prev_banip = results[0][0] + matches = [] + failures = 0 + for banip, timeofban, data in results: + #TODO: Implement data parts once arbitrary match keys completed + if banip != prev_banip: + ticket = FailTicket(prev_banip, prev_timeofban, matches) + ticket.setAttempt(failures) + tickets.append(ticket) + # Reset variables + prev_banip = banip + matches = [] + failures = 0 + matches.extend(data['matches']) + failures += data['failures'] + prev_timeofban = timeofban + ticket = FailTicket(banip, prev_timeofban, matches) + ticket.setAttempt(failures) + tickets.append(ticket) - if cacheKey: - self._bansMergedCache[cacheKey] = tickets if ip is None else ticket - return tickets if ip is None else ticket + if cacheKey: + 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): diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index fef7dcae..5a06a301 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -32,18 +32,21 @@ import shutil from ..server.filter import FileContainer from ..server.mytime import MyTime from ..server.ticket import FailTicket +from ..server.actions import Actions from .dummyjail import DummyJail try: from ..server.database import Fail2BanDb except ImportError: Fail2BanDb = None +from .utils import LogCaptureTestCase TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "files") -class DatabaseTest(unittest.TestCase): +class DatabaseTest(LogCaptureTestCase): def setUp(self): """Call before every test case.""" + super(DatabaseTest, self).setUp() if Fail2BanDb is None and sys.version_info >= (2,7): # pragma: no cover raise unittest.SkipTest( "Unable to import fail2ban database module as sqlite is not " @@ -55,6 +58,7 @@ class DatabaseTest(unittest.TestCase): def tearDown(self): """Call after every test case.""" + super(DatabaseTest, self).tearDown() if Fail2BanDb is None: # pragma: no cover return # Cleanup @@ -267,6 +271,23 @@ class DatabaseTest(unittest.TestCase): tickets = self.db.getBansMerged(bantime=-1) self.assertEqual(len(tickets), 2) + def testActionWithDB(self): + # test action together with database functionality + self.testAddJail() # Jail required + self.jail.database = self.db; + actions = Actions(self.jail) + actions.add( + "action_checkainfo", + os.path.join(TEST_FILES_DIR, "action.d/action_checkainfo.py"), + {}) + ticket = FailTicket("1.2.3.4") + ticket.setAttempt(5) + ticket.setMatches(['test', 'test']) + self.jail.putFailTicket(ticket) + actions._Actions__checkBan() + self.assertTrue(self._is_logged("ban ainfo %s, %s, %s, %s" % (True, True, True, True))) + + def testPurge(self): if Fail2BanDb is None: # pragma: no cover return diff --git a/fail2ban/tests/files/action.d/action_checkainfo.py b/fail2ban/tests/files/action.d/action_checkainfo.py new file mode 100644 index 00000000..eec9cc85 --- /dev/null +++ b/fail2ban/tests/files/action.d/action_checkainfo.py @@ -0,0 +1,14 @@ + +from fail2ban.server.action import ActionBase + +class TestAction(ActionBase): + + def ban(self, aInfo): + self._logSys.info("ban ainfo %s, %s, %s, %s", + aInfo["ipmatches"] != '', aInfo["ipjailmatches"] != '', aInfo["ipfailures"] > 0, aInfo["ipjailfailures"] > 0 + ) + + def unban(self, aInfo): + pass + +Action = TestAction From 145a9fb8915e0532031d3d75812cb9e0f5afd162 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 24 Sep 2014 13:21:37 +0200 Subject: [PATCH 34/60] filter, datedetector, datetemplate: performance optimizing of combination datedetector.matchTime/getTime2, because early getTime search a template and call template.matchTime again (so the date parsing was really executed twice, now just once); debug logging optimized; added info line log "Start Fail2ban ..." after changed logging target; --- fail2ban/server/action.py | 10 +++-- fail2ban/server/datedetector.py | 56 +++++++++++++++++++++----- fail2ban/server/datetemplate.py | 17 ++++---- fail2ban/server/filter.py | 16 ++++---- fail2ban/server/server.py | 6 +-- fail2ban/tests/datedetectortestcase.py | 15 +++++++ 6 files changed, 90 insertions(+), 30 deletions(-) diff --git a/fail2ban/server/action.py b/fail2ban/server/action.py index da7517f6..66657ba8 100644 --- a/fail2ban/server/action.py +++ b/fail2ban/server/action.py @@ -570,10 +570,12 @@ class CommandAction(ActionBase): std_level = retcode == 0 and logging.DEBUG or logging.ERROR if std_level >= logSys.getEffectiveLevel(): - stdout.seek(0) - logSys.log(std_level, "%s -- stdout: %r" % (realCmd, stdout.read())) - stderr.seek(0) - logSys.log(std_level, "%s -- stderr: %r" % (realCmd, stderr.read())) + stdout.seek(0); msg = stdout.read() + if msg != '': + logSys.log(std_level, "%s -- stdout: %r", realCmd, msg) + stderr.seek(0); msg = stderr.read() + if msg != '': + logSys.log(std_level, "%s -- stderr: %r", realCmd, msg) stdout.close() stderr.close() diff --git a/fail2ban/server/datedetector.py b/fail2ban/server/datedetector.py index fe5282fd..ba623f4a 100644 --- a/fail2ban/server/datedetector.py +++ b/fail2ban/server/datedetector.py @@ -29,6 +29,8 @@ from ..helpers import getLogger # Gets the instance of the logger. logSys = getLogger(__name__) +logLevel = 6 + class DateDetector(object): """Manages one or more date templates to find a date within a log line. @@ -142,7 +144,7 @@ class DateDetector(object): Returns ------- - re.MatchObject + re.MatchObject, DateTemplate The regex match returned from the first successfully matched template. """ @@ -151,10 +153,11 @@ class DateDetector(object): for template in self.__templates: match = template.matchDate(line) if not match is None: - logSys.debug("Matched time template %s" % template.name) + if logSys.getEffectiveLevel() <= logLevel: + logSys.log(logLevel, "Matched time template %s", template.name) template.hits += 1 - return match - return None + return (match, template) + return (None, None) finally: self.__lock.release() @@ -173,7 +176,7 @@ class DateDetector(object): ------- float The Unix timestamp returned from the first successfully matched - template. + template or None if not found. """ self.__lock.acquire() try: @@ -182,8 +185,9 @@ class DateDetector(object): date = template.getDate(line) if date is None: continue - logSys.debug("Got time %f for \"%r\" using template %s" % - (date[0], date[1].group(), template.name)) + if logSys.getEffectiveLevel() <= logLevel: + logSys.log(logLevel, "Got time %f for \"%r\" using template %s", + date[0], date[1].group(), template.name) return date except ValueError: pass @@ -191,6 +195,38 @@ class DateDetector(object): finally: self.__lock.release() + def getTime2(self, line, timeMatch = None): + """Attempts to return the date on a log line using given template. + + This uses the templates' `getDate` method in an attempt to find + a date. + Method 'getTime2' is a little bit faster as 'getTime' if template was specified (cause works without locking and without cycle) + + Parameters + ---------- + line : str + Line which is searched by the date templates. + timeMatch (timeMatch, template) : (Match, DateTemplate) + Time match and template previously returned from matchTime + + Returns + ------- + float + The Unix timestamp returned from the first successfully matched + template or None if not found. + """ + date = None + if timeMatch: + template = timeMatch[1] + if template is not None: + date = template.getDate(line, timeMatch[0]) + if date is not None: + if logSys.getEffectiveLevel() <= logLevel: + logSys.log(logLevel, "Got time(2) %f for \"%r\" using template %s", + date[0], date[1].group(), template.name) + return date + return self.getTime(line) + def sortTemplate(self): """Sort the date templates by number of hits @@ -201,9 +237,11 @@ class DateDetector(object): """ self.__lock.acquire() try: - logSys.debug("Sorting the template list") + if logSys.getEffectiveLevel() <= logLevel: + logSys.log(logLevel, "Sorting the template list") self.__templates.sort(key=lambda x: x.hits, reverse=True) t = self.__templates[0] - logSys.debug("Winning template: %s with %d hits" % (t.name, t.hits)) + if logSys.getEffectiveLevel() <= logLevel: + logSys.log(logLevel, "Winning template: %s with %d hits", t.name, t.hits) finally: self.__lock.release() diff --git a/fail2ban/server/datetemplate.py b/fail2ban/server/datetemplate.py index a5179ed1..013a8105 100644 --- a/fail2ban/server/datetemplate.py +++ b/fail2ban/server/datetemplate.py @@ -98,7 +98,7 @@ class DateTemplate(object): return dateMatch @abstractmethod - def getDate(self, line): + def getDate(self, line, dateMatch = None): """Abstract method, which should return the date for a log line This should return the date for a log line, typically taking the @@ -134,7 +134,7 @@ class DateEpoch(DateTemplate): DateTemplate.__init__(self) self.regex = "(?:^|(?P(?<=^\[))|(?P(?<=audit\()))\d{10}(?:\.\d{3,6})?(?(selinux)(?=:\d+\))(?(square)(?=\])))" - def getDate(self, line): + def getDate(self, line, dateMatch = None): """Method to return the date for a log line. Parameters @@ -148,7 +148,8 @@ class DateEpoch(DateTemplate): Tuple containing a Unix timestamp, and the string of the date which was matched and in turned used to calculated the timestamp. """ - dateMatch = self.matchDate(line) + if not dateMatch: + dateMatch = self.matchDate(line) if dateMatch: # extract part of format which represents seconds since epoch return (float(dateMatch.group()), dateMatch) @@ -211,7 +212,7 @@ class DatePatternRegex(DateTemplate): def name(self, value): raise NotImplementedError("Name derived from pattern") - def getDate(self, line): + def getDate(self, line, dateMatch = None): """Method to return the date for a log line. This uses a custom version of strptime, using the named groups @@ -228,7 +229,8 @@ class DatePatternRegex(DateTemplate): Tuple containing a Unix timestamp, and the string of the date which was matched and in turned used to calculated the timestamp. """ - dateMatch = self.matchDate(line) + if not dateMatch: + dateMatch = self.matchDate(line) if dateMatch: groupdict = dict( (key, value) @@ -251,7 +253,7 @@ class DateTai64n(DateTemplate): # yoh: we should not add an additional front anchor self.setRegex("@[0-9a-f]{24}", wordBegin=False) - def getDate(self, line): + def getDate(self, line, dateMatch = None): """Method to return the date for a log line. Parameters @@ -265,7 +267,8 @@ class DateTai64n(DateTemplate): Tuple containing a Unix timestamp, and the string of the date which was matched and in turned used to calculated the timestamp. """ - dateMatch = self.matchDate(line) + if not dateMatch: + dateMatch = self.matchDate(line) if dateMatch: # extract part of format which represents seconds since epoch value = dateMatch.group() diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 65e78142..30c8c48d 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -394,14 +394,16 @@ class Filter(JailThread): l = line.rstrip('\r\n') logSys.log(7, "Working on line %r", line) - timeMatch = self.dateDetector.matchTime(l) + (timeMatch, template) = self.dateDetector.matchTime(l) if timeMatch: tupleLine = ( l[:timeMatch.start()], l[timeMatch.start():timeMatch.end()], - l[timeMatch.end():]) + l[timeMatch.end():], + (timeMatch, template) + ) else: - tupleLine = (l, "", "") + tupleLine = (l, "", "", None) return "".join(tupleLine[::2]), self.findFailure( tupleLine, date, returnRawHost, checkAllRegex) @@ -469,7 +471,7 @@ class Filter(JailThread): self.__lastDate = date elif timeText: - dateTimeMatch = self.dateDetector.getTime(timeText) + dateTimeMatch = self.dateDetector.getTime2(timeText, tupleLine[3]) if dateTimeMatch is None: logSys.error("findFailure failed to parse timeText: " + timeText) @@ -486,7 +488,7 @@ class Filter(JailThread): date = self.__lastDate self.__lineBuffer = ( - self.__lineBuffer + [tupleLine])[-self.__lineBufferSize:] + self.__lineBuffer + [tupleLine[:3]])[-self.__lineBufferSize:] # Iterates over all the regular expressions. for failRegexIndex, failRegex in enumerate(self.__failRegex): @@ -731,9 +733,9 @@ class FileFilter(Filter): break llen += len(line) l = line.rstrip('\r\n') - timeMatch = self.dateDetector.matchTime(l) + (timeMatch, template) = self.dateDetector.matchTime(l) if timeMatch: - dateTimeMatch = self.dateDetector.getTime(l[timeMatch.start():timeMatch.end()]) + dateTimeMatch = self.dateDetector.getTime2(l[timeMatch.start():timeMatch.end()], (timeMatch, template)) if not dateTimeMatch and lncntr: lncntr -= 1 continue diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 54ee0ef2..125086a5 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -65,7 +65,7 @@ class Server: self.quit() def start(self, sock, pidfile, force = False): - logSys.info("Starting Fail2ban v" + version.version) + logSys.info("Starting Fail2ban v%s", version.version) # Install signal handlers signal.signal(signal.SIGTERM, self.__sigTERMhandler) @@ -429,8 +429,8 @@ class Server: logger.addHandler(hdlr) # Does not display this message at startup. if not self.__logTarget is None: - logSys.info("Changed logging target to %s for Fail2ban v%s" % - (target, version.version)) + logSys.info("Start Fail2ban v%s", version.version) + logSys.info("Changed logging target to %s", target) # Sets the logging target. self.__logTarget = target return True diff --git a/fail2ban/tests/datedetectortestcase.py b/fail2ban/tests/datedetectortestcase.py index 726e73f8..6f1fe35a 100644 --- a/fail2ban/tests/datedetectortestcase.py +++ b/fail2ban/tests/datedetectortestcase.py @@ -103,6 +103,7 @@ class DateDetectorTest(unittest.TestCase): (not anchored, "bogus-prefix ")): log = prefix + sdate + "[sshd] error: PAM: Authentication failure" + # with getTime: logtime = self.__datedetector.getTime(log) if should_match: self.assertNotEqual(logtime, None, "getTime retrieved nothing: failure for %s, anchored: %r, log: %s" % ( sdate, anchored, log)) @@ -115,6 +116,20 @@ class DateDetectorTest(unittest.TestCase): self.assertEqual(logMatch.group(), sdate) else: self.assertEqual(logtime, None, "getTime should have not matched for %r Got: %s" % (sdate, logtime)) + # with matchTime and getTime2 (this combination used in filter) : + matchTime = self.__datedetector.matchTime(log) + logtime = self.__datedetector.getTime2(log, matchTime) + if should_match: + self.assertNotEqual(logtime, None, "getTime retrieved nothing: failure for %s, anchored: %r, log: %s" % ( sdate, anchored, log)) + ( logUnix, logMatch ) = logtime + self.assertEqual(logUnix, dateUnix, "getTime comparison failure for %s: \"%s\" is not \"%s\"" % (sdate, logUnix, dateUnix)) + if sdate.startswith('audit('): + # yes, special case, the group only matches the number + self.assertEqual(logMatch.group(), '1106513999.000') + else: + self.assertEqual(logMatch.group(), sdate) + else: + self.assertEqual(logtime, None, "getTime should have not matched for %r Got: %s" % (sdate, logtime)) def testStableSortTemplate(self): old_names = [x.name for x in self.__datedetector.templates] From 7688db262837ec89f301ccd4b7d85b7a8b0320d7 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 24 Sep 2014 15:22:48 +0200 Subject: [PATCH 35/60] observer: logging optimized, some log messages switched to debug level (because long time stable) --- fail2ban/server/observer.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 7f73d3d4..4b81b831 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -342,7 +342,7 @@ class ObserverThread(JailThread): return ip = ticket.getIP() unixTime = ticket.getTime() - logSys.info("[%s] Observer: failure found %s", jail.name, ip) + 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 @@ -359,8 +359,8 @@ class ObserverThread(JailThread): 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.info("[%s] Ignore failure %s before last ban %s < %s, restored" - % (jail.name, ip, 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: @@ -420,7 +420,7 @@ class ObserverThread(JailThread): 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)); + logSys.debug('IP %s was already banned: %s #, %s', ip, banCount, timeOfBan); ticket.setBanCount(banCount); # calculate new ban time if banCount > 0: @@ -447,7 +447,7 @@ class ObserverThread(JailThread): """ oldbtime = btime ip = ticket.getIP() - logSys.info("[%s] Observer: ban found %s, %s", jail.name, ip, btime) + 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: @@ -462,7 +462,7 @@ class ObserverThread(JailThread): datetime.datetime.fromtimestamp(bendtime).strftime("%Y-%m-%d %H:%M:%S")) # check ban is not too old : if bendtime < MyTime.time(): - logSys.info('Ignore old bantime %s', logtime[1]) + logSys.debug('Ignore old bantime %s', logtime[1]) return False else: logtime = ('permanent', 'infinite') From e6127a278e51ec3b04f052790de809a78beec00e Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 25 Sep 2014 18:29:10 +0200 Subject: [PATCH 36/60] The tricky bug fixed - last position of log file will be never retrieved (#795): addJail (executed before addLog) early uses a "INSERT OR REPLACE" statement to update "enabled" to 1 (and add jail the first time used at once), but this syntax in sqlite always deletes an entry (cause of constraint) and inserts it again, so because of CASCADE all log entries with this jail will be also deleted from logs table. --- fail2ban/server/database.py | 6 +++++- fail2ban/server/filter.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index 8227dea5..b3bae41b 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -265,8 +265,12 @@ class Fail2BanDb(object): Jail to be added to the database. """ cur.execute( - "INSERT OR REPLACE INTO jails(name, enabled) VALUES(?, 1)", + "INSERT OR IGNORE INTO jails(name, enabled) VALUES(?, 1)", (jail.name,)) + if cur.rowcount <= 0: + cur.execute( + "UPDATE jails SET enabled = 1 WHERE name = ? AND enabled != 1", + (jail.name,)) @commitandrollback def delJail(self, cur, jail): diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 30c8c48d..deada0e0 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -570,7 +570,7 @@ class FileFilter(Filter): if lastpos and not tail: container.setPos(lastpos) self.__logPath.append(container) - logSys.info("Added logfile = %s" % path) + logSys.info("Added logfile = %s (pos = %s, hash = %s)" , path, container.getPos(), container.getHash()) self._addLogPath(path) # backend specific def _addLogPath(self, path): From f70656cdd74ae5020b32e00a8b3c9aefb301eed8 Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 2 Oct 2014 22:29:09 +0200 Subject: [PATCH 37/60] caching of read config files, to make start of fail2ban faster, see issue #820 --- fail2ban/client/configparserinc.py | 186 ++++++++++++++++++------- fail2ban/client/configreader.py | 21 +-- fail2ban/tests/clientreadertestcase.py | 16 ++- 3 files changed, 158 insertions(+), 65 deletions(-) diff --git a/fail2ban/client/configparserinc.py b/fail2ban/client/configparserinc.py index 80b99517..ad0a255c 100644 --- a/fail2ban/client/configparserinc.py +++ b/fail2ban/client/configparserinc.py @@ -65,7 +65,136 @@ logSys = getLogger(__name__) __all__ = ['SafeConfigParserWithIncludes'] -class SafeConfigParserWithIncludes(SafeConfigParser): +class SafeConfigParserWithIncludes(object): + + SECTION_NAME = "INCLUDES" + CFG_CACHE = {} + CFG_INC_CACHE = {} + CFG_EMPY_CFG = None + + def __init__(self): + self.__cr = None + + def __check_read(self, attr): + if self.__cr is None: + # raise RuntimeError("Access to wrapped attribute \"%s\" before read call" % attr) + if SafeConfigParserWithIncludes.CFG_EMPY_CFG is None: + SafeConfigParserWithIncludes.CFG_EMPY_CFG = _SafeConfigParserWithIncludes() + self.__cr = SafeConfigParserWithIncludes.CFG_EMPY_CFG + + def __getattr__(self,attr): + # check we access local implementation + try: + orig_attr = self.__getattribute__(attr) + except AttributeError: + self.__check_read(attr) + orig_attr = self.__cr.__getattribute__(attr) + return orig_attr + + @staticmethod + def _resource_mtime(resource): + mt = [] + dirnames = [] + for filename in resource: + if os.path.exists(filename): + s = os.stat(filename) + mt.append(s.st_mtime) + mt.append(s.st_mode) + mt.append(s.st_size) + dirname = os.path.dirname(filename) + if dirname not in dirnames: + dirnames.append(dirname) + for dirname in dirnames: + if os.path.exists(dirname): + s = os.stat(dirname) + mt.append(s.st_mtime) + mt.append(s.st_mode) + mt.append(s.st_size) + return mt + + def read(self, resource, get_includes=True, log_info=None): + SCPWI = SafeConfigParserWithIncludes + # check includes : + fileNamesFull = [] + if not isinstance(resource, list): + resource = [ resource ] + if get_includes: + for filename in resource: + fileNamesFull += SCPWI.getIncludes(filename) + else: + fileNamesFull = resource + # check cache + hashv = '///'.join(fileNamesFull) + cr, ret, mtime = SCPWI.CFG_CACHE.get(hashv, (None, False, 0)) + curmt = SCPWI._resource_mtime(fileNamesFull) + if cr is not None and mtime == curmt: + self.__cr = cr + logSys.debug("Cached config files: %s", resource) + #logSys.debug("Cached config files: %s", fileNamesFull) + return ret + # not yet in cache - create/read and add to cache: + if log_info is not None: + logSys.info(*log_info) + cr = _SafeConfigParserWithIncludes() + ret = cr.read(fileNamesFull) + SCPWI.CFG_CACHE[hashv] = (cr, ret, curmt) + self.__cr = cr + return ret + + def getOptions(self, *args, **kwargs): + self.__check_read('getOptions') + return self.__cr.getOptions(*args, **kwargs) + + @staticmethod + def getIncludes(resource, seen = []): + """ + Given 1 config resource returns list of included files + (recursively) with the original one as well + Simple loops are taken care about + """ + + # Use a short class name ;) + SCPWI = SafeConfigParserWithIncludes + + resources = seen + [resource] + # check cache + hashv = '///'.join(resources) + cinc, mtime = SCPWI.CFG_INC_CACHE.get(hashv, (None, 0)) + curmt = SCPWI._resource_mtime(resources) + if cinc is not None and mtime == curmt: + return cinc + + parser = SCPWI() + try: + # read without includes + parser.read(resource, get_includes = False) + except UnicodeDecodeError, e: + logSys.error("Error decoding config file '%s': %s" % (resource, e)) + return [] + + resourceDir = os.path.dirname(resource) + + newFiles = [ ('before', []), ('after', []) ] + if SCPWI.SECTION_NAME in parser.sections(): + for option_name, option_list in newFiles: + if option_name in parser.options(SCPWI.SECTION_NAME): + newResources = parser.get(SCPWI.SECTION_NAME, option_name) + for newResource in newResources.split('\n'): + if os.path.isabs(newResource): + r = newResource + else: + r = os.path.join(resourceDir, newResource) + if r in seen: + continue + option_list += SCPWI.getIncludes(r, resources) + # combine lists + cinc = newFiles[0][1] + [resource] + newFiles[1][1] + # cache and return : + SCPWI.CFG_INC_CACHE[hashv] = (cinc, curmt) + return cinc + #print "Includes list for " + resource + " is " + `resources` + +class _SafeConfigParserWithIncludes(SafeConfigParser, object): """ Class adds functionality to SafeConfigParser to handle included other configuration files (or may be urls, whatever in the future) @@ -94,69 +223,22 @@ after = 1.conf """ - SECTION_NAME = "INCLUDES" - if sys.version_info >= (3,2): # overload constructor only for fancy new Python3's def __init__(self, *args, **kwargs): kwargs = kwargs.copy() kwargs['interpolation'] = BasicInterpolationWithName() kwargs['inline_comment_prefixes'] = ";" - super(SafeConfigParserWithIncludes, self).__init__( + super(_SafeConfigParserWithIncludes, self).__init__( *args, **kwargs) - #@staticmethod - def getIncludes(resource, seen = []): - """ - Given 1 config resource returns list of included files - (recursively) with the original one as well - Simple loops are taken care about - """ - - # Use a short class name ;) - SCPWI = SafeConfigParserWithIncludes - - parser = SafeConfigParser() - try: - if sys.version_info >= (3,2): # pragma: no cover - parser.read(resource, encoding='utf-8') - else: - parser.read(resource) - except UnicodeDecodeError, e: - logSys.error("Error decoding config file '%s': %s" % (resource, e)) - return [] - - resourceDir = os.path.dirname(resource) - - newFiles = [ ('before', []), ('after', []) ] - if SCPWI.SECTION_NAME in parser.sections(): - for option_name, option_list in newFiles: - if option_name in parser.options(SCPWI.SECTION_NAME): - newResources = parser.get(SCPWI.SECTION_NAME, option_name) - for newResource in newResources.split('\n'): - if os.path.isabs(newResource): - r = newResource - else: - r = os.path.join(resourceDir, newResource) - if r in seen: - continue - s = seen + [resource] - option_list += SCPWI.getIncludes(r, s) - # combine lists - return newFiles[0][1] + [resource] + newFiles[1][1] - #print "Includes list for " + resource + " is " + `resources` - getIncludes = staticmethod(getIncludes) - def read(self, filenames): - fileNamesFull = [] if not isinstance(filenames, list): filenames = [ filenames ] - for filename in filenames: - fileNamesFull += SafeConfigParserWithIncludes.getIncludes(filename) - logSys.debug("Reading files: %s" % fileNamesFull) + logSys.debug("Reading files: %s", filenames) if sys.version_info >= (3,2): # pragma: no cover - return SafeConfigParser.read(self, fileNamesFull, encoding='utf-8') + return SafeConfigParser.read(self, filenames, encoding='utf-8') else: - return SafeConfigParser.read(self, fileNamesFull) + return SafeConfigParser.read(self, filenames) diff --git a/fail2ban/client/configreader.py b/fail2ban/client/configreader.py index 22115d3a..ada48803 100644 --- a/fail2ban/client/configreader.py +++ b/fail2ban/client/configreader.py @@ -55,7 +55,7 @@ class ConfigReader(SafeConfigParserWithIncludes): raise ValueError("Base configuration directory %s does not exist " % self._basedir) basename = os.path.join(self._basedir, filename) - logSys.info("Reading configs for %s under %s " % (basename, self._basedir)) + logSys.debug("Reading configs for %s under %s " , filename, self._basedir) config_files = [ basename + ".conf" ] # possible further customizations under a .conf.d directory @@ -71,14 +71,15 @@ class ConfigReader(SafeConfigParserWithIncludes): if len(config_files): # at least one config exists and accessible - logSys.debug("Reading config files: " + ', '.join(config_files)) - config_files_read = SafeConfigParserWithIncludes.read(self, config_files) + logSys.debug("Reading config files: %s", ', '.join(config_files)) + config_files_read = SafeConfigParserWithIncludes.read(self, config_files, + log_info=("Cache configs for %s under %s " , filename, self._basedir)) missed = [ cf for cf in config_files if cf not in config_files_read ] if missed: - logSys.error("Could not read config files: " + ', '.join(missed)) + logSys.error("Could not read config files: %s", ', '.join(missed)) if config_files_read: return True - logSys.error("Found no accessible config files for %r under %s" % + logSys.error("Found no accessible config files for %r under %s", ( filename, self.getBaseDir() )) return False else: @@ -133,12 +134,12 @@ class ConfigReader(SafeConfigParserWithIncludes): class DefinitionInitConfigReader(ConfigReader): """Config reader for files with options grouped in [Definition] and - [Init] sections. + [Init] sections. - Is a base class for readers of filters and actions, where definitions - in jails might provide custom values for options defined in [Init] - section. - """ + Is a base class for readers of filters and actions, where definitions + in jails might provide custom values for options defined in [Init] + section. + """ _configOpts = [] diff --git a/fail2ban/tests/clientreadertestcase.py b/fail2ban/tests/clientreadertestcase.py index ce19a50e..f505f297 100644 --- a/fail2ban/tests/clientreadertestcase.py +++ b/fail2ban/tests/clientreadertestcase.py @@ -21,7 +21,7 @@ __author__ = "Cyril Jaquier, Yaroslav Halchenko" __copyright__ = "Copyright (c) 2004 Cyril Jaquier, 2011-2013 Yaroslav Halchenko" __license__ = "GPL" -import os, glob, shutil, tempfile, unittest +import os, glob, shutil, tempfile, unittest, time from ..client.configreader import ConfigReader from ..client.jailreader import JailReader @@ -37,6 +37,8 @@ CONFIG_DIR='config' if STOCK else '/etc/fail2ban' IMPERFECT_CONFIG = os.path.join(os.path.dirname(__file__), 'config') +LAST_WRITE_TIME = 0 + class ConfigReaderTest(unittest.TestCase): def setUp(self): @@ -55,7 +57,8 @@ class ConfigReaderTest(unittest.TestCase): d_ = os.path.join(self.d, d) if not os.path.exists(d_): os.makedirs(d_) - f = open("%s/%s" % (self.d, fname), "w") + fname = "%s/%s" % (self.d, fname) + f = open(fname, "w") if value is not None: f.write(""" [section] @@ -64,6 +67,14 @@ option = %s if content is not None: f.write(content) f.close() + # set modification time to another second to revalidate cache (if milliseconds not supported) : + global LAST_WRITE_TIME + mtime = os.path.getmtime(fname) + if LAST_WRITE_TIME == mtime: + mtime += 1 + os.utime(fname, (mtime, mtime)) + LAST_WRITE_TIME = mtime + def _remove(self, fname): os.unlink("%s/%s" % (self.d, fname)) @@ -89,7 +100,6 @@ option = %s # raise unittest.SkipTest("Skipping on %s -- access rights are not enforced" % platform) pass - def testOptionalDotDDir(self): self.assertFalse(self.c.read('c')) # nothing is there yet self._write("c.conf", "1") From 704357467aefed6af26057793a4ef9c72e34cbc0 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 7 Oct 2014 14:07:50 +0200 Subject: [PATCH 38/60] test case for check the read of config files will be cached; --- fail2ban/tests/clientreadertestcase.py | 32 +++++++++++++++++++++++++- fail2ban/tests/utils.py | 4 ++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/fail2ban/tests/clientreadertestcase.py b/fail2ban/tests/clientreadertestcase.py index f505f297..7e343954 100644 --- a/fail2ban/tests/clientreadertestcase.py +++ b/fail2ban/tests/clientreadertestcase.py @@ -21,7 +21,7 @@ __author__ = "Cyril Jaquier, Yaroslav Halchenko" __copyright__ = "Copyright (c) 2004 Cyril Jaquier, 2011-2013 Yaroslav Halchenko" __license__ = "GPL" -import os, glob, shutil, tempfile, unittest, time +import os, glob, shutil, tempfile, unittest, time, re from ..client.configreader import ConfigReader from ..client.jailreader import JailReader @@ -343,6 +343,36 @@ class FilterReaderTest(unittest.TestCase): self.assertRaises(ValueError, FilterReader.convert, filterReader) +class JailsReaderTestCache(LogCaptureTestCase): + + def testTestJailConfCache(self): + basedir = tempfile.mkdtemp("fail2ban_conf") + try: + shutil.rmtree(basedir) + shutil.copytree(CONFIG_DIR, basedir) + shutil.copy(CONFIG_DIR + '/jail.conf', basedir + '/jail.local') + shutil.copy(CONFIG_DIR + '/fail2ban.conf', basedir + '/fail2ban.local') + + # read whole configuration like a file2ban-client ... + configurator = Configurator() + configurator.setBaseDir(basedir) + configurator.readEarly() + configurator.getEarlyOptions() + configurator.readAll() + # from here we test a cache : + self.assertTrue(configurator.getOptions(None)) + cnt = 0 + for s in self.getLog().rsplit('\n'): + if re.match(r"^Reading files: .*jail.local", s): + cnt += 1 + # if cnt > 2: + # self.printLog() + self.assertFalse(cnt > 2, "Too many times reading of config files, cnt = %s" % cnt) + self.assertFalse(cnt <= 0) + finally: + shutil.rmtree(basedir) + + class JailsReaderTest(LogCaptureTestCase): def testProvidingBadBasedir(self): diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 8c8710a2..55b63bd3 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -103,6 +103,7 @@ def gatherTests(regexps=None, no_network=False): tests.addTest(unittest.makeSuite(clientreadertestcase.JailReaderTest)) tests.addTest(unittest.makeSuite(clientreadertestcase.FilterReaderTest)) tests.addTest(unittest.makeSuite(clientreadertestcase.JailsReaderTest)) + tests.addTest(unittest.makeSuite(clientreadertestcase.JailsReaderTestCache)) # CSocket and AsyncServer tests.addTest(unittest.makeSuite(sockettestcase.Socket)) # Misc helpers @@ -212,5 +213,8 @@ class LogCaptureTestCase(unittest.TestCase): def _is_logged(self, s): return s in self._log.getvalue() + def getLog(self): + return self._log.getvalue() + def printLog(self): print(self._log.getvalue()) From f1d8272693389f977e4b170e6a42fdb4001dc64e Mon Sep 17 00:00:00 2001 From: sebres Date: Mon, 20 Oct 2014 01:01:31 +0200 Subject: [PATCH 39/60] bug fix (in master): option 'dbpurgeage' was never set (default always) by start of fail2ban, because of invalid sorting of options ('dbfile' should be always set before other database options) / python3 compatibility fix --- fail2ban/client/fail2banreader.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/fail2ban/client/fail2banreader.py b/fail2ban/client/fail2banreader.py index a151ebf0..ab250ba0 100644 --- a/fail2ban/client/fail2banreader.py +++ b/fail2ban/client/fail2banreader.py @@ -52,16 +52,12 @@ class Fail2banReader(ConfigWrapper): self.__opts = ConfigWrapper.getOptions(self, "Definition", opts) def convert(self): + order = {"loglevel":0, "logtarget":1, "dbfile":2, "dbpurgeage":3} stream = list() for opt in self.__opts: - if opt == "loglevel": - stream.append(["set", "loglevel", self.__opts[opt]]) - elif opt == "logtarget": - stream.append(["set", "logtarget", self.__opts[opt]]) - elif opt == "dbfile": - stream.append(["set", "dbfile", self.__opts[opt]]) - elif opt == "dbpurgeage": - stream.append(["set", "dbpurgeage", self.__opts[opt]]) + if opt in order: + stream.append((order[opt], ["set", opt, self.__opts[opt]])) # Ensure logtarget/level set first so any db errors are captured - return sorted(stream, reverse=True) + # and dbfile set before all other database options + return [opt[1] for opt in sorted(stream)] From 293a5066d278caec0660c39b31ddfccf04d73628 Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 24 Oct 2014 01:32:04 +0200 Subject: [PATCH 40/60] normalizing time config entries: use time abbreviation (str2seconds) for all time options such 'dbpurgeage', 'bantime', 'findtime', ex.: default '1d' instead '86400'; code review and test case extended; --- config/fail2ban.conf | 2 +- config/jail.conf | 14 +++++++------- fail2ban/client/fail2banreader.py | 2 +- fail2ban/server/database.py | 2 +- fail2ban/server/mytime.py | 3 +++ fail2ban/tests/clientreadertestcase.py | 2 +- fail2ban/tests/databasetestcase.py | 9 +++++++++ 7 files changed, 23 insertions(+), 11 deletions(-) diff --git a/config/fail2ban.conf b/config/fail2ban.conf index 1a22a9e8..a3240ecb 100644 --- a/config/fail2ban.conf +++ b/config/fail2ban.conf @@ -60,4 +60,4 @@ dbfile = /var/lib/fail2ban/fail2ban.sqlite3 # Options: dbpurgeage # Notes.: Sets age at which bans should be purged from the database # Values: [ SECONDS ] Default: 86400 (24hours) -dbpurgeage = 86400 +dbpurgeage = 1d diff --git a/config/jail.conf b/config/jail.conf index db7f7c86..955ba44a 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -18,7 +18,7 @@ # See man 5 jail.conf for details. # # [DEFAULT] -# bantime = 3600 +# bantime = 1h # # [sshd] # enabled = true @@ -50,7 +50,7 @@ before = paths-debian.conf # "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 = 5*60 +#bantime.rndtime = # "bantime.maxtime" is the max number of seconds using the ban time can reach (don't grows further) #bantime.maxtime = @@ -94,11 +94,11 @@ ignoreip = 127.0.0.1/8 ignorecommand = # "bantime" is the number of seconds that a host is banned. -bantime = 600 +bantime = 10m # A host is banned if it has generated "maxretry" during the last "findtime" # seconds. -findtime = 600 +findtime = 10m # "maxretry" is the number of failures before a host get banned. maxretry = 5 @@ -283,7 +283,7 @@ logpath = %(apache_error_log)s # for email addresses. The mail outputs are buffered. port = http,https logpath = %(apache_access_log)s -bantime = 172800 +bantime = 48h maxretry = 1 @@ -695,8 +695,8 @@ maxretry = 5 logpath = /var/log/fail2ban.log port = all protocol = all -bantime = 604800 ; 1 week -findtime = 86400 ; 1 day +bantime = 1w +findtime = 1d maxretry = 5 diff --git a/fail2ban/client/fail2banreader.py b/fail2ban/client/fail2banreader.py index d6d36e11..52065b30 100644 --- a/fail2ban/client/fail2banreader.py +++ b/fail2ban/client/fail2banreader.py @@ -47,7 +47,7 @@ class Fail2banReader(ConfigReader): opts = [["string", "loglevel", "INFO" ], ["string", "logtarget", "STDERR"], ["string", "dbfile", "/var/lib/fail2ban/fail2ban.sqlite3"], - ["int", "dbpurgeage", 86400]] + ["string", "dbpurgeage", "1d"]] self.__opts = ConfigReader.getOptions(self, "Definition", opts) def convert(self): diff --git a/fail2ban/server/database.py b/fail2ban/server/database.py index b3bae41b..ba18fcf4 100644 --- a/fail2ban/server/database.py +++ b/fail2ban/server/database.py @@ -193,7 +193,7 @@ class Fail2BanDb(object): @purgeage.setter def purgeage(self, value): - self._purgeAge = int(value) + self._purgeAge = MyTime.str2seconds(value) @commitandrollback def createDb(self, cur): diff --git a/fail2ban/server/mytime.py b/fail2ban/server/mytime.py index 684a7a0c..55142188 100644 --- a/fail2ban/server/mytime.py +++ b/fail2ban/server/mytime.py @@ -111,6 +111,9 @@ class MyTime: def str2seconds(val): if isinstance(val, (int, long, float, complex)): return val + # replace together standing abbreviations, example '1d12h' -> '1d 12h': + val = re.sub(r"(?i)(?<=[a-z])(\d)", r" \1", val) + # replace abbreviation with expression: for rexp, rpl in ( (r"days?|da|dd?", 24*60*60), (r"week?|wee?|ww?", 7*24*60*60), (r"months?|mon?", (365*3+366)*24*60*60/4/12), (r"years?|yea?|yy?", (365*3+366)*24*60*60/4), diff --git a/fail2ban/tests/clientreadertestcase.py b/fail2ban/tests/clientreadertestcase.py index e4dc2189..3f24f2b5 100644 --- a/fail2ban/tests/clientreadertestcase.py +++ b/fail2ban/tests/clientreadertestcase.py @@ -576,7 +576,7 @@ class JailsReaderTest(LogCaptureTestCase): self.assertEqual(sorted(commands), [['set', 'dbfile', '/var/lib/fail2ban/fail2ban.sqlite3'], - ['set', 'dbpurgeage', 86400], + ['set', 'dbpurgeage', '1d'], ['set', 'loglevel', "INFO"], ['set', 'logtarget', '/var/log/fail2ban.log']]) diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 5a06a301..869e9e8f 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -69,6 +69,15 @@ class DatabaseTest(LogCaptureTestCase): return self.assertEqual(self.dbFilename, self.db.filename) + def testPurgeAge(self): + if Fail2BanDb is None: # pragma: no cover + return + self.assertEqual(self.db.purgeage, 86400) + self.db.purgeage = '1y6mon15d5h30m' + self.assertEqual(self.db.purgeage, 48652200) + self.db.purgeage = '2y 12mon 30d 10h 60m' + self.assertEqual(self.db.purgeage, 48652200*2) + def testCreateInvalidPath(self): if Fail2BanDb is None: # pragma: no cover return From 286ef6aa8781969fb05bc935a3f774d26507b3d1 Mon Sep 17 00:00:00 2001 From: sebres Date: Sun, 26 Oct 2014 22:14:57 +0100 Subject: [PATCH 41/60] code review --- fail2ban/server/actions.py | 2 +- fail2ban/server/observer.py | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 8a1d8ec9..8f7a0652 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -324,7 +324,7 @@ class Actions(JailThread, Mapping): datetime.datetime.fromtimestamp(bendtime).strftime("%Y-%m-%d %H:%M:%S")) # check ban is not too old : if bendtime < MyTime.time(): - logSys.info('[%s] Ignore %s, expiered bantime - %s', self._jail.name, ip, logtime[1]) + logSys.info('[%s] Ignore %s, expired bantime - %s', self._jail.name, ip, logtime[1]) return False else: logtime = ('permanent', 'infinite') diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 4b81b831..0b97e732 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -245,12 +245,13 @@ class ObserverThread(JailThread): 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) - 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 + 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() @@ -269,9 +270,10 @@ class ObserverThread(JailThread): 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 : - self.add_wn('nop') - if self.is_full and self.idle: - self.pulse_notify() + 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 From 595edc8d4698fd7b27630328470903b85220cb8f Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 28 Oct 2014 21:57:16 +0100 Subject: [PATCH 42/60] increase code coverage --- fail2ban/server/datedetector.py | 2 +- fail2ban/server/jail.py | 5 +++-- fail2ban/tests/datedetectortestcase.py | 13 +++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/fail2ban/server/datedetector.py b/fail2ban/server/datedetector.py index ba623f4a..bab6302c 100644 --- a/fail2ban/server/datedetector.py +++ b/fail2ban/server/datedetector.py @@ -189,7 +189,7 @@ class DateDetector(object): logSys.log(logLevel, "Got time %f for \"%r\" using template %s", date[0], date[1].group(), template.name) return date - except ValueError: + except ValueError: # pragma: no cover pass return None finally: diff --git a/fail2ban/server/jail.py b/fail2ban/server/jail.py index 1e1f7dd1..0de5f783 100644 --- a/fail2ban/server/jail.py +++ b/fail2ban/server/jail.py @@ -108,11 +108,12 @@ class Jail: logSys.info("Initiated %r backend" % b) self.__actions = Actions(self) return # we are done - except ImportError, e: + except ImportError, e: # pragma: no cover # Log debug if auto, but error if specific logSys.log( logging.DEBUG if backend == "auto" else logging.ERROR, "Backend %r failed to initialize due to %s" % (b, e)) + # pragma: no cover # log error since runtime error message isn't printed, INVALID COMMAND logSys.error( "Failed to initialize any backend for Jail %r" % self.name) @@ -272,7 +273,7 @@ class Jail: # mark ticked was restored from database - does not put it again into db: ticket.setRestored(True) self.putFailTicket(ticket) - except Exception as e: + except Exception as e: # pragma: no cover logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) #logSys.error('%s', e, exc_info=True) diff --git a/fail2ban/tests/datedetectortestcase.py b/fail2ban/tests/datedetectortestcase.py index fe5448d4..393991ef 100644 --- a/fail2ban/tests/datedetectortestcase.py +++ b/fail2ban/tests/datedetectortestcase.py @@ -29,19 +29,28 @@ import time import datetime from ..server.datedetector import DateDetector +from ..server import datedetector from ..server.datetemplate import DateTemplate -from .utils import setUpMyTime, tearDownMyTime +from .utils import setUpMyTime, tearDownMyTime, LogCaptureTestCase +from ..helpers import getLogger -class DateDetectorTest(unittest.TestCase): +logSys = getLogger("fail2ban") + +class DateDetectorTest(LogCaptureTestCase): def setUp(self): """Call before every test case.""" + LogCaptureTestCase.setUp(self) + self.__old_eff_level = datedetector.logLevel + datedetector.logLevel = logSys.getEffectiveLevel() setUpMyTime() self.__datedetector = DateDetector() self.__datedetector.addDefaultTemplate() def tearDown(self): """Call after every test case.""" + LogCaptureTestCase.tearDown(self) + datedetector.logLevel = self.__old_eff_level tearDownMyTime() def testGetEpochTime(self): From 697da99f8f579a565de0947f2c9b402d5b5a47a6 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 2 Dec 2014 00:56:20 +0100 Subject: [PATCH 43/60] code review and few new test cases --- fail2ban/server/actions.py | 14 ++++---------- fail2ban/server/datetemplate.py | 8 ++++---- fail2ban/server/jail.py | 1 - fail2ban/server/mytime.py | 8 ++++---- fail2ban/server/observer.py | 3 --- fail2ban/tests/misctestcase.py | 18 ++++++++++++++++++ fail2ban/tests/observertestcase.py | 19 +++++++++++-------- fail2ban/tests/utils.py | 1 + 8 files changed, 42 insertions(+), 30 deletions(-) diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index f4a833ca..6daef31c 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -25,7 +25,7 @@ __copyright__ = "Copyright (c) 2004 Cyril Jaquier" __license__ = "GPL" import time, logging -import os, datetime +import os import sys if sys.version_info >= (3, 3): import importlib.machinery @@ -322,21 +322,16 @@ class Actions(JailThread, Mapping): if btime != -1: bendtime = aInfo["time"] + 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.info('[%s] Ignore %s, expired bantime - %s', self._jail.name, ip, logtime[1]) + logSys.info('[%s] Ignore %s, expired bantime', self._jail.name, ip) return False - else: - logtime = ('permanent', 'infinite') if self.__banManager.addBanTicket(bTicket): # 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 (%s # %s -> %s)", self._jail.name, ('' if not bTicket.getRestored() else 'Restore '), - aInfo["ip"], (bTicket.getBanCount() if bTicket.getRestored() else '_'), *logtime) + 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: @@ -349,8 +344,7 @@ class Actions(JailThread, Mapping): exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) return True else: - logSys.notice("[%s] %s already banned (%d # %s -> %s)" % ((self._jail.name, - aInfo["ip"], bTicket.getBanCount()) + logtime)) + logSys.notice("[%s] %s already banned" % (self._jail.name, aInfo["ip"])) return False def __checkUnBan(self): diff --git a/fail2ban/server/datetemplate.py b/fail2ban/server/datetemplate.py index 013a8105..c0346f9f 100644 --- a/fail2ban/server/datetemplate.py +++ b/fail2ban/server/datetemplate.py @@ -98,7 +98,7 @@ class DateTemplate(object): return dateMatch @abstractmethod - def getDate(self, line, dateMatch = None): + def getDate(self, line, dateMatch=None): """Abstract method, which should return the date for a log line This should return the date for a log line, typically taking the @@ -134,7 +134,7 @@ class DateEpoch(DateTemplate): DateTemplate.__init__(self) self.regex = "(?:^|(?P(?<=^\[))|(?P(?<=audit\()))\d{10}(?:\.\d{3,6})?(?(selinux)(?=:\d+\))(?(square)(?=\])))" - def getDate(self, line, dateMatch = None): + def getDate(self, line, dateMatch=None): """Method to return the date for a log line. Parameters @@ -212,7 +212,7 @@ class DatePatternRegex(DateTemplate): def name(self, value): raise NotImplementedError("Name derived from pattern") - def getDate(self, line, dateMatch = None): + def getDate(self, line, dateMatch=None): """Method to return the date for a log line. This uses a custom version of strptime, using the named groups @@ -253,7 +253,7 @@ class DateTai64n(DateTemplate): # yoh: we should not add an additional front anchor self.setRegex("@[0-9a-f]{24}", wordBegin=False) - def getDate(self, line, dateMatch = None): + def getDate(self, line, dateMatch=None): """Method to return the date for a log line. Parameters diff --git a/fail2ban/server/jail.py b/fail2ban/server/jail.py index 0de5f783..a1a9acf3 100644 --- a/fail2ban/server/jail.py +++ b/fail2ban/server/jail.py @@ -275,7 +275,6 @@ class Jail: self.putFailTicket(ticket) except Exception as e: # pragma: no cover logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - #logSys.error('%s', e, exc_info=True) def start(self): """Start the jail, by starting filter and actions threads. diff --git a/fail2ban/server/mytime.py b/fail2ban/server/mytime.py index f675bcf4..43ea4a29 100644 --- a/fail2ban/server/mytime.py +++ b/fail2ban/server/mytime.py @@ -108,9 +108,9 @@ class MyTime: 1year-6mo = 15778800 6 months = 15778800 warn: month is not 30 days, it is a year in seconds / 12, the leap years will be respected also: - >>>> float(Test.str2seconds("1month")) / 60 / 60 / 24 + >>>> float(str2seconds("1month")) / 60 / 60 / 24 30.4375 - >>>> float(Test.str2seconds("1year")) / 60 / 60 / 24 + >>>> float(str2seconds("1year")) / 60 / 60 / 24 365.25 @returns number (calculated seconds from expression "val") @@ -121,9 +121,9 @@ class MyTime: val = re.sub(r"(?i)(?<=[a-z])(\d)", r" \1", val) # replace abbreviation with expression: for rexp, rpl in ( - (r"days?|da|dd?", 24*60*60), (r"week?|wee?|ww?", 7*24*60*60), (r"months?|mon?", (365*3+366)*24*60*60/4/12), + (r"days?|da|dd?", 24*60*60), (r"weeks?|wee?|ww?", 7*24*60*60), (r"months?|mon?", (365*3+366)*24*60*60/4/12), (r"years?|yea?|yy?", (365*3+366)*24*60*60/4), - (r"seconds?|sec?|ss?", 1), (r"minutes?|min?|mm?", 60), (r"hours?|ho|hh?", 60*60), + (r"seconds?|sec?|ss?", 1), (r"minutes?|min?|mm?", 60), (r"hours?|hou?|hh?", 60*60), ): val = re.sub(r"(?i)(?<=[\d\s])(%s)\b" % rexp, "*"+str(rpl), val) val = re.sub(r"(\d)\s+(\d)", r"\1+\2", val); diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index 0b97e732..bc8dbca8 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -389,7 +389,6 @@ class ObserverThread(JailThread): except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - #logSys.error('%s', e, exc_info=True) class BanTimeIncr: @@ -438,7 +437,6 @@ class ObserverThread(JailThread): break except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - #logSys.error('%s', e, exc_info=True) return banTime def banFound(self, ticket, jail, btime): @@ -480,7 +478,6 @@ class ObserverThread(JailThread): jail.database.addBan(jail, ticket) except Exception as e: logSys.error('%s', e, exc_info=logSys.getEffectiveLevel()<=logging.DEBUG) - #logSys.error('%s', e, exc_info=True) # Global observer initial created in server (could be later rewriten via singleton) class _Observers: diff --git a/fail2ban/tests/misctestcase.py b/fail2ban/tests/misctestcase.py index 64b6b65e..2a57b071 100644 --- a/fail2ban/tests/misctestcase.py +++ b/fail2ban/tests/misctestcase.py @@ -34,6 +34,7 @@ from StringIO import StringIO from ..helpers import formatExceptionInfo, mbasename, TraceBack, FormatterWithTraceBack, getLogger from ..server.datetemplate import DatePatternRegex +from ..server.mytime import MyTime class HelpersTest(unittest.TestCase): @@ -211,3 +212,20 @@ class CustomDateFormatsTest(unittest.TestCase): self.assertEqual( date, datetime.datetime(2007, 1, 25, 16, 0)) + +class MyTimeTest(unittest.TestCase): + + def testStr2Seconds(self): + # several formats / write styles: + str2sec = MyTime.str2seconds + self.assertEqual(str2sec('1y6mo30w15d12h35m25s'), 66821725) + self.assertEqual(str2sec('2yy 3mo 4ww 10dd 5hh 30mm 20ss'), 74307620) + self.assertEqual(str2sec('2 years 3 months 4 weeks 10 days 5 hours 30 minutes 20 seconds'), 74307620) + self.assertEqual(str2sec('1 year + 1 month - 1 week + 1 day'), 33669000) + self.assertEqual(str2sec('2 * 0.5 yea + 1*1 mon - 3*1/3 wee + 2/2 day - (2*12 hou 3*20 min 80 sec) '), 33578920.0) + self.assertEqual(str2sec('2*.5y+1*1mo-3*1/3w+2/2d-(2*12h3*20m80s) '), 33578920.0) + self.assertEqual(str2sec('1ye -2mo -3we -4da -5ho -6mi -7se'), 24119633) + # month and year in days : + self.assertEqual(float(str2sec("1 month")) / 60 / 60 / 24, 30.4375) + self.assertEqual(float(str2sec("1 year")) / 60 / 60 / 24, 365.25) + diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py index bc484f07..a858d641 100644 --- a/fail2ban/tests/observertestcase.py +++ b/fail2ban/tests/observertestcase.py @@ -83,7 +83,7 @@ class BanTimeIncr(LogCaptureTestCase): arr = arr[0:multcnt-1] + ([arr[multcnt-2]] * (11-multcnt)) self.assertEqual( [a.calcBanTime(600, i) for i in xrange(1, 11)], - arr + arr ) a.setBanTimeExtra('maxtime', '1d') # change factor : @@ -377,20 +377,20 @@ class BanTimeIncrDB(unittest.TestCase): self.assertEqual(len(restored_tickets), 2) self.assertEqual(restored_tickets[0].getIP(), ip) - # purge remove 1st 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 + # 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 : + # two separate jails : jail1 = DummyJail(backend='polling') jail1.database = self.db self.db.addJail(jail1) @@ -418,14 +418,14 @@ class BanTimeIncrDB(unittest.TestCase): 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: + # 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): + # 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 @@ -477,7 +477,7 @@ class BanTimeIncrDB(unittest.TestCase): obs.add('failureFound', failManager, jail, ticket) obs.wait_empty(5) self.assertEqual(ticket.getBanCount(), 0) - # check still not ban : + # check still not ban : self.assertTrue(not jail.getFailTicket()) # add manually 4th times banned (added to bips - make ip bad): ticket.setBanCount(4) @@ -493,11 +493,14 @@ class BanTimeIncrDB(unittest.TestCase): 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(0.1) + 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.getAttempt(), failManager.getMaxRetry()) @@ -509,7 +512,7 @@ class BanTimeIncrDB(unittest.TestCase): self.assertEqual(ticket2.getBanTime(), 160) self.assertEqual(ticket2.getBanCount(), 5) - # check prolonged in database also : + # 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) diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 5da9c694..95bf312e 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -121,6 +121,7 @@ def gatherTests(regexps=None, no_network=False): tests.addTest(unittest.makeSuite(misctestcase.SetupTest)) tests.addTest(unittest.makeSuite(misctestcase.TestsUtilsTest)) tests.addTest(unittest.makeSuite(misctestcase.CustomDateFormatsTest)) + tests.addTest(unittest.makeSuite(misctestcase.MyTimeTest)) # Database tests.addTest(unittest.makeSuite(databasetestcase.DatabaseTest)) # Observer From 4dbc77dbbb047542c6db565dff527dcf99ec1db6 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 2 Dec 2014 11:57:43 +0100 Subject: [PATCH 44/60] code review, test case extended; --- fail2ban/server/filter.py | 27 ++++++++------- fail2ban/tests/filtertestcase.py | 58 ++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 9428e750..9edf0b69 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -559,7 +559,7 @@ class FileFilter(Filter): # # @param path log file path - def addLogPath(self, path, tail = False): + def addLogPath(self, path, tail=False): if self.containsLogPath(path): logSys.error(path + " already exists") else: @@ -655,7 +655,7 @@ class FileFilter(Filter): # MyTime.time()-self.findTime. When a failure is detected, a FailTicket # is created and is added to the FailManager. - def getFailures(self, filename, startTime = None): + def getFailures(self, filename, startTime=None): container = self.getFileContainer(filename) if container is None: logSys.error("Unable to get failures in " + filename) @@ -673,14 +673,19 @@ class FileFilter(Filter): logSys.exception(e) return False except OSError, e: # pragma: no cover - Requires implemention error in FileContainer to generate - logSys.error("Internal errror in FileContainer open method - please report as a bug to https://github.com/fail2ban/fail2ban/issues") + logSys.error("Internal error in FileContainer open method - please report as a bug to https://github.com/fail2ban/fail2ban/issues") logSys.exception(e) return False # prevent completely read of big files first time (after start of service), initial seek to start time using half-interval search algorithm: if container.getPos() == 0 and startTime is not None: - # startTime = MyTime.time() - self.getFindTime() - self.seekToTime(container, startTime) + try: + # startTime = MyTime.time() - self.getFindTime() + self.seekToTime(container, startTime) + except Exception, e: # pragma: no cover + logSys.error("Error during seek to start time in \"%s\"", filename) + logSys.exception(e) + return False # yoh: has_content is just a bool, so do not expect it to # change -- loop is exited upon break, and is not entered at @@ -717,7 +722,7 @@ class FileFilter(Filter): cntr = 0 unixTime = None lasti = 0 - movecntr = 3 + movecntr = 1 while maxp > minp: i = int(minp + (maxp - minp) / 2) pos = container.seek(i) @@ -726,7 +731,8 @@ class FileFilter(Filter): lncntr = 5; dateTimeMatch = None llen = 0 - i = pos + if lastpos == pos: + i = pos while True: line = container.readline() if not line: @@ -763,6 +769,7 @@ class FileFilter(Filter): lastpos = container.seek(lastFew, False) else: lastpos = container.seek(lastpos, False) + container.setPos(lastpos) if logSys.getEffectiveLevel() <= logging.DEBUG: logSys.debug("Position %s from %s, found time %s (%s) within %s seeks", lastpos, fs, unixTime, (datetime.datetime.fromtimestamp(unixTime).strftime("%Y-%m-%d %H:%M:%S") if unixTime is not None else ''), cntr) @@ -943,10 +950,6 @@ class DNSUtils: logSys.warning("Unable to find a corresponding IP address for %s: %s" % (dns, e)) return list() - except socket.error, e: - logSys.warning("Socket error raised trying to resolve hostname %s: %s" - % (dns, e)) - return list() @staticmethod def searchIP(text): @@ -967,7 +970,7 @@ class DNSUtils: try: socket.inet_aton(s[0]) return True - except socket.error: + except socket.error: # pragma: no cover return False @staticmethod diff --git a/fail2ban/tests/filtertestcase.py b/fail2ban/tests/filtertestcase.py index a462cf80..26f1801a 100644 --- a/fail2ban/tests/filtertestcase.py +++ b/fail2ban/tests/filtertestcase.py @@ -27,7 +27,7 @@ import unittest import getpass import os import sys -import time +import time, datetime import tempfile import uuid @@ -38,7 +38,7 @@ except ImportError: from ..server.jail import Jail from ..server.filterpoll import FilterPoll -from ..server.filter import Filter, FileFilter, DNSUtils +from ..server.filter import Filter, FileFilter, FileContainer, DNSUtils from ..server.failmanager import FailManagerEmpty from ..server.mytime import MyTime from .utils import setUpMyTime, tearDownMyTime, mtimesleep, LogCaptureTestCase @@ -314,6 +314,60 @@ class LogFileFilterPoll(unittest.TestCase): self.assertTrue(self.filter.isModified(LogFileFilterPoll.FILENAME)) self.assertFalse(self.filter.isModified(LogFileFilterPoll.FILENAME)) + def testSeekToTime(self): + fname = tempfile.mktemp(prefix='tmp_fail2ban', suffix='.log') + tm = lambda time: datetime.datetime.fromtimestamp(time).strftime("%Y-%m-%d %H:%M:%S") + time = 1417512352 + f = open(fname, 'w') + fc = FileContainer(fname, self.filter.getLogEncoding()) + fc.open() + fc.setPos(0); self.filter.seekToTime(fc, time) + try: + f.flush() + # empty : + fc.setPos(0); self.filter.seekToTime(fc, time) + self.assertEqual(fc.getPos(), 0) + # one entry with exact time: + f.write("%s [sshd] error: PAM: failure len 1\n" % tm(time)) + f.flush() + fc.setPos(0); self.filter.seekToTime(fc, time) + # one entry with smaller time: + f.seek(0) + f.write("%s [sshd] error: PAM: failure len 1\n" % tm(time - 10)) + f.flush() + fc.setPos(0); self.filter.seekToTime(fc, time) + self.assertEqual(fc.getPos(), 0) + f.write("%s [sshd] error: PAM: failure len 3 2 1\n" % tm(time - 9)) + f.flush() + fc.setPos(0); self.filter.seekToTime(fc, time) + self.assertEqual(fc.getPos(), 0) + # add exact time between: + f.write("%s [sshd] error: PAM: failure\n" % tm(time - 1)) + f.flush() + fc.setPos(0); self.filter.seekToTime(fc, time) + self.assertEqual(fc.getPos(), 110) + # stil one exact line: + f.write("%s [sshd] error: PAM: Authentication failure\n" % tm(time)) + f.write("%s [sshd] error: PAM: failure len 1\n" % tm(time)) + f.flush() + fc.setPos(0); self.filter.seekToTime(fc, time) + self.assertEqual(fc.getPos(), 110) + # add something hereafter: + f.write("%s [sshd] error: PAM: failure len 3 2 1\n" % tm(time + 2)) + f.write("%s [sshd] error: PAM: Authentication failure\n" % tm(time + 3)) + f.flush() + fc.setPos(0); self.filter.seekToTime(fc, time) + self.assertEqual(fc.getPos(), 110) + # add something hereafter: + f.write("%s [sshd] error: PAM: failure\n" % tm(time + 9)) + f.write("%s [sshd] error: PAM: failure len 3 2 1\n" % tm(time + 9)) + f.flush() + fc.setPos(0); self.filter.seekToTime(fc, time) + self.assertEqual(fc.getPos(), 110) + + finally: + fc.close() + _killfile(f, fname) class LogFileMonitor(LogCaptureTestCase): """Few more tests for FilterPoll API From ef7f3230ecc3c07d73074d0889df9cac973d1f06 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 3 Dec 2014 10:54:32 +0100 Subject: [PATCH 45/60] added a test case for upgrade database from v2 (additionally to existing v1); --- fail2ban/tests/databasetestcase.py | 27 +++++++++++++++++++++++++++ fail2ban/tests/files/database_v2.db | Bin 0 -> 16384 bytes 2 files changed, 27 insertions(+) create mode 100644 fail2ban/tests/files/database_v2.db diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 48f8b3b3..0d775320 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -112,6 +112,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: # 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 diff --git a/fail2ban/tests/files/database_v2.db b/fail2ban/tests/files/database_v2.db new file mode 100644 index 0000000000000000000000000000000000000000..8954c8b5a458d535f48a9dce04d4838a8f82faaf GIT binary patch literal 16384 zcmeI2&1)M+6u@_O)+>L+Ngz>|qC+PZC0bCdkF~Y3f}pCkcHPJ_v1LdIMy#Y&w5dK^ z?K-5C23#NdFBJMeltQ709CGO`&_hl=<`N1fJ>-&8FP)iPeXK3T>cOrQMzGrXc=P7X zZ~XR+-g@V5&a@3wZM7S^jTBDc0N_4Dh~u~fzOLe{^O?g1p;N(k&{rN-OmMgU;-`o( z{D;U~c5cJOSvw=Z2wWV2OX579jQA$=%ZsDVW?}@!fB*zM;2r;e%fYu}U=cHc5nu$) zmB3{I2B-J+#;u1&(`cLJSv(uS&d>b+T(L9djKEkBxB`Qq&;11_{y*d3v#~Ib8NmoJ z0wWQ~f~jC+6ZScCUPN~L?n!+ZI*T0Y&B6; zH|qy&<2J(Qo2K3{(&)gfq@&1iZnphRnrd$!SVlXI+O?Ktr(N3?!pLjQa(u8giRk}g z&GHayl#8%t55sZcl6_OYJ1T)8o2(X|zNik{WF?l0CI>AY+|v;5zPk31&E2z&qpPUim{|9`{5Hy^;_*b*24Mqn6$>o78N zDn-chf5ULGc1B?037nb#BftMe@R|d!`9=Om{&)B@{Bz=0%@*Jx@C6X18#ln7t(WQs z`H3Gd>CJ4ZSK?QOJiO^%CZIPtQM!uk}Yuj>-_eqij4kJ|6Bdp4z zU(lm~HibxX1{Jh>1(e^y*KRI{cJmu|ceOAwn#7VyM{Q3atF2{ra|INWWhhE%32gq)@AHUdSTXl*cC05+d?mlfpRXWY=h0 z7*)--W!KH7(WorDO?BO}t7hHUZ&_VyJ4QysXl-j-+gQ(|JKBA!Tt?g4nzpUwSG66~ z!yu!rJj!Z04KrTN?5t+87=i5|OHp%kMDN(xZA4Y|&u^1!Y;!W{P!8j50 zk@(MJmxq5dzxOx|t1tqSOW?W?6#Dlz=)fN&!FEq;U|3ajR(VliO7{+HK^s+CAiUkg!9FA#-0|za&bZ`a!ujki;i< z^p(@2-QKPWQV%1n=+N(@;r9D^8g7Yxa!q@}EuEDNIT>!veK)e&X2WPzv0OKQ zRMBl6eYvxh{|^yX&P?M|js))01(K<)A~}N4(u`+)y-u<^TBD~GIXxpvpZLKpRY9XE zIUSI~tVcn&i;O3g5RsENXGKZ&gRgqSiSA-Y$J}Wh-Ttm3(&8Qm%5y$x#SadrPHb71 zoV$7R9=(J-Js%bOx)2}nXP&>#8TSKs_cxV=C4 lJMF6D|HmAB2aiXcg2^!gjKG8un1cb(c^1sVz>Mqv{{jH^J%0cI literal 0 HcmV?d00001 From 9d4f163e8826fce5182bb7d3be65795e6831fa52 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 29 Dec 2015 14:45:56 +0100 Subject: [PATCH 46/60] code review and minor repair after merge with performance branch (changed naming convention, wrong resolved conflicts, etc) --- ChangeLog | 12 ++++++--- fail2ban/server/filter.py | 2 +- fail2ban/server/filterpoll.py | 1 - fail2ban/server/observer.py | 43 ++++++++++++++++-------------- fail2ban/server/ticket.py | 14 +++++----- fail2ban/tests/databasetestcase.py | 2 +- fail2ban/tests/observertestcase.py | 30 ++++++++++++--------- 7 files changed, 57 insertions(+), 47 deletions(-) diff --git a/ChangeLog b/ChangeLog index a7c09ece..d15d8af6 100644 --- a/ChangeLog +++ b/ChangeLog @@ -8,9 +8,16 @@ Fail2Ban: Changelog ver. 0.9.5 (2015/XX/XXX) - increment ban time ----------- + +- Fixes: + * 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: * increment ban time (+ observer) functionality introduced. Thanks Serg G. Brester (sebres) + * database functionality extended with bad ips. ver. 0.9.4 (2015/XX/XXX) - wanna-be-released ----------- @@ -94,10 +101,6 @@ ver. 0.9.3 (2015/08/01) - lets-all-stay-friends the emails. Adjust to augment the behavior. - Fixes: - * purge database will be executed now (within observer). - * database functionality extended with bad ips. - * restoring currently banned ip after service restart fixed - (now < timeofban + bantime), ignore old log failures (already banned) * reload in interactive mode appends all the jails twice (gh-825) * reload server/jail failed if database used (but was not changed) and some jail active (gh-1072) @@ -241,6 +244,7 @@ ver. 0.9.2 (2015/04/29) - better-quick-now-than-later * Added syslogsocket configuration to fail2ban.conf * Note in the jail.conf for the recidive jail to increase dbpurgeage (gh-964) + ver. 0.9.1 (2014/10/29) - better, faster, stronger ---------- diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index 469a6e33..8c07044f 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -683,7 +683,7 @@ class FileFilter(Filter): # MyTime.time()-self.findTime. When a failure is detected, a FailTicket # is created and is added to the FailManager. - def getFailures(self, filename, startTime=None): + def getFailures(self, filename): log = self.getLog(filename) if log is None: logSys.error("Unable to get failures in " + filename) diff --git a/fail2ban/server/filterpoll.py b/fail2ban/server/filterpoll.py index 747b302d..c7b04970 100644 --- a/fail2ban/server/filterpoll.py +++ b/fail2ban/server/filterpoll.py @@ -58,7 +58,6 @@ class FilterPoll(FileFilter): ## The time of the last modification of the file. self.__prevStats = dict() self.__file404Cnt = dict() - self.__initial = dict() logSys.debug("Created FilterPoll") ## diff --git a/fail2ban/server/observer.py b/fail2ban/server/observer.py index bc8dbca8..52db03e8 100644 --- a/fail2ban/server/observer.py +++ b/fail2ban/server/observer.py @@ -27,10 +27,12 @@ __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__) @@ -55,9 +57,14 @@ class ObserverThread(JailThread): 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): - self.active = False - self.idle = False + # init thread + super(ObserverThread, self).__init__(name='Observer') + # before started - idle: + self.idle = True ## Event queue self._queue_lock = threading.RLock() self._queue = [] @@ -71,8 +78,6 @@ class ObserverThread(JailThread): self._paused = False self.__db = None self.__db_purge_interval = 60*60 - # start thread - super(ObserverThread, self).__init__(name='Observer') # observer is a not main thread: self.daemon = True @@ -167,8 +172,8 @@ class ObserverThread(JailThread): 'db_set': self.db_set, 'db_purge': self.db_purge, # service events of observer self: - 'is_alive' : self.is_alive, - 'is_active': self.is_active, + 'is_alive' : self.isAlive, + 'is_active': self.isActive, 'start': self.start, 'stop': self.stop, 'nop': lambda:(), @@ -208,7 +213,7 @@ class ObserverThread(JailThread): continue else: ## notify event deleted (shutdown) - just sleep a litle bit (waiting for shutdown events, prevent high cpu usage) - time.sleep(0.001) + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) ## stop by shutdown and empty queue : if not self.is_full: break @@ -224,11 +229,11 @@ class ObserverThread(JailThread): self.idle = True return True - def is_alive(self): + def isAlive(self): #logSys.debug("Observer alive...") return True - def is_active(self, fromStr=None): + 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)) @@ -266,7 +271,7 @@ class ObserverThread(JailThread): def wait_empty(self, sleeptime=None): """Wait observer is running and returns if observer has no more events (queue is empty) """ - time.sleep(0.001) + 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 : @@ -277,16 +282,16 @@ class ObserverThread(JailThread): while self.is_full: if sleeptime is not None and MyTime.time() > e: break - time.sleep(0.01) + 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.01) + 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(0.001) + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) if self.idle: return True if sleeptime is not None: @@ -294,7 +299,7 @@ class ObserverThread(JailThread): while not self.idle: if sleeptime is not None and MyTime.time() > e: break - time.sleep(0.01) + time.sleep(ObserverThread.DEFAULT_SLEEP_INTERVAL) return self.idle @property @@ -340,7 +345,7 @@ class ObserverThread(JailThread): Observer will check ip was known (bad) and possibly increase an retry count """ # check jail active : - if not jail.is_alive(): + if not jail.isAlive(): return ip = ticket.getIP() unixTime = ticket.getTime() @@ -371,10 +376,8 @@ class ObserverThread(JailThread): 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 '')) - # remove matches from this ticket, because a ticket was already added by filter self - ticket.setMatches(None) # retryCount-1, because a ticket was already once incremented by filter self - failManager.addFailure(ticket, retryCount - 1, True) + retryCount = failManager.addFailure(ticket, retryCount - 1, True) # after observe we have increased count >= maxretry ... if retryCount >= maxRetry: @@ -384,7 +387,7 @@ class ObserverThread(JailThread): while True: ticket = failManager.toBan(ip) jail.putFailTicket(ticket) - except Exception: + except FailManagerEmpty: failManager.cleanup(MyTime.time()) except Exception as e: @@ -409,7 +412,7 @@ class ObserverThread(JailThread): new ban time. """ # check jail active : - if not jail.is_alive(): + if not jail.isAlive() or not jail.database: return be = jail.getBanTimeExtra() ip = ticket.getIP() diff --git a/fail2ban/server/ticket.py b/fail2ban/server/ticket.py index 50db094b..c2d60aaf 100644 --- a/fail2ban/server/ticket.py +++ b/fail2ban/server/ticket.py @@ -24,19 +24,16 @@ __author__ = "Cyril Jaquier" __copyright__ = "Copyright (c) 2004 Cyril Jaquier" __license__ = "GPL" -import sys - from ..helpers import getLogger from .mytime import MyTime # Gets the instance of the logger. logSys = getLogger(__name__) -RESTORED = 0x01 - class Ticket: + RESTORED = 0x01 def __init__(self, ip=None, time=None, matches=None, ticket=None): """Ticket constructor @@ -60,7 +57,7 @@ class Ticket: def __str__(self): 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._banTime, self._banCount, self._data['failures'], self._data.get('matches', [])) def __repr__(self): @@ -125,10 +122,13 @@ class Ticket: return self._data.get('matches', []) def setRestored(self, value): - self._flags |= RESTORED + if value: + self._flags = Ticket.RESTORED + else: + self._flags &= ~(Ticket.RESTORED) def getRestored(self): - return 1 if self._flags & RESTORED else 0 + return self._flags & Ticket.RESTORED def setData(self, *args, **argv): # if overwrite - set data and filter None values: diff --git a/fail2ban/tests/databasetestcase.py b/fail2ban/tests/databasetestcase.py index 09bf0bf8..80b998c2 100644 --- a/fail2ban/tests/databasetestcase.py +++ b/fail2ban/tests/databasetestcase.py @@ -127,7 +127,7 @@ class DatabaseTest(LogCaptureTestCase): os.remove(self.db._dbBackupFilename) def testUpdateDb2(self): - if Fail2BanDb is None: # pragma: no cover + 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) diff --git a/fail2ban/tests/observertestcase.py b/fail2ban/tests/observertestcase.py index a858d641..e1c29cc9 100644 --- a/fail2ban/tests/observertestcase.py +++ b/fail2ban/tests/observertestcase.py @@ -33,14 +33,14 @@ 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 -try: - from ..server.database import Fail2BanDb -except ImportError: - Fail2BanDb = None + +from .databasetestcase import getFail2BanDb, Fail2BanDb class BanTimeIncr(LogCaptureTestCase): @@ -191,7 +191,7 @@ class BanTimeIncrDB(unittest.TestCase): elif Fail2BanDb is None: return _, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_") - self.db = Fail2BanDb(self.dbFilename) + self.db = getFail2BanDb(self.dbFilename) self.jail = DummyJail() self.jail.database = self.db self.Observer = ObserverThread() @@ -199,13 +199,13 @@ class BanTimeIncrDB(unittest.TestCase): def tearDown(self): """Call after every test case.""" - super(BanTimeIncrDB, self).tearDown() 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; @@ -457,7 +457,7 @@ class BanTimeIncrDB(unittest.TestCase): self.db._purgeAge = -240*60*60 obs.add_named_timer('DB_PURGE', 0.001, 'db_purge') # wait for timer ready - time.sleep(0.025) + obs.wait_idle(0.025) # wait for ready obs.add('nop') obs.wait_empty(5) @@ -498,13 +498,17 @@ class BanTimeIncrDB(unittest.TestCase): ticket2 = jail.getFailTicket() if ticket2: break - time.sleep(0.1) + 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.getAttempt(), failManager.getMaxRetry()) + 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) @@ -568,7 +572,7 @@ class ObserverTest(LogCaptureTestCase): obs = ObserverThread() obs.start() # wait for idle - obs.wait_idle(0.1) + obs.wait_idle(1) # observer will replace test set: o = set(['test']) obs.add('call', o.clear) @@ -582,7 +586,7 @@ class ObserverTest(LogCaptureTestCase): # observer will replace test set, but first after pause ends: obs.add('call', o.clear) obs.add('call', o.add, 'test3') - obs.wait_empty(0.25) + obs.wait_empty(10 * Utils.DEFAULT_SLEEP_TIME) self.assertTrue(obs.is_full) self.assertEqual(o, set(['test2'])) obs.paused = False @@ -590,8 +594,8 @@ class ObserverTest(LogCaptureTestCase): obs.wait_empty(1) self.assertEqual(o, set(['test3'])) - self.assertTrue(obs.is_active()) - self.assertTrue(obs.is_alive()) + self.assertTrue(obs.isActive()) + self.assertTrue(obs.isAlive()) obs.stop() obs = None From b3d4ce291ec29ee25d355082d036a7af8d3343a6 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 29 Dec 2015 18:59:33 +0100 Subject: [PATCH 47/60] start observer together with the server (parametrized to prevent constantly start/stop of observer by addJail in test cases) --- fail2ban/server/server.py | 17 ++++++++++------- fail2ban/tests/servertestcase.py | 16 +++++++++++++++- fail2ban/tests/utils.py | 2 +- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 2586ec73..e5b81db8 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -52,7 +52,7 @@ except ImportError: # pragma: no cover class Server: - def __init__(self, daemon = False): + def __init__(self, daemon=False): self.__loggingLock = Lock() self.__lock = RLock() self.__jails = Jails() @@ -81,7 +81,7 @@ class Server: logSys.debug("Caught signal %d. Flushing logs" % signum) self.flushLogs() - def start(self, sock, pidfile, force = False): + def start(self, sock, pidfile, force=False, observer=True): logSys.info("Starting Fail2ban v%s", version.version) # Install signal handlers @@ -112,6 +112,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: @@ -150,15 +156,10 @@ class Server: self.__loggingLock.release() def addJail(self, name, backend): - # Create an observer if not yet created and start it: - if Observers.Main is None: - Observers.Main = ObserverThread() - Observers.Main.start() # Add jail hereafter: self.__jails.add(name, backend, self.__db) if self.__db is not None: self.__db.addJail(self.__jails[name]) - Observers.Main.db_set(self.__db) def delJail(self, name): if self.__db is not None: @@ -541,6 +542,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/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 873fa00c..dbe80c32 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -71,7 +71,7 @@ class TransmitterBase(unittest.TestCase): 'fail2ban.pid', 'transmitter') os.close(pidfile_fd) self.tmp_files.append(pidfile_name) - self.server.start(sock_name, pidfile_name, force=False) + self.server.start(sock_name, pidfile_name, **self.server_start_args) self.jailName = "TestJail1" self.server.addJail(self.jailName, FAST_BACKEND) @@ -160,6 +160,7 @@ class Transmitter(TransmitterBase): def setUp(self): self.server = TestServer() + self.server_start_args = {'force':False, 'observer':False} super(Transmitter, self).setUp() def testStopServer(self): @@ -795,6 +796,7 @@ class TransmitterLogging(TransmitterBase): self.server.setLogTarget("/dev/null") self.server.setLogLevel("CRITICAL") self.server.setSyslogSocket("auto") + self.server_start_args = {'force':False, 'observer':False} super(TransmitterLogging, self).setUp() def testLogTarget(self): @@ -912,6 +914,18 @@ class TransmitterLogging(TransmitterBase): 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 TransmitterWithObserver(TransmitterBase): + + def setUp(self): + self.server = TestServer() + self.server_start_args = {'force':False, 'observer':True} + super(TransmitterWithObserver, self).setUp() + + def testObserver(self): + pass + + class JailTests(unittest.TestCase): def testLongName(self): diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 54299157..0ba0c7c6 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -167,8 +167,8 @@ 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.TransmitterWithObserver)) tests.addTest(unittest.makeSuite(servertestcase.JailTests)) tests.addTest(unittest.makeSuite(servertestcase.RegexTests)) tests.addTest(unittest.makeSuite(servertestcase.LoggingTests)) From 44490664f5616046eca504aab3f4b78f3fdadf23 Mon Sep 17 00:00:00 2001 From: sebres Date: Tue, 9 Feb 2016 14:23:40 +0100 Subject: [PATCH 48/60] try to start server in foreground # Conflicts: # fail2ban/server/server.py --- bin/fail2ban-client | 117 ++++++++++++++++++++++-------- bin/fail2ban-server | 3 +- fail2ban/client/fail2banreader.py | 9 ++- fail2ban/server/server.py | 18 ++--- 4 files changed, 106 insertions(+), 41 deletions(-) diff --git a/bin/fail2ban-client b/bin/fail2ban-client index 7f3f5639..4a1bab39 100755 --- a/bin/fail2ban-client +++ b/bin/fail2ban-client @@ -54,6 +54,7 @@ class Fail2banClient: PROMPT = "fail2ban> " def __init__(self): + self.__server = None self.__argv = None self.__stream = None self.__configurator = Configurator() @@ -89,13 +90,16 @@ class Fail2banClient: print " -c configuration directory" print " -s socket path" print " -p pidfile path" + print " --loglevel logging level" + print " --logtarget |STDOUT|STDERR|SYSLOG" + print " --syslogsocket auto|file" print " -d dump configuration. For debugging" print " -i interactive mode" print " -v increase verbosity" print " -q decrease verbosity" print " -x force execution of the server (remove socket file)" print " -b start server in background (default)" - print " -f start server in foreground (note that the client forks once itself)" + print " -f start server in foreground" print " -h, --help display this help message" print " -V, --version print the version" print @@ -128,6 +132,8 @@ class Fail2banClient: self.__conf["socket"] = opt[1] elif opt[0] == "-p": self.__conf["pidfile"] = opt[1] + elif opt[0].startswith("--log") or opt[0].startswith("--sys"): + self.__conf[ opt[0][2:] ] = opt[1] elif opt[0] == "-d": self.__conf["dump"] = True elif opt[0] == "-v": @@ -234,24 +240,32 @@ class Fail2banClient: "Directory %s exists but not accessible for writing" % (socket_dir,)) return False - # Start the server - self.__startServerAsync(self.__conf["socket"], - self.__conf["pidfile"], - self.__conf["force"], - self.__conf["background"]) - try: - # Wait for the server to start - self.__waitOnServer() - # Configure the server - self.__processCmd(self.__stream, False) - return True - except ServerExecutionException: - logSys.error("Could not start server. Maybe an old " - "socket file is still present. Try to " - "remove " + self.__conf["socket"] + ". If " - "you used fail2ban-client to start the " - "server, adding the -x option will do it") + + # Check already running + if not self.__conf["force"] and os.path.exists(self.__conf["socket"]): + logSys.error("Fail2ban seems to be in unexpected state (not running but socket exists)") return False + + # Start the server + t = None + if self.__conf["background"]: + # Start server daemon as fork of client process: + self.__startServerAsync() + # Send config stream to server: + return self.__processStartStreamAfterWait() + else: + # In foreground mode we should start server/client communication in other thread: + from threading import Thread + t = Thread(target=Fail2banClient.__processStartStreamAfterWait, args=(self,)) + t.start() + # Start server direct here in main thread: + try: + self.__startServerDirect() + except KeyboardInterrupt: + None + + return True + elif len(cmd) == 1 and cmd[0] == "reload": if self.__ping(): ret = self.__readConfig() @@ -281,12 +295,50 @@ class Fail2banClient: return self.__processCmd([cmd]) + def __processStartStreamAfterWait(self): + try: + # Wait for the server to start + self.__waitOnServer() + # Configure the server + self.__processCmd(self.__stream, False) + except ServerExecutionException: + logSys.error("Could not start server. Maybe an old " + "socket file is still present. Try to " + "remove " + self.__conf["socket"] + ". If " + "you used fail2ban-client to start the " + "server, adding the -x option will do it") + if not self.__conf["background"]: + self.__server.quit() + sys.exit(-1) + return False + return True + + + ## + # Start Fail2Ban server in main thread without fork (foreground). + # + # Start the Fail2ban server in foreground (daemon mode or not). + + def __startServerDirect(self): + from fail2ban.server.server import Server + try: + self.__server = Server(False) + self.__server.start(self.__conf["socket"], + self.__conf["pidfile"], self.__conf["force"], + conf=self.__conf) + except Exception, e: + logSys.exception(e) + if self.__server: + self.__server.quit() + sys.exit(-1) + + ## # Start Fail2Ban server. # # Start the Fail2ban server in daemon mode. - def __startServerAsync(self, socket, pidfile, force = False, background = True): + def __startServerAsync(self): # Forks the current process. pid = os.fork() if pid == 0: @@ -294,18 +346,15 @@ class Fail2banClient: args.append(self.SERVER) # Set the socket path. args.append("-s") - args.append(socket) + args.append(self.__conf["socket"]) # Set the pidfile args.append("-p") - args.append(pidfile) + args.append(self.__conf["pidfile"]) # Force the execution if needed. - if force: + if self.__conf["force"]: args.append("-x") - # Start in foreground mode if requested. - if background: - args.append("-b") - else: - args.append("-f") + # Start in background as requested. + args.append("-b") try: # Use the current directory. @@ -361,7 +410,7 @@ class Fail2banClient: # Reads the command line options. try: cmdOpts = 'hc:s:p:xfbdviqV' - cmdLongOpts = ['help', 'version'] + cmdLongOpts = ['loglevel', 'logtarget', 'syslogsocket', 'help', 'version'] optList, args = getopt.getopt(self.__argv[1:], cmdOpts, cmdLongOpts) except getopt.GetoptError: self.dispUsage() @@ -396,7 +445,17 @@ class Fail2banClient: self.__conf["socket"] = conf["socket"] if self.__conf["pidfile"] is None: self.__conf["pidfile"] = conf["pidfile"] - logSys.info("Using socket file " + self.__conf["socket"]) + if self.__conf.get("logtarget", None) is None: + self.__conf["logtarget"] = conf["logtarget"] + if self.__conf.get("loglevel", None) is None: + self.__conf["loglevel"] = conf["loglevel"] + if self.__conf.get("syslogsocket", None) is None: + self.__conf["syslogsocket"] = conf["syslogsocket"] + + logSys.info("Using socket file %s", self.__conf["socket"]) + + logSys.info("Using pid file %s, [%s] logging to %s", + self.__conf["pidfile"], self.__conf["loglevel"], self.__conf["logtarget"]) if self.__conf["dump"]: ret = self.__readConfig() diff --git a/bin/fail2ban-server b/bin/fail2ban-server index f522f418..0b8b6418 100755 --- a/bin/fail2ban-server +++ b/bin/fail2ban-server @@ -129,7 +129,8 @@ class Fail2banServer: return True except Exception, e: logSys.exception(e) - self.__server.quit() + if self.__server: + self.__server.quit() return False if __name__ == "__main__": diff --git a/fail2ban/client/fail2banreader.py b/fail2ban/client/fail2banreader.py index c55f65ea..b3012c9b 100644 --- a/fail2ban/client/fail2banreader.py +++ b/fail2ban/client/fail2banreader.py @@ -40,8 +40,13 @@ class Fail2banReader(ConfigReader): ConfigReader.read(self, "fail2ban") def getEarlyOptions(self): - opts = [["string", "socket", "/var/run/fail2ban/fail2ban.sock"], - ["string", "pidfile", "/var/run/fail2ban/fail2ban.pid"]] + opts = [ + ["string", "socket", "/var/run/fail2ban/fail2ban.sock"], + ["string", "pidfile", "/var/run/fail2ban/fail2ban.pid"], + ["string", "loglevel", "INFO"], + ["string", "logtarget", "/var/log/fail2ban.log"], + ["string", "syslogsocket", "auto"] + ] return ConfigReader.getOptions(self, "Definition", opts) def getOptions(self): diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 3bdfd71b..923b6ba3 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -67,10 +67,6 @@ class Server: 'FreeBSD': '/var/run/log', 'Linux': '/dev/log', } - self.setSyslogSocket("auto") - # Set logging level - self.setLogLevel("INFO") - self.setLogTarget("STDOUT") def __sigTERMhandler(self, signum, frame): logSys.debug("Caught signal %d. Exiting" % signum) @@ -80,7 +76,12 @@ class Server: logSys.debug("Caught signal %d. Flushing logs" % signum) self.flushLogs() - def start(self, sock, pidfile, force = False): + def start(self, sock, pidfile, force=False, conf={}): + # First set all logging parameters: + self.setSyslogSocket(conf.get("syslogsocket", "auto")) + self.setLogLevel(conf.get("loglevel", "INFO")) + self.setLogTarget(conf.get("logtarget", "STDOUT")) + logSys.info("Starting Fail2ban v%s", version.version) # Install signal handlers @@ -392,8 +393,9 @@ class Server: # @param target the logging target def setLogTarget(self, target): - try: - self.__loggingLock.acquire() + with self.__loggingLock: + if self.__logTarget == target: + return True # set a format which is simpler for console use formatter = logging.Formatter("%(asctime)s %(name)-24s[%(process)d]: %(levelname)-7s %(message)s") if target == "SYSLOG": @@ -461,8 +463,6 @@ class Server: # Sets the logging target. self.__logTarget = target return True - finally: - self.__loggingLock.release() ## # Sets the syslog socket. From 3fda77227efcd8b65b06e2e96b9eada558bfc17a Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 10 Feb 2016 21:00:00 +0100 Subject: [PATCH 49/60] temporary commit (move client/server from bin) --- bin/fail2ban-client => fail2ban/client/fail2banclient.py | 0 bin/fail2ban-server => fail2ban/client/fail2banserver.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename bin/fail2ban-client => fail2ban/client/fail2banclient.py (100%) rename bin/fail2ban-server => fail2ban/client/fail2banserver.py (100%) diff --git a/bin/fail2ban-client b/fail2ban/client/fail2banclient.py similarity index 100% rename from bin/fail2ban-client rename to fail2ban/client/fail2banclient.py diff --git a/bin/fail2ban-server b/fail2ban/client/fail2banserver.py similarity index 100% rename from bin/fail2ban-server rename to fail2ban/client/fail2banserver.py From 4d696d69a035902eadaa2e8449d8ec08cccb359d Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 11 Feb 2016 08:56:12 +0100 Subject: [PATCH 50/60] starting of the server (and client/server communication behavior during start and daemonize) completely rewritten: - client/server functionality moved away from bin and using now the common interface (introduced in fail2bancmdline); - start in foreground fixed; - server can act as client corresponding command line; - command "restart" added: in opposite to "reload" in reality restarts the server (new process); - several client/server bugs during starting process fixed. --- MANIFEST | 4 + bin/fail2ban-client | 37 ++ bin/fail2ban-server | 37 ++ fail2ban/client/beautifier.py | 2 + fail2ban/client/fail2banclient.py | 578 +++++++++++------------------ fail2ban/client/fail2bancmdline.py | 245 ++++++++++++ fail2ban/client/fail2banserver.py | 234 +++++++----- fail2ban/protocol.py | 6 +- fail2ban/server/asyncserver.py | 39 +- fail2ban/server/server.py | 95 ++--- fail2ban/server/transmitter.py | 2 + 11 files changed, 775 insertions(+), 504 deletions(-) create mode 100755 bin/fail2ban-client create mode 100755 bin/fail2ban-server mode change 100755 => 100644 fail2ban/client/fail2banclient.py create mode 100644 fail2ban/client/fail2bancmdline.py mode change 100755 => 100644 fail2ban/client/fail2banserver.py diff --git a/MANIFEST b/MANIFEST index fb70bb4b..f77caad6 100644 --- a/MANIFEST +++ b/MANIFEST @@ -165,7 +165,11 @@ fail2ban/client/configparserinc.py fail2ban/client/configreader.py fail2ban/client/configurator.py fail2ban/client/csocket.py +fail2ban/client/fail2banclient.py +fail2ban/client/fail2bancmdline.py fail2ban/client/fail2banreader.py +fail2ban/client/fail2banregex.py +fail2ban/client/fail2banserver.py fail2ban/client/filterreader.py fail2ban/client/jailreader.py fail2ban/client/jailsreader.py diff --git a/bin/fail2ban-client b/bin/fail2ban-client new file mode 100755 index 00000000..19e76a98 --- /dev/null +++ b/bin/fail2ban-client @@ -0,0 +1,37 @@ +#!/usr/bin/python +# 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. + +""" +Fail2Ban reads log file that contains password failure report +and bans the corresponding IP addresses using firewall rules. + +This tools starts/stops fail2ban server or does client/server communication, +to change/read parameters of the server or jails. + +""" + +__author__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2014 Yaroslav Halchenko, 2014-2016 Serg G. Brester" +__license__ = "GPL" + +from fail2ban.client.fail2banclient import exec_command_line + +if __name__ == "__main__": + exec_command_line() diff --git a/bin/fail2ban-server b/bin/fail2ban-server new file mode 100755 index 00000000..8e64d865 --- /dev/null +++ b/bin/fail2ban-server @@ -0,0 +1,37 @@ +#!/usr/bin/python +# 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. + +""" +Fail2Ban reads log file that contains password failure report +and bans the corresponding IP addresses using firewall rules. + +This tools starts/stops fail2ban server or does client/server communication, +to change/read parameters of the server or jails. + +""" + +__author__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2014 Yaroslav Halchenko, 2014-2016 Serg G. Brester" +__license__ = "GPL" + +from fail2ban.client.fail2banserver import exec_command_line + +if __name__ == "__main__": + exec_command_line() diff --git a/fail2ban/client/beautifier.py b/fail2ban/client/beautifier.py index 812fbe65..08ff484d 100644 --- a/fail2ban/client/beautifier.py +++ b/fail2ban/client/beautifier.py @@ -68,6 +68,8 @@ class Beautifier: msg = "Added jail " + response elif inC[0] == "flushlogs": msg = "logs: " + response + elif inC[0] == "echo": + msg = ' '.join(msg) elif inC[0:1] == ['status']: if len(inC) > 1: # Display information diff --git a/fail2ban/client/fail2banclient.py b/fail2ban/client/fail2banclient.py old mode 100755 new mode 100644 index 4a1bab39..7adbab95 --- a/fail2ban/client/fail2banclient.py +++ b/fail2ban/client/fail2banclient.py @@ -1,7 +1,7 @@ #!/usr/bin/python # 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 @@ -17,99 +17,38 @@ # 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__ = "Cyril Jaquier" -__copyright__ = "Copyright (c) 2004 Cyril Jaquier" +__author__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2014 Yaroslav Halchenko, 2014-2016 Serg G. Brester" __license__ = "GPL" -import getopt -import logging import os -import pickle -import re import shlex import signal import socket -import string import sys import time -from fail2ban.version import version -from fail2ban.protocol import printFormatted -from fail2ban.client.csocket import CSocket -from fail2ban.client.configurator import Configurator -from fail2ban.client.beautifier import Beautifier -from fail2ban.helpers import getLogger +from threading import Thread -# Gets the instance of the logger. -logSys = getLogger("fail2ban") +from ..version import version +from .csocket import CSocket +from .beautifier import Beautifier +from .fail2bancmdline import Fail2banCmdLine, logSys, exit ## # # @todo This class needs cleanup. -class Fail2banClient: +class Fail2banClient(Fail2banCmdLine, Thread): - SERVER = "fail2ban-server" PROMPT = "fail2ban> " def __init__(self): - self.__server = None - self.__argv = None - self.__stream = None - self.__configurator = Configurator() - self.__conf = dict() - self.__conf["conf"] = "/etc/fail2ban" - self.__conf["dump"] = False - self.__conf["force"] = False - self.__conf["background"] = True - self.__conf["verbose"] = 1 - self.__conf["interactive"] = False - self.__conf["socket"] = None - self.__conf["pidfile"] = None - - def dispVersion(self): - print "Fail2Ban v" + version - print - print "Copyright (c) 2004-2008 Cyril Jaquier, 2008- Fail2Ban Contributors" - print "Copyright of modifications held by their respective authors." - print "Licensed under the GNU General Public License v2 (GPL)." - print - print "Written by Cyril Jaquier ." - print "Many contributions by Yaroslav O. Halchenko ." - - def dispUsage(self): - """ Prints Fail2Ban command line options and exits - """ - print "Usage: "+self.__argv[0]+" [OPTIONS] " - print - print "Fail2Ban v" + version + " reads log file that contains password failure report" - print "and bans the corresponding IP addresses using firewall rules." - print - print "Options:" - print " -c configuration directory" - print " -s socket path" - print " -p pidfile path" - print " --loglevel logging level" - print " --logtarget |STDOUT|STDERR|SYSLOG" - print " --syslogsocket auto|file" - print " -d dump configuration. For debugging" - print " -i interactive mode" - print " -v increase verbosity" - print " -q decrease verbosity" - print " -x force execution of the server (remove socket file)" - print " -b start server in background (default)" - print " -f start server in foreground" - print " -h, --help display this help message" - print " -V, --version print the version" - print - print "Command:" - - # Prints the protocol - printFormatted() - - print - print "Report bugs to https://github.com/fail2ban/fail2ban/issues" + Fail2banCmdLine.__init__(self) + Thread.__init__(self) + self._alive = True + self._server = None + self._beautifier = None def dispInteractive(self): print "Fail2Ban v" + version + " reads log file that contains password failure report" @@ -120,58 +59,32 @@ class Fail2banClient: # Print a new line because we probably come from wait print logSys.warning("Caught signal %d. Exiting" % signum) - sys.exit(-1) - - def __getCmdLineOptions(self, optList): - """ Gets the command line options - """ - for opt in optList: - if opt[0] == "-c": - self.__conf["conf"] = opt[1] - elif opt[0] == "-s": - self.__conf["socket"] = opt[1] - elif opt[0] == "-p": - self.__conf["pidfile"] = opt[1] - elif opt[0].startswith("--log") or opt[0].startswith("--sys"): - self.__conf[ opt[0][2:] ] = opt[1] - elif opt[0] == "-d": - self.__conf["dump"] = True - elif opt[0] == "-v": - self.__conf["verbose"] = self.__conf["verbose"] + 1 - elif opt[0] == "-q": - self.__conf["verbose"] = self.__conf["verbose"] - 1 - elif opt[0] == "-x": - self.__conf["force"] = True - elif opt[0] == "-i": - self.__conf["interactive"] = True - elif opt[0] == "-b": - self.__conf["background"] = True - elif opt[0] == "-f": - self.__conf["background"] = False - elif opt[0] in ["-h", "--help"]: - self.dispUsage() - sys.exit(0) - elif opt[0] in ["-V", "--version"]: - self.dispVersion() - sys.exit(0) + exit(-1) def __ping(self): return self.__processCmd([["ping"]], False) - def __processCmd(self, cmd, showRet = True): + @property + def beautifier(self): + if self._beautifier: + return self._beautifier + self._beautifier = Beautifier() + return self._beautifier + + def __processCmd(self, cmd, showRet=True): client = None try: - beautifier = Beautifier() + beautifier = self.beautifier streamRet = True for c in cmd: beautifier.setInputCmd(c) try: if not client: - client = CSocket(self.__conf["socket"]) + client = CSocket(self._conf["socket"]) ret = client.send(c) if ret[0] == 0: logSys.debug("OK : " + `ret[1]`) - if showRet: + if showRet or c[0] == 'echo': print beautifier.beautify(ret[1]) else: logSys.error("NOK: " + `ret[1].args`) @@ -179,38 +92,126 @@ class Fail2banClient: print beautifier.beautifyError(ret[1]) streamRet = False except socket.error: - if showRet: + if showRet or self._conf["verbose"] > 1: self.__logSocketError() return False except Exception, e: - if showRet: + if showRet or self._conf["verbose"] > 1: logSys.error(e) return False finally: if client: client.close() + if showRet or c[0] == 'echo': + sys.stdout.flush() return streamRet def __logSocketError(self): try: - if os.access(self.__conf["socket"], os.F_OK): + if os.access(self._conf["socket"], os.F_OK): # This doesn't check if path is a socket, # but socket.error should be raised - if os.access(self.__conf["socket"], os.W_OK): + if os.access(self._conf["socket"], os.W_OK): # Permissions look good, but socket.error was raised logSys.error("Unable to contact server. Is it running?") else: logSys.error("Permission denied to socket: %s," - " (you must be root)", self.__conf["socket"]) + " (you must be root)", self._conf["socket"]) else: logSys.error("Failed to access socket path: %s." " Is fail2ban running?", - self.__conf["socket"]) + self._conf["socket"]) except Exception as e: logSys.error("Exception while checking socket access: %s", - self.__conf["socket"]) + self._conf["socket"]) logSys.error(e) + ## + def __prepareStartServer(self): + if self.__ping(): + logSys.error("Server already running") + return None + + # Read the config + ret, stream = self.readConfig() + # Do not continue if configuration is not 100% valid + if not ret: + return None + + # verify that directory for the socket file exists + socket_dir = os.path.dirname(self._conf["socket"]) + if not os.path.exists(socket_dir): + logSys.error( + "There is no directory %s to contain the socket file %s." + % (socket_dir, self._conf["socket"])) + return None + if not os.access(socket_dir, os.W_OK | os.X_OK): + logSys.error( + "Directory %s exists but not accessible for writing" + % (socket_dir,)) + return None + + # Check already running + if not self._conf["force"] and os.path.exists(self._conf["socket"]): + logSys.error("Fail2ban seems to be in unexpected state (not running but the socket exists)") + return None + + stream.append(['echo', 'Server ready']) + return stream + + ## + def __startServer(self, background=True): + from .fail2banserver import Fail2banServer + stream = self.__prepareStartServer() + self._alive = True + if not stream: + return False + # Start the server or just initialize started one: + try: + if background: + # Start server daemon as fork of client process: + Fail2banServer.startServerAsync(self._conf) + # Send config stream to server: + if not self.__processStartStreamAfterWait(stream, False): + return False + else: + # In foreground mode we should make server/client communication in different threads: + Thread(target=Fail2banClient.__processStartStreamAfterWait, args=(self, stream, False)).start() + # Mark current (main) thread as daemon: + self.setDaemon(True) + # Start server direct here in main thread (not fork): + self._server = Fail2banServer.startServerDirect(self._conf, False) + + except Exception as e: + print + logSys.error("Exception while starting server foreground") + logSys.error(e) + finally: + self._alive = False + + return True + + ## + def configureServer(self, async=True, phase=None): + # if asynchron start this operation in the new thread: + if async: + return Thread(target=Fail2banClient.configureServer, args=(self, False, phase)).start() + # prepare: read config, check configuration is valid, etc.: + if phase is not None: + phase['start'] = True + logSys.debug('-- client phase %s', phase) + stream = self.__prepareStartServer() + if phase is not None: + phase['ready'] = phase['start'] = (True if stream else False) + logSys.debug('-- client phase %s', phase) + if not stream: + return False + # configure server with config stream: + ret = self.__processStartStreamAfterWait(stream, False) + if phase is not None: + phase['done'] = ret + return ret + ## # Process a command line. # @@ -219,251 +220,101 @@ class Fail2banClient: def __processCommand(self, cmd): if len(cmd) == 1 and cmd[0] == "start": - if self.__ping(): - logSys.error("Server already running") + + ret = self.__startServer(self._conf["background"]) + if not ret: return False - else: - # Read the config - ret = self.__readConfig() - # Do not continue if configuration is not 100% valid - if not ret: - return False - # verify that directory for the socket file exists - socket_dir = os.path.dirname(self.__conf["socket"]) - if not os.path.exists(socket_dir): - logSys.error( - "There is no directory %s to contain the socket file %s." - % (socket_dir, self.__conf["socket"])) - return False - if not os.access(socket_dir, os.W_OK | os.X_OK): - logSys.error( - "Directory %s exists but not accessible for writing" - % (socket_dir,)) - return False + return ret - # Check already running - if not self.__conf["force"] and os.path.exists(self.__conf["socket"]): - logSys.error("Fail2ban seems to be in unexpected state (not running but socket exists)") - return False + elif len(cmd) == 1 and cmd[0] == "restart": - # Start the server - t = None - if self.__conf["background"]: - # Start server daemon as fork of client process: - self.__startServerAsync() - # Send config stream to server: - return self.__processStartStreamAfterWait() + if self._conf.get("interactive", False): + print(' ## stop ... ') + self.__processCommand(['stop']) + self.__waitOnServer(False) + # in interactive mode reset config, to make full-reload if there something changed: + if self._conf.get("interactive", False): + print(' ## load configuration ... ') + self.resetConf() + ret = self.initCmdLine(self._argv) + if ret is not None: + return ret + if self._conf.get("interactive", False): + print(' ## start ... ') + return self.__processCommand(['start']) + + elif len(cmd) >= 1 and cmd[0] == "reload": + if self.__ping(): + if len(cmd) == 1: + jail = 'all' + ret, stream = self.readConfig() else: - # In foreground mode we should start server/client communication in other thread: - from threading import Thread - t = Thread(target=Fail2banClient.__processStartStreamAfterWait, args=(self,)) - t.start() - # Start server direct here in main thread: - try: - self.__startServerDirect() - except KeyboardInterrupt: - None - - return True - - elif len(cmd) == 1 and cmd[0] == "reload": - if self.__ping(): - ret = self.__readConfig() - # Do not continue if configuration is not 100% valid - if not ret: - return False - self.__processCmd([['stop', 'all']], False) - # Configure the server - return self.__processCmd(self.__stream, False) - else: - logSys.error("Could not find server") - return False - elif len(cmd) == 2 and cmd[0] == "reload": - if self.__ping(): - jail = cmd[1] - ret = self.__readConfig(jail) + jail = cmd[1] + ret, stream = self.readConfig(jail) # Do not continue if configuration is not 100% valid if not ret: return False self.__processCmd([['stop', jail]], False) # Configure the server - return self.__processCmd(self.__stream, False) + return self.__processCmd(stream, True) else: logSys.error("Could not find server") return False + else: return self.__processCmd([cmd]) - def __processStartStreamAfterWait(self): + def __processStartStreamAfterWait(self, *args): try: # Wait for the server to start self.__waitOnServer() - # Configure the server - self.__processCmd(self.__stream, False) + # Configure the server + self.__processCmd(*args) except ServerExecutionException: logSys.error("Could not start server. Maybe an old " "socket file is still present. Try to " - "remove " + self.__conf["socket"] + ". If " + "remove " + self._conf["socket"] + ". If " "you used fail2ban-client to start the " "server, adding the -x option will do it") - if not self.__conf["background"]: - self.__server.quit() - sys.exit(-1) + if self._server: + self._server.quit() + exit(-1) return False return True - - ## - # Start Fail2Ban server in main thread without fork (foreground). - # - # Start the Fail2ban server in foreground (daemon mode or not). - - def __startServerDirect(self): - from fail2ban.server.server import Server - try: - self.__server = Server(False) - self.__server.start(self.__conf["socket"], - self.__conf["pidfile"], self.__conf["force"], - conf=self.__conf) - except Exception, e: - logSys.exception(e) - if self.__server: - self.__server.quit() - sys.exit(-1) - - - ## - # Start Fail2Ban server. - # - # Start the Fail2ban server in daemon mode. - - def __startServerAsync(self): - # Forks the current process. - pid = os.fork() - if pid == 0: - args = list() - args.append(self.SERVER) - # Set the socket path. - args.append("-s") - args.append(self.__conf["socket"]) - # Set the pidfile - args.append("-p") - args.append(self.__conf["pidfile"]) - # Force the execution if needed. - if self.__conf["force"]: - args.append("-x") - # Start in background as requested. - args.append("-b") - - try: - # Use the current directory. - exe = os.path.abspath(os.path.join(sys.path[0], self.SERVER)) - logSys.debug("Starting %r with args %r" % (exe, args)) - os.execv(exe, args) - except OSError: - try: - # Use the PATH env. - logSys.warning("Initial start attempt failed. Starting %r with the same args" % (self.SERVER,)) - os.execvp(self.SERVER, args) - except OSError: - logSys.error("Could not start %s" % self.SERVER) - os.exit(-1) - - def __waitOnServer(self): - # Wait for the server to start - cnt = 0 - if self.__conf["verbose"] > 1: - pos = 0 - delta = 1 - mask = "[ ]" - while not self.__ping(): - # Wonderful visual :) - if self.__conf["verbose"] > 1: - pos += delta - sys.stdout.write("\rINFO " + mask[:pos] + '#' + mask[pos+1:] + - " Waiting on the server...") - sys.stdout.flush() - if pos > len(mask)-3: - delta = -1 - elif pos < 2: - delta = 1 - # The server has 30 seconds to start. - if cnt >= 300: - if self.__conf["verbose"] > 1: - sys.stdout.write('\n') - raise ServerExecutionException("Failed to start server") - time.sleep(0.1) - cnt += 1 - if self.__conf["verbose"] > 1: - sys.stdout.write('\n') - + def __waitOnServer(self, alive=True, maxtime=30): + # Wait for the server to start (the server has 30 seconds to answer ping) + starttime = time.time() + with VisualWait(self._conf["verbose"]) as vis: + while self._alive and not self.__ping() == alive or ( + not alive and os.path.exists(self._conf["socket"]) + ): + now = time.time() + # Wonderful visual :) + if now > starttime + 1: + vis.heartbeat() + # f end time reached: + if now - starttime >= maxtime: + raise ServerExecutionException("Failed to start server") + time.sleep(0.1) def start(self, argv): - # Command line options - self.__argv = argv - # Install signal handlers signal.signal(signal.SIGTERM, self.__sigTERMhandler) signal.signal(signal.SIGINT, self.__sigTERMhandler) - # Reads the command line options. - try: - cmdOpts = 'hc:s:p:xfbdviqV' - cmdLongOpts = ['loglevel', 'logtarget', 'syslogsocket', 'help', 'version'] - optList, args = getopt.getopt(self.__argv[1:], cmdOpts, cmdLongOpts) - except getopt.GetoptError: - self.dispUsage() - return False + # Command line options + if self._argv is None: + ret = self.initCmdLine(argv) + if ret is not None: + return ret - self.__getCmdLineOptions(optList) - - verbose = self.__conf["verbose"] - if verbose <= 0: - logSys.setLevel(logging.ERROR) - elif verbose == 1: - logSys.setLevel(logging.WARNING) - elif verbose == 2: - logSys.setLevel(logging.INFO) - else: - logSys.setLevel(logging.DEBUG) - # Add the default logging handler to dump to stderr - logout = logging.StreamHandler(sys.stderr) - # set a format which is simpler for console use - formatter = logging.Formatter('%(levelname)-6s %(message)s') - # tell the handler to use this format - logout.setFormatter(formatter) - logSys.addHandler(logout) - - # Set the configuration path - self.__configurator.setBaseDir(self.__conf["conf"]) - - # Set socket path - self.__configurator.readEarly() - conf = self.__configurator.getEarlyOptions() - if self.__conf["socket"] is None: - self.__conf["socket"] = conf["socket"] - if self.__conf["pidfile"] is None: - self.__conf["pidfile"] = conf["pidfile"] - if self.__conf.get("logtarget", None) is None: - self.__conf["logtarget"] = conf["logtarget"] - if self.__conf.get("loglevel", None) is None: - self.__conf["loglevel"] = conf["loglevel"] - if self.__conf.get("syslogsocket", None) is None: - self.__conf["syslogsocket"] = conf["syslogsocket"] - - logSys.info("Using socket file %s", self.__conf["socket"]) - - logSys.info("Using pid file %s, [%s] logging to %s", - self.__conf["pidfile"], self.__conf["loglevel"], self.__conf["logtarget"]) - - if self.__conf["dump"]: - ret = self.__readConfig() - self.dumpConfig(self.__stream) - return ret + # Commands + args = self._args # Interactive mode - if self.__conf["interactive"]: + if self._conf.get("interactive", False): try: import readline except ImportError: @@ -498,35 +349,56 @@ class Fail2banClient: return False return self.__processCommand(args) - def __readConfig(self, jail=None): - # Read the configuration - # TODO: get away from stew of return codes and exception - # handling -- handle via exceptions - try: - self.__configurator.Reload() - self.__configurator.readAll() - ret = self.__configurator.getOptions(jail) - self.__configurator.convertToProtocol() - self.__stream = self.__configurator.getConfigStream() - except Exception, e: - logSys.error("Failed during configuration: %s" % e) - ret = False - return ret - - @staticmethod - def dumpConfig(cmd): - for c in cmd: - print c - return True - class ServerExecutionException(Exception): pass -if __name__ == "__main__": # pragma: no cover - can't test main + +## +# Wonderful visual :) +# + +class _VisualWait: + pos = 0 + delta = 1 + maxpos = 10 + def __enter__(self): + return self + def __exit__(self, *args): + if self.pos: + sys.stdout.write('\r'+(' '*(35+self.maxpos))+'\r') + sys.stdout.flush() + def heartbeat(self): + if not self.pos: + sys.stdout.write("\nINFO [#" + (' '*self.maxpos) + "] Waiting on the server...\r\x1b[8C") + self.pos += self.delta + if self.delta > 0: + s = " #\x1b[1D" if self.pos > 1 else "# \x1b[2D" + else: + s = "\x1b[1D# \x1b[2D" + sys.stdout.write(s) + sys.stdout.flush() + if self.pos > self.maxpos: + self.delta = -1 + elif self.pos < 2: + self.delta = 1 +class _NotVisualWait: + def __enter__(self): + return self + def __exit__(self, *args): + pass + def heartbeat(self): + pass + +def VisualWait(verbose): + return _VisualWait() if verbose > 1 else _NotVisualWait() + + +def exec_command_line(): # pragma: no cover - can't test main client = Fail2banClient() # Exit with correct return value if client.start(sys.argv): - sys.exit(0) + exit(0) else: - sys.exit(-1) + exit(-1) + diff --git a/fail2ban/client/fail2bancmdline.py b/fail2ban/client/fail2bancmdline.py new file mode 100644 index 00000000..3628f695 --- /dev/null +++ b/fail2ban/client/fail2bancmdline.py @@ -0,0 +1,245 @@ +#!/usr/bin/python +# 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__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2014 Yaroslav Halchenko, 2014-2016 Serg G. Brester" +__license__ = "GPL" + +import getopt +import logging +import os +import sys + +from ..version import version +from ..protocol import printFormatted +from ..helpers import getLogger + +# Gets the instance of the logger. +logSys = getLogger("fail2ban") + +CONFIG_PARAMS = ("socket", "pidfile", "logtarget", "loglevel", "syslogsocket",) + + +class Fail2banCmdLine(): + + def __init__(self): + self._argv = self._args = None + self._configurator = None + self.resetConf() + + def resetConf(self): + self._conf = { + "async": False, + "conf": "/etc/fail2ban", + "force": False, + "background": True, + "verbose": 1, + "socket": None, + "pidfile": None + } + + @property + def configurator(self): + if self._configurator: + return self._configurator + # New configurator + from .configurator import Configurator + self._configurator = Configurator() + # Set the configuration path + self._configurator.setBaseDir(self._conf["conf"]) + return self._configurator + + + def applyMembers(self, obj): + for o in obj.__dict__: + self.__dict__[o] = obj.__dict__[o] + + def dispVersion(self): + print "Fail2Ban v" + version + print + print "Copyright (c) 2004-2008 Cyril Jaquier, 2008- Fail2Ban Contributors" + print "Copyright of modifications held by their respective authors." + print "Licensed under the GNU General Public License v2 (GPL)." + print + print "Written by Cyril Jaquier ." + print "Many contributions by Yaroslav O. Halchenko ." + + def dispUsage(self): + """ Prints Fail2Ban command line options and exits + """ + caller = os.path.basename(self._argv[0]) + print "Usage: "+caller+" [OPTIONS]" + (" " if not caller.endswith('server') else "") + print + print "Fail2Ban v" + version + " reads log file that contains password failure report" + print "and bans the corresponding IP addresses using firewall rules." + print + print "Options:" + print " -c configuration directory" + print " -s socket path" + print " -p pidfile path" + print " --loglevel logging level" + print " --logtarget |STDOUT|STDERR|SYSLOG" + print " --syslogsocket auto|" + print " -d dump configuration. For debugging" + print " -i interactive mode" + print " -v increase verbosity" + print " -q decrease verbosity" + print " -x force execution of the server (remove socket file)" + print " -b start server in background (default)" + print " -f start server in foreground" + print " --async start server in async mode (for internal usage only, don't read configuration)" + print " -h, --help display this help message" + print " -V, --version print the version" + + if not caller.endswith('server'): + print + print "Command:" + # Prints the protocol + printFormatted() + + print + print "Report bugs to https://github.com/fail2ban/fail2ban/issues" + + def __getCmdLineOptions(self, optList): + """ Gets the command line options + """ + for opt in optList: + o = opt[0] + if o == "--async": + self._conf["async"] = True + if o == "-c": + self._conf["conf"] = opt[1] + elif o == "-s": + self._conf["socket"] = opt[1] + elif o == "-p": + self._conf["pidfile"] = opt[1] + elif o.startswith("--log") or o.startswith("--sys"): + self._conf[ o[2:] ] = opt[1] + elif o == "-d": + self._conf["dump"] = True + elif o == "-v": + self._conf["verbose"] += 1 + elif o == "-q": + self._conf["verbose"] -= 1 + elif o == "-x": + self._conf["force"] = True + elif o == "-i": + self._conf["interactive"] = True + elif o == "-b": + self._conf["background"] = True + elif o == "-f": + self._conf["background"] = False + elif o in ["-h", "--help"]: + self.dispUsage() + exit(0) + elif o in ["-V", "--version"]: + self.dispVersion() + exit(0) + + def initCmdLine(self, argv): + # First time? + initial = (self._argv is None) + + # Command line options + self._argv = argv + + # Reads the command line options. + try: + cmdOpts = 'hc:s:p:xfbdviqV' + cmdLongOpts = ['loglevel=', 'logtarget=', 'syslogsocket=', 'async', 'help', 'version'] + optList, self._args = getopt.getopt(self._argv[1:], cmdOpts, cmdLongOpts) + except getopt.GetoptError: + self.dispUsage() + exit(-1) + + self.__getCmdLineOptions(optList) + + if initial: + verbose = self._conf["verbose"] + if verbose <= 0: + logSys.setLevel(logging.ERROR) + elif verbose == 1: + logSys.setLevel(logging.WARNING) + elif verbose == 2: + logSys.setLevel(logging.INFO) + else: + logSys.setLevel(logging.DEBUG) + # Add the default logging handler to dump to stderr + logout = logging.StreamHandler(sys.stderr) + # set a format which is simpler for console use + formatter = logging.Formatter('%(levelname)-6s %(message)s') + # tell the handler to use this format + logout.setFormatter(formatter) + logSys.addHandler(logout) + + # Set expected parameters (like socket, pidfile, etc) from configuration, + # if those not yet specified, in which read configuration only if needed here: + conf = None + for o in CONFIG_PARAMS: + if self._conf.get(o, None) is None: + if not conf: + self.configurator.readEarly() + conf = self.configurator.getEarlyOptions() + self._conf[o] = conf[o] + + logSys.info("Using socket file %s", self._conf["socket"]) + + logSys.info("Using pid file %s, [%s] logging to %s", + self._conf["pidfile"], self._conf["loglevel"], self._conf["logtarget"]) + + if self._conf.get("dump", False): + ret, stream = self.readConfig() + self.dumpConfig(stream) + return ret + + # Nothing to do here, process in client/server + return None + + def readConfig(self, jail=None): + # Read the configuration + # TODO: get away from stew of return codes and exception + # handling -- handle via exceptions + stream = None + try: + self.configurator.Reload() + self.configurator.readAll() + ret = self.configurator.getOptions(jail) + self.configurator.convertToProtocol() + stream = self.configurator.getConfigStream() + except Exception, e: + logSys.error("Failed during configuration: %s" % e) + ret = False + return ret, stream + + @staticmethod + def dumpConfig(cmd): + for c in cmd: + print c + return True + + @staticmethod + def exit(code=0): + logSys.debug("Exit with code %s", code) + if os._exit: + os._exit(code) + else: + sys.exit(code) + +# global exit handler: +exit = Fail2banCmdLine.exit \ No newline at end of file diff --git a/fail2ban/client/fail2banserver.py b/fail2ban/client/fail2banserver.py old mode 100755 new mode 100644 index 0b8b6418..6c1dd694 --- a/fail2ban/client/fail2banserver.py +++ b/fail2ban/client/fail2banserver.py @@ -1,7 +1,7 @@ #!/usr/bin/python # 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 @@ -17,125 +17,171 @@ # 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__ = "Cyril Jaquier" -__copyright__ = "Copyright (c) 2004 Cyril Jaquier" +__author__ = "Fail2Ban Developers" +__copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2014 Yaroslav Halchenko, 2014-2016 Serg G. Brester" __license__ = "GPL" -import getopt import os import sys -from fail2ban.version import version -from fail2ban.server.server import Server -from fail2ban.helpers import getLogger - -# Gets the instance of the logger. -logSys = getLogger("fail2ban") +from ..version import version +from ..server.server import Server, ServerDaemonize +from ..server.utils import Utils +from .fail2bancmdline import Fail2banCmdLine, logSys, exit +SERVER = "fail2ban-server" ## # \mainpage Fail2Ban # # \section Introduction # -# Fail2ban is designed to protect your server against brute force attacks. -# Its first goal was to protect a SSH server. +class Fail2banServer(Fail2banCmdLine): -class Fail2banServer: + # def __init__(self): + # Fail2banCmdLine.__init__(self) - def __init__(self): - self.__server = None - self.__argv = None - self.__conf = dict() - self.__conf["background"] = True - self.__conf["force"] = False - self.__conf["socket"] = "/var/run/fail2ban/fail2ban.sock" - self.__conf["pidfile"] = "/var/run/fail2ban/fail2ban.pid" + ## + # Start Fail2Ban server in main thread without fork (foreground). + # + # Start the Fail2ban server in foreground (daemon mode or not). - def dispVersion(self): - print "Fail2Ban v" + version - print - print "Copyright (c) 2004-2008 Cyril Jaquier, 2008- Fail2Ban Contributors" - print "Copyright of modifications held by their respective authors." - print "Licensed under the GNU General Public License v2 (GPL)." - print - print "Written by Cyril Jaquier ." - print "Many contributions by Yaroslav O. Halchenko ." + @staticmethod + def startServerDirect(conf, daemon=True): + server = None + try: + # Start it in foreground (current thread, not new process), + # server object will internally fork self if daemon is True + server = Server(daemon) + server.start(conf["socket"], + conf["pidfile"], conf["force"], + conf=conf) + except ServerDaemonize: + pass + except Exception, e: + logSys.exception(e) + if server: + server.quit() + exit(-1) - def dispUsage(self): - """ Prints Fail2Ban command line options and exits - """ - print "Usage: "+self.__argv[0]+" [OPTIONS]" - print - print "Fail2Ban v" + version + " reads log file that contains password failure report" - print "and bans the corresponding IP addresses using firewall rules." - print - print "Only use this command for debugging purpose. Start the server with" - print "fail2ban-client instead. The default behaviour is to start the server" - print "in background." - print - print "Options:" - print " -b start in background" - print " -f start in foreground" - print " -s socket path" - print " -p pidfile path" - print " -x force execution of the server (remove socket file)" - print " -h, --help display this help message" - print " -V, --version print the version" - print - print "Report bugs to https://github.com/fail2ban/fail2ban/issues" + return server - def __getCmdLineOptions(self, optList): - """ Gets the command line options - """ - for opt in optList: - if opt[0] == "-b": - self.__conf["background"] = True - if opt[0] == "-f": - self.__conf["background"] = False - if opt[0] == "-s": - self.__conf["socket"] = opt[1] - if opt[0] == "-p": - self.__conf["pidfile"] = opt[1] - if opt[0] == "-x": - self.__conf["force"] = True - if opt[0] in ["-h", "--help"]: - self.dispUsage() - sys.exit(0) - if opt[0] in ["-V", "--version"]: - self.dispVersion() - sys.exit(0) + ## + # Start Fail2Ban server. + # + # Start the Fail2ban server in daemon mode (background, start from client). + + @staticmethod + def startServerAsync(conf): + # Forks the current process. + pid = os.fork() + if pid == 0: + args = list() + args.append(SERVER) + # Start async (don't read config) and in background as requested. + args.append("--async") + args.append("-b") + # Set the socket path. + args.append("-s") + args.append(conf["socket"]) + # Set the pidfile + args.append("-p") + args.append(conf["pidfile"]) + # Force the execution if needed. + if conf["force"]: + args.append("-x") + # Logging parameters: + for o in ('loglevel', 'logtarget', 'syslogsocket'): + args.append("--"+o) + args.append(conf[o]) + + try: + # Use the current directory. + exe = os.path.abspath(os.path.join(sys.path[0], SERVER)) + logSys.debug("Starting %r with args %r", exe, args) + os.execv(exe, args) + except OSError: + try: + # Use the PATH env. + logSys.warning("Initial start attempt failed. Starting %r with the same args", SERVER) + os.execvp(SERVER, args) + except OSError: + exit(-1) + + def _Fail2banClient(self): + from .fail2banclient import Fail2banClient + cli = Fail2banClient() + cli.applyMembers(self) + return cli def start(self, argv): # Command line options - self.__argv = argv + ret = self.initCmdLine(argv) + if ret is not None: + return ret - # Reads the command line options. + # Commands + args = self._args + + cli = None + # If client mode - whole processing over client: + if len(args) or self._conf.get("interactive", False): + cli = self._Fail2banClient() + return cli.start(argv) + + # Start the server: + server = None try: - cmdOpts = 'bfs:p:xhV' - cmdLongOpts = ['help', 'version'] - optList, args = getopt.getopt(self.__argv[1:], cmdOpts, cmdLongOpts) - except getopt.GetoptError: - self.dispUsage() - sys.exit(-1) + # async = True, if started from client, should fork, daemonize, etc... + # background = True, if should start in new process, otherwise start in foreground + async = self._conf.get("async", False) + background = self._conf["background"] + # If was started not from the client: + if not async: + # Start new thread with client to read configuration and + # transfer it to the server: + cli = self._Fail2banClient() + phase = dict() + logSys.debug('Configure via async client thread') + cli.configureServer(async=True, phase=phase) + # wait up to 30 seconds, do not continue if configuration is not 100% valid: + Utils.wait_for(lambda: phase.get('ready', None) is not None, 30) + if not phase.get('start', False): + return False - self.__getCmdLineOptions(optList) + # Start server, daemonize it, etc. + if async or not background: + server = Fail2banServer.startServerDirect(self._conf, background) + else: + Fail2banServer.startServerAsync(self._conf) + if cli: + cli._server = server + + # wait for client answer "done": + if not async and cli: + Utils.wait_for(lambda: phase.get('done', None) is not None, 30) + if not phase.get('done', False): + if server: + server.quit() + exit(-1) + logSys.debug('Starting server done') - try: - self.__server = Server(self.__conf["background"]) - self.__server.start(self.__conf["socket"], - self.__conf["pidfile"], - self.__conf["force"]) - return True except Exception, e: logSys.exception(e) - if self.__server: - self.__server.quit() - return False + if server: + server.quit() + exit(-1) -if __name__ == "__main__": + return True + + @staticmethod + def exit(code=0): # pragma: no cover + if code != 0: + logSys.error("Could not start %s", SERVER) + exit(code) + +def exec_command_line(): # pragma: no cover - can't test main server = Fail2banServer() if server.start(sys.argv): - sys.exit(0) + exit(0) else: - sys.exit(-1) + exit(-1) diff --git a/fail2ban/protocol.py b/fail2ban/protocol.py index 5d9fdd65..857d5fa6 100644 --- a/fail2ban/protocol.py +++ b/fail2ban/protocol.py @@ -42,11 +42,13 @@ CSPROTO = dotdict({ protocol = [ ['', "BASIC", ""], ["start", "starts the server and the jails"], -["reload", "reloads the configuration"], +["restart", "restarts the server"], +["reload", "reloads the configuration without restart"], ["reload ", "reloads the jail "], ["stop", "stops all jails and terminate the server"], ["status", "gets the current status of the server"], -["ping", "tests if the server is alive"], +["ping", "tests if the server is alive"], +["echo", "for internal usage, returns back and outputs a given string"], ["help", "return this output"], ["version", "return the server version"], ['', "LOGGING", ""], diff --git a/fail2ban/server/asyncserver.py b/fail2ban/server/asyncserver.py index ad37544a..6454ef1c 100644 --- a/fail2ban/server/asyncserver.py +++ b/fail2ban/server/asyncserver.py @@ -95,6 +95,7 @@ def loop(active, timeout=None, use_poll=False): # Use poll instead of loop, because of recognition of active flag, # because of loop timeout mistake: different in poll and poll2 (sec vs ms), # and to prevent sporadical errors like EBADF 'Bad file descriptor' etc. (see gh-161) + errCount = 0 if timeout is None: timeout = Utils.DEFAULT_SLEEP_TIME poll = asyncore.poll @@ -107,11 +108,20 @@ def loop(active, timeout=None, use_poll=False): while active(): try: poll(timeout) + if errCount: + errCount -= 1 except Exception as e: # pragma: no cover - if e.args[0] in (errno.ENOTCONN, errno.EBADF): # (errno.EBADF, 'Bad file descriptor') - logSys.info('Server connection was closed: %s', str(e)) - else: - logSys.error('Server connection was closed: %s', str(e)) + if not active(): + break + errCount += 1 + if errCount < 20: + if e.args[0] in (errno.ENOTCONN, errno.EBADF): # (errno.EBADF, 'Bad file descriptor') + logSys.info('Server connection was closed: %s', str(e)) + else: + logSys.error('Server connection was closed: %s', str(e)) + elif errCount == 20: + logSys.info('Too many errors - stop logging connection errors') + logSys.exception(e) ## @@ -162,7 +172,7 @@ class AsyncServer(asyncore.dispatcher): logSys.error("Fail2ban seems to be already running") if force: logSys.warning("Forcing execution of the server") - os.remove(sock) + self._remove_sock() else: raise AsyncServerException("Server already running") # Creates the socket. @@ -175,20 +185,22 @@ class AsyncServer(asyncore.dispatcher): AsyncServer.__markCloseOnExec(self.socket) self.listen(1) # Sets the init flag. - self.__init = self.__active = True + self.__init = self.__loop = self.__active = True # Event loop as long as active: - loop(lambda: self.__active) + loop(lambda: self.__loop) + self.__active = False # Cleanup all self.stop() def close(self): if self.__active: + self.__loop = False asyncore.dispatcher.close(self) # Remove socket (file) only if it was created: if self.__init and os.path.exists(self.__sock): logSys.debug("Removed socket file " + self.__sock) - os.remove(self.__sock) + self._remove_sock() logSys.debug("Socket shutdown") self.__active = False @@ -201,6 +213,17 @@ class AsyncServer(asyncore.dispatcher): def isActive(self): return self.__active + + ## + # Safe remove (in multithreaded mode): + + def _remove_sock(self): + try: + os.remove(self.__sock) + except OSError as e: + if e.errno != errno.ENOENT: + raise + ## # Marks socket as close-on-exec to avoid leaking file descriptors when # running actions involving command execution. diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 923b6ba3..72279b2d 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -67,6 +67,11 @@ class Server: 'FreeBSD': '/var/run/log', 'Linux': '/dev/log', } + # todo: remove that, if test cases are fixed + self.setSyslogSocket("auto") + # Set logging level + self.setLogLevel("INFO") + self.setLogTarget("STDOUT") def __sigTERMhandler(self, signum, frame): logSys.debug("Caught signal %d. Exiting" % signum) @@ -77,13 +82,27 @@ class Server: self.flushLogs() def start(self, sock, pidfile, force=False, conf={}): - # First set all logging parameters: - self.setSyslogSocket(conf.get("syslogsocket", "auto")) - self.setLogLevel(conf.get("loglevel", "INFO")) - self.setLogTarget(conf.get("logtarget", "STDOUT")) + # First set the mask to only allow access to owner + os.umask(0077) + # Second daemonize before logging etc, because it will close all handles: + if self.__daemon: # pragma: no cover + logSys.info("Starting in daemon mode") + ret = self.__createDaemon() + if not ret: + logSys.error("Could not create daemon") + raise ServerInitializationError("Could not create daemon") + + # Set all logging parameters (or use default if not specified): + self.setSyslogSocket(conf.get("syslogsocket", self.__syslogSocket)) + self.setLogLevel(conf.get("loglevel", self.__logLevel)) + self.setLogTarget(conf.get("logtarget", self.__logTarget)) + logSys.info("-"*50) logSys.info("Starting Fail2ban v%s", version.version) + if self.__daemon: # pragma: no cover + logSys.info("Daemon started") + # Install signal handlers signal.signal(signal.SIGTERM, self.__sigTERMhandler) signal.signal(signal.SIGINT, self.__sigTERMhandler) @@ -92,17 +111,6 @@ class Server: # Ensure unhandled exceptions are logged sys.excepthook = excepthook - # First set the mask to only allow access to owner - os.umask(0077) - if self.__daemon: # pragma: no cover - logSys.info("Starting in daemon mode") - ret = self.__createDaemon() - if ret: - logSys.info("Daemon started") - else: - logSys.error("Could not create daemon") - raise ServerInitializationError("Could not create daemon") - # Creates a PID file. try: logSys.debug("Creating PID file %s" % pidfile) @@ -139,11 +147,8 @@ class Server: self.stopAllJail() # Only now shutdown the logging. - try: - self.__loggingLock.acquire() + with self.__loggingLock: logging.shutdown() - finally: - self.__loggingLock.release() def addJail(self, name, backend): self.__jails.add(name, backend, self.__db) @@ -362,16 +367,15 @@ class Server: # @param value the level def setLogLevel(self, value): - try: - self.__loggingLock.acquire() - getLogger("fail2ban").setLevel( - getattr(logging, value.upper())) - except AttributeError: - raise ValueError("Invalid log level") - else: - self.__logLevel = value.upper() - finally: - self.__loggingLock.release() + value = value.upper() + with self.__loggingLock: + if self.__logLevel == value: + return + try: + getLogger("fail2ban").setLevel(getattr(logging, value)) + self.__logLevel = value + except AttributeError: + raise ValueError("Invalid log level") ## # Get the logging level. @@ -380,11 +384,8 @@ class Server: # @return the log level def getLogLevel(self): - try: - self.__loggingLock.acquire() + with self.__loggingLock: return self.__logLevel - finally: - self.__loggingLock.release() ## # Sets the logging target. @@ -470,24 +471,21 @@ class Server: # syslogsocket is the full path to the syslog socket # @param syslogsocket the syslog socket path def setSyslogSocket(self, syslogsocket): - self.__syslogSocket = syslogsocket - # Conditionally reload, logtarget depends on socket path when SYSLOG - return self.__logTarget != "SYSLOG"\ - or self.setLogTarget(self.__logTarget) + with self.__loggingLock: + if self.__syslogSocket == syslogsocket: + return True + self.__syslogSocket = syslogsocket + # Conditionally reload, logtarget depends on socket path when SYSLOG + return self.__logTarget != "SYSLOG"\ + or self.setLogTarget(self.__logTarget) def getLogTarget(self): - try: - self.__loggingLock.acquire() + with self.__loggingLock: return self.__logTarget - finally: - self.__loggingLock.release() def getSyslogSocket(self): - try: - self.__loggingLock.acquire() + with self.__loggingLock: return self.__syslogSocket - finally: - self.__loggingLock.release() def flushLogs(self): if self.__logTarget not in ['STDERR', 'STDOUT', 'SYSLOG']: @@ -542,7 +540,7 @@ class Server: # child process, and this makes sure that it is effect even if the parent # terminates quickly. signal.signal(signal.SIGHUP, signal.SIG_IGN) - + try: # Fork a child process so the parent can exit. This will return control # to the command line or shell. This is required so that the new process @@ -583,7 +581,7 @@ class Server: os._exit(0) # Exit parent (the first child) of the second child. else: os._exit(0) # Exit parent of the first child. - + # Close all open files. Try the system configuration variable, SC_OPEN_MAX, # for the maximum number of open files to close. If it doesn't exist, use # the default value (configurable). @@ -615,3 +613,6 @@ class Server: class ServerInitializationError(Exception): pass + +class ServerDaemonize(Exception): + pass \ No newline at end of file diff --git a/fail2ban/server/transmitter.py b/fail2ban/server/transmitter.py index 4c4c32f7..2194f591 100644 --- a/fail2ban/server/transmitter.py +++ b/fail2ban/server/transmitter.py @@ -93,6 +93,8 @@ class Transmitter: name = command[1] self.__server.stopJail(name) return None + elif command[0] == "echo": + return command[1:] elif command[0] == "sleep": value = command[1] time.sleep(float(value)) From f1208777561cfc7cbb3be948fe28d649ad90d433 Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 11 Feb 2016 17:57:23 +0100 Subject: [PATCH 51/60] client/server (bin) test cases introduced, ultimate closes #1121, closes #1139 small code review and fixing of some bugs during client-server communication process (in the test cases); --- bin/fail2ban-client | 4 +- bin/fail2ban-server | 4 +- fail2ban/client/fail2banclient.py | 146 ++++---- fail2ban/client/fail2bancmdline.py | 190 ++++++----- fail2ban/client/fail2banserver.py | 41 ++- fail2ban/protocol.py | 29 +- fail2ban/server/server.py | 46 ++- fail2ban/server/utils.py | 6 +- fail2ban/tests/fail2banclienttestcase.py | 411 +++++++++++++++++++++++ fail2ban/tests/fail2banregextestcase.py | 12 - fail2ban/tests/utils.py | 25 ++ 11 files changed, 710 insertions(+), 204 deletions(-) create mode 100644 fail2ban/tests/fail2banclienttestcase.py diff --git a/bin/fail2ban-client b/bin/fail2ban-client index 19e76a98..f5ae7946 100755 --- a/bin/fail2ban-client +++ b/bin/fail2ban-client @@ -31,7 +31,7 @@ __author__ = "Fail2Ban Developers" __copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2014 Yaroslav Halchenko, 2014-2016 Serg G. Brester" __license__ = "GPL" -from fail2ban.client.fail2banclient import exec_command_line +from fail2ban.client.fail2banclient import exec_command_line, sys if __name__ == "__main__": - exec_command_line() + exec_command_line(sys.argv) diff --git a/bin/fail2ban-server b/bin/fail2ban-server index 8e64d865..ffafabe2 100755 --- a/bin/fail2ban-server +++ b/bin/fail2ban-server @@ -31,7 +31,7 @@ __author__ = "Fail2Ban Developers" __copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2014 Yaroslav Halchenko, 2014-2016 Serg G. Brester" __license__ = "GPL" -from fail2ban.client.fail2banserver import exec_command_line +from fail2ban.client.fail2banserver import exec_command_line, sys if __name__ == "__main__": - exec_command_line() + exec_command_line(sys.argv) diff --git a/fail2ban/client/fail2banclient.py b/fail2ban/client/fail2banclient.py index 7adbab95..736f8fd2 100644 --- a/fail2ban/client/fail2banclient.py +++ b/fail2ban/client/fail2banclient.py @@ -28,12 +28,20 @@ import socket import sys import time +import threading from threading import Thread from ..version import version from .csocket import CSocket from .beautifier import Beautifier -from .fail2bancmdline import Fail2banCmdLine, logSys, exit +from .fail2bancmdline import Fail2banCmdLine, ExitException, logSys, exit, output + +MAX_WAITTIME = 30 + + +def _thread_name(): + return threading.current_thread().__class__.__name__ + ## # @@ -51,13 +59,13 @@ class Fail2banClient(Fail2banCmdLine, Thread): self._beautifier = None def dispInteractive(self): - print "Fail2Ban v" + version + " reads log file that contains password failure report" - print "and bans the corresponding IP addresses using firewall rules." - print + output("Fail2Ban v" + version + " reads log file that contains password failure report") + output("and bans the corresponding IP addresses using firewall rules.") + output("") def __sigTERMhandler(self, signum, frame): # Print a new line because we probably come from wait - print + output("") logSys.warning("Caught signal %d. Exiting" % signum) exit(-1) @@ -85,11 +93,11 @@ class Fail2banClient(Fail2banCmdLine, Thread): if ret[0] == 0: logSys.debug("OK : " + `ret[1]`) if showRet or c[0] == 'echo': - print beautifier.beautify(ret[1]) + output(beautifier.beautify(ret[1])) else: logSys.error("NOK: " + `ret[1].args`) if showRet: - print beautifier.beautifyError(ret[1]) + output(beautifier.beautifyError(ret[1])) streamRet = False except socket.error: if showRet or self._conf["verbose"] > 1: @@ -182,10 +190,13 @@ class Fail2banClient(Fail2banCmdLine, Thread): # Start server direct here in main thread (not fork): self._server = Fail2banServer.startServerDirect(self._conf, False) + except ExitException: + pass except Exception as e: - print - logSys.error("Exception while starting server foreground") + output("") + logSys.error("Exception while starting server " + ("background" if background else "foreground")) logSys.error(e) + return False finally: self._alive = False @@ -229,18 +240,18 @@ class Fail2banClient(Fail2banCmdLine, Thread): elif len(cmd) == 1 and cmd[0] == "restart": if self._conf.get("interactive", False): - print(' ## stop ... ') + output(' ## stop ... ') self.__processCommand(['stop']) self.__waitOnServer(False) # in interactive mode reset config, to make full-reload if there something changed: if self._conf.get("interactive", False): - print(' ## load configuration ... ') + output(' ## load configuration ... ') self.resetConf() ret = self.initCmdLine(self._argv) if ret is not None: return ret if self._conf.get("interactive", False): - print(' ## start ... ') + output(' ## start ... ') return self.__processCommand(['start']) elif len(cmd) >= 1 and cmd[0] == "reload": @@ -283,7 +294,9 @@ class Fail2banClient(Fail2banCmdLine, Thread): return False return True - def __waitOnServer(self, alive=True, maxtime=30): + def __waitOnServer(self, alive=True, maxtime=None): + if maxtime is None: + maxtime = MAX_WAITTIME # Wait for the server to start (the server has 30 seconds to answer ping) starttime = time.time() with VisualWait(self._conf["verbose"]) as vis: @@ -301,53 +314,59 @@ class Fail2banClient(Fail2banCmdLine, Thread): def start(self, argv): # Install signal handlers - signal.signal(signal.SIGTERM, self.__sigTERMhandler) - signal.signal(signal.SIGINT, self.__sigTERMhandler) + _prev_signals = {} + if _thread_name() == '_MainThread': + for s in (signal.SIGTERM, signal.SIGINT): + _prev_signals[s] = signal.getsignal(s) + signal.signal(s, self.__sigTERMhandler) + try: + # Command line options + if self._argv is None: + ret = self.initCmdLine(argv) + if ret is not None: + return ret - # Command line options - if self._argv is None: - ret = self.initCmdLine(argv) - if ret is not None: - return ret + # Commands + args = self._args - # Commands - args = self._args - - # Interactive mode - if self._conf.get("interactive", False): - try: - import readline - except ImportError: - logSys.error("Readline not available") - return False - try: - ret = True - if len(args) > 0: - ret = self.__processCommand(args) - if ret: - readline.parse_and_bind("tab: complete") - self.dispInteractive() - while True: - cmd = raw_input(self.PROMPT) - if cmd == "exit" or cmd == "quit": - # Exit - return True - if cmd == "help": - self.dispUsage() - elif not cmd == "": - try: - self.__processCommand(shlex.split(cmd)) - except Exception, e: - logSys.error(e) - except (EOFError, KeyboardInterrupt): - print - return True - # Single command mode - else: - if len(args) < 1: - self.dispUsage() - return False - return self.__processCommand(args) + # Interactive mode + if self._conf.get("interactive", False): + try: + import readline + except ImportError: + logSys.error("Readline not available") + return False + try: + ret = True + if len(args) > 0: + ret = self.__processCommand(args) + if ret: + readline.parse_and_bind("tab: complete") + self.dispInteractive() + while True: + cmd = raw_input(self.PROMPT) + if cmd == "exit" or cmd == "quit": + # Exit + return True + if cmd == "help": + self.dispUsage() + elif not cmd == "": + try: + self.__processCommand(shlex.split(cmd)) + except Exception, e: + logSys.error(e) + except (EOFError, KeyboardInterrupt): + output("") + return True + # Single command mode + else: + if len(args) < 1: + self.dispUsage() + return False + return self.__processCommand(args) + finally: + for s, sh in _prev_signals.iteritems(): + signal.signal(s, sh) class ServerExecutionException(Exception): @@ -361,7 +380,8 @@ class ServerExecutionException(Exception): class _VisualWait: pos = 0 delta = 1 - maxpos = 10 + def __init__(self, maxpos=10): + self.maxpos = maxpos def __enter__(self): return self def __exit__(self, *args): @@ -390,14 +410,14 @@ class _NotVisualWait: def heartbeat(self): pass -def VisualWait(verbose): - return _VisualWait() if verbose > 1 else _NotVisualWait() +def VisualWait(verbose, *args, **kwargs): + return _VisualWait(*args, **kwargs) if verbose > 1 else _NotVisualWait() -def exec_command_line(): # pragma: no cover - can't test main +def exec_command_line(argv): client = Fail2banClient() # Exit with correct return value - if client.start(sys.argv): + if client.start(argv): exit(0) else: exit(-1) diff --git a/fail2ban/client/fail2bancmdline.py b/fail2ban/client/fail2bancmdline.py index 3628f695..abbf8363 100644 --- a/fail2ban/client/fail2bancmdline.py +++ b/fail2ban/client/fail2bancmdline.py @@ -33,7 +33,12 @@ from ..helpers import getLogger # Gets the instance of the logger. logSys = getLogger("fail2ban") +def output(s): + print(s) + CONFIG_PARAMS = ("socket", "pidfile", "logtarget", "loglevel", "syslogsocket",) +# Used to signal - we are in test cases (ex: prevents change logging params, log capturing, etc) +PRODUCTION = True class Fail2banCmdLine(): @@ -71,50 +76,50 @@ class Fail2banCmdLine(): self.__dict__[o] = obj.__dict__[o] def dispVersion(self): - print "Fail2Ban v" + version - print - print "Copyright (c) 2004-2008 Cyril Jaquier, 2008- Fail2Ban Contributors" - print "Copyright of modifications held by their respective authors." - print "Licensed under the GNU General Public License v2 (GPL)." - print - print "Written by Cyril Jaquier ." - print "Many contributions by Yaroslav O. Halchenko ." + output("Fail2Ban v" + version) + output("") + output("Copyright (c) 2004-2008 Cyril Jaquier, 2008- Fail2Ban Contributors") + output("Copyright of modifications held by their respective authors.") + output("Licensed under the GNU General Public License v2 (GPL).") + output("") + output("Written by Cyril Jaquier .") + output("Many contributions by Yaroslav O. Halchenko .") def dispUsage(self): """ Prints Fail2Ban command line options and exits """ caller = os.path.basename(self._argv[0]) - print "Usage: "+caller+" [OPTIONS]" + (" " if not caller.endswith('server') else "") - print - print "Fail2Ban v" + version + " reads log file that contains password failure report" - print "and bans the corresponding IP addresses using firewall rules." - print - print "Options:" - print " -c configuration directory" - print " -s socket path" - print " -p pidfile path" - print " --loglevel logging level" - print " --logtarget |STDOUT|STDERR|SYSLOG" - print " --syslogsocket auto|" - print " -d dump configuration. For debugging" - print " -i interactive mode" - print " -v increase verbosity" - print " -q decrease verbosity" - print " -x force execution of the server (remove socket file)" - print " -b start server in background (default)" - print " -f start server in foreground" - print " --async start server in async mode (for internal usage only, don't read configuration)" - print " -h, --help display this help message" - print " -V, --version print the version" + output("Usage: "+caller+" [OPTIONS]" + (" " if not caller.endswith('server') else "")) + output("") + output("Fail2Ban v" + version + " reads log file that contains password failure report") + output("and bans the corresponding IP addresses using firewall rules.") + output("") + output("Options:") + output(" -c configuration directory") + output(" -s socket path") + output(" -p pidfile path") + output(" --loglevel logging level") + output(" --logtarget |STDOUT|STDERR|SYSLOG") + output(" --syslogsocket auto|") + output(" -d dump configuration. For debugging") + output(" -i interactive mode") + output(" -v increase verbosity") + output(" -q decrease verbosity") + output(" -x force execution of the server (remove socket file)") + output(" -b start server in background (default)") + output(" -f start server in foreground") + output(" --async start server in async mode (for internal usage only, don't read configuration)") + output(" -h, --help display this help message") + output(" -V, --version print the version") if not caller.endswith('server'): - print - print "Command:" + output("") + output("Command:") # Prints the protocol printFormatted() - print - print "Report bugs to https://github.com/fail2ban/fail2ban/issues" + output("") + output("Report bugs to https://github.com/fail2ban/fail2ban/issues") def __getCmdLineOptions(self, optList): """ Gets the command line options @@ -147,69 +152,78 @@ class Fail2banCmdLine(): self._conf["background"] = False elif o in ["-h", "--help"]: self.dispUsage() - exit(0) + return True elif o in ["-V", "--version"]: self.dispVersion() - exit(0) + return True + return None def initCmdLine(self, argv): - # First time? - initial = (self._argv is None) - - # Command line options - self._argv = argv - - # Reads the command line options. try: - cmdOpts = 'hc:s:p:xfbdviqV' - cmdLongOpts = ['loglevel=', 'logtarget=', 'syslogsocket=', 'async', 'help', 'version'] - optList, self._args = getopt.getopt(self._argv[1:], cmdOpts, cmdLongOpts) - except getopt.GetoptError: - self.dispUsage() - exit(-1) + # First time? + initial = (self._argv is None) - self.__getCmdLineOptions(optList) + # Command line options + self._argv = argv + logSys.info("Using start params %s", argv[1:]) - if initial: - verbose = self._conf["verbose"] - if verbose <= 0: - logSys.setLevel(logging.ERROR) - elif verbose == 1: - logSys.setLevel(logging.WARNING) - elif verbose == 2: - logSys.setLevel(logging.INFO) - else: - logSys.setLevel(logging.DEBUG) - # Add the default logging handler to dump to stderr - logout = logging.StreamHandler(sys.stderr) - # set a format which is simpler for console use - formatter = logging.Formatter('%(levelname)-6s %(message)s') - # tell the handler to use this format - logout.setFormatter(formatter) - logSys.addHandler(logout) + # Reads the command line options. + try: + cmdOpts = 'hc:s:p:xfbdviqV' + cmdLongOpts = ['loglevel=', 'logtarget=', 'syslogsocket=', 'async', 'help', 'version'] + optList, self._args = getopt.getopt(self._argv[1:], cmdOpts, cmdLongOpts) + except getopt.GetoptError: + self.dispUsage() + return False - # Set expected parameters (like socket, pidfile, etc) from configuration, - # if those not yet specified, in which read configuration only if needed here: - conf = None - for o in CONFIG_PARAMS: - if self._conf.get(o, None) is None: - if not conf: - self.configurator.readEarly() - conf = self.configurator.getEarlyOptions() - self._conf[o] = conf[o] + ret = self.__getCmdLineOptions(optList) + if ret is not None: + return ret - logSys.info("Using socket file %s", self._conf["socket"]) + if initial and PRODUCTION: # pragma: no cover - can't test + verbose = self._conf["verbose"] + if verbose <= 0: + logSys.setLevel(logging.ERROR) + elif verbose == 1: + logSys.setLevel(logging.WARNING) + elif verbose == 2: + logSys.setLevel(logging.INFO) + else: + logSys.setLevel(logging.DEBUG) + # Add the default logging handler to dump to stderr + logout = logging.StreamHandler(sys.stderr) + # set a format which is simpler for console use + formatter = logging.Formatter('%(levelname)-6s %(message)s') + # tell the handler to use this format + logout.setFormatter(formatter) + logSys.addHandler(logout) - logSys.info("Using pid file %s, [%s] logging to %s", - self._conf["pidfile"], self._conf["loglevel"], self._conf["logtarget"]) + # Set expected parameters (like socket, pidfile, etc) from configuration, + # if those not yet specified, in which read configuration only if needed here: + conf = None + for o in CONFIG_PARAMS: + if self._conf.get(o, None) is None: + if not conf: + self.configurator.readEarly() + conf = self.configurator.getEarlyOptions() + self._conf[o] = conf[o] - if self._conf.get("dump", False): - ret, stream = self.readConfig() - self.dumpConfig(stream) - return ret + logSys.info("Using socket file %s", self._conf["socket"]) - # Nothing to do here, process in client/server - return None + logSys.info("Using pid file %s, [%s] logging to %s", + self._conf["pidfile"], self._conf["loglevel"], self._conf["logtarget"]) + + if self._conf.get("dump", False): + ret, stream = self.readConfig() + self.dumpConfig(stream) + return ret + + # Nothing to do here, process in client/server + return None + except Exception as e: + output("ERROR: %s" % (e,)) + #logSys.exception(e) + return False def readConfig(self, jail=None): # Read the configuration @@ -242,4 +256,8 @@ class Fail2banCmdLine(): sys.exit(code) # global exit handler: -exit = Fail2banCmdLine.exit \ No newline at end of file +exit = Fail2banCmdLine.exit + + +class ExitException: + pass \ No newline at end of file diff --git a/fail2ban/client/fail2banserver.py b/fail2ban/client/fail2banserver.py index 6c1dd694..da8e57b8 100644 --- a/fail2ban/client/fail2banserver.py +++ b/fail2ban/client/fail2banserver.py @@ -29,7 +29,11 @@ from ..server.server import Server, ServerDaemonize from ..server.utils import Utils from .fail2bancmdline import Fail2banCmdLine, logSys, exit +MAX_WAITTIME = 30 + SERVER = "fail2ban-server" + + ## # \mainpage Fail2Ban # @@ -72,8 +76,15 @@ class Fail2banServer(Fail2banCmdLine): @staticmethod def startServerAsync(conf): - # Forks the current process. - pid = os.fork() + # Directory of client (to try the first start from the same directory as client): + startdir = sys.path[0] + if startdir in ("", "."): # may be uresolved in test-cases, so get bin-directory: + startdir = os.path.dirname(sys.argv[0]) + # Forks the current process, don't fork if async specified (ex: test cases) + pid = 0 + frk = not conf["async"] + if frk: + pid = os.fork() if pid == 0: args = list() args.append(SERVER) @@ -96,14 +107,20 @@ class Fail2banServer(Fail2banCmdLine): try: # Use the current directory. - exe = os.path.abspath(os.path.join(sys.path[0], SERVER)) + exe = os.path.abspath(os.path.join(startdir, SERVER)) logSys.debug("Starting %r with args %r", exe, args) - os.execv(exe, args) - except OSError: + if frk: + os.execv(exe, args) + else: + os.spawnv(os.P_NOWAITO, exe, args) + except OSError as e: try: # Use the PATH env. - logSys.warning("Initial start attempt failed. Starting %r with the same args", SERVER) - os.execvp(SERVER, args) + logSys.warning("Initial start attempt failed (%s). Starting %r with the same args", e, SERVER) + if frk: + os.execvp(SERVER, args) + else: + os.spawnvp(os.P_NOWAITO, SERVER, args) except OSError: exit(-1) @@ -143,8 +160,8 @@ class Fail2banServer(Fail2banCmdLine): phase = dict() logSys.debug('Configure via async client thread') cli.configureServer(async=True, phase=phase) - # wait up to 30 seconds, do not continue if configuration is not 100% valid: - Utils.wait_for(lambda: phase.get('ready', None) is not None, 30) + # wait up to MAX_WAITTIME, do not continue if configuration is not 100% valid: + Utils.wait_for(lambda: phase.get('ready', None) is not None, MAX_WAITTIME) if not phase.get('start', False): return False @@ -158,7 +175,7 @@ class Fail2banServer(Fail2banCmdLine): # wait for client answer "done": if not async and cli: - Utils.wait_for(lambda: phase.get('done', None) is not None, 30) + Utils.wait_for(lambda: phase.get('done', None) is not None, MAX_WAITTIME) if not phase.get('done', False): if server: server.quit() @@ -179,9 +196,9 @@ class Fail2banServer(Fail2banCmdLine): logSys.error("Could not start %s", SERVER) exit(code) -def exec_command_line(): # pragma: no cover - can't test main +def exec_command_line(argv): server = Fail2banServer() - if server.start(sys.argv): + if server.start(argv): exit(0) else: exit(-1) diff --git a/fail2ban/protocol.py b/fail2ban/protocol.py index 857d5fa6..648666a1 100644 --- a/fail2ban/protocol.py +++ b/fail2ban/protocol.py @@ -26,6 +26,9 @@ __license__ = "GPL" import textwrap +def output(s): + print(s) + ## # Describes the protocol used to communicate with the server. @@ -143,7 +146,7 @@ def printFormatted(): firstHeading = False for m in protocol: if m[0] == '' and firstHeading: - print + output("") firstHeading = True first = True if len(m[0]) >= MARGIN: @@ -154,7 +157,7 @@ def printFormatted(): first = False else: line = ' ' * (INDENT + MARGIN) + n.strip() - print line + output(line) ## @@ -165,20 +168,20 @@ def printWiki(): for m in protocol: if m[0] == '': if firstHeading: - print "|}" + output("|}") __printWikiHeader(m[1], m[2]) firstHeading = True else: - print "|-" - print "| " + m[0] + " || || " + m[1] - print "|}" + output("|-") + output("| " + m[0] + " || || " + m[1]) + output("|}") def __printWikiHeader(section, desc): - print - print "=== " + section + " ===" - print - print desc - print - print "{|" - print "| '''Command''' || || '''Description'''" + output("") + output("=== " + section + " ===") + output("") + output(desc) + output("") + output("{|") + output("| '''Command''' || || '''Description'''") diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 72279b2d..a542659b 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -24,6 +24,7 @@ __author__ = "Cyril Jaquier" __copyright__ = "Copyright (c) 2004 Cyril Jaquier" __license__ = "GPL" +import threading from threading import Lock, RLock import logging import logging.handlers @@ -42,6 +43,10 @@ from ..helpers import getLogger, excepthook # Gets the instance of the logger. logSys = getLogger(__name__) +DEF_SYSLOGSOCKET = "auto" +DEF_LOGLEVEL = "INFO" +DEF_LOGTARGET = "STDOUT" + try: from .database import Fail2BanDb except ImportError: # pragma: no cover @@ -49,6 +54,10 @@ except ImportError: # pragma: no cover Fail2BanDb = None +def _thread_name(): + return threading.current_thread().__class__.__name__ + + class Server: def __init__(self, daemon = False): @@ -67,11 +76,7 @@ class Server: 'FreeBSD': '/var/run/log', 'Linux': '/dev/log', } - # todo: remove that, if test cases are fixed - self.setSyslogSocket("auto") - # Set logging level - self.setLogLevel("INFO") - self.setLogTarget("STDOUT") + self.__prev_signals = {} def __sigTERMhandler(self, signum, frame): logSys.debug("Caught signal %d. Exiting" % signum) @@ -93,9 +98,12 @@ class Server: raise ServerInitializationError("Could not create daemon") # Set all logging parameters (or use default if not specified): - self.setSyslogSocket(conf.get("syslogsocket", self.__syslogSocket)) - self.setLogLevel(conf.get("loglevel", self.__logLevel)) - self.setLogTarget(conf.get("logtarget", self.__logTarget)) + self.setSyslogSocket(conf.get("syslogsocket", + self.__syslogSocket if self.__syslogSocket is not None else DEF_SYSLOGSOCKET)) + self.setLogLevel(conf.get("loglevel", + self.__logLevel if self.__logLevel is not None else DEF_LOGLEVEL)) + self.setLogTarget(conf.get("logtarget", + self.__logTarget if self.__logTarget is not None else DEF_LOGTARGET)) logSys.info("-"*50) logSys.info("Starting Fail2ban v%s", version.version) @@ -104,10 +112,10 @@ class Server: logSys.info("Daemon started") # Install signal handlers - signal.signal(signal.SIGTERM, self.__sigTERMhandler) - signal.signal(signal.SIGINT, self.__sigTERMhandler) - signal.signal(signal.SIGUSR1, self.__sigUSR1handler) - + if _thread_name() == '_MainThread': + for s in (signal.SIGTERM, signal.SIGINT, signal.SIGUSR1): + self.__prev_signals[s] = signal.getsignal(s) + signal.signal(s, self.__sigTERMhandler if s != signal.SIGUSR1 else self.__sigUSR1handler) # Ensure unhandled exceptions are logged sys.excepthook = excepthook @@ -150,6 +158,10 @@ class Server: with self.__loggingLock: logging.shutdown() + # Restore default signal handlers: + for s, sh in self.__prev_signals.iteritems(): + signal.signal(s, sh) + def addJail(self, name, backend): self.__jails.add(name, backend, self.__db) if self.__db is not None: @@ -395,8 +407,13 @@ class Server: def setLogTarget(self, target): with self.__loggingLock: + # don't set new handlers if already the same + # or if "INHERITED" (foreground worker of the test cases, to prevent stop logging): if self.__logTarget == target: return True + if target == "INHERITED": + self.__logTarget = target + return True # set a format which is simpler for console use formatter = logging.Formatter("%(asctime)s %(name)-24s[%(process)d]: %(levelname)-7s %(message)s") if target == "SYSLOG": @@ -539,7 +556,10 @@ class Server: # We need to set this in the parent process, so it gets inherited by the # child process, and this makes sure that it is effect even if the parent # terminates quickly. - signal.signal(signal.SIGHUP, signal.SIG_IGN) + if _thread_name() == '_MainThread': + for s in (signal.SIGHUP,): + self.__prev_signals[s] = signal.getsignal(s) + signal.signal(s, signal.SIG_IGN) try: # Fork a child process so the parent can exit. This will return control diff --git a/fail2ban/server/utils.py b/fail2ban/server/utils.py index 45d1c09d..a8496f8e 100644 --- a/fail2ban/server/utils.py +++ b/fail2ban/server/utils.py @@ -125,6 +125,7 @@ class Utils(): timeout_expr = lambda: time.time() - stime <= timeout else: timeout_expr = timeout + popen = None try: popen = subprocess.Popen( realCmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=shell, @@ -151,7 +152,10 @@ class Utils(): if retcode is None and not Utils.pid_exists(pgid): retcode = signal.SIGKILL except OSError as e: - logSys.error("%s -- failed with %s" % (realCmd, e)) + stderr = "%s -- failed with %s" % (realCmd, e) + logSys.error(stderr) + if not popen: + return False if not output else (False, stdout, stderr, retcode) std_level = retcode == 0 and logging.DEBUG or logging.ERROR # if we need output (to return or to log it): diff --git a/fail2ban/tests/fail2banclienttestcase.py b/fail2ban/tests/fail2banclienttestcase.py new file mode 100644 index 00000000..7db48fe8 --- /dev/null +++ b/fail2ban/tests/fail2banclienttestcase.py @@ -0,0 +1,411 @@ +# 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. + +# Fail2Ban developers + +__author__ = "Serg Brester" +__copyright__ = "Copyright (c) 2014- Serg G. Brester (sebres), 2008- Fail2Ban Contributors" +__license__ = "GPL" + +import fileinput +import os +import re +import time +import unittest + +from threading import Thread + +from ..client import fail2banclient, fail2banserver, fail2bancmdline +from ..client.fail2banclient import Fail2banClient, exec_command_line as _exec_client, VisualWait +from ..client.fail2banserver import Fail2banServer, exec_command_line as _exec_server +from .. import protocol +from ..server import server +from ..server.utils import Utils +from .utils import LogCaptureTestCase, logSys, withtmpdir, shutil, logging + + +STOCK_CONF_DIR = "config" +STOCK = os.path.exists(os.path.join(STOCK_CONF_DIR,'fail2ban.conf')) +TEST_CONF_DIR = os.path.join(os.path.dirname(__file__), "config") +if STOCK: + CONF_DIR = STOCK_CONF_DIR +else: + CONF_DIR = TEST_CONF_DIR + +CLIENT = "fail2ban-client" +SERVER = "fail2ban-server" +BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "bin") + +MAX_WAITTIME = 10 +MAX_WAITTIME = unittest.F2B.maxWaitTime(MAX_WAITTIME) + +## +# Several wrappers and settings for proper testing: +# + +fail2banclient.MAX_WAITTIME = \ +fail2banserver.MAX_WAITTIME = MAX_WAITTIME + + +fail2bancmdline.logSys = \ +fail2banclient.logSys = \ +fail2banserver.logSys = logSys + +LOG_LEVEL = logSys.level + +server.DEF_LOGTARGET = "/dev/null" + +def _test_output(*args): + logSys.info(args[0]) +fail2bancmdline.output = \ +fail2banclient.output = \ +fail2banserver.output = \ +protocol.output = _test_output + +def _test_exit(code=0): + logSys.debug("Exit with code %s", code) + if code == 0: + raise ExitException() + else: + raise FailExitException() +fail2bancmdline.exit = \ +fail2banclient.exit = \ +fail2banserver.exit = _test_exit + +INTERACT = [] +def _test_raw_input(*args): + if len(INTERACT): + #print('--- interact command: ', INTERACT[0]) + return INTERACT.pop(0) + else: + return "exit" +fail2banclient.raw_input = _test_raw_input + +# prevents change logging params, log capturing, etc: +fail2bancmdline.PRODUCTION = False + + +class ExitException(fail2bancmdline.ExitException): + pass +class FailExitException(fail2bancmdline.ExitException): + pass + + +def _out_file(fn): # pragma: no cover + logSys.debug('---- ' + fn + ' ----') + for line in fileinput.input(fn): + line = line.rstrip('\n') + logSys.debug(line) + logSys.debug('-'*30) + +def _start_params(tmp, use_stock=False, logtarget="/dev/null"): + cfg = tmp+"/config" + if use_stock and STOCK: + # copy config: + def ig_dirs(dir, files): + return [f for f in files if not os.path.isfile(os.path.join(dir, f))] + shutil.copytree(STOCK_CONF_DIR, cfg, ignore=ig_dirs) + os.symlink(STOCK_CONF_DIR+"/action.d", cfg+"/action.d") + os.symlink(STOCK_CONF_DIR+"/filter.d", cfg+"/filter.d") + # replace fail2ban params (database with memory): + r = re.compile(r'^dbfile\s*=') + for line in fileinput.input(cfg+"/fail2ban.conf", inplace=True): + line = line.rstrip('\n') + if r.match(line): + line = "dbfile = :memory:" + print(line) + # replace jail params (polling as backend to be fast in initialize): + r = re.compile(r'^backend\s*=') + for line in fileinput.input(cfg+"/jail.conf", inplace=True): + line = line.rstrip('\n') + if r.match(line): + line = "backend = polling" + print(line) + else: + # just empty config directory without anything (only fail2ban.conf/jail.conf): + os.mkdir(cfg) + f = open(cfg+"/fail2ban.conf", "wb") + f.write('\n'.join(( + "[Definition]", + "loglevel = INFO", + "logtarget = " + logtarget, + "syslogsocket = auto", + "socket = "+tmp+"/f2b.sock", + "pidfile = "+tmp+"/f2b.pid", + "backend = polling", + "dbfile = :memory:", + "dbpurgeage = 1d", + "", + ))) + f.close() + f = open(cfg+"/jail.conf", "wb") + f.write('\n'.join(( + "[INCLUDES]", "", + "[DEFAULT]", "", + "", + ))) + f.close() + if LOG_LEVEL < logging.DEBUG: # if HEAVYDEBUG + _out_file(cfg+"/fail2ban.conf") + _out_file(cfg+"/jail.conf") + # parameters: + return ("-c", cfg, + "--logtarget", logtarget, "--loglevel", "DEBUG", "--syslogsocket", "auto", + "-s", tmp+"/f2b.sock", "-p", tmp+"/f2b.pid") + + +class Fail2banClientTest(LogCaptureTestCase): + + def setUp(self): + """Call before every test case.""" + LogCaptureTestCase.setUp(self) + + def tearDown(self): + """Call after every test case.""" + LogCaptureTestCase.tearDown(self) + + def testClientUsage(self): + self.assertRaises(ExitException, _exec_client, + (CLIENT, "-h",)) + self.assertLogged("Usage: " + CLIENT) + self.assertLogged("Report bugs to ") + + @withtmpdir + def testClientStartBackgroundInside(self, tmp): + # always add "--async" by start inside, should don't fork by async (not replace client with server, just start in new process) + # (we can't fork the test cases process): + startparams = _start_params(tmp, True) + # start: + self.assertRaises(ExitException, _exec_client, + (CLIENT, "--async", "-b") + startparams + ("start",)) + self.assertLogged("Server ready") + self.assertLogged("Exit with code 0") + try: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) + self.assertRaises(FailExitException, _exec_client, + (CLIENT,) + startparams + ("~~unknown~cmd~failed~~",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("stop",)) + self.assertLogged("Shutdown successful") + self.assertLogged("Exit with code 0") + + @withtmpdir + def testClientStartBackgroundCall(self, tmp): + global INTERACT + startparams = _start_params(tmp) + # start (without async in new process): + cmd = os.path.join(os.path.join(BIN), CLIENT) + logSys.debug('Start %s ...', cmd) + Utils.executeCmd((cmd,) + startparams + ("start",), + timeout=MAX_WAITTIME, shell=False, output=False) + self.pruneLog() + try: + # echo from client (inside): + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) + self.assertLogged("TEST-ECHO") + self.assertLogged("Exit with code 0") + self.pruneLog() + # interactive client chat with started server: + INTERACT += [ + "echo INTERACT-ECHO", + "status", + "exit" + ] + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("-i",)) + self.assertLogged("INTERACT-ECHO") + self.assertLogged("Status", "Number of jail:") + self.assertLogged("Exit with code 0") + self.pruneLog() + # test reload and restart over interactive client: + INTERACT += [ + "reload", + "restart", + "exit" + ] + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("-i",)) + self.assertLogged("Reading config files:") + self.assertLogged("Shutdown successful") + self.assertLogged("Server ready") + self.assertLogged("Exit with code 0") + self.pruneLog() + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("stop",)) + self.assertLogged("Shutdown successful") + self.assertLogged("Exit with code 0") + + def _testClientStartForeground(self, tmp, startparams, phase): + # start and wait to end (foreground): + phase['start'] = True + self.assertRaises(ExitException, _exec_client, + (CLIENT, "-f") + startparams + ("start",)) + # end : + phase['end'] = True + + @withtmpdir + def testClientStartForeground(self, tmp): + # started directly here, so prevent overwrite test cases logger with "INHERITED" + startparams = _start_params(tmp, logtarget="INHERITED") + # because foreground block execution - start it in thread: + phase = dict() + Thread(name="_TestCaseWorker", + target=Fail2banClientTest._testClientStartForeground, args=(self, tmp, startparams, phase)).start() + try: + # wait for start thread: + Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) + self.assertTrue(phase.get('start', None)) + # wait for server (socket): + Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) + self.assertLogged("Starting communication") + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("ping",)) + self.assertRaises(FailExitException, _exec_client, + (CLIENT,) + startparams + ("~~unknown~cmd~failed~~",)) + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + 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") + self.assertLogged("Exit with code 0") + + @withtmpdir + def testClientFailStart(self, tmp): + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "--async", "-c", tmp+"/miss", "start",)) + self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") + + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "--async", "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock", "start",)) + self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") + + def testVisualWait(self): + sleeptime = 0.035 + for verbose in (2, 0): + cntr = 15 + with VisualWait(verbose, 5) as vis: + while cntr: + vis.heartbeat() + if verbose and not unittest.F2B.fast: + time.sleep(sleeptime) + cntr -= 1 + + +class Fail2banServerTest(LogCaptureTestCase): + + def setUp(self): + """Call before every test case.""" + LogCaptureTestCase.setUp(self) + + def tearDown(self): + """Call after every test case.""" + LogCaptureTestCase.tearDown(self) + + def testServerUsage(self): + self.assertRaises(ExitException, _exec_server, + (SERVER, "-h",)) + self.assertLogged("Usage: " + SERVER) + self.assertLogged("Report bugs to ") + + @withtmpdir + def testServerStartBackground(self, tmp): + # don't add "--async" by start, because if will fork current process by daemonize + # (we can't fork the test cases process), + # because server started internal communication in new thread use INHERITED as logtarget here: + startparams = _start_params(tmp, logtarget="INHERITED") + # start: + self.assertRaises(ExitException, _exec_server, + (SERVER, "-b") + startparams) + self.assertLogged("Server ready") + self.assertLogged("Exit with code 0") + try: + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("echo", "TEST-ECHO",)) + self.assertRaises(FailExitException, _exec_server, + (SERVER,) + startparams + ("~~unknown~cmd~failed~~",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("stop",)) + self.assertLogged("Shutdown successful") + self.assertLogged("Exit with code 0") + + def _testServerStartForeground(self, tmp, startparams, phase): + # start and wait to end (foreground): + phase['start'] = True + self.assertRaises(ExitException, _exec_server, + (SERVER, "-f") + startparams + ("start",)) + # end : + phase['end'] = True + @withtmpdir + def testServerStartForeground(self, tmp): + # started directly here, so prevent overwrite test cases logger with "INHERITED" + startparams = _start_params(tmp, logtarget="INHERITED") + # because foreground block execution - start it in thread: + phase = dict() + Thread(name="_TestCaseWorker", + target=Fail2banServerTest._testServerStartForeground, args=(self, tmp, startparams, phase)).start() + try: + # wait for start thread: + Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) + self.assertTrue(phase.get('start', None)) + # wait for server (socket): + Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) + self.assertLogged("Starting communication") + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("ping",)) + self.assertRaises(FailExitException, _exec_server, + (SERVER,) + startparams + ("~~unknown~cmd~failed~~",)) + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("echo", "TEST-ECHO",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_server, + (SERVER,) + 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") + self.assertLogged("Exit with code 0") + + @withtmpdir + def testServerFailStart(self, tmp): + self.assertRaises(FailExitException, _exec_server, + (SERVER, "-c", tmp+"/miss",)) + self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") + + self.assertRaises(FailExitException, _exec_server, + (SERVER, "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock",)) + self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") diff --git a/fail2ban/tests/fail2banregextestcase.py b/fail2ban/tests/fail2banregextestcase.py index 2fd362c7..a1dcb4da 100644 --- a/fail2ban/tests/fail2banregextestcase.py +++ b/fail2ban/tests/fail2banregextestcase.py @@ -23,19 +23,7 @@ __author__ = "Serg Brester" __copyright__ = "Copyright (c) 2015 Serg G. Brester (sebres), 2008- Fail2Ban Contributors" __license__ = "GPL" -from __builtin__ import open as fopen -import unittest -import getpass import os -import sys -import time -import tempfile -import uuid - -try: - from systemd import journal -except ImportError: - journal = None from ..client import fail2banregex from ..client.fail2banregex import Fail2banRegex, get_opt_parser, output diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index ce6d638a..9cdace42 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -26,10 +26,14 @@ import logging import optparse import os import re +import tempfile +import shutil import sys import time import unittest + from StringIO import StringIO +from functools import wraps from ..helpers import getLogger from ..server.filter import DNSUtils @@ -71,6 +75,17 @@ class F2B(optparse.Values): return wtime +def withtmpdir(f): + @wraps(f) + def wrapper(self, *args, **kwargs): + tmp = tempfile.mkdtemp(prefix="f2b-temp") + try: + return f(self, tmp, *args, **kwargs) + finally: + # clean up + shutil.rmtree(tmp) + return wrapper + def initTests(opts): unittest.F2B = F2B(opts) # --fast : @@ -145,6 +160,7 @@ def gatherTests(regexps=None, opts=None): from . import misctestcase from . import databasetestcase from . import samplestestcase + from . import fail2banclienttestcase from . import fail2banregextestcase if not regexps: # pragma: no cover @@ -223,6 +239,9 @@ def gatherTests(regexps=None, opts=None): # Filter Regex tests with sample logs tests.addTest(unittest.makeSuite(samplestestcase.FilterSamplesRegex)) + # bin/fail2ban-client, bin/fail2ban-server + tests.addTest(unittest.makeSuite(fail2banclienttestcase.Fail2banClientTest)) + tests.addTest(unittest.makeSuite(fail2banclienttestcase.Fail2banServerTest)) # bin/fail2ban-regex tests.addTest(unittest.makeSuite(fail2banregextestcase.Fail2banRegexTest)) @@ -293,8 +312,11 @@ class LogCaptureTestCase(unittest.TestCase): # Let's log everything into a string self._log = StringIO() logSys.handlers = [logging.StreamHandler(self._log)] + if self._old_level <= logging.DEBUG: + print("") if self._old_level < logging.DEBUG: # so if HEAVYDEBUG etc -- show them! logSys.handlers += self._old_handlers + logSys.debug('--'*40) logSys.setLevel(getattr(logging, 'DEBUG')) def tearDown(self): @@ -340,6 +362,9 @@ class LogCaptureTestCase(unittest.TestCase): raise AssertionError("All of the %r were found present in the log: %r" % (s, logged)) + def pruneLog(self): + self._log.truncate(0) + def getLog(self): return self._log.getvalue() From 0e11d81adb6796efc5d70d29f5a70920dbdd0a03 Mon Sep 17 00:00:00 2001 From: sebres Date: Thu, 11 Feb 2016 21:15:03 +0100 Subject: [PATCH 52/60] several bug fixed: fork in client-server test cases prohibited, all worker threads daemonized (to prevent hanging on exit). --- fail2ban/client/fail2banclient.py | 27 +- fail2ban/client/fail2banserver.py | 11 +- fail2ban/server/jailthread.py | 2 + fail2ban/server/server.py | 6 +- fail2ban/tests/action_d/test_smtp.py | 1 + fail2ban/tests/fail2banclienttestcase.py | 373 ++++++++++++++--------- fail2ban/tests/utils.py | 3 +- 7 files changed, 255 insertions(+), 168 deletions(-) diff --git a/fail2ban/client/fail2banclient.py b/fail2ban/client/fail2banclient.py index 736f8fd2..4f4caf79 100644 --- a/fail2ban/client/fail2banclient.py +++ b/fail2ban/client/fail2banclient.py @@ -34,7 +34,7 @@ from threading import Thread from ..version import version from .csocket import CSocket from .beautifier import Beautifier -from .fail2bancmdline import Fail2banCmdLine, ExitException, logSys, exit, output +from .fail2bancmdline import Fail2banCmdLine, ExitException, PRODUCTION, logSys, exit, output MAX_WAITTIME = 30 @@ -108,8 +108,13 @@ class Fail2banClient(Fail2banCmdLine, Thread): logSys.error(e) return False finally: + # prevent errors by close during shutdown (on exit command): if client: - client.close() + try : + client.close() + except Exception as e: + if showRet or self._conf["verbose"] > 1: + logSys.debug(e) if showRet or c[0] == 'echo': sys.stdout.flush() return streamRet @@ -184,7 +189,9 @@ class Fail2banClient(Fail2banCmdLine, Thread): return False else: # In foreground mode we should make server/client communication in different threads: - Thread(target=Fail2banClient.__processStartStreamAfterWait, args=(self, stream, False)).start() + th = Thread(target=Fail2banClient.__processStartStreamAfterWait, args=(self, stream, False)) + th.daemon = True + th.start() # Mark current (main) thread as daemon: self.setDaemon(True) # Start server direct here in main thread (not fork): @@ -197,8 +204,6 @@ class Fail2banClient(Fail2banCmdLine, Thread): logSys.error("Exception while starting server " + ("background" if background else "foreground")) logSys.error(e) return False - finally: - self._alive = False return True @@ -206,7 +211,9 @@ class Fail2banClient(Fail2banCmdLine, Thread): def configureServer(self, async=True, phase=None): # if asynchron start this operation in the new thread: if async: - return Thread(target=Fail2banClient.configureServer, args=(self, False, phase)).start() + th = Thread(target=Fail2banClient.configureServer, args=(self, False, phase)) + th.daemon = True + return th.start() # prepare: read config, check configuration is valid, etc.: if phase is not None: phase['start'] = True @@ -290,7 +297,6 @@ class Fail2banClient(Fail2banCmdLine, Thread): "server, adding the -x option will do it") if self._server: self._server.quit() - exit(-1) return False return True @@ -299,10 +305,12 @@ class Fail2banClient(Fail2banCmdLine, Thread): maxtime = MAX_WAITTIME # Wait for the server to start (the server has 30 seconds to answer ping) starttime = time.time() + logSys.debug("__waitOnServer: %r", (alive, maxtime)) with VisualWait(self._conf["verbose"]) as vis: - while self._alive and not self.__ping() == alive or ( + while self._alive and ( + not self.__ping() == alive or ( not alive and os.path.exists(self._conf["socket"]) - ): + )): now = time.time() # Wonderful visual :) if now > starttime + 1: @@ -365,6 +373,7 @@ class Fail2banClient(Fail2banCmdLine, Thread): return False return self.__processCommand(args) finally: + self._alive = False for s, sh in _prev_signals.iteritems(): signal.signal(s, sh) diff --git a/fail2ban/client/fail2banserver.py b/fail2ban/client/fail2banserver.py index da8e57b8..ac927251 100644 --- a/fail2ban/client/fail2banserver.py +++ b/fail2ban/client/fail2banserver.py @@ -25,15 +25,14 @@ import os import sys from ..version import version -from ..server.server import Server, ServerDaemonize +from ..server.server import Server from ..server.utils import Utils -from .fail2bancmdline import Fail2banCmdLine, logSys, exit +from .fail2bancmdline import Fail2banCmdLine, logSys, PRODUCTION, exit MAX_WAITTIME = 30 SERVER = "fail2ban-server" - ## # \mainpage Fail2Ban # @@ -51,6 +50,7 @@ class Fail2banServer(Fail2banCmdLine): @staticmethod def startServerDirect(conf, daemon=True): + logSys.debug("-- direct starting of server in %s, deamon: %s", os.getpid(), daemon) server = None try: # Start it in foreground (current thread, not new process), @@ -59,8 +59,6 @@ class Fail2banServer(Fail2banCmdLine): server.start(conf["socket"], conf["pidfile"], conf["force"], conf=conf) - except ServerDaemonize: - pass except Exception, e: logSys.exception(e) if server: @@ -82,9 +80,10 @@ class Fail2banServer(Fail2banCmdLine): startdir = os.path.dirname(sys.argv[0]) # Forks the current process, don't fork if async specified (ex: test cases) pid = 0 - frk = not conf["async"] + frk = not conf["async"] and PRODUCTION if frk: pid = os.fork() + logSys.debug("-- async starting of server in %s, fork: %s - %s", os.getpid(), frk, pid) if pid == 0: args = list() args.append(SERVER) diff --git a/fail2ban/server/jailthread.py b/fail2ban/server/jailthread.py index eb43e453..39a86c2b 100644 --- a/fail2ban/server/jailthread.py +++ b/fail2ban/server/jailthread.py @@ -51,6 +51,8 @@ class JailThread(Thread): def __init__(self, name=None): super(JailThread, self).__init__(name=name) + ## Should going with main thread also: + self.daemon = True ## Control the state of the thread. self.active = False ## Control the idle state of the thread. diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index a542659b..9f56f8a8 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -341,6 +341,9 @@ class Server: def getBanTime(self, name): return self.__jails[name].actions.getBanTime() + def isStarted(self): + self.__asyncServer.isActive() + def isAlive(self, jailnum=None): if jailnum is not None and len(self.__jails) != jailnum: return 0 @@ -633,6 +636,3 @@ class Server: class ServerInitializationError(Exception): pass - -class ServerDaemonize(Exception): - pass \ No newline at end of file diff --git a/fail2ban/tests/action_d/test_smtp.py b/fail2ban/tests/action_d/test_smtp.py index 37fe0138..1385fe82 100644 --- a/fail2ban/tests/action_d/test_smtp.py +++ b/fail2ban/tests/action_d/test_smtp.py @@ -65,6 +65,7 @@ class SMTPActionTest(unittest.TestCase): self._active = True self._loop_thread = threading.Thread( target=asyncserver.loop, kwargs={'active': lambda: self._active}) + self._loop_thread.daemon = True self._loop_thread.start() def tearDown(self): diff --git a/fail2ban/tests/fail2banclienttestcase.py b/fail2ban/tests/fail2banclienttestcase.py index 7db48fe8..fd8a074b 100644 --- a/fail2ban/tests/fail2banclienttestcase.py +++ b/fail2ban/tests/fail2banclienttestcase.py @@ -98,7 +98,9 @@ def _test_raw_input(*args): fail2banclient.raw_input = _test_raw_input # prevents change logging params, log capturing, etc: -fail2bancmdline.PRODUCTION = False +fail2bancmdline.PRODUCTION = \ +fail2banclient.PRODUCTION = \ +fail2banserver.PRODUCTION = False class ExitException(fail2bancmdline.ExitException): @@ -117,9 +119,9 @@ def _out_file(fn): # pragma: no cover def _start_params(tmp, use_stock=False, logtarget="/dev/null"): cfg = tmp+"/config" if use_stock and STOCK: - # copy config: + # copy config (sub-directories as alias): def ig_dirs(dir, files): - return [f for f in files if not os.path.isfile(os.path.join(dir, f))] + return [f for f in files if os.path.isdir(os.path.join(dir, f))] shutil.copytree(STOCK_CONF_DIR, cfg, ignore=ig_dirs) os.symlink(STOCK_CONF_DIR+"/action.d", cfg+"/action.d") os.symlink(STOCK_CONF_DIR+"/filter.d", cfg+"/filter.d") @@ -169,6 +171,47 @@ def _start_params(tmp, use_stock=False, logtarget="/dev/null"): "--logtarget", logtarget, "--loglevel", "DEBUG", "--syslogsocket", "auto", "-s", tmp+"/f2b.sock", "-p", tmp+"/f2b.pid") +def _kill_srv(pidfile): # pragma: no cover + def _pid_exists(pid): + try: + os.kill(pid, 0) + return True + except OSError: + return False + logSys.debug("-- cleanup: %r", (pidfile, os.path.isdir(pidfile))) + if os.path.isdir(pidfile): + piddir = pidfile + pidfile = piddir + "/f2b.pid" + if not os.path.isfile(pidfile): + pidfile = piddir + "/fail2ban.pid" + if not os.path.isfile(pidfile): + logSys.debug("--- cleanup: no pidfile for %r", piddir) + return True + f = pid = None + try: + logSys.debug("--- cleanup pidfile: %r", pidfile) + f = open(pidfile) + pid = f.read().split()[1] + pid = int(pid) + logSys.debug("--- cleanup pid: %r", pid) + if pid <= 0: + raise ValueError('pid %s of %s is invalid' % (pid, pidfile)) + if not _pid_exists(pid): + return True + ## try to preper stop (have signal handler): + os.kill(pid, signal.SIGTERM) + ## check still exists after small timeout: + if not Utils.wait_for(lambda: not _pid_exists(pid), MAX_WAITTIME / 3): + ## try to kill hereafter: + os.kill(pid, signal.SIGKILL) + return not _pid_exists(pid) + except Exception as e: + sysLog.debug(e) + finally: + if f is not None: + f.close() + return True + class Fail2banClientTest(LogCaptureTestCase): @@ -188,126 +231,144 @@ class Fail2banClientTest(LogCaptureTestCase): @withtmpdir def testClientStartBackgroundInside(self, tmp): - # always add "--async" by start inside, should don't fork by async (not replace client with server, just start in new process) - # (we can't fork the test cases process): - startparams = _start_params(tmp, True) - # start: - self.assertRaises(ExitException, _exec_client, - (CLIENT, "--async", "-b") + startparams + ("start",)) - self.assertLogged("Server ready") - self.assertLogged("Exit with code 0") try: + # always add "--async" by start inside, should don't fork by async (not replace client with server, just start in new process) + # (we can't fork the test cases process): + startparams = _start_params(tmp, True) + # start: self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) - self.assertRaises(FailExitException, _exec_client, - (CLIENT,) + startparams + ("~~unknown~cmd~failed~~",)) - finally: - self.pruneLog() - # stop: - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("stop",)) - self.assertLogged("Shutdown successful") + (CLIENT, "--async", "-b") + startparams + ("start",)) + self.assertLogged("Server ready") self.assertLogged("Exit with code 0") + try: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) + self.assertRaises(FailExitException, _exec_client, + (CLIENT,) + startparams + ("~~unknown~cmd~failed~~",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("stop",)) + self.assertLogged("Shutdown successful") + self.assertLogged("Exit with code 0") + finally: + _kill_srv(tmp) @withtmpdir def testClientStartBackgroundCall(self, tmp): - global INTERACT - startparams = _start_params(tmp) - # start (without async in new process): - cmd = os.path.join(os.path.join(BIN), CLIENT) - logSys.debug('Start %s ...', cmd) - Utils.executeCmd((cmd,) + startparams + ("start",), - timeout=MAX_WAITTIME, shell=False, output=False) - self.pruneLog() try: - # echo from client (inside): - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) - self.assertLogged("TEST-ECHO") - self.assertLogged("Exit with code 0") - self.pruneLog() - # interactive client chat with started server: - INTERACT += [ - "echo INTERACT-ECHO", - "status", - "exit" - ] - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("-i",)) - self.assertLogged("INTERACT-ECHO") - self.assertLogged("Status", "Number of jail:") - self.assertLogged("Exit with code 0") - self.pruneLog() - # test reload and restart over interactive client: - INTERACT += [ - "reload", - "restart", - "exit" - ] - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("-i",)) - self.assertLogged("Reading config files:") - self.assertLogged("Shutdown successful") - self.assertLogged("Server ready") - self.assertLogged("Exit with code 0") + global INTERACT + startparams = _start_params(tmp) + # start (without async in new process): + cmd = os.path.join(os.path.join(BIN), CLIENT) + logSys.debug('Start %s ...', cmd) + Utils.executeCmd((cmd,) + startparams + ("start",), + timeout=MAX_WAITTIME, shell=False, output=False) self.pruneLog() + try: + # echo from client (inside): + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) + self.assertLogged("TEST-ECHO") + self.assertLogged("Exit with code 0") + self.pruneLog() + # interactive client chat with started server: + INTERACT += [ + "echo INTERACT-ECHO", + "status", + "exit" + ] + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("-i",)) + self.assertLogged("INTERACT-ECHO") + self.assertLogged("Status", "Number of jail:") + self.assertLogged("Exit with code 0") + self.pruneLog() + # test reload and restart over interactive client: + INTERACT += [ + "reload", + "restart", + "exit" + ] + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("-i",)) + self.assertLogged("Reading config files:") + self.assertLogged("Shutdown successful") + self.assertLogged("Server ready") + self.assertLogged("Exit with code 0") + self.pruneLog() + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("stop",)) + self.assertLogged("Shutdown successful") + self.assertLogged("Exit with code 0") finally: - self.pruneLog() - # stop: - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("stop",)) - self.assertLogged("Shutdown successful") - self.assertLogged("Exit with code 0") + _kill_srv(tmp) def _testClientStartForeground(self, tmp, startparams, phase): # start and wait to end (foreground): + logSys.debug("-- start of test worker") phase['start'] = True self.assertRaises(ExitException, _exec_client, (CLIENT, "-f") + startparams + ("start",)) # end : phase['end'] = True + logSys.debug("-- end of test worker") @withtmpdir def testClientStartForeground(self, tmp): - # started directly here, so prevent overwrite test cases logger with "INHERITED" - startparams = _start_params(tmp, logtarget="INHERITED") - # because foreground block execution - start it in thread: - phase = dict() - Thread(name="_TestCaseWorker", - target=Fail2banClientTest._testClientStartForeground, args=(self, tmp, startparams, phase)).start() + th = None try: - # wait for start thread: - Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) - self.assertTrue(phase.get('start', None)) - # wait for server (socket): - Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) - self.assertLogged("Starting communication") - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("ping",)) - self.assertRaises(FailExitException, _exec_client, - (CLIENT,) + startparams + ("~~unknown~cmd~failed~~",)) - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) + # started directly here, so prevent overwrite test cases logger with "INHERITED" + startparams = _start_params(tmp, logtarget="INHERITED") + # because foreground block execution - start it in thread: + phase = dict() + th = Thread(name="_TestCaseWorker", + target=Fail2banClientTest._testClientStartForeground, args=(self, tmp, startparams, phase)) + th.daemon = True + th.start() + try: + # wait for start thread: + Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) + self.assertTrue(phase.get('start', None)) + # wait for server (socket): + Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) + self.assertLogged("Starting communication") + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("ping",)) + self.assertRaises(FailExitException, _exec_client, + (CLIENT,) + startparams + ("~~unknown~cmd~failed~~",)) + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + 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") finally: - self.pruneLog() - # stop: - self.assertRaises(ExitException, _exec_client, - (CLIENT,) + 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") - self.assertLogged("Exit with code 0") + _kill_srv(tmp) + if th: + th.join() @withtmpdir def testClientFailStart(self, tmp): - self.assertRaises(FailExitException, _exec_client, - (CLIENT, "--async", "-c", tmp+"/miss", "start",)) - self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") + try: + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "--async", "-c", tmp+"/miss", "start",)) + self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") - self.assertRaises(FailExitException, _exec_client, - (CLIENT, "--async", "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock", "start",)) - self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "--async", "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock", "start",)) + self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") + finally: + _kill_srv(tmp) def testVisualWait(self): sleeptime = 0.035 @@ -339,73 +400,89 @@ class Fail2banServerTest(LogCaptureTestCase): @withtmpdir def testServerStartBackground(self, tmp): - # don't add "--async" by start, because if will fork current process by daemonize - # (we can't fork the test cases process), - # because server started internal communication in new thread use INHERITED as logtarget here: - startparams = _start_params(tmp, logtarget="INHERITED") - # start: - self.assertRaises(ExitException, _exec_server, - (SERVER, "-b") + startparams) - self.assertLogged("Server ready") - self.assertLogged("Exit with code 0") try: + # don't add "--async" by start, because if will fork current process by daemonize + # (we can't fork the test cases process), + # because server started internal communication in new thread use INHERITED as logtarget here: + startparams = _start_params(tmp, logtarget="INHERITED") + # start: self.assertRaises(ExitException, _exec_server, - (SERVER,) + startparams + ("echo", "TEST-ECHO",)) - self.assertRaises(FailExitException, _exec_server, - (SERVER,) + startparams + ("~~unknown~cmd~failed~~",)) - finally: - self.pruneLog() - # stop: - self.assertRaises(ExitException, _exec_server, - (SERVER,) + startparams + ("stop",)) - self.assertLogged("Shutdown successful") + (SERVER, "-b") + startparams) + self.assertLogged("Server ready") self.assertLogged("Exit with code 0") + try: + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("echo", "TEST-ECHO",)) + self.assertRaises(FailExitException, _exec_server, + (SERVER,) + startparams + ("~~unknown~cmd~failed~~",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("stop",)) + self.assertLogged("Shutdown successful") + self.assertLogged("Exit with code 0") + finally: + _kill_srv(tmp) def _testServerStartForeground(self, tmp, startparams, phase): # start and wait to end (foreground): + logSys.debug("-- start of test worker") phase['start'] = True self.assertRaises(ExitException, _exec_server, (SERVER, "-f") + startparams + ("start",)) # end : phase['end'] = True + logSys.debug("-- end of test worker") + @withtmpdir def testServerStartForeground(self, tmp): - # started directly here, so prevent overwrite test cases logger with "INHERITED" - startparams = _start_params(tmp, logtarget="INHERITED") - # because foreground block execution - start it in thread: - phase = dict() - Thread(name="_TestCaseWorker", - target=Fail2banServerTest._testServerStartForeground, args=(self, tmp, startparams, phase)).start() + th = None try: - # wait for start thread: - Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) - self.assertTrue(phase.get('start', None)) - # wait for server (socket): - Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) - self.assertLogged("Starting communication") - self.assertRaises(ExitException, _exec_server, - (SERVER,) + startparams + ("ping",)) - self.assertRaises(FailExitException, _exec_server, - (SERVER,) + startparams + ("~~unknown~cmd~failed~~",)) - self.assertRaises(ExitException, _exec_server, - (SERVER,) + startparams + ("echo", "TEST-ECHO",)) + # started directly here, so prevent overwrite test cases logger with "INHERITED" + startparams = _start_params(tmp, logtarget="INHERITED") + # because foreground block execution - start it in thread: + phase = dict() + th = Thread(name="_TestCaseWorker", + target=Fail2banServerTest._testServerStartForeground, args=(self, tmp, startparams, phase)) + th.daemon = True + th.start() + try: + # wait for start thread: + Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) + self.assertTrue(phase.get('start', None)) + # wait for server (socket): + Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) + self.assertLogged("Starting communication") + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("ping",)) + self.assertRaises(FailExitException, _exec_server, + (SERVER,) + startparams + ("~~unknown~cmd~failed~~",)) + self.assertRaises(ExitException, _exec_server, + (SERVER,) + startparams + ("echo", "TEST-ECHO",)) + finally: + self.pruneLog() + # stop: + self.assertRaises(ExitException, _exec_server, + (SERVER,) + 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") finally: - self.pruneLog() - # stop: - self.assertRaises(ExitException, _exec_server, - (SERVER,) + 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") - self.assertLogged("Exit with code 0") + _kill_srv(tmp) + if th: + th.join() @withtmpdir def testServerFailStart(self, tmp): - self.assertRaises(FailExitException, _exec_server, - (SERVER, "-c", tmp+"/miss",)) - self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") + try: + self.assertRaises(FailExitException, _exec_server, + (SERVER, "-c", tmp+"/miss",)) + self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") - self.assertRaises(FailExitException, _exec_server, - (SERVER, "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock",)) - self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") + self.assertRaises(FailExitException, _exec_server, + (SERVER, "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock",)) + self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") + finally: + _kill_srv(tmp) diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 9cdace42..1a54d37f 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -312,9 +312,8 @@ class LogCaptureTestCase(unittest.TestCase): # Let's log everything into a string self._log = StringIO() logSys.handlers = [logging.StreamHandler(self._log)] - if self._old_level <= logging.DEBUG: + if self._old_level <= logging.DEBUG: # so if DEBUG etc -- show them (and log it in travis)! print("") - if self._old_level < logging.DEBUG: # so if HEAVYDEBUG etc -- show them! logSys.handlers += self._old_handlers logSys.debug('--'*40) logSys.setLevel(getattr(logging, 'DEBUG')) From 6cd19894e9b2630be3fdc2a60ff4acb39fe55c6d Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 12 Feb 2016 21:51:32 +0100 Subject: [PATCH 53/60] some compatibility fixes (prevent forking of testcase-process, code review), wait 4 server ready, test cases fixed (py2/py3) --- fail2ban/client/fail2banclient.py | 80 ++++++++++------ fail2ban/client/fail2bancmdline.py | 8 +- fail2ban/client/fail2banserver.py | 108 +++++++++++++-------- fail2ban/server/server.py | 36 ++++--- fail2ban/tests/fail2banclienttestcase.py | 116 +++++++++++++---------- fail2ban/tests/utils.py | 6 +- 6 files changed, 219 insertions(+), 135 deletions(-) diff --git a/fail2ban/client/fail2banclient.py b/fail2ban/client/fail2banclient.py index 4f4caf79..ad5cc57e 100644 --- a/fail2ban/client/fail2banclient.py +++ b/fail2ban/client/fail2banclient.py @@ -34,14 +34,18 @@ from threading import Thread from ..version import version from .csocket import CSocket from .beautifier import Beautifier -from .fail2bancmdline import Fail2banCmdLine, ExitException, PRODUCTION, logSys, exit, output +from .fail2bancmdline import Fail2banCmdLine, ServerExecutionException, ExitException, \ + logSys, PRODUCTION, exit, output MAX_WAITTIME = 30 +PROMPT = "fail2ban> " def _thread_name(): return threading.current_thread().__class__.__name__ +def input_command(): + return raw_input(PROMPT) ## # @@ -49,8 +53,6 @@ def _thread_name(): class Fail2banClient(Fail2banCmdLine, Thread): - PROMPT = "fail2ban> " - def __init__(self): Fail2banCmdLine.__init__(self) Thread.__init__(self) @@ -91,11 +93,11 @@ class Fail2banClient(Fail2banCmdLine, Thread): client = CSocket(self._conf["socket"]) ret = client.send(c) if ret[0] == 0: - logSys.debug("OK : " + `ret[1]`) + logSys.debug("OK : %r", ret[1]) if showRet or c[0] == 'echo': output(beautifier.beautify(ret[1])) else: - logSys.error("NOK: " + `ret[1].args`) + logSys.error("NOK: %r", ret[1].args) if showRet: output(beautifier.beautifyError(ret[1])) streamRet = False @@ -202,7 +204,10 @@ class Fail2banClient(Fail2banCmdLine, Thread): except Exception as e: output("") logSys.error("Exception while starting server " + ("background" if background else "foreground")) - logSys.error(e) + if self._conf["verbose"] > 1: + logSys.exception(e) + else: + logSys.error(e) return False return True @@ -249,7 +254,9 @@ class Fail2banClient(Fail2banCmdLine, Thread): if self._conf.get("interactive", False): output(' ## stop ... ') self.__processCommand(['stop']) - self.__waitOnServer(False) + if not self.__waitOnServer(False): + logSys.error("Could not stop server") + return False # in interactive mode reset config, to make full-reload if there something changed: if self._conf.get("interactive", False): output(' ## load configuration ... ') @@ -286,10 +293,14 @@ class Fail2banClient(Fail2banCmdLine, Thread): def __processStartStreamAfterWait(self, *args): try: # Wait for the server to start - self.__waitOnServer() + if not self.__waitOnServer(): + logSys.error("Could not find server, waiting failed") + return False # Configure the server self.__processCmd(*args) - except ServerExecutionException: + except ServerExecutionException as e: + if self._conf["verbose"] > 1: + logSys.exception(e) logSys.error("Could not start server. Maybe an old " "socket file is still present. Try to " "remove " + self._conf["socket"] + ". If " @@ -306,11 +317,13 @@ class Fail2banClient(Fail2banCmdLine, Thread): # Wait for the server to start (the server has 30 seconds to answer ping) starttime = time.time() logSys.debug("__waitOnServer: %r", (alive, maxtime)) + test = lambda: os.path.exists(self._conf["socket"]) and self.__ping() with VisualWait(self._conf["verbose"]) as vis: - while self._alive and ( - not self.__ping() == alive or ( - not alive and os.path.exists(self._conf["socket"]) - )): + sltime = 0.0125 / 2 + while self._alive: + runf = test() + if runf == alive: + return True now = time.time() # Wonderful visual :) if now > starttime + 1: @@ -318,7 +331,9 @@ class Fail2banClient(Fail2banCmdLine, Thread): # f end time reached: if now - starttime >= maxtime: raise ServerExecutionException("Failed to start server") - time.sleep(0.1) + sltime = min(sltime * 2, 0.5) + time.sleep(sltime) + return False def start(self, argv): # Install signal handlers @@ -332,27 +347,31 @@ class Fail2banClient(Fail2banCmdLine, Thread): if self._argv is None: ret = self.initCmdLine(argv) if ret is not None: - return ret + if ret: + return True + raise ServerExecutionException("Init of command line failed") # Commands args = self._args # Interactive mode if self._conf.get("interactive", False): - try: - import readline - except ImportError: - logSys.error("Readline not available") - return False + # no readline in test: + if PRODUCTION: # pragma: no cover + try: + import readline + except ImportError: + raise ServerExecutionException("Readline not available") try: ret = True if len(args) > 0: ret = self.__processCommand(args) if ret: - readline.parse_and_bind("tab: complete") + if PRODUCTION: # pragma: no cover + readline.parse_and_bind("tab: complete") self.dispInteractive() while True: - cmd = raw_input(self.PROMPT) + cmd = input_command() if cmd == "exit" or cmd == "quit": # Exit return True @@ -362,26 +381,31 @@ class Fail2banClient(Fail2banCmdLine, Thread): try: self.__processCommand(shlex.split(cmd)) except Exception, e: - logSys.error(e) + if self._conf["verbose"] > 1: + logSys.exception(e) + else: + logSys.error(e) except (EOFError, KeyboardInterrupt): output("") - return True + raise # Single command mode else: if len(args) < 1: self.dispUsage() return False return self.__processCommand(args) + except Exception as e: + if self._conf["verbose"] > 1: + logSys.exception(e) + else: + logSys.error(e) + return False finally: self._alive = False for s, sh in _prev_signals.iteritems(): signal.signal(s, sh) -class ServerExecutionException(Exception): - pass - - ## # Wonderful visual :) # diff --git a/fail2ban/client/fail2bancmdline.py b/fail2ban/client/fail2bancmdline.py index abbf8363..58fd47c2 100644 --- a/fail2ban/client/fail2bancmdline.py +++ b/fail2ban/client/fail2bancmdline.py @@ -259,5 +259,9 @@ class Fail2banCmdLine(): exit = Fail2banCmdLine.exit -class ExitException: - pass \ No newline at end of file +class ExitException(Exception): + pass + + +class ServerExecutionException(Exception): + pass diff --git a/fail2ban/client/fail2banserver.py b/fail2ban/client/fail2banserver.py index ac927251..73e528ca 100644 --- a/fail2ban/client/fail2banserver.py +++ b/fail2ban/client/fail2banserver.py @@ -24,10 +24,8 @@ __license__ = "GPL" import os import sys -from ..version import version -from ..server.server import Server -from ..server.utils import Utils -from .fail2bancmdline import Fail2banCmdLine, logSys, PRODUCTION, exit +from .fail2bancmdline import Fail2banCmdLine, ServerExecutionException, \ + logSys, PRODUCTION, exit MAX_WAITTIME = 30 @@ -44,13 +42,14 @@ class Fail2banServer(Fail2banCmdLine): # Fail2banCmdLine.__init__(self) ## - # Start Fail2Ban server in main thread without fork (foreground). + # Start Fail2Ban server in main thread without fork (direct, it can fork itself in Server if daemon=True). # - # Start the Fail2ban server in foreground (daemon mode or not). + # Start the Fail2ban server in background/foreground (daemon mode or not). @staticmethod def startServerDirect(conf, daemon=True): logSys.debug("-- direct starting of server in %s, deamon: %s", os.getpid(), daemon) + from ..server.server import Server server = None try: # Start it in foreground (current thread, not new process), @@ -59,11 +58,14 @@ class Fail2banServer(Fail2banCmdLine): server.start(conf["socket"], conf["pidfile"], conf["force"], conf=conf) - except Exception, e: - logSys.exception(e) - if server: - server.quit() - exit(-1) + except Exception as e: + try: + if server: + server.quit() + except Exception as e2: + if conf["verbose"] > 1: + logSys.exception(e2) + raise return server @@ -74,10 +76,6 @@ class Fail2banServer(Fail2banCmdLine): @staticmethod def startServerAsync(conf): - # Directory of client (to try the first start from the same directory as client): - startdir = sys.path[0] - if startdir in ("", "."): # may be uresolved in test-cases, so get bin-directory: - startdir = os.path.dirname(sys.argv[0]) # Forks the current process, don't fork if async specified (ex: test cases) pid = 0 frk = not conf["async"] and PRODUCTION @@ -103,25 +101,43 @@ class Fail2banServer(Fail2banCmdLine): for o in ('loglevel', 'logtarget', 'syslogsocket'): args.append("--"+o) args.append(conf[o]) - try: - # Use the current directory. - exe = os.path.abspath(os.path.join(startdir, SERVER)) + # Directory of client (to try the first start from current or the same directory as client, and from relative bin): + exe = Fail2banServer.getServerPath() + if not frk: + # Wrapr args to use the same python version in client/server (important for multi-python systems): + args[0] = exe + exe = sys.executable + args[0:0] = [exe] logSys.debug("Starting %r with args %r", exe, args) if frk: - os.execv(exe, args) + return os.execv(exe, args) else: - os.spawnv(os.P_NOWAITO, exe, args) + # use P_WAIT instead of P_NOWAIT (to prevent defunct-zomby process), it startet as daemon, so parent exit fast after fork): + return os.spawnv(os.P_WAIT, exe, args) except OSError as e: - try: - # Use the PATH env. - logSys.warning("Initial start attempt failed (%s). Starting %r with the same args", e, SERVER) - if frk: - os.execvp(SERVER, args) - else: - os.spawnvp(os.P_NOWAITO, SERVER, args) - except OSError: - exit(-1) + # Use the PATH env. + logSys.warning("Initial start attempt failed (%s). Starting %r with the same args", e, SERVER) + if frk: + return os.execvp(SERVER, args) + else: + del args[0] + args[0] = SERVER + return os.spawnvp(os.P_WAIT, SERVER, args) + return pid + + @staticmethod + def getServerPath(): + startdir = sys.path[0] + exe = os.path.abspath(os.path.join(startdir, SERVER)) + if not os.path.isfile(exe): # may be uresolved in test-cases, so get relative starter (client): + startdir = os.path.dirname(sys.argv[0]) + exe = os.path.abspath(os.path.join(startdir, SERVER)) + if not os.path.isfile(exe): # may be uresolved in test-cases, so try to get relative bin-directory: + startdir = os.path.dirname(os.path.abspath(__file__)) + startdir = os.path.join(os.path.dirname(os.path.dirname(startdir)), "bin") + exe = os.path.abspath(os.path.join(startdir, SERVER)) + return exe def _Fail2banClient(self): from .fail2banclient import Fail2banClient @@ -139,18 +155,24 @@ class Fail2banServer(Fail2banCmdLine): args = self._args cli = None - # If client mode - whole processing over client: - if len(args) or self._conf.get("interactive", False): - cli = self._Fail2banClient() - return cli.start(argv) + # Just start: + if len(args) == 1 and args[0] == 'start' and not self._conf.get("interactive", False): + pass + else: + # If client mode - whole processing over client: + if len(args) or self._conf.get("interactive", False): + cli = self._Fail2banClient() + return cli.start(argv) # Start the server: server = None try: - # async = True, if started from client, should fork, daemonize, etc... - # background = True, if should start in new process, otherwise start in foreground - async = self._conf.get("async", False) + from ..server.utils import Utils + # background = True, if should be new process running in background, otherwise start in foreground + # process will be forked in daemonize, inside of Server module. + # async = True, if started from client, should... background = self._conf["background"] + async = self._conf.get("async", False) # If was started not from the client: if not async: # Start new thread with client to read configuration and @@ -162,13 +184,14 @@ class Fail2banServer(Fail2banCmdLine): # wait up to MAX_WAITTIME, do not continue if configuration is not 100% valid: Utils.wait_for(lambda: phase.get('ready', None) is not None, MAX_WAITTIME) if not phase.get('start', False): - return False + raise ServerExecutionException('Async configuration of server failed') # Start server, daemonize it, etc. - if async or not background: - server = Fail2banServer.startServerDirect(self._conf, background) - else: - Fail2banServer.startServerAsync(self._conf) + pid = os.getpid() + server = Fail2banServer.startServerDirect(self._conf, background) + # If forked - just exit other processes + if pid != os.getpid(): + os._exit(0) if cli: cli._server = server @@ -182,7 +205,8 @@ class Fail2banServer(Fail2banCmdLine): logSys.debug('Starting server done') except Exception, e: - logSys.exception(e) + if self._conf["verbose"] > 1: + logSys.exception(e) if server: server.quit() exit(-1) diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 9f56f8a8..0ddaea35 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -93,9 +93,15 @@ class Server: if self.__daemon: # pragma: no cover logSys.info("Starting in daemon mode") ret = self.__createDaemon() - if not ret: - logSys.error("Could not create daemon") - raise ServerInitializationError("Could not create daemon") + # If forked parent - return here (parent process will configure server later): + if ret is None: + return False + # If error: + if not ret[0]: + err = "Could not create daemon %s", ret[1:] + logSys.error(err) + raise ServerInitializationError(err) + # We are daemon. # Set all logging parameters (or use default if not specified): self.setSyslogSocket(conf.get("syslogsocket", @@ -159,8 +165,12 @@ class Server: logging.shutdown() # Restore default signal handlers: - for s, sh in self.__prev_signals.iteritems(): - signal.signal(s, sh) + if _thread_name() == '_MainThread': + for s, sh in self.__prev_signals.iteritems(): + signal.signal(s, sh) + + # Prevent to call quit twice: + self.quit = lambda: False def addJail(self, name, backend): self.__jails.add(name, backend, self.__db) @@ -559,10 +569,9 @@ class Server: # We need to set this in the parent process, so it gets inherited by the # child process, and this makes sure that it is effect even if the parent # terminates quickly. - if _thread_name() == '_MainThread': - for s in (signal.SIGHUP,): - self.__prev_signals[s] = signal.getsignal(s) - signal.signal(s, signal.SIG_IGN) + for s in (signal.SIGHUP,): + self.__prev_signals[s] = signal.getsignal(s) + signal.signal(s, signal.SIG_IGN) try: # Fork a child process so the parent can exit. This will return control @@ -573,7 +582,7 @@ class Server: # PGID. pid = os.fork() except OSError, e: - return((e.errno, e.strerror)) # ERROR (return a tuple) + return (False, (e.errno, e.strerror)) # ERROR (return a tuple) if pid == 0: # The first child. @@ -594,7 +603,7 @@ class Server: # preventing the daemon from ever acquiring a controlling terminal. pid = os.fork() # Fork a second child. except OSError, e: - return((e.errno, e.strerror)) # ERROR (return a tuple) + return (False, (e.errno, e.strerror)) # ERROR (return a tuple) if (pid == 0): # The second child. # Ensure that the daemon doesn't keep any directory in use. Failure @@ -603,7 +612,8 @@ class Server: else: os._exit(0) # Exit parent (the first child) of the second child. else: - os._exit(0) # Exit parent of the first child. + # Signal to exit, parent of the first child. + return None # Close all open files. Try the system configuration variable, SC_OPEN_MAX, # for the maximum number of open files to close. If it doesn't exist, use @@ -631,7 +641,7 @@ class Server: os.open("/dev/null", os.O_RDONLY) # standard input (0) os.open("/dev/null", os.O_RDWR) # standard output (1) os.open("/dev/null", os.O_RDWR) # standard error (2) - return True + return (True,) class ServerInitializationError(Exception): diff --git a/fail2ban/tests/fail2banclienttestcase.py b/fail2ban/tests/fail2banclienttestcase.py index fd8a074b..6fee732b 100644 --- a/fail2ban/tests/fail2banclienttestcase.py +++ b/fail2ban/tests/fail2banclienttestcase.py @@ -26,6 +26,7 @@ __license__ = "GPL" import fileinput import os import re +import sys import time import unittest @@ -50,10 +51,9 @@ else: CLIENT = "fail2ban-client" SERVER = "fail2ban-server" -BIN = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "bin") +BIN = os.path.dirname(Fail2banServer.getServerPath()) -MAX_WAITTIME = 10 -MAX_WAITTIME = unittest.F2B.maxWaitTime(MAX_WAITTIME) +MAX_WAITTIME = 30 if not unittest.F2B.fast else 5 ## # Several wrappers and settings for proper testing: @@ -67,8 +67,6 @@ fail2bancmdline.logSys = \ fail2banclient.logSys = \ fail2banserver.logSys = logSys -LOG_LEVEL = logSys.level - server.DEF_LOGTARGET = "/dev/null" def _test_output(*args): @@ -89,13 +87,13 @@ fail2banclient.exit = \ fail2banserver.exit = _test_exit INTERACT = [] -def _test_raw_input(*args): +def _test_input_command(*args): if len(INTERACT): - #print('--- interact command: ', INTERACT[0]) + #logSys.debug('--- interact command: %r', INTERACT[0]) return INTERACT.pop(0) else: return "exit" -fail2banclient.raw_input = _test_raw_input +fail2banclient.input_command = _test_input_command # prevents change logging params, log capturing, etc: fail2bancmdline.PRODUCTION = \ @@ -142,7 +140,7 @@ def _start_params(tmp, use_stock=False, logtarget="/dev/null"): else: # just empty config directory without anything (only fail2ban.conf/jail.conf): os.mkdir(cfg) - f = open(cfg+"/fail2ban.conf", "wb") + f = open(cfg+"/fail2ban.conf", "w") f.write('\n'.join(( "[Definition]", "loglevel = INFO", @@ -156,20 +154,20 @@ def _start_params(tmp, use_stock=False, logtarget="/dev/null"): "", ))) f.close() - f = open(cfg+"/jail.conf", "wb") + f = open(cfg+"/jail.conf", "w") f.write('\n'.join(( "[INCLUDES]", "", "[DEFAULT]", "", "", ))) f.close() - if LOG_LEVEL < logging.DEBUG: # if HEAVYDEBUG + if logSys.level < logging.DEBUG: # if HEAVYDEBUG _out_file(cfg+"/fail2ban.conf") _out_file(cfg+"/jail.conf") - # parameters: - return ("-c", cfg, - "--logtarget", logtarget, "--loglevel", "DEBUG", "--syslogsocket", "auto", - "-s", tmp+"/f2b.sock", "-p", tmp+"/f2b.pid") + # parameters (sock/pid and config, increase verbosity, set log, etc.): + return ("-c", cfg, "-s", tmp+"/f2b.sock", "-p", tmp+"/f2b.pid", + "-vv", "--logtarget", logtarget, "--loglevel", "DEBUG", "--syslogsocket", "auto", + ) def _kill_srv(pidfile): # pragma: no cover def _pid_exists(pid): @@ -206,14 +204,14 @@ def _kill_srv(pidfile): # pragma: no cover os.kill(pid, signal.SIGKILL) return not _pid_exists(pid) except Exception as e: - sysLog.debug(e) + logSys.debug(e) finally: if f is not None: f.close() return True -class Fail2banClientTest(LogCaptureTestCase): +class Fail2banClientServerBase(LogCaptureTestCase): def setUp(self): """Call before every test case.""" @@ -223,6 +221,21 @@ class Fail2banClientTest(LogCaptureTestCase): """Call after every test case.""" LogCaptureTestCase.tearDown(self) + def _wait_for_srv(self, tmp, ready=True, startparams=None): + sock = tmp+"/f2b.sock" + # wait for server (socket): + ret = Utils.wait_for(lambda: os.path.exists(sock), MAX_WAITTIME) + if not ret: + raise Exception('Unexpected: Socket file does not exists.\nStart failed: %r' % (startparams,)) + if ready: + # wait for communication with worker ready: + ret = Utils.wait_for(lambda: "Server ready" in self.getLog(), MAX_WAITTIME) + if not ret: + raise Exception('Unexpected: Server ready was not found.\nStart failed: %r' % (startparams,)) + + +class Fail2banClientTest(Fail2banClientServerBase): + def testClientUsage(self): self.assertRaises(ExitException, _exec_client, (CLIENT, "-h",)) @@ -232,12 +245,13 @@ class Fail2banClientTest(LogCaptureTestCase): @withtmpdir def testClientStartBackgroundInside(self, tmp): try: - # always add "--async" by start inside, should don't fork by async (not replace client with server, just start in new process) - # (we can't fork the test cases process): + # use once the stock configuration (to test starting also) startparams = _start_params(tmp, True) # start: self.assertRaises(ExitException, _exec_client, - (CLIENT, "--async", "-b") + startparams + ("start",)) + (CLIENT, "-b") + startparams + ("start",)) + # wait for server (socket and ready): + self._wait_for_srv(tmp, True, startparams=startparams) self.assertLogged("Server ready") self.assertLogged("Exit with code 0") try: @@ -245,6 +259,11 @@ class Fail2banClientTest(LogCaptureTestCase): (CLIENT,) + startparams + ("echo", "TEST-ECHO",)) self.assertRaises(FailExitException, _exec_client, (CLIENT,) + startparams + ("~~unknown~cmd~failed~~",)) + self.pruneLog() + # start again (should fail): + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "-b") + startparams + ("start",)) + self.assertLogged("Server already running") finally: self.pruneLog() # stop: @@ -260,11 +279,14 @@ class Fail2banClientTest(LogCaptureTestCase): try: global INTERACT startparams = _start_params(tmp) - # start (without async in new process): - cmd = os.path.join(os.path.join(BIN), CLIENT) + # start (in new process, using the same python version): + cmd = (sys.executable, os.path.join(os.path.join(BIN), CLIENT)) logSys.debug('Start %s ...', cmd) - Utils.executeCmd((cmd,) + startparams + ("start",), - timeout=MAX_WAITTIME, shell=False, output=False) + cmd = cmd + startparams + ("start",) + Utils.executeCmd(cmd, timeout=MAX_WAITTIME, shell=False, output=True) + # wait for server (socket and ready): + self._wait_for_srv(tmp, True, startparams=cmd) + self.assertLogged("Server ready") self.pruneLog() try: # echo from client (inside): @@ -312,7 +334,7 @@ class Fail2banClientTest(LogCaptureTestCase): # start and wait to end (foreground): logSys.debug("-- start of test worker") phase['start'] = True - self.assertRaises(ExitException, _exec_client, + self.assertRaises(fail2bancmdline.ExitException, _exec_client, (CLIENT, "-f") + startparams + ("start",)) # end : phase['end'] = True @@ -334,9 +356,10 @@ class Fail2banClientTest(LogCaptureTestCase): # wait for start thread: Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) self.assertTrue(phase.get('start', None)) - # wait for server (socket): - Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) - self.assertLogged("Starting communication") + # wait for server (socket and ready): + self._wait_for_srv(tmp, True, startparams=startparams) + self.pruneLog() + # several commands to server: self.assertRaises(ExitException, _exec_client, (CLIENT,) + startparams + ("ping",)) self.assertRaises(FailExitException, _exec_client, @@ -382,15 +405,7 @@ class Fail2banClientTest(LogCaptureTestCase): cntr -= 1 -class Fail2banServerTest(LogCaptureTestCase): - - def setUp(self): - """Call before every test case.""" - LogCaptureTestCase.setUp(self) - - def tearDown(self): - """Call after every test case.""" - LogCaptureTestCase.tearDown(self) +class Fail2banServerTest(Fail2banClientServerBase): def testServerUsage(self): self.assertRaises(ExitException, _exec_server, @@ -401,15 +416,17 @@ class Fail2banServerTest(LogCaptureTestCase): @withtmpdir def testServerStartBackground(self, tmp): try: - # don't add "--async" by start, because if will fork current process by daemonize - # (we can't fork the test cases process), - # because server started internal communication in new thread use INHERITED as logtarget here: - startparams = _start_params(tmp, logtarget="INHERITED") - # start: - self.assertRaises(ExitException, _exec_server, - (SERVER, "-b") + startparams) + # to prevent fork of test-cases process, start server in background via command: + startparams = _start_params(tmp) + # start (in new process, using the same python version): + cmd = (sys.executable, os.path.join(os.path.join(BIN), SERVER)) + logSys.debug('Start %s ...', cmd) + cmd = cmd + startparams + ("-b",) + Utils.executeCmd(cmd, timeout=MAX_WAITTIME, shell=False, output=True) + # wait for server (socket and ready): + self._wait_for_srv(tmp, True, startparams=cmd) self.assertLogged("Server ready") - self.assertLogged("Exit with code 0") + self.pruneLog() try: self.assertRaises(ExitException, _exec_server, (SERVER,) + startparams + ("echo", "TEST-ECHO",)) @@ -429,7 +446,7 @@ class Fail2banServerTest(LogCaptureTestCase): # start and wait to end (foreground): logSys.debug("-- start of test worker") phase['start'] = True - self.assertRaises(ExitException, _exec_server, + self.assertRaises(fail2bancmdline.ExitException, _exec_server, (SERVER, "-f") + startparams + ("start",)) # end : phase['end'] = True @@ -451,9 +468,10 @@ class Fail2banServerTest(LogCaptureTestCase): # wait for start thread: Utils.wait_for(lambda: phase.get('start', None) is not None, MAX_WAITTIME) self.assertTrue(phase.get('start', None)) - # wait for server (socket): - Utils.wait_for(lambda: os.path.exists(tmp+"/f2b.sock"), MAX_WAITTIME) - self.assertLogged("Starting communication") + # wait for server (socket and ready): + self._wait_for_srv(tmp, True, startparams=startparams) + self.pruneLog() + # several commands to server: self.assertRaises(ExitException, _exec_server, (SERVER,) + startparams + ("ping",)) self.assertRaises(FailExitException, _exec_server, diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index 1a54d37f..b171511d 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -54,6 +54,10 @@ if not CONFIG_DIR: else: CONFIG_DIR = '/etc/fail2ban' +# In not installed env (setup, test-cases) use fail2ban modules from main directory: +if 1 or os.environ.get('PYTHONPATH', None) is None: + os.putenv('PYTHONPATH', os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))))) class F2B(optparse.Values): def __init__(self, opts={}): @@ -315,7 +319,7 @@ class LogCaptureTestCase(unittest.TestCase): if self._old_level <= logging.DEBUG: # so if DEBUG etc -- show them (and log it in travis)! print("") logSys.handlers += self._old_handlers - logSys.debug('--'*40) + logSys.debug('='*10 + ' %s ' + '='*20, self.id()) logSys.setLevel(getattr(logging, 'DEBUG')) def tearDown(self): From 4ec70d7851bb4ff8a103ac335fc446592401f1f8 Mon Sep 17 00:00:00 2001 From: sebres Date: Mon, 15 Feb 2016 19:19:31 +0100 Subject: [PATCH 54/60] code review, timeout fix, better tracing (and test coverage) by start of server/client (with or without fork) --- fail2ban/client/fail2banclient.py | 21 ++-- fail2ban/client/fail2bancmdline.py | 22 +++-- fail2ban/client/fail2banserver.py | 21 ++-- fail2ban/server/utils.py | 12 ++- fail2ban/tests/actiontestcase.py | 4 +- fail2ban/tests/fail2banclienttestcase.py | 116 ++++++++++++++++++----- 6 files changed, 139 insertions(+), 57 deletions(-) diff --git a/fail2ban/client/fail2banclient.py b/fail2ban/client/fail2banclient.py index ad5cc57e..23d31dc5 100644 --- a/fail2ban/client/fail2banclient.py +++ b/fail2ban/client/fail2banclient.py @@ -37,14 +37,13 @@ from .beautifier import Beautifier from .fail2bancmdline import Fail2banCmdLine, ServerExecutionException, ExitException, \ logSys, PRODUCTION, exit, output -MAX_WAITTIME = 30 PROMPT = "fail2ban> " def _thread_name(): return threading.current_thread().__class__.__name__ -def input_command(): +def input_command(): # pragma: no cover return raw_input(PROMPT) ## @@ -101,13 +100,19 @@ class Fail2banClient(Fail2banCmdLine, Thread): if showRet: output(beautifier.beautifyError(ret[1])) streamRet = False - except socket.error: + except socket.error as e: if showRet or self._conf["verbose"] > 1: - self.__logSocketError() + if showRet or c != ["ping"]: + self.__logSocketError() + else: + logSys.debug(" -- ping failed -- %r", e) return False - except Exception, e: + except Exception as e: # pragma: no cover if showRet or self._conf["verbose"] > 1: - logSys.error(e) + if self._conf["verbose"] > 1: + logSys.exception(e) + else: + logSys.error(e) return False finally: # prevent errors by close during shutdown (on exit command): @@ -123,7 +128,7 @@ class Fail2banClient(Fail2banCmdLine, Thread): def __logSocketError(self): try: - if os.access(self._conf["socket"], os.F_OK): + if os.access(self._conf["socket"], os.F_OK): # pragma: no cover # This doesn't check if path is a socket, # but socket.error should be raised if os.access(self._conf["socket"], os.W_OK): @@ -313,7 +318,7 @@ class Fail2banClient(Fail2banCmdLine, Thread): def __waitOnServer(self, alive=True, maxtime=None): if maxtime is None: - maxtime = MAX_WAITTIME + maxtime = self._conf["timeout"] # Wait for the server to start (the server has 30 seconds to answer ping) starttime = time.time() logSys.debug("__waitOnServer: %r", (alive, maxtime)) diff --git a/fail2ban/client/fail2bancmdline.py b/fail2ban/client/fail2bancmdline.py index 58fd47c2..a7754bc3 100644 --- a/fail2ban/client/fail2bancmdline.py +++ b/fail2ban/client/fail2bancmdline.py @@ -33,13 +33,14 @@ from ..helpers import getLogger # Gets the instance of the logger. logSys = getLogger("fail2ban") -def output(s): +def output(s): # pragma: no cover print(s) CONFIG_PARAMS = ("socket", "pidfile", "logtarget", "loglevel", "syslogsocket",) # Used to signal - we are in test cases (ex: prevents change logging params, log capturing, etc) PRODUCTION = True +MAX_WAITTIME = 30 class Fail2banCmdLine(): @@ -56,7 +57,8 @@ class Fail2banCmdLine(): "background": True, "verbose": 1, "socket": None, - "pidfile": None + "pidfile": None, + "timeout": MAX_WAITTIME } @property @@ -109,6 +111,7 @@ class Fail2banCmdLine(): output(" -b start server in background (default)") output(" -f start server in foreground") output(" --async start server in async mode (for internal usage only, don't read configuration)") + output(" --timeout timeout to wait for the server (for internal usage only, don't read configuration)") output(" -h, --help display this help message") output(" -V, --version print the version") @@ -126,8 +129,6 @@ class Fail2banCmdLine(): """ for opt in optList: o = opt[0] - if o == "--async": - self._conf["async"] = True if o == "-c": self._conf["conf"] = opt[1] elif o == "-s": @@ -150,6 +151,11 @@ class Fail2banCmdLine(): self._conf["background"] = True elif o == "-f": self._conf["background"] = False + elif o == "--async": + self._conf["async"] = True + elif o == "-timeout": + from ..mytime import MyTime + self._conf["timeout"] = MyTime.str2seconds(opt[1]) elif o in ["-h", "--help"]: self.dispUsage() return True @@ -170,7 +176,7 @@ class Fail2banCmdLine(): # Reads the command line options. try: cmdOpts = 'hc:s:p:xfbdviqV' - cmdLongOpts = ['loglevel=', 'logtarget=', 'syslogsocket=', 'async', 'help', 'version'] + cmdLongOpts = ['loglevel=', 'logtarget=', 'syslogsocket=', 'async', 'timeout=', 'help', 'version'] optList, self._args = getopt.getopt(self._argv[1:], cmdOpts, cmdLongOpts) except getopt.GetoptError: self.dispUsage() @@ -180,6 +186,8 @@ class Fail2banCmdLine(): if ret is not None: return ret + logSys.debug("-- conf: %r, args: %r", self._conf, self._args) + if initial and PRODUCTION: # pragma: no cover - can't test verbose = self._conf["verbose"] if verbose <= 0: @@ -244,11 +252,11 @@ class Fail2banCmdLine(): @staticmethod def dumpConfig(cmd): for c in cmd: - print c + output(c) return True @staticmethod - def exit(code=0): + def exit(code=0): # pragma: no cover - can't test logSys.debug("Exit with code %s", code) if os._exit: os._exit(code) diff --git a/fail2ban/client/fail2banserver.py b/fail2ban/client/fail2banserver.py index 73e528ca..a6c8a7c5 100644 --- a/fail2ban/client/fail2banserver.py +++ b/fail2ban/client/fail2banserver.py @@ -27,8 +27,6 @@ import sys from .fail2bancmdline import Fail2banCmdLine, ServerExecutionException, \ logSys, PRODUCTION, exit -MAX_WAITTIME = 30 - SERVER = "fail2ban-server" ## @@ -114,16 +112,17 @@ class Fail2banServer(Fail2banCmdLine): return os.execv(exe, args) else: # use P_WAIT instead of P_NOWAIT (to prevent defunct-zomby process), it startet as daemon, so parent exit fast after fork): - return os.spawnv(os.P_WAIT, exe, args) - except OSError as e: + ret = os.spawnv(os.P_WAIT, exe, args) + if ret != 0: + raise OSError(ret, "Unknown error by executing server %r with %r" % (args[1], exe)) + return 0 + except OSError as e: # pragma: no cover + if not frk: #not PRODUCTION: + raise # Use the PATH env. logSys.warning("Initial start attempt failed (%s). Starting %r with the same args", e, SERVER) if frk: return os.execvp(SERVER, args) - else: - del args[0] - args[0] = SERVER - return os.spawnvp(os.P_WAIT, SERVER, args) return pid @staticmethod @@ -181,8 +180,8 @@ class Fail2banServer(Fail2banCmdLine): phase = dict() logSys.debug('Configure via async client thread') cli.configureServer(async=True, phase=phase) - # wait up to MAX_WAITTIME, do not continue if configuration is not 100% valid: - Utils.wait_for(lambda: phase.get('ready', None) is not None, MAX_WAITTIME) + # wait, do not continue if configuration is not 100% valid: + Utils.wait_for(lambda: phase.get('ready', None) is not None, self._conf["timeout"]) if not phase.get('start', False): raise ServerExecutionException('Async configuration of server failed') @@ -197,7 +196,7 @@ class Fail2banServer(Fail2banCmdLine): # wait for client answer "done": if not async and cli: - Utils.wait_for(lambda: phase.get('done', None) is not None, MAX_WAITTIME) + Utils.wait_for(lambda: phase.get('done', None) is not None, self._conf["timeout"]) if not phase.get('done', False): if server: server.quit() diff --git a/fail2ban/server/utils.py b/fail2ban/server/utils.py index a8496f8e..fcf54d5f 100644 --- a/fail2ban/server/utils.py +++ b/fail2ban/server/utils.py @@ -168,8 +168,10 @@ class Utils(): stdout = popen.stdout.read() except IOError as e: logSys.error(" ... -- failed to read stdout %s", e) - if stdout is not None and stdout != '': - logSys.log(std_level, "%s -- stdout: %r", realCmd, stdout) + if stdout is not None and stdout != '' and std_level >= logSys.getEffectiveLevel(): + logSys.log(std_level, "%s -- stdout:", realCmd) + for l in stdout.splitlines(): + logSys.log(std_level, " -- stdout: %r", l) popen.stdout.close() if popen.stderr: try: @@ -178,8 +180,10 @@ class Utils(): stderr = popen.stderr.read() except IOError as e: logSys.error(" ... -- failed to read stderr %s", e) - if stderr is not None and stderr != '': - logSys.log(std_level, "%s -- stderr: %r", realCmd, stderr) + if stderr is not None and stderr != '' and std_level >= logSys.getEffectiveLevel(): + logSys.log(std_level, "%s -- stderr:", realCmd) + for l in stderr.splitlines(): + logSys.log(std_level, " -- stderr: %r", l) popen.stderr.close() if retcode == 0: diff --git a/fail2ban/tests/actiontestcase.py b/fail2ban/tests/actiontestcase.py index 6d8fcc82..1872eb1f 100644 --- a/fail2ban/tests/actiontestcase.py +++ b/fail2ban/tests/actiontestcase.py @@ -271,11 +271,11 @@ class CommandActionTest(LogCaptureTestCase): def testCaptureStdOutErr(self): CommandAction.executeCmd('echo "How now brown cow"') - self.assertLogged("'How now brown cow\\n'") + self.assertLogged("stdout: 'How now brown cow'\n", "stdout: b'How now brown cow'\n") CommandAction.executeCmd( 'echo "The rain in Spain stays mainly in the plain" 1>&2') self.assertLogged( - "'The rain in Spain stays mainly in the plain\\n'") + "stderr: 'The rain in Spain stays mainly in the plain'\n", "stderr: b'The rain in Spain stays mainly in the plain'\n") def testCallingMap(self): mymap = CallingMap(callme=lambda: str(10), error=lambda: int('a'), diff --git a/fail2ban/tests/fail2banclienttestcase.py b/fail2ban/tests/fail2banclienttestcase.py index 6fee732b..aa507fe6 100644 --- a/fail2ban/tests/fail2banclienttestcase.py +++ b/fail2ban/tests/fail2banclienttestcase.py @@ -43,11 +43,6 @@ from .utils import LogCaptureTestCase, logSys, withtmpdir, shutil, logging STOCK_CONF_DIR = "config" STOCK = os.path.exists(os.path.join(STOCK_CONF_DIR,'fail2ban.conf')) -TEST_CONF_DIR = os.path.join(os.path.dirname(__file__), "config") -if STOCK: - CONF_DIR = STOCK_CONF_DIR -else: - CONF_DIR = TEST_CONF_DIR CLIENT = "fail2ban-client" SERVER = "fail2ban-server" @@ -59,9 +54,7 @@ MAX_WAITTIME = 30 if not unittest.F2B.fast else 5 # Several wrappers and settings for proper testing: # -fail2banclient.MAX_WAITTIME = \ -fail2banserver.MAX_WAITTIME = MAX_WAITTIME - +fail2bancmdline.MAX_WAITTIME = MAX_WAITTIME-1 fail2bancmdline.logSys = \ fail2banclient.logSys = \ @@ -167,6 +160,7 @@ def _start_params(tmp, use_stock=False, logtarget="/dev/null"): # parameters (sock/pid and config, increase verbosity, set log, etc.): return ("-c", cfg, "-s", tmp+"/f2b.sock", "-p", tmp+"/f2b.pid", "-vv", "--logtarget", logtarget, "--loglevel", "DEBUG", "--syslogsocket", "auto", + "--timeout", str(fail2bancmdline.MAX_WAITTIME), ) def _kill_srv(pidfile): # pragma: no cover @@ -199,7 +193,7 @@ def _kill_srv(pidfile): # pragma: no cover ## try to preper stop (have signal handler): os.kill(pid, signal.SIGTERM) ## check still exists after small timeout: - if not Utils.wait_for(lambda: not _pid_exists(pid), MAX_WAITTIME / 3): + if not Utils.wait_for(lambda: not _pid_exists(pid), 1): ## try to kill hereafter: os.kill(pid, signal.SIGKILL) return not _pid_exists(pid) @@ -222,25 +216,50 @@ class Fail2banClientServerBase(LogCaptureTestCase): LogCaptureTestCase.tearDown(self) def _wait_for_srv(self, tmp, ready=True, startparams=None): - sock = tmp+"/f2b.sock" - # wait for server (socket): - ret = Utils.wait_for(lambda: os.path.exists(sock), MAX_WAITTIME) - if not ret: - raise Exception('Unexpected: Socket file does not exists.\nStart failed: %r' % (startparams,)) - if ready: - # wait for communication with worker ready: - ret = Utils.wait_for(lambda: "Server ready" in self.getLog(), MAX_WAITTIME) + try: + sock = tmp+"/f2b.sock" + # wait for server (socket): + ret = Utils.wait_for(lambda: os.path.exists(sock), MAX_WAITTIME) if not ret: - raise Exception('Unexpected: Server ready was not found.\nStart failed: %r' % (startparams,)) + raise Exception('Unexpected: Socket file does not exists.\nStart failed: %r' % (startparams,)) + if ready: + # wait for communication with worker ready: + ret = Utils.wait_for(lambda: "Server ready" in self.getLog(), MAX_WAITTIME) + if not ret: + raise Exception('Unexpected: Server ready was not found.\nStart failed: %r' % (startparams,)) + except: # pragma: no cover + log = tmp+"/f2b.log" + if os.path.isfile(log): + _out_file(log) + else: + logSys.debug("No log file %s to examine details of error", log) + raise class Fail2banClientTest(Fail2banClientServerBase): + def testConsistency(self): + self.assertTrue(os.path.isfile(os.path.join(os.path.join(BIN), CLIENT))) + self.assertTrue(os.path.isfile(os.path.join(os.path.join(BIN), SERVER))) + def testClientUsage(self): self.assertRaises(ExitException, _exec_client, (CLIENT, "-h",)) self.assertLogged("Usage: " + CLIENT) self.assertLogged("Report bugs to ") + self.pruneLog() + self.assertRaises(ExitException, _exec_client, + (CLIENT, "-vq", "-V",)) + self.assertLogged("Fail2Ban v" + fail2bancmdline.version) + + @withtmpdir + def testClientDump(self, tmp): + # use here the stock configuration (if possible) + startparams = _start_params(tmp, True) + self.assertRaises(ExitException, _exec_client, + ((CLIENT,) + startparams + ("-vvd",))) + self.assertLogged("Loading files") + self.assertLogged("logtarget") @withtmpdir def testClientStartBackgroundInside(self, tmp): @@ -271,6 +290,13 @@ class Fail2banClientTest(Fail2banClientServerBase): (CLIENT,) + startparams + ("stop",)) self.assertLogged("Shutdown successful") self.assertLogged("Exit with code 0") + + self.pruneLog() + # stop again (should fail): + self.assertRaises(FailExitException, _exec_client, + (CLIENT,) + startparams + ("stop",)) + self.assertLogged("Failed to access socket path") + self.assertLogged("Is fail2ban running?") finally: _kill_srv(tmp) @@ -278,12 +304,13 @@ class Fail2banClientTest(Fail2banClientServerBase): def testClientStartBackgroundCall(self, tmp): try: global INTERACT - startparams = _start_params(tmp) + startparams = _start_params(tmp, logtarget=tmp+"/f2b.log") # start (in new process, using the same python version): cmd = (sys.executable, os.path.join(os.path.join(BIN), CLIENT)) logSys.debug('Start %s ...', cmd) - cmd = cmd + startparams + ("start",) - Utils.executeCmd(cmd, timeout=MAX_WAITTIME, shell=False, output=True) + cmd = cmd + startparams + ("--async", "start",) + ret = Utils.executeCmd(cmd, timeout=MAX_WAITTIME, shell=False, output=True) + self.assertTrue(len(ret) and ret[0]) # wait for server (socket and ready): self._wait_for_srv(tmp, True, startparams=cmd) self.assertLogged("Server ready") @@ -383,13 +410,35 @@ class Fail2banClientTest(Fail2banClientServerBase): @withtmpdir def testClientFailStart(self, tmp): try: + # started directly here, so prevent overwrite test cases logger with "INHERITED" + startparams = _start_params(tmp, logtarget="INHERITED") + + ## wrong config directory self.assertRaises(FailExitException, _exec_client, (CLIENT, "--async", "-c", tmp+"/miss", "start",)) self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") + self.pruneLog() + ## wrong socket self.assertRaises(FailExitException, _exec_client, - (CLIENT, "--async", "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock", "start",)) + (CLIENT, "--async", "-c", tmp+"/config", "-s", tmp+"/miss/f2b.sock", "start",)) self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") + self.pruneLog() + + ## already exists: + open(tmp+"/f2b.sock", 'a').close() + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "--async", "-c", tmp+"/config", "-s", tmp+"/f2b.sock", "start",)) + self.assertLogged("Fail2ban seems to be in unexpected state (not running but the socket exists)") + self.pruneLog() + os.remove(tmp+"/f2b.sock") + + ## wrong option: + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "-s",)) + self.assertLogged("Usage: ") + self.pruneLog() + finally: _kill_srv(tmp) @@ -417,12 +466,13 @@ class Fail2banServerTest(Fail2banClientServerBase): def testServerStartBackground(self, tmp): try: # to prevent fork of test-cases process, start server in background via command: - startparams = _start_params(tmp) + startparams = _start_params(tmp, logtarget=tmp+"/f2b.log") # start (in new process, using the same python version): cmd = (sys.executable, os.path.join(os.path.join(BIN), SERVER)) logSys.debug('Start %s ...', cmd) cmd = cmd + startparams + ("-b",) - Utils.executeCmd(cmd, timeout=MAX_WAITTIME, shell=False, output=True) + ret = Utils.executeCmd(cmd, timeout=MAX_WAITTIME, shell=False, output=True) + self.assertTrue(len(ret) and ret[0]) # wait for server (socket and ready): self._wait_for_srv(tmp, True, startparams=cmd) self.assertLogged("Server ready") @@ -495,12 +545,28 @@ class Fail2banServerTest(Fail2banClientServerBase): @withtmpdir def testServerFailStart(self, tmp): try: + # started directly here, so prevent overwrite test cases logger with "INHERITED" + startparams = _start_params(tmp, logtarget="INHERITED") + + ## wrong config directory self.assertRaises(FailExitException, _exec_server, (SERVER, "-c", tmp+"/miss",)) self.assertLogged("Base configuration directory " + tmp+"/miss" + " does not exist") + self.pruneLog() + ## wrong socket self.assertRaises(FailExitException, _exec_server, - (SERVER, "-c", CONF_DIR, "-s", tmp+"/miss/f2b.sock",)) + (SERVER, "-c", tmp+"/config", "-x", "-s", tmp+"/miss/f2b.sock",)) self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") + self.pruneLog() + + ## already exists: + open(tmp+"/f2b.sock", 'a').close() + self.assertRaises(FailExitException, _exec_server, + (SERVER, "-c", tmp+"/config", "-s", tmp+"/f2b.sock",)) + self.assertLogged("Fail2ban seems to be in unexpected state (not running but the socket exists)") + self.pruneLog() + os.remove(tmp+"/f2b.sock") + finally: _kill_srv(tmp) From 0fef5022f098b220a815387dfca63f8084538ccd Mon Sep 17 00:00:00 2001 From: sebres Date: Mon, 15 Feb 2016 20:41:20 +0100 Subject: [PATCH 55/60] reader bug fix: prevent to silent "load" of not existing jail; coverage of test cases increased; --- fail2ban/client/configreader.py | 4 +++- fail2ban/client/fail2banclient.py | 20 ++++++++++---------- fail2ban/client/fail2banserver.py | 24 ++++++++++++------------ fail2ban/client/jailreader.py | 2 +- fail2ban/tests/fail2banclienttestcase.py | 21 +++++++++++++++++++++ 5 files changed, 47 insertions(+), 24 deletions(-) diff --git a/fail2ban/client/configreader.py b/fail2ban/client/configreader.py index c6dd1b60..f333cdc1 100644 --- a/fail2ban/client/configreader.py +++ b/fail2ban/client/configreader.py @@ -208,7 +208,7 @@ class ConfigReaderUnshared(SafeConfigParserWithIncludes): # 1 -> the name of the option # 2 -> the default value for the option - def getOptions(self, sec, options, pOptions=None): + def getOptions(self, sec, options, pOptions=None, shouldExist=False): values = dict() for option in options: try: @@ -222,6 +222,8 @@ class ConfigReaderUnshared(SafeConfigParserWithIncludes): continue values[option[1]] = v except NoSectionError, e: + if shouldExist: + raise # No "Definition" section or wrong basedir logSys.error(e) values[option[1]] = option[2] diff --git a/fail2ban/client/fail2banclient.py b/fail2ban/client/fail2banclient.py index 23d31dc5..a8e0a331 100644 --- a/fail2ban/client/fail2banclient.py +++ b/fail2ban/client/fail2banclient.py @@ -64,7 +64,7 @@ class Fail2banClient(Fail2banCmdLine, Thread): output("and bans the corresponding IP addresses using firewall rules.") output("") - def __sigTERMhandler(self, signum, frame): + def __sigTERMhandler(self, signum, frame): # pragma: no cover # Print a new line because we probably come from wait output("") logSys.warning("Caught signal %d. Exiting" % signum) @@ -141,7 +141,7 @@ class Fail2banClient(Fail2banCmdLine, Thread): logSys.error("Failed to access socket path: %s." " Is fail2ban running?", self._conf["socket"]) - except Exception as e: + except Exception as e: # pragma: no cover logSys.error("Exception while checking socket access: %s", self._conf["socket"]) logSys.error(e) @@ -165,7 +165,7 @@ class Fail2banClient(Fail2banCmdLine, Thread): "There is no directory %s to contain the socket file %s." % (socket_dir, self._conf["socket"])) return None - if not os.access(socket_dir, os.W_OK | os.X_OK): + if not os.access(socket_dir, os.W_OK | os.X_OK): # pragma: no cover logSys.error( "Directory %s exists but not accessible for writing" % (socket_dir,)) @@ -204,9 +204,9 @@ class Fail2banClient(Fail2banCmdLine, Thread): # Start server direct here in main thread (not fork): self._server = Fail2banServer.startServerDirect(self._conf, False) - except ExitException: + except ExitException: # pragma: no cover pass - except Exception as e: + except Exception as e: # pragma: no cover output("") logSys.error("Exception while starting server " + ("background" if background else "foreground")) if self._conf["verbose"] > 1: @@ -259,7 +259,7 @@ class Fail2banClient(Fail2banCmdLine, Thread): if self._conf.get("interactive", False): output(' ## stop ... ') self.__processCommand(['stop']) - if not self.__waitOnServer(False): + if not self.__waitOnServer(False): # pragma: no cover logSys.error("Could not stop server") return False # in interactive mode reset config, to make full-reload if there something changed: @@ -298,12 +298,12 @@ class Fail2banClient(Fail2banCmdLine, Thread): def __processStartStreamAfterWait(self, *args): try: # Wait for the server to start - if not self.__waitOnServer(): + if not self.__waitOnServer(): # pragma: no cover logSys.error("Could not find server, waiting failed") return False # Configure the server self.__processCmd(*args) - except ServerExecutionException as e: + except ServerExecutionException as e: # pragma: no cover if self._conf["verbose"] > 1: logSys.exception(e) logSys.error("Could not start server. Maybe an old " @@ -385,12 +385,12 @@ class Fail2banClient(Fail2banCmdLine, Thread): elif not cmd == "": try: self.__processCommand(shlex.split(cmd)) - except Exception, e: + except Exception, e: # pragma: no cover if self._conf["verbose"] > 1: logSys.exception(e) else: logSys.error(e) - except (EOFError, KeyboardInterrupt): + except (EOFError, KeyboardInterrupt): # pragma: no cover output("") raise # Single command mode diff --git a/fail2ban/client/fail2banserver.py b/fail2ban/client/fail2banserver.py index a6c8a7c5..a511e017 100644 --- a/fail2ban/client/fail2banserver.py +++ b/fail2ban/client/fail2banserver.py @@ -56,7 +56,7 @@ class Fail2banServer(Fail2banCmdLine): server.start(conf["socket"], conf["pidfile"], conf["force"], conf=conf) - except Exception as e: + except Exception as e: # pragma: no cover try: if server: server.quit() @@ -77,7 +77,7 @@ class Fail2banServer(Fail2banCmdLine): # Forks the current process, don't fork if async specified (ex: test cases) pid = 0 frk = not conf["async"] and PRODUCTION - if frk: + if frk: # pragma: no cover pid = os.fork() logSys.debug("-- async starting of server in %s, fork: %s - %s", os.getpid(), frk, pid) if pid == 0: @@ -108,22 +108,20 @@ class Fail2banServer(Fail2banCmdLine): exe = sys.executable args[0:0] = [exe] logSys.debug("Starting %r with args %r", exe, args) - if frk: - return os.execv(exe, args) + if frk: # pragma: no cover + os.execv(exe, args) else: # use P_WAIT instead of P_NOWAIT (to prevent defunct-zomby process), it startet as daemon, so parent exit fast after fork): ret = os.spawnv(os.P_WAIT, exe, args) - if ret != 0: + if ret != 0: # pragma: no cover raise OSError(ret, "Unknown error by executing server %r with %r" % (args[1], exe)) - return 0 except OSError as e: # pragma: no cover if not frk: #not PRODUCTION: raise # Use the PATH env. logSys.warning("Initial start attempt failed (%s). Starting %r with the same args", e, SERVER) - if frk: - return os.execvp(SERVER, args) - return pid + if frk: # pragma: no cover + os.execvp(SERVER, args) @staticmethod def getServerPath(): @@ -189,7 +187,7 @@ class Fail2banServer(Fail2banCmdLine): pid = os.getpid() server = Fail2banServer.startServerDirect(self._conf, background) # If forked - just exit other processes - if pid != os.getpid(): + if pid != os.getpid(): # pragma: no cover os._exit(0) if cli: cli._server = server @@ -198,7 +196,7 @@ class Fail2banServer(Fail2banCmdLine): if not async and cli: Utils.wait_for(lambda: phase.get('done', None) is not None, self._conf["timeout"]) if not phase.get('done', False): - if server: + if server: # pragma: no cover server.quit() exit(-1) logSys.debug('Starting server done') @@ -206,7 +204,9 @@ class Fail2banServer(Fail2banCmdLine): except Exception, e: if self._conf["verbose"] > 1: logSys.exception(e) - if server: + else: + logSys.error(e) + if server: # pragma: no cover server.quit() exit(-1) diff --git a/fail2ban/client/jailreader.py b/fail2ban/client/jailreader.py index 46f910e3..be53b3f3 100644 --- a/fail2ban/client/jailreader.py +++ b/fail2ban/client/jailreader.py @@ -109,7 +109,7 @@ class JailReader(ConfigReader): ["string", "action", ""]] # Read first options only needed for merge defaults ('known/...' from filter): - self.__opts = ConfigReader.getOptions(self, self.__name, opts1st) + self.__opts = ConfigReader.getOptions(self, self.__name, opts1st, shouldExist=True) if not self.__opts: return False diff --git a/fail2ban/tests/fail2banclienttestcase.py b/fail2ban/tests/fail2banclienttestcase.py index aa507fe6..82ffe9b3 100644 --- a/fail2ban/tests/fail2banclienttestcase.py +++ b/fail2ban/tests/fail2banclienttestcase.py @@ -347,6 +347,21 @@ class Fail2banClientTest(Fail2banClientServerBase): self.assertLogged("Server ready") self.assertLogged("Exit with code 0") self.pruneLog() + # test reload missing jail (interactive): + INTERACT += [ + "reload ~~unknown~jail~fail~~", + "exit" + ] + self.assertRaises(ExitException, _exec_client, + (CLIENT,) + startparams + ("-i",)) + self.assertLogged("Failed during configuration: No section: '~~unknown~jail~fail~~'") + self.pruneLog() + # test reload missing jail (direct): + self.assertRaises(FailExitException, _exec_client, + (CLIENT,) + startparams + ("reload", "~~unknown~jail~fail~~")) + self.assertLogged("Failed during configuration: No section: '~~unknown~jail~fail~~'") + self.assertLogged("Exit with code -1") + self.pruneLog() finally: self.pruneLog() # stop: @@ -425,6 +440,12 @@ class Fail2banClientTest(Fail2banClientServerBase): self.assertLogged("There is no directory " + tmp+"/miss" + " to contain the socket file") self.pruneLog() + ## not running + self.assertRaises(FailExitException, _exec_client, + (CLIENT, "-c", tmp+"/config", "-s", tmp+"/f2b.sock", "reload",)) + self.assertLogged("Could not find server") + self.pruneLog() + ## already exists: open(tmp+"/f2b.sock", 'a').close() self.assertRaises(FailExitException, _exec_client, From 95af3c63ac0d0f29ff48bac9be98918907d33cd2 Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 12 Feb 2016 21:57:12 +0100 Subject: [PATCH 56/60] increase readability and details level by increased verbosity --- bin/fail2ban-testcases | 12 +++++++---- fail2ban/server/server.py | 13 +++++++---- fail2ban/tests/actiontestcase.py | 2 +- fail2ban/tests/servertestcase.py | 37 +++++++++++++++++++++----------- fail2ban/tests/utils.py | 1 + 5 files changed, 43 insertions(+), 22 deletions(-) diff --git a/bin/fail2ban-testcases b/bin/fail2ban-testcases index 606b0b06..3b18b7c2 100755 --- a/bin/fail2ban-testcases +++ b/bin/fail2ban-testcases @@ -119,10 +119,14 @@ else: # Custom log format for the verbose tests runs if verbosity > 1: # pragma: no cover - stdout.setFormatter(Formatter(' %(asctime)-15s %(thread)s' + fmt)) -else: # pragma: no cover - # just prefix with the space - stdout.setFormatter(Formatter(fmt)) + if verbosity > 3: + fmt = ' | %(module)15.15s-%(levelno)-2d: %(funcName)-20.20s |' + fmt + if verbosity > 2: + fmt = ' +%(relativeCreated)5d %(thread)X %(levelname)-5.5s' + fmt + else: + fmt = ' %(asctime)-15s %(thread)X %(levelname)-5.5s' + fmt +# +stdout.setFormatter(Formatter(fmt)) logSys.addHandler(stdout) # diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index 0ddaea35..933b3e72 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -67,7 +67,8 @@ class Server: self.__db = None self.__daemon = daemon self.__transm = Transmitter(self) - self.__asyncServer = AsyncServer(self.__transm) + #self.__asyncServer = AsyncServer(self.__transm) + self.__asyncServer = None self.__logLevel = None self.__logTarget = None self.__syslogSocket = None @@ -137,6 +138,7 @@ class Server: # Start the communication logSys.debug("Starting communication") try: + self.__asyncServer = AsyncServer(self.__transm) self.__asyncServer.start(sock, force) except AsyncServerException, e: logSys.error("Could not start server: %s", e) @@ -155,14 +157,17 @@ class Server: # communications first (which should be ok anyways since we # are exiting) # See https://github.com/fail2ban/fail2ban/issues/7 - self.__asyncServer.stop() + if self.__asyncServer is not None: + self.__asyncServer.stop() + self.__asyncServer = None # Now stop all the jails self.stopAllJail() # Only now shutdown the logging. - with self.__loggingLock: - logging.shutdown() + if self.__logTarget is not None: + with self.__loggingLock: + logging.shutdown() # Restore default signal handlers: if _thread_name() == '_MainThread': diff --git a/fail2ban/tests/actiontestcase.py b/fail2ban/tests/actiontestcase.py index 1872eb1f..39984169 100644 --- a/fail2ban/tests/actiontestcase.py +++ b/fail2ban/tests/actiontestcase.py @@ -44,8 +44,8 @@ class CommandActionTest(LogCaptureTestCase): def tearDown(self): """Call after every test case.""" - LogCaptureTestCase.tearDown(self) self.__action.stop() + LogCaptureTestCase.tearDown(self) def testSubstituteRecursiveTags(self): aInfo = { diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 96734262..4b9542c1 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -62,25 +62,18 @@ class TransmitterBase(unittest.TestCase): def setUp(self): """Call before every test case.""" + #super(TransmitterBase, self).setUp() self.transm = self.server._Server__transm - self.tmp_files = [] - sock_fd, sock_name = tempfile.mkstemp('fail2ban.sock', 'transmitter') - os.close(sock_fd) - self.tmp_files.append(sock_name) - pidfile_fd, pidfile_name = tempfile.mkstemp( - 'fail2ban.pid', 'transmitter') - os.close(pidfile_fd) - self.tmp_files.append(pidfile_name) - self.server.start(sock_name, pidfile_name, force=False) + # To test thransmitter we don't need to start server... + #self.server.start('/dev/null', '/dev/null', force=False) self.jailName = "TestJail1" self.server.addJail(self.jailName, FAST_BACKEND) def tearDown(self): """Call after every test case.""" + # stop jails, etc. self.server.quit() - for f in self.tmp_files: - if os.path.exists(f): - os.remove(f) + #super(TransmitterBase, self).tearDown() def setGetTest(self, cmd, inValue, outValue=(None,), outCode=0, jail=None, repr_=False): """Process set/get commands and compare both return values @@ -792,10 +785,10 @@ class TransmitterLogging(TransmitterBase): def setUp(self): self.server = Server() + super(TransmitterLogging, self).setUp() self.server.setLogTarget("/dev/null") self.server.setLogLevel("CRITICAL") self.server.setSyslogSocket("auto") - super(TransmitterLogging, self).setUp() def testLogTarget(self): logTargets = [] @@ -963,3 +956,21 @@ class LoggingTests(LogCaptureTestCase): sys.__excepthook__ = prev_exchook self.assertEqual(len(x), 1) self.assertEqual(x[0][0], RuntimeError) + + def testStartFailedSockExists(self): + tmp_files = [] + sock_fd, sock_name = tempfile.mkstemp('fail2ban.sock', 'f2b-test') + os.close(sock_fd) + tmp_files.append(sock_name) + pidfile_fd, pidfile_name = tempfile.mkstemp('fail2ban.pid', 'f2b-test') + os.close(pidfile_fd) + tmp_files.append(pidfile_name) + server = TestServer() + try: + server.start(sock_name, pidfile_name, force=False) + self.assertLogged("Server already running") + finally: + server.quit() + for f in tmp_files: + if os.path.exists(f): + os.remove(f) diff --git a/fail2ban/tests/utils.py b/fail2ban/tests/utils.py index b171511d..218cb160 100644 --- a/fail2ban/tests/utils.py +++ b/fail2ban/tests/utils.py @@ -325,6 +325,7 @@ class LogCaptureTestCase(unittest.TestCase): def tearDown(self): """Call after every test case.""" # print "O: >>%s<<" % self._log.getvalue() + self.pruneLog() logSys = getLogger("fail2ban") logSys.handlers = self._old_handlers logSys.level = self._old_level From 70c329e235a1dc8562a8eee485a097bf35155875 Mon Sep 17 00:00:00 2001 From: sebres Date: Mon, 15 Feb 2016 19:22:18 +0100 Subject: [PATCH 57/60] increase verbosity for travis/py3 (currently "debug", use "heavydebug" for more details if needed) --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index adb41e7d..71a0a05f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,7 +38,7 @@ script: # Keep the legacy setup.py test approach of checking coverage for python2 - if [[ "$F2B_PY_2" ]]; then coverage run setup.py test; fi # Coverage doesn't pick up setup.py test with python3, so run it directly - - if [[ "$F2B_PY_3" ]]; then coverage run bin/fail2ban-testcases; fi + - if [[ "$F2B_PY_3" ]]; then coverage run bin/fail2ban-testcases -l debug; fi # Use $VENV_BIN (not python) or else sudo will always run the system's python (2.7) - sudo $VENV_BIN/pip install . after_success: From 1ec6782f321bf0fca43a2a598cc33f70b39e7f35 Mon Sep 17 00:00:00 2001 From: sebres Date: Sun, 6 Mar 2016 15:46:52 +0100 Subject: [PATCH 58/60] fix test cases by testing with multi-threaded execution (wait for threaded execution done) --- fail2ban/tests/action_d/test_smtp.py | 29 ++++++++++++++++++---------- fail2ban/tests/observertestcase.py | 13 ++++++++----- fail2ban/tests/servertestcase.py | 3 ++- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/fail2ban/tests/action_d/test_smtp.py b/fail2ban/tests/action_d/test_smtp.py index 1385fe82..4155cfa0 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.assertTrue(aInfo['matches'] in self.smtpd.data) self.action.matches = "ipjailmatches" - self.action.ban(aInfo) + self._exec_and_wait(lambda: self.action.ban(aInfo)) self.assertTrue(aInfo['ipjailmatches'] in self.smtpd.data) self.action.matches = "ipmatches" - self.action.ban(aInfo) + self._exec_and_wait(lambda: self.action.ban(aInfo)) self.assertTrue(aInfo['ipmatches'] in 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/observertestcase.py b/fail2ban/tests/observertestcase.py index e1c29cc9..3e4bfd10 100644 --- a/fail2ban/tests/observertestcase.py +++ b/fail2ban/tests/observertestcase.py @@ -611,11 +611,14 @@ class ObserverTest(LogCaptureTestCase): prev_exchook = sys.__excepthook__ x = [] sys.__excepthook__ = lambda *args: x.append(args) - obs.start() - obs.stop() - obs = None - self.assertTrue(self._is_logged("Unhandled exception")) - sys.__excepthook__ = prev_exchook + 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 d593355e..58f5e173 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -960,9 +960,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) From facda179456ee697978705f5c9e1f081c434d29d Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 10 Jun 2016 02:12:11 +0200 Subject: [PATCH 59/60] Prevent travis failure with "The log length has exceeded the limit of 4 MB. The job has been terminated" --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index ca63b4f7..a188fe25 100644 --- a/.travis.yml +++ b/.travis.yml @@ -38,7 +38,7 @@ script: # Keep the legacy setup.py test approach of checking coverage for python2 - if [[ "$F2B_PY_2" ]]; then coverage run setup.py test; fi # Coverage doesn't pick up setup.py test with python3, so run it directly - - if [[ "$F2B_PY_3" ]]; then coverage run bin/fail2ban-testcases -l debug; fi + - if [[ "$F2B_PY_3" ]]; then coverage run bin/fail2ban-testcases -l info; fi # Use $VENV_BIN (not python) or else sudo will always run the system's python (2.7) - sudo $VENV_BIN/pip install . after_success: From 3bbbe640c0e3a2336e0d6cb2cc9b6fa13d926028 Mon Sep 17 00:00:00 2001 From: sebres Date: Fri, 15 Jul 2016 10:48:02 +0200 Subject: [PATCH 60/60] MANIFEST update for release ban-time-incr version of 0.10 --- MANIFEST | 3 +++ 1 file changed, 3 insertions(+) diff --git a/MANIFEST b/MANIFEST index 05e665b2..a4680157 100644 --- a/MANIFEST +++ b/MANIFEST @@ -194,6 +194,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 @@ -245,6 +246,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 @@ -340,6 +342,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