commit 7b41672d05f213240f2d8b6ea6d4c881de09cb21 Author: Mateusz Gruszczyński Date: Tue Oct 28 21:27:10 2025 +0100 upload diff --git a/backends/base.py b/backends/base.py new file mode 100644 index 0000000..7bdcbff --- /dev/null +++ b/backends/base.py @@ -0,0 +1,63 @@ +""" +Bazowa klasa dla backendów firewall +""" + +import logging + + +class FirewallBackend: + """Bazowa klasa dla backendów firewall""" + + def __init__(self, config): + """ + Args: + config: ConfigParser object z konfiguracją + """ + self.config = config + self.logger = logging.getLogger(self.__class__.__name__) + + def ban_ip(self, ip, duration): + """ + Banuje IP na określony czas + + Args: + ip: Adres IP do zbanowania + duration: Czas bana w sekundach + + Returns: + bool: True jeśli ban się powiódł + """ + raise NotImplementedError("Subclasses must implement ban_ip()") + + def unban_ip(self, ip): + """ + Usuwa ban dla IP + + Args: + ip: Adres IP do odbanowania + + Returns: + bool: True jeśli odbanowanie się powiodło + """ + raise NotImplementedError("Subclasses must implement unban_ip()") + + def is_banned(self, ip): + """ + Sprawdza czy IP jest zbanowany + + Args: + ip: Adres IP do sprawdzenia + + Returns: + bool: True jeśli IP jest zbanowany + """ + raise NotImplementedError("Subclasses must implement is_banned()") + + def test_availability(self): + """ + Sprawdza czy backend jest dostępny w systemie + + Returns: + bool: True jeśli backend jest dostępny + """ + return True diff --git a/backends/csf.py b/backends/csf.py new file mode 100644 index 0000000..f97cd00 --- /dev/null +++ b/backends/csf.py @@ -0,0 +1,102 @@ +""" +Backend dla ConfigServer Security & Firewall (CSF) +""" + +import subprocess +from pathlib import Path +from .base import FirewallBackend + + +class CSFBackend(FirewallBackend): + """Backend dla ConfigServer Firewall""" + + def __init__(self, config): + super().__init__(config) + self.csf_path = config.get('backend_csf', 'csf_path', + fallback='/usr/sbin/csf') + + if not self.test_availability(): + self.logger.warning(f"CSF not found at {self.csf_path}") + + def test_availability(self): + """Sprawdza czy CSF jest zainstalowany""" + return Path(self.csf_path).exists() + + def ban_ip(self, ip, duration): + """ + Banuje IP używając CSF + + CSF używa: + - csf -d IP "comment" - permanent deny + - csf -td IP duration "comment" - temporary deny + """ + try: + # Temporary deny na określony czas (w sekundach) + cmd = [ + self.csf_path, '-td', ip, + str(duration), + f"LogMon auto-ban" + ] + + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0: + self.logger.debug(f"CSF ban successful: {result.stdout.strip()}") + return True + else: + self.logger.error(f"CSF ban failed: {result.stderr.strip()}") + return False + + except subprocess.TimeoutExpired: + self.logger.error(f"CSF ban command timed out for {ip}") + return False + except Exception as e: + self.logger.error(f"Error banning IP with CSF: {e}") + return False + + def unban_ip(self, ip): + """Usuwa ban używając CSF""" + try: + # Remove temporary ban + cmd = [self.csf_path, '-tr', ip] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0: + self.logger.debug(f"CSF unban successful for {ip}") + return True + else: + self.logger.warning(f"CSF unban may have failed: {result.stderr.strip()}") + # CSF czasem zwraca error nawet gdy się udało + return True + + except Exception as e: + self.logger.error(f"Error unbanning IP with CSF: {e}") + return False + + def is_banned(self, ip): + """Sprawdza czy IP jest zbanowany w CSF""" + try: + cmd = [self.csf_path, '-g', ip] + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=10 + ) + + output = result.stdout.lower() + return "deny" in output or "drop" in output + + except Exception as e: + self.logger.error(f"Error checking ban status: {e}") + return False diff --git a/backends/init.py b/backends/init.py new file mode 100644 index 0000000..2bd3135 --- /dev/null +++ b/backends/init.py @@ -0,0 +1,17 @@ +""" +LogMon Backends - Integracje z różnymi firewallami +""" + +from .base import FirewallBackend +from .csf import CSFBackend +from .nftables import NFTablesBackend +from .iptables import IPTablesBackend +from .ufw import UFWBackend + +__all__ = [ + 'FirewallBackend', + 'CSFBackend', + 'NFTablesBackend', + 'IPTablesBackend', + 'UFWBackend' +] diff --git a/backends/iptables.py b/backends/iptables.py new file mode 100644 index 0000000..c1f2676 --- /dev/null +++ b/backends/iptables.py @@ -0,0 +1,104 @@ +""" +Backend dla iptables +""" + +import subprocess +from .base import FirewallBackend + + +class IPTablesBackend(FirewallBackend): + """Backend dla iptables""" + + def __init__(self, config): + super().__init__(config) + self.chain = config.get('backend_iptables', 'chain_name', + fallback='LOGMON_BLOCK') + + if self.test_availability(): + self.setup_chain() + else: + self.logger.warning("iptables not available") + + def test_availability(self): + """Sprawdza czy iptables jest dostępny""" + try: + result = subprocess.run( + ['iptables', '--version'], + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except: + return False + + def setup_chain(self): + """Tworzy chain jeśli nie istnieje""" + try: + # Sprawdź czy chain istnieje + cmd = ['iptables', '-L', self.chain, '-n'] + result = subprocess.run(cmd, capture_output=True, timeout=5) + + if result.returncode != 0: + # Utwórz chain + subprocess.run(['iptables', '-N', self.chain], check=True, timeout=5) + + # Dodaj do INPUT na początku + subprocess.run( + ['iptables', '-I', 'INPUT', '1', '-j', self.chain], + check=True, + timeout=5 + ) + + self.logger.info(f"Created iptables chain: {self.chain}") + + except Exception as e: + self.logger.error(f"Error setting up iptables chain: {e}") + + def ban_ip(self, ip, duration): + """Banuje IP używając iptables""" + try: + cmd = [ + 'iptables', '-I', self.chain, '1', + '-s', ip, '-j', 'DROP', + '-m', 'comment', '--comment', f'LogMon ban {duration}s' + ] + + result = subprocess.run(cmd, capture_output=True, timeout=5) + + if result.returncode == 0: + self.logger.debug(f"iptables ban successful for {ip}") + return True + else: + self.logger.error(f"iptables ban failed: {result.stderr.decode()}") + return False + + except Exception as e: + self.logger.error(f"Error banning IP with iptables: {e}") + return False + + def unban_ip(self, ip): + """Usuwa ban używając iptables""" + try: + cmd = ['iptables', '-D', self.chain, '-s', ip, '-j', 'DROP'] + result = subprocess.run(cmd, capture_output=True, timeout=5) + + if result.returncode == 0: + self.logger.debug(f"iptables unban successful for {ip}") + return True + else: + self.logger.warning(f"iptables unban may have failed") + return False + + except Exception as e: + self.logger.error(f"Error unbanning IP with iptables: {e}") + return False + + def is_banned(self, ip): + """Sprawdza czy IP jest zbanowany""" + try: + cmd = ['iptables', '-L', self.chain, '-n', '--line-numbers'] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + return ip in result.stdout + except Exception as e: + self.logger.error(f"Error checking ban status: {e}") + return False diff --git a/backends/nftables.py b/backends/nftables.py new file mode 100644 index 0000000..96f9fb5 --- /dev/null +++ b/backends/nftables.py @@ -0,0 +1,120 @@ +""" +Backend dla nftables +""" + +import subprocess +from .base import FirewallBackend + + +class NFTablesBackend(FirewallBackend): + """Backend dla nftables""" + + def __init__(self, config): + super().__init__(config) + self.table = config.get('backend_nftables', 'table_name', + fallback='filter') + self.chain = config.get('backend_nftables', 'chain_name', + fallback='logmon_block') + + if self.test_availability(): + self.setup_chain() + else: + self.logger.warning("nftables not available") + + def test_availability(self): + """Sprawdza czy nftables jest dostępny""" + try: + result = subprocess.run( + ['nft', '--version'], + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except: + return False + + def setup_chain(self): + """Tworzy chain jeśli nie istnieje""" + try: + # Sprawdź czy chain istnieje + cmd = ['nft', 'list', 'chain', 'inet', self.table, self.chain] + result = subprocess.run(cmd, capture_output=True, timeout=5) + + if result.returncode != 0: + # Utwórz chain z priorytetem input + cmd = [ + 'nft', 'add', 'chain', 'inet', self.table, self.chain, + '{', 'type', 'filter', 'hook', 'input', 'priority', '0', ';', '}' + ] + subprocess.run(cmd, check=True, timeout=5) + self.logger.info(f"Created nftables chain: {self.chain}") + + except Exception as e: + self.logger.error(f"Error setting up nftables chain: {e}") + + def ban_ip(self, ip, duration): + """Banuje IP używając nftables""" + try: + # Dodaj regułę DROP + cmd = [ + 'nft', 'add', 'rule', 'inet', self.table, self.chain, + 'ip', 'saddr', ip, 'counter', 'drop', + 'comment', f'"LogMon ban {duration}s"' + ] + + result = subprocess.run(cmd, capture_output=True, timeout=5) + + if result.returncode == 0: + self.logger.debug(f"nftables ban successful for {ip}") + return True + else: + self.logger.error(f"nftables ban failed: {result.stderr.decode()}") + return False + + except Exception as e: + self.logger.error(f"Error banning IP with nftables: {e}") + return False + + def unban_ip(self, ip): + """Usuwa ban używając nftables""" + try: + # Znajdź handle reguły + cmd = ['nft', '-a', 'list', 'chain', 'inet', self.table, self.chain] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + + if result.returncode != 0: + return False + + # Parsuj output i znajdź handle + for line in result.stdout.split('\n'): + if ip in line and '# handle' in line: + try: + handle = line.split('# handle')[1].strip() + + # Usuń regułę + cmd = [ + 'nft', 'delete', 'rule', 'inet', + self.table, self.chain, 'handle', handle + ] + subprocess.run(cmd, check=True, timeout=5) + self.logger.debug(f"nftables unban successful for {ip}") + return True + + except Exception as e: + self.logger.error(f"Error parsing handle: {e}") + + return False + + except Exception as e: + self.logger.error(f"Error unbanning IP with nftables: {e}") + return False + + def is_banned(self, ip): + """Sprawdza czy IP jest zbanowany""" + try: + cmd = ['nft', 'list', 'chain', 'inet', self.table, self.chain] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + return ip in result.stdout + except Exception as e: + self.logger.error(f"Error checking ban status: {e}") + return False diff --git a/backends/ufw.py b/backends/ufw.py new file mode 100644 index 0000000..9536cec --- /dev/null +++ b/backends/ufw.py @@ -0,0 +1,78 @@ +""" +Backend dla UFW (Uncomplicated Firewall) +""" + +import subprocess +from .base import FirewallBackend + + +class UFWBackend(FirewallBackend): + """Backend dla UFW""" + + def test_availability(self): + """Sprawdza czy UFW jest dostępny""" + try: + result = subprocess.run( + ['ufw', 'version'], + capture_output=True, + timeout=5 + ) + return result.returncode == 0 + except: + return False + + def ban_ip(self, ip, duration): + """Banuje IP używając UFW""" + try: + # UFW nie wspiera natywnie timeout, więc używamy prostego deny + cmd = ['ufw', 'deny', 'from', ip] + + result = subprocess.run(cmd, capture_output=True, timeout=5) + + if result.returncode == 0: + self.logger.debug(f"UFW ban successful for {ip}") + # UFW wymaga reload + subprocess.run(['ufw', 'reload'], capture_output=True, timeout=5) + return True + else: + self.logger.error(f"UFW ban failed: {result.stderr.decode()}") + return False + + except Exception as e: + self.logger.error(f"Error banning IP with UFW: {e}") + return False + + def unban_ip(self, ip): + """Usuwa ban używając UFW""" + try: + cmd = ['ufw', 'delete', 'deny', 'from', ip] + result = subprocess.run(cmd, capture_output=True, timeout=5) + + if result.returncode == 0: + self.logger.debug(f"UFW unban successful for {ip}") + subprocess.run(['ufw', 'reload'], capture_output=True, timeout=5) + return True + else: + self.logger.warning(f"UFW unban may have failed") + return False + + except Exception as e: + self.logger.error(f"Error unbanning IP with UFW: {e}") + return False + + def is_banned(self, ip): + """Sprawdza czy IP jest zbanowany""" + try: + cmd = ['ufw', 'status', 'numbered'] + result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + + # Szukaj IP z DENY + for line in result.stdout.split('\n'): + if ip in line and 'DENY' in line: + return True + + return False + + except Exception as e: + self.logger.error(f"Error checking ban status: {e}") + return False diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..aef3d57 --- /dev/null +++ b/config.ini @@ -0,0 +1,42 @@ +[general] +debug = false +log_file = /var/log/logmon.log +pid_file = /var/run/logmon.pid +backend = csf + +[backend_csf] +csf_path = /usr/sbin/csf +# Dodatkowe opcje CSF + +[backend_nftables] +table_name = filter +chain_name = logmon_block + +[backend_iptables] +chain_name = LOGMON_BLOCK + +[backend_ufw] +# UFW nie wymaga dodatkowych parametrów + +[module_postfix] +enabled = true +log_file = /var/log/mail.log +# Alternatywnie dla systemd: +# use_journald = true +# journald_unit = postfix.service + +# Parametry detekcji +max_failures = 5 +time_window = 60 +ban_duration = 86400 + +# Wzorce do wykrywania +patterns = auth_failed,sasl_failed + +[pattern_auth_failed] +regex = authentication failed +score = 1 + +[pattern_sasl_failed] +regex = SASL [A-Z\-\d]+ authentication failed +score = 2 diff --git a/logmon.py b/logmon.py new file mode 100644 index 0000000..4775add --- /dev/null +++ b/logmon.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +""" +LogMon - Modularny demon do monitorowania logów i blokowania IP +Autor: System Administrator +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 + +# Importy z lokalnych modułów +from modules import PostfixModule +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}") + + # 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) + """ + 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 and ip not in self.banned_ips: + self.ban_ip(ip) + elif total_score >= max_failures and ip in self.banned_ips: + self.logger.debug(f"IP {ip} already banned, ignoring") + + 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() diff --git a/logmon.service b/logmon.service new file mode 100644 index 0000000..4322757 --- /dev/null +++ b/logmon.service @@ -0,0 +1,23 @@ +[Unit] +Description=LogMon - Log Monitoring and IP Blocking Daemon +After=network.target syslog.target + +[Service] +Type=forking +User=root +Group=root +WorkingDirectory=/opt/logmon +ExecStart=/usr/bin/python3 /opt/logmon/logmon.py -c /etc/logmon/config.ini +ExecReload=/bin/kill -HUP $MAINPID +PIDFile=/var/run/logmon.pid +Restart=on-failure +RestartSec=10s + +# Security hardening +PrivateTmp=yes +NoNewPrivileges=false +ProtectSystem=strict +ReadWritePaths=/var/log /var/run /etc/csf + +[Install] +WantedBy=multi-user.target diff --git a/modules/base.py b/modules/base.py new file mode 100644 index 0000000..d0faff6 --- /dev/null +++ b/modules/base.py @@ -0,0 +1,54 @@ +""" +Bazowa klasa dla modułów monitorowania logów +""" + +import logging +import time +import threading + + +class LogModule: + """Bazowa klasa dla modułów monitorowania""" + + def __init__(self, config, daemon): + """ + Args: + config: ConfigParser object z konfiguracją + daemon: Referencja do głównego demona + """ + self.config = config + self.daemon = daemon + self.logger = logging.getLogger(self.__class__.__name__) + self.running = False + self.thread = None + + def start(self): + """Uruchamia moduł w osobnym wątku""" + if self.running: + self.logger.warning("Module already running") + return + + self.running = True + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + self.logger.info(f"{self.__class__.__name__} started") + + def stop(self): + """Zatrzymuje moduł""" + self.running = False + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=5) + self.logger.info(f"{self.__class__.__name__} stopped") + + def _run(self): + """Główna pętla modułu - do nadpisania w klasach potomnych""" + raise NotImplementedError("Subclasses must implement _run()") + + def process_line(self, line): + """ + Przetwarza pojedynczą linię logu + + Args: + line: Linia tekstu z logu + """ + raise NotImplementedError("Subclasses must implement process_line()") diff --git a/modules/init.py b/modules/init.py new file mode 100644 index 0000000..14b94ec --- /dev/null +++ b/modules/init.py @@ -0,0 +1,8 @@ +""" +LogMon Modules - Moduły monitorowania różnych aplikacji +""" + +from .base import LogModule +from .postfix import PostfixModule + +__all__ = ['LogModule', 'PostfixModule'] diff --git a/modules/postfix.py b/modules/postfix.py new file mode 100644 index 0000000..ed7d465 --- /dev/null +++ b/modules/postfix.py @@ -0,0 +1,120 @@ +""" +Moduł monitorujący Postfix SMTP server +""" + +import re +import time +from .base import LogModule + + +class PostfixModule(LogModule): + """Moduł monitorujący Postfix""" + + def __init__(self, config, daemon): + super().__init__(config, daemon) + + # Kompiluj wzorce regex dla wydajności + self.patterns = self._load_patterns() + + # Regex do wyciągania IP z logów Postfix + # Obsługuje zarówno unknown[IP] jak i hostname[IP] + self.ip_pattern = re.compile( + r'(?:unknown|[\w\-\.]+)\[(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\]' + ) + + # Ścieżka do pliku logu + self.log_file = config.get('module_postfix', 'log_file', + fallback='/var/log/mail.log') + + self.logger.info(f"Loaded {len(self.patterns)} patterns for Postfix") + + def _load_patterns(self): + """Ładuje wzorce z konfiguracji""" + patterns = [] + pattern_names = self.config.get('module_postfix', 'patterns', + fallback='').split(',') + + for name in pattern_names: + name = name.strip() + if not name: + continue + + section = f'pattern_{name}' + if section not in self.config: + self.logger.warning(f"Pattern section '{section}' not found in config") + continue + + try: + regex = self.config.get(section, 'regex') + score = self.config.getint(section, 'score', fallback=1) + + patterns.append({ + 'name': name, + 'regex': re.compile(regex, re.IGNORECASE), + 'score': score + }) + + self.logger.debug(f"Loaded pattern '{name}': {regex} (score: {score})") + + except Exception as e: + self.logger.error(f"Error loading pattern '{name}': {e}") + + return patterns + + def _run(self): + """Główna pętla - tail -f na pliku logu""" + self.logger.info(f"Tailing log file: {self.log_file}") + + try: + with open(self.log_file, 'r') as f: + # Przejdź na koniec pliku + f.seek(0, 2) + + while self.running: + line = f.readline() + + if line: + self.process_line(line.strip()) + else: + # Brak nowych linii, czekaj chwilę + time.sleep(0.1) + + except FileNotFoundError: + self.logger.error(f"Log file not found: {self.log_file}") + except PermissionError: + self.logger.error(f"Permission denied reading: {self.log_file}") + except Exception as e: + self.logger.error(f"Error tailing log: {e}") + + def process_line(self, line): + """ + Przetwarza linię z logu Postfix + + Przykłady linii: + - postfix/smtpd[1234]: warning: unknown[1.2.3.4]: SASL LOGIN authentication failed + - postfix/smtpd[1234]: connect from unknown[1.2.3.4] + """ + # Wyciągnij IP + ip_match = self.ip_pattern.search(line) + if not ip_match: + return + + ip = ip_match.group(1) + + # Pomiń lokalne IP + if ip.startswith('127.') or ip.startswith('192.168.') or ip.startswith('10.'): + return + + # Sprawdź wzorce + for pattern in self.patterns: + if pattern['regex'].search(line): + self.logger.debug( + f"Pattern '{pattern['name']}' matched for IP {ip}" + ) + self.logger.debug(f"Line: {line}") + + # Zgłoś niepowodzenie do demona + self.daemon.track_failure(ip, pattern['score']) + + # Tylko pierwszy pasujący wzorzec + break diff --git a/utils/init.py b/utils/init.py new file mode 100644 index 0000000..a6c55a4 --- /dev/null +++ b/utils/init.py @@ -0,0 +1,5 @@ +""" +LogMon Utils - Narzędzia pomocnicze +""" + +__all__ = [] diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..2cd1896 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,40 @@ +""" +Pomocnicze funkcje do logowania +""" + +import logging +import logging.handlers + + +def setup_rotating_logger(name, log_file, max_bytes=10485760, backup_count=5, level=logging.INFO): + """ + Konfiguruje logger z rotacją plików + + Args: + name: Nazwa loggera + log_file: Ścieżka do pliku logu + max_bytes: Maksymalny rozmiar pliku (domyślnie 10MB) + backup_count: Liczba backup plików + level: Poziom logowania + + Returns: + Logger object + """ + logger = logging.getLogger(name) + logger.setLevel(level) + + # Rotating file handler + handler = logging.handlers.RotatingFileHandler( + log_file, + maxBytes=max_bytes, + backupCount=backup_count + ) + + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + handler.setFormatter(formatter) + + logger.addHandler(handler) + + return logger