diff --git a/MANIFEST b/MANIFEST index 4c60f8e4..eef145b6 100644 --- a/MANIFEST +++ b/MANIFEST @@ -20,6 +20,7 @@ client/configurator.py client/csocket.py server/asyncserver.py server/filter.py +server/filterpyinotify.py server/filtergamin.py server/filterpoll.py server/iso8601.py diff --git a/README b/README index 4b5048ed..556e2fac 100644 --- a/README +++ b/README @@ -22,7 +22,12 @@ Required: >=python-2.3 (http://www.python.org) Optional: - >=gamin-0.0.21 (http://www.gnome.org/~veillard/gamin) + pyinotify: + >=linux-2.6.13 + >=python-2.4 + >=pyinotify-0.8.3 (https://github.com/seb-m/pyinotify) + Gamin: + >=gamin-0.0.21 (http://www.gnome.org/~veillard/gamin) To install, just do: diff --git a/TODO b/TODO index 2b17c916..ce40ebcf 100644 --- a/TODO +++ b/TODO @@ -19,8 +19,6 @@ Legend: - Add timeout to external commands (signal alarm, watchdog thread, etc) -- New backend: pyinotify - - Uniformize filters and actions name. Use the software name (openssh, postfix, proftp) @@ -52,3 +50,5 @@ Legend: # better return values in function # refactoring in server.py, actions.py, filter.py + +* New backend: pyinotify diff --git a/config/jail.conf b/config/jail.conf index fec6b1bd..cdef1cb3 100644 --- a/config/jail.conf +++ b/config/jail.conf @@ -25,14 +25,17 @@ findtime = 600 # "maxretry" is the number of failures before a host get banned. maxretry = 3 -# "backend" specifies the backend used to get files modification. Available -# options are "gamin", "polling" and "auto". This option can be overridden in -# each jail too (use "gamin" for a jail and "polling" for another). +# "backend" specifies the backend used to get files modification. +# Available options are "pyinotify", "gamin", "polling" and "auto". +# This option can be overridden in each jail as well. # -# gamin: requires Gamin (a file alteration monitor) to be installed. If Gamin -# is not installed, Fail2ban will use polling. -# polling: uses a polling algorithm which does not require external libraries. -# auto: will choose Gamin if available and polling otherwise. +# pyinotify: requires pyinotify (a file alteration monitor) to be installed. +# If pyinotify is not installed, Fail2ban will use auto. +# gamin: requires Gamin (a file alteration monitor) to be installed. +# If Gamin is not installed, Fail2ban will use auto. +# polling: uses a polling algorithm which does not require external libraries. +# auto: will try to use the following backends, in order: +# pyinotify, gamin, polling. backend = auto diff --git a/server/filterpyinotify.py b/server/filterpyinotify.py new file mode 100644 index 00000000..66d43e53 --- /dev/null +++ b/server/filterpyinotify.py @@ -0,0 +1,155 @@ +# 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. + +# Original author: Cyril Jaquier + +__author__ = "Cyril Jaquier, Lee Clemens, Yaroslav Halchenko" +__copyright__ = "Copyright (c) 2004 Cyril Jaquier, 2011-2012 Lee Clemens, 2012 Yaroslav Halchenko" +__license__ = "GPL" + +from failmanager import FailManagerEmpty +from filter import FileFilter +from mytime import MyTime + +import time, logging, pyinotify + +# Gets the instance of the logger. +logSys = logging.getLogger("fail2ban.filter") + +## +# Log reader class. +# +# This class reads a log file and detects login failures or anything else +# that matches a given regular expression. This class is instantiated by +# a Jail object. + +class FilterPyinotify(FileFilter): + ## + # Constructor. + # + # Initialize the filter object with default values. + # @param jail the jail object + + def __init__(self, jail): + FileFilter.__init__(self, jail) + self.__modified = False + # Pyinotify watch manager + self.__monitor = pyinotify.WatchManager() + self.__watches = dict() + logSys.debug("Created FilterPyinotify") + + + def callback(self, path): + self.getFailures(path) + try: + while True: + ticket = self.failManager.toBan() + self.jail.putFailTicket(ticket) + except FailManagerEmpty: + self.failManager.cleanup(MyTime.time()) + self.dateDetector.sortTemplate() + self.__modified = False + + ## + # Add a log file path + # + # @param path log file path + + def addLogPath(self, path, tail=False): + if self.containsLogPath(path): + logSys.error(path + " already exists") + else: + wd = self.__monitor.add_watch(path, pyinotify.IN_MODIFY) + self.__watches.update(wd) + FileFilter.addLogPath(self, path, tail) + logSys.info("Added logfile = %s" % path) + + ## + # Delete a log path + # + # @param path the log file to delete + + def delLogPath(self, path): + if not self.containsLogPath(path): + logSys.error(path + " is not monitored") + else: + wdInt = self.__watches[path] + wd = self.__monitor.rm_watch(wdInt) + if wd[wdInt]: + del self.__watches[path] + FileFilter.delLogPath(self, path) + logSys.info("Removed logfile = %s" % path) + else: + logSys.error("Failed to remove watch on path: %s", path) + + ## + # Main loop. + # + # This function is the main loop of the thread. It checks if the + # file has been modified and looks for failures. + # @return True when the thread exits nicely + + def run(self): + self.setActive(True) + self.__notifier = pyinotify.ThreadedNotifier(self.__monitor, + ProcessPyinotify(self)) + self.__notifier.start() + while self._isActive(): + if not self.getIdle(): + self.__notifier.process_events() + if self.__notifier.check_events(): + self.__notifier.read_events() + else: + time.sleep(self.getSleepTime()) + # Cleanup pyinotify + self.__cleanup() + logSys.debug(self.jail.getName() + ": filter terminated") + return True + + ## + # Call super.stop() and then stop the 'Notifier' + + def stop(self): + # Call super to set __isRunning + super(FilterPyinotify, self).stop() + # Now stop the Notifier, otherwise we're deadlocked + self.__notifier.stop() + + ## + # Deallocates the resources used by pyinotify. + + def __cleanup(self): + del self.__notifier + del self.__monitor + + +class ProcessPyinotify(pyinotify.ProcessEvent): + def __init__(self, FileFilter, **kargs): + #super(ProcessPyinotify, self).__init__(**kargs) + # for some reason root class _ProcessEvent is old-style (is + # not derived from object), so to play safe let's avoid super + # for now, and call superclass directly + pyinotify.ProcessEvent.__init__(self, **kargs) + self.__FileFilter = FileFilter + pass + + # just need default, since using mask on watch to limit events + def process_default(self, event): + logSys.debug("Callback for Event: %s" % event) + self.__FileFilter.callback(event.pathname) diff --git a/server/jail.py b/server/jail.py index fa077f22..dd179a30 100644 --- a/server/jail.py +++ b/server/jail.py @@ -18,13 +18,9 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # Author: Cyril Jaquier -# -# $Revision$ -__author__ = "Cyril Jaquier" -__version__ = "$Revision$" -__date__ = "$Date$" -__copyright__ = "Copyright (c) 2004 Cyril Jaquier" +__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 @@ -35,33 +31,66 @@ from actions import Actions logSys = logging.getLogger("fail2ban.jail") class Jail: - + + #Known backends. Each backend should have corresponding __initBackend method + _BACKENDS = ('pyinotify', 'gamin', 'polling') + def __init__(self, name, backend = "auto"): self.__name = name self.__queue = Queue.Queue() self.__filter = None logSys.info("Creating new jail '%s'" % self.__name) - if backend == "polling": - self.__initPoller() - else: + self._setBackend(backend) + + def _setBackend(self, backend): + backend = backend.lower() # to assure consistent matching + + backends = self._BACKENDS + if backend != 'auto': + # we have got strict specification of the backend to use + if not (backend in self._BACKENDS): + raise ValueError("Unknown backend %s. Must be among %s or 'auto'" + % (backend, backends)) + # so explore starting from it till the 'end' + backends = backends[backends.index(backend):] + + for b in backends: + initmethod = getattr(self, '_init%s' % b.capitalize()) try: - self.__initGamin() - except ImportError: - self.__initPoller() - self.__action = Actions(self) - - def __initPoller(self): + initmethod() + if backend != 'auto' and b != backend: + logSys.warning("Could only initiated %r backend whenever " + "%r was requested" % (b, backend)) + else: + logSys.info("Initiated %r backend" % b) + self.__action = Actions(self) + return # we are done + except ImportError, e: + logSys.debug( + "Backend %r failed to initialize due to %s" % (b, e)) + raise RuntimeError( + "We should have initialized at least 'polling' backend") + + + def _initPoller(self): logSys.info("Jail '%s' uses poller" % self.__name) from filterpoll import FilterPoll self.__filter = FilterPoll(self) - def __initGamin(self): + def _initGamin(self): # Try to import gamin import gamin logSys.info("Jail '%s' uses Gamin" % self.__name) from filtergamin import FilterGamin self.__filter = FilterGamin(self) + def _initPyinotify(self): + # Try to import pyinotify + import pyinotify + logSys.info("Jail '%s' uses pyinotify" % self.__name) + from filterpyinotify import FilterPyinotify + self.__filter = FilterPyinotify(self) + def setName(self, name): self.__name = name