fail2ban/fail2ban/server/database.py

462 lines
13 KiB
Python

# 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
import sys
import shutil, time
import sqlite3
import json
import locale
from functools import wraps
from threading import Lock
from .mytime import MyTime
from .ticket import FailTicket
# Gets the instance of the logger.
logSys = logging.getLogger(__name__)
if sys.version_info >= (3,):
sqlite3.register_adapter(
dict,
lambda x: json.dumps(x, ensure_ascii=False).encode(
locale.getpreferredencoding(), 'replace'))
sqlite3.register_converter(
"JSON",
lambda x: json.loads(x.decode(
locale.getpreferredencoding(), 'replace')))
else:
sqlite3.register_adapter(dict, json.dumps)
sqlite3.register_converter("JSON", json.loads)
def commitandrollback(f):
@wraps(f)
def wrapper(self, *args, **kwargs):
with self._lock: # Threading lock
with self._db: # Auto commit and rollback on exception
return f(self, self._db.cursor(), *args, **kwargs)
return wrapper
class Fail2BanDb(object):
"""Fail2Ban database for storing persistent data.
This allows after Fail2Ban is restarted to reinstated bans and
to continue monitoring logs from the same point.
This will either create a new Fail2Ban database, connect to an
existing, and if applicable upgrade the schema in the process.
Parameters
----------
filename : str
File name for SQLite3 database, which will be created if
doesn't already exist.
purgeAge : int
Purge age in seconds, used to remove old bans from
database during purge.
Raises
------
sqlite3.OperationalError
Error connecting/creating a SQLite3 database.
RuntimeError
If exisiting database fails to update to new schema.
Attributes
----------
filename
purgeage
"""
__version__ = 2
# Note all _TABLE_* strings must end in ';' for py26 compatibility
_TABLE_fail2banDb = "CREATE TABLE fail2banDb(version INTEGER);"
_TABLE_jails = "CREATE TABLE jails(" \
"name TEXT NOT NULL UNIQUE, " \
"enabled INTEGER NOT NULL DEFAULT 1" \
");" \
"CREATE INDEX jails_name ON jails(name);"
_TABLE_logs = "CREATE TABLE logs(" \
"jail TEXT NOT NULL, " \
"path TEXT, " \
"firstlinemd5 TEXT, " \
"lastfilepos INTEGER DEFAULT 0, " \
"FOREIGN KEY(jail) REFERENCES jails(name) ON DELETE CASCADE, " \
"UNIQUE(jail, path)," \
"UNIQUE(jail, path, firstlinemd5)" \
");" \
"CREATE INDEX logs_path ON logs(path);" \
"CREATE INDEX logs_jail_path ON logs(jail, path);"
#TODO: systemd journal features \
#"journalmatch TEXT, " \
#"journlcursor TEXT, " \
#"lastfiletime INTEGER DEFAULT 0, " # is this easily available \
_TABLE_bans = "CREATE TABLE bans(" \
"jail TEXT NOT NULL, " \
"ip TEXT, " \
"timeofban INTEGER NOT NULL, " \
"data JSON, " \
"FOREIGN KEY(jail) REFERENCES jails(name) " \
");" \
"CREATE INDEX bans_jail_timeofban_ip ON bans(jail, timeofban);" \
"CREATE INDEX bans_jail_ip ON bans(jail, ip);" \
"CREATE INDEX bans_ip ON bans(ip);" \
def __init__(self, filename, purgeAge=24*60*60):
try:
self._lock = Lock()
self._db = sqlite3.connect(
filename, check_same_thread=False,
detect_types=sqlite3.PARSE_DECLTYPES)
self._dbFilename = filename
self._purgeAge = purgeAge
self._bansMergedCache = {}
logSys.info(
"Connected to fail2ban persistent database '%s'", filename)
except sqlite3.OperationalError, e:
logSys.error(
"Error connecting to fail2ban persistent database '%s': %s",
filename, e.args[0])
raise
cur = self._db.cursor()
cur.execute("PRAGMA foreign_keys = ON;")
try:
cur.execute("SELECT version FROM fail2banDb LIMIT 1")
except sqlite3.OperationalError:
logSys.warning("New database created. Version '%i'",
self.createDb())
else:
version = cur.fetchone()[0]
if version < Fail2BanDb.__version__:
newversion = self.updateDb(version)
if newversion == Fail2BanDb.__version__:
logSys.warning( "Database updated from '%i' to '%i'",
version, newversion)
else:
logSys.error( "Database update failed to achieve version '%i'"
": updated from '%i' to '%i'",
Fail2BanDb.__version__, version, newversion)
raise RuntimeError('Failed to fully update')
finally:
cur.close()
@property
def filename(self):
"""File name of SQLite3 database file.
"""
return self._dbFilename
@property
def purgeage(self):
"""Purge age in seconds.
"""
return self._purgeAge
@purgeage.setter
def purgeage(self, value):
self._purgeAge = int(value)
@commitandrollback
def createDb(self, cur):
"""Creates a new database, called during initialisation.
"""
# Version info
cur.executescript(Fail2BanDb._TABLE_fail2banDb)
cur.execute("INSERT INTO fail2banDb(version) VALUES(?)",
(Fail2BanDb.__version__, ))
# Jails
cur.executescript(Fail2BanDb._TABLE_jails)
# Logs
cur.executescript(Fail2BanDb._TABLE_logs)
# Bans
cur.executescript(Fail2BanDb._TABLE_bans)
cur.execute("SELECT version FROM fail2banDb LIMIT 1")
return cur.fetchone()[0]
@commitandrollback
def updateDb(self, cur, version):
"""Update an existing database, called during initialisation.
A timestamped backup is also created prior to attempting the update.
"""
self._dbBackupFilename = self.filename + '.' + time.strftime('%Y%m%d-%H%M%S', MyTime.gmtime())
shutil.copyfile(self.filename, self._dbBackupFilename)
logSys.info("Database backup created: %s", self._dbBackupFilename)
if version > Fail2BanDb.__version__:
raise NotImplementedError(
"Attempt to travel to future version of database ...how did you get here??")
if version < 2:
cur.executescript("BEGIN TRANSACTION;"
"CREATE TEMPORARY TABLE logs_temp AS SELECT * FROM logs;"
"DROP TABLE logs;"
"%s;"
"INSERT INTO logs SELECT * from logs_temp;"
"DROP TABLE logs_temp;"
"UPDATE fail2banDb SET version = 2;"
"COMMIT;" % Fail2BanDb._TABLE_logs)
cur.execute("SELECT version FROM fail2banDb LIMIT 1")
return cur.fetchone()[0]
@commitandrollback
def addJail(self, cur, jail):
"""Adds a jail to the database.
Parameters
----------
jail : Jail
Jail to be added to the database.
"""
cur.execute(
"INSERT OR REPLACE INTO jails(name, enabled) VALUES(?, 1)",
(jail.name,))
@commitandrollback
def delJail(self, cur, jail):
"""Deletes a jail from the database.
Parameters
----------
jail : Jail
Jail to be removed from the database.
"""
# Will be deleted by purge as appropriate
cur.execute(
"UPDATE jails SET enabled=0 WHERE name=?", (jail.name, ))
@commitandrollback
def delAllJails(self, cur):
"""Deletes all jails from the database.
"""
# Will be deleted by purge as appropriate
cur.execute("UPDATE jails SET enabled=0")
@commitandrollback
def getJailNames(self, cur):
"""Get name of jails in database.
Currently only used for testing purposes.
Returns
-------
set
Set of jail names.
"""
cur.execute("SELECT name FROM jails")
return set(row[0] for row in cur.fetchmany())
@commitandrollback
def addLog(self, cur, jail, container):
"""Adds a log to the database.
Parameters
----------
jail : Jail
Jail that log is being monitored by.
container : FileContainer
File container of the log file being added.
Returns
-------
int
If log was already present in database, value of last position
in the log file; else `None`
"""
lastLinePos = None
cur.execute(
"SELECT firstlinemd5, lastfilepos FROM logs "
"WHERE jail=? AND path=?",
(jail.name, container.getFileName()))
try:
firstLineMD5, lastLinePos = cur.fetchone()
except TypeError:
firstLineMD5 = False
cur.execute(
"INSERT OR REPLACE INTO logs(jail, path, firstlinemd5, lastfilepos) "
"VALUES(?, ?, ?, ?)",
(jail.name, container.getFileName(),
container.getHash(), container.getPos()))
if container.getHash() != firstLineMD5:
lastLinePos = None
return lastLinePos
@commitandrollback
def getLogPaths(self, cur, jail=None):
"""Gets all the log paths from the database.
Currently only for testing purposes.
Parameters
----------
jail : Jail
If specified, will only reutrn logs belonging to the jail.
Returns
-------
set
Set of log paths.
"""
query = "SELECT path FROM logs"
queryArgs = []
if jail is not None:
query += " WHERE jail=?"
queryArgs.append(jail.name)
cur.execute(query, queryArgs)
return set(row[0] for row in cur.fetchmany())
@commitandrollback
def updateLog(self, cur, *args, **kwargs):
"""Updates hash and last position in log file.
Parameters
----------
jail : Jail
Jail of which the log file belongs to.
container : FileContainer
File container of the log file being updated.
"""
self._updateLog(cur, *args, **kwargs)
def _updateLog(self, cur, jail, container):
cur.execute(
"UPDATE logs SET firstlinemd5=?, lastfilepos=? "
"WHERE jail=? AND path=?",
(container.getHash(), container.getPos(),
jail.name, container.getFileName()))
@commitandrollback
def addBan(self, cur, jail, ticket):
"""Add a ban to the database.
Parameters
----------
jail : Jail
Jail in which the ban has occurred.
ticket : BanTicket
Ticket of the ban to be added.
"""
self._bansMergedCache = {}
#TODO: Implement data parts once arbitrary match keys completed
cur.execute(
"INSERT INTO bans(jail, ip, timeofban, data) VALUES(?, ?, ?, ?)",
(jail.name, ticket.getIP(), ticket.getTime(),
{"matches": ticket.getMatches(),
"failures": ticket.getAttempt()}))
@commitandrollback
def _getBans(self, cur, jail=None, bantime=None, ip=None):
query = "SELECT ip, timeofban, data FROM bans WHERE 1"
queryArgs = []
if jail is not None:
query += " AND jail=?"
queryArgs.append(jail.name)
if bantime is not None:
query += " AND timeofban > ?"
queryArgs.append(MyTime.time() - bantime)
if ip is not None:
query += " AND ip=?"
queryArgs.append(ip)
query += " ORDER BY timeofban"
return cur.execute(query, queryArgs)
def getBans(self, **kwargs):
"""Get bans from the database.
Parameters
----------
jail : Jail
Jail that the ban belongs to. Default `None`; all jails.
bantime : int
Ban time in seconds, such that bans returned would still be
valid now. Default `None`; no limit.
ip : str
IP Address to filter bans by. Default `None`; all IPs.
Returns
-------
list
List of `Ticket`s for bans stored in database.
"""
tickets = []
for ip, timeofban, data in self._getBans(**kwargs):
#TODO: Implement data parts once arbitrary match keys completed
tickets.append(FailTicket(ip, timeofban, data['matches']))
tickets[-1].setAttempt(data['failures'])
return tickets
def getBansMerged(self, ip, jail=None, **kwargs):
"""Get bans from the database, merged into single ticket.
This is the same as `getBans`, but bans merged into single
ticket.
Parameters
----------
jail : Jail
Jail that the ban belongs to. Default `None`; all jails.
bantime : int
Ban time in seconds, such that bans returned would still be
valid now. Default `None`; no limit.
ip : str
IP Address to filter bans by. Default `None`; all IPs.
Returns
-------
Ticket
Single ticket representing bans stored in database.
"""
cacheKey = ip if jail is None else "%s|%s" % (ip, jail.name)
if cacheKey in self._bansMergedCache:
return self._bansMergedCache[cacheKey]
matches = []
failures = 0
for ip, timeofban, data in self._getBans(ip=ip, jail=jail, **kwargs):
#TODO: Implement data parts once arbitrary match keys completed
matches.extend(data['matches'])
failures += data['failures']
ticket = FailTicket(ip, timeofban, matches)
ticket.setAttempt(failures)
self._bansMergedCache[cacheKey] = ticket
return ticket
@commitandrollback
def purge(self, cur):
"""Purge old bans, jails and log files from database.
"""
self._bansMergedCache = {}
cur.execute(
"DELETE FROM bans WHERE timeofban < ?",
(MyTime.time() - self._purgeAge, ))
cur.execute(
"DELETE FROM jails WHERE enabled = 0 "
"AND NOT EXISTS(SELECT * FROM bans WHERE jail = jails.name)")