#!/usr/bin/env python3 """ LogMon - Modularny demon do monitorowania logów i blokowania IP Autor: Mateusz Gruszczyński Wersja: 1.0 """ import sys import os import time import signal import logging import argparse import configparser from collections import defaultdict, deque from datetime import datetime, timedelta from pathlib import Path sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) # Importy z lokalnych modułów from modules import PostfixModule, DovecotModule from backends import CSFBackend, NFTablesBackend, IPTablesBackend, UFWBackend class LogMonDaemon: """Główny demon LogMon""" def __init__(self, config_file): """ Inicjalizacja demona Args: config_file: Ścieżka do pliku konfiguracyjnego INI """ self.config = configparser.ConfigParser() self.config.read(config_file) self.running = False self.ip_tracker = defaultdict(lambda: deque()) self.banned_ips = {} # Konfiguracja logowania self.setup_logging() # Wybór backendu firewall self.backend = self.load_backend() # Ładowanie modułów monitorowania self.modules = self.load_modules() # Obsługa sygnałów systemowych signal.signal(signal.SIGTERM, self.signal_handler) signal.signal(signal.SIGINT, self.signal_handler) signal.signal(signal.SIGHUP, self.signal_reload) self.logger.info("LogMon daemon initialized") def setup_logging(self): """Konfiguracja systemu logowania""" debug = self.config.getboolean('general', 'debug', fallback=False) log_file = self.config.get('general', 'log_file', fallback='/var/log/logmon.log') level = logging.DEBUG if debug else logging.INFO # Usuń istniejące handlery logging.basicConfig( level=level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(log_file), logging.StreamHandler(sys.stdout) ] ) self.logger = logging.getLogger('LogMon') self.logger.info(f"Logging initialized (debug={debug})") def load_backend(self): """Ładuje odpowiedni backend firewall""" backend_name = self.config.get('general', 'backend', fallback='csf') self.logger.info(f"Loading backend: {backend_name}") backend_map = { 'csf': CSFBackend, 'nftables': NFTablesBackend, 'iptables': IPTablesBackend, 'ufw': UFWBackend } if backend_name not in backend_map: raise ValueError(f"Unknown backend: {backend_name}") backend_class = backend_map[backend_name] backend = backend_class(self.config) # Sprawdź dostępność if not backend.test_availability(): self.logger.warning(f"Backend {backend_name} may not be available!") return backend def load_modules(self): """Ładuje moduły monitorowania""" modules = [] # Postfix module if self.config.getboolean('module_postfix', 'enabled', fallback=False): try: module = PostfixModule(self.config, self) modules.append(module) self.logger.info("Loaded Postfix module") except Exception as e: self.logger.error(f"Error loading Postfix module: {e}") # Dovecot module if self.config.getboolean('module_dovecot', 'enabled', fallback=False): try: module = DovecotModule(self.config, self) modules.append(module) self.logger.info("Loaded Dovecot module") except Exception as e: self.logger.error(f"Error loading Dovecot module: {e}") # Tutaj można dodać więcej modułów w przyszłości # if self.config.getboolean('module_ssh', 'enabled', fallback=False): # modules.append(SSHModule(self.config, self)) if not modules: self.logger.warning("No modules loaded!") return modules def signal_handler(self, signum, frame): """Obsługa sygnałów SIGTERM i SIGINT""" self.logger.info(f"Received signal {signum}, shutting down gracefully...") self.running = False def signal_reload(self, signum, frame): """Obsługa sygnału SIGHUP - reload konfiguracji""" self.logger.info("Received SIGHUP, reloading configuration...") # TODO: Implementacja reload konfiguracji def track_failure(self, ip, score=1): """ Śledzi nieudane próby logowania dla danego IP Args: ip: Adres IP atakującego score: Punkty za dane niepowodzenie (domyślnie 1) """ if ip in self.banned_ips: self.logger.debug(f"IP {ip} already banned, ignoring") return now = datetime.now() # Dodaj wpis do trackera self.ip_tracker[ip].append((now, score)) # Usuń stare wpisy spoza okna czasowego time_window = self.config.getint('module_postfix', 'time_window', fallback=60) cutoff = now - timedelta(seconds=time_window) while self.ip_tracker[ip] and self.ip_tracker[ip][0][0] < cutoff: self.ip_tracker[ip].popleft() # Oblicz całkowity wynik total_score = sum(score for _, score in self.ip_tracker[ip]) failures_count = len(self.ip_tracker[ip]) max_failures = self.config.getint('module_postfix', 'max_failures', fallback=5) self.logger.debug( f"IP {ip}: {failures_count} failures, score {total_score}/{max_failures}" ) # Sprawdź czy przekroczono limit if total_score >= max_failures: self.ban_ip(ip) def ban_ip(self, ip): """ Banuje adres IP Args: ip: Adres IP do zbanowania """ ban_duration = self.config.getint('module_postfix', 'ban_duration', fallback=86400) self.logger.warning( f"Banning IP {ip} for {ban_duration} seconds " f"({ban_duration/3600:.1f} hours)" ) if self.backend.ban_ip(ip, ban_duration): expiry_time = datetime.now() + timedelta(seconds=ban_duration) self.banned_ips[ip] = expiry_time self.logger.info( f"Successfully banned {ip} until {expiry_time.strftime('%Y-%m-%d %H:%M:%S')}" ) # Wyczyść tracker dla tego IP if ip in self.ip_tracker: del self.ip_tracker[ip] else: self.logger.error(f"Failed to ban {ip}") def unban_ip(self, ip): """ Odbania adres IP Args: ip: Adres IP do odbanowania """ self.logger.info(f"Unbanning expired IP {ip}") if self.backend.unban_ip(ip): if ip in self.banned_ips: del self.banned_ips[ip] self.logger.info(f"Successfully unbanned {ip}") else: self.logger.error(f"Failed to unban {ip}") def unban_expired(self): """Usuwa bany, które wygasły""" now = datetime.now() expired = [ip for ip, expiry in self.banned_ips.items() if now >= expiry] for ip in expired: self.unban_ip(ip) def save_state(self): """Zapisuje stan banów do pliku (opcjonalnie)""" # TODO: Implementacja persystencji stanu pass def load_state(self): """Ładuje stan banów z pliku (opcjonalnie)""" # TODO: Implementacja persystencji stanu pass def daemonize(self): """Przechodzi w tryb demona (fork)""" pid_file = self.config.get('general', 'pid_file', fallback='/var/run/logmon.pid') # Sprawdź czy już działa if os.path.exists(pid_file): with open(pid_file, 'r') as f: old_pid = int(f.read().strip()) # Sprawdź czy proces istnieje try: os.kill(old_pid, 0) self.logger.error(f"Daemon already running with PID {old_pid}") sys.exit(1) except OSError: # Proces nie istnieje, usuń stary PID file os.remove(pid_file) # Pierwszy fork try: pid = os.fork() if pid > 0: # Parent process sys.exit(0) except OSError as e: self.logger.error(f"Fork #1 failed: {e}") sys.exit(1) # Odłącz od terminala os.chdir('/') os.setsid() os.umask(0) # Drugi fork try: pid = os.fork() if pid > 0: # Parent process sys.exit(0) except OSError as e: self.logger.error(f"Fork #2 failed: {e}") sys.exit(1) # Zapisz PID pid = os.getpid() with open(pid_file, 'w') as f: f.write(str(pid)) self.logger.info(f"Daemon started with PID {pid}") # Przekieruj standardowe wyjścia sys.stdout.flush() sys.stderr.flush() devnull = open(os.devnull, 'r+') os.dup2(devnull.fileno(), sys.stdin.fileno()) # Stdout i stderr zostawiamy dla logowania def cleanup(self): """Sprzątanie przed zakończeniem""" pid_file = self.config.get('general', 'pid_file', fallback='/var/run/logmon.pid') # Usuń PID file if os.path.exists(pid_file): try: os.remove(pid_file) except Exception as e: self.logger.error(f"Error removing PID file: {e}") def print_status(self): """Wyświetla status demona""" print("\n=== LogMon Status ===") print(f"Backend: {self.backend.__class__.__name__}") print(f"Modules: {len(self.modules)}") print(f"Currently banned IPs: {len(self.banned_ips)}") print(f"Tracked IPs: {len(self.ip_tracker)}") if self.banned_ips: print("\nBanned IPs:") for ip, expiry in sorted(self.banned_ips.items(), key=lambda x: x[1]): remaining = (expiry - datetime.now()).total_seconds() if remaining > 0: print(f" {ip:15s} - expires in {remaining/60:.1f} minutes") else: print(f" {ip:15s} - EXPIRED") print("\n") def run(self, daemonize=True): """ Główna pętla demona Args: daemonize: Czy przejść w tryb demona (fork) """ if daemonize: self.daemonize() self.running = True self.logger.info("LogMon daemon started") # Uruchom moduły for module in self.modules: try: module.start() except Exception as e: self.logger.error(f"Error starting module: {e}") # Główna pętla try: while self.running: try: # Sprawdź wygasłe bany co 10 sekund self.unban_expired() # Wyświetl status co minutę (tylko w debug mode) if self.config.getboolean('general', 'debug', fallback=False): if int(time.time()) % 60 == 0: self.logger.debug( f"Status: {len(self.banned_ips)} banned, " f"{len(self.ip_tracker)} tracked" ) # Krótkie oczekiwanie time.sleep(10) except KeyboardInterrupt: self.logger.info("Keyboard interrupt received") break except Exception as e: self.logger.error(f"Error in main loop: {e}", exc_info=True) time.sleep(1) finally: # Zatrzymaj moduły self.logger.info("Stopping modules...") for module in self.modules: try: module.stop() except Exception as e: self.logger.error(f"Error stopping module: {e}") # Sprzątanie self.cleanup() self.logger.info("LogMon daemon stopped") def main(): """Główna funkcja programu""" parser = argparse.ArgumentParser( description='LogMon - Log Monitoring and IP Blocking Daemon', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Przykłady użycia: %(prog)s -c /etc/logmon/config.ini # Uruchom jako demon %(prog)s -c /etc/logmon/config.ini -f # Uruchom w foreground %(prog)s -c /etc/logmon/config.ini -f -d # Uruchom w foreground z debugiem Sygnały: SIGTERM, SIGINT - graceful shutdown SIGHUP - reload konfiguracji (TODO) """ ) parser.add_argument( '-c', '--config', default='/etc/logmon/config.ini', help='Ścieżka do pliku konfiguracyjnego (domyślnie: /etc/logmon/config.ini)' ) parser.add_argument( '-f', '--foreground', action='store_true', help='Uruchom w foreground (nie demonizuj)' ) parser.add_argument( '-d', '--debug', action='store_true', help='Włącz tryb debug (nadpisuje ustawienie z config.ini)' ) parser.add_argument( '-t', '--test', action='store_true', help='Test konfiguracji i wyjście' ) parser.add_argument( '--version', action='version', version='LogMon 1.0' ) args = parser.parse_args() # Sprawdź czy plik konfiguracyjny istnieje if not os.path.exists(args.config): print(f"Error: Configuration file not found: {args.config}", file=sys.stderr) sys.exit(1) # Sprawdź uprawnienia root if os.geteuid() != 0 and not args.test: print("Error: This program must be run as root", file=sys.stderr) sys.exit(1) try: # Inicjalizacja demona daemon = LogMonDaemon(args.config) # Nadpisz debug jeśli podano w argumentach if args.debug: daemon.config.set('general', 'debug', 'true') daemon.setup_logging() # Tryb testowy if args.test: print("Configuration test successful") daemon.print_status() sys.exit(0) # Uruchom demona daemon.run(daemonize=not args.foreground) except KeyboardInterrupt: print("\nInterrupted by user") sys.exit(0) except Exception as e: print(f"Fatal error: {e}", file=sys.stderr) import traceback traceback.print_exc() sys.exit(1) if __name__ == '__main__': main()