Automatically recover or recreate corrupt persistent database (e. g. if failed to open with 'database disk image is malformed').

Closes #1465
pull/2004/head
sebres 2017-12-21 22:38:54 +01:00
parent 61109d5c4f
commit 9374de59f3
2 changed files with 144 additions and 31 deletions

View File

@ -22,6 +22,7 @@ __copyright__ = "Copyright (c) 2013 Steven Hiscocks"
__license__ = "GPL" __license__ = "GPL"
import json import json
import os
import shutil import shutil
import sqlite3 import sqlite3
import sys import sys
@ -31,6 +32,7 @@ from threading import RLock
from .mytime import MyTime from .mytime import MyTime
from .ticket import FailTicket from .ticket import FailTicket
from .utils import Utils
from ..helpers import getLogger, PREFER_ENC from ..helpers import getLogger, PREFER_ENC
# Gets the instance of the logger. # Gets the instance of the logger.
@ -163,13 +165,17 @@ class Fail2BanDb(object):
def __init__(self, filename, purgeAge=24*60*60): def __init__(self, filename, purgeAge=24*60*60):
self.maxEntries = 50 self.maxEntries = 50
try:
self._lock = RLock() self._lock = RLock()
self._dbFilename = filename
self._purgeAge = purgeAge
self._connectDB()
def _connectDB(self, checkIntegrity=False):
filename = self._dbFilename
try:
self._db = sqlite3.connect( self._db = sqlite3.connect(
filename, check_same_thread=False, filename, check_same_thread=False,
detect_types=sqlite3.PARSE_DECLTYPES) detect_types=sqlite3.PARSE_DECLTYPES)
self._dbFilename = filename
self._purgeAge = purgeAge
self._bansMergedCache = {} self._bansMergedCache = {}
@ -190,6 +196,7 @@ class Fail2BanDb(object):
pypy = False pypy = False
cur = self._db.cursor() cur = self._db.cursor()
try:
cur.execute("PRAGMA foreign_keys = ON") cur.execute("PRAGMA foreign_keys = ON")
# speedup: write data through OS without syncing (no wait): # speedup: write data through OS without syncing (no wait):
cur.execute("PRAGMA synchronous = OFF") cur.execute("PRAGMA synchronous = OFF")
@ -199,11 +206,28 @@ class Fail2BanDb(object):
# speedup: temporary tables and indices are kept in memory: # speedup: temporary tables and indices are kept in memory:
cur.execute("PRAGMA temp_store = MEMORY") cur.execute("PRAGMA temp_store = MEMORY")
try: if checkIntegrity:
logSys.debug(" Check integrity ...")
cur.execute("PRAGMA integrity_check")
for s in cur.fetchall():
logSys.debug(" %s", s)
self._db.commit()
cur.execute("SELECT version FROM fail2banDb LIMIT 1") cur.execute("SELECT version FROM fail2banDb LIMIT 1")
except sqlite3.OperationalError: except sqlite3.OperationalError:
logSys.warning("New database created. Version '%i'", logSys.warning("New database created. Version '%i'",
self.createDb()) self.createDb())
except sqlite3.Error as e:
logSys.error(
"Error opening fail2ban persistent database '%s': %s",
filename, e.args[0])
# if not a file - raise an error:
if not os.path.isfile(filename):
raise
# try to repair it:
cur.close()
cur = None
self.repairDB()
else: else:
version = cur.fetchone()[0] version = cur.fetchone()[0]
if version < Fail2BanDb.__version__: if version < Fail2BanDb.__version__:
@ -217,6 +241,7 @@ class Fail2BanDb(object):
Fail2BanDb.__version__, version, newversion) Fail2BanDb.__version__, version, newversion)
raise RuntimeError('Failed to fully update') raise RuntimeError('Failed to fully update')
finally: finally:
if cur:
# pypy: set journal mode after possible upgrade db: # pypy: set journal mode after possible upgrade db:
if pypy: if pypy:
cur.execute("PRAGMA journal_mode = MEMORY") cur.execute("PRAGMA journal_mode = MEMORY")
@ -227,6 +252,44 @@ class Fail2BanDb(object):
self._db.close() self._db.close()
logSys.info("Connection to database closed.") logSys.info("Connection to database closed.")
@property
def _dbBackupFilename(self):
try:
return self.__dbBackupFilename
except AttributeError:
self.__dbBackupFilename = self._dbFilename + '.' + time.strftime('%Y%m%d-%H%M%S', MyTime.gmtime())
return self.__dbBackupFilename
def repairDB(self):
# avoid endless recursion if reconnect failed again for some reasons:
_repairDB = self.repairDB
self.repairDB = None
try:
# backup
logSys.info("Trying to repair database %s", self._dbFilename)
shutil.move(self._dbFilename, self._dbBackupFilename)
logSys.info(" Database backup created: %s", self._dbBackupFilename)
# first try to repair using dump/restore in order
Utils.executeCmd((r"""f2b_db=$0; f2b_dbbk=$1; sqlite3 "$f2b_dbbk" ".dump" | sqlite3 "$f2b_db" """,
self._dbFilename, self._dbBackupFilename))
dbFileSize = os.stat(self._dbFilename).st_size
if dbFileSize:
logSys.info(" Repair seems to be successful, restored %d byte(s).", dbFileSize)
# succeeded - try to reconnect:
self._connectDB(checkIntegrity=True)
else:
logSys.info(" Repair seems to be failed, restored %d byte(s).", dbFileSize)
raise Exception('Recreate ...')
except Exception as e:
# if still failed, just recreate database as fallback:
logSys.error(" Error repairing of fail2ban database '%s': %s",
self._dbFilename, e.args[0])
os.remove(self._dbFilename)
self._connectDB()
finally:
self.repairDB = _repairDB
@property @property
def filename(self): def filename(self):
"""File name of SQLite3 database file. """File name of SQLite3 database file.
@ -271,7 +334,8 @@ class Fail2BanDb(object):
raise NotImplementedError( raise NotImplementedError(
"Attempt to travel to future version of database ...how did you get here??") "Attempt to travel to future version of database ...how did you get here??")
self._dbBackupFilename = self.filename + '.' + time.strftime('%Y%m%d-%H%M%S', MyTime.gmtime()) logSys.info("Uprade database: %s", self._dbBackupFilename)
if not os.path.isfile(self._dbBackupFilename):
shutil.copyfile(self.filename, self._dbBackupFilename) shutil.copyfile(self.filename, self._dbBackupFilename)
logSys.info(" Database backup created: %s", self._dbBackupFilename) logSys.info(" Database backup created: %s", self._dbBackupFilename)

View File

@ -62,7 +62,18 @@ class DatabaseTest(LogCaptureTestCase):
self.dbFilename = None self.dbFilename = None
if not unittest.F2B.memory_db: if not unittest.F2B.memory_db:
_, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_") _, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_")
self.db = getFail2BanDb(self.dbFilename) self._db = ':auto-create-in-memory:'
@property
def db(self):
if isinstance(self._db, basestring) and self._db == ':auto-create-in-memory:':
self._db = getFail2BanDb(self.dbFilename)
return self._db
@db.setter
def db(self, value):
if isinstance(self._db, Fail2BanDb): # pragma: no cover
self._db.close()
self._db = value
def tearDown(self): def tearDown(self):
"""Call after every test case.""" """Call after every test case."""
@ -106,10 +117,46 @@ class DatabaseTest(LogCaptureTestCase):
self.jail.name in self.db.getJailNames(), self.jail.name in self.db.getJailNames(),
"Jail not retained in Db after disconnect reconnect.") "Jail not retained in Db after disconnect reconnect.")
def testRepairDb(self):
if Fail2BanDb is None: # pragma: no cover
return
self.db = None
if self.dbFilename is None: # pragma: no cover
_, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_")
# test truncated database with different sizes:
# - 14000 bytes - seems to be reparable,
# - 4000 bytes - is totally broken.
for truncSize in (14000, 4000):
self.pruneLog("[test-repair], next phase - file-size: %d" % truncSize)
shutil.copyfile(
os.path.join(TEST_FILES_DIR, 'database_v1.db'), self.dbFilename)
# produce currupt database:
f = os.open(self.dbFilename, os.O_RDWR)
os.ftruncate(f, truncSize)
os.close(f)
# test repair:
try:
self.db = Fail2BanDb(self.dbFilename)
if truncSize == 14000: # restored:
self.assertLogged("Repair seems to be successful",
"Check integrity", "Database updated", all=True)
self.assertEqual(self.db.getLogPaths(), set(['/tmp/Fail2BanDb_pUlZJh.log']))
self.assertEqual(len(self.db.getJailNames()), 1)
else: # recreated:
self.assertLogged("Repair seems to be failed",
"New database created.", all=True)
self.assertEqual(len(self.db.getLogPaths()), 0)
self.assertEqual(len(self.db.getJailNames()), 0)
finally:
if self.db and self.db._dbFilename != ":memory:":
os.remove(self.db._dbBackupFilename)
self.db = None
def testUpdateDb(self): def testUpdateDb(self):
if Fail2BanDb is None: # pragma: no cover if Fail2BanDb is None: # pragma: no cover
return return
self.db = None self.db = None
try:
if self.dbFilename is None: # pragma: no cover if self.dbFilename is None: # pragma: no cover
_, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_") _, self.dbFilename = tempfile.mkstemp(".db", "fail2ban_")
shutil.copyfile( shutil.copyfile(
@ -122,6 +169,8 @@ class DatabaseTest(LogCaptureTestCase):
self.assertEqual(self.db.updateDb(Fail2BanDb.__version__), Fail2BanDb.__version__) self.assertEqual(self.db.updateDb(Fail2BanDb.__version__), Fail2BanDb.__version__)
self.assertRaises(NotImplementedError, self.db.updateDb, Fail2BanDb.__version__ + 1) self.assertRaises(NotImplementedError, self.db.updateDb, Fail2BanDb.__version__ + 1)
finally:
if self.db and self.db._dbFilename != ":memory:":
os.remove(self.db._dbBackupFilename) os.remove(self.db._dbBackupFilename)
def testAddJail(self): def testAddJail(self):