diff --git a/config/action.d/badips.py b/config/action.d/badips.py new file mode 100644 index 00000000..d09b905d --- /dev/null +++ b/config/action.d/badips.py @@ -0,0 +1,309 @@ +# 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. + +import json +from functools import partial +import threading +import sys +if sys.version_info >= (3, ): + from urllib.request import Request, urlopen + from urllib.parse import urlencode + from urllib.error import HTTPError +else: + from urllib2 import Request, urlopen, HTTPError + from urllib import urlencode + +from fail2ban.server.actions import ActionBase +from fail2ban.version import version as f2bVersion + +class BadIPsAction(ActionBase): + """Fail2Ban action which resports bans to badips.com, and also + blacklist bad IPs listed on badips.com by using another action's + ban method. + """ + badips = "http://www.badips.com" + Request = partial( + Request, headers={'User-Agent': "Fail2Ban %s" % f2bVersion}) + + def __init__(self, jail, name, category, score=5, age="24h", + banaction=None, updateperiod=900): + """Initialise action. + + Parameters + ---------- + jail : Jail + The jail which the action belongs to. + name : str + Name assigned to the action. + category : str + Valid badips.com category. + score : int, optional + Minimum score for bad IPs. Default 5. + age : str, optional + Age of last report for bad IPs, per badips.com syntax. + Default "24h" (24 hours) + banaction : str, optional + Name of banaction to use for blacklisting bad IPs. If `None`, + no blacklist of IPs will take place. + Default `None`. + updateperiod : int, optional + Time in seconds between updating bad IPs blacklist. + Default 900 (15 minutes) + + Raises + ------ + ValueError + If invalid `category`, `score`, `banaction` or `updateperiod`. + """ + super(BadIPsAction, self).__init__(jail, name) + + self.category = category + self.score = score + self.age = age + self.banaction = banaction + self.updateperiod = updateperiod + + self._bannedips = set() + # Used later for threading.Timer for updating badips + self._timer = None + + @classmethod + def getCategories(cls): + """Get badips.com categories. + + Returns + ------- + set + Set of categories. + + Raises + ------ + HTTPError + Any issues with badips.com request. + """ + try: + response = urlopen( + cls.Request("/".join([cls.badips, "get", "categories"]))) + except HTTPError as response: + messages = json.loads(response.read().decode('utf-8')) + self._logSys.error( + "Failed to fetch categories. badips.com response: '%s'", + messages['err']) + raise + else: + categories = json.loads(response.read().decode('utf-8'))['categories'] + categories_names = set( + value['Name'] for value in categories) + return categories_names + + @classmethod + def getList(cls, category, score, age): + """Get badips.com list of bad IPs. + + Parameters + ---------- + category : str + Valid badips.com category. + score : int + Minimum score for bad IPs. + age : str + Age of last report for bad IPs, per badips.com syntax. + + Returns + ------- + set + Set of bad IPs. + + Raises + ------ + HTTPError + Any issues with badips.com request. + """ + try: + response = urlopen(cls.Request("?".join([ + "/".join([cls.badips, "get", "list", category, str(score)]), + urlencode({'age': age})]))) + except HTTPError as response: + messages = json.loads(response.read().decode('utf-8')) + self._logSys.error( + "Failed to fetch bad IP list. badips.com response: '%s'", + messages['err']) + raise + else: + return set(response.read().decode('utf-8').split()) + + @property + def category(self): + """badips.com category for fetching/reporting IPs. + """ + return self._category + + @category.setter + def category(self, category): + if category not in self.getCategories(): + self._logSys.error("Category name '%s' not valid. " + "see badips.com for list of valid categories", + category) + raise ValueError("Invalid category: %s" % category) + self._category = category + + @property + def score(self): + """badips.com minimum score for fetching IPs. + """ + return self._score + + @score.setter + def score(self, score): + score = int(score) + if 0 <= score <= 5: + self._score = score + else: + raise ValueError("Score must be 0-5") + + @property + def banaction(self): + """Jail action to use for banning/unbanning. + """ + return self._banaction + + @banaction.setter + def banaction(self, banaction): + if banaction is not None and banaction not in self._jail.actions: + self._logSys.error("Action name '%s' not in jail '%s'", + banaction, self._jail.getName()) + raise ValueError("Invalid banaction") + self._banaction = banaction + + @property + def updateperiod(self): + """Period in seconds between banned bad IPs will be updated. + """ + return self._updateperiod + + @updateperiod.setter + def updateperiod(self, updateperiod): + updateperiod = int(updateperiod) + if updateperiod > 0: + self._updateperiod = updateperiod + else: + raise ValueError("Update period must be integer greater than 0") + + def _banIPs(self, ips): + for ip in ips: + self._jail.actions[self.banaction].ban({ + 'ip': ip, + 'failures': 0, + 'matches': "", + 'ipmatches': "", + 'ipjailmatches': "", + }) + self._bannedips.add(ip) + self._logSys.info( + "Banned IP %s for jail '%s' with action '%s'", + ip, self._jail.getName(), self.banaction) + + def _unbanIPs(self, ips): + for ip in ips: + self._jail.actions[self.banaction].unban({ + 'ip': ip, + 'failures': 0, + 'matches': "", + 'ipmatches': "", + 'ipjailmatches': "", + }) + self._bannedips.remove(ip) + self._logSys.info( + "Unbanned IP %s for jail '%s' with action '%s'", + ip, self._jail.getName(), self.banaction) + + def start(self): + """If `banaction` set, blacklists bad IPs. + """ + if self.banaction is not None: + self._banIPs(self.getList(self.category, self.score, self.age)) + self._timer = threading.Timer(self.updateperiod, self.update) + self._timer.start() + self._logSys.info( + "Banned IPs for jail '%s'. Update in %i seconds", + self._jail.getName(), self.updateperiod) + + def update(self): + """If `banaction` set, updates blacklisted IPs. + + Queries badips.com for list of bad IPs, removing IPs from the + blacklist if no longer present, and adds new bad IPs to the + blacklist. + """ + if self.banaction is not None: + if self._timer: + self._timer.cancel() + self._timer = None + + ips = self.getList(self.category, self.score, self.age) + # Remove old IPs no longer listed + self._unbanIPs(self._bannedips - ips) + # Add new IPs which are now listed + self._banIPs(ips - self._bannedips) + + self._timer = threading.Timer(self.updateperiod, self.update) + self._timer.start() + self._logSys.info( + "Updated IPs for jail '%s'. Update again in %i seconds", + self._jail.getName(), self.updateperiod) + + def stop(self): + """If `banaction` set, clears blacklisted IPs. + """ + if self.banaction is not None: + if self._timer: + self._timer.cancel() + self._timer = None + self._unbanIPs(self._bannedips.copy()) + + def ban(self, aInfo): + """Reports banned IP to badips.com. + + Parameters + ---------- + aInfo : dict + Dictionary which includes information in relation to + the ban. + + Raises + ------ + HTTPError + Any issues with badips.com request. + """ + try: + response = urlopen(self.Request( + "/".join([self.badips, "add", self.category, aInfo['ip']]))) + except HTTPError as response: + messages = json.loads(response.read().decode('utf-8')) + self._logSys.error( + "Response from badips.com report: '%s'", + messages['err']) + raise + else: + messages = json.loads(response.read().decode('utf-8')) + self._logSys.info( + "Response from badips.com report: '%s'", + messages['suc']) + +Action = BadIPsAction diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 52624f29..003e3ca5 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -31,7 +31,7 @@ if sys.version_info >= (3, 3): import importlib.machinery else: import imp -from collections import Mapping +from collections import Mapping, OrderedDict from .banmanager import BanManager from .jailthread import JailThread @@ -62,7 +62,7 @@ class Actions(JailThread, Mapping): JailThread.__init__(self) ## The jail which contains this action. self._jail = jail - self._actions = dict() + self._actions = OrderedDict() ## The ban manager. self.__banManager = BanManager() @@ -209,7 +209,10 @@ class Actions(JailThread, Mapping): else: time.sleep(self.getSleepTime()) self.__flushBan() - for name, action in self._actions.iteritems(): + + actions = self._actions.items() + actions.reverse() + for name, action in actions: try: action.stop() except Exception as e: diff --git a/fail2ban/tests/action_d/test_badips.py b/fail2ban/tests/action_d/test_badips.py new file mode 100644 index 00000000..a203c40b --- /dev/null +++ b/fail2ban/tests/action_d/test_badips.py @@ -0,0 +1,89 @@ +# 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. + +import os +import unittest +import sys +if sys.version_info >= (3, 3): + import importlib +else: + import imp + +from ..dummyjail import DummyJail + +if os.path.exists('config/fail2ban.conf'): + CONFIG_DIR = "config" +else: + CONFIG_DIR='/etc/fail2ban' + +class BadIPsActionTest(unittest.TestCase): + + def setUp(self): + """Call before every test case.""" + self.jail = DummyJail() + + self.jail.actions.add("test") + + pythonModule = os.path.join(CONFIG_DIR, "action.d", "badips.py") + self.jail.actions.add("badips", pythonModule, initOpts={ + 'category': "ssh", + 'banaction': "test", + }) + self.action = self.jail.actions["badips"] + + def tearDown(self): + """Call after every test case.""" + # Must cancel timer! + if self.action._timer: + self.action._timer.cancel() + + def testCategory(self): + categories = self.action.getCategories() + self.assertTrue("ssh" in categories) + self.assertTrue(len(categories) >= 10) + + self.assertRaises( + ValueError, setattr, self.action, "category", "invalid-category") + + def testScore(self): + self.assertRaises(ValueError, setattr, self.action, "score", -5) + self.action.score = 5 + self.action.score = "5" + + def testBanaction(self): + self.assertRaises( + ValueError, setattr, self.action, "banaction", "invalid-action") + self.action.banaction = "test" + + def testUpdateperiod(self): + self.assertRaises( + ValueError, setattr, self.action, "updateperiod", -50) + self.assertRaises( + ValueError, setattr, self.action, "updateperiod", 0) + self.action.updateperiod = 900 + self.action.updateperiod = "900" + + def testStart(self): + self.action.start() + self.assertTrue(len(self.action._bannedips) > 10) + + def testStop(self): + self.testStart() + self.action.stop() + self.assertTrue(len(self.action._bannedips) == 0)