summaryrefslogtreecommitdiff
path: root/fail2ban/server/database.py
blob: 59eeb8fd1198860c4933172577241994be73541d (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
# 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 json
import os
import shutil
import sqlite3
import sys
import time
from functools import wraps
from threading import RLock

from .mytime import MyTime
from .ticket import FailTicket
from .utils import Utils
from ..helpers import getLogger, uni_string, PREFER_ENC

# Gets the instance of the logger.
logSys = getLogger(__name__)


def _json_default(x):
	"""Avoid errors on types unknown in json-adapters."""
	if isinstance(x, set):
		x = list(x)
	return uni_string(x)

if sys.version_info >= (3,): # pragma: 2.x no cover
	def _json_dumps_safe(x):
		try:
			x = json.dumps(x, ensure_ascii=False, default=_json_default).encode(
				PREFER_ENC, 'replace')
		except Exception as e:
			# adapter handler should be exception-safe
			logSys.error('json dumps failed: %r', e, exc_info=logSys.getEffectiveLevel() <= 4)
			x = '{}'
		return x

	def _json_loads_safe(x):
		try:
			x = json.loads(x.decode(PREFER_ENC, 'replace'))
		except Exception as e:
			# converter handler should be exception-safe
			logSys.error('json loads failed: %r', e, exc_info=logSys.getEffectiveLevel() <= 4)
			x = {}
		return x
else: # pragma: 3.x no cover
	def _normalize(x):
		if isinstance(x, dict):
			return dict((_normalize(k), _normalize(v)) for k, v in x.iteritems())
		elif isinstance(x, (list, set)):
			return [_normalize(element) for element in x]
		elif isinstance(x, unicode):
			# in 2.x default text_factory is unicode - so return proper unicode here:
			return x.encode(PREFER_ENC, 'replace').decode(PREFER_ENC)
		elif isinstance(x, basestring):
			return x.decode(PREFER_ENC, 'replace')
		return x

	def _json_dumps_safe(x):
		try:
			x = json.dumps(_normalize(x), ensure_ascii=False, default=_json_default)
		except Exception as e:
			# adapter handler should be exception-safe
			logSys.error('json dumps failed: %r', e, exc_info=logSys.getEffectiveLevel() <= 4)
			x = '{}'
		return x

	def _json_loads_safe(x):
		try:
			x = json.loads(x.decode(PREFER_ENC, 'replace'))
		except Exception as e:
			# converter handler should be exception-safe
			logSys.error('json loads failed: %r', e, exc_info=logSys.getEffectiveLevel() <= 4)
			x = {}
		return x

sqlite3.register_adapter(dict, _json_dumps_safe)
sqlite3.register_converter("JSON", _json_loads_safe)


def commitandrollback(f):
	@wraps(f)
	def wrapper(self, *args, **kwargs):
		with self._lock: # Threading lock
			with self._db: # Auto commit and rollback on exception
				cur = self._db.cursor()
				try:
					return f(self, cur, *args, **kwargs)
				finally:
					cur.close()
	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__ = 4
	# Note all SCRIPTS strings must end in ';' for py26 compatibility
	_CREATE_SCRIPTS = (
		 ('fail2banDb', "CREATE TABLE IF NOT EXISTS fail2banDb(version INTEGER);")
		,('jails', "CREATE TABLE IF NOT EXISTS jails(" \
			"name TEXT NOT NULL UNIQUE, " \
			"enabled INTEGER NOT NULL DEFAULT 1" \
			");" \
			"CREATE INDEX IF NOT EXISTS jails_name ON jails(name);")
		,('logs', "CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS logs_path ON logs(path);" \
			"CREATE INDEX IF NOT EXISTS logs_jail_path ON logs(jail, path);")
			#TODO: systemd journal features \
			#"journalmatch TEXT, " \
			#"journlcursor TEXT, " \
			#"lastfiletime INTEGER DEFAULT 0, " # is this easily available
		,('bans', "CREATE TABLE IF NOT EXISTS bans(" \
			"jail TEXT NOT NULL, " \
			"ip TEXT, " \
			"timeofban INTEGER NOT NULL, " \
			"bantime INTEGER NOT NULL, " \
			"bancount INTEGER NOT NULL default 1, " \
			"data JSON, " \
			"FOREIGN KEY(jail) REFERENCES jails(name) " \
			");" \
			"CREATE INDEX IF NOT EXISTS bans_jail_timeofban_ip ON bans(jail, timeofban);" \
			"CREATE INDEX IF NOT EXISTS bans_jail_ip ON bans(jail, ip);" \
			"CREATE INDEX IF NOT EXISTS bans_ip ON bans(ip);")
		,('bips', "CREATE TABLE IF NOT EXISTS bips(" \
			"ip TEXT NOT NULL, " \
			"jail TEXT NOT NULL, " \
			"timeofban INTEGER NOT NULL, " \
			"bantime INTEGER NOT NULL, " \
			"bancount INTEGER NOT NULL default 1, " \
			"data JSON, " \
			"PRIMARY KEY(ip, jail), " \
			"FOREIGN KEY(jail) REFERENCES jails(name) " \
			");" \
			"CREATE INDEX IF NOT EXISTS bips_timeofban ON bips(timeofban);" \
			"CREATE INDEX IF NOT EXISTS bips_ip ON bips(ip);")
	)
	_CREATE_TABS = dict(_CREATE_SCRIPTS)


	def __init__(self, filename, purgeAge=24*60*60, outDatedFactor=3):
		self.maxMatches = 10
		self._lock = RLock()
		self._dbFilename = filename
		self._purgeAge = purgeAge
		self._outDatedFactor = outDatedFactor;
		self._connectDB()

	def _connectDB(self, checkIntegrity=False):
		filename = self._dbFilename
		try:
			self._db = sqlite3.connect(
				filename, check_same_thread=False,
				detect_types=sqlite3.PARSE_DECLTYPES)
			# # to allow use multi-byte utf-8
			# self._db.text_factory = str

			self._bansMergedCache = {}

			logSys.info(
				"Connected to fail2ban persistent database '%s'", filename)
		except sqlite3.OperationalError as e:
			logSys.error(
				"Error connecting to fail2ban persistent database '%s': %s",
				filename, e.args[0])
			raise

		# differentiate pypy: switch journal mode later (save it during the upgrade), 
		# to prevent errors like "database table is locked":
		try:
			import __pypy__
			pypy = True
		except ImportError:
			pypy = False

		cur = self._db.cursor()
		try:
			cur.execute("PRAGMA foreign_keys = ON")
			# speedup: write data through OS without syncing (no wait):
			cur.execute("PRAGMA synchronous = OFF")
			# speedup: transaction log in memory, alternate using OFF (disable, rollback will be impossible):
			if not pypy:
				cur.execute("PRAGMA journal_mode = MEMORY")
			# speedup: temporary tables and indices are kept in memory:
			cur.execute("PRAGMA temp_store = MEMORY")

			cur.execute("SELECT version FROM fail2banDb LIMIT 1")
		except sqlite3.OperationalError:
			logSys.warning("New database created. Version '%r'",
				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:
			version = cur.fetchone()[0]
			if version != Fail2BanDb.__version__:
				newversion = self.updateDb(version)
				if newversion == Fail2BanDb.__version__:
					logSys.warning( "Database updated from '%r' to '%r'",
						version, newversion)
				else: # pragma: no cover
					logSys.error( "Database update failed to achieve version '%r'"
						": updated from '%r' to '%r'",
						Fail2BanDb.__version__, version, newversion)
					raise RuntimeError('Failed to fully update')
		finally:
			if checkIntegrity:
				logSys.debug("  Create missing tables/indices ...")
				self._createDb(cur, incremental=True)
				logSys.debug("  -> ok")
				logSys.debug("  Check integrity ...")
				cur.execute("PRAGMA integrity_check")
				for s in cur.fetchall():
					logSys.debug("  -> %s", ' '.join(s))
				self._db.commit()
			if cur:
				# pypy: set journal mode after possible upgrade db:
				if pypy:
					cur.execute("PRAGMA journal_mode = MEMORY")
				cur.close()

	def close(self):
		logSys.debug("Close connection to database ...")
		self._db.close()
		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):
		class RepairException(Exception):
			pass
		# 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)
			if not os.path.isfile(self._dbBackupFilename):
				shutil.move(self._dbFilename, self._dbBackupFilename)
				logSys.info("  Database backup created: %s", self._dbBackupFilename)
			elif os.path.isfile(self._dbFilename):
				os.remove(self._dbFilename)
			# 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 RepairException('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], 
				exc_info=(not isinstance(e, RepairException) and logSys.getEffectiveLevel() <= 10))
			os.remove(self._dbFilename)
			self._connectDB(checkIntegrity=True)
		finally:
			self.repairDB = _repairDB

	@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 = MyTime.str2seconds(value)

	def _createDb(self, cur, incremental=False):
		"""Creates a new database, called during initialisation.
		"""
		# create all (if not exists):
		for (n, s) in Fail2BanDb._CREATE_SCRIPTS:
			cur.executescript(s)
		# save current database version (if not already set):			
		cur.execute("INSERT INTO fail2banDb(version)"
			" SELECT ? WHERE NOT EXISTS (SELECT 1 FROM fail2banDb LIMIT 1)",
			(Fail2BanDb.__version__, ))
		cur.execute("SELECT version FROM fail2banDb LIMIT 1")
		return cur.fetchone()[0]

	@commitandrollback
	def createDb(self, cur, incremental=False):
		return self._createDb(cur, incremental);

	def _tableExists(self, cur, table):
		cur.execute("select 1 where exists ("
			"select 1 from sqlite_master WHERE type='table' AND name=?)", (table,))
		res = cur.fetchone()
		return res is not None and res[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.
		"""
		if version > Fail2BanDb.__version__:
			raise NotImplementedError(
						"Attempt to travel to future version of database ...how did you get here??")
		try:
			logSys.info("Upgrade database: %s from version '%r'", self._dbBackupFilename, version)
			if not os.path.isfile(self._dbBackupFilename):
				shutil.copyfile(self.filename, self._dbBackupFilename)
				logSys.info("  Database backup created: %s", self._dbBackupFilename)

			if version < 2 and self._tableExists(cur, "logs"):
				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._CREATE_TABS['logs'])

			if version < 3 and self._tableExists(cur, "bans"):
				# set ban-time to -2 (note it means rather unknown, as persistent, will be fixed by restore):
				cur.executescript("BEGIN TRANSACTION;"
							"CREATE TEMPORARY TABLE bans_temp AS SELECT jail, ip, timeofban, -2 as bantime, 1 as bancount, data FROM bans;"
							"DROP TABLE bans;"
							"%s;\n"
							"INSERT INTO bans SELECT * from bans_temp;"
							"DROP TABLE bans_temp;"
							"COMMIT;" % Fail2BanDb._CREATE_TABS['bans'])
			if version < 4 and not self._tableExists(cur, "bips"):
				cur.executescript("BEGIN TRANSACTION;"
							"%s;\n"
							"UPDATE fail2banDb SET version = 4;"
							"COMMIT;" % Fail2BanDb._CREATE_TABS['bips'])
				if self._tableExists(cur, "bans"):
					cur.execute(
							"INSERT OR REPLACE INTO bips(ip, jail, timeofban, bantime, bancount, data)"
							"  SELECT ip, jail, timeofban, bantime, bancount, data FROM bans order by timeofban")

			cur.execute("SELECT version FROM fail2banDb LIMIT 1")
			return cur.fetchone()[0]
		except Exception as e:
			# if still failed, just recreate database as fallback:
			logSys.error("Failed to upgrade database '%s': %s",
				self._dbFilename, e.args[0], 
				exc_info=logSys.getEffectiveLevel() <= 10)
			self.repairDB()

	@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 IGNORE INTO jails(name, enabled) VALUES(?, 1)",
			(jail.name,))
		if cur.rowcount <= 0:
			cur.execute(
				"UPDATE jails SET enabled = 1 WHERE name = ? AND enabled != 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, enabled=None):
		"""Get name of jails in database.

		Currently only used for testing purposes.

		Returns
		-------
		set
			Set of jail names.
		"""
		if enabled is None:
			cur.execute("SELECT name FROM jails")
		else:
			cur.execute("SELECT name FROM jails WHERE enabled=%s" %
				(int(enabled),))
		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`
		"""
		return self._addLog(cur, jail, container.getFileName(), container.getPos(), container.getHash())

	def _addLog(self, cur, jail, name, pos=0, md5=None):
		lastLinePos = None
		cur.execute(
			"SELECT firstlinemd5, lastfilepos FROM logs "
				"WHERE jail=? AND path=?",
			(jail.name, name))
		try:
			firstLineMD5, lastLinePos = cur.fetchone()
		except TypeError:
			firstLineMD5 = None

		if not firstLineMD5 and (pos or md5):
			cur.execute(
					"INSERT OR REPLACE INTO logs(jail, path, firstlinemd5, lastfilepos) "
						"VALUES(?, ?, ?, ?)", (jail.name, name, md5, pos))
		if md5 is not None and md5 != 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, jail, container):
		"""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, jail, container.getFileName(), container.getPos(), container.getHash())

	def _updateLog(self, cur, jail, name, pos, md5):
		cur.execute(
			"UPDATE logs SET firstlinemd5=?, lastfilepos=? "
				"WHERE jail=? AND path=?", (md5, pos, jail.name, name))
		# be sure it is set (if not available):
		if not cur.rowcount:
			cur.execute(
					"INSERT OR REPLACE INTO logs(jail, path, firstlinemd5, lastfilepos) "
						"VALUES(?, ?, ?, ?)", (jail.name, name, md5, pos))

	@commitandrollback
	def getJournalPos(self, cur, jail, name, time=0, iso=None):
		"""Get journal position from database.

		Parameters
		----------
		jail : Jail
			Jail of which the journal belongs to.
		name, time, iso :
			Journal name (typically systemd-journal) and last known time.

		Returns
		-------
		int (or float)
			Last position (as time) if it was already present in database; else `None`
		"""
		return self._addLog(cur, jail, name, time, iso); # no hash, just time as iso

	@commitandrollback
	def updateJournal(self, cur, jail, name, time, iso):
		"""Updates last position (as time) of journal.

		Parameters
		----------
		jail : Jail
			Jail of which the journal belongs to.
		name, time, iso :
			Journal name (typically systemd-journal) and last known time.
		"""
		self._updateLog(cur, jail, name, time, iso); # no hash, just time as iso

	@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.
		"""
		ip = str(ticket.getIP())
		try:
			del self._bansMergedCache[(ip, jail)]
		except KeyError:
			pass
		try:
			del self._bansMergedCache[(ip, None)]
		except KeyError:
			pass
		#TODO: Implement data parts once arbitrary match keys completed
		data = ticket.getData()
		matches = data.get('matches')
		if self.maxMatches:
			if matches and len(matches) > self.maxMatches:
				data = data.copy()
				data['matches'] = matches[-self.maxMatches:]
		elif matches:
			data = data.copy()
			del data['matches']
		cur.execute(
			"INSERT INTO bans(jail, ip, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)",
			(jail.name, ip, int(round(ticket.getTime())), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(),
				data))
		cur.execute(
			"INSERT OR REPLACE INTO bips(ip, jail, timeofban, bantime, bancount, data) VALUES(?, ?, ?, ?, ?, ?)",
			(ip, jail.name, int(round(ticket.getTime())), ticket.getBanTime(jail.actions.getBanTime()), ticket.getBanCount(),
				data))

	@commitandrollback
	def delBan(self, cur, jail, *args):
		"""Delete a single or multiple tickets from the database.

		Parameters
		----------
		jail : Jail
			Jail in which the ticket(s) should be removed.
		args : list of IP
			IPs to be removed, if not given all tickets of jail will be removed.
		"""
		query1 = "DELETE FROM bips WHERE jail = ?"
		query2 = "DELETE FROM bans WHERE jail = ?"
		queryArgs = [jail.name];
		if not len(args):
			cur.execute(query1, queryArgs);
			cur.execute(query2, queryArgs);
			return
		query1 += " AND ip = ?"
		query2 += " AND ip = ?"
		queryArgs.append('');
		for ip in args:
			queryArgs[1] = str(ip);
			cur.execute(query1, queryArgs);
			cur.execute(query2, queryArgs);

	@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 and bantime >= 0:
			query += " AND timeofban > ?"
			queryArgs.append(MyTime.time() - bantime)
		if ip is not None:
			query += " AND ip=?"
			queryArgs.append(str(ip))
		query += " ORDER BY ip, timeofban desc"

		# repack iterator as long as in lock:
		return list(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.  Negative values are equivalent to `None`.
			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))
			tickets[-1].setData(data)
		return tickets

	def getBansMerged(self, ip=None, jail=None, bantime=None):
		"""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. Negative values are equivalent to `None`.
			Default `None`; no limit.
		ip : str
			IP Address to filter bans by. Default `None`; all IPs.

		Returns
		-------
		list or Ticket
			Single ticket representing bans stored in database per IP
			in a list. When `ip` argument passed, a single `Ticket` is
			returned.
		"""
		with self._lock:
			cacheKey = None
			if bantime is None or bantime < 0:
				cacheKey = (ip, jail)
				if cacheKey in self._bansMergedCache:
					return self._bansMergedCache[cacheKey]

			tickets = []
			ticket = None

			results = list(self._getBans(ip=ip, jail=jail, bantime=bantime))
			if results:
				prev_banip = results[0][0]
				matches = []
				failures = 0
				tickdata = {}
				for banip, timeofban, data in results:
					#TODO: Implement data parts once arbitrary match keys completed
					if banip != prev_banip:
						ticket = FailTicket(prev_banip, prev_timeofban, matches)
						ticket.setAttempt(failures)
						tickets.append(ticket)
						# Reset variables
						prev_banip = banip
						matches = []
						failures = 0
						tickdata = {}
					m = data.get('matches', [])
					# pre-insert "maxadd" enries (because tickets are ordered desc by time)
					maxadd = self.maxMatches - len(matches)
					if maxadd > 0:
						if len(m) <= maxadd:
							matches = m + matches
						else:
							matches = m[-maxadd:] + matches
					failures += data.get('failures', 1)
					data['failures'] = failures
					data['matches'] = matches
					tickdata.update(data)
					prev_timeofban = timeofban
				ticket = FailTicket(banip, prev_timeofban, data=tickdata)
				tickets.append(ticket)

			if cacheKey:
				self._bansMergedCache[cacheKey] = tickets if ip is None else ticket
			return tickets if ip is None else ticket

	@commitandrollback
	def getBan(self, cur, ip, jail=None, forbantime=None, overalljails=None, fromtime=None):
		ip = str(ip)
		if not overalljails:
			query = "SELECT bancount, timeofban, bantime FROM bips"
		else:
			query = "SELECT sum(bancount), max(timeofban), sum(bantime) FROM bips"
		query += " WHERE ip = ?"
		queryArgs = [ip]
		if not overalljails and jail is not None:
			query += " AND jail=?"
			queryArgs.append(jail.name)
		if forbantime is not None:
			query += " AND timeofban > ?"
			queryArgs.append(MyTime.time() - forbantime)
		if fromtime is not None:
			query += " AND timeofban > ?"
			queryArgs.append(fromtime)
		if overalljails or jail is None:
			query += " GROUP BY ip ORDER BY timeofban DESC LIMIT 1"
		# repack iterator as long as in lock:
		return list(cur.execute(query, queryArgs))

	def _getCurrentBans(self, cur, jail = None, ip = None, forbantime=None, fromtime=None):
		queryArgs = []
		if jail is not None:
			query = "SELECT ip, timeofban, bantime, bancount, data FROM bips WHERE jail=?"
			queryArgs.append(jail.name)
		else:
			query = "SELECT ip, max(timeofban), bantime, bancount, data FROM bips WHERE 1"
		if ip is not None:
			query += " AND ip=?"
			queryArgs.append(ip)
		query += " AND (timeofban + bantime > ? OR bantime <= -1)"
		queryArgs.append(fromtime)
		if forbantime not in (None, -1): # not specified or persistent (all)
			query += " AND timeofban > ?"
			queryArgs.append(fromtime - forbantime)
		if ip is None:
			query += " GROUP BY ip ORDER BY ip, timeofban DESC"
		else:
			query += " ORDER BY timeofban DESC LIMIT 1"
		return cur.execute(query, queryArgs)

	def getCurrentBans(self, jail=None, ip=None, forbantime=None, fromtime=None,
		correctBanTime=True, maxmatches=None
	):
		"""Reads tickets (with merged info) currently affected from ban from the database.
		
		There are all the tickets corresponding parameters jail/ip, forbantime,
		fromtime (normally now).
		
		If correctBanTime specified (default True) it will fix the restored ban-time 
		(and therefore endOfBan) of the ticket (normally it is ban-time of jail as maximum)
		for all tickets with ban-time greater (or persistent).
		"""
		cur = self._db.cursor()
		try:
			if fromtime is None:
				fromtime = MyTime.time()
			tickets = []
			ticket = None
			if correctBanTime is True:
				correctBanTime = jail.getMaxBanTime() if jail is not None else None
				# don't change if persistent allowed:
				if correctBanTime == -1: correctBanTime = None

			with self._lock:
				bans = self._getCurrentBans(cur, jail=jail, ip=ip, 
					forbantime=forbantime, fromtime=fromtime
				)
			for ticket in bans:
				# can produce unpack error (database may return sporadical wrong-empty row):
				try:
					banip, timeofban, bantime, bancount, data = ticket
					# additionally check for empty values:
					if banip is None or banip == "": # pragma: no cover
						raise ValueError('unexpected value %r' % (banip,))
					# if bantime unknown (after upgrade-db from earlier version), just use min known ban-time:
					if bantime == -2: # todo: remove it in future version
						bantime = jail.actions.getBanTime() if jail is not None else (
							correctBanTime if correctBanTime else 600)
					elif correctBanTime and correctBanTime >= 0:
						# if persistent ban (or greater as max), use current max-bantime of the jail:
						if bantime == -1 or bantime > correctBanTime:
							bantime = correctBanTime
					# after correction check the end of ban again:
					if bantime != -1 and timeofban + bantime <= fromtime:
						# not persistent and too old - ignore it:
						logSys.debug("ignore ticket (with new max ban-time %r): too old %r <= %r, ticket: %r",
							bantime, timeofban + bantime, fromtime, ticket)
						continue
				except ValueError as e: # pragma: no cover
					logSys.debug("get current bans: ignore row %r - %s", ticket, e)
					continue
				# logSys.debug('restore ticket   %r, %r, %r', banip, timeofban, data)
				ticket = FailTicket(banip, timeofban, data=data)
				# filter matches if expected (current count > as maxmatches specified):
				if maxmatches is None:
					maxmatches = self.maxMatches
				if maxmatches:
					matches = ticket.getMatches()
					if matches and len(matches) > maxmatches:
						ticket.setMatches(matches[-maxmatches:])
				else:
					ticket.setMatches(None)
				# logSys.debug('restored ticket: %r', ticket)
				ticket.setBanTime(bantime)
				ticket.setBanCount(bancount)
				if ip is not None: return ticket
				tickets.append(ticket)
		finally:
			cur.close() 

		return tickets

	def _cleanjails(self, cur):
		"""Remove empty jails jails and log files from database.
		"""
		cur.execute(
			"DELETE FROM jails WHERE enabled = 0 "
				"AND NOT EXISTS(SELECT * FROM bans WHERE jail = jails.name) "
				"AND NOT EXISTS(SELECT * FROM bips WHERE jail = jails.name)")

	def _purge_bips(self, cur):
		"""Purge old bad ips (jails and log files from database).
		Currently it is timed out IP, whose time since last ban is several times out-dated (outDatedFactor is default 3).
		Permanent banned ips will be never removed.
		"""
		cur.execute(
			"DELETE FROM bips WHERE timeofban < ? and bantime != -1 and (timeofban + (bantime * ?)) < ?",
			(int(MyTime.time()) - self._purgeAge, self._outDatedFactor, int(MyTime.time()) - self._purgeAge))

	@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, ))
		self._purge_bips(cur)
		self._cleanjails(cur)