munin-f2b-remote/f2b-responder.py

176 lines
6.2 KiB
Python
Raw Normal View History

2024-08-06 18:18:31 +02:00
#!/usr/bin/env python
2024-08-06 20:39:11 +02:00
from contextlib import suppress
2024-08-06 18:18:31 +02:00
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from random import choices
from signal import SIGTERM, signal
2024-08-06 20:39:11 +02:00
from socket import AF_INET6, IPPROTO_IPV6, IPV6_V6ONLY, socket
2024-08-06 18:18:31 +02:00
from sqlite3 import connect
from string import ascii_lowercase
2024-08-06 22:55:16 +02:00
from sys import stderr
2024-08-06 18:18:31 +02:00
from tempfile import gettempdir
from threading import Thread
from types import FrameType
2024-08-06 23:57:08 +02:00
from typing import Literal, NamedTuple
2024-08-06 18:18:31 +02:00
_F2B_DBPATH = Path('/', 'var', 'db', 'fail2ban', 'fail2ban.sqlite3')
2024-08-06 23:57:08 +02:00
_CONFIG_RESULT_ALL = """\
2024-08-06 18:18:31 +02:00
graph_category fail2ban
2024-08-07 00:18:24 +02:00
graph_title ALL RECORDS in DB table '{db_table}'
2024-08-06 18:18:31 +02:00
graph_vlabel Database record count by jail name
2024-08-06 22:35:47 +02:00
graph_printf %3.0lf
2024-08-06 18:18:31 +02:00
"""
2024-08-06 23:57:08 +02:00
_CONFIG_RESULT_CURRENT = """\
graph_category fail2ban
2024-08-07 00:18:24 +02:00
graph_title CURRENTLY BANNED in DB table '{db_table}'
2024-08-06 23:57:08 +02:00
graph_vlabel Currently banned IPs by jail name
graph_printf %3.0lf
"""
_DB_QUERY_ALL = (
'SELECT `jail`, COUNT(*) FROM `{db_table}` WHERE `jail` IN ({in_clause}) '
'GROUP BY `jail`')
_DB_QUERY_CURRENT = (
'SELECT `jail`, COUNT(*) FROM `{db_table}` WHERE `timeofban` + `bantime` '
'> UNIXEPOCH() AND `jail` IN ({in_clause}) GROUP BY `jail`')
2024-08-06 18:18:31 +02:00
2024-08-06 22:55:16 +02:00
def _log_message(message: str) -> None:
'Do a simple log on `stderr`.'
stderr.write(f'=== {message}\n')
2024-08-06 18:18:31 +02:00
class _JailInfo(NamedTuple):
id: int
name: str
class F2bRemoteRequestHandler(SimpleHTTPRequestHandler):
server_version = 'F2bRemoteRequestHandler'
_randompath = Path(
gettempdir(),
''.join(choices(population=ascii_lowercase, k=15)))
def __init__(
self, request: socket, client_address: tuple[str, int],
server: ThreadingHTTPServer):
super().__init__(
request=request, client_address=client_address, server=server,
directory=str(self._randompath))
def __send_403(self) -> None:
'Send a HTTP 403.'
self.send_error(code=HTTPStatus.FORBIDDEN)
def __get_jails(self) -> list[_JailInfo]:
'Return the list of jails.'
cursor = self.__connection.cursor()
cursor.execute('SELECT `ROWID`, `name` FROM `jails` ORDER BY `ROWID`')
result = list[_JailInfo]()
while row := cursor.fetchone():
result.append(_JailInfo(*row))
cursor.close()
return result
def __send_data_response(self, body: str) -> None:
'Encode and send a response with a HTTP 200.'
body_bytes = body.encode(encoding='utf-8', errors='surrogateescape')
self.send_response(code=HTTPStatus.OK)
self.send_header(
keyword='Content-Type', value='text/plain; charset=utf-8')
self.send_header(keyword='Content-Length', value=str(len(body_bytes)))
self.send_header(keyword='Connection', value='close')
self.end_headers()
self.wfile.write(body_bytes)
2024-08-06 23:57:08 +02:00
def __get_config(
self, db_table: str, qtype: Literal['all', 'current']) -> None:
2024-08-06 18:18:31 +02:00
'Send the current plugin configuration for `db_table`.'
2024-08-06 23:57:08 +02:00
template = \
_CONFIG_RESULT_ALL if qtype == 'all' else _CONFIG_RESULT_CURRENT
message = template.format(db_table=db_table)
2024-08-06 18:18:31 +02:00
for jail in self.__get_jails():
message += f'usercount{jail.id}.label {jail.name}\n'
2024-08-06 21:48:02 +02:00
message += f'usercount{jail.id}.draw LINE1\n'
2024-08-06 18:18:31 +02:00
self.__send_data_response(body=message)
2024-08-06 23:57:08 +02:00
def __get_data(
self, db_table: str, qtype: Literal['all', 'current']) -> None:
2024-08-06 18:18:31 +02:00
'Return organized data for the requested `db_table`.'
output = ''
cursor = self.__connection.cursor()
jails = self.__get_jails()
in_clause = ', '.join(f'\'{jail.name}\'' for jail in jails)
2024-08-06 23:57:08 +02:00
template = _DB_QUERY_ALL if qtype == 'all' else _DB_QUERY_CURRENT
cursor.execute(template.format(db_table=db_table, in_clause=in_clause))
2024-08-06 18:18:31 +02:00
result = dict[str, int]()
while row := cursor.fetchone():
jail_name, count = row
result[jail_name] = count
cursor.close()
for jail_id, jail_name in jails:
count = result.get(jail_name) or 0
2024-08-06 22:00:26 +02:00
output += f'usercount{jail_id}.value {count}\n'
2024-08-06 18:18:31 +02:00
self.__send_data_response(body=output)
def do_GET(self) -> None:
'Overwriting `do_GET`.'
self.__connection = connect(database=_F2B_DBPATH)
2024-08-06 23:57:08 +02:00
match self.path:
case '/db-all/bans/config/':
self.__get_config(db_table='bans', qtype='all')
case '/db-all/bips/config/':
self.__get_config(db_table='bips', qtype='all')
case '/db-all/bans/data/':
self.__get_data(db_table='bans', qtype='all')
case '/db-all/bips/data/':
self.__get_data(db_table='bips', qtype='all')
case '/db-current/bans/config/':
self.__get_config(db_table='bans', qtype='current')
case '/db-current/bips/config/':
self.__get_config(db_table='bips', qtype='current')
case '/db-current/bans/data/':
self.__get_data(db_table='bans', qtype='current')
case '/db-current/bips/data/':
self.__get_data(db_table='bips', qtype='current')
2024-08-06 18:18:31 +02:00
case _:
self.__send_403()
self.__connection.close()
do_HEAD = do_POST = __send_403
2024-08-06 20:39:11 +02:00
class DualStackThreadingHttpServer(ThreadingHTTPServer):
address_family = AF_INET6
def server_bind(self):
# suppress exception when protocol is IPv4-only
with suppress(OSError):
self.socket.setsockopt(IPPROTO_IPV6, IPV6_V6ONLY, 0)
return super().server_bind()
server = DualStackThreadingHttpServer(
2024-08-06 18:18:31 +02:00
server_address=('', 8000), RequestHandlerClass=F2bRemoteRequestHandler,
bind_and_activate=True)
def shutdown(signalnum: int, frame: FrameType | None):
'Shut down upon SIGTERM.'
2024-08-06 22:55:16 +02:00
_log_message('Shutting down.')
2024-08-06 18:18:31 +02:00
server.shutdown()
if __name__ == '__main__':
signal(signalnum=SIGTERM, handler=shutdown)
run_thread = Thread(
target=server.serve_forever, kwargs=dict(poll_interval=1))
2024-08-06 22:55:16 +02:00
_log_message('Serving.')
2024-08-06 18:18:31 +02:00
run_thread.start()
try:
run_thread.join()
except KeyboardInterrupt:
pass
2024-08-06 22:55:16 +02:00
_log_message('Server is shut down.')