#!/usr/bin/env python3 """ Cisco MAC Address Monitor for Zabbix LLD ======================================== Script para monitorizar direcciones MAC conectadas a puertos de switches Cisco. Compatible con modelos C2960, C3560, C3560E, C2960X, C3560CX y Catalyst series. Genera salida JSON compatible con Zabbix Low Level Discovery. Autor: Sistema de Monitorización de Red Fecha: Octubre 2024 """ import json import logging import time import yaml import paramiko import re import sys from datetime import datetime from typing import Dict, List, Any, Optional from dataclasses import dataclass @dataclass class DeviceConfig: """Configuración de dispositivo de red""" hostname: str ip: str username: str password: str port: int = 22 device_type: str = "cisco_ios" timeout: int = 30 @dataclass class MacEntry: """Entrada de tabla MAC""" vlan: str mac_address: str type: str port: str class CiscoSSHConnector: """Conector SSH para dispositivos Cisco""" def __init__(self, device_config: DeviceConfig): self.device_config = device_config self.ssh_client = None self.logger = logging.getLogger(__name__) def connect(self) -> bool: """Establece conexión SSH con el dispositivo""" try: self.ssh_client = paramiko.SSHClient() self.ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self.logger.info(f"Conectando a {self.device_config.hostname} ({self.device_config.ip})") self.ssh_client.connect( hostname=self.device_config.ip, port=self.device_config.port, username=self.device_config.username, password=self.device_config.password, timeout=self.device_config.timeout, look_for_keys=False, allow_agent=False ) self.logger.info(f"Conexión exitosa a {self.device_config.hostname}") return True except Exception as e: self.logger.error(f"Error conectando a {self.device_config.hostname}: {str(e)}") return False def execute_command(self, command: str) -> str: """Ejecuta comando en el dispositivo""" if not self.ssh_client: raise Exception("No hay conexión SSH activa") try: self.logger.debug(f"Ejecutando comando: {command}") stdin, stdout, stderr = self.ssh_client.exec_command(command) output = stdout.read().decode('utf-8') error = stderr.read().decode('utf-8') if error: self.logger.warning(f"Error en comando '{command}': {error}") return output except Exception as e: self.logger.error(f"Error ejecutando comando '{command}': {str(e)}") raise def disconnect(self): """Cierra la conexión SSH""" if self.ssh_client: self.ssh_client.close() self.logger.info(f"Desconectado de {self.device_config.hostname}") class CiscoMacTableParser: """Parser para tabla MAC de dispositivos Cisco""" def __init__(self): self.logger = logging.getLogger(__name__) def get_mac_table(self, ssh_connector: CiscoSSHConnector) -> List[MacEntry]: """Obtiene la tabla MAC del dispositivo""" try: # Comando para obtener tabla MAC (compatible con IOS 12.x y 15.x) command = "show mac address-table" output = ssh_connector.execute_command(command) mac_entries = self._parse_mac_table_output(output) self.logger.info(f"Obtenidas {len(mac_entries)} entradas de tabla MAC") return mac_entries except Exception as e: self.logger.error(f"Error obteniendo tabla MAC: {str(e)}") return [] def _parse_mac_table_output(self, output: str) -> List[MacEntry]: """Parsea la salida del comando show mac address-table""" mac_entries = [] # Patrones para diferentes formatos de salida de IOS # Formato típico: VLAN MAC Address Type Ports patterns = [ # IOS 12.x y 15.x estándar r'^\s*(\d+)\s+([0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4})\s+(\w+)\s+(.+)$', # Formato alternativo con guiones r'^\s*(\d+)\s+([0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2}-[0-9a-fA-F]{2})\s+(\w+)\s+(.+)$', # Formato con dos puntos r'^\s*(\d+)\s+([0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2})\s+(\w+)\s+(.+)$' ] lines = output.split('\n') for line in lines: line = line.strip() if not line or line.startswith('-') or 'VLAN' in line.upper() or 'MAC Address' in line: continue for pattern in patterns: match = re.match(pattern, line) if match: vlan = match.group(1) mac_address = self._normalize_mac_address(match.group(2)) mac_type = match.group(3) port = match.group(4).strip() # Filtrar MACs del CPU y otras no relevantes if not self._is_valid_port(port): continue mac_entries.append(MacEntry( vlan=vlan, mac_address=mac_address, type=mac_type, port=port )) break return mac_entries def _normalize_mac_address(self, mac: str) -> str: """Normaliza formato de dirección MAC""" # Remover todos los separadores y convertir a minúsculas clean_mac = re.sub(r'[.:-]', '', mac.lower()) # Formatear como XX:XX:XX:XX:XX:XX if len(clean_mac) == 12: return ':'.join([clean_mac[i:i+2] for i in range(0, 12, 2)]) return mac def _is_valid_port(self, port: str) -> bool: """Verifica si el puerto es válido para monitorización""" # Excluir puertos del CPU, uplinks management, etc. exclude_patterns = [ r'cpu', r'switch', r'router', r'null', r'drop', r'punt' ] port_lower = port.lower() for pattern in exclude_patterns: if re.search(pattern, port_lower): return False return True class ZabbixLLDFormatter: """Formateador de datos para Zabbix Low Level Discovery""" def __init__(self): self.logger = logging.getLogger(__name__) def format_for_zabbix(self, device_data: Dict[str, List[MacEntry]]) -> Dict[str, Any]: """Formatea datos para Zabbix LLD""" discovery_data = [] timestamp = datetime.now().isoformat() for hostname, mac_entries in device_data.items(): # Agrupar MACs por puerto ports_data = {} for entry in mac_entries: port = entry.port if port not in ports_data: ports_data[port] = { "port_name": port, "device_hostname": hostname, "mac_addresses": [], "mac_count": 0, "vlans": set() } ports_data[port]["mac_addresses"].append({ "mac": entry.mac_address, "vlan": entry.vlan, "type": entry.type }) ports_data[port]["vlans"].add(entry.vlan) # Crear entradas para LLD for port, data in ports_data.items(): data["mac_count"] = len(data["mac_addresses"]) data["vlans"] = list(data["vlans"]) discovery_item = { "{#DEVICE_HOSTNAME}": hostname, "{#PORT_NAME}": port, "{#MAC_COUNT}": data["mac_count"], "{#VLANS}": ",".join(data["vlans"]), "{#MAC_ADDRESSES}": json.dumps(data["mac_addresses"]), "{#LAST_UPDATE}": timestamp } discovery_data.append(discovery_item) result = { "data": discovery_data, "timestamp": timestamp, "total_devices": len(device_data), "total_ports": len(discovery_data) } self.logger.info(f"Formateados datos para {len(device_data)} dispositivos, {len(discovery_data)} puertos") return result class CiscoMacMonitor: """Monitor principal para dispositivos Cisco""" def __init__(self, config_file: str = "config.yaml"): self.config_file = config_file self.config = None self.devices = [] self.logger = self._setup_logging() self.mac_parser = CiscoMacTableParser() self.zabbix_formatter = ZabbixLLDFormatter() def _setup_logging(self) -> logging.Logger: """Configura el sistema de logging""" logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('/tmp/cisco_mac_monitor.log'), logging.StreamHandler(sys.stdout) ] ) return logging.getLogger(__name__) def load_config(self) -> bool: """Carga configuración desde archivo YAML""" try: with open(self.config_file, 'r', encoding='utf-8') as f: self.config = yaml.safe_load(f) # Validar configuración if 'devices' not in self.config: raise ValueError("No se encontró sección 'devices' en configuración") if 'monitoring' not in self.config: raise ValueError("No se encontró sección 'monitoring' en configuración") # Crear objetos DeviceConfig self.devices = [] for device_data in self.config['devices']: device = DeviceConfig(**device_data) self.devices.append(device) self.logger.info(f"Configuración cargada: {len(self.devices)} dispositivos") return True except Exception as e: self.logger.error(f"Error cargando configuración: {str(e)}") return False def monitor_single_device(self, device: DeviceConfig) -> List[MacEntry]: """Monitoriza un dispositivo individual""" ssh_connector = CiscoSSHConnector(device) mac_entries = [] try: if ssh_connector.connect(): mac_entries = self.mac_parser.get_mac_table(ssh_connector) except Exception as e: self.logger.error(f"Error monitorizando {device.hostname}: {str(e)}") finally: ssh_connector.disconnect() return mac_entries def monitor_all_devices(self) -> Dict[str, List[MacEntry]]: """Monitoriza todos los dispositivos configurados""" all_data = {} for device in self.devices: self.logger.info(f"Monitorizando dispositivo: {device.hostname}") mac_entries = self.monitor_single_device(device) all_data[device.hostname] = mac_entries return all_data def run_monitoring_cycle(self) -> Dict[str, Any]: """Ejecuta un ciclo completo de monitorización""" self.logger.info("Iniciando ciclo de monitorización") # Monitorizar todos los dispositivos device_data = self.monitor_all_devices() # Formatear para Zabbix zabbix_data = self.zabbix_formatter.format_for_zabbix(device_data) self.logger.info("Ciclo de monitorización completado") return zabbix_data def run_continuous_monitoring(self): """Ejecuta monitorización continua según configuración""" if not self.config: self.logger.error("No hay configuración cargada") return interval = self.config['monitoring'].get('interval_minutes', 5) output_file = self.config['monitoring'].get('output_file', '/tmp/cisco_mac_data.json') self.logger.info(f"Iniciando monitorización continua cada {interval} minutos") while True: try: # Ejecutar ciclo de monitorización zabbix_data = self.run_monitoring_cycle() # Guardar datos with open(output_file, 'w', encoding='utf-8') as f: json.dump(zabbix_data, f, indent=2, ensure_ascii=False) self.logger.info(f"Datos guardados en: {output_file}") # Esperar hasta el siguiente ciclo time.sleep(interval * 60) except KeyboardInterrupt: self.logger.info("Monitorización detenida por usuario") break except Exception as e: self.logger.error(f"Error en ciclo de monitorización: {str(e)}") time.sleep(60) # Esperar 1 minuto antes de reintentar def main(): """Función principal""" import argparse parser = argparse.ArgumentParser(description='Monitor de direcciones MAC para dispositivos Cisco') parser.add_argument('--config', '-c', default='config.yaml', help='Archivo de configuración') parser.add_argument('--single-run', '-s', action='store_true', help='Ejecutar una sola vez') parser.add_argument('--output', '-o', help='Archivo de salida para modo single-run') args = parser.parse_args() monitor = CiscoMacMonitor(args.config) if not monitor.load_config(): sys.exit(1) if args.single_run: # Ejecución única zabbix_data = monitor.run_monitoring_cycle() output_file = args.output or '/tmp/cisco_mac_data.json' with open(output_file, 'w', encoding='utf-8') as f: json.dump(zabbix_data, f, indent=2, ensure_ascii=False) print(f"Datos guardados en: {output_file}") else: # Monitorización continua monitor.run_continuous_monitoring() if __name__ == '__main__': main()