avoid unhandled exception during flush, better invariant check (and repair), avoid repair by unban/stop etc...

pull/2588/head
sebres 2019-09-20 16:26:15 +02:00
parent 690a0050f0
commit 8f6ba15325
9 changed files with 168 additions and 83 deletions

View File

@ -861,7 +861,8 @@ filter = apache-pass[knocking_url="%(knocking_url)s"]
logpath = %(apache_access_log)s logpath = %(apache_access_log)s
blocktype = RETURN blocktype = RETURN
returntype = DROP returntype = DROP
action = %(action_)s[blocktype=%(blocktype)s, returntype=%(returntype)s] action = %(action_)s[blocktype=%(blocktype)s, returntype=%(returntype)s,
actionstart_on_demand=True, actionrepair_on_unban=True]
bantime = 1h bantime = 1h
maxretry = 1 maxretry = 1
findtime = 1 findtime = 1

View File

@ -44,6 +44,7 @@ class ActionReader(DefinitionInitConfigReader):
"actionreload": ["string", None], "actionreload": ["string", None],
"actioncheck": ["string", None], "actioncheck": ["string", None],
"actionrepair": ["string", None], "actionrepair": ["string", None],
"actionrepair_on_unban": ["string", None],
"actionban": ["string", None], "actionban": ["string", None],
"actionunban": ["string", None], "actionunban": ["string", None],
"norestored": ["string", None], "norestored": ["string", None],
@ -78,7 +79,7 @@ class ActionReader(DefinitionInitConfigReader):
opts = self.getCombined( opts = self.getCombined(
ignore=CommandAction._escapedTags | set(('timeout', 'bantime'))) ignore=CommandAction._escapedTags | set(('timeout', 'bantime')))
# type-convert only after combined (otherwise boolean converting prevents substitution): # type-convert only after combined (otherwise boolean converting prevents substitution):
for o in ('norestored', 'actionstart_on_demand'): for o in ('norestored', 'actionstart_on_demand', 'actionrepair_on_unban'):
if opts.get(o): if opts.get(o):
opts[o] = self._convert_to_boolean(opts[o]) opts[o] = self._convert_to_boolean(opts[o])

View File

@ -33,7 +33,7 @@ from .configreader import ConfigReaderUnshared, ConfigReader
from .filterreader import FilterReader from .filterreader import FilterReader
from .actionreader import ActionReader from .actionreader import ActionReader
from ..version import version from ..version import version
from ..helpers import getLogger, extractOptions, splitwords from ..helpers import getLogger, extractOptions, splitWithOptions, splitwords
# Gets the instance of the logger. # Gets the instance of the logger.
logSys = getLogger(__name__) logSys = getLogger(__name__)
@ -164,21 +164,15 @@ class JailReader(ConfigReader):
self.__filter.getOptions(self.__opts) self.__filter.getOptions(self.__opts)
# Read action # Read action
prevln = '' for act in splitWithOptions(self.__opts["action"]):
actlst = self.__opts["action"].split('\n')
for n, act in enumerate(actlst):
try: try:
act = act.strip()
if not act: # skip empty actions if not act: # skip empty actions
continue continue
# join with previous line if needed (consider possible new-line): # join with previous line if needed (consider possible new-line):
if prevln: act = prevln + '\n' + act
actName, actOpt = extractOptions(act) actName, actOpt = extractOptions(act)
prevln = '' prevln = ''
if not actName: if not actName:
# consider possible new-line, so repeat with joined next line's:
if n < len(actlst) - 1:
prevln = act
continue
raise JailDefError("Invalid action definition %r" % act) raise JailDefError("Invalid action definition %r" % act)
if actName.endswith(".py"): if actName.endswith(".py"):
self.__actions.append([ self.__actions.append([

View File

@ -336,6 +336,9 @@ OPTION_CRE = re.compile(r"^([^\[]+)(?:\[(.*)\])?\s*$", re.DOTALL)
# `action = act[p1=...][p2=...]` # `action = act[p1=...][p2=...]`
OPTION_EXTRACT_CRE = re.compile( OPTION_EXTRACT_CRE = re.compile(
r'([\w\-_\.]+)=(?:"([^"]*)"|\'([^\']*)\'|([^,\]]*))(?:,|\]\s*\[|$)', re.DOTALL) r'([\w\-_\.]+)=(?:"([^"]*)"|\'([^\']*)\'|([^,\]]*))(?:,|\]\s*\[|$)', re.DOTALL)
# split by new-line considering possible new-lines within options [...]:
OPTION_SPLIT_CRE = re.compile(
r'(?:[^\[\n]+(?:\s*\[\s*(?:[\w\-_\.]+=(?:"[^"]*"|\'[^\']*\'|[^,\]]*)\s*(?:,|\]\s*\[)?\s*)*\])?\s*|[^\n]+)(?=\n\s*|$)', re.DOTALL)
def extractOptions(option): def extractOptions(option):
match = OPTION_CRE.match(option) match = OPTION_CRE.match(option)
@ -352,6 +355,9 @@ def extractOptions(option):
option_opts[opt.strip()] = value.strip() option_opts[opt.strip()] = value.strip()
return option_name, option_opts return option_name, option_opts
def splitWithOptions(option):
return OPTION_SPLIT_CRE.findall(option)
# #
# Following facilities used for safe recursive interpolation of # Following facilities used for safe recursive interpolation of
# tags (<tag>) in tagged options. # tags (<tag>) in tagged options.
@ -386,8 +392,7 @@ def substituteRecursiveTags(inptags, conditional='',
""" """
#logSys = getLogger("fail2ban") #logSys = getLogger("fail2ban")
tre_search = TAG_CRE.search tre_search = TAG_CRE.search
# copy return tags dict to prevent modifying of inptags: tags = inptags
tags = inptags.copy()
# init: # init:
ignore = set(ignore) ignore = set(ignore)
done = set() done = set()
@ -449,6 +454,9 @@ def substituteRecursiveTags(inptags, conditional='',
# check still contains any tag - should be repeated (possible embedded-recursive substitution): # check still contains any tag - should be repeated (possible embedded-recursive substitution):
if tre_search(value): if tre_search(value):
repFlag = True repFlag = True
# copy return tags dict to prevent modifying of inptags:
if id(tags) == id(inptags):
tags = inptags.copy()
tags[tag] = value tags[tag] = value
# no more sub tags (and no possible composite), add this tag to done set (just to be faster): # no more sub tags (and no possible composite), add this tag to done set (just to be faster):
if '<' not in value: done.add(tag) if '<' not in value: done.add(tag)

View File

@ -50,6 +50,7 @@ allowed_ipv6 = True
# capture groups from filter for map to ticket data: # capture groups from filter for map to ticket data:
FCUSTAG_CRE = re.compile(r'<F-([A-Z0-9_\-]+)>'); # currently uppercase only FCUSTAG_CRE = re.compile(r'<F-([A-Z0-9_\-]+)>'); # currently uppercase only
COND_FAMILIES = ('inet4', 'inet6')
CONDITIONAL_FAM_RE = re.compile(r"^(\w+)\?(family)=") CONDITIONAL_FAM_RE = re.compile(r"^(\w+)\?(family)=")
# Special tags: # Special tags:
@ -363,8 +364,8 @@ class CommandAction(ActionBase):
self.__properties = dict( self.__properties = dict(
(key, getattr(self, key)) (key, getattr(self, key))
for key in dir(self) for key in dir(self)
if not key.startswith("_") and not callable(getattr(self, key))) if not key.startswith("_") and not callable(getattr(self, key))
# )
return self.__properties return self.__properties
@property @property
@ -372,8 +373,13 @@ class CommandAction(ActionBase):
return self.__substCache return self.__substCache
def _getOperation(self, tag, family): def _getOperation(self, tag, family):
# be sure family is enclosed as conditional value (if not overwritten in action):
if family and self._hasCondSection:
if 'family' not in self._properties and 'family?family='+family not in self._properties:
self._properties['family?family='+family] = family
# replace operation tag (interpolate all values):
return self.replaceTag(tag, self._properties, return self.replaceTag(tag, self._properties,
conditional=('family=' + family), cache=self.__substCache) conditional=('family='+family if family else ''), cache=self.__substCache)
def _executeOperation(self, tag, operation, family=[]): def _executeOperation(self, tag, operation, family=[]):
"""Executes the operation commands (like "actionstart", "actionstop", etc). """Executes the operation commands (like "actionstart", "actionstop", etc).
@ -400,7 +406,17 @@ class CommandAction(ActionBase):
raise RuntimeError("Error %s action %s/%s: %r" % (operation, self._jail, self._name, e)) raise RuntimeError("Error %s action %s/%s: %r" % (operation, self._jail, self._name, e))
return res return res
COND_FAMILIES = ('inet4', 'inet6') @property
def _hasCondSection(self):
v = self._properties.get('__hasCondSection')
if v is None:
v = False
for n in self._properties:
if CONDITIONAL_FAM_RE.match(n):
v = True
break
self._properties['__hasCondSection'] = v
return v
@property @property
def _startOnDemand(self): def _startOnDemand(self):
@ -409,11 +425,7 @@ class CommandAction(ActionBase):
if v is not None: if v is not None:
return v return v
# not set - auto-recognize (depending on conditional): # not set - auto-recognize (depending on conditional):
v = False v = self._hasCondSection
for n in self._properties:
if CONDITIONAL_FAM_RE.match(n):
v = True
break
self._properties['actionstart_on_demand'] = v self._properties['actionstart_on_demand'] = v
return v return v
@ -514,6 +526,17 @@ class CommandAction(ActionBase):
""" """
return self._executeOperation('<actionreload>', 'reloading') return self._executeOperation('<actionreload>', 'reloading')
def consistencyCheck(self, beforeRepair=None):
"""Executes the invariant check with repair if expected (conditional).
"""
ret = True
# for each started family:
if self.actioncheck:
for (family, started) in self.__started.iteritems():
if started and not self._invariantCheck(family, beforeRepair):
ret &= False
return ret
@staticmethod @staticmethod
def escapeTag(value): def escapeTag(value):
"""Escape characters which may be used for command injection. """Escape characters which may be used for command injection.
@ -705,7 +728,40 @@ class CommandAction(ActionBase):
realCmd = Utils.buildShellCmd(realCmd, varsDict) realCmd = Utils.buildShellCmd(realCmd, varsDict)
return realCmd return realCmd
def _processCmd(self, cmd, aInfo=None, conditional=''): def _invariantCheck(self, family='', beforeRepair=None):
"""Executes a substituted `actioncheck` command.
"""
checkCmd = self._getOperation('<actioncheck>', family)
if not checkCmd or self.executeCmd(checkCmd, self.timeout):
return True
# if don't need repair/restore - just return:
if beforeRepair and not beforeRepair():
return False
self._logSys.error(
"Invariant check failed. Trying to restore a sane environment")
# try to find repair command, if exists - exec it:
repairCmd = self._getOperation('<actionrepair>', family)
if repairCmd:
if not self.executeCmd(repairCmd, self.timeout):
self._logSys.critical("Unable to restore environment")
return False
else:
# no repair command, try to restart action...
# [WARNING] TODO: be sure all banactions get a repair command, because
# otherwise stop/start will theoretically remove all the bans,
# but the tickets are still in BanManager, so in case of new failures
# it will not be banned, because "already banned" will happen.
try:
self.stop()
except RuntimeError: # bypass error in stop (if start/check succeeded hereafter).
pass
self.start()
if not self.executeCmd(checkCmd, self.timeout):
self._logSys.critical("Unable to restore environment")
return False
return True
def _processCmd(self, cmd, aInfo=None):
"""Executes a command with preliminary checks and substitutions. """Executes a command with preliminary checks and substitutions.
Before executing any commands, executes the "check" command first Before executing any commands, executes the "check" command first
@ -730,47 +786,26 @@ class CommandAction(ActionBase):
return True return True
# conditional corresponding family of the given ip: # conditional corresponding family of the given ip:
if conditional == '': try:
conditional = 'family=inet4' family = aInfo["family"]
if allowed_ipv6: except KeyError:
try: family = ''
ip = aInfo["ip"]
if ip and asip(ip).isIPv6:
conditional = 'family=inet6'
except KeyError:
pass
checkCmd = self.replaceTag('<actioncheck>', self._properties, # invariant check:
conditional=conditional, cache=self.__substCache) if self.actioncheck:
if checkCmd: # don't repair/restore if unban (no matter):
if not self.executeCmd(checkCmd, self.timeout): def _beforeRepair():
self._logSys.error( if cmd == '<actionunban>' and not self._properties.get('actionrepair_on_unban'):
"Invariant check failed. Trying to restore a sane environment") self._logSys.error("Invariant check failed. Unban is impossible.")
# try to find repair command, if exists - exec it:
repairCmd = self.replaceTag('<actionrepair>', self._properties,
conditional=conditional, cache=self.__substCache)
if repairCmd:
if not self.executeCmd(repairCmd, self.timeout):
self._logSys.critical("Unable to restore environment")
return False
else:
# no repair command, try to restart action...
# [WARNING] TODO: be sure all banactions get a repair command, because
# otherwise stop/start will theoretically remove all the bans,
# but the tickets are still in BanManager, so in case of new failures
# it will not be banned, because "already banned" will happen.
try:
self.stop()
except RuntimeError: # bypass error in stop (if start/check succeeded hereafter).
pass
self.start()
if not self.executeCmd(checkCmd, self.timeout):
self._logSys.critical("Unable to restore environment")
return False return False
return True
ret = self._invariantCheck(family, _beforeRepair)
if not ret:
return False
# Replace static fields # Replace static fields
realCmd = self.replaceTag(cmd, self._properties, realCmd = self.replaceTag(cmd, self._properties,
conditional=conditional, cache=self.__substCache) conditional=('family='+family if family else ''), cache=self.__substCache)
# Replace dynamical tags, important - don't cache, no recursion and auto-escape here # Replace dynamical tags, important - don't cache, no recursion and auto-escape here
if aInfo is not None: if aInfo is not None:

View File

@ -160,8 +160,8 @@ class Actions(JailThread, Mapping):
delacts = OrderedDict((name, action) for name, action in self._actions.iteritems() delacts = OrderedDict((name, action) for name, action in self._actions.iteritems()
if name not in self._reload_actions) if name not in self._reload_actions)
if len(delacts): if len(delacts):
# unban all tickets using remove action only: # unban all tickets using removed actions only:
self.__flushBan(db=False, actions=delacts) self.__flushBan(db=False, actions=delacts, stop=True)
# stop and remove it: # stop and remove it:
self.stopActions(actions=delacts) self.stopActions(actions=delacts)
delattr(self, '_reload_actions') delattr(self, '_reload_actions')
@ -326,7 +326,7 @@ class Actions(JailThread, Mapping):
self.__checkUnBan(bancnt if bancnt and bancnt < self.unbanMaxCount else self.unbanMaxCount) self.__checkUnBan(bancnt if bancnt and bancnt < self.unbanMaxCount else self.unbanMaxCount)
cnt = 0 cnt = 0
self.__flushBan() self.__flushBan(stop=True)
self.stopActions() self.stopActions()
return True return True
@ -494,7 +494,7 @@ class Actions(JailThread, Mapping):
cnt, self.__banManager.size(), self._jail.name) cnt, self.__banManager.size(), self._jail.name)
return cnt return cnt
def __flushBan(self, db=False, actions=None): def __flushBan(self, db=False, actions=None, stop=False):
"""Flush the ban list. """Flush the ban list.
Unban all IP address which are still in the banning list. Unban all IP address which are still in the banning list.
@ -513,11 +513,26 @@ class Actions(JailThread, Mapping):
# first we'll execute flush for actions supporting this operation: # first we'll execute flush for actions supporting this operation:
unbactions = {} unbactions = {}
for name, action in (actions if actions is not None else self._actions).iteritems(): for name, action in (actions if actions is not None else self._actions).iteritems():
if hasattr(action, 'flush') and action.actionflush: try:
logSys.notice("[%s] Flush ticket(s) with %s", self._jail.name, name) if hasattr(action, 'flush') and (not isinstance(action, CommandAction) or action.actionflush):
action.flush() logSys.notice("[%s] Flush ticket(s) with %s", self._jail.name, name)
else: action.flush()
unbactions[name] = action else:
unbactions[name] = action
except Exception as e:
logSys.error("Failed to flush bans in jail '%s' action '%s': %s",
self._jail.name, name, e,
exc_info=logSys.getEffectiveLevel()<=logging.DEBUG)
logSys.info("No flush occured, do consistency check")
def _beforeRepair():
if stop:
self._logSys.error("Invariant check failed. Flush is impossible.")
return False
return True
if not hasattr(action, 'consistencyCheck') or action.consistencyCheck(_beforeRepair):
# fallback to single unbans:
logSys.info("unban tickets each individualy")
unbactions[name] = action
actions = unbactions actions = unbactions
# flush the database also: # flush the database also:
if db and self._jail.database is not None: if db and self._jail.database is not None:

View File

@ -44,20 +44,20 @@ class ExecuteActions(LogCaptureTestCase):
super(ExecuteActions, self).setUp() super(ExecuteActions, self).setUp()
self.__jail = DummyJail() self.__jail = DummyJail()
self.__actions = Actions(self.__jail) self.__actions = Actions(self.__jail)
self.__tmpfile, self.__tmpfilename = tempfile.mkstemp()
def tearDown(self): def tearDown(self):
super(ExecuteActions, self).tearDown() super(ExecuteActions, self).tearDown()
os.remove(self.__tmpfilename)
def defaultActions(self): def defaultAction(self):
self.__actions.add('ip') self.__actions.add('ip')
self.__ip = self.__actions['ip'] act = self.__actions['ip']
self.__ip.actionstart = 'echo ip start 64 >> "%s"' % self.__tmpfilename act.actionstart = 'echo ip start'
self.__ip.actionban = 'echo ip ban <ip> >> "%s"' % self.__tmpfilename act.actionban = 'echo ip ban <ip>'
self.__ip.actionunban = 'echo ip unban <ip> >> "%s"' % self.__tmpfilename act.actionunban = 'echo ip unban <ip>'
self.__ip.actioncheck = 'echo ip check <ip> >> "%s"' % self.__tmpfilename act.actioncheck = 'echo ip check'
self.__ip.actionstop = 'echo ip stop >> "%s"' % self.__tmpfilename act.actionflush = 'echo ip flush <family>'
act.actionstop = 'echo ip stop'
return act
def testActionsAddDuplicateName(self): def testActionsAddDuplicateName(self):
self.__actions.add('test') self.__actions.add('test')
@ -89,13 +89,12 @@ class ExecuteActions(LogCaptureTestCase):
self.assertLogged('Ban 192.0.2.3') self.assertLogged('Ban 192.0.2.3')
def testActionsOutput(self): def testActionsOutput(self):
self.defaultActions() self.defaultAction()
self.__actions.start() self.__actions.start()
with open(self.__tmpfilename) as f: self.assertLogged("stdout: %r" % 'ip start', wait=True)
self.assertTrue( Utils.wait_for(lambda: (f.read() == "ip start 64\n"), 3) )
self.__actions.stop() self.__actions.stop()
self.__actions.join() self.__actions.join()
self.assertLogged("stdout: %r" % 'ip flush', "stdout: %r" % 'ip stop')
self.assertEqual(self.__actions.status(),[("Currently banned", 0 ), self.assertEqual(self.__actions.status(),[("Currently banned", 0 ),
("Total banned", 0 ), ("Banned IP list", [] )]) ("Total banned", 0 ), ("Banned IP list", [] )])
@ -211,3 +210,30 @@ class ExecuteActions(LogCaptureTestCase):
self.assertLogged('Unbanned 30, 0 ticket(s)') self.assertLogged('Unbanned 30, 0 ticket(s)')
self.assertNotLogged('Unbanned 50, 0 ticket(s)') self.assertNotLogged('Unbanned 50, 0 ticket(s)')
@with_alt_time
def testActionsConsistencyCheck(self):
# flush is broken - test no unhandled except and invariant check:
act = self.defaultAction()
setattr(act, 'actionflush?family=inet6', 'echo ip flush <family>; exit 1')
act.actionstart_on_demand = True
self.__actions.start()
self.assertNotLogged("stdout: %r" % 'ip start')
self.assertEqual(self.__actions.addBannedIP('192.0.2.1'), 1)
self.assertEqual(self.__actions.addBannedIP('2001:db8::1'), 1)
self.assertLogged('Ban 192.0.2.1', 'Ban 2001:db8::1',
"stdout: %r" % 'ip start',
"stdout: %r" % 'ip ban 192.0.2.1',
"stdout: %r" % 'ip ban 2001:db8::1',
all=True, wait=True)
self.__actions._Actions__flushBan()
self.assertLogged('Failed to flush bans',
'No flush occured, do consistency check',
"stdout: %r" % 'ip ban 192.0.2.1',
all=True, wait=True)
self.__actions.stop()
self.__actions.join()

View File

@ -31,7 +31,7 @@ import unittest
from ..client.configreader import ConfigReader, ConfigReaderUnshared, \ from ..client.configreader import ConfigReader, ConfigReaderUnshared, \
DefinitionInitConfigReader, NoSectionError DefinitionInitConfigReader, NoSectionError
from ..client import configparserinc from ..client import configparserinc
from ..client.jailreader import JailReader, extractOptions from ..client.jailreader import JailReader, extractOptions, splitWithOptions
from ..client.filterreader import FilterReader from ..client.filterreader import FilterReader
from ..client.jailsreader import JailsReader from ..client.jailsreader import JailsReader
from ..client.actionreader import ActionReader, CommandAction from ..client.actionreader import ActionReader, CommandAction
@ -778,7 +778,7 @@ class JailsReaderTest(LogCaptureTestCase):
# somewhat duplicating here what is done in JailsReader if # somewhat duplicating here what is done in JailsReader if
# the jail is enabled # the jail is enabled
for act in actions.split('\n'): for act in splitWithOptions(actions):
actName, actOpt = extractOptions(act) actName, actOpt = extractOptions(act)
self.assertTrue(len(actName)) self.assertTrue(len(actName))
self.assertTrue(isinstance(actOpt, dict)) self.assertTrue(isinstance(actOpt, dict))

View File

@ -12,4 +12,9 @@ class TestAction(ActionBase):
del aInfo['ip'] del aInfo['ip']
self._logSys.info("%s unban deleted aInfo IP", self._name) self._logSys.info("%s unban deleted aInfo IP", self._name)
def flush(self):
# intended error to cover no unhandled exception occurs in flash
# as well as unbans are done individually after errored flush.
raise ValueError("intended error")
Action = TestAction Action = TestAction