diff --git a/config/action.d/pf.conf b/config/action.d/pf.conf index b7476fa2..deb38c09 100644 --- a/config/action.d/pf.conf +++ b/config/action.d/pf.conf @@ -18,6 +18,9 @@ actionstart = echo "table <-> persist counters" | pfctl -f- echo "block proto from <-> to " | pfctl -f- +# Option: start_on_demand - to start action on demand +# Example: `action=pf[actionstart_on_demand=true]` +actionstart_on_demand = false # Option: actionstop # Notes.: command executed once at the end of Fail2Ban @@ -71,8 +74,6 @@ tablename = f2b # protocol = tcp - - # Option: actiontype # Notes.: defines additions to the blocking rule # Values: leave empty to block all attempts from the host diff --git a/fail2ban/client/actionreader.py b/fail2ban/client/actionreader.py index 0fd55f41..b85f22a0 100644 --- a/fail2ban/client/actionreader.py +++ b/fail2ban/client/actionreader.py @@ -38,6 +38,7 @@ class ActionReader(DefinitionInitConfigReader): _configOpts = { "actionstart": ["string", None], + "actionstart_on_demand": ["string", None], "actionstop": ["string", None], "actionreload": ["string", None], "actioncheck": ["string", None], @@ -73,8 +74,10 @@ class ActionReader(DefinitionInitConfigReader): opts = self.getCombined( ignore=CommandAction._escapedTags | set(('timeout', 'bantime'))) # type-convert only after combined (otherwise boolean converting prevents substitution): - if opts.get('norestored'): - opts['norestored'] = self._convert_to_boolean(opts['norestored']) + for o in ('norestored', 'actionstart_on_demand'): + if opts.get(o): + opts[o] = self._convert_to_boolean(opts[o]) + # stream-convert: head = ["set", self._jailName] stream = list() diff --git a/fail2ban/server/action.py b/fail2ban/server/action.py index 1c5eb0c9..8afeea6e 100644 --- a/fail2ban/server/action.py +++ b/fail2ban/server/action.py @@ -50,6 +50,8 @@ allowed_ipv6 = True # capture groups from filter for map to ticket data: FCUSTAG_CRE = re.compile(r''); # currently uppercase only +CONDITIONAL_FAM_RE = re.compile(r"^(\w+)\?(family)=") + # New line, space ADD_REPL_TAGS = { "br": "\n", @@ -290,6 +292,7 @@ class CommandAction(ActionBase): super(CommandAction, self).__init__(jail, name) self.__init = 1 self.__properties = None + self.__started = {} self.__substCache = {} self.clearAllParams() self._logSys.debug("Created %s" % self.__class__) @@ -342,7 +345,11 @@ class CommandAction(ActionBase): def _substCache(self): return self.__substCache - def _executeOperation(self, tag, operation): + def _getOperation(self, tag, family): + return self.replaceTag(tag, self._properties, + conditional=('family=' + family), cache=self.__substCache) + + def _executeOperation(self, tag, operation, family=[]): """Executes the operation commands (like "actionstart", "actionstop", etc). Replace the tags in the action command with actions properties @@ -352,14 +359,14 @@ class CommandAction(ActionBase): res = True try: # common (resp. ipv4): - startCmd = self.replaceTag(tag, self._properties, - conditional='family=inet4', cache=self.__substCache) - if startCmd: - res &= self.executeCmd(startCmd, self.timeout) + startCmd = None + if not family or 'inet4' in family: + startCmd = self._getOperation(tag, 'inet4') + if startCmd: + res &= self.executeCmd(startCmd, self.timeout) # start ipv6 actions if available: - if allowed_ipv6: - startCmd6 = self.replaceTag(tag, self._properties, - conditional='family=inet6', cache=self.__substCache) + if allowed_ipv6 and (not family or 'inet6' in family): + startCmd6 = self._getOperation(tag, 'inet6') if startCmd6 and startCmd6 != startCmd: res &= self.executeCmd(startCmd6, self.timeout) if not res: @@ -367,13 +374,34 @@ class CommandAction(ActionBase): except ValueError as e: raise RuntimeError("Error %s action %s/%s: %r" % (operation, self._jail, self._name, e)) - def start(self): + COND_FAMILIES = {'inet4':1, 'inet6':1} + + @property + def _startOnDemand(self): + """Checks the action depends on family (conditional)""" + v = self._properties.get('actionstart_on_demand') + if v is None: + v = False + for n in self._properties: + if CONDITIONAL_FAM_RE.match(n): + v = True + break + self._properties['actionstart_on_demand'] = v + return v + + def start(self, family=[]): """Executes the "actionstart" command. Replace the tags in the action command with actions properties and executes the resulting command. """ - return self._executeOperation('', 'starting') + if not family: + # check the action depends on family (conditional): + if self._startOnDemand: + return True + elif self.__started.get(family): + return True + return self._executeOperation('', 'starting', family=family) def ban(self, aInfo): """Executes the "actionban" command. @@ -387,6 +415,20 @@ class CommandAction(ActionBase): Dictionary which includes information in relation to the ban. """ + # if we should start the action on demand (conditional by family): + if self._startOnDemand: + family = aInfo.get('family') + if not self.__started.get(family): + self.start(family) + self.__started[family] = 1 + # mark also another families as "started" (-1), if they are equal + # (on demand, but the same for ipv4 and ipv6): + cmd = self._getOperation('', family) + for f in CommandAction.COND_FAMILIES: + if f != family and not self.__started.get(f): + if cmd == self._getOperation('', f): + self.__started[f] = -1 + # ban: if not self._processCmd('', aInfo): raise RuntimeError("Error banning %(ip)s" % aInfo) @@ -411,7 +453,16 @@ class CommandAction(ActionBase): Replaces the tags in the action command with actions properties and executes the resulting command. """ - return self._executeOperation('', 'stopping') + family = [] + # cumulate started families, if started on demand (conditional): + if self._startOnDemand: + for f in CommandAction.COND_FAMILIES: + if self.__started.get(f) == 1: # only real started: + family.append(f) + self.__started[f] = 0 + # if no started (on demand) actions: + if not family: return True + return self._executeOperation('', 'stopping', family=family) def reload(self, **kwargs): """Executes the "actionreload" command. diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 51ff8880..603bb69f 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -1185,10 +1185,12 @@ class ServerConfigReaderTests(LogCaptureTestCase): # iptables-multiport -- ('j-w-iptables-mp', 'iptables-multiport[name=%(__name__)s, bantime="10m", port="http,https", protocol="tcp", chain="INPUT"]', { 'ip4': ('`iptables ', 'icmp-port-unreachable'), 'ip6': ('`ip6tables ', 'icmp6-port-unreachable'), - 'start': ( + 'ip4-start': ( "`iptables -w -N f2b-j-w-iptables-mp`", "`iptables -w -A f2b-j-w-iptables-mp -j RETURN`", "`iptables -w -I INPUT -p tcp -m multiport --dports http,https -j f2b-j-w-iptables-mp`", + ), + 'ip6-start': ( "`ip6tables -w -N f2b-j-w-iptables-mp`", "`ip6tables -w -A f2b-j-w-iptables-mp -j RETURN`", "`ip6tables -w -I INPUT -p tcp -m multiport --dports http,https -j f2b-j-w-iptables-mp`", @@ -1223,10 +1225,12 @@ class ServerConfigReaderTests(LogCaptureTestCase): # iptables-allports -- ('j-w-iptables-ap', 'iptables-allports[name=%(__name__)s, bantime="10m", protocol="tcp", chain="INPUT"]', { 'ip4': ('`iptables ', 'icmp-port-unreachable'), 'ip6': ('`ip6tables ', 'icmp6-port-unreachable'), - 'start': ( + 'ip4-start': ( "`iptables -w -N f2b-j-w-iptables-ap`", "`iptables -w -A f2b-j-w-iptables-ap -j RETURN`", "`iptables -w -I INPUT -p tcp -j f2b-j-w-iptables-ap`", + ), + 'ip6-start': ( "`ip6tables -w -N f2b-j-w-iptables-ap`", "`ip6tables -w -A f2b-j-w-iptables-ap -j RETURN`", "`ip6tables -w -I INPUT -p tcp -j f2b-j-w-iptables-ap`", @@ -1261,9 +1265,11 @@ class ServerConfigReaderTests(LogCaptureTestCase): # iptables-ipset-proto6 -- ('j-w-iptables-ipset', 'iptables-ipset-proto6[name=%(__name__)s, bantime="10m", port="http", protocol="tcp", chain="INPUT"]', { 'ip4': (' f2b-j-w-iptables-ipset ',), 'ip6': (' f2b-j-w-iptables-ipset6 ',), - 'start': ( + 'ip4-start': ( "`ipset create f2b-j-w-iptables-ipset hash:ip timeout 600`", "`iptables -w -I INPUT -p tcp -m multiport --dports http -m set --match-set f2b-j-w-iptables-ipset src -j REJECT --reject-with icmp-port-unreachable`", + ), + 'ip6-start': ( "`ipset create f2b-j-w-iptables-ipset6 hash:ip timeout 600 family inet6`", "`ip6tables -w -I INPUT -p tcp -m multiport --dports http -m set --match-set f2b-j-w-iptables-ipset6 src -j REJECT --reject-with icmp6-port-unreachable`", ), @@ -1293,9 +1299,11 @@ class ServerConfigReaderTests(LogCaptureTestCase): # iptables-ipset-proto6-allports -- ('j-w-iptables-ipset-ap', 'iptables-ipset-proto6-allports[name=%(__name__)s, bantime="10m", chain="INPUT"]', { 'ip4': (' f2b-j-w-iptables-ipset-ap ',), 'ip6': (' f2b-j-w-iptables-ipset-ap6 ',), - 'start': ( + 'ip4-start': ( "`ipset create f2b-j-w-iptables-ipset-ap hash:ip timeout 600`", "`iptables -w -I INPUT -m set --match-set f2b-j-w-iptables-ipset-ap src -j REJECT --reject-with icmp-port-unreachable`", + ), + 'ip6-start': ( "`ipset create f2b-j-w-iptables-ipset-ap6 hash:ip timeout 600 family inet6`", "`ip6tables -w -I INPUT -m set --match-set f2b-j-w-iptables-ipset-ap6 src -j REJECT --reject-with icmp6-port-unreachable`", ), @@ -1325,10 +1333,12 @@ class ServerConfigReaderTests(LogCaptureTestCase): # iptables -- ('j-w-iptables', 'iptables[name=%(__name__)s, bantime="10m", port="http", protocol="tcp", chain="INPUT"]', { 'ip4': ('`iptables ', 'icmp-port-unreachable'), 'ip6': ('`ip6tables ', 'icmp6-port-unreachable'), - 'start': ( + 'ip4-start': ( "`iptables -w -N f2b-j-w-iptables`", "`iptables -w -A f2b-j-w-iptables -j RETURN`", "`iptables -w -I INPUT -p tcp --dport http -j f2b-j-w-iptables`", + ), + 'ip6-start': ( "`ip6tables -w -N f2b-j-w-iptables`", "`ip6tables -w -A f2b-j-w-iptables -j RETURN`", "`ip6tables -w -I INPUT -p tcp --dport http -j f2b-j-w-iptables`", @@ -1363,10 +1373,12 @@ class ServerConfigReaderTests(LogCaptureTestCase): # iptables-new -- ('j-w-iptables-new', 'iptables-new[name=%(__name__)s, bantime="10m", port="http", protocol="tcp", chain="INPUT"]', { 'ip4': ('`iptables ', 'icmp-port-unreachable'), 'ip6': ('`ip6tables ', 'icmp6-port-unreachable'), - 'start': ( + 'ip4-start': ( "`iptables -w -N f2b-j-w-iptables-new`", "`iptables -w -A f2b-j-w-iptables-new -j RETURN`", "`iptables -w -I INPUT -m state --state NEW -p tcp --dport http -j f2b-j-w-iptables-new`", + ), + 'ip6-start': ( "`ip6tables -w -N f2b-j-w-iptables-new`", "`ip6tables -w -A f2b-j-w-iptables-new -j RETURN`", "`ip6tables -w -I INPUT -m state --state NEW -p tcp --dport http -j f2b-j-w-iptables-new`", @@ -1401,8 +1413,10 @@ class ServerConfigReaderTests(LogCaptureTestCase): # iptables-xt_recent-echo -- ('j-w-iptables-xtre', 'iptables-xt_recent-echo[name=%(__name__)s, bantime="10m", chain="INPUT"]', { 'ip4': ('`iptables ', '/f2b-j-w-iptables-xtre`'), 'ip6': ('`ip6tables ', '/f2b-j-w-iptables-xtre6`'), - 'start': ( + 'ip4-start': ( "`if [ `id -u` -eq 0 ];then iptables -w -I INPUT -m recent --update --seconds 3600 --name f2b-j-w-iptables-xtre -j REJECT --reject-with icmp-port-unreachable;fi`", + ), + 'ip6-start': ( "`if [ `id -u` -eq 0 ];then ip6tables -w -I INPUT -m recent --update --seconds 3600 --name f2b-j-w-iptables-xtre6 -j REJECT --reject-with icmp6-port-unreachable;fi`", ), 'stop': ( @@ -1431,7 +1445,7 @@ class ServerConfigReaderTests(LogCaptureTestCase): ), }), # pf default -- multiport on default port (tag set in jail.conf, but not in this test case) - ('j-w-pf', 'pf[name=%(__name__)s]', { + ('j-w-pf', 'pf[name=%(__name__)s, actionstart_on_demand=false]', { 'ip4': (), 'ip6': (), 'start': ( '`echo "table persist counters" | pfctl -f-`', @@ -1468,13 +1482,14 @@ class ServerConfigReaderTests(LogCaptureTestCase): 'ip6-ban': ("`pfctl -t f2b-j-w-pf-mp -T add 2001:db8::`",), 'ip6-unban': ("`pfctl -t f2b-j-w-pf-mp -T delete 2001:db8::`",), }), - # pf allports -- - ('j-w-pf-ap', 'pf[actiontype=][name=%(__name__)s]', { + # pf allports -- test additionally "actionstart_on_demand" was set to true + ('j-w-pf-ap', 'pf[actiontype=, actionstart_on_demand=true][name=%(__name__)s]', { 'ip4': (), 'ip6': (), - 'start': ( + 'ip4-start': ( '`echo "table persist counters" | pfctl -f-`', '`echo "block proto tcp from to any" | pfctl -f-`', ), + 'ip6-start': (), # the same as ipv4 'stop': ( '`pfctl -sr 2>/dev/null | grep -v f2b-j-w-pf-ap | pfctl -f-`', '`pfctl -t f2b-j-w-pf-ap -T flush`', @@ -1490,10 +1505,12 @@ class ServerConfigReaderTests(LogCaptureTestCase): # firewallcmd-multiport -- ('j-w-fwcmd-mp', 'firewallcmd-multiport[name=%(__name__)s, bantime="10m", port="http,https", protocol="tcp", chain="INPUT"]', { 'ip4': (' ipv4 ', 'icmp-port-unreachable'), 'ip6': (' ipv6 ', 'icmp6-port-unreachable'), - 'start': ( + 'ip4-start': ( "`firewall-cmd --direct --add-chain ipv4 filter f2b-j-w-fwcmd-mp`", "`firewall-cmd --direct --add-rule ipv4 filter f2b-j-w-fwcmd-mp 1000 -j RETURN`", "`firewall-cmd --direct --add-rule ipv4 filter INPUT 0 -m conntrack --ctstate NEW -p tcp -m multiport --dports http,https -j f2b-j-w-fwcmd-mp`", + ), + 'ip6-start': ( "`firewall-cmd --direct --add-chain ipv6 filter f2b-j-w-fwcmd-mp`", "`firewall-cmd --direct --add-rule ipv6 filter f2b-j-w-fwcmd-mp 1000 -j RETURN`", "`firewall-cmd --direct --add-rule ipv6 filter INPUT 0 -m conntrack --ctstate NEW -p tcp -m multiport --dports http,https -j f2b-j-w-fwcmd-mp`", @@ -1528,10 +1545,12 @@ class ServerConfigReaderTests(LogCaptureTestCase): # firewallcmd-allports -- ('j-w-fwcmd-ap', 'firewallcmd-allports[name=%(__name__)s, bantime="10m", protocol="tcp", chain="INPUT"]', { 'ip4': (' ipv4 ', 'icmp-port-unreachable'), 'ip6': (' ipv6 ', 'icmp6-port-unreachable'), - 'start': ( + 'ip4-start': ( "`firewall-cmd --direct --add-chain ipv4 filter f2b-j-w-fwcmd-ap`", "`firewall-cmd --direct --add-rule ipv4 filter f2b-j-w-fwcmd-ap 1000 -j RETURN`", "`firewall-cmd --direct --add-rule ipv4 filter INPUT 0 -j f2b-j-w-fwcmd-ap`", + ), + 'ip6-start': ( "`firewall-cmd --direct --add-chain ipv6 filter f2b-j-w-fwcmd-ap`", "`firewall-cmd --direct --add-rule ipv6 filter f2b-j-w-fwcmd-ap 1000 -j RETURN`", "`firewall-cmd --direct --add-rule ipv6 filter INPUT 0 -j f2b-j-w-fwcmd-ap`", @@ -1566,9 +1585,11 @@ class ServerConfigReaderTests(LogCaptureTestCase): # firewallcmd-ipset -- ('j-w-fwcmd-ipset', 'firewallcmd-ipset[name=%(__name__)s, bantime="10m", port="http", protocol="tcp", chain="INPUT"]', { 'ip4': (' f2b-j-w-fwcmd-ipset ',), 'ip6': (' f2b-j-w-fwcmd-ipset6 ',), - 'start': ( + 'ip4-start': ( "`ipset create f2b-j-w-fwcmd-ipset hash:ip timeout 600`", "`firewall-cmd --direct --add-rule ipv4 filter INPUT 0 -p tcp -m multiport --dports http -m set --match-set f2b-j-w-fwcmd-ipset src -j REJECT --reject-with icmp-port-unreachable`", + ), + 'ip6-start': ( "`ipset create f2b-j-w-fwcmd-ipset6 hash:ip timeout 600`", "`firewall-cmd --direct --add-rule ipv6 filter INPUT 0 -p tcp -m multiport --dports http -m set --match-set f2b-j-w-fwcmd-ipset6 src -j REJECT --reject-with icmp6-port-unreachable`", ), @@ -1614,6 +1635,10 @@ class ServerConfigReaderTests(LogCaptureTestCase): jails = server._Server__jails + tickets = { + 'ip4': BanTicket('192.0.2.1'), + 'ip6': BanTicket('2001:DB8::'), + } for jail, act, tests in testJailsActions: # print(jail, jails[jail]) for a in jails[jail].actions: @@ -1627,25 +1652,36 @@ class ServerConfigReaderTests(LogCaptureTestCase): # test start : self.pruneLog('# === start ===') action.start() - self.assertLogged(*tests['start'], all=True) + if tests.get('start'): + self.assertLogged(*tests['start'], all=True) + else: + self.assertNotLogged(*tests['ip4-start']+tests['ip6-start'], all=True) + ainfo = { + 'ip4': _actions.Actions.ActionInfo(tickets['ip4'], jails[jail]), + 'ip6': _actions.Actions.ActionInfo(tickets['ip6'], jails[jail]), + } # test ban ip4 : self.pruneLog('# === ban-ipv4 ===') - action.ban({'ip': IPAddr('192.0.2.1')}) + action.ban(ainfo['ip4']) + if tests.get('ip4-start'): self.assertLogged(*tests['ip4-start'], all=True) + if tests.get('ip6-start'): self.assertNotLogged(*tests['ip6-start'], all=True) self.assertLogged(*tests['ip4-check']+tests['ip4-ban'], all=True) self.assertNotLogged(*tests['ip6'], all=True) # test unban ip4 : self.pruneLog('# === unban ipv4 ===') - action.unban({'ip': IPAddr('192.0.2.1')}) + action.unban(ainfo['ip4']) self.assertLogged(*tests['ip4-check']+tests['ip4-unban'], all=True) self.assertNotLogged(*tests['ip6'], all=True) # test ban ip6 : self.pruneLog('# === ban ipv6 ===') - action.ban({'ip': IPAddr('2001:DB8::')}) + action.ban(ainfo['ip6']) + if tests.get('ip6-start'): self.assertLogged(*tests['ip6-start'], all=True) + if tests.get('ip4-start'): self.assertNotLogged(*tests['ip4-start'], all=True) self.assertLogged(*tests['ip6-check']+tests['ip6-ban'], all=True) self.assertNotLogged(*tests['ip4'], all=True) # test unban ip6 : self.pruneLog('# === unban ipv6 ===') - action.unban({'ip': IPAddr('2001:DB8::')}) + action.unban(ainfo['ip6']) self.assertLogged(*tests['ip6-check']+tests['ip6-unban'], all=True) self.assertNotLogged(*tests['ip4'], all=True) # test stop :