From 3ca36464725989d089d84b993689566a7b6e2615 Mon Sep 17 00:00:00 2001 From: sebres Date: Wed, 24 Apr 2024 18:49:59 +0200 Subject: [PATCH] implemented `fail2ban-client stats` (or alias `fail2ban-client statistic[s]`) for tabulated output of fail2ban stats amend to #2975 --- fail2ban/client/beautifier.py | 40 ++++++++++++++++++++++ fail2ban/protocol.py | 1 + fail2ban/server/actions.py | 4 ++- fail2ban/server/filter.py | 4 +++ fail2ban/server/filtersystemd.py | 2 ++ fail2ban/server/jail.py | 16 ++++++--- fail2ban/server/server.py | 11 ++++-- fail2ban/server/transmitter.py | 2 ++ fail2ban/tests/clientbeautifiertestcase.py | 21 ++++++++++++ fail2ban/tests/servertestcase.py | 5 +++ 10 files changed, 97 insertions(+), 9 deletions(-) diff --git a/fail2ban/client/beautifier.py b/fail2ban/client/beautifier.py index 4c913a9a..7ef173a6 100644 --- a/fail2ban/client/beautifier.py +++ b/fail2ban/client/beautifier.py @@ -103,6 +103,46 @@ class Beautifier: msg.append(" %s Jail: %s" % (prefix1, n)) jail_stat(j, " " if i == len(jstat) else " | ") msg = "\n".join(msg) + elif inC[0:1] == ['stats'] or inC[0:1] == ['statistics']: + def _statstable(response): + tophead = ["Jail", "Backend", "Filter", "Actions"] + headers = ["", "", "cur", "tot", "cur", "tot"] + minlens = [8, 8, 3, 3, 3, 3] + ralign = [0, 0, 1, 1, 1, 1] + rows = [[n, r[0], *r[1], *r[2]] for n, r in response.items()] + lens = [] + for i in range(len(rows[0])): + col = (len(str(s[i])) for s in rows) + lens.append(max(minlens[i], max(col))) + rfmt = [] + hfmt = [] + for i in range(len(rows[0])): + f = "%%%ds" if ralign[i] else "%%-%ds" + rfmt.append(f % lens[i]) + hfmt.append(f % lens[i]) + rfmt = [rfmt[0], rfmt[1], "%s \u2502 %s" % (rfmt[2], rfmt[3]), "%s \u2502 %s" % (rfmt[4], rfmt[5])] + hfmt = [hfmt[0], hfmt[1], "%s \u2502 %s" % (hfmt[2], hfmt[3]), "%s \u2502 %s" % (hfmt[4], hfmt[5])] + tlens = [lens[0], lens[1], 3 + lens[2] + lens[3], 3 + lens[4] + lens[5]] + tfmt = [hfmt[0], hfmt[1], "%%-%ds" % (tlens[2],), "%%-%ds" % (tlens[3],)] + tsep = tfmt[0:2] + rfmt = " \u2551 ".join(rfmt) + hfmt = " \u2551 ".join(hfmt) + tfmt = " \u2551 ".join(tfmt) + tsep = " \u2551 ".join(tsep) + separator = ((tsep % tuple(tophead[0:2])) + " \u255F\u2500" + + ("\u2500\u256B\u2500".join(['\u2500' * n for n in tlens[2:]])) + '\u2500') + ret = [] + ret.append(tfmt % tuple(["", ""]+tophead[2:])) + ret.append(separator) + ret.append(hfmt % tuple(headers)) + separator = "\u2550\u256C\u2550".join(['\u2550' * n for n in tlens]) + '\u2550' + ret.append(separator) + for row in rows: + ret.append(rfmt % tuple(row)) + separator = "\u2550\u2569\u2550".join(['\u2550' * n for n in tlens]) + '\u2550' + ret.append(separator) + return ret + msg = "\n".join(_statstable(response)) elif len(inC) < 2: pass # to few cmd args for below elif inC[1] == "syslogsocket": diff --git a/fail2ban/protocol.py b/fail2ban/protocol.py index 5764737b..077091f7 100644 --- a/fail2ban/protocol.py +++ b/fail2ban/protocol.py @@ -59,6 +59,7 @@ protocol = [ ["banned ... ]", "return list(s) of jails where given IP(s) are banned"], ["status", "gets the current status of the server"], ["status --all [FLAVOR]", "gets the current status of all jails, with optional flavor or extended info"], +["stat[istic]s", "gets the current statistics of all jails as table"], ["ping", "tests if the server is alive"], ["echo", "for internal usage, returns back and outputs a given string"], ["help", "return this output"], diff --git a/fail2ban/server/actions.py b/fail2ban/server/actions.py index 65571c50..26e80107 100644 --- a/fail2ban/server/actions.py +++ b/fail2ban/server/actions.py @@ -721,9 +721,11 @@ class Actions(JailThread, Mapping): """Status of current and total ban counts and current banned IP list. """ # TODO: Allow this list to be printed as 'status' output - supported_flavors = ["short", "basic", "cymru"] + supported_flavors = ["short", "basic", "stats", "cymru"] if flavor is None or flavor not in supported_flavors: logSys.warning("Unsupported extended jail status flavor %r. Supported: %s" % (flavor, supported_flavors)) + if flavor == "stats": + return (self.banManager.size(), self.banManager.getBanTotal()) # Always print this information (basic) if flavor != "short": banned = self.banManager.getBanList() diff --git a/fail2ban/server/filter.py b/fail2ban/server/filter.py index c2b4886b..f8b36cf6 100644 --- a/fail2ban/server/filter.py +++ b/fail2ban/server/filter.py @@ -978,6 +978,8 @@ class Filter(JailThread): def status(self, flavor="basic"): """Status of failures detected by filter. """ + if flavor == "stats": + return (self.failManager.size(), self.failManager.getFailTotal()) ret = [("Currently failed", self.failManager.size()), ("Total failed", self.failManager.getFailTotal())] return ret @@ -1255,6 +1257,8 @@ class FileFilter(Filter): """Status of Filter plus files being monitored. """ ret = super(FileFilter, self).status(flavor=flavor) + if flavor == "stats": + return ret path = list(self.__logs.keys()) ret.append(("File list", path)) return ret diff --git a/fail2ban/server/filtersystemd.py b/fail2ban/server/filtersystemd.py index 5ff1bff2..5aea9fda 100644 --- a/fail2ban/server/filtersystemd.py +++ b/fail2ban/server/filtersystemd.py @@ -429,6 +429,8 @@ class FilterSystemd(JournalFilter): # pragma: systemd no cover def status(self, flavor="basic"): ret = super(FilterSystemd, self).status(flavor=flavor) + if flavor == "stats": + return ret ret.append(("Journal matches", [" + ".join(" ".join(match) for match in self.__matches)])) return ret diff --git a/fail2ban/server/jail.py b/fail2ban/server/jail.py index 7e199751..0f8e3566 100644 --- a/fail2ban/server/jail.py +++ b/fail2ban/server/jail.py @@ -81,8 +81,9 @@ class Jail(object): # Extra parameters for increase ban time self._banExtra = {}; logSys.info("Creating new jail '%s'" % self.name) + self._realBackend = None if backend is not None: - self._setBackend(backend) + self._realBackend = self._setBackend(backend) self.backend = backend def __repr__(self): @@ -113,7 +114,7 @@ class Jail(object): else: logSys.info("Initiated %r backend" % b) self.__actions = Actions(self) - return # we are done + return b # we are done except ImportError as e: # pragma: no cover # Log debug if auto, but error if specific logSys.log( @@ -185,10 +186,15 @@ class Jail(object): def status(self, flavor="basic"): """The status of the jail. """ + fstat = self.filter.status(flavor=flavor) + astat = self.actions.status(flavor=flavor) + if flavor == "stats": + backend = type(self.filter).__name__.replace('Filter', '').lower() + return [self._realBackend or self.backend, fstat, astat] return [ - ("Filter", self.filter.status(flavor=flavor)), - ("Actions", self.actions.status(flavor=flavor)), - ] + ("Filter", fstat), + ("Actions", astat), + ] @property def hasFailTickets(self): diff --git a/fail2ban/server/server.py b/fail2ban/server/server.py index f712c751..e438c4ca 100644 --- a/fail2ban/server/server.py +++ b/fail2ban/server/server.py @@ -608,13 +608,18 @@ class Server: try: self.__lock.acquire() jails = sorted(self.__jails.items()) - jailList = [n for n, j in jails] - ret = [("Number of jail", len(jailList)), - ("Jail list", ", ".join(jailList))] + if flavor != "stats": + jailList = [n for n, j in jails] + ret = [ + ("Number of jail", len(jailList)), + ("Jail list", ", ".join(jailList)) + ] if name == '--all': jstat = dict(jails) for n, j in jails: jstat[n] = j.status(flavor=flavor) + if flavor == "stats": + return jstat ret.append(jstat) return ret finally: diff --git a/fail2ban/server/transmitter.py b/fail2ban/server/transmitter.py index da2f3072..92d591f0 100644 --- a/fail2ban/server/transmitter.py +++ b/fail2ban/server/transmitter.py @@ -144,6 +144,8 @@ class Transmitter: return self.__commandGet(command[1:]) elif name == "status": return self.status(command[1:]) + elif name in ("stats", "statistic", "statistics"): + return self.__server.status("--all", "stats") elif name == "version": return version.version elif name == "config-error": diff --git a/fail2ban/tests/clientbeautifiertestcase.py b/fail2ban/tests/clientbeautifiertestcase.py index bd18dab4..defedbe1 100644 --- a/fail2ban/tests/clientbeautifiertestcase.py +++ b/fail2ban/tests/clientbeautifiertestcase.py @@ -168,6 +168,27 @@ class BeautifierTest(unittest.TestCase): ) self.assertEqual(self.b.beautify(response), output) + def testStatusStats(self): + self.b.setInputCmd(["stats"]) + response = { + "ssh": ["systemd", (3, 6), (12, 24)], + "exim4": ["pyinotify", (6, 12), (20, 20)], + "jail-with-long-name": ["polling", (0, 0), (0, 0)] + } + output = ("" + + " ? ? Filter ? Actions \n" + + "Jail ? Backend ????????????????????????\n" + + " ? ? cur ? tot ? cur ? tot\n" + + "????????????????????????????????????????????????????????\n" + + "ssh ? systemd ? 3 ? 6 ? 12 ? 24\n" + + "exim4 ? pyinotify ? 6 ? 12 ? 20 ? 20\n" + + "jail-with-long-name ? polling ? 0 ? 0 ? 0 ? 0\n" + + "????????????????????????????????????????????????????????" + ) + response = self.b.beautify(response).encode('ascii', 'replace').decode('ascii') + self.assertEqual(response, output) + + def testFlushLogs(self): self.b.setInputCmd(["flushlogs"]) self.assertEqual(self.b.beautify("rolled over"), "logs: rolled over") diff --git a/fail2ban/tests/servertestcase.py b/fail2ban/tests/servertestcase.py index 47b6437e..f8cd8db5 100644 --- a/fail2ban/tests/servertestcase.py +++ b/fail2ban/tests/servertestcase.py @@ -645,6 +645,11 @@ class Transmitter(TransmitterBase): (0, [('Number of jail', len(jails)), ('Jail list', ", ".join(jails)), {"TestJail1": self._JAIL_STATUS, "TestJail2": self._JAIL_STATUS} ])) + self.assertEqual(self.transm.proceed(["stats"]), + (0, { + "TestJail1": [FAST_BACKEND, (0, 0), (0, 0)], + "TestJail2": [FAST_BACKEND, (0, 0), (0, 0)] + })) def testJailStatus(self): self.assertEqual(self.transm.proceed(["status", self.jailName]),