Merge branch 'systemd-journal' into 0.9

Conflicts:
	bin/fail2ban-regex
	config/filter.d/sshd.conf

Closes github #224
pull/277/head
Steven Hiscocks 2013-06-29 13:00:40 +01:00
commit 5ca6a9aeb6
27 changed files with 779 additions and 57 deletions

View File

@ -17,4 +17,6 @@ script:
- if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then export PYTHONPATH="$PYTHONPATH:/usr/share/pyshared:/usr/lib/pyshared/python2.7"; fi
- if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then coverage run --rcfile=.travis_coveragerc setup.py test; else python setup.py test; fi
after_success:
# Coverage config file must be .coveragerc for coveralls
- if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then cp -v .travis_coveragerc .coveragerc; fi
- if [[ $TRAVIS_PYTHON_VERSION == 2.7 ]]; then coveralls; fi

View File

@ -4,3 +4,4 @@ branch = True
omit =
/usr/*
/home/travis/virtualenv/*
fail2ban/server/filtersystemd.py

View File

@ -27,6 +27,7 @@ fail2ban/server/filter.py
fail2ban/server/filterpyinotify.py
fail2ban/server/filtergamin.py
fail2ban/server/filterpoll.py
fail2ban/server/filtersystemd.py
fail2ban/server/iso8601.py
fail2ban/server/server.py
fail2ban/server/actions.py

View File

@ -27,6 +27,7 @@ Optional:
- [pyinotify >= 0.8.3](https://github.com/seb-m/pyinotify)
- Linux >= 2.6.13
- [gamin >= 0.0.21](http://www.gnome.org/~veillard/gamin)
- [systemd >= 204](http://www.freedesktop.org/wiki/Software/systemd)
To install, just do:

View File

@ -30,12 +30,18 @@ __author__ = "Cyril Jaquier, Yaroslav Halchenko"
__copyright__ = "Copyright (c) 2004-2008 Cyril Jaquier, 2012-2013 Yaroslav Halchenko"
__license__ = "GPL"
import getopt, sys, time, logging, os, locale
import getopt, sys, time, logging, os, locale, shlex
from optparse import OptionParser, Option
from ConfigParser import NoOptionError, NoSectionError, MissingSectionHeaderError
try:
from systemd import journal
from fail2ban.server.filtersystemd import FilterSystemd
except ImportError:
journal = None
from fail2ban.version import version
from fail2ban.client.configparserinc import SafeConfigParserWithIncludes
from fail2ban.server.filter import Filter
@ -69,6 +75,7 @@ def get_opt_parser():
LOG:
string a string representing a log line
filename path to a log file (/var/log/auth.log)
"systemd-journal" search systemd journal (systemd-python required)
REGEX:
string a string representing a 'failregex'
@ -85,6 +92,9 @@ IGNOREREGEX:
help="File encoding. Default: system locale"),
Option("-L", "--maxlines", type=int, default=0,
help="maxlines for multi-line regex"),
Option("-m", "--journalmatch",
help="journalctl style matches overriding filter file. "
"\"systemd-journal\" only"),
Option("-v", "--verbose", action='store_true',
help="Be verbose in output"),
@ -167,6 +177,8 @@ class Fail2banRegex(object):
self._print_all_missed = opts.print_all_missed
self._print_all_ignored = opts.print_all_ignored
self._maxlines_set = False # so we allow to override maxlines in cmdline
self._journalmatch = None
if opts.encoding:
self.encoding = opts.encoding
else:
@ -179,6 +191,8 @@ class Fail2banRegex(object):
if opts.maxlines:
self.setMaxLines(opts.maxlines)
if opts.journalmatch is not None:
self.setJournalMatch(opts.journalmatch.split())
def setMaxLines(self, v):
@ -186,6 +200,9 @@ class Fail2banRegex(object):
self._filter.setMaxLines(int(v))
self._maxlines_set = True
def setJournalMatch(self, v):
if self._journalmatch is None:
self._journalmatch = v
def readRegex(self, value, regextype):
assert(regextype in ('fail', 'ignore'))
@ -222,6 +239,14 @@ class Fail2banRegex(object):
print "ERROR: Invalid value for maxlines (%(maxlines)r) " \
"read from %(value)s" % locals()
return False
# Read out and set possible value for journalmatch
try:
journalmatch = reader.get("Init", "journalmatch")
except (NoSectionError, NoOptionError):
# No [Init].journalmatch found.
pass
else:
self.setJournalMatch(shlex.split(journalmatch))
else:
print "Use %11s line : %s" % (regex, shortstr(value))
regex_values = [RegexStat(value)]
@ -425,6 +450,32 @@ if __name__ == "__main__":
except IOError, e:
print e
sys.exit(-1)
elif cmd_log == "systemd-journal":
if not journal:
print "Error: systemd library not found. Exiting..."
sys.exit(-1)
myjournal = journal.Reader(converters={'__CURSOR': lambda x: x})
journalmatch = fail2banRegex._journalmatch
if journalmatch:
try:
for element in journalmatch:
if element == "+":
myjournal.add_disjunction()
else:
myjournal.add_match(element)
except ValueError:
print "Error: Invalid journalmatch: %s" % shortstr(" ".join(journalmatch))
sys.exit(-1)
print "Use journal match : %s" % " ".join(journalmatch)
test_lines = []
while True:
try:
entry = myjournal.get_next()
except OSError:
continue
if not entry:
break
test_lines.append(FilterSystemd.formatJournalEntry(entry))
else:
print "Use single line : %s" % shortstr(cmd_log)
test_lines = [ cmd_log ]

View File

@ -24,3 +24,11 @@ failregex = ^%(__prefix_line)s(pam_unix(?:\(\S+\))?:)?\s+authentication failure;
# Values: TEXT
#
ignoreregex =
[Init]
# Option: journalmatch
# Notes.: systemd journalctl style match filter for journal based backends
# Values: TEXT
#
journalmatch = _SYSTEMD_UNIT=dovecot.service

View File

@ -21,3 +21,11 @@ failregex = reject: RCPT from (.*)\[<HOST>\]: 554
# Values: TEXT
#
ignoreregex =
[Init]
# Option: journalmatch
# Notes.: systemd journalctl style match filter for journal based backends
# Values: TEXT
#
journalmatch = _SYSTEMD_UNIT=postfix.service

View File

@ -36,3 +36,11 @@ failregex = fail2ban.actions:\s+WARNING\s+\[(?:.*)\]\s+Ban\s+<HOST>
#
# Ignore our own bans, to keep our counts exact.
ignoreregex = fail2ban.actions:\s+WARNING\s+\[%(_jailname)s\]\s+Ban\s+<HOST>
[Init]
# Option: journalmatch
# Notes.: systemd journalctl style match filter for journal based backends
# Values: TEXT
#
journalmatch = _SYSTEMD_UNIT=fail2ban.service

View File

@ -34,3 +34,11 @@ failregex = ^%(__prefix_line)sDid not receive identification string from <HOST>\
# Values: TEXT
#
ignoreregex =
[Init]
# Option: journalmatch
# Notes.: systemd journalctl style match filter for journal based backend
# Values: TEXT
#
journalmatch = _SYSTEMD_UNIT=sshd.service + _COMM=sshd

View File

@ -48,3 +48,9 @@ ignoreregex =
# "maxlines" is number of log lines to buffer for multi-line regex searches
maxlines = 10
# Option: journalmatch
# Notes.: systemd journalctl style match filter for journal based backend
# Values: TEXT
#
journalmatch = _SYSTEMD_UNIT=sshd.service + _COMM=sshd

View File

@ -42,7 +42,7 @@ findtime = 600
maxretry = 5
# "backend" specifies the backend used to get files modification.
# Available options are "pyinotify", "gamin", "polling" and "auto".
# Available options are "pyinotify", "gamin", "polling", "systemd" and "auto".
# This option can be overridden in each jail as well.
#
# pyinotify: requires pyinotify (a file alteration monitor) to be installed.
@ -50,6 +50,9 @@ maxretry = 5
# 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.
# systemd: uses systemd python library to access the systemd journal.
# Specifying "logpath" is not valid for this backend.
# See "journalmatch" in the jails associated filter config
# auto: will try to use the following backends, in order:
# pyinotify, gamin, polling.
backend = auto

View File

@ -113,6 +113,12 @@ class Beautifier:
elif inC[2] == "logencoding":
msg = "Current log encoding is set to:\n"
msg = msg + response
elif inC[2] in ("journalmatch", "addjournalmatch", "deljournalmatch"):
if len(response) == 0:
msg = "No journal match filter set"
else:
msg = "Current match filter:\n"
msg += ' + '.join(" ".join(res) for res in response)
elif inC[2] in ("ignoreip", "addignoreip", "delignoreip"):
if len(response) == 0:
msg = "No IP address/network is ignored"

View File

@ -24,7 +24,7 @@ __author__ = "Cyril Jaquier"
__copyright__ = "Copyright (c) 2004 Cyril Jaquier"
__license__ = "GPL"
import logging, os
import logging, os, shlex
from configreader import ConfigReader, DefinitionInitConfigReader
# Gets the instance of the logger.
@ -56,5 +56,11 @@ class FilterReader(DefinitionInitConfigReader):
if self._initOpts:
if 'maxlines' in self._initOpts:
stream.append(["set", self._jailName, "maxlines", self._initOpts["maxlines"]])
# Do not send a command if the match is empty.
if self._initOpts.get("journalmatch", '') != '':
for match in self._initOpts["journalmatch"].split("\n"):
stream.append(
["set", self._jailName, "addjournalmatch"] +
shlex.split(match))
return stream

View File

@ -36,6 +36,8 @@ logSys = logging.getLogger(__name__)
class JailReader(ConfigReader):
optionCRE = re.compile("^((?:\w|-|_|\.)+)(?:\[(.*)\])?$")
optionExtractRE = re.compile(
r'([\w\-_\.]+)=(?:"([^"]*)"|\'([^\']*)\'|([^,]*))(?:,|$)')
def __init__(self, name, force_enable=False, **kwargs):
ConfigReader.__init__(self, **kwargs)
@ -155,46 +157,13 @@ class JailReader(ConfigReader):
#@staticmethod
def extractOptions(option):
m = JailReader.optionCRE.match(option)
d = dict()
mgroups = m.groups()
if len(mgroups) == 2:
option_name, option_opts = mgroups
elif len(mgroups) == 1:
option_name, option_opts = mgroups[0], None
else:
raise ValueError("While reading option %s we should have got up to "
"2 groups. Got: %r" % (option, mgroups))
if not option_opts is None:
# Huge bad hack :( This method really sucks. TODO Reimplement it.
options = ""
escapeChar = None
allowComma = False
for c in option_opts:
if c in ('"', "'") and not allowComma:
# Start
escapeChar = c
allowComma = True
elif c == escapeChar:
# End
escapeChar = None
allowComma = False
else:
if c == ',' and allowComma:
options += "<COMMA>"
else:
options += c
# Split using ,
optionsSplit = options.split(',')
# Replace the tag <COMMA> with ,
optionsSplit = [n.replace("<COMMA>", ',') for n in optionsSplit]
for param in optionsSplit:
p = param.split('=')
try:
d[p[0].strip()] = p[1].strip()
except IndexError:
logSys.error("Invalid argument %s in '%s'" % (p, option_opts))
return [option_name, d]
option_name, optstr = JailReader.optionCRE.match(option).groups()
option_opts = dict()
if optstr:
for optmatch in JailReader.optionExtractRE.finditer(optstr):
opt = optmatch.group(1)
value = [
val for val in optmatch.group(2,3,4) if val is not None][0]
option_opts[opt.strip()] = value.strip()
return option_name, option_opts
extractOptions = staticmethod(extractOptions)

View File

@ -55,6 +55,8 @@ protocol = [
["set <JAIL> addlogpath <FILE>", "adds <FILE> to the monitoring list of <JAIL>"],
["set <JAIL> dellogpath <FILE>", "removes <FILE> from the monitoring list of <JAIL>"],
["set <JAIL> logencoding <ENCODING>", "sets the <ENCODING> of the log files for <JAIL>"],
["set <JAIL> addjournalmatch <MATCH>", "adds <MATCH> to the journal filter of <JAIL>"],
["set <JAIL> deljournalmatch <MATCH>", "removes <MATCH> from the journal filter of <JAIL>"],
["set <JAIL> addfailregex <REGEX>", "adds the regular expression <REGEX> which must match failures for <JAIL>"],
["set <JAIL> delfailregex <INDEX>", "removes the regular expression at <INDEX> for failregex"],
["set <JAIL> addignoreregex <REGEX>", "adds the regular expression <REGEX> which should match pattern to exclude for <JAIL>"],
@ -79,6 +81,7 @@ protocol = [
['', "JAIL INFORMATION", ""],
["get <JAIL> logpath", "gets the list of the monitored files for <JAIL>"],
["get <JAIL> logencoding <ENCODING>", "gets the <ENCODING> of the log files for <JAIL>"],
["get <JAIL> journalmatch", "gets the journal filter match for <JAIL>"],
["get <JAIL> ignoreip", "gets the list of ignored IP addresses for <JAIL>"],
["get <JAIL> failregex", "gets the list of regular expressions which matches the failures for <JAIL>"],
["get <JAIL> ignoreregex", "gets the list of regular expressions which matches patterns to ignore for <JAIL>"],

View File

@ -647,6 +647,21 @@ class FileContainer:
self.__handler = None
##
# JournalFilter class.
#
# Base interface class for systemd journal filters
class JournalFilter(Filter): # pragma: systemd no cover
def addJournalMatch(self, match): # pragma: no cover - Base class, not used
pass
def delJournalMatch(self, match): # pragma: no cover - Base class, not used
pass
def getJournalMatch(self, match): # pragma: no cover - Base class, not used
return []
##
# Utils class for DNS and IP handling.

View File

@ -0,0 +1,263 @@
# 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__ = "Steven Hiscocks"
__copyright__ = "Copyright (c) 2013 Steven Hiscocks"
__license__ = "GPL"
import logging, datetime
from distutils.version import LooseVersion
from systemd import journal
if LooseVersion(getattr(journal, '__version__', "0")) < '204':
raise ImportError("Fail2Ban requires systemd >= 204")
from failmanager import FailManagerEmpty
from filter import JournalFilter
from mytime import MyTime
# Gets the instance of the logger.
logSys = logging.getLogger("fail2ban.filter")
##
# Journal reader class.
#
# This class reads from systemd journal and detects login failures or anything
# else that matches a given regular expression. This class is instantiated by
# a Jail object.
class FilterSystemd(JournalFilter): # pragma: systemd no cover
##
# Constructor.
#
# Initialize the filter object with default values.
# @param jail the jail object
def __init__(self, jail, **kwargs):
JournalFilter.__init__(self, jail, **kwargs)
self.__modified = False
# Initialise systemd-journal connection
self.__journal = journal.Reader(converters={'__CURSOR': lambda x: x})
self.__matches = []
logSys.debug("Created FilterSystemd")
##
# Add a journal match filters from list structure
#
# @param matches list structure with journal matches
def _addJournalMatches(self, matches):
if self.__matches:
self.__journal.add_disjunction() # Add OR
newMatches = []
for match in matches:
newMatches.append([])
for match_element in match:
self.__journal.add_match(match_element)
newMatches[-1].append(match_element)
self.__journal.add_disjunction()
self.__matches.extend(newMatches)
##
# Add a journal match filter
#
# @param match journalctl syntax matches in list structure
def addJournalMatch(self, match):
newMatches = [[]]
for match_element in match:
if match_element == "+":
newMatches.append([])
else:
newMatches[-1].append(match_element)
try:
self._addJournalMatches(newMatches)
except ValueError:
logSys.error(
"Error adding journal match for: %r", " ".join(match))
self.resetJournalMatches()
raise
else:
logSys.info("Added journal match for: %r", " ".join(match))
##
# Reset a journal match filter called on removal or failure
#
# @return None
def resetJournalMatches(self):
self.__journal.flush_matches()
logSys.debug("Flushed all journal matches")
match_copy = self.__matches[:]
self.__matches = []
try:
self._addJournalMatches(match_copy)
except ValueError:
logSys.error("Error restoring journal matches")
raise
else:
logSys.debug("Journal matches restored")
##
# Delete a journal match filter
#
# @param match journalctl syntax matches
def delJournalMatch(self, match):
if match in self.__matches:
del self.__matches[self.__matches.index(match)]
self.resetJournalMatches()
else:
raise ValueError("Match not found")
logSys.info("Removed journal match for: %r" % " ".join(match))
##
# Get current journal match filter
#
# @return journalctl syntax matches
def getJournalMatch(self):
return self.__matches
##
# Join group of log elements which may be a mix of bytes and strings
#
# @param elements list of strings and bytes
# @return elements joined as string
@staticmethod
def _joinStrAndBytes(elements):
strElements = []
for element in elements:
if isinstance(element, str):
strElements.append(element)
else:
strElements.append(str(element, errors='ignore'))
return " ".join(strElements)
##
# Format journal log entry into syslog style
#
# @param entry systemd journal entry dict
# @return format log line
@staticmethod
def formatJournalEntry(logentry):
logelements = [logentry.get('_SOURCE_REALTIME_TIMESTAMP',
logentry.get('__REALTIME_TIMESTAMP')).isoformat()]
if logentry.get('_HOSTNAME'):
logelements.append(logentry['_HOSTNAME'])
if logentry.get('SYSLOG_IDENTIFIER'):
logelements.append(logentry['SYSLOG_IDENTIFIER'])
if logentry.get('_PID'):
logelements[-1] += ("[%i]" % logentry['_PID'])
logelements[-1] += ":"
elif logentry.get('_COMM'):
logelements.append(logentry['_COMM'])
if logentry.get('_PID'):
logelements[-1] += ("[%i]" % logentry['_PID'])
logelements[-1] += ":"
if logelements[-1] == "kernel:":
if '_SOURCE_MONOTONIC_TIMESTAMP' in logentry:
monotonic = logentry.get('_SOURCE_MONOTONIC_TIMESTAMP')
else:
monotonic = logentry.get('__MONOTONIC_TIMESTAMP')[0]
logelements.append("[%12.6f]" % monotonic.total_seconds())
if isinstance(logentry.get('MESSAGE',''), list):
logelements.append(" ".join(logentry['MESSAGE']))
else:
logelements.append(logentry.get('MESSAGE', ''))
try:
logline = u" ".join(logelements) + u"\n"
except UnicodeDecodeError:
# Python 2, so treat as string
logline = " ".join([str(logline) for logline in logelements]) + "\n"
except TypeError:
# Python 3, one or more elements bytes
logSys.warning("Error decoding log elements from journal: %s" %
repr(logelements))
logline = self._joinStrAndBytes(logelements) + "\n"
logSys.debug("Read systemd journal entry: %s" % repr(logline))
return logline
##
# Main loop.
#
# Peridocily check for new journal entries matching the filter and
# handover to FailManager
def run(self):
self.setActive(True)
# Seek to now - findtime in journal
start_time = datetime.datetime.now() - \
datetime.timedelta(seconds=int(self.getFindTime()))
self.__journal.seek_realtime(start_time)
# Move back one entry to ensure do not end up in dead space
# if start time beyond end of journal
try:
self.__journal.get_previous()
except OSError:
pass # Reading failure, so safe to ignore
while self._isActive():
if not self.getIdle():
while self._isActive():
try:
logentry = self.__journal.get_next()
except OSError:
logSys.warning(
"Error reading line from systemd journal")
continue
if logentry:
self.processLineAndAdd(
self.formatJournalEntry(logentry))
self.__modified = True
else:
break
if self.__modified:
try:
while True:
ticket = self.failManager.toBan()
self.jail.putFailTicket(ticket)
except FailManagerEmpty:
self.failManager.cleanup(MyTime.time())
self.dateDetector.sortTemplate()
self.__modified = False
self.__journal.wait(self.getSleepTime())
logSys.debug((self.jail is not None and self.jail.getName()
or "jailless") +" filter terminated")
return True
##
# Get the status of the filter.
#
# Get some informations about the filter state such as the total
# number of failures.
# @return a list with tuple
def status(self):
ret = JournalFilter.status(self)
ret.append(("Journal matches",
[" + ".join(" ".join(match) for match in self.__matches)]))
return ret

View File

@ -35,7 +35,7 @@ class Jail:
#Known backends. Each backend should have corresponding __initBackend method
# yoh: stored in a list instead of a tuple since only
# list had .index until 2.6
_BACKENDS = ['pyinotify', 'gamin', 'polling']
_BACKENDS = ['pyinotify', 'gamin', 'polling', 'systemd']
def __init__(self, name, backend = "auto"):
self.setName(name)
@ -101,6 +101,13 @@ class Jail:
from filterpyinotify import FilterPyinotify
self.__filter = FilterPyinotify(self)
def _initSystemd(self): # pragma: systemd no cover
# Try to import systemd
import systemd
logSys.info("Jail '%s' uses systemd" % self.__name)
from filtersystemd import FilterSystemd
self.__filter = FilterSystemd(self)
def setName(self, name):
if len(name) >= 20:
logSys.warning("Jail name %r might be too long and some commands "

View File

@ -26,6 +26,7 @@ __license__ = "GPL"
from threading import Lock, RLock
from jails import Jails
from filter import FileFilter, JournalFilter
from transmitter import Transmitter
from asyncserver import AsyncServer
from asyncserver import AsyncServerException
@ -169,20 +170,51 @@ class Server:
return self.__jails.getFilter(name).getIgnoreIP()
def addLogPath(self, name, fileName):
self.__jails.getFilter(name).addLogPath(fileName)
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, FileFilter):
filter_.addLogPath(fileName)
def delLogPath(self, name, fileName):
self.__jails.getFilter(name).delLogPath(fileName)
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, FileFilter):
filter_.delLogPath(fileName)
def getLogPath(self, name):
return [m.getFileName()
for m in self.__jails.getFilter(name).getLogPath()]
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, FileFilter):
return [m.getFileName()
for m in filter_.getLogPath()]
else: # pragma: systemd no cover
logSys.info("Jail %s is not a FileFilter instance" % name)
return []
def addJournalMatch(self, name, match): # pragma: systemd no cover
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, JournalFilter):
filter_.addJournalMatch(match)
def delJournalMatch(self, name, match): # pragma: systemd no cover
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, JournalFilter):
filter_.delJournalMatch(match)
def getJournalMatch(self, name): # pragma: systemd no cover
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, JournalFilter):
return filter_.getJournalMatch()
else:
logSys.info("Jail %s is not a JournalFilter instance" % name)
return []
def setLogEncoding(self, name, encoding):
return self.__jails.getFilter(name).setLogEncoding(encoding)
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, FileFilter):
filter_.setLogEncoding(encoding)
def getLogEncoding(self, name):
return self.__jails.getFilter(name).getLogEncoding()
filter_ = self.__jails.getFilter(name)
if isinstance(filter_, FileFilter):
return filter_.getLogEncoding()
def setFindTime(self, name, value):
self.__jails.getFilter(name).setFindTime(value)

View File

@ -144,6 +144,14 @@ class Transmitter:
value = command[2]
self.__server.setLogEncoding(name, value)
return self.__server.getLogEncoding(name)
elif command[1] == "addjournalmatch": # pragma: systemd no cover
value = command[2:]
self.__server.addJournalMatch(name, value)
return self.__server.getJournalMatch(name)
elif command[1] == "deljournalmatch": # pragma: systemd no cover
value = command[2:]
self.__server.delJournalMatch(name, value)
return self.__server.getJournalMatch(name)
elif command[1] == "addfailregex":
value = command[2]
self.__server.addFailRegex(name, value)
@ -250,6 +258,8 @@ class Transmitter:
return self.__server.getLogPath(name)
elif command[1] == "logencoding":
return self.__server.getLogEncoding(name)
elif command[1] == "journalmatch": # pragma: systemd no cover
return self.__server.getJournalMatch(name)
elif command[1] == "ignoreip":
return self.__server.getIgnoreIP(name)
elif command[1] == "failregex":

View File

@ -145,11 +145,35 @@ class JailReaderTest(unittest.TestCase):
self.assertEqual(jail.getName(), 'sshd')
def testSplitOption(self):
action = "mail-whois[name=SSH]"
expected = ['mail-whois', {'name': 'SSH'}]
result = JailReader.extractOptions(action)
self.assertEquals(expected, result)
# Simple example
option = "mail-whois[name=SSH]"
expected = ('mail-whois', {'name': 'SSH'})
result = JailReader.extractOptions(option)
self.assertEqual(expected, result)
# Empty option
option = "abc[]"
expected = ('abc', {})
result = JailReader.extractOptions(option)
self.assertEqual(expected, result)
# More complex examples
option = 'option[opt01=abc,opt02="123",opt03="with=okay?",opt04="andwith,okay...",opt05="how about spaces",opt06="single\'in\'double",opt07=\'double"in"single\', opt08= leave some space, opt09=one for luck, opt10=, opt11=]'
expected = ('option', {
'opt01': "abc",
'opt02': "123",
'opt03': "with=okay?",
'opt04': "andwith,okay...",
'opt05': "how about spaces",
'opt06': "single'in'double",
'opt07': "double\"in\"single",
'opt08': "leave some space",
'opt09': "one for luck",
'opt10': "",
'opt11': "",
})
result = JailReader.extractOptions(option)
self.assertEqual(expected, result)
class FilterReaderTest(unittest.TestCase):
@ -173,7 +197,12 @@ class FilterReaderTest(unittest.TestCase):
"+$<SKIPLINES>^.+ module for .* from <HOST>\\s*$"],
['set', 'testcase01', 'addignoreregex',
"^.+ john from host 192.168.1.1\\s*$"],
['set', 'testcase01', 'maxlines', "1"]]
['set', 'testcase01', 'addjournalmatch',
"_COMM=sshd", "+", "_SYSTEMD_UNIT=sshd.service", "_UID=0"],
['set', 'testcase01', 'addjournalmatch',
"FIELD= with spaces ", "+", "AFIELD= with + char and spaces"],
['set', 'testcase01', 'maxlines', "1"], # Last for overide test
]
filterReader = FilterReader("testcase01", "testcase01", {})
filterReader.setBaseDir(TEST_FILES_DIR)
filterReader.read()

View File

@ -36,3 +36,10 @@ ignoreregex = ^.+ john from host 192.168.1.1\s*$
[Init]
# "maxlines" is number of log lines to buffer for multi-line regex searches
maxlines = 1
# Option: journalmatch
# Notes.: systemd journalctl style match filter for journal based backends
# Values: TEXT
#
journalmatch = _COMM=sshd + _SYSTEMD_UNIT=sshd.service _UID=0
"FIELD= with spaces " + AFIELD=" with + char and spaces"

View File

@ -0,0 +1,19 @@
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from failed.dns.ch
error: PAM: Authentication failure for kevin from failed.dns.ch
error: PAM: Authentication failure for kevin from failed.dns.ch
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 193.168.0.128
error: PAM: Authentication failure for kevin from 87.142.124.10
error: PAM: Authentication failure for kevin from 87.142.124.10
error: PAM: Authentication failure for kevin from 87.142.124.10
error: PAM: Authentication failure for kevin from 87.142.124.10

View File

@ -29,6 +29,11 @@ import sys
import time
import tempfile
try:
from systemd import journal
except ImportError:
journal = None
from fail2ban.server.jail import Jail
from fail2ban.server.filterpoll import FilterPoll
from fail2ban.server.filter import FileFilter, DNSUtils
@ -160,6 +165,34 @@ def _copy_lines_between_files(in_, fout, n=None, skip=0, mode='a', terminal_line
time.sleep(0.1)
return fout
def _copy_lines_to_journal(in_, fields={},n=None, skip=0, terminal_line=""): # pragma: systemd no cover
"""Copy lines from one file to systemd journal
Returns None
"""
if isinstance(in_, str): # pragma: no branch - only used with str in test cases
fin = open(in_, 'r')
else:
fin = in_
# Required for filtering
fields.update({"SYSLOG_IDENTIFIER": "fail2ban-testcases",
"PRIORITY": "7",
})
# Skip
for i in xrange(skip):
_ = fin.readline()
# Read/Write
i = 0
while n is None or i < n:
l = fin.readline()
if terminal_line is not None and l == terminal_line:
break
journal.send(MESSAGE=l.strip(), **fields)
i += 1
if isinstance(in_, str): # pragma: no branch - only used with str in test cases
# Opened earlier, therefore must close it
fin.close()
#
# Actual tests
#
@ -574,6 +607,129 @@ def get_monitor_failures_testcase(Filter_):
% (Filter_.__name__, testclass_name) # 'tempfile')
return MonitorFailures
def get_monitor_failures_journal_testcase(Filter_): # pragma: systemd no cover
"""Generator of TestCase's for journal based filters/backends
"""
class MonitorJournalFailures(unittest.TestCase):
def setUp(self):
"""Call before every test case."""
self.test_file = os.path.join(TEST_FILES_DIR, "testcase-journal.log")
self.jail = DummyJail()
self.filter = Filter_(self.jail)
# UUID used to ensure that only meeages generated
# as part of this test are picked up by the filter
import uuid
self.test_uuid = str(uuid.uuid4())
self.name = "monitorjournalfailures-%s" % self.test_uuid
self.filter.addJournalMatch([
"SYSLOG_IDENTIFIER=fail2ban-testcases",
"TEST_FIELD=1",
"TEST_UUID=%s" % self.test_uuid])
self.filter.addJournalMatch([
"SYSLOG_IDENTIFIER=fail2ban-testcases",
"TEST_FIELD=2",
"TEST_UUID=%s" % self.test_uuid])
self.journal_fields = {
'TEST_FIELD': "1", 'TEST_UUID': self.test_uuid}
self.filter.setActive(True)
self.filter.addFailRegex("(?:(?:Authentication failure|Failed [-/\w+]+) for(?: [iI](?:llegal|nvalid) user)?|[Ii](?:llegal|nvalid) user|ROOT LOGIN REFUSED) .*(?: from|FROM) <HOST>")
self.filter.start()
def tearDown(self):
self.filter.stop()
self.filter.join() # wait for the thread to terminate
pass
def __str__(self):
return "MonitorJournalFailures%s(%s)" \
% (Filter_, hasattr(self, 'name') and self.name or 'tempfile')
def isFilled(self, delay=2.):
"""Wait up to `delay` sec to assure that it was modified or not
"""
time0 = time.time()
while time.time() < time0 + delay:
if len(self.jail):
return True
time.sleep(0.1)
return False
def isEmpty(self, delay=0.4):
# shorter wait time for not modified status
return not self.isFilled(delay)
def assert_correct_ban(self, test_ip, test_attempts):
self.assertTrue(self.isFilled(10)) # give Filter a chance to react
ticket = self.jail.getFailTicket()
attempts = ticket.getAttempt()
ip = ticket.getIP()
matches = ticket.getMatches()
self.assertEqual(ip, test_ip)
self.assertEqual(attempts, test_attempts)
def test_grow_file(self):
self.assertRaises(FailManagerEmpty, self.filter.failManager.toBan)
# Now let's feed it with entries from the file
_copy_lines_to_journal(
self.test_file, self.journal_fields, n=2)
self.assertRaises(FailManagerEmpty, self.filter.failManager.toBan)
# and our dummy jail is empty as well
self.assertFalse(len(self.jail))
# since it should have not been enough
_copy_lines_to_journal(
self.test_file, self.journal_fields, skip=2, n=3)
self.assertTrue(self.isFilled(6))
# so we sleep for up to 6 sec for it not to become empty,
# and meanwhile pass to other thread(s) and filter should
# have gathered new failures and passed them into the
# DummyJail
self.assertEqual(len(self.jail), 1)
# and there should be no "stuck" ticket in failManager
self.assertRaises(FailManagerEmpty, self.filter.failManager.toBan)
self.assert_correct_ban("193.168.0.128", 3)
self.assertEqual(len(self.jail), 0)
# Lets read some more to check it bans again
_copy_lines_to_journal(
self.test_file, self.journal_fields, skip=5, n=4)
self.assert_correct_ban("193.168.0.128", 3)
def test_delJournalMatch(self):
# Smoke test for removing of match
# basic full test
_copy_lines_to_journal(
self.test_file, self.journal_fields, n=5)
self.assert_correct_ban("193.168.0.128", 3)
# and now remove the JournalMatch
self.filter.delJournalMatch([
"SYSLOG_IDENTIFIER=fail2ban-testcases",
"TEST_FIELD=1",
"TEST_UUID=%s" % self.test_uuid])
_copy_lines_to_journal(
self.test_file, self.journal_fields, n=5, skip=5)
# so we should get no more failures detected
self.assertTrue(self.isEmpty(2))
# but then if we add it back again
self.filter.addJournalMatch([
"SYSLOG_IDENTIFIER=fail2ban-testcases",
"TEST_FIELD=1",
"TEST_UUID=%s" % self.test_uuid])
self.assert_correct_ban("193.168.0.128", 4)
_copy_lines_to_journal(
self.test_file, self.journal_fields, n=6, skip=10)
# we should detect the failures
self.assertTrue(self.isFilled(6))
return MonitorJournalFailures
class GetFailures(unittest.TestCase):

View File

@ -29,6 +29,10 @@ import unittest, socket, time, tempfile, os, locale
from fail2ban.server.server import Server
from fail2ban.server.jail import Jail
from fail2ban.exceptions import UnknownJailException
try:
from fail2ban.server import filtersystemd
except ImportError:
filtersystemd = None
TEST_FILES_DIR = os.path.join(os.path.dirname(__file__), "files")
@ -499,6 +503,76 @@ class Transmitter(TransmitterBase):
self.assertEqual(
self.transm.proceed(["status", "INVALID", "COMMAND"])[0],1)
if filtersystemd: # pragma: systemd no cover
def testJournalMatch(self):
jailName = "TestJail2"
self.server.addJail(jailName, "systemd")
values = [
"_SYSTEMD_UNIT=sshd.service",
"TEST_FIELD1=ABC",
"_HOSTNAME=example.com",
]
for n, value in enumerate(values):
self.assertEqual(
self.transm.proceed(
["set", jailName, "addjournalmatch", value]),
(0, [[val] for val in values[:n+1]]))
for n, value in enumerate(values):
self.assertEqual(
self.transm.proceed(
["set", jailName, "deljournalmatch", value]),
(0, [[val] for val in values[n+1:]]))
# Try duplicates
value = "_COMM=sshd"
self.assertEqual(
self.transm.proceed(
["set", jailName, "addjournalmatch", value]),
(0, [[value]]))
# Duplicates are accepted, as automatically OR'd, and journalctl
# also accepts them without issue.
self.assertEqual(
self.transm.proceed(
["set", jailName, "addjournalmatch", value]),
(0, [[value], [value]]))
# Remove first instance
self.assertEqual(
self.transm.proceed(
["set", jailName, "deljournalmatch", value]),
(0, [[value]]))
# Remove second instance
self.assertEqual(
self.transm.proceed(
["set", jailName, "deljournalmatch", value]),
(0, []))
value = [
"_COMM=sshd", "+", "_SYSTEMD_UNIT=sshd.service", "_UID=0"]
self.assertEqual(
self.transm.proceed(
["set", jailName, "addjournalmatch"] + value),
(0, [["_COMM=sshd"], ["_SYSTEMD_UNIT=sshd.service", "_UID=0"]]))
self.assertEqual(
self.transm.proceed(
["set", jailName, "deljournalmatch"] + value[:1]),
(0, [["_SYSTEMD_UNIT=sshd.service", "_UID=0"]]))
self.assertEqual(
self.transm.proceed(
["set", jailName, "deljournalmatch"] + value[2:]),
(0, []))
# Invalid match
value = "This isn't valid!"
result = self.transm.proceed(
["set", jailName, "addjournalmatch", value])
self.assertTrue(isinstance(result[1], ValueError))
# Delete invalid match
value = "FIELD=NotPresent"
result = self.transm.proceed(
["set", jailName, "deljournalmatch", value])
self.assertTrue(isinstance(result[1], ValueError))
class TransmitterLogging(TransmitterBase):
def setUp(self):

View File

@ -208,6 +208,12 @@ def gatherTests(regexps=None, no_network=False):
for Filter_ in filters:
tests.addTest(unittest.makeSuite(
filtertestcase.get_monitor_failures_testcase(Filter_)))
try: # pragma: systemd no cover
from fail2ban.server.filtersystemd import FilterSystemd
tests.addTest(unittest.makeSuite(filtertestcase.get_monitor_failures_journal_testcase(FilterSystemd)))
except Exception, e: # pragma: no cover
logSys.warning("I: Skipping systemd backend testing. Got exception '%s'" % e)
# Server test for logging elements which break logging used to support
# testcases analysis

View File

@ -60,6 +60,26 @@ The following options are applicable to all jails. Their meaning is described in
.TP
\fBusedns\fR
.PP
.SS Backends
\fBbackend\fR specifies the backend used to get files modification. This option can be overridden in each jail as well.
Available options are listed below.
.TP
\fIpyinotify\fR
requires pyinotify (a file alteration monitor) to be installed. If pyinotify is not installed, Fail2ban will use auto.
.TP
\fIgamin\fR
requires Gamin (a file alteration monitor) to be installed. If Gamin is not installed, Fail2ban will use auto.
.TP
\fIpolling\fR
uses a polling algorithm which does not require external libraries.
.TP
\fIsystemd\fR
uses systemd python library to access the systemd journal. Specifying \fBlogpath\fR is not valid for this backend and instead utilises \fBjournalmatch\fR from the jails associated filter config.
.TP
\fIauto\fR
will try to use the following backends, in order: pyinotify, gamin, polling
.PP
.SS Actions
Each jail can be configured with only a single filter, but may have multiple actions. By default, the name of a action is the action filename. In the case where multiple of the same action are to be used, the \fBactname\fR option can be assigned to the action to avoid duplicatione.g.:
.PP
.nf
@ -154,6 +174,9 @@ Similar to actions, filters have an [Init] section which can be overridden in \f
.TP
\fBmaxlines\fR
specifies the maximum number of lines to buffer to match multi-line regexs. For some log formats this will not required to be changed. Other logs may require to increase this value if a particular log file is frequently written to.
.TP
\fBjournalmatch\fR
specifies the systemd journal match used to filter the journal entries. See \fBjournalctl(1)\fR and \fBsystemd.journal-fields(7)\fR for matches syntax and more details on special journal fields. This option is only valid for the \fIsystemd\fR backend.
.PP
Filters can also have a section called [INCLUDES]. This is used to read other configuration files.