first commit
This commit is contained in:
Executable
+423
@@ -0,0 +1,423 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user